【第4回】Terraform × AWS インフラ自動化入門 — 本番運用で踏んだ落とし穴とdrift検知・CI/CD連携のベストプラクティス

テックツール

前回(第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_IDAWS_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)あたりを試してみたいと思っています。

📚 シリーズ「Terraform × AWS インフラ自動化入門」(第4回 / 全4回)

← 前回の記事: 前回の記事はこちら

🎉 このシリーズは今回で完結です!

参考になったらクリックしてもらえると嬉しいです!

Blogmura NetDev Ranking
タイトルとURLをコピーしました