selmertsxの素振り日記

ひたすら日々の素振り内容を書き続けるだけの日記

Terraformを使ってTypeScriptのLambdaをDeployする方法

この資料の目的

この記事では 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 の設計は基本的に下記のようにしています。

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),)