前回(第3回)はTerraformのモジュール化とS3リモートバックエンド・DynamoDBによるステートロックを紹介しました。今回はシリーズ最終回として、GitHub Actionsを使ったCI/CDパイプラインの組み方と、本番運用で実際に詰まったポイント、そしてdrift検知の仕組みまでまとめます。
正直、最初はCI/CDなんて「planとapplyをワークフローに書けばいいんでしょ」くらいに思っていたんですが、本番環境を触るとなるとそう簡単じゃなかったです。
このセクションでわかること
- GitHub ActionsでTerraform CI/CDを構築する基本的な流れ
- AWSクレデンシャル管理の安全なやり方(OIDC認証)
- 本番運用で直面しやすい落とし穴(state lockとdrift)
- 定期的なdrift検知ワークフローの実装方法
Terraform CI/CDのよくある構成
一番シンプルな構成はこんな流れです。
- PR作成 →
terraform planを自動実行してレビュアーに差分を見せる - mainブランチへのマージ →
terraform applyを自動実行 - 本番環境だけ承認ゲートを設ける
この「PRにplanを出す」だけでも、手動で terraform plan を実行してSlackに貼るという作業がなくなるのでかなり楽になります。
落とし穴①:AWSクレデンシャルをSecretsに置いていた問題
最初は素直に AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY をGitHub Secretsに登録していました。動くは動くんですが、これには問題があって——
- キーが漏洩すると期限なしで使われ続ける
- ローテーションを忘れがち(というか確実に忘れる)
- どのワークフローがどのキーを使っているか管理が地味につらい
そこで今は OIDC(OpenID Connect) を使った認証に切り替えています。GitHub ActionsがAWSに対してJWTトークンを発行して、IAMロールを一時的に引き受ける仕組みです。長期的なアクセスキーを持たないので、漏洩リスクが格段に下がります。
OIDCプロバイダーの設定(Terraform)
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]
}
resource "aws_iam_role" "github_actions_terraform" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:*"
}
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
}]
})
}
output "github_actions_role_arn" {
value = aws_iam_role.github_actions_terraform.arn
}
sub の条件でリポジトリを絞れるので、特定のリポジトリ・ブランチからしかAssumeRoleできないようにできます。これ、最初に知っておきたかった。
GitHub Actionsワークフロー実装ガイド
ディレクトリ構成はこんな感じを想定しています。
.
├── .github/
│ └── workflows/
│ ├── terraform-plan.yml # PR時にplanを実行
│ └── terraform-apply.yml # mainマージ後にapplyを実行
├── environments/
│ ├── dev/
│ └── prod/
└── modules/
Plan ワークフロー(PR時)
name: Terraform Plan
on:
pull_request:
paths:
- "environments/**"
- "modules/**"
permissions:
id-token: write # OIDCトークン発行に必要
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: environments/dev
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.12.0"
- run: terraform init
- run: terraform fmt -check
- run: terraform validate
- id: plan
run: terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
continue-on-error: true
- name: PRにplanの結果をコメント
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const outputText = fs.readFileSync('environments/dev/plan_output.txt', 'utf8');
const output = `#### Terraform Plan 結果
\`\`\`
${outputText.slice(0, 3000)}
\`\`\`
ステータス: \`${{ steps.plan.outcome }}\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
- name: planが失敗していたら終了
if: steps.plan.outcome == 'failure'
run: exit 1
プルリクエスト時にterraform planを自動実行してレビュアーに差分を見せることができます。これを見てレビューする、という流れ。チームで使い始めるとplanコメントのフォーマットについて「あ、このレイアウトのほうが見やすいかな」みたいな細かい調整が発生しがちなので、最初にフォーマットを決めておくのがおすすめです。
Apply ワークフロー(mainマージ後)
name: Terraform Apply
on:
push:
branches:
- main
paths:
- "environments/**"
- "modules/**"
permissions:
id-token: write
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: environments/prod
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN_PROD }}
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.12.0"
- run: terraform init
- run: terraform plan -out=tfplan
- run: terraform apply -auto-approve tfplan
environment: production を指定すると、GitHubリポジトリの「Environments」設定でレビュアーを必須にできます。本番applyの前に人間の目が入る仕組みで、これがないと怖くて -auto-approve を書けません。
落とし穴②:state lockが残り続ける問題
CIでapplyが途中でコケると、DynamoDBのロックレコードが残ったまま次の実行が詰まる、という事態が発生しました。焦って何度もリトライしてさらに詰まる、という負のループ。
確認コマンドはこれです。
terraform force-unlock LOCK_ID
ロックIDはDynamoDBのレコードか、エラーメッセージに出ています。ただし force-unlock は本当に別のプロセスが動いていないことを確認してから実行しないと状態が壊れるので、慎重に。
CI側の対策としては、ワークフローのキャンセル時にlockを解放する処理を入れるか、そもそも並列実行が起きないようにconcurrencyグループを設定しておくのが有効です。
concurrency:
group: terraform-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: true にすると先行ジョブがキャンセルされてlockが宙ぶらりんになる可能性があるので、Terraformの場合は false にしておくのが無難だと思います。
Terraform drift検知:状態と実態のズレを検出する
「誰かがコンソールで設定を直接変えてしまった」「手動でスケールアップした」——こういう手作業による変更をTerraformは知らないので、stateと実際のリソースが乖離していきます。これを drift(ドリフト) と呼びます。
クラウドリソースの実際の設定が構成ファイルで定義されている内容と一致しなくなることを、インフラドリフトと言います。チームメンバーが緊急対応のためにコンソールで直接ポートを開けたり、CLIコマンドでS3バケットポリシーを書き換えたりしたときに起きがちです。これを定期的に検出するワークフローを別途用意するのが運用上有効です。
name: Terraform Drift Detection
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
id-token: write
contents: read
issues: write
jobs:
drift-check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: environments/prod
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN_PROD }}
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.12.0"
- run: terraform init
- name: drift検知
id: plan
run: |
terraform plan -detailed-exitcode -no-color 2>&1 | tee plan_output.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
- name: driftが検出されたらGitHub Issueを作成
if: steps.plan.outputs.exitcode == '2'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const planOutput = fs.readFileSync('environments/prod/plan_output.txt', 'utf8');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Drift] 本番環境にTerraformの差分が検出されました (${new Date().toISOString().split('T')[0]})`,
body: `## drift検知レポート\n\n実行ワークフロー: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\n\`\`\`\n${planOutput.slice(0, 3000)}\n\`\`\``,
labels: ['terraform-drift', 'infrastructure']
});
-detailed-exitcode はexit code 0(変更なし)、1(エラー)、2(driftあり)を返します。そして $? だとパイプの最後のコマンドのexit codeになるので、${PIPESTATUS[0]} でterraform本体のexit codeを取る必要があります。最初に $? で書いて全然機能しなくて詰みました。
driftが検出されるとGitHub Issueが自動的に作成されます。Issueには実行ワークフローへのリンクと差分の内容が含まれ、タイムスタンプ付きの履歴として残ります。Issueが積み重なると「誰かがまたコンソールを触った」ということが可視化されるので、チームの運用改善につながります。
本番運用でもう一個だけ気をつけたこと:planとapplyの間に時間が空く問題
PR作成時にplanを実行して、レビューして、マージして、applyする——という流れだと、planを取得してからapplyするまでに数時間空くことがあります。その間に誰かが別のPRをマージしてstateが変わっていると、planの内容と実際に適用される内容が変わってしまいます。
完璧な解決策があるわけじゃないですが、少なくとも apply直前にplanを再実行する、または plan artifactを保存して同一のplanファイルでapplyする、という対策があります。後者のほうが厳密ですが、plan artifactに機密情報が含まれる場合は暗号化が必要なので注意。planファイルには機密が平文で入りうるので、取り扱いはガチで要注意です。
本番環境のapplyに「何時間以上古いplanはNG」みたいな明確な推奨がある、という話も見かけますが、これは組織や運用ルール次第なところが大きい印象です。自分の現場では「planとapplyのズレが起きうる」前提で、できるだけapply直前にplanを作り直す、もしくは同一planを安全に運ぶ方針に寄せています。
※この記事にはプロモーションが含まれます
ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー![]()
まとめ
全4回を通じて、Terraformの基本から始まり、モジュール化、リモートステート管理、そしてCI/CD連携まで一通り触ってきました。自分自身まだ完全に理解できていない部分があって、たとえば複数AWSアカウントをまたいだdrift検知とか、Terraform Cloudと比べたときの使い分けとか、そのあたりは正直まだよくわかっていません。
ただ、「手でコンソールを触る」から「コードでインフラを管理してCIで自動デプロイ」に変わると、変更履歴がGitに残るし、レビューができるし、だいぶ精神的に楽になりました。次はTerraformのテスト(terraform test)あたりを試してみたいと思っています。

