ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

Github Actions を利用し、Terraform plan と Apply を自動化する方法について

みなさん、こんにちは!

AWSグループの大澤 (@yukiblue63) です。

Terraform を運用するにあたり、作業者の端末が1人1人異なると、Terraformの実行環境を揃えるのが大変ではないでしょうか。

そういった場合に、Github Actions を用いて、Terraform のPlanと Apply を実行できるようにすることで、作業者の端末に依存せずに実行環境を整備することが可能となります。

今回は、AWSリソースの管理にTerraform を利用する場合を想定し、Github Actions でPlan と Apply を自動化させる場合の例とポイントをご紹介します。

イメージ図

今回ご紹介する方法について、Github Actions と Terraform と AWSリソースの関係性について示します。

①: Github Actions が OpenID Connect を利用して各AWSリソースにアクセスします。

②: User がGithub Repository にコードをPush の上、Pull Request (PR) を発行したタイミングで、 Terraform Planを自動的に実行し、PR のコメント欄に結果を追記します。

PRがMergeされると、Terraform Apply が自動的に実行されます。

Github Actions

AWS でOpenID Connect の設定

Github Actions がAWSリソースへアクセスするために、OpenID Connect (OIDC) によって、有効期間の長い GitHub シークレット を利用できるようにします。

GitHub Actionsと連携するためのIAMを設定する手順 については、下記のテックブログの記事 でもご紹介しております。

techblog.forgevision.com

ID プロバイダを作成

IAM コンソールから ID プロバイダプロバイダを追加 の順に選択します。

プロバイダの設定

下記のように入力します。

項目 入力する値
プロバイダのタイプ OpenID Connect
プロバイダの URL https://token.actions.githubusercontent.com
対象者 sts.amazonaws.com

以上で、GitHub Actions 用の ID プロバイダが作成されます。

IAMロールの作成

IAM コンソールにおいて、新規にIAMロールを作成します。

IAM コンソールから ロールロールを作成 の順に選択します。

信頼ポリシーの設定では、信頼されたエンティティタイプカスタム信頼ポリシー を選択後、

カスタム信頼ポリシー を入力します。

(ここでは、カスタム信頼ポリシーの例を記載します)

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::<AWSアカウントID>:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
                    "token.actions.githubusercontent.com:sub": "repo:<GitHubユーザー名>/<GitHubレポジトリ名>:ref:refs/heads/<ブランチ名>"
                }
            }
        }
    ]
}

ここでのPOINT として、Condition となります。

  • Condition を設定しないと全ての GitHub リポジトリから認証できるようになってしまうため、必ず設定してください。
"Condition": {
    "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        // 特定のレポジトリかつ特定のブランチからのみ認証を許可
        "token.actions.githubusercontent.com:sub": "repo:<GitHubユーザー名>/<GitHubレポジトリ名>:ref:refs/heads/<ブランチ名>"
    }
}

入力したら 次へを選択します。

IAM ロールにポリシーをアタッチ

IAMロールに任意のポリシーをアタッチします。

ここでは、Github Actions ・Terraform が作成するAWSサービスをコントロールできるようにポリシーを付与してください。

(例として、AmazonEC2ReadOnlyAccess を付与しています)

Workflow のyaml ファイルの配置

レポジトリのトップに、.github/workflows ディレクトリを配置し、その配下に、yamlファイルを設置します。

// .github ディレクトリの配下のツリー図
.
├
└── workflows
    ├── terraform_apply_common.yml
    ├── terraform_apply_production.yml
    ├── terraform_apply_staging.yml
    └── terraform_plan.yml

ロールを作成

ロール名 に任意のロール名を入力して ロールを作成します。

ここまで完了すると、Github Actions から OIDC による AWS認証の準備が完了します。

terraform plan の自動実行

Terraform Plan をPR (Pull Request) が作成されたときとPRを作成後に対象ブランチにpushしたときのそれぞれのタイミングで実行させる場合を想定します。

---

name: "Workflow for Terraform Plan"
on:
  workflow_call:
    inputs:
      TF_VERSION:
        type: string
        required: true
  pull_request: ①
    branches:
      - (Planを自動実行させたいブランチ名を指定)
    types: [opened, synchronize]

permissions:
  id-token: write
  contents: read
  pull-requests: write

env:
  ROLE_TO_ASSUME: arn:aws:iam::(略):role/(OpenID Connect でGithub Actions が利用するIAMロール名)
  AWS_REGION: ap-northeast-1

jobs:
  terraform:
    name: "Terraform Directory Checks"
    runs-on: ubuntu-latest
    strategy:
      matrix:
        directory:
          [
            "(同じレポジトリ内にあるTerraformのコードがある場所を指定)"
          ]
    defaults:
      run:
        working-directory: ${{ matrix.directory }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

# .terraform-version で定義しているTerraformバージョンを取得 ②
      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.directory }}

# .terraform-version で定義しているTerraformバージョンでinit ②
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      - name: Setup AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.ROLE_TO_ASSUME }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Check if directory is not empty
        id: check
        run: |
          if [ "$(ls -A .)" ]; then
            echo "Directory is not empty"
          else
            echo "Directory is empty"
            exit 1
          fi

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color
        continue-on-error: true

      - name: truncate terraform plan result
        run: |
          plan=$(cat <<'EOF'
          ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }}
          EOF
          )
          echo "PLAN<<EOF" >> $GITHUB_ENV
          echo "${plan}" | grep -v 'Refreshing state' | tail -c 65000 >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV

      - name: create comment from plan result ③
        uses: actions/github-script@0.9.0
        if: github.event_name == 'pull_request'
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`

            <details><summary>Show Plan</summary>

            \`\`\`\n
            ${ process.env.PLAN }
            \`\`\`

            </details>

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Working Directory: \`${{ matrix.directory }}\`, Workflow: \`${{ github.workflow }}\`*`;

            github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

上記のコード内に今回ポイントとなる箇所を番号で示しています。

  • ①: 指定したブランチに取り込まれたプッシュイベントをトリガーにワークフローが実行されます。

  • ②: 実行の際にTerraformのバージョンを指定させるために、tfenv で利用する、.terraform-version ファイルに実行させたいTerraformバージョンを記載しておき、それを読み取り、指定バージョンで実行します。

  • ③: マージ元のPRにapply結果をコメントで投稿させます。これにより、plan結果を自動的に1つのPR上でまとめて確認することができ、見通しが良くなります。

Terraform Apply の自動実行

Terraform Apply をPR (Pull Request) がCloseされたときに自動実行、対象ブランチを指定し、

手動実行させる場合を想定します。

---
name: "Workflow Terraform Apply"
on:
  pull_request: ①
    branches:
      - (Planを自動実行させたいブランチ名を指定)
    types:
      - closed ②
  workflow_dispatch: ③

  workflow_call:
    inputs:
      TF_VERSION:
        type: string
        required: true

permissions:
  id-token: write
  contents: read
  pull-requests: write

env:
  ROLE_TO_ASSUME: arn:aws:iam::(略):role/(OpenID Connect でGithub Actions が利用するIAMロール名)

jobs:
  terraform:
    name: "Terraform Directory Checks"
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == true ②
    strategy:
      matrix:
        directory:
          [
            "(同じレポジトリ内にあるTerraformのコードがある場所を指定)"
          ]
    defaults:
      run:
        working-directory: ${{ matrix.directory }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 2

# .terraform-version で定義しているTerraformバージョンを取得
      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.directory }}

# .terraform-version で定義しているTerraformバージョンでinit
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      - name: Setup AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.ROLE_TO_ASSUME }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Check if directory is not empty
        id: check
        run: |
          if [ "$(ls -A .)" ]; then
            echo "Directory is not empty"
          else
            echo "Directory is empty"
            exit 1
          fi

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Apply
        id: apply
        working-directory: ${{ matrix.directory }}
        run: |
          terraform apply -auto-approve -no-color

      - name: truncate terraform apply result
        run: |
          apply=$(cat <<'EOF'
          ${{ format('{0}{1}', steps.apply.outputs.stdout, steps.apply.outputs.stderr) }}
          EOF
          )
          echo "PLAN<<EOF" >> $GITHUB_ENV
          echo "${apply}" | grep -v 'Refreshing state' | tail -c 65000 >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV

上記のコード内に今回ポイントとなる箇所を番号で示しています。

  • ①: 指定したブランチに対するPRのcloseイベントをトリガーにワークフローが実行されます。

  • ②: Pull Request がMergeかつClose された場合は、「PRがクローズされた際のイベントとPRがマージされているかどうかの条件を組み合わせる」必要がある点に注意が必要となります。

  • ③: PRにMergeする前に、検証等でApplyを実行させるような場合を想定し、workflow_dispatch により手動実行も可能としています。

Terraform

Terraform のコードやディレクトリ構成として、下記のような構成を準備しました。

想定として、AWSリソースの環境として、staging環境とprod環境の2つがあると仮定し、両環境で共通で必要となるリソースをcommon ディレクトリにまとめています。

└── terraform
    ├── common
    │   └── aws
    │       ├── <共通環境で必要なtfファイル群>
    ├── modules
    │   └── aws
    │       ├── <AWSリソース名>
    │       │   ├── main.tf
    │       │   ├── output.tf
    │       └── └── variables.tf
    |       (以下、略)
    ├── production
    │   └── aws
    │       ├── <prod環境で必要なtfファイル群>
    └── staging
        └── aws
            ├── <staging環境で必要なtfファイル群>

.terraform-version で定義しているTerraformバージョンを取得 するための、.terraform-version は、

common、staging、production のそれぞれのディレクトリに配置します。

Terraform のバージョン管理ツールの tfenv を使う上で、実行バージョンを指定するファイルを活用しています。

また、それぞれのディレクトリに、provider の指定、stateファイルを s3バケットに置く場合のbackend 設定 などもそれぞれ用意します。

実行例

実際に、Github Actions で実行させた場合のサンプル例です。

Github の 対象レポジトリ内の Actions の画面ですが、画像内の矢印の通り、

ブランチを指定して、Terraform Apply ができるようになっています。

まとめ

  • Terraform を運用するにあたり、作業者の端末の環境やOSが1人1人異なると、Terraformの実行環境を揃えるのが大変になってきてしまう

  • Github Actions やその他のCI/CDツールを活用することで、Terraform の実行環境を整備することが可能

  • 事前準備として、OpenID Connect でGithub Actions がAWSリソースへアクセスするために設定が必要

  • OIDC用のIAMロールの信頼ポリシーでは、Condition を設定しないと全ての GitHub リポジトリから認証できるようになってしまうため、必ず設定する

  • 今回の例では、PRが作成されると、Terraform Plan を自動実行し、PR内のコメントとして追記されるように設定

  • tfenv で利用する、.terraform-version ファイルに実行させたいTerraformバージョンを記載しておくことで、指定したTerraformのバージョンで実行させることが可能

  • Pull Request がMergeかつClose された場合は、「PRがクローズされた際のイベントとPRがマージされているかどうかの条件を組み合わせる」必要がある

レポジトリの初期設定の際に合わせて、Gtihub Actions による自動化も実施いただくと、実際に運用時の煩雑さ軽減に寄与できる方法の1つではないでしょうか。

長い記事となりましたが、今後も汎用的に使えるテクニックなどをご紹介していきます。