生産性のない時間 is プライスレス

TerraformでWebアプリケーションをLambdaでホスティングする環境を構築する(の個人的に詰まったところまとめ)

公開日時:

Lambda Web Adapterを用いてWebアプリケーションをデプロイするというのが、巷では流行っているようです。 かという筆者もよく利用させていただいています。 それを実現するためのインフラを構築することがあるのですが、毎回手作業で構築するのは大変なため、使いまわせるように一般化したIac(Terraform)を作成することにしました。

AIの時代なので、コードを書いたり解説したりすることの価値は下がっているかもしれませんが、 コードを書く際の思考過程や詰まったところなどにはまだ価値があるだろうと信じてこの記事をインターネットの海に納めたいと思います。成果物自体はリポジトリにまとめていますのでそちらを参照してください。

構成について

今回実現するのは以下のようなインフラになります。

AWSアーキテクチャ

また、追加の要件としては以下のようになっています。

構成自体の解説については、以下の記事が大変参考になりますので紹介させていただきます。

https://serverless.co.jp/blog/nwg365t1vv/ https://serverless.co.jp/blog/g30vzpio0ww/

成果物

以下が今回の成果物です。

https://gitlab.com/wtb-nishiki/web-framework-on-lambda-with-terraform-example

構築

環境毎に同一構成を使いまわせるようにするということで、大体の場合モジュール化が選択肢になると思います。 一般的にはAWSリソース単位にモジュールを作成するのですが、今回は適度に抽象化を施して環境毎のTerraformではweb-framework-on-lambdaという単一のモジュールを呼び出せば構築されるようにします。

モジュールの抽象度を上げることで、プロジェクトごとに細かいリソース定義を繰り返す必要がなくなり、インフラ構成の標準化や品質の均一化にもつながります。 ただし、過度に抽象化するとそれはそれで取り回しが面倒になるので注意が必要です。

分かりやすい指針としては、常に同じタイミングで生成・破壊が行われるものに限定するといいと思います。

最終的には以下のような構造になりました。

├ envs/...
└ modules
  └ web-framework-on-lambda
    ├ content-hash
    ├ init
    ├ lambda_image.tf
    ├ lambda_zip.tf
    ├ lambda.tf
    ├ main.tf
    ├ outputs.tf
    ├ s3.tf
    └ variables.tf

いつもはvariables.tf,main.tf,outputs.tfの組み合わせで構成するのですが、 今回は以下のような理由でいくつかにmain.tfを分割しています。

分割の指針としては、main.tfには各種リソースとの関係が発生するものを(CloudFrontなど)、各リソース単体+関連するIAMロールで成立するものといったようにしています。

詳細については次の段落から解説していきます。

Lambdaの作成で詰まったところ

Lambdaを作成する部分については、明確にTerraformで行うこととそれ以外を定義しています。 具体的には以下のようになっています。

Lambdaを構築するコード群についてですが、今回はzipファイルおよびDockerイメージによるデプロイのどちらにも対応することにしました。Terraformのdynamic構文を使って対応しため、場合によっては生成されないリソースが発生しています。

ということで、lambda_image.tfには変数でimageを選択したときに生成されるリソースを、lambda_zip.tfには変数でzipを選択したときに生成されるリソースを定義しています。 lambda.tfは双方で利用する共通のものです。

# lambda.tf
data "archive_file" "content_hash" {
  type        = "zip"
  source_file = "${path.module}/content-hash/dist/index/index.js"
  output_path = "${path.module}/content-hash/dist/index/index.zip"
}

data "aws_iam_policy_document" "edge_assume_role" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["edgelambda.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "app_content_hash" {
  name               = "${var.prefix}-${var.env}-app_contenthash"
  assume_role_policy = data.aws_iam_policy_document.edge_assume_role.json
}

resource "aws_iam_role_policy_attachment" "app_content_hash" {
  role       = aws_iam_role.app_content_hash.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_function" "app_content_hash" {
  provider         = aws.virginia
  function_name    = "${var.prefix}-${var.env}-app-contenthash"
  role             = aws_iam_role.app_content_hash.arn
  architectures    = ["x86_64"]
  handler          = "index.handler"
  runtime          = "nodejs22.x"
  publish          = true
  filename         = data.archive_file.content_hash.output_path
  source_code_hash = data.archive_file.content_hash.output_base64sha256
}

data "aws_iam_policy_document" "lambda_assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "app_dynamic" {
  name               = "${var.prefix}-${var.env}-app-dynamic"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}

resource "aws_iam_role_policy_attachments_exclusive" "app_dynamic" {
  role_name   = aws_iam_role.app_dynamic.name
  policy_arns = ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"]
}

data "aws_iam_policy_document" "create_network_interface" {
  statement {
    effect = "Allow"

    actions = [
      "ec2:CreateNetworkInterface",
      "ec2:DescribeNetworkInterfaces",
      "ec2:DeleteNetworkInterface",
      "ec2:DescribeSecurityGroups",
      "ec2:DescribeSubnets",
      "ec2:DescribeVpcs",
    ]

    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "app_dynamic_create_network_interface" {
  count  = length(var.lambda_security_group_ids) > 0 && length(var.lambda_subnet_ids) > 0 ? 1 : 0
  name   = "create-network-interface"
  role   = aws_iam_role.app_dynamic.name
  policy = data.aws_iam_policy_document.create_network_interface.json
}

resource "aws_lambda_function_url" "this" {
  function_name = coalesce(
    try(aws_lambda_function.this_zip[0].function_name, null),
    try(aws_lambda_function.this_image[0].function_name, null)
  )
  authorization_type = "AWS_IAM"
}

このコードにおいて個人的に気になる点は以下の二つだと考えてます。

前者については、単純にセキュリティグループおよびサブネットが設定された場合にVPCと疎通するための各種権限群をアタッチしています。

app_content_hashがこの構成の肝になります。CloudFrontからLambdaへのIAM認証は基本的にOACが解決してくれるのですが、POSTやPUTといったリクエストボディが含まれるものについてはx-amz-content-sha256というコンテンツから計算されるハッシュ値が必要になります。 というわけで動的アプリケーション部分が動作するLambdaに到達する前にLambda@edgeで計算してリクエストに渡してあげます。

今回はクラスメソッド様の記事を参考に以下のコードをビルドしてデプロイしています。

https://dev.classmethod.jp/articles/cloudfront-lambda-url-with-post-put-request/

import { Buffer } from "node:buffer";
import type {
	CloudFrontRequestEvent,
	CloudFrontRequestHandler,
} from "aws-lambda";

const hashPayload = async (payload: string) => {
	const encoder = new TextEncoder().encode(payload);
	const hash = await crypto.subtle.digest("SHA-256", encoder);
	const hashArray = Array.from(new Uint8Array(hash));
	return hashArray.map((bytes) => bytes.toString(16).padStart(2, "0")).join("");
};

export const handler: CloudFrontRequestHandler = async (
	event: CloudFrontRequestEvent,
	_context,
) => {
	const request = event.Records[0].cf.request;

	if (!request.body?.data) {
		return request;
	}

	const body = request.body.data;
	const decodedBody = Buffer.from(body, "base64").toString("utf-8");

	request.headers["x-amz-content-sha256"] = [
		{ key: "x-amz-content-sha256", value: await hashPayload(decodedBody) },
	];

	return request;
};

ちなみにですが、ランタイムを使用したデプロイとECRを利用したデプロイの場合、前者のほうがトップレベルで記載したコードのロードにブーストがかかるとかで起動時間が早くなるようです。

S3のパブリックアクセスブロックについて

S3にはパブリックアクセスブロックというのものがあります。 簡単に言うとAWS認証がない接続をブロックするものです。

こちら、アカウントによってはデフォルトで有効化されるのですが、前述のとおりアカウント依存っぽいので基本的には明示的に指定したほうが安全なようです。忘れず指定するようにします。

resource "aws_s3_bucket" "this" {
  bucket        = "${var.prefix}-${var.env}-app-static"
  force_destroy = true
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.bucket
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

後日談、というか今回のオチ

AIが本格に強くなってほとんどこういう構築記事を書く理由がなくなってしまって久しいですが、 今回は最初にも記載した通り個人的に詰まったところに絞って書いてみました。

zennに投稿する記事というの、難易度高すぎてつらいなという気持ちになってしまいましたが、 それはまぁ私が地味な記事ばっかり書いているという話なのでしょうがない……。