この記事を書いた背景
ソーイでは自社・受託プロダクトでは1案件で複数のAWSサービスをまとめて管理する場面が日常になってきました。手順書による構成管理はヒューマンエラーが発生しやすいため、IaCによる構成管理ツールの導入を検討しています。
そこで、前回のCDK入門記事 と同じ構成を Terraform でも実装してみて、コード量・IAM定義・状態管理 / デプロイサイクルの3つの軸で比較しました
結論を先に書いておくと、どちらか一方が「優れている」のではなく得意領域がはっきり違うツールだと感じました。前記事と同様にステップバイステップで試します。
何を作るか(前記事と同じ)
Lambda Function URL を叩くと S3 への署名付きアップロードURLが返ってくる、というシンプルなAPIです。
想定読者
- AWS CDK か Terraform のどちらかは触ったことがあり、もう片方が気になっている人
- IaC ツールの導入を検討中で、判断材料を探しているテックリード・SRE
比較の前提条件
| 項目 | 値 |
|---|---|
| AWS CDK | v2.x(Python) |
| Terraform | v1.5 以上 |
| AWS Provider | hashicorp/aws v5.x |
| リージョン | ap-northeast-1 |
| 比較軸 | コード量・記述性 / IAM・権限設定 / State・デプロイサイクル |
1. CDK実装(前回記事を参照)
CDK側の実装はすべて前回の記事にまとめてあります。
👉 AWS CDK入門 - Lambda × S3 署名URLでファイルアップロードAPIを作る
本記事ではTerraformとの比較で使う要点だけ振り返ります。
-
スタック構成:
S3StackとLambdaStackの2スタック。app.pyからs3_stack.upload_bucketをLambdaStackに渡して依存解決 -
IAM: バケットへのアップロード権限は 1行
upload_bucket.grant_put(self.presign_fn)これだけで、必要なアクション(
s3:PutObject)・対象リソース・Lambda実行ロールへのアタッチがすべて自動生成される -
Function URL:
fn.add_function_url(...)でCORS含めて一発で公開 -
デプロイ:
cdk bootstrap(初回のみ)→cdk diff→cdk deploy --all -
総コード行数: Lambda の
handler.pyを除いて 約44行(コメント・空行除く)
これと同じものを、ここからTerraformで段階的に組み立てていきます。
2. Terraformで実装
CDK版と同じく、スタックを段階的に進めるスタイルで進めます。各ステップで terraform apply して動作を確認しながら進めます。
2-1. 環境構築
Terraform CLI のインストール
Mac環境(MacOS 26.3.1)での実行です。
$ brew tap hashicorp/tap
$ brew install hashicorp/tap/terraform
$ terraform -version
Terraform v1.15.6
on darwin_arm64
AWS CLI の認証情報
前回記事と同じです。aws sts get-caller-identity で自分のアカウントIDが返ればOK。
プロジェクトディレクトリの作成
mkdir terraform-upload-api && cd terraform-upload-api
プロバイダーと変数を定義
まず providers.tf で、使用する Terraform 本体と AWS プロバイダーのバージョン制約を宣言します。archive プロバイダーは後でLambdaのソースをzip化するために使います。
# providers.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
}
}
provider "aws" {
region = var.region
}
ポイント:
-
required_version: Terraform 本体の下限バージョン。チームで揃えると環境差異を防げる -
required_providers: 使うプロバイダーとそのバージョンを宣言(~> 5.0は 5.x の最新を許可) -
provider "aws": リージョンなどプロバイダー固有の設定。値は変数から取得
次に variables.tf で、プロジェクト全体で使い回す変数を定義します。CDK では Python の変数として書く部分が、Terraform では variable ブロックとして外出しになります。
# variables.tf
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "project" {
type = string
default = "tf-upload-demo"
}
-
region: デプロイ先のリージョン -
project: バケット名や関数名のプレフィックスとして使う識別子
変数化しておくと、後でステージング/本番で値を差し替えたり、terraform.tfvars で上書きしたりが楽になります。
初期化
$ terraform init
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Finding hashicorp/archive versions matching "~> 2.4"...
...
プロバイダーがダウンロードされ、.terraform/ ディレクトリが作られます。これは CDK の cdk bootstrap とは別物で、プロジェクト毎に1回 実行します(CDK の bootstrap は アカウント × リージョン毎に1回)。
2-2. 最小構成 - S3バケットを作る
まずS3バケットだけを作って、Terraformの基本サイクルに慣れます。
# s3.tf
resource "aws_s3_bucket" "upload" {
bucket_prefix = "${var.project}-upload-"
force_destroy = true # 学習用。本番ではfalse
}
resource "aws_s3_bucket_public_access_block" "upload" {
bucket = aws_s3_bucket.upload.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
CDKでは block_public_access=BlockPublicAccess.BLOCK_ALL の1引数で済んだ箇所が、Terraformでは独立した aws_s3_bucket_public_access_block リソースになります。AWSのAPI構造に近い分、慣れるとどちらが何をするか分かりやすいですが、初見では記述量が増えます。
Terraform の基本2コマンド
CDK の diff / deploy に対応します。
$ terraform plan # 差分を確認(CDK の diff 相当)
Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_s3_bucket.upload will be created
+ resource "aws_s3_bucket" "upload" {
+ acceleration_status = (known after apply)
+ acl = (known after apply)
...
$ terraform apply # 反映(CDK の deploy 相当)
Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_s3_bucket.upload will be created
+ resource "aws_s3_bucket" "upload" {
+ acceleration_status = (known after apply)
+ acl = (known after apply)
+ arn = (known after apply)
...
aws_s3_bucket_public_access_block.upload: Creation complete after 0s [id=tf-upload-demo-upload-20260620111304999100000001]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
plan は必ずapply前に実行する習慣をつけると事故が減ります。
apply 後、マネジメントコンソールのS3画面でバケットが作られていることを確認しましょう。
2-3. Lambda関数を追加する
S3バケットができたので、署名URLを発行するLambdaを追加します。
Lambda コードを配置
CDK版と同じ handler.py を lambda/ ディレクトリに置きます。
mkdir lambda
# lambda/handler.py(CDK版と同一)
import json
import os
import uuid
import boto3
from botocore.config import Config
BUCKET_NAME = os.environ["BUCKET_NAME"]
URL_EXPIRES_IN = 300
s3 = boto3.client("s3", config=Config(signature_version="s3v4"))
def handler(event, context):
qs = event.get("queryStringParameters") or {}
filename = qs.get("filename", f"{uuid.uuid4()}.bin")
key = f"uploads/{filename}"
url = s3.generate_presigned_url(
ClientMethod="put_object",
Params={"Bucket": BUCKET_NAME, "Key": key},
ExpiresIn=URL_EXPIRES_IN,
HttpMethod="PUT",
)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"uploadUrl": url,
"key": key,
"expiresIn": URL_EXPIRES_IN,
}),
}
IAMロール・ポリシー・Lambda関数を定義
# lambda.tf
# (1) Lambdaソースをzip化
data "archive_file" "lambda" {
type = "zip"
source_dir = "${path.module}/lambda"
output_path = "${path.module}/build/lambda.zip"
}
# (2) Lambda実行ロール(Assume Role Policy)
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda" {
name = "${var.project}-presign-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
# (3) CloudWatch Logs への書き込み権限
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# (4) S3 PutObject 権限
data "aws_iam_policy_document" "lambda_s3_put" {
statement {
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.upload.arn}/*"]
}
}
resource "aws_iam_role_policy" "lambda_s3_put" {
name = "s3-put"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.lambda_s3_put.json
}
# (5) Lambda関数本体
resource "aws_lambda_function" "presign" {
function_name = "${var.project}-presign"
role = aws_iam_role.lambda.arn
handler = "handler.handler"
runtime = "python3.14"
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
timeout = 10
environment {
variables = {
BUCKET_NAME = aws_s3_bucket.upload.bucket
}
}
}
CDKでは upload_bucket.grant_put(fn) の1行で済んでいた箇所が、Terraformでは (4) のポリシードキュメント定義 + ロールへのアタッチで計14行 になります。これは Terraform が「AWSのAPIをそのまま素直にコード化する」設計思想だからで、悪いことではなく 明示的で監査しやすい とも言えます。
反映と動作確認
$ terraform plan # 4リソース追加が出る(data ソースは増分にカウントされない)
$ terraform apply
補足:
dataで始まるブロック(data.archive_fileやdata.aws_iam_policy_document)は既存の情報を読み取るだけなので、Plan: N to addのカウントには含まれません。今回追加されるのはaws_iam_role/aws_iam_role_policy_attachment/aws_iam_role_policy/aws_lambda_functionの4つです。
ここではまだ HTTP エンドポイントがないので、aws lambda invoke で直接呼び出して確認します。
$ aws lambda invoke \
--function-name tf-upload-demo-presign \
--payload '{"queryStringParameters": {"filename": "hello.txt"}}' \
--cli-binary-format raw-in-base64-out \
response.json
$ cat response.json
# {"statusCode": 200, "body": "{\"uploadUrl\": \"...\", \"key\": \"uploads/hello.txt\", \"expiresIn\": 300}"}
uploadUrl が返ってくれば Lambda 側は完成です。
2-4. HTTPエンドポイントを追加する(Function URL)
Lambda を外から叩けるように、Function URL を追加します。
# lambda.tf に追記
resource "aws_lambda_function_url" "presign" {
function_name = aws_lambda_function.presign.function_name
authorization_type = "NONE" # 認証なし(学習用)
cors {
allow_origins = ["*"]
allow_methods = ["GET", "POST"]
}
}
# Function URL からの呼び出しを許可
resource "aws_lambda_permission" "function_url" {
statement_id = "FunctionURLAllowPublicAccess"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.presign.function_name
principal = "*"
function_url_auth_type = "NONE"
}
authorization_type = "NONE" は 誰でも叩ける 状態です。学習用なので一旦これでよいですが、本番では AWS_IAM にするか Lambda 側でトークン検証するなど対策を入れてください。
URLを画面に出すために outputs を追加します。
# outputs.tf
output "function_url" {
value = aws_lambda_function_url.presign.function_url
}
output "bucket_name" {
value = aws_s3_bucket.upload.bucket
}
反映と動作確認
$ terraform plan
$ terraform apply
apply 後、function_url が標準出力に表示されます。
$ FN_URL=$(terraform output -raw function_url)
# ① 署名URLをもらう
$ curl "${FN_URL}?filename=hello.txt"
# {"uploadUrl": "...", "key": "uploads/hello.txt", "expiresIn": 300}
# ② 署名URLにPUTでファイルをアップロード
$ echo "hello from terraform" > hello.txt
$ UPLOAD_URL="(上で返ってきたuploadUrl)"
$ curl -X PUT --upload-file hello.txt "${UPLOAD_URL}"
# ③ S3にファイルが入ったか確認
$ aws s3 ls s3://$(terraform output -raw bucket_name)/uploads/
hello.txt が見えればゴールです。CDK版と全く同じ動きになります。
2-5. 片付け
学習用に作ったリソースはきっちり消しましょう。
$ terraform destroy
...
Destroy complete! Resources: 8 destroyed.
yes で確認して進めます。force_destroy = true を設定しているのでバケット内のオブジェクトごと削除されます。
3. 3つの軸で比較
3-1. コード量・記述性
同じ構成のコード行数(コメント・空行除く、Lambdaのhandler.pyは共通なので除外):
| 項目 | CDK (Python) | Terraform (HCL) |
|---|---|---|
| プロバイダー / アプリ初期化 | 8行 (app.py) | 18行 (providers.tf + variables.tf) |
| S3バケット | 10行 | 13行 |
| Lambda関数 + IAM | 15行 | 35行 |
| Function URL | 10行 | 8行 |
| 出力 | 1行 (CfnOutput) | 6行 |
| 合計 | 約44行 | 約80行 |
Terraform は CDK のおおむね 1.7〜2倍 の行数になりました。差の大部分は IAM 周りです。
ただし「行数が多い = 悪い」とはなりません。それぞれメリットとなる部分があります。
- CDKのメリット: 短く書ける。プログラミング言語の補完・抽象化が効く
- Terraformのメリット: 何がどう作られるか1対1で対応していて読みやすい。レビューしやすい
3-2. IAM・権限設定
最大の差が出るのがここです。CDK の grant_put 系メソッドが強力です。
CDK:
upload_bucket.grant_put(self.presign_fn)
たった1行で:
- 必要なアクション(
s3:PutObject)を自動選定 - 対象リソース(
bucket.arn + /*)を自動指定 - Lambda実行ロールへのアタッチも自動
Terraform:
data "aws_iam_policy_document" "lambda_s3_put" {
statement {
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.upload.arn}/*"]
}
}
resource "aws_iam_role_policy" "lambda_s3_put" {
name = "s3-put"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.lambda_s3_put.json
}
アクション名・リソースARNパターン・ロールへのアタッチを自分で書く必要があります。
これを「面倒」と取るか「明示的で安全」と取るかは判断が分かれます。
3-3. State・デプロイサイクル
ここは思想が大きく違うので、表で整理します。
| 観点 | CDK | Terraform |
|---|---|---|
| Stateの管理 | CloudFormation スタック(AWS側で管理) | tfstateファイル(自分で管理) |
| State保存場所 | AWS(CloudFormation) | ローカル or リモート(S3等) |
| 初期化 |
cdk bootstrap(1アカウント1回) |
terraform init(プロジェクト毎) |
| プレビュー | cdk diff |
terraform plan |
| 反映 | cdk deploy |
terraform apply |
| ロールバック | CloudFormationが自動ロールバック | 失敗時はリソース単位で止まる。手動修復が基本 |
| ドリフト検出 | CloudFormation のドリフト検出機能 |
terraform plan で差分が出る |
| ロック | CloudFormationが裏でロック | 自分でDynamoDB等の仕組みを用意 |
CDKの強み:
- State管理を AWS に丸投げできる(ファイル運用が不要)
- 自動ロールバックが標準でついてくる
Terraformの強み:
- planがCDK diffより詳細で読みやすい(リソース単位で「+/-/~」が出る)
- マルチクラウド・SaaS(Datadog、Cloudflare、GitHub等)も同じ流儀で書ける
- tfstate を見ればリソースの状態を自分で確認できる
チーム開発時の注意:
- CDK: 同じスタックを2人が同時に
deployすると、CloudFormation 側で衝突して片方が失敗する(破壊はされない) - Terraform: tfstateをローカル管理していると上書き事故が起こる。必ずS3 backend + DynamoDB lock を設定する
# Terraformでのリモートステート設定例
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "upload-api/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Terraform利用する場合はロックの設定を最初に確認することが重要になりそうです。
要点の整理
- 「TerraformはマルチクラウドだからCDKより上位互換」→ AWS内での記述効率はCDKの方が高い場面が多い
- 「CDKはCloudFormationのラッパーだから限界がある」→ Custom Resource + Lambda でTerraformと同等の柔軟性
- 「Terraformはstate管理が面倒」→ S3 backend + lockを最初に整備すれば運用負荷は低い
4. どちらを選ぶか — 判断の目安
得意領域が違うツールなので、チームや案件の特性に合わせて選ぶのが現実的です。
- AWS単体で完結する案件が多い → CDKの抽象化が活きる
- マルチクラウドやSaaS(Datadog、Cloudflare等)も一緒に管理したい → Terraform
- チームにプログラミング言語の経験が豊富 → CDKの型補完・テストが武器になる
- IAMポリシーを明示的にレビューしたい文化 → Terraformの明示性が合う
もちろん両方を使い分けるチームもあります。「AWSインフラはCDK、SaaS連携はTerraform」という併用パターンも選択肢の一つです。
5. まとめ
同じ「Lambda + S3 + 署名URL」を CDK と Terraform で書き比べた結果:
- コード量: CDKは約44行、Terraformは約80行。差の大半はIAM関連
- IAM: CDKの
grant_put系メソッドが圧倒的に楽。Terraformは明示性で勝る - State / デプロイ: CDKはAWSに任せられる手軽さ、Terraformは詳細なplan出力とマルチクラウド対応で勝る
得意領域がはっきり違うツールなので、チームが何を重視するかによって選択が変わります。本記事が判断材料の一つになれば幸いです。
参考リンク
お知らせ
技術ブログを週1〜2本更新中、ソーイをフォローして最新記事をチェック!
https://qiita.com/organizations/sewii