API Gateway(REST) + Lambda を「追加が冗長にならない」Terraform 構成にする

目次
AWS の API Gateway(REST API v1) を Terraform で素直に書くと、ルートを1本足すたびに aws_api_gateway_resource → method → integration → deployment の再デプロイ → lambda_permission… とリソースが増えていきます。Lambda を分ければ実行ロールやロググループも増える。**「エンドポイントを1個足すのにコピペが5〜6リソース」**になりがちです。
この記事では、それを 「エンドポイント追加は openapi.yml に1ブロック、Lambda 追加は locals に1行」 に収める構成をまとめます。キモは3つ。
- OpenAPI を
bodyに import して、ルート定義を1ファイルに集約する - Lambda は
local.lambdasマップ + module のfor_eachで1回だけ定義する - api へ渡すマップは
for内包表記で派生生成して、手で二重管理しない
サンプルのプロジェクト名は myapp、環境は dev / prod の2つとします。
なぜ discrete リソースだと冗長になるのか
REST v1 を「リソースを1個ずつ」書くと、/items と /items/{id} のような階層が曲者です。aws_api_gateway_resource は parent_id を要求するツリー構造なので、素朴に for_each で回すとこうなります。
resource "aws_api_gateway_resource" "this" {
for_each = local.resources
parent_id = each.value.parent == "" ? aws_api_gateway_rest_api.this.root_resource_id : aws_api_gateway_resource.this[each.value.parent].id
# ^^^ 同じ resource の別インスタンスを参照
}これは self-reference(自己参照)でエラーになります。Terraform は同一 for_each ブロック内での相互参照を許しません。つまり「ネストしたパスを1つの for_each で回す」ができない。ここが REST v1 を discrete に書いたときの一番の壁です。
回避策として深さごとにブロックを分けることもできますが、結局ルートが増えるほど手数が増えます。そこで OpenAPI import に振り切るのが、増える前提のテンプレートでは一番きれいでした。
ルートは OpenAPI(body) に集約する
aws_api_gateway_rest_api の body に OpenAPI(YAML) を渡すと、リソースツリーは API Gateway 側が自動構築します。discrete な resource/method/integration は一切書きません(書くと二重定義で壊れる)。
modules/api/openapi.yml(templatefile のテンプレート):
openapi: "3.0.1"
info:
title: "${name}"
version: "1.0"
paths:
/health:
get:
x-amazon-apigateway-integration:
type: aws_proxy
httpMethod: POST # Lambda 統合は常に POST(REST v1 も)
uri: ${invoke_arns["health"]}
passthroughBehavior: when_no_match
responses:
"200":
description: OKポイントは uri: ${invoke_arns["health"]} の部分。invoke_arn のマップを丸ごとテンプレートに渡し、パスごとにキーで引くようにしておくと、ルート追加時に Terraform 側(main.tf)を触らずに済みます。
modules/api/main.tf:
resource "aws_api_gateway_rest_api" "this" {
name = local.name
body = templatefile("${path.module}/openapi.yml", {
name = local.name
invoke_arns = var.lambda_invoke_arns # ← マップごと渡す
})
endpoint_configuration {
types = ["REGIONAL"]
}
}
resource "aws_api_gateway_deployment" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
# body(=OpenAPI)が変われば再デプロイ
triggers = {
redeploy = sha1(jsonencode(aws_api_gateway_rest_api.this.body))
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_api_gateway_stage" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
deployment_id = aws_api_gateway_deployment.this.id
stage_name = var.stage_name
}
# Lambda ごとに invoke 許可(増えても for_each で自動追従)
resource "aws_lambda_permission" "this" {
for_each = var.lambda_function_names
statement_id = "AllowAPIGatewayInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = each.value
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.this.execution_arn}/*/*"
}REST v1 特有の落とし穴を3つ潰しておきます。
triggersに body のハッシュを入れる。これが無いと OpenAPI を編集しても再デプロイされない。httpMethod: POSTは固定。get:のルートでも、Lambda 統合の内部呼び出しは常に POST。lambda_permissionはfor_eachで全 Lambda に自動付与。source_arnは.../*/*でステージ/メソッドをワイルドカード。
Lambda は1回だけ定義する(locals + for_each + 派生マップ)
ここが「冗長にならない」の本丸です。env 側(envs/dev/main.tf など)で Lambda を locals のマップに1回だけ書き、module を for_each で回します。
envs/dev/locals.tf:
locals {
# Lambda はここだけに増やす(キー=論理名, handler=エントリ)
lambdas = {
health = { handler = "health.handler" }
# items = { handler = "items.handler" } ← 増やすときはここに1行
}
}envs/dev/main.tf:
module "lambda" {
source = "../../modules/lambda"
for_each = local.lambdas
project = var.project
env = var.env
zip_path = "${path.root}/../../../backend/dist/function.zip"
handler = each.value.handler
name_suffix = each.key
}
module "api" {
source = "../../modules/api"
project = var.project
env = var.env
stage_name = var.env
# module.lambda(for_each)から自動生成する。手で増やさない
lambda_invoke_arns = { for k, m in module.lambda : k => m.invoke_arn }
lambda_function_names = { for k, m in module.lambda : k => m.function_name }
}{ for k, m in module.lambda : k => m.invoke_arn } が肝で、Lambda を増やしても2つのマップは自動で追従します。local.lambdas に1行足すだけで、module.lambda のインスタンスが増え、派生マップも増え、api の lambda_permission の for_each も自動で1個増える。手で書き足すのは locals の1行と、openapi.yml の path 1ブロックだけです。
Lambda module 側は、複数関数を区別できるよう name_suffix 変数を持たせて命名に使います。
# modules/lambda/locals.tf
locals {
function_name = "${var.project}-${var.env}-${var.name_suffix}"
log_group = "/aws/lambda/${var.project}-${var.env}-${var.name_suffix}"
}こうしておくと myapp-dev-health / myapp-dev-items のように関数ごとに一意な名前・ロググループ・IAM ロールになります(ロール名も ${local.function_name}-role に)。
パッケージング:zip は Makefile で作り、Terraform は参照するだけ
Python Lambda の zip は Terraform 内で作らず、Makefile(や CI)で決定論的に作って、Terraform は出来た zip を source_code_hash で参照します。
Makefile:
back-build: ## backend を zip 化(backend/dist/function.zip)
rm -rf backend/build backend/dist
mkdir -p backend/build backend/dist
pip install -r backend/requirements.txt -t backend/build
cp backend/src/*.py backend/build/
cd backend/build && zip -qr ../dist/function.zip .modules/lambda/main.tf はこの zip を参照:
resource "aws_lambda_function" "this" {
function_name = local.function_name
role = aws_iam_role.this.arn
runtime = var.runtime
handler = var.handler
architectures = [var.architecture]
filename = var.zip_path
source_code_hash = filebase64sha256(var.zip_path) # zip が変われば更新
}複数 Lambda でも zip は1つでよく、backend/src/*.py を全部同梱して関数ごとに handler(例 health.handler / items.handler)を変えるだけです。
注意:
source_code_hash = filebase64sha256(var.zip_path)は plan 時に zip を読むので、make back-buildを plan/apply より先に実行しておく必要があります。
GitHub Actions:apply の前に zip を作る
デプロイ workflow では、terraform apply の前に backend の zip を作るステップが要ります(でないと filebase64sha256 が落ちる)。
.github/workflows/deploy.yml(抜粋):
- uses: actions/setup-python@v5
with:
python-version: "3.13"
# 0) backend を zip 化(terraform が source_code_hash で参照するので apply の前に必須)
- name: Build backend
run: make back-build
# 1) インフラ(Lambda / API Gateway / S3 / CloudFront ...)
- name: Terraform apply
env:
ENV: ${{ steps.env.outputs.name }}
run: |
terraform -chdir=infra/envs/$ENV init
terraform -chdir=infra/envs/$ENV apply -auto-approveさらに、PR 段階で backend が壊れていないかだけ見る dry-run の CI を別途置いておくと安心です(デプロイはしない)。
.github/workflows/backend.yml(抜粋):
on:
pull_request:
paths:
- "backend/**"
- ".github/workflows/backend.yml"
jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install ruff pytest
- run: ruff check .
- run: ruff format --check .
- run: pytest -qruff(lint + format を1本化)と pytest の3点。フロントの「eslint / tsc / build」に対応させる形です。整形は husky + lint-staged(backend/**/*.py → ruff check --fix → ruff format)でコミット時に自動でかかるようにしておけば、この ruff format --check が PR で赤くなることもありません。
結果:追加時の手数
この構成にすると、拡張の手数はこうなります。
| 追加内容 | 触るファイル |
|---|---|
| 新パス(既存 Lambda で捌く) | openapi.yml に1ブロック + ハンドラにパス分岐 |
| 新 Lambda を分離 | locals.lambdas に1行 + openapi.yml に1ブロック + ハンドラ新規 |
| lambda_permission / 再デプロイ | 自動追従(手を入れない) |
resource/method/integration のコピペも、2つのマップの二重管理も消えます。REST v1 の冗長さは「OpenAPI に集約 + Lambda を1回定義 + マップは派生生成」で十分に飼い慣らせる、という話でした。
まとめ
- REST v1 を discrete に書くと、階層リソースの for_each 自己参照の壁もあって、ルート追加が高コストになる
- ルートは OpenAPI(
body)に集約し、uriは invoke_arn マップをキー引きする - Lambda は
localsのマップ + module のfor_each、api へ渡すマップはfor内包表記で派生して二重管理を避ける - zip は Makefile で作って
source_code_hashで参照、GHA は apply の前に build、PR は ruff/pytest の dry-run - 拡張は「
localsに1行」「openapi.ymlに1ブロック」で完結する
参考ソース
- AWS 公式: API Gateway extensions to OpenAPI(x-amazon-apigateway-integration)
- AWS 公式: Configure a REST API using OpenAPI(body import)
- Terraform: aws_api_gateway_rest_api(body 引数)
- Terraform: aws_lambda_function(source_code_hash)
- Terraform: for_each メタ引数
- Terraform: for 式(内包表記)
関連記事

create-next-app から Amplify Gen2 のバックエンド(Cognito/DynamoDB/Lambda)を追加し、Server Actions での SSR 書き込み、Lambda を API Gateway で REST 公開、Next.js API Route、Amplify Hosting の Git 連携デプロイまでを一気通貫でまとめます。

AWS API Gateway の REST API(v1) と HTTP API(v2) を、機能・料金・認証・Terraform の書き味の観点で比較します。公式のフィーチャーマトリクスと料金表を出典つきで整理し、「迷ったら HTTP API、これが要るなら REST」という選定基準に落とし込みます。

Terraform の S3 backend が要求する state バケットを、GitHub Actions の OIDC + IAM ロールで手動ワークフローから作成する手順。backend の鶏卵問題、最小権限ロール、冪等な bootstrap、そして role-to-assume が空になるハマりどころまで解説します。