👾

Terraform x Lambda (TypeScript) のおれおれディレクトリ構成

に公開

何の記事?

Terraform が大好きで仕方ないのですが、Lambdaと組み合わせる際に毎回ディレクトリ構成を試行錯誤してました。
あくまで「こういうパターンもあるよね」で誰かの参考になればと思います。

ディレクトリ構成

ESbuild を使ってTypeScriptをトランスパイル & バンドルします。

  • lambdaフォルダはバンドルまでが責務
  • awsフォルダ内でバンドルした資材の移動とデプロイ
    • aws/module/lambda 内の data archive_file を使ってバンドル結果を取得する
📁ROOT
├─ 📁lambda  : Node.js のプロジェクト
│  ├─ 📁functions
│  │  └─ 📁<関数名> : Lambdaにデプロイする関数ごとのフォルダ
│  │     ├─ 📁bundle : ESBuildのバンドル結果置場
│  │     ├─ 📁src : ソースコード
│  │     │  └─ main.ts: Lambdaの本処理。modulesのインポートと処理の実装。
│  │     └─ index.ts : Lambdaのハンドラ。main.tsをインポートするだけ
│  ├─ 📁modules : 関数間で共有するユーティリティ
│  └─ package.json
├─ 📁aws : AWSプロバイダ向けTerraformのプロジェクト
│  ├─ 📁modules : AWSサービスごとにモジュール化
│  │  └─ 📁lambda
│  │     └─ main.tf
│  ├─ main.tf : モジュールを参照する
│  └─ provider.tf
└─ .env
Makefile

make initmy-sample 関数向けディレクトリ構成を作成する

.PHONY: init clean

ROOT_DIR=$(shell pwd)
LAMBDA_DIR=lambda
FUNCTION_NAME=my-sample

init:
	# .env ファイル
	touch $(ROOT_DIR)/.env

	# Lambda 関連
	mkdir -p $(ROOT_DIR)/$(LAMBDA_DIR)/functions/$(FUNCTION_NAME)/bundle
	mkdir -p $(ROOT_DIR)/$(LAMBDA_DIR)/functions/$(FUNCTION_NAME)/src
	touch $(ROOT_DIR)/$(LAMBDA_DIR)/functions/$(FUNCTION_NAME)/src/main.ts
	touch $(ROOT_DIR)/$(LAMBDA_DIR)/functions/$(FUNCTION_NAME)/index.ts
	mkdir -p $(ROOT_DIR)/$(LAMBDA_DIR)/modules
	
	cd $(LAMBDA_DIR) && npm init -y
	npm install --save-dev typescript ts-node @types/node esbuild fs
	npx tsc --init

	# terraform関連
	mkdir -p $(ROOT_DIR)/aws/modules/cloud-watch
	mkdir -p $(ROOT_DIR)/aws/modules/lambda-role
	mkdir -p $(ROOT_DIR)/aws/modules/lambda
	touch $(ROOT_DIR)/aws/modules/lambda/variable.tf
	touch $(ROOT_DIR)/aws/modules/lambda/main.tf
	touch $(ROOT_DIR)/aws/modules/lambda/output.tf
	touch $(ROOT_DIR)/aws/main.tf
	touch $(ROOT_DIR)/aws/provider.tf

clean:
	rm -rf $(ROOT_DIR)/$(LAMBDA_DIR) $(ROOT_DIR)/aws $(ROOT_DIR)/.env

lambdaフォルダ

my-sample 関数を例として作成します。

index.ts

Lambda のハンドラ向けの抽象レイヤ。
ESBuild のエントリーポイントとして扱う。
main に実際の処理を実装し、ここでは何もしない。

/lambda/functions/my-sample/index.ts
import { main } from "./src/main";

export const handler = (event: any, context: any): Promise<any> => {
  console.log("event: %j", event);
  const result = main(event, context);
  console.log("result: %j", result);
  return result;
};

build.mjs

npm run build 関数名 で実行するファイル。

npm run build my-sample
→ /lambda/functions/my-sample/index.js を生成する

"scripts": {
  "build": "node ./build.mjs"
},
/lambda/build.mjs
import fs from "fs";
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";

export const __filename = fileURLToPath(import.meta.url);
export const __dirname = path.dirname(__filename);

(async (dirname) => {
  console.log(dirname);
  const srcDir = path.resolve(__dirname, `./functions/${dirname}`);
  const distDir = path.resolve(__dirname, `./functions/${dirname}/bundle`);
  const entryPoint = path.resolve(srcDir, "index.ts");
  const tsconfig = path.resolve(__dirname, "./tsconfig.json");

  if (!fs.existsSync(distDir)) {
    fs.mkdirSync(distDir, { recursive: true });
  }
  console.log("Build: ", entryPoint);

  // ESBuild でビルドする
  const res = await esbuild.build({
    entryPoints: [entryPoint],
    bundle: true,
    minify: true,
    platform: "node",
    // target: "node22", 必要に応じてver指定
    outfile: path.resolve(distDir, "index.js"),
    format: "cjs",
  });
  console.log("%o", res);
  return distDir;
})(process.argv.slice(2));

Terraform フォルダ

main.tf

/aws/main.tf
locals {
  function_name = "my-sample"
}

# Policy は別途作成してください
resource "aws_iam_role" "function_my_sample" {
  path = "/${local.function_name}/"
  name = "${local.function_name}_lambda-role-${local.function_name}"

  assume_role_policy = data.aws_iam_policy_document.assume_role.json

 } 

# Lambdaの共通モジュールに関数名とソースコードを渡す
module "function_my_sample" {
  source = "./modules/lambda"

  function_name   = local.function_name
  function_dir    = "../lambda/functions/${local.function_name}"
  lambda_role_arn = aws_iam_role.function_my_sample.arn
}

module/lambdaフォルダ

適宜分割してください

/aws/module/lambda/main.tf
variable "function_name" {
  type = string
}

variable "function_dir" {
  type = string
  validation {
    condition     = fileexists("${var.function_dir}/bundle/index.js")
    error_message = "Directory not found. Before Applying, Run 'npm bundle ${var.function_dir}'."
  }
}
variable "lambda_role_arn" {
  type = string
}

# バンドルのZip化
# --------------------------
data "archive_file" "package" {
  type        = "zip"
  source_file = "${var.function_dir}/bundle/index.js"
  output_path = "${var.function_dir}/bundle/package.zip"
}

# lambda function 本体
# --------------------------
resource "aws_lambda_function" "function" {
  function_name = "${var.function_name}"

  filename    = data.archive_file.package.output_path
  runtime     = "nodejs20.x"
  memory_size = 512
  timeout     = 60
  role        = var.lambda_role_arn

  handler = "index.handler"
  publish = true

  # Zipを連係する
  source_code_hash = data.archive_file.package.output_base64sha256

  tags = {
    Name        = "${var.function_name}"
    generated_by = "terraform"
  }
}

output "function_arn" {
  value = aws_lambda_function.function.arn
}

Discussion