TerraformでAWSリソースを更新する際のCI/CDをGitHub Actionsで作成

TerraformでAWSリソースを更新する際のCI/CDをGitHub Actionsで作成

TerraformでAWSリソースを更新する際のCI/CDをGitHub Actionsで作成
2025.12.14

TerraformでAWSリソースを更新する際のCI/CDをGitHub Actionsを利用して作成しましたので、その際の経緯や手順などを記載します。

経緯

あるAWSアカウントで、全てWeb Consoleで設定作業がされていたため、IaC化を進めていくこととなりました。先ずは既存のリソースをTerraformのコードにimportしていきました。対象のリソースは、以下のブログに紹介したようなものです。
Terraformで多数のDNSレコードを含むRoute53ホストゾーンを一括インポート
TerraformでIAM系リソースの一括インポート
TerraformでSecurity GroupやNetwork ACL等の一括インポート

このままでは当然、Web Consoleでリソースのメンテ -> Terraformのコードに反映(import)という二度手間になりますので、次は、これらのリソースをTerraformから直接メンテナンスしていきたいと考えました。但し、それには幾つか課題があると考え、その課題を解決するために、今回はGitHub ActionsでCI/CDを作成しました。

前提

今回はGitHub Actionsを利用しましたが、どのような手段を利用するかは状況により変わってきますので、最初に前提を幾つか記載します。

  • 管理対象AWSアカウントは2つで、対象リソースは上記リンク先のもの(Route53,IAM関連,SG/NACL等)
  • 管理対象のリソース数自体は多いが、更新の頻度はそれほど多くない。作業担当者も数名。
  • Terraformのコードを管理しているGithub Orgは無償プラン。また、Github Actionsの無償利用枠は常に十分に残る
  • できるだけコストをかけたくない。その上で、初期構築や運用負荷も少なくしたい。

課題

作業者の端末からTerraformでリソースを直接更新する際の大雑把な流れと、課題と考えましたものを以下に記載しました。(Githubでのコード管理含みます)

No 作業 課題
1 コードを作成 -
2 terraform init(環境初期化) -
3 terraform plan(コードのテスト) 1) terraform planの結果が、PRに自動で残らない(レビュアーが確認できない)
4 PR作成 -> レビュー承認を受ける -
5 terraform apply(コードをAWS環境へ反映) 2) 作業者に直接AWSリソース更新権限のあるロールを渡す必要がある(誤操作やセキュリティ上のリスク)
3) レビュー承認前にapplyする可能性がある(誤ったコードでの本番適用リスク)
4) applyの結果が自動でPRに残らない(変更記録が履歴に残らない)

上記のような4点を課題と考え、以下のようにしたいと考えました。

  1. terraform planの結果をPRに自動で残したい(レビュアーの承認判断にplan結果を確実に含めたい。履歴に残したい。また、エラーが起きた時のトラブル対応等がメンバー間でやりやすくなる)
  2. 作業者の端末から、直接AWSリソースを更新する権限操作をできるだけ減らしたい(誤操作・セキュリティリスク減らす)
  3. terraform applyが可能となるのは、レビュー承認後にしたい(誤ったコードでの本番適用リスクを減らす)
  4. terraform applyの結果をPRに自動で残したい(履歴に残したい。また、エラー起きた時のトラブル対応等がメンバー間でやりやすくなる)

解決手段の検討

上記のような課題を解決するため、幾つか手段を検討しましたが、今回はGitHub ActionsでCI/CDを作成することとしました。理由は以下の通りです。なお、前述した前提・要件などにより、これらの検討内容とどのような手段を利用するかは変わりますので、手段はそれぞれの状況によりご検討ください。

  • 初期費用:0円(既に無償プランのGitHub Orgを利用していますが、Github Actionsのワークフローを内製すれば費用はかかりません)
  • 運用費用:0円(上記前提の通り、Github Actions無償利用枠は常に十分に残っています。リソース更新時にGithub Actionsを利用しますが、更新頻度からすると、無償利用枠を使い切らない想定です)
  • 初期構築の作業負荷:Github Actionsのワークフロー作成や、AWS側との連携が必要ですが、AIの利用で作業時間短縮できると想定しました。
  • 運用の作業負荷:管理対象アカウントと対象リソースが上記前提の通り限定されていて、すぐにそれらが変更される想定もなく、リソース更新頻度も頻繁ではないので、現状だと運用の負荷もあまりないように思われました。

上記の通り、今回の環境では、費用がかからず、構築・運用の負荷もそれほど多くならない想定のため、GitHub Actionsを利用することにしました。

解決後の運用

Github Actionsを利用して以下のような作業の流れを想定しました。これにより、上記の課題が解決されます。

No 作業 課題への対応
1 コードを作成 -
2 terraform init(環境初期化) -
3 PR作成 → Github上で自動でterrafom plan(コードのテスト)が起動して、結果がPRコメントに残る 1) terraform planの結果がPRに自動で残り、レビュアーが確認できる
4 レビュアーは、コードと、terrafom planの結果を確認して、レビュー承認する
5 PR マージ→ Github上で自動でterrafom apply(AWSへの反映)が起動して、結果がPRコメントに残る 2) 作業者にAWSリソース更新権限のあるロールを渡す必要がない(Github/AWSでOIDC)
3) 作業者が自端末でレビュー前にapplyできなくなる
4) applyの結果が自動でPRに残る

上記のNo3で、PR作成時に自動的にterraform planを実行するGithub Actionsのワークフローが起動し、No5で、マージ時に自動的にterraform applyを実行するワークフローが起動する形です。
また、Github側からAWS側への接続は、OpneID Connectで行います。

実装の概要

Github側、AWS側でそれぞれ以下が必要です。

Github

  • Github Actionsのワークフロー:terraformのコードを保存するリポジトリで以下のワークフローが必要です。(ファイル名は例です)
    • terraform plan用:.github/workflows/terraform-plan.yml
    • terraform apply用:.github/workflows/terraform-apply.yml

AWS

  • IAMの設定:今回は接続先アカウントが2つのため、2つのアカウント両方で必要です。
    • IDプロバイダ:OpenID ConnectでのGithub接続用
    • I AM Policy:各アカウントでterraformでのメンテナンス対象リソースの更新権限を設定
    • I AM Role:IDプロバイダと紐付けて、接続元Github Orgのリポジトリと信頼関係を設定し、必要なI AM Policyをアタッチ

実装の詳細(AWSのIAM設定)

先ずは、AWS側の設定詳細を記載します。(接続先AWSアカウントが複数ある場合、全てで設定必要です)
IDプロバイダ
WebConsoleから IAM -> IDプロバイダ -> プロバイダを追加 より、以下のように設定して作成できます。

I AM Policy
各AWSアカウントでTerraformから更新を行うのに必要なポリシーを作成します。今回の更新対象は以下で記載したようなものですが、対象の2つのアカウントで、対象リソースが異なります。

また、どちらのアカウントともに、terraformのtfstateファイルをS3に保存しているので、所定のS3バケットへの書き込み権限も必要ですので、以下の権限が必要です。

  • アカウントA:Route53更新権限、IAM関連リソース更新権限、tfstate保存のS3バケットへの書き込み権限
  • アカウントB:SG/NACL等の更新権限、IAM関連リソース更新権限、tfstate保存のS3バケットへの書き込み権限

それぞれ、以下のようなポリシー(JSON)を作成しました。なお、この内容は、AIに更新対象のリソースを与えて質問し、生成させました。(このブログでは更新対象リソースの詳細まで記載していませんが、上記ブログリンク先にさらに詳細を記載しています。例えば、「IAM関連」と記載していますが、I AMの全てのリソースを更新対象にはしていません。更新対象は、以下のJSONポリシーの範囲内です。環境により、適宜必要なポリシーを設定下さい)

以下のポリシーを、それぞれ必要なアカウントで作成します。(今回は、IAM関連更新用とtfstate保存S3バケット書き込み用は両方のアカウントで作成します。Route53更新用とSG/NACL等の更新用は、片方のアカウントでそれぞれ作成します)

  • 所定のS3バケットへの書き込み用のポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::<tfstate保存用のバケット名>/*",
                "arn:aws:s3:::<tfstate保存用のバケット名>"
            ]
        }
    ]
}
  • Route53更新用のポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "route53:GetHostedZone",
                "route53:ListHostedZones",
                "route53:ListHostedZonesByName",
                "route53:CreateHostedZone",
                "route53:DeleteHostedZone",
                "route53:UpdateHostedZoneComment",
                "route53:GetChange",
                "route53:ListResourceRecordSets",
                "route53:ChangeResourceRecordSets",
                "route53:GetHostedZoneCount",
                "route53:ListTagsForResource",
                "route53:ChangeTagsForResource",
                "route53:AssociateVPCWithHostedZone",
                "route53:DisassociateVPCFromHostedZone",
                "route53:ListHostedZonesByVPC"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeVpcs",
                "ec2:DescribeRegions"
            ],
            "Resource": "*"
        }
    ]
}
  • IAM関連更新用のポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:GetRole",
                "iam:CreateRole",
                "iam:UpdateRole",
                "iam:DeleteRole",
                "iam:ListRolePolicies",
                "iam:ListAttachedRolePolicies",
                "iam:ListInstanceProfilesForRole",
                "iam:TagRole",
                "iam:UntagRole",
                "iam:GetPolicy",
                "iam:CreatePolicy",
                "iam:DeletePolicy",
                "iam:GetPolicyVersion",
                "iam:ListPolicyVersions",
                "iam:CreatePolicyVersion",
                "iam:DeletePolicyVersion",
                "iam:TagPolicy",
                "iam:UntagPolicy",
                "iam:AttachRolePolicy",
                "iam:DetachRolePolicy",
                "iam:PutRolePolicy",
                "iam:GetRolePolicy",
                "iam:DeleteRolePolicy",
                "iam:GetGroup",
                "iam:CreateGroup",
                "iam:DeleteGroup",
                "iam:UpdateGroup",
                "iam:ListGroupPolicies",
                "iam:ListAttachedGroupPolicies",
                "iam:AttachGroupPolicy",
                "iam:DetachGroupPolicy",
                "iam:PutGroupPolicy",
                "iam:GetGroupPolicy",
                "iam:DeleteGroupPolicy",
                "iam:GetInstanceProfile",
                "iam:CreateInstanceProfile",
                "iam:DeleteInstanceProfile",
                "iam:AddRoleToInstanceProfile",
                "iam:RemoveRoleFromInstanceProfile",
                "iam:TagInstanceProfile",
                "iam:UntagInstanceProfile",
                "iam:PassRole"
            ],
            "Resource": "*"
        }
    ]
}
  • SG/NACL等の更新用のポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "TerraformVPCManagement",
            "Effect": "Allow",
            "Action": [
                "ec2:Describe*",
                "ec2:CreateSecurityGroup",
                "ec2:DeleteSecurityGroup",
                "ec2:AuthorizeSecurityGroup*",
                "ec2:RevokeSecurityGroup*",
                "ec2:ModifySecurityGroupRules",
                "ec2:UpdateSecurityGroupRuleDescriptions*",
                "ec2:CreateVpc",
                "ec2:DeleteVpc",
                "ec2:ModifyVpcAttribute",
                "ec2:CreateNetworkAcl",
                "ec2:DeleteNetworkAcl",
                "ec2:*NetworkAclEntry",
                "ec2:ReplaceNetworkAcl*",
                "ec2:CreateSubnet",
                "ec2:DeleteSubnet",
                "ec2:ModifySubnetAttribute",
                "ec2:CreateTags",
                "ec2:DeleteTags"
            ],
            "Resource": "*"
        }
    ]
}

I AM Role
IAM -> ロール -> ロールを作成 より、以下のように設定します。

  • 信頼されたエンティティタイプ:ウェブアイデンティティ
  • アイデンティティプロバイダー:token.actions.githubusercontent.com
  • Audience:sts.amazonaws.com
  • GitHub organization:接続元のGithub Org名を指定
  • GitHub repository:接続元のリポジトリ名を指定
  • GitHub branch:(今回はデフォルトのままとしました)
    2

この後は、通常のロール作成と同じで、アタッチするポリシーを設定します。ポリシーは上記で作成したものを、AWSアカウント毎に必要なものを選択します。

最後に、ロール名の設定と、設定内容の確認となります。信頼ポリシーは自動的に以下のように作成されていました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Principal": {
                "Federated": "arn:aws:iam::<AWS アカウントID>:oidc-provider/token.actions.githubusercontent.com"
            },
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": [
                        "sts.amazonaws.com"
                    ]
                },
                "StringLike": {
                    "token.actions.githubusercontent.com:sub": [
                        "repo:<Github Org名>/<リポジトリ名>:*"
                    ]
                }
            }
        }
    ]
}

実装の詳細(Github Actionsワークフローの要件整理)

今回はAIでGithub Actionsのワークフローを2つ作成しましたが、要件を整理しておきます。(これらは最初から全てAIに提示できたわけではなく、AIと壁打ちやデバッグを行いながら纏まってきたものも含まれます)

作成するワークフロー
(ファイル名は例)

  • terraform plan用:.github/workflows/terraform-plan.yml
  • terraform apply用:.github/workflows/terraform-apply.yml

ワークフローの起動タイミング
各ワークフロー起動タイミングは、それぞれ以下の通りとします。(今回のterraformコードは、リポジトリ直下の「terraform」というディレクトリ配下に全て配置されています)

  • terraform plan用
    • リポジトリ内の「terraform/**/*.tf」が変更対象であるPR作成時(「terraform」ディレクトリ配下の.tfファイルが変更されるPRの作成時)
    • 上記PR作成後、マージの前に再度git pushされた時
  • terraform apply用
    • リポジトリ内の「terraform/**/*.tf」が変更対象であるPRのマージ時(「terraform」ディレクトリ配下の.tfファイルが変更されるPRのマージ時)

AWS側への接続方法
2つのワークフローともに、接続先の2つのAWSアカウントへは、前述したAWS側で設定したOpenID Connectで所定のロールで接続します。接続先アカウントIDの判別は、PR対象のterraformのコードから取得して行います。今回のterraformコードでは、必ず、locals.tfというファイルに以下のような定義をしているため、以下より取得しています。

locals {
  account_id = "<アカウントID>"
}

Terraformのバージョン取得
ワークフローの処理の中で、terraformコードで指定したバージョンと同じものを使用する必要があるため、バージョン情報の取得が必要です。今回のterraformコードでは、必ず、terraform.tfというファイルに以下のような定義をしているため、以下より取得しています。(バージョン情報は例です)

terraform {
  required_version = "= 1.13.3"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.15.0"
    }
  }
}

処理結果などの出力
2つのワークフローともに、処理結果はPRのコメント欄に以下を出力するようにします。

  • 処理が起動しているディレクトリ
  • 接続先AWSアカウントID
  • 接続先AWSアカウントのRole
  • ステータス:成功/失敗
  • terraform plan, terraform applyの標準出力結果(txtの量が多くなり、PRコメント欄の最大容量を超える可能性を考慮して、出力結果が60KBを超える場合はコメントを分割して出力する)
  • terraform applyについては、処理開始時と処理終了時に、それぞれの日時をコメント欄に出力(applyは処理時間が長くなることがあり、本番環境への反映処理でもあるので、ログとして処理時間帯を残したかったため)
  • terraform plan起動直後に「レビュアーが承認するまでマージNG」の警告メッセージを出力(今回のOrgは無償プランのため、レビュアー未承認でのマージをブロックすることが強制できず、運用ルールでの対応となっているため)

apply処理の同時起動防止
安全のため、同じリポジトリ内でapplyのワークフローは同時起動しないようにしています。もしタイミング重複した場合は、後から起動した方が、先行のワークフローが終了するのを待って起動するようにしています。

Github Actionsの権限設定
対象リポジトリで、今回のワークフローで必要な権限のため、以下が有効になっていることを確認します。

  • Settings → Actions → General → Workflow permissions → 「Read and write permissions」が有効化

処理の概要図
処理の流れとして、以下のようになります。(以下の図は、実際にはコード作成後に、AIに生成させたものです)

  • terraform plan用ワークフロー
    3
  • terraform apply用ワークフロー
    4

実装の詳細(Github Actionsワークフローのコード)

上記の要件でAIにより作成したワークフローは以下の通りです。これらを対象リポジトリの .github/workflows に配置すれば、動作するようになります。なお、アカウントIDとロール名は伏せてあります。

  • terraform plan用ワークフロー
name: Terraform Plan

on:
  pull_request:
    branches:
      - "main"
    paths:
      - "terraform/**/*.tf"

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write

    steps:
      # 0. 処理開始コメント(最初に投稿)
      - name: Post processing start comment
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          curl -X POST \
            -H "Authorization: token $GITHUB_TOKEN" \
            -H "Accept: application/vnd.github.v3+json" \
            https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
            -d "{\"body\":\"**terraform plan処理中...**\n**⚠️ レビュアーが承認するまでマージしないで下さい! terraform applyも起動します**\"}"

      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # 1. GitHub APIでPR変更ファイルから対象terraformディレクトリを検出
      - name: Find target terraform directory (via GitHub API)
        id: detect-dir
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          files=$(gh api repos/${GITHUB_REPOSITORY}/pulls/$PR_NUMBER/files --paginate --jq '.[].filename')
          dir=$(echo "$files" | grep -E '^terraform/.*\.tf$' | xargs -I{} dirname {} | sort -u | head -1)
          if [ -z "$dir" ]; then
            echo "No changed terraform/*.tf files in this PR. Skip."
            echo "target_dir=" >> $GITHUB_OUTPUT
            exit 0
          fi
          echo "target_dir=$dir" >> $GITHUB_OUTPUT
          echo "Detected target_dir=$dir"

      # 2. locals.tfからAWSアカウントIDを取得してロールARNを決定
      - name: Determine AWS Account from locals.tf
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        id: aws-account
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: |
          # locals.tfからaccount_idを抽出
          if [ ! -f "locals.tf" ]; then
            echo "Error: locals.tf not found in ${{ steps.detect-dir.outputs.target_dir }}"
            exit 1
          fi

          # account_id = "数字12桁" の形式から数字部分を抽出
          account_id=$(grep -E 'account_id\s*=\s*"[0-9]{12}"' locals.tf | sed -E 's/.*account_id\s*=\s*"([0-9]{12})".*/\1/')

          if [ -z "$account_id" ]; then
            echo "Error: Could not extract account_id from locals.tf"
            exit 1
          fi

          # アカウントIDに基づいてロールARNを設定
          if [ "$account_id" = "アカウントID-A" ]; then
            role_arn="arn:aws:iam::アカウントID-A:role/ロール名"
          elif [ "$account_id" = "アカウントID-B" ]; then
            role_arn="arn:aws:iam::アカウントID-B:role/ロール名"
          else
            echo "Error: Unknown account ID: $account_id"
            echo "Please add the role ARN mapping for this account ID in the workflow"
            exit 1
          fi

          echo "account_id=$account_id" >> $GITHUB_OUTPUT
          echo "role_arn=$role_arn" >> $GITHUB_OUTPUT
          echo "Detected AWS Account ID: $account_id"
          echo "Using Role ARN: $role_arn"

      # 3. Configure AWS credentials (OIDC)
      - name: Configure AWS credentials (OIDC)
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ steps.aws-account.outputs.role_arn }}
          aws-region: ap-northeast-1

      # 4. terraform.tfからrequired_versionを抽出
      - name: Extract required_version
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        id: tf-version
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: |
          ver=$(grep 'required_version' terraform.tf | sed -E 's/.*=\s*"([^"]+)".*/\1/')
          echo "version=$ver" >> $GITHUB_OUTPUT
          echo "Detected required_version: $ver"

      # 5. Setup Terraform
      - name: Setup Terraform
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.tf-version.outputs.version }}

      # 6. terraform plan 実行(エラーハンドリング付き)
      - name: Terraform Init
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: terraform init -input=false

      - name: Terraform Plan
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        id: plan
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: |
          # terraform planを実行し、結果とエラーの両方をキャプチャ
          set +e  # エラーでも続行
          terraform plan -no-color > $GITHUB_WORKSPACE/plan-result.txt 2>&1
          plan_exit_code=$?
          set -e

          # 終了コードを保存
          echo "exit_code=$plan_exit_code" >> $GITHUB_OUTPUT

          # planが失敗した場合のフラグ
          if [ $plan_exit_code -ne 0 ]; then
            echo "has_error=true" >> $GITHUB_OUTPUT
            echo "Terraform plan failed with exit code: $plan_exit_code"
          else
            echo "has_error=false" >> $GITHUB_OUTPUT
            echo "Terraform plan succeeded"
          fi

          # エラーでもワークフローは継続
          exit 0

      # 7. 分割してPRにコメント投稿(エラーメッセージ付き)
      - name: Post plan result to PR comment(s)
        if: ${{ steps.detect-dir.outputs.target_dir != '' }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          set -euo pipefail

          # エラーメッセージの準備
          error_header=""
          if [ "${{ steps.plan.outputs.has_error }}" = "true" ]; then
            error_header="**⚠️ Terraform Planの結果エラーがあるので、修正して再度pushして下さい**\n\n"
          fi

          split -b 60000 "$GITHUB_WORKSPACE/plan-result.txt" plan-part- || true
          if ls plan-part-* 1> /dev/null 2>&1; then
            i=0
            for f in plan-part-*; do
              body_file=$(mktemp)
              if [ $i -eq 0 ]; then
                {
                  if [ -n "$error_header" ]; then
                    echo -e "$error_header"
                  fi
                  echo "**terraform plan** in \`${{ steps.detect-dir.outputs.target_dir }}\`"
                  echo "AWS Account: \`${{ steps.aws-account.outputs.account_id }}\`"
                  echo "AWS Role: \`${{ steps.aws-account.outputs.role_arn }}\`"
                  if [ "${{ steps.plan.outputs.has_error }}" = "true" ]; then
                    echo "Status: ❌ **Failed**"
                  else
                    echo "Status: ✅ **Success**"
                  fi
                  echo '```'
                  cat "$f"
                  echo '```'
                } > "$body_file"
              else
                {
                  echo "続き **terraform plan (part $i)** in \`${{ steps.detect-dir.outputs.target_dir }}\`"
                  echo '```'
                  cat "$f"
                  echo '```'
                } > "$body_file"
              fi
              gh pr comment "$PR_NUMBER" --body-file "$body_file"
              i=$((i+1))
            done
          else
            body_file=$(mktemp)
            {
              if [ -n "$error_header" ]; then
                echo -e "$error_header"
              fi
              echo "**terraform plan** in \`${{ steps.detect-dir.outputs.target_dir }}\` 完了(出力なし or 短文)"
              echo "AWS Account: \`${{ steps.aws-account.outputs.account_id }}\`"
              echo "AWS Role: \`${{ steps.aws-account.outputs.role_arn }}\`"
              if [ "${{ steps.plan.outputs.has_error }}" = "true" ]; then
                echo "Status: ❌ **Failed**"
              else
                echo "Status: ✅ **Success**"
              fi
            } > "$body_file"
            gh pr comment "$PR_NUMBER" --body-file "$body_file"
          fi

      # 8. Plan失敗時は最終的にワークフローを失敗させる
      - name: Check plan result
        if: ${{ steps.detect-dir.outputs.target_dir != '' && steps.plan.outputs.has_error == 'true' }}
        run: |
          echo "::error::Terraform plan failed. Please check the PR comments for details."
          exit 1
  • terraform apply用ワークフロー
name: Terraform Apply (on PR merge)

on:
  pull_request:
    types: [closed]
    branches:
      - "main"
    paths:
      - "terraform/**/*.tf"

concurrency:
  group: terraform-apply
  cancel-in-progress: false

jobs:
  apply:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write

    steps:
      # 0. 処理開始コメント(チェックアウト前に即座に投稿)
      - name: Post processing start comment (fast)
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TZ: 'Asia/Tokyo'
        run: |
          jst_time=$(date '+%Y-%m-%d %H:%M:%S JST')
          curl -X POST \
            -H "Authorization: token $GITHUB_TOKEN" \
            -H "Accept: application/vnd.github.v3+json" \
            https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
            -d "{\"body\":\"🔄 **Terraform Apply 処理中...** (開始時刻: $jst_time)\"}"

      # 1. マージ後の main をチェックアウト
      - name: Checkout main
        uses: actions/checkout@v4
        with:
          ref: main
          fetch-depth: 0

      # 2. PRの変更ファイルから対象terraformディレクトリを検出(GitHub API)
      - name: Find target terraform directory
        id: detect-dir
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          files=$(gh api repos/${GITHUB_REPOSITORY}/pulls/$PR_NUMBER/files --jq '.[].filename')
          dirs=$(echo "$files" | grep '^terraform/' | grep '\.tf$' | xargs -I{} dirname {} | sort -u)
          if [ -z "$dirs" ]; then
            echo "対象Terraformディレクトリがありませんでした(終了)"
            exit 0
          fi
          dir=$(echo "$dirs" | head -1)
          echo "target_dir=$dir" >> $GITHUB_OUTPUT
          echo "Detected target_dir=$dir"

      # 3. locals.tfからAWSアカウントIDを取得してロールARNを決定
      - name: Determine AWS Account from locals.tf
        id: aws-account
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: |
          # locals.tfからaccount_idを抽出
          if [ ! -f "locals.tf" ]; then
            echo "Error: locals.tf not found in ${{ steps.detect-dir.outputs.target_dir }}"
            exit 1
          fi

          # account_id = "数字12桁" の形式から数字部分を抽出
          account_id=$(grep -E 'account_id\s*=\s*"[0-9]{12}"' locals.tf | sed -E 's/.*account_id\s*=\s*"([0-9]{12})".*/\1/')

          if [ -z "$account_id" ]; then
            echo "Error: Could not extract account_id from locals.tf"
            exit 1
          fi

          # アカウントIDに基づいてロールARNを設定
          if [ "$account_id" = "アカウントID-A" ]; then
            role_arn="arn:aws:iam::アカウントID-A:role/ロール名"
          elif [ "$account_id" = "アカウントID-B" ]; then
            role_arn="arn:aws:iam::アカウントID-B:role/ロール名"
          else
            echo "Error: Unknown account ID: $account_id"
            echo "Please add the role ARN mapping for this account ID in the workflow"
            exit 1
          fi

          echo "account_id=$account_id" >> $GITHUB_OUTPUT
          echo "role_arn=$role_arn" >> $GITHUB_OUTPUT
          echo "Detected AWS Account ID: $account_id"
          echo "Using Role ARN: $role_arn"

      # 4. AWS 認証(OIDC)
      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ steps.aws-account.outputs.role_arn }}
          aws-region: ap-northeast-1

      # 5. required_version を terraform.tf から抽出
      - name: Extract required_version
        id: tf-version
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: |
          ver=$(grep 'required_version' terraform.tf | sed -E 's/.*=\s*"([^"]+)".*/\1/')
          echo "version=$ver" >> $GITHUB_OUTPUT
          echo "Detected required_version: $ver"

      # 6. Terraform セットアップ
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.tf-version.outputs.version }}

      # 7. Terraform init & apply(エラーハンドリング付き)
      - name: Terraform Init
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: terraform init -input=false

      - name: Terraform Apply
        id: apply
        working-directory: ${{ steps.detect-dir.outputs.target_dir }}
        run: |
          # terraform applyを実行し、結果とエラーの両方をキャプチャ
          set +e  # エラーでも続行
          terraform apply -auto-approve -no-color > $GITHUB_WORKSPACE/apply-result.txt 2>&1
          apply_exit_code=$?
          set -e

          # 終了コードを保存
          echo "exit_code=$apply_exit_code" >> $GITHUB_OUTPUT

          # applyが失敗した場合のフラグ
          if [ $apply_exit_code -ne 0 ]; then
            echo "has_error=true" >> $GITHUB_OUTPUT
            echo "Terraform apply failed with exit code: $apply_exit_code"
          else
            echo "has_error=false" >> $GITHUB_OUTPUT
            echo "Terraform apply succeeded"
          fi

          # エラーでもワークフローは継続(コメント投稿のため)
          exit 0

      # 8. 結果をPRにコメント(エラー情報付き)
      - name: Post apply result to PR comment(s)
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          # エラーメッセージの準備
          error_header=""
          if [ "${{ steps.apply.outputs.has_error }}" = "true" ]; then
            error_header="**⚠️ Terraform Apply でエラーが発生しました。以下の内容を確認してください。**\n\n"
          fi

          split -b 60000 $GITHUB_WORKSPACE/apply-result.txt apply-part- || true
          if ls apply-part-* 1> /dev/null 2>&1; then
            i=0
            for f in apply-part-*; do
              body_file=$(mktemp)
              if [ $i -eq 0 ]; then
                {
                  if [ -n "$error_header" ]; then
                    echo -e "$error_header"
                  fi
                  echo "**terraform apply** in \`${{ steps.detect-dir.outputs.target_dir }}\`"
                  echo "AWS Account: \`${{ steps.aws-account.outputs.account_id }}\`"
                  echo "AWS Role: \`${{ steps.aws-account.outputs.role_arn }}\`"
                  if [ "${{ steps.apply.outputs.has_error }}" = "true" ]; then
                    echo "Status: ❌ **Failed**"
                  else
                    echo "Status: ✅ **Success**"
                  fi
                  echo '```'
                  cat "$f"
                  echo '```'
                } > "$body_file"
              else
                {
                  echo "続き **terraform apply (part $i)** in \`${{ steps.detect-dir.outputs.target_dir }}\`"
                  echo '```'
                  cat "$f"
                  echo '```'
                } > "$body_file"
              fi
              gh pr comment "$PR_NUMBER" --body-file "$body_file"
              i=$((i+1))
            done
          else
            body_file=$(mktemp)
            {
              if [ -n "$error_header" ]; then
                echo -e "$error_header"
              fi
              echo "**terraform apply** in \`${{ steps.detect-dir.outputs.target_dir }}\` 完了(出力なし or 短文)"
              echo "AWS Account: \`${{ steps.aws-account.outputs.account_id }}\`"
              echo "AWS Role: \`${{ steps.aws-account.outputs.role_arn }}\`"
              if [ "${{ steps.apply.outputs.has_error }}" = "true" ]; then
                echo "Status: ❌ **Failed**"
              else
                echo "Status: ✅ **Success**"
              fi
            } > "$body_file"
            gh pr comment "$PR_NUMBER" --body-file "$body_file"
          fi

      # 9. 処理完了コメント(エラー状態も反映)
      - name: Post processing complete comment
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TZ: 'Asia/Tokyo'
        run: |
          # apply自体のエラーとワークフロー全体のステータスを区別
          if [ "${{ steps.apply.outputs.has_error }}" = "true" ]; then
            gh pr comment ${{ github.event.pull_request.number }} \
              --body "❌ **Terraform Apply 失敗** (終了時刻: $(date '+%Y-%m-%d %H:%M:%S JST'))"
          elif [ "${{ job.status }}" == "success" ]; then
            gh pr comment ${{ github.event.pull_request.number }} \
              --body "✅ **Terraform Apply 完了** (終了時刻: $(date '+%Y-%m-%d %H:%M:%S JST'))"
          else
            gh pr comment ${{ github.event.pull_request.number }} \
              --body "⚠️ **Terraform Apply ワークフローエラー** (終了時刻: $(date '+%Y-%m-%d %H:%M:%S JST'))"
          fi

      # 10. Apply失敗時は最終的にワークフローを失敗させる
      - name: Check apply result
        if: ${{ steps.apply.outputs.has_error == 'true' }}
        run: |
          echo "::error::Terraform apply failed. Please check the PR comments for details."
          exit 1

動作結果

動作結果、以下のようになります。以下は、PR作成後からのGithubのPR上の画面表示です。terraform planとterraform applyが、全てGihub上で行われ、その結果もPRの画面内で確認できます。

1. PRを作成すると、plan用のワークフローが自動起動し、以下のように表示されます。(前述の通り、今回のOrgは無償プランのため、レビュアー未承認でのマージをブロックすることが強制できないため、このようなメッセージを出しています)
5
2. plan用のワークフロー処理が完了すると、以下のように表示されます。planの処理結果や、対象のAWSアカウントなどが表示されます。仮に処理結果が想定外の場合には、ローカルPC上でコードを修正し、git pushを行うと、plan用のワークフローが再度自動起動します。(このため、マージされるまでは、何度でもコード修正に伴うterraform planが可能です)
6-2
3. (レビュアーが確認して承認後)マージを行うと、apply用のワークフローが自動起動し、以下のように表示されます。
7
4. apply用のワークフロー処理が完了すると、以下のように表示されます。applyの処理結果や、対象のAWSアカウントなどが表示されます。
8-2
5. 最後に処理完了日時が表示されます。(前述した通り、applyはAWS環境への実際の反映で、時間もある程度かかる場合もあり、開始日時から終了日時をログとしてわかりやすく残したかったので、このように表示させています)
9

運用上の留意点

上記の通り動作していますが、最後にいくつか運用上の留意点を記載します。今回の環境独自の内容も含まれます。

  • 今回の環境はGithub Orgが無償プランのため、mainブランチへの直接pushはブロックできません。これは行わないようにする運用ルールですが、間違わないように注意は必要です。(レビューされないのはもとより、ワークフローも起動しません)
  • これもGithub Orgが無償プランのための注意点ですが、レビュアーがPRのapprove前のマージをブロックできないため、approve前でもマージできてしまいます。これを行わないようにする運用ルールであり、今回の処理で警告メッセージをPR作成時に表示するようにしていますが、これも間違わないように注意は必要です。
  • 今回のワークフローでは、1つのPRで、1つのterraformディレクトリ(tfstate)の更新のみに対応しています。今回は、一度に複数のtfstateの環境を更新するような運用は不要で、危険性もあるため、このようにしています。
  • 今回のワークフローでは、locals.tfからAWSアカウントIDを取得し、terraform.tfからterraformのバージョン情報を取得しています。これらのファイル名と記述内容は変更不可です。今回はこれをコード記述の標準としています。(従ってなかった場合はエラーとなり、修正が必要となります)
  • 管理対象AWSアカウントの追加がある場合は、ワークフローのAWSアカウント接続処理の箇所の修正と、対象のAWSアカウント側でIAMの設定が必要です。
  • 管理対象AWSアカウント内で、管理対象のリソース追加がある場合は、対象のI AM Policy追加が必要です。
  • 今回はplanとapplyのみ自動化していますが、他のチェック等も組み込むのは今後の検討事項です。

おわりに

TerraformでAWSリソースを更新する際のCI/CDをGitHub Actionsを利用して作成し、その際の経緯や手順などについて記載しました。この記事が皆様のお役に立てば幸いです。

アノテーション株式会社について

アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。

サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新 IT テクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。

当社は様々な職種でメンバーを募集しています。

「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。

この記事をシェアする

FacebookHatena blogX

関連記事