この資料の目的
この記事では Terraform で、TypeScript を利用した AWS Lambda を管理する方法を記載しています。 個人的な好みで言えば Lambda を IaC で扱う場合、AWS の提供してくれる CI/CD の機能・サービスに乗りやすい CDK の方が好みです。しかし、チームによっては terraform が望ましい場合もあります。
今回、Terraform で AWS Lambda のリソースを作成したので、後々忘れないように自分のための備忘録として残します。
前提情報
言葉の定義
数々存在しているインフラリソースを任意のまとまりを、この記事では「Stack」と呼ばせていただきます。
Lambda の目的・役割
今回作成する Lambda は S3 Bucket にファイルがアップロードされたとき、それを event として SQS に message を送信し、Lambda を起動します。今回詳細は書きませんが、Lambda は外部サービスを利用して得られた結果を DynamoDB に保存します。なお、ここでは S3 Bucket は別の stack で既に作成されているものとします。
Terraform コーディング規約
Terraform のコーディング規約は、基本的な部分では Terraform 公式のものを採用しつつ、細かい部分では GCP から提供されているものを採用しています。公式と GCP のコーディング規約では、1 点主張がコンフリクトしている部分があります。それは別のスタックで作成されたリソースの参照です。GCP では、下記のように terraform_remote_state の利用を推奨しています。
Terraform を使用するためのベスト プラクティス(GCP)
別の Terraform 構成で管理されているリソースのクエリに、データソースを使用しないでください。これを行うと、リソース名と構造に暗黙的な依存関係が作成され、通常の Terraform オペレーションで意図しない中断が発生する可能性があります。Terraform 構成間で情報を共有する場合は、リモート状態を使用して他のルート モジュールを参照することをおすすめします。
しかし、Terraform の公式では下記の文章を読むと data source の利用を推奨しているように見えます。
The terraform_remote_state Data Source(Terraform 公式)
Sharing data with root module outputs is convenient, but it has drawbacks. Although terraform_remote_state only exposes output values, its user must have access to the entire state snapshot, which often includes some sensitive information. When possible, we recommend explicitly publishing data for external consumption to a separate location instead of accessing it via remote state. This lets you apply different access controls for shared information and state snapshots.
ここは組織によって判断が分かれる部分でしょう。stack 毎に権限を明確に分離する必要がある組織では Terraform 公式の方針が望ましいように見えますし、そうでない組織では Google 公式の方針の方が、依存先の stack を明確に表現しやすいように見えます。ちなみに私は Terraform 公式の意見を採用しています。
IaC の Stack 設計
私は IaC の Stack の設計は基本的に下記のようにしています。
基本的に、主役となる App Stack 以外は、すべて小さい Stack になるようにしています。設計の意図は私の過去の資料、チームみんなが参加しやすくなる IaC 設計の工夫に記載しています。
Directory 設計
GCP の環境固有のサブディレクトリにアプリケーションを分割するにおいて、推奨される directory 設計は下記のようになっています。
-- SERVICE-DIRECTORY/ -- modules/ -- <service-name>/ -- main.tf -- variables.tf -- ... -- ...other… -- environments/ -- dev/ -- backend.tf -- main.tf -- prod/ -- backend.tf -- main.tf
これを参考として、Terraform の Directory 設計を下記のようにしました。 ちょっと module が小さすぎるという意見もあるかも知れませんが、自分はこれくらいの粒度で扱っています。
$ tree -L 2 lambda_sample lambda_sample ├── Makefile ├── build # Lambdaをbuildしたファイルを置く場所 ├── environments // Terraform の各環境の構成を置く場所 │ ├── development │ ├── production │ └── staging ├── modules // Terraform の modules │ ├── dynamodb │ ├── lambda │ ├── parameters │ └── queue ├── package.json ├── src // Lambda のプログラムを置く場所 │ ├── lambda_sample │ └── utils ├── tsconfig.json └── yarn.lock
Terraform のプログラム
入口となる各環境ごとの main.tf ファイルを記載します。 s3 bucket からの event を受け取る queue (SQS)、Lambda の処理内容を保存する DynamoDB、Lambda で外部 SaaS を利用するための Parameter Store、そして Lambda そのものが定義されています。 DynamoDB や Parameter Store は特段変わったことはないので、ここでは SQS と Lambda の設定のみ記載します。
environemnts/${env}/main.tf はこちら
// environemnts/${env}/main.tf terraform { required_providers { aws = { source = "hashicorp/aws" version = "5.42.0" } } required_version = "1.7.5" backend "s3" { bucket = "sample-terraform-bucket" key = "terraform-iac/terraform.tfstate" region = "ap-northeast-1" encrypt = true } } module "queue" { source = "../../modules/queue" bucket_name = "lambda-sample-${var.env}" } module "dynamodb" { source = "../../modules/dynamodb" } module "parameters" { source = "../../modules/parameters" encrypted_sample_key = var.encrypted_sample_key } module "lambda" { source = "../../modules/lambda" package_filename = var.package_filename sqs_event_source_arn = module.queue.sqs_event_source_arn dynamodb_table_arn = module.dynamodb.table_arn dynamodb_table_name = module.dynamodb.table_name sample_key_arn = module.parameters.sample_key_arn }
Lambda module
Terraform から Lambda を設定する上で忘れちゃいけないことは、下記 2 点です。
- source_code_hash に filebase64sha256 を設定すること
- lambda layers に AWS 公式が提供する ParameterStore のキャッシュを入れること
source_code_hash の設定は、Lambda の設定が変更されたときのみ、Lambda を Deploy するために必要です。また、Lambda Layers に ParameterStore の設定をする理由は、公式ドキュメントのこちらを参考にしてください。
modules/lambda/main.tf
data "aws_caller_identity" "current" {} resource "aws_iam_role" "this" { name = "sample-lambda-role" description = "IAM Role for Sample Lambda" assume_role_policy = file("${path.module}/lambda-role.json") } resource "aws_iam_policy" "this" { name = "lambda-policy" description = "IAM Policy for Lambda" policy = templatefile("${path.module}/lambda-policy.json.tftpl", { bucket_name = "sample bucket name", sample_key_arn = var.sample_key_arn dynamodb_table_arn = var.dynamodb_table_arn }) } # Lambda自体のPOLICY resource "aws_iam_role_policy_attachment" "custom_policy" { role = aws_iam_role.this.name policy_arn = aws_iam_policy.this.arn } # SQSから起動できるようにするためのPolicy resource "aws_iam_role_policy_attachment" "invoke_from_sqs" { role = aws_iam_role.this.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole" } # NOTE: architecturesはARM64を採用する。安くて高速であり、現時点でデメリットも見えないため # NOTE: ParameterStoreの値をキャッシュするLayerの設定は下記公式ドキュメントを参考にしている # https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ps-integration-lambda-extensions.html resource "aws_lambda_function" "this" { filename = var.package_filename function_name = "sample-lambda" role = aws_iam_role.this.arn handler = "index.handler" source_code_hash = filebase64sha256(var.package_filename) runtime = "nodejs20.x" architectures = ["arm64"] layers = ["arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension-Arm64:11"] timeout = 60 memory_size = 256 } resource "aws_lambda_event_source_mapping" "this" { event_source_arn = var.sqs_event_source_arn function_name = aws_lambda_function.lambda.arn }
lambda-role.json
// lambda-role.json { "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Principal": { "Service": "lambda.amazonaws.com" }, "Effect": "Allow" } ] }
lambda-policy.json
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowS3ObjectRead", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::${bucket_name}/*"], "Effect": "Allow" }, { "Sid": "AllowKMSKeyDecrypt", "Action": ["ssm:GetParameter", "kms:Decrypt"], "Resource": ["${sample_key_arn}"], "Effect": "Allow" }, { "Sid": "AllowDynamoDBTableRead", "Action": [ "dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Scan", "dynamodb:Query", "dynamodb:PutItem", "dynamodb:UpdateItem" ], "Resource": ["${dynamodb_table_arn}"], "Effect": "Allow" } ] }
queue module
S3 のイベントをフックとして Lambda を起動する方法はいくつかあります。 最もシンプルな方法は、aws_s3_bucket_notification にて lambda_function の設定をすることでしょう。しかし、今回は DLQ の設定をしたかったので下記のような実装にしました。DLQ を受け取ったあとの処理については、ここでは割愛しています。
modules/queues/main.tf
data "aws_caller_identity" "current" {} resource "aws_sqs_queue" "deadletter_queue" { name = "lambda-deadletter-queue" visibility_timeout_seconds = 60 message_retention_seconds = 604800 } // DLQを受け取って何らかの処理をする機能がここに入る resource "aws_sqs_queue" "lambda_invoke" { name = "lambda-queue" visibility_timeout_seconds = 60 message_retention_seconds = 345600 policy = templatefile("${path.module}/lambda-queue-policy.json.tftpl", { account_id = data.aws_caller_identity.current.account_id, bucket_name = var.bucket_name }) redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.deadletter_queue.arn maxReceiveCount = 4 }) } resource "aws_s3_bucket_notification" "lambda_invoke" { bucket = var.bucket_name queue { queue_arn = aws_sqs_queue.lambda_invoke.arn events = ["s3:ObjectCreated:Put"] filter_prefix = "bucket内部の所定のprefix" } }
lambda-queue-policy.json.tftpl
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "s3.amazonaws.com" }, "Action": "SQS:SendMessage", "Resource": "arn:aws:sqs:ap-northeast-1:${account_id}:*", "Condition": { "ArnLike": { "aws:SourceArn": "arn:aws:s3:*:*:${bucket_name}" } } } ] }
Lambda のプログラム
Lambda の実装について、特段特徴的な部分はありません。 1 点だけ上げるならば、ParameterStore の Layer はデフォルトでは localhost の port 2773 にてアクセスできます。なので、下記のようなプログラムを用意して ParameterStore にアクセスさせていました。
// Lambda Layerを利用して Parameter Store にアクセスする // 公式ドキュメント: https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ps-integration-lambda-extensions.html import fetch from "node-fetch"; import type { GetParameterResult } from "@aws-sdk/client-ssm"; const ENDPOINT = "http://localhost:2773/systemsmanager/parameters/get/?name="; export class ParameterStore { private sessionToken: string; constructor() { this.sessionToken = process.env.AWS_SESSION_TOKEN as string; } async getParameter( parameter: string, withDecryption: boolean = false // SecureStringの場合はtrue ): Promise<string> { const requestURL = `${ENDPOINT}${this.encodedParameter( parameter )}&withDecryption=${withDecryption}`; const response = await fetch(requestURL, { method: "GET", headers: { "X-Aws-Parameters-Secrets-Token": this.sessionToken }, }); const json = (await response.json()) as GetParameterResult; if (json?.Parameter?.Value === undefined) { throw new Error(`Parameter is not found. parameter: ${parameter}`); } return json.Parameter.Value; } private encodedParameter(parameter: string) { return encodeURIComponent(`${parameter}`); } }
handler.ts
import type { SQSEvent, SQSHandler } from "aws-lambda"; export const handler: SQSHandler = async (event: SQSEvent): Promise<any> => { const record = getS3EventRecord(event); if (record === undefined) return; /* ここに色んな処理を書く */ return; };
tsconfig.json
// tsconfig.json { "compilerOptions": { "lib": ["esnext"], "target": "es2020", "module": "commonjs", "rootDir": "./src/", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "moduleResolution": "node" }, "ts-node": { "esm": true, "experimentalSpecifierResolution": "node" } }
package.json
{ "name": "sample_lambda", "version": "1.0.0", "main": "index.js", "license": "UNLICENSED", "devDependencies": { "@types/aws-lambda": "^8.10.130", "@types/jest": "^29.5.10", "@types/node": "^20.10.4", "@types/prettier": "^3.0.0", "esbuild": "^0.19.8", "ts-node": "^10.9.2", "typescript": "^5.3.2" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.470.0", "@aws-sdk/client-s3": "^3.465.0", "@aws-sdk/client-ssm": "^3.465.0", "node-fetch": "^3.3.2" } }
実行ファイル
terraform の操作は make コマンドを使ってます。 環境毎の IAM Role の switch を aws-vault を使って行いたいので、諸々 Makefile にまとめてます。
# Makefile include ../base-terraform.mk BUILD=$(shell realpath build) PACKAGE=$(BUILD)/lambda.zip .PHONY: zip # プログラムに差分がないときデプロイされないようにtimestampを固定する zip: ## プログラムをzipする find ${BUILD} -exec touch -t 197001010000.00 {} \; cd ${BUILD} && zip -X -r lambda.zip index.js .PHONY: build build: ## ビルドする yarn esbuild --bundle src/sample_lambda/handler.ts --format=cjs --target=node20 --outfile=${BUILD}/index.js --platform=node .PHONY: apply apply: build zip ## terraformをAPPLYする cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform apply $(if $(TARGET),-target=$(TARGET),) -var package_filename=${PACKAGE}
base-terraform.mk
# ../base-terraform.mk # AWS Profileを環境毎に出し分ける設定 AWS_VAULT_PROFILE_production ?= selmertsx-prod AWS_VAULT_PROFILE_staging ?= selmertsx-stg AWS_VAULT_PROFILE_development ?= selmertsx-dev AWS_VAULT_PROFILE ?= $(AWS_VAULT_PROFILE_$(ENV)) AWS_VAULT_EXEC ?= aws-vault exec $(AWS_VAULT_PROFILE) --duration=12h .PHONY: correct correct: ## 静的解析によるコードの自動修正を実行 terraform fmt -diff -recursive prettier --write './**/*.{yml,yaml,md,json}' .PHONY: init init: ## terraformの初期化をします cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform init -upgrade .PHONY: plan plan: ## terraformの差分を表示します cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform plan $(if $(TARGET),-target=$(TARGET),) .PHONY: apply apply: ## terraformを実行します cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform apply $(if $(TARGET),-target=$(TARGET),) .PHONY: destroy destroy: ## terraformのリソースを削除します cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform destroy $(if $(TARGET),-target=$(TARGET),)