2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hono を Lambda + CloudFront でいい感じにホスティングしてみた

Posted at

はじめに

Hono で実装した API を AWS Lambda + CloudFront でホスティングする方法を試してみました。
公式ドキュメントでも Lambda 関数 URL を使ったホスティングについて記載されていますが、本記事では公式ドキュメントをベースに以下のような構成を構築します。

  • CloudFront を Lambda 関数 URL の前段に配置する(将来的には独自ドメイン対応できるようにする)
  • Lambda 関数 URL へのアクセスは CloudFront からのみに制限したい(OAC

image.png

実行環境

  • Node.js: v22.14.0
  • npm: 10.9.2
  • Hono: 4.10.3
  • AWS CDK: 2.1010.0

セットアップ

プロジェクトのセットアップ

公式のセットアップ手順にしたがって、プロジェクトをセットアップします。

mkdir hono-lambda-hosting-sample
cd hono-lambda-hosting-sample
cdk init app -l typescript
npm i hono
npm i -D esbuild
mkdir lambda
touch lambda/index.ts

作成したプロジェクトのディレクトリ構成は以下のようになりました。

% tree -L 1
.
├── bin
├── cdk.json
├── jest.config.js
├── lambda
├── lib
├── node_modules
├── package-lock.json
├── package.json
├── README.md
├── test
└── tsconfig.json

アプリケーションを作成

シンプルな API を Lambda で実装します。

lambda:index.ts
import { Hono } from "hono";
import { handle } from "hono/aws-lambda";

const app = new Hono();

app.get("/", (c) => {
  return c.json({
    message: "Hello Hono!",
    endpoints: {
      "GET /api/users": "ユーザー一覧取得",
      "GET /api/users/:id": "特定ユーザー取得",
      "POST /api/users": "ユーザー作成",
      "PUT /api/users/:id": "ユーザー更新",
      "DELETE /api/users/:id": "ユーザー削除",
    },
  });
});

app.get("/api/users", (c) => {
  return c.json({
    data: [
      { id: "1", name: "Alice", email: "alice@example.com" },
      { id: "2", name: "Bob", email: "bob@example.com" },
    ],
  });
});

app.get("/api/users/:id", (c) => {
  const id = c.req.param("id");
  return c.json({
    data: { id, name: `User ${id}`, email: `user${id}@example.com` },
  });
});

app.post("/api/users", async (c) => {
  const body = await c.req.json();
  return c.json(
    {
      message: "User created",
      data: { id: "3", ...body },
    },
    201
  );
});

app.put("/api/users/:id", async (c) => {
  const id = c.req.param("id");
  const body = await c.req.json();
  return c.json({
    message: "User updated",
    data: { id, ...body },
  });
});

app.delete("/api/users/:id", (c) => {
  const id = c.req.param("id");
  return c.json({
    message: "User deleted",
    data: { id },
  });
});

export const handler = handle(app);

デプロイ

CDK を使って Lambda 関数 URL に必要なリソースを定義します。

lib/hono-lambda-hosting-sample-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";

export class HonoLambdaHostingSampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const fn = new NodejsFunction(this, "Lambda", {
      entry: "lambda/index.ts",
      handler: "handler",
      runtime: lambda.Runtime.NODEJS_22_X,
    });
    const fnUrl = fn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });
    new cdk.CfnOutput(this, "LambdaUrl", {
      value: fnUrl.url!,
    });
  }
}

定義したリソースを CDK コマンドでデプロイします。(必要に応じて bootstrap してください。)

npm run cdk -- deploy

デプロイが成功すると、Lambda の URL が出力されます。

...
✅  HonoLambdaHostingSampleStack

✨  Deployment time: 43.84s

Outputs:
HonoLambdaHostingSampleStack.LambdaUrl = https://xxxxx.lambda-url.ap-northeast-1.on.aws/
...

動作確認

デプロイした API を実行して、動作確認してみます。

# GET /api/users
% curl -i https://xxxxx.lambda-url.ap-northeast-1.on.aws/api/users
HTTP/1.1 200 OK
...
{"data":[{"id":"1","name":"Alice","email":"alice@example.com"},{"id":"2","name":"Bob","email":"bob@example.com"}]}
# POST /api/users
% curl -i -X POST https://xxxxx.lambda-url.ap-northeast-1.on.aws/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Hoge Foo","email":"hoge-foo@example.com"}'
HTTP/1.1 201 Created
...

{"message":"User created","data":{"id":"3","name":"Hoge Foo","email":"hoge-foo@example.com"}}

意図したレスポンスが返ってくることが確認できました。

CloudFront OAC による直接アクセスの制限

現状の設定では、Lambda 関数 URL のエンドポイントに誰でもアクセスできる状態です。
これを CloudFront 経由のアクセスのみを許可するように設定します。

CloudFront OAC を実装

CDK のスタックに、CloudFront と OAC の設定を実装します。

lib/hono-lambda-hosting-sample-stack.ts
// ...省略
const fnUrl = fn.addFunctionUrl({
  authType: lambda.FunctionUrlAuthType.AWS_IAM, // AWS_IAM を設定
});

const distribution = new cloudfront.Distribution(this, "Distribution", {
  priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
  defaultBehavior: {
    // aws-cdk-lib/aws-cloudfront-origins を利用して、OAC を設定
    origin:
      origins.FunctionUrlOrigin.withOriginAccessControl(fnUrl),
    
    // ↓ 実際の環境や要件に合わせて調整してください
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
    cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
    originRequestPolicy:
      cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
  },
});
// ...省略

OAC の設定には、aws-cdk-lib/aws-cloudfront-origins パッケージに Lambda 関数 URL 用のオプション(FunctionUrlOrigin.withOriginAccessControl)が用意されているので、これを利用しました。

動作確認

CDK でデプロイ後、動作確認をしてみます。

# GET /api/users
% curl -i https://xxxxx.cloudfront.net/api/users
HTTP/2 200 
...
{"data":[{"id":"1","name":"Alice","email":"alice@example.com"},{"id":"2","name":"Bob","email":"bob@example.com"}]}%        

意図したレスポンスが返ってくることが確認できました。

# POST /api/users
% curl -i -X POST https://xxxxx.cloudfront.net/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Hoge Foo","email":"hoge-foo@example.com"}'
HTTP/2 403 
x-amzn-errortype: InvalidSignatureException
...
{"message":"The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details."}% 

レスポンスで 403 が返ってきていますね。。。
原因を確認すると、CloudFront + OAC を利用した場合、PUT や POST メソッドを使用する際は、リクエストボディの SHA256 ハッシュ値を計算し、x-amz-content-sha256 ヘッダーに含める必要があるとのことです。(バッチリ公式ドキュメントに記載されていますね 😅)

リクエストヘッダーにリクエストボディのハッシュを付与する

リクエストボディのハッシュをリクエストヘッダーに付与する方法として、アプリケーションのミドルウェアなどで実装する方法や Lambda@Edge を利用する方法など考えられますが、このヘッダーはホスティング先の都合で必要なヘッダーなので、ホスティング側で解決できる Lambda@Edge で実装したいと思います。
なお、リクエストボディを扱う都合上、CloudFront Functions は利用できません。

ハッシュを付与する Lambda を実装する

公式ドキュメントに記載されているとおり、PUT, POST メソッドの場合、リクエストボディの SHA256 ハッシュを設定した x-amz-content-sha256 ヘッダーを付与する処理を実装します。

lambda/edge/index.mjs
import { createHash } from "crypto";

const hashPayload = (payload) => {
  const hash = createHash("sha256");
  hash.update(payload);
  return hash.digest("hex");
};

export const handler = async (event) => {
  const record = event.Records[0];
  const request = record.cf.request;
  const method = request.method.toUpperCase();

  if (method === "POST" || method === "PUT") {
    const bodyData = request.body?.data ?? "";
    const decodedBody = Buffer.from(bodyData, "base64").toString("utf-8");
    request.headers["x-amz-content-sha256"] = [
      { key: "x-amz-content-sha256", value: hashPayload(decodedBody) },
    ];
  }

  return request;
};

CloudFront に Lambda@Edge を設定する

CDK で CloudFront のビヘイビアのオリジンリクエストに Lambda@Edge を設定します。なお、今回の処理ではリクエストボディを利用するので、リクエストボディを含めるオプションを有効にします。

lib/hono-lambda-hosting-sample-stack.ts
// ...省略
// Lambda@Edge の作成
const edgeFunction = new cloudfront.experimental.EdgeFunction(
  this,
  "EdgeFunction",
  {
    runtime: lambda.Runtime.NODEJS_22_X,
    handler: "index.handler",
    code: lambda.Code.fromAsset("lambda/edge"),
    stackId: "hono-lambda-hosting-edge-lambda-stack" // ここに設定した名前のスタックが us-east-1 リージョンに作成される
  }
);

const distribution = new cloudfront.Distribution(this, "Distribution", {
    // ...省略
    edgeLambdas: [
      {
        functionVersion: edgeFunction.currentVersion,
        eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
        includeBody: true,  // リクエストボディを含める
      },
    ],
  },
});
// ...省略

実装完了後、デプロイします。

動作確認

CDK でデプロイ後、動作確認をしてみます。

# GET /api/users
% curl -i https://xxxxx.cloudfront.net/api/users
HTTP/2 200 
...
{"data":[{"id":"1","name":"Alice","email":"alice@example.com"},{"id":"2","name":"Bob","email":"bob@example.com"}]}

これまで通り、意図したレスポンスが返ってくることが確認できました。
続いて、これまでエラーが返ってきていた POST リクエストを実行してみます。

# POST /api/users
% curl -i -X POST https://xxxxx.cloudfront.net/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Hoge Foo","email":"hoge-foo@example.com"}'
HTTP/2 201 
...

{"message":"User created","data":{"id":"3","name":"Hoge Foo","email":"hoge-foo@example.com"}}% 

リクエストが成功していますね 🙌
Lambda@Edge で x-amz-content-sha256 ヘッダーを付与する処理が意図したとおりに動いていることを確認できました!

続いて、Lambda 関数 URL のエンドポイントに直接アクセスできないことを確認してみます。

# GET /api/users
% curl -i https://xxxxx.lambda-url.ap-northeast-1.on.aws/
HTTP/1.1 403 Forbidden
...

{"Message":"Forbidden"}
# POST /api/users
% curl -i -X POST https://xxxxx.lambda-url.ap-northeast-1.on.aws/api/users \ 
  -H "Content-Type: application/json" \
  -d '{"name":"Hoge Foo","email":"hoge-foo@example.com"}'
HTTP/1.1 403 Forbidden
...

{"Message":"Forbidden"}

直接アクセスできないことを確認できました!

さいごに

Hono を使った API を Lambda + CloudFront で構築することができました。
CloudFront と組み合わせてホスティングする場合は、特定のヘッダーを付与する必要がありますが、CDK でリソースを作成することで比較的簡単に実装することができました。
同様の処理を実装しようとしている方の参考になれば幸いです。

参考

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?