"実務でつまずいたところ全部まとめました:Terraform×AWSデータ基盤ハンドブック(前編)"
1. はじめに
外資コンサルのデータチームにジョインしました。直近のプロジェクトで弱いところを補うテーマで実装してみることになり、最初のタスクが 「RSS → S3 → Glue/Athena基盤をTerraformで立ち上げること」でした。
やってみたら、予想以上にハマりどころだらけ(笑)。
そこで、同じような基盤をつくる人向けに、実務でつまずいたポイントを全部まとめて記事化しました。
今回の記事は 前編(設計・Terraform適用編) です。
後編では 監視・運用・権限管理のベストプラクティス を掘り下げていく予定です。
2. アーキテクチャ概要(今回の採用技術)
今回つくった基盤の全体像はこんなイメージです 👇
[RSS] --fetch--> [Lambda]
|
v (CSV, SSE-KMS)
s3://raw/rss/dt=YYYY-MM-DD/source=<name>/part-*.csv
|
v
[Glue Catalog: rss table]
|
v
[Athena WorkGroup]
- s3://athena-results (SSE-KMS)
- 結果保存先と暗号化を強制
ストレージ(S3)
- バケットは
raw
とathena-results
の2種類を用意 - Public Access Block 有効化
- Versioning 有効化
- デフォルト暗号化は SSE-KMS
暗号鍵(KMS)
- alias 運用で管理
- Key Policy に 運用ロール と Lambda実行ロール を明示的に許可
メタデータ(Glue Catalog)
- Database + Table を定義
-
rss
テーブルは Partition Projection を採用- パーティションキー:
dt
(日付),source
(取得元)
- パーティションキー:
クエリエンジン(Athena)
- WorkGroup を固定
- クエリ結果は 専用S3 + SSE-KMS で保存
取得処理(Lambda)
- 言語は Python
- 処理フロー: RSS → 正規化 → CSV → S3
- 保存先:
raw/rss/dt=YYYY-MM-DD/source=<name>/part-*.csv
- 環境変数
KMS_KEY_ARN
で暗号鍵を指定
スケジュール(EventBridge)
- ルール:
rate(15 minutes)
- Lambda Invoke の許可は 関数側のリソースベースポリシーで付与
監視(CloudWatch Logs)
- Lambda の実行ログを CloudWatch Logs に送信
- Athena結果S3の暗号化状態も合わせてチェック
3. Terraform設計パターン
3.1 ディレクトリ構成
Terraform のリポジトリ構成はこんな感じにしました。
new-project-terraform-aws/
├─ env/
│ ├─ dev/
│ │ ├─ backend.tf
│ │ ├─ main.tf
│ │ ├─ variables.tf
│ │ └─ terraform.tfvars.example
│ └─ bootstrap/ # state 用 S3 や DynamoDB ロック用
└─ modules/
├─ security-kms/
├─ storage-s3/
├─ analytics-athena-glue/
├─ iam-lambda/
├─ lambda-function/
└─ scheduler-eventbridge/
-
env/dev
: 開発環境用 -
env/bootstrap
: state 管理用(S3 バケットや DynamoDB ロックテーブル) -
modules
: 論理コンポーネントごとに分割して再利用できるようにした。
3.2 Backend(state 管理)
state は S3 + DynamoDB Lock を採用しました。
terraform {
backend "s3" {
bucket = "<state-bucket>"
key = "env/<env>/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "<lock-table>"
encrypt = true
}
}
-
初回注意点:S3 バケットと DynamoDB テーブルが存在しないと
terraform init
が失敗します - そのため bootstrap 環境を用意し、まずはそこから state 管理用のリソースを作るのがおすすめです
- 既存の S3 がある場合は
terraform import
で取り込みます。
3.3 変数・出力・タグの扱い
- 共通変数:
project
,env
,region
- 論理名:
kms_alias
,raw_bucket_name
,athena_results_bucket_name
,glue_database_name
- すべてのモジュールに
Project
,Env
タグを付与して課金や運用を追跡しやすくします。
3.4 KMSモジュール(security-kms)
- Key Policy に
root
とLambda実行ロール
を明示的に許可 - Grant を多用せず、ポリシーで完結できる範囲はポリシーで対応
- 出力値:
key_id
,key_arn
,alias_arn
3.5 S3モジュール(storage-s3)
-
raw
とathena-results
の2つのバケットを作成 - SSE-KMS を強制
- Public Access Block 有効化
- Versioning 有効化
3.6 Glue / Athenaモジュール(analytics-athena-glue)
- Glue Database と
rss
テーブルを作成 - Athena WorkGroup を専用に作成し、結果出力先と暗号化を固定化
rssテーブル定義のイメージ:
- パーティションキー:
dt (string)
,source (string)
- パラメータ(Partition Projection)
projection.enabled = true
projection.dt.type = date
projection.source.type = injected
storage.location.template = s3://<raw>/raw/rss/dt=$${dt}/source=$${source}/
- カラム:
title
,link
,published_at
,source_name
3.7 IAM(Lambda実行ロール)
- Logs:
logs:CreateLogGroup
,logs:CreateLogStream
,logs:PutLogEvents
- S3:
-
ListBucket
は prefix 条件つき(raw/rss/*
) -
PutObject/GetObject
はオブジェクトキーに限定
-
- KMS: 対象キーのみに限定して
Encrypt/Decrypt/GenerateDataKey*/DescribeKey
💡 よくある失敗
-
ListBucket
に prefix 条件を付け忘れて過剰権限になる - Resource をバケット直下に指定してしまい、オブジェクトキーが限定されない
3.8 Lambdaデプロイ(lambda-function)
- 組織によっては
lambda:CreateFunction
が SCP や Permissions boundary で禁止されている - 初回はコンソールで手動作成し、以降は
UpdateFunctionCode
運用 - Terraformでは
use_existing_lambda
フラグを用意して既存関数を参照できるようにした
3.9 EventBridge(scheduler-eventbridge)
-
aws_cloudwatch_event_rule
+aws_cloudwatch_event_target
を利用 - Lambda Invoke の許可は 関数のリソースベースポリシーで制御
- Terraformでは
add_lambda_permission
フラグで付与の有無を切り替え可能
4. デプロイ手順(dev想定)
Terraform で dev 環境を立ち上げるまでの手順を、前提 → 認証 → 適用 → 検証 の順でまとめます。
4.1 前提(ディレクトリ & tfvars)
-
この記事の前提ディレクトリ構成:
new-project-terraform-aws/
├─ env/
│ ├─ dev/
│ │ ├─ backend.tf
│ │ ├─ main.tf
│ │ ├─ variables.tf
│ │ └─ terraform.tfvars # ★ 実値を入れる
│ └─ bootstrap/
└─ modules/
├─ ... -
env/dev/terraform.tfvars
に一意な名前等を設定します(例):
project = "new-project"
env = "dev"
region = "ap-northeast-1"
raw_bucket_name = "new-project-dev-raw-123456789013"
athena_results_bucket_name = "new-project-dev-athena-results-123456789013"
glue_database_name = "new_project_dev"
kms_alias = "alias/new-project-dev"
use_existing_lambda = true
lambda_function_arn = "arn:aws:lambda:ap-northeast-1:123456789013:function:new-project-dev-rss-ingestion"
add_lambda_permission = false
4.2 認証(AWS SSO / プロファイル)
- 例:
terraform-bootstrap
という SSO プロファイルを使う前提です。
ログイン
AWS_PROFILE=terraform-bootstrap aws sso login
認証確認
AWS_PROFILE=terraform-bootstrap aws sts get-caller-identity --region ap-northeast-1
4.3 初期化と適用(dev 環境)
-
env/dev
配下で init → apply の順に実行します。
cd new-project-terraform-aws/env/dev
初期化(backend.tf の S3/DynamoDB が存在する前提)
terraform init -input=false
差分確認(任意)
terraform plan
適用
terraform apply -auto-approve
📝 メモ:
- もし
terraform init
で backend 用の S3/DynamoDB が無くて失敗する場合は、
先にenv/bootstrap
で state リソースを作成するか、既存リソースを import して整合を取ってください。
4.4 既存 Lambda 参照パターン(CreateFunction 禁止対応)
- 組織の SCP/Permissions boundary で
lambda:CreateFunction
が禁止されているケース向けの回避策です。 - 初回は コンソールで関数を手動作成 → Terraform は 既存関数を参照。
変数フラグで既存参照に切り替え(tfvars 側で設定している場合は不要)
terraform apply
-var="use_existing_lambda=true"
-var="lambda_function_arn=arn:aws:lambda:ap-northeast-1:123456789013:function:new-project-dev-rss-ingestion"
-var="add_lambda_permission=false"
-auto-approve
- EventBridge → Lambda の Invoke 許可は 関数側のリソースベースポリシー で制御します(必要に応じて手動付与)。
4.5 動作確認(Lambda → S3 → Athena)
-
Lambda を手動実行(コンソールでテスト
{}
) - CloudWatch Logs に処理ログが出ることを確認
- S3 生成物(当日パーティション配下)を確認
- Athena(WorkGroup 固定)で SELECT が通ることを確認
直近1時間のログを tail(関数名は環境に合わせて変更)
AWS_PROFILE=terraform-bootstrap aws logs tail /aws/lambda/new-project-dev-rss-ingestion --since 1h
-- Athena(WorkGroup は専用WGに切り替え)
-- 当日分をサッと確認
SELECT *
FROM new_project_dev.rss
WHERE dt = 'YYYY-MM-DD'
LIMIT 10;
-- 日別件数の確認
SELECT dt, count(*) AS cnt
FROM new_project_dev.rss
GROUP BY dt
ORDER BY dt DESC;
4.6 よくあるエラーと素早い対処
-
AccessDenied: athena:StartQueryExecution
- WorkGroup で結果保存先や KMS を固定しているか確認
- 対象バケット/KMS の権限がロールに付与されているか確認
-
CreateFunction: 403
- 組織の SCP/Permissions boundary が原因
- 初回は手動作成 → 以降は既存参照で回避
-
AddPermission: 404/403
- Lambda が未作成 /
GetPolicy
不足 - 先に関数を作成し、関数側のリソースベースポリシーで
events.amazonaws.com
を許可
- Lambda が未作成 /
-
S3 Put/Get: AccessDenied
- IAM の
Resource
がバケット直下になっていないか(オブジェクトキー配下に限定) -
ListBucket
にprefix
条件を付けているか
- IAM の
4.7 検証チェックリスト(使い回しOK)
-
AWS SSO ログイン済み(
aws sts get-caller-identity
OK) - backend の S3/DynamoDB が存在、または import 完了
-
terraform init / plan / apply
が成功 - Lambda 手動実行で CloudWatch Logs が更新
-
S3(
raw/rss/dt=YYYY-MM-DD/source=<name>/
)に CSV が生成 - Athena(専用 WorkGroup)で SELECT が成功
- EventBridge ルール(15分間隔)が動作
5. セキュリティ・ベストプラクティス
5.1 IAM ポリシーは「最小権限」
-
ListBucket は必ず
prefix
条件を付ける- ✅ 良い例:
arn:aws:s3:::<raw-bucket>/raw/rss/*
- ❌ 悪い例:
arn:aws:s3:::<raw-bucket>
(バケット直下だと過剰権限)
- ✅ 良い例:
- PutObject/GetObject はオブジェクトキー配下だけに限定
- KMS は対象キーのみに制限(Encrypt/Decrypt/GenerateDataKey/DescribeKey)
5.2 暗号化は SSE-KMS をデフォルトで強制
- S3 バケットは デフォルト SSE-KMS を設定
- Athena WorkGroup の出力先も SSE-KMS で強制
- 「デフォルト暗号化なし」だとレビューで落とされることが多い
5.3 リソースベースポリシーでの制御
- EventBridge → Lambda の Invoke は 関数側のリソースベースポリシー で許可
- IAM ロールに余計な権限を付けずに済むのでセキュア
- Terraform 側では
add_lambda_permission
をフラグ化し、環境ごとに制御
5.4 組織ポリシー(SCP/Boundary)との付き合い方
- 多くの組織では lambda:CreateFunction が禁止されている
- 回避策:
- 初回はコンソールで Lambda を作成
- Terraform 側は
use_existing_lambda=true
で既存参照
- 長期的には「運用専用ロール」を定義して
CreateFunction
を部分的に許可するのが理想
5.5 ログと監査
- Lambda 実行ログは必ず CloudWatch Logs に出力
- Athena クエリログも WorkGroup 経由で監査可能
- S3 のアクセスログや CloudTrail 連携で「誰がいつ何をしたか」を可視化できるとさらに安心
6. トラブルシュート(実録)
ここでは、実際にハマったポイントとその解決策をまとめます。
6.1 Lambda CreateFunction が 403 になる
症状
-
terraform apply
時にlambda:CreateFunction
が 403 で失敗
原因
- 組織の SCP や Permissions boundary で
CreateFunction
が禁止されている
解決策
- 初回だけ コンソールで関数を手動作成
- Terraform は
use_existing_lambda=true
で既存関数を参照するように変更
6.2 Lambda AddPermission が 404 / 403 になる
症状
- EventBridge → Lambda の Invoke 許可を Terraform で付与しようとすると失敗
原因
- 関数がまだ存在しない
- または
GetPolicy
権限不足
解決策
- コンソールから関数にリソースベースポリシーを手動追加
- その後、権限が整えば IaC 化に戻す
6.3 RSS 取得が失敗する
症状
- Lambda 実行時にタイムアウト / 接続エラー
原因
- VPC 接続ありで NAT が無いケース
- タイムアウト値が短すぎるケース
解決策
- 基本は VPC 接続なし で動かす
- やむを得ず VPC 内で実行するなら NAT Gateway を用意する
- Lambda のタイムアウトを 60〜90秒 に調整する
6.4 Athena の結果保存でエラー
症状
- クエリ実行時に
AccessDenied
やOutput location not found
が発生
原因
- WorkGroup 側で出力先 S3 や SSE-KMS 設定がされていない
- 実行ロールに権限が不足している
解決策
- WorkGroup を 専用に作成し、S3 出力先と KMS を固定化する
- S3/KMS 権限をロールに付与する
6.5 S3 バケットの import 周り
症状
-
terraform apply
が S3 でハング / 403 / 409 エラーになる
原因
- 既に同名バケットが存在していた
- 読み取り系権限不足(GetBucketVersioning など)
解決策
-
terraform import
を活用して既存バケットを state に取り込む - IAM 権限に
s3:GetBucketVersioning
,s3:GetEncryptionConfiguration
などを追加
7. 他プロジェクトへの展開ポイント
今回つくった基盤は、そのまま別の案件でも再利用しやすいように設計しました。
横展開する際に気をつけたポイントをまとめます。
7.1 変数と命名規約を外出し
-
project
,env
,region
を共通変数として定義 - バケット名 / Glue DB 名 / KMS alias などは
terraform.tfvars
側で差し替えられるようにした - 環境ごとに
env/dev
,env/stage
,env/prod
を切って使い回し
7.2 モジュールの入出力IFを固定化
-
modules/security-kms
,modules/storage-s3
,modules/analytics-athena-glue
などの IF を固定化 - 共通化すると、別プロジェクトでも「env/ 配下をコピーして tfvars を書き換えるだけ」で利用可能
7.3 Lambda 運用の標準化
- 多くの組織では
CreateFunction
が禁止されているので「既存関数参照フラグ」を標準パターン化 - IaC 側では EventBridge のスケジュールや IAM だけを管理
- 関数の初期作成は人間がやって、以降のコード更新は CI/CD or UpdateFunctionCode で回す
7.4 EventBridge → Lambda 権限の統一
- EventBridge → Lambda の Invoke は 関数側のリソースベースポリシーに統一
- プロジェクトごとにバラバラにしないことで、セキュリティレビューがスムーズに通る
7.5 S3 のデフォルト設定をテンプレ化
- SSE-KMS / Versioning / Public Access Block を強制するのをデフォルトにした
- 「特別な理由が無い限り、この設定で」というルールを組織内で共有すると安心
8. まとめと次回予告
今回は Terraform × AWS データ基盤の設計と適用編(前編) をまとめました 🎉
- ディレクトリ構成は
env/
とmodules/
を分けて再利用性を確保 - state は S3 + DynamoDB Lock で安定運用
- IAM は prefix 条件つきで最小権限を徹底
- S3 / Athena は SSE-KMS を強制
- Lambda は「既存関数参照」を標準化して CreateFunction 禁止にも対応
- EventBridge → Lambda の権限は関数側リソースベースポリシーに集約
要するに、実務でハマったところを整理してハンドブック化した、という感じです。
次回(後編)では、もっと運用寄りのテーマにフォーカスします👇
- 監視・通知のベストプラクティス(失敗回数 / レイテンシ / メトリクス監視)
- Runbook(手順書)のサンプル
- SCP / Permissions boundary と権限管理のリアルなTips
後編も、実務で役立つ内容をガッツリ書いていく予定です。
Discussion