初めてTerraformを触るエンジニアに向けて、
自分が初めてTerraformを触ってから二週間でキャッチアップした内容を共有します
前提
- AWSとTerraformを使う
- GitHub Actionsを多少知っている
今回の内容まとめ
- 基本コマンド
- init → plan → apply の基本
- AWSへの連携
- State を S3 / Lock を DynamoDB
- CICD
- PR で plan、main で apply
- Trivyでセキュリティスキャン
- ブランチ戦略
- トランクベースパターン
- 環境ブランチパターンで起こるトラップ
- ディレクトリ構造
- modules × environments 構成
1. Terraform の基本コマンドと State
Terraform はコードとクラウドの差分をterraform.tfstate
というファイルで管理する。
このとき、Stateを管理するS3バケットをBackendと呼ぶ。
以下が基本コマンド三種とTerraformがどのように動くのかをまとめたもの。
- init: Provider/Module 取得、バックエンド初期化
- plan: コードと実態の差分をプレビュー
- apply: 差分を適用、State を更新
❗ ローカル State(
terraform.tfstate
)のまま運用すると上書き・紛失・競合の温床。必ず次の項目で説明するやり方のdynamo+S3で管理しておこう。
2. State は S3、Lock は DynamoDB(チーム前提の必須構成)
前項で説明した通り、Terraformはクラウドの状態を.tfstateファイルで管理する。
もちろんチーム開発では複数人でTerraformコードを管理するため、
Stateファイルは皆が参照できる場所においておく必要がある。
また、複数人が同時にterraform apply
を実行しないようにするために
競合を防ぐ必要がある。
これをS3とDynamo DBで実現することができる。
2-1. まず初めにやるべきこと
- 先に S3 バケット & DynamoDB テーブルを手動で作成する
- Terraform の backend を書き、 S3 + DynamoDB に向ける
-
terraform init
を実行し、連携できたか確認
→AWS CLIの設定が必要
これにより、複数の開発者が
terraform applyを同時実行してもにより競合を防止することができる
2-2. Backend 設定サンプルコード
terraform {
backend "s3" {
bucket = "mycompany-tfstate-apne1"
key = "envs/prod/terraform.tfstate" # 環境ごとにパスを分ける
region = "ap-northeast-1"
dynamodb_table = "sample-tfstate-lock"
encrypt = true
}
required_version = ">= 1.6.0"
}
3. CI/CD:PRで plan&trivy、mainにマージ前に apply
コマンドと手順の流れ
1.ローカルでterraform init
とterraform plan
を実行
→planで出てきた変更が意図したものかチェック
2.プルリクエストを作成し以下のActionsを実行する
- Actions1 再度init→planし、レビュアが変更内容をチェック
- Actions2 trivyでセキュリティスキャン
trivyとは
コンテナ/IaCの脆弱性を検出するOSSで、特にAWSやTerraformになれないうちに意図せず脆弱な構成にしてしまうことを防げます。
3.レビュー後本番環境にApply&Mainブランチにマージ
おすすめはterraform apply
が成功した後でmainブランチにマージ。
成功した後にmainブランチへマージすることで、以下のようなメリットがある
-
terraform apply
が失敗するterraformコードが他のメンバーに渡らない - 次の項目で話す厄介なトラブルを防ぐ
-
terraform apply
が失敗しても元に戻しやすい
最小の GitHub Actions 例(抜粋)
Trivyはこちらを参照
https://github.com/aquasecurity/trivy-action/blob/master/README.md
init plan→ applyのActions
name: terraform-premerge-prod-apply
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled]
paths:
- 'environments/**'
- 'modules/**'
- '.github/workflows/terraform-premerge-prod-apply.yml'
permissions:
id-token: write # for GitHub OIDC → AWS
contents: read
pull-requests: write # to comment plan result
concurrency:
group: terraform-prod-${{ github.event.pull_request.number }}
cancel-in-progress: false
env:
TF_IN_AUTOMATION: "true"
AWS_REGION: ap-northeast-1
TF_WORKING_DIR: environments/prod
jobs:
plan:
name: Terraform Plan (PR)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
# ここでシークレットを使った連携をしないこと!
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/gha-terraform-prod
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform -chdir=${{ env.TF_WORKING_DIR }} init -input=false
- name: Terraform Fmt
run: terraform -chdir=${{ env.TF_WORKING_DIR }} fmt -check
- name: Terraform Validate
run: terraform -chdir=${{ env.TF_WORKING_DIR }} validate
- name: Terraform Plan
id: plan
run: |
set -e
terraform -chdir=${{ env.TF_WORKING_DIR }} plan -no-color -lock-timeout=5m | tee plan.txt
echo "PLAN<<EOF" >> $GITHUB_OUTPUT
sed -e 's/`/\\`/g' plan.txt >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Comment plan to PR
uses: marocchino/sticky-pull-request-comment@v2
with:
header: terraform-plan-prod
message: |
## Terraform Plan (prod)
<details><summary>Show</summary>
```
${{ steps.plan.outputs.PLAN }}
```
</details>
apply_premerge:
name: ✅ Apply to Production (Pre-merge)
needs: plan
runs-on: ubuntu-latest
# ▶️ Apply は「apply-prod」ラベルが付いた PR のみで実行
if: contains(github.event.pull_request.labels.*.name, 'apply-prod')
# 環境保護(Required reviewers)で人の承認を必須に
environment:
name: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/gha-terraform-prod
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform -chdir=${{ env.TF_WORKING_DIR }} init -input=false
# 👇 直前の状態から不整合がないか安全のため再プラン
- name: Safety Plan before Apply
run: terraform -chdir=${{ env.TF_WORKING_DIR }} plan -no-color -lock-timeout=5m
- name: Terraform Apply (Pre-merge to prod)
run: terraform -chdir=${{ env.TF_WORKING_DIR }} apply -auto-approve -lock-timeout=5m
4. ブランチ戦略
自分がやらかした落とし穴(環境ブランチパターンにて)
結果としてrevert が優先されてしまい、 結果的に staging までrevertされた状態になる…ことに
-
staging
にmain
ブランチをマージ - mainブランチマージ後に
terraform apply
で問題発生 -
main
をgit revert
-
staging
ブランチを修正 - 再度
staging
に再度main
をマージしコンフリクトを解消しようとした
自分のGitの使い方が悪いだけかも知れないが、これを防ぐために
以下の二つを実施してほしい。
- トランクベースパターンで
- マージ前に
terarform apply
し、成功後にMainにマージ
トランクベースパターンとは
Mainブランチとそこから派生するFeatureブランチを作り、Fatureから随時Mainにマージをかけるブランチ戦略。
検索すると色々出てくるので検索してみてほしい。
こうしておけば、Mainブランチに入る前のコードでterraform apply
が失敗してもMainブランチを再度Applyすることで元に戻すことができる。
5. ディレクトリ構成:modules/ × environments/
.
├── modules/
│ ├── network/
│ │ ├── main.tf # VPCやサブネットなどの関連するリソース
│ │ ├── variables.tf # CIDRブロックや名前のプレフィックスなどの引数
│ │ └── outputs.tf # サブネットIDなどの他のモジュールに渡す値
│ ├── alb/
│ ├── ecs_service/
│ ├── rds/
│ └── waf/
└── environments/
├── prod/
│ ├── backend.tf # S3+DynamoDB backend
│ ├── main.tf # modulesを組み合わせる
│ ├── locals.tf # 各環境で利用する値
│ └── terraform.tfvars # terraformやawsなどのバージョン定義
└── staging/
└── ...
例:environments/prod/main.tf
(一部)
module "network" {
#各modulesのvariable.tfに書いた引数に値を渡す
source = "../../modules/vpc"
env = local.environment
cidr = local.cidr
product_name = local.product_name
tag = local.tag
}
module "alb" {
source = "../../modules/alb"
product_name = local.product_name
env = local.environment
#他のモジュールと連携する場合にこのように書く
subnet_id = module.network.subnet.id
cognito_client_id = module.cognito.client_id
#依存関係明確化
depends_on = [module.network]
tag = local.tag
}
例:environments/prod/locals.tf
(一部)
locals {
product_name = "my_product"
environment = "production"
cidr_list = ["10.1.0.0/16", "10.2.0.0/16"]
}
locals {
tags = {
Project = local.product_name
Environment = local.environment
ManagedBy = "Terraform"
}
}
例:modules/network/variables.tf
(一部)
variable "vpc_cidr_block" {
type = string
description = "VPCのCIDRブロック"
}
variable "product_name" {
type = string
description = "名前のプレフィックスにつけるプロダクト名"
}
variable "tags" {
type = map(string)
description = "全てのリソースにつける共通タグ"
}
例:modules/network/output.tf
(一部)
output "vpc_id" {
value = aws_vpc.main_vpc.id
description = "メインのVPCのVPCID"
}
6. ブートストラップの具体手順(安全第一の導線)
-
手動で事前に以下を作成
- S3 バケット(Versioning + SSE。名前は
org-tfstate-<region>
など) - DynamoDB テーブル(
mycompany-tfstate-lock
、PK=LockID
)
- S3 バケット(Versioning + SSE。名前は
-
IaC リポジトリの backend を S3/DDB に切り替え
-
terraform init
時に State 移行のプロンプトに従う
-
-
PR で plan、main で apply の CI を導入
- マージ前にapplyし、成功したらmainにマージ
- 並行開発しても DDB Lock で安全
-
modules/ と environments/ を分離
- まずは最小セット(VPC → ALB → App → DB)から
- 監視・WAF・Route53 などは段階的に追加
まとめ
- State を中央集約し、DynamoDBで競合を防ぎ、PRでplan&Trivy
- Mainマージ前にapplyしapply成功後にMainブランチへマージ。
- トランクベースで履歴の一貫性を保ち、modules × environments の構造で安全に切り戻せる設計に。