技術(tech)

TerraformのCI/CDをGitHub Actionsで実現する

はじめに

いざTerraformでインフラを管理しようとなった際に、どうすれば安全なパイプラインを構築できるものか、迷うことかと思います。
この記事ではTerraformのCI/CDをGitHub Actionsで構築する方法を紹介します。

前提

この記事は、以下の記事の続きとなっております。
もしも前提となるコンテキストが必要となる場合、こちらの記事もご覧ください。

TerraformでのAWSリソースIaC化のためのテンプレートを紹介はじめに インフラのリソースをコードで管理したい。 さて、Terraformを使おう... となった際に、どのようなディレクトリ構成で、...

また、本パイプラインは以下を前提としています。

  • AWSのリソースを管理する
  • 環境はstgとprdの2環境を想定する
  • planは自動で、applyはGitHub Actionsから手動でキックする

概要

結論として、以下のリポジトリにコードを置いています。
https://github.com/gonkunkun/terraform-template/tree/main/.github/workflows

Planについて

Planの実行は以下の仕様となっています。

  • PRの作成/PRの更新時にPlanが実行される
  • stgとprdの2環境に対するPlanが同時に実行される
  • コードの変更がない場合にはPlanをスキップする(クレジットの節約のため)
  • Planの実行結果はPRのコメントに貼り付けられる

Applyについて

CDにおいては、GitHubの無料枠の制約を受けています。
具体的には、有料枠のみで提供されている以下の機能が使えない制約がありました。

  • ブランチの保護ルールを設定する
  • GitHub ActionsのApply前のStepに承認プロセス(Approve)を設ける

そこで、いくつか妥協を重ねつつ、Applyの実行を以下の仕様としました。

  • mainブランチの状態に対してApplyを実行する(これは仕様というか運用ルールです)
    • ここは運用対処で凌いでいる
  • mainブランチとApply対象の環境を指定して、apply用のCDをキックする
  • CDキック後は、Plan -> 数秒間のwait(Planの確認用) -> Applyの順でパイプラインが動作する
    • 承認プロセスを挟めないので、手動でキックしている
      (複数人が同時にmainにマージすると、PRのPlan結果と異なるApplyが適用されるリスクがあります)
    • 擬似的に承認プロセスを挟めるようなモジュールもあったものの、承認待ちの間もクレジットを消費してしまうので、節約の観点で諦めました

詳細

重複したコードが多いので、もっと綺麗に整理することもできると思います。

事前準備

  • Terraformユーザ用のクレデンシャルはGitHubリポジトリ側に設定する

都度都度クレデンシャルを持ってくる方がいいのかもしれないですが、今回は楽なやり方を選んでます。

Plan

name: Terraform Plan

on:
  pull_request:
    types:
      - opened
      - synchronize
    branches:
      - '*'

permissions:
  contents: read
  pull-requests: write

jobs:
  check_changed_dirs:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix_dirs }}
    steps:
      - run: echo "fetch_depth=$(( commits + 1 ))" >> $GITHUB_ENV
        env:
          commits: ${{ github.event.pull_request.commits }}

      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: ${{ env.fetch_depth }}

      - name: Set Matrix
        id: set-matrix
        run: |
          git fetch origin ${{ github.base_ref }}
          changed_files=$(git diff --name-only origin/${{ github.base_ref }})
          terraform_dirs=$(echo "$changed_files" | grep -E '^[^/]+/.*\.tf$' | awk -F/ '{print $1}' | sort -u || echo "")
          if [ -z "$terraform_dirs" ]; then
            echo "No Terraform directories changed."
          else
            echo "Changed directories: $terraform_dirs"
          fi
          echo "matrix_dirs=$(echo $terraform_dirs | jq -R -s -c 'split("\n") | map(select(. != ""))')" >> "$GITHUB_OUTPUT"
        shell: bash

  plan:
    name: Plan
    runs-on: ubuntu-latest
    needs: check_changed_dirs
    if: ${{ needs.check_changed_dirs.outputs.matrix != '[]' && needs.check_changed_dirs.outputs.matrix != '' }}
    strategy:
      fail-fast: false
      matrix:
        dir: ${{ fromJson(needs.check_changed_dirs.outputs.matrix) }}
        env: [stg, prd]

    steps:
      # Setup
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: "ap-northeast-1"

      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.dir }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      # Cache Terraform plugins
      - name: Cache Terraform plugin directory
        uses: actions/cache@v3
        with:
          path: .terraform
          key: terraform-plugin-${{ runner.os }}-${{ hashFiles('**/*.tf*') }}

      - name: Run Terraform Format Check
        run: terraform fmt -recursive -check

      # Run Terraform Init
      - name: Terraform Init
        run: |
          terraform init -backend-config="envs/${{ matrix.env }}/backend.hcl"
        working-directory: ${{ matrix.dir }}

      - name: Run Terraform Validate Check
        run: terraform validate
        working-directory: ${{ matrix.dir }}

      # Run Terraform Plan
      - name: Terraform Plan
        run: |
          terraform plan -var="env=${{ matrix.env }}" -out=tfplan-${{ matrix.dir }}-${{ matrix.env }}.out
        working-directory: ${{ matrix.dir }}

      # Post results to PR
      - name: Post Plan Results to PR
        uses: borchero/terraform-plan-comment@v2
        with:
          token: ${{ github.token }}
          header: "terraform-plan Dir: ${{ matrix.dir }} (${{ matrix.env }})"
          planfile: tfplan-${{ matrix.dir }}-${{ matrix.env }}.out
          working-directory: ${{ matrix.dir }}

ref: https://github.com/gonkunkun/terraform-template/blob/main/.github/workflows/terraform-plan.yml

アウトプットのイメージとしては以下の感じです。

planの結果のポストは、以下の記事で解説しています。

Terraform用のCI構築 - plan結果をPRのコメントに貼り付けるはじめに Terraformを利用している以上、planの実行結果を確認するフローをCIに組み込むことは避けては通れません。 &qu...

Apply

name: Terraform Apply (manual)

on:
  push:
    branches:
      - main
  workflow_dispatch:
    inputs:
      environment:
        description: 'Select the environment (e.g., stg, prd)'
        required: true
        type: choice
        options:
          - stg
          - prd
        default: stg

permissions:
  contents: read
  pull-requests: write

jobs:
  pre-plan:
    name: Pre-Plan
    runs-on: ubuntu-latest
    # needs: check_terraform_dirs
    if: github.event_name != 'workflow_dispatch'
    strategy:
      fail-fast: false
      matrix:
        dir: ["workloads"]
        env: [stg, prd]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.base_ref }}

      - name: Setup AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: "ap-northeast-1"

      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.dir }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      # Cache Terraform plugins
      - name: Cache Terraform plugin directory
        uses: actions/cache@v3
        with:
          path: .terraform
          key: terraform-plugin-${{ runner.os }}-${{ hashFiles('**/*.tf*') }}

      # Run Terraform Init
      - name: Terraform Init
        run: |
          terraform init -backend-config="envs/${{ matrix.env }}/backend.hcl"
        working-directory: ${{ matrix.dir }}

      # Run Terraform Plan
      - name: Terraform Plan
        run: |
          terraform plan -var="env=${{ matrix.env }}"
        working-directory: ${{ matrix.dir }}

  apply:
    name: Apply to specific env
    runs-on: ubuntu-latest
    # needs: check_terraform_dirs
    if: github.event_name == 'workflow_dispatch'
    strategy:
      matrix:
        dir: ["workloads"]
        env: ["${{ github.event.inputs.environment }}"]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.ref_name }}

      - name: Ensure Main is Up-to-Date
        run: |
          git fetch origin ${{ github.ref_name }}
          git diff --quiet origin/${{ github.ref_name }} || (echo "Main has diverged. Please rebase your branch." && exit 1)

      - name: Setup AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: "ap-northeast-1"

      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.dir }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      # Run Terraform Init
      - name: Terraform Init - ${{ matrix.env }}
        run: |
          terraform init -backend-config="envs/${{ matrix.env }}/backend.hcl"
        working-directory: ${{ matrix.dir }}

      # Run Terraform Plan
      - name: Terraform Plan
        if: github.event_name == 'workflow_dispatch'
        run: |
          terraform plan -var="env=${{ matrix.env }}"
          sleep 6
        working-directory: ${{ matrix.dir }}

      # Run Terraform Apply
      - name: Terraform Apply - ${{ matrix.env }}
        if: github.event_name == 'workflow_dispatch'
        run: |
          terraform apply -var="env=${{ matrix.env }}" -auto-approve
        working-directory: ${{ matrix.dir }}

ref: https://github.com/gonkunkun/terraform-template/blob/main/.github/workflows/terraform-apply.yml

GitHub Actionsから手動でワークフローを実行します。

おわりに

ここまで読んでいただきありがとうございました。
良いTerraformライフを!