📖

Github ActionsでTerraformを適用するワークフロー

2024/12/19に公開

この記事は MICIN Advent Calendar 2024 の 19日目の記事です。

https://adventar.org/calendars/10022

前回はkurosawaさんの、「 S3のマルウェアチェックをGuardDutyにやってもらったらミスった」 でした。

1.はじめに

株式会社MICINのSREチームのonoです。
普段はAWSやGCPなどのクラウドの管理やモニタリングの整備などを行っています。

MICINではAWSなどのインフラの設定はterraformを使って管理していて、CI/CDでGitHub Actionsを利用しています。
GitHubのPullRequest作成やマージなどをトリガーにしてterraformを実行しています。

この記事ではGithub Actionsのワークフローがどのように変化してきたかを振り返りながら、ワークフローの共通化や導入して良かったツールを紹介したいと思います。

2.当時のワークフローを振り返る

MICINではインフラのワークフローは基本的にPlanとApplyの2つのワークフローを定義しています。この2つはトリガーが異なるのでファイルを分けています。
インフラに関するリポジトリはプロダクトごとに作成されています。30を超えるリポジトリがあるのでワークフローファイルは60個ほどありました。
2023年頃から改善を始めましたが、当時のPlanのワークフローファイルは以下のようになっていました。

name: "Terraform"
on:
  pull_request:
    types: [opened, synchronize]
    branches: [master]
permissions:
  id-token: write
  contents: read
  pull-requests: write
jobs:
  terraform:
    name: "Terraform Validate/Plan"
    runs-on: ubuntu-latest
    steps:
      - name: "Checkout"
        uses: actions/checkout@v2

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: $${{ matrix.role_arn }}
          role-session-name: ci-session
          aws-region: ap-northeast-1

      - uses: hashicorp/setup-terraform@v1

      - name: Terraform fmt
        id: fmt
        working-directory: "./$${{ matrix.root_module }}"
        run: terraform fmt -check
        continue-on-error: true

      - name: Terraform Init
        id: init
        working-directory: "./$${{ matrix.root_module }}"
        run: terraform init 

      - name: Terraform Validate
        id: validate
        working-directory: "./$${{ matrix.root_module }}"
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        working-directory: "./$${{ matrix.root_module }}"
        run: terraform plan -no-color 
        continue-on-error: true

      - uses: actions/github-script@0.9.0

      - name: Check on failures
        if: steps.plan.outputs.status == 'failure'
        run: exit 1

細かいところは省略していますが、PRが作成された時もしくはpushされると fmt -> init -> plan -> PRに出力 という流れで処理されます。
処理の流れは大体一緒なのでコピペで作成する運用をとっていました。

SREチームでは月に一度「リファクタ会」を行っていて、チームメンバーが日々見つけた課題を共有して解決策を考える場を設けています。
terraformのワークフローに対して以下のような課題が上がりました。

  • terraform planの結果が見にくい
  • Actionsのバージョンアップができていない
  • リポジトリによっては改善されているが全体に展開できていない

どう対応したのかをそれぞれ説明していきたいと思います。

3.planの結果が見にくい

PRに terraform plan の結果を出力することでレビュアーはコードの変更によるリソースの変化について確認できます。
PRへの出力はgithub-scriptを使って自前のフォーマットで行っていました。見にくいと感じる理由は、terraformが出力するログが原因でした。

module.lambda_iam.data.aws_region.current: Reading...
module.lambda_iam.data.aws_caller_identity.current: Reading...
module.lambda_iam.data.aws_iam_policy_document.lambda_deploy_role_policy: Reading...
module.lambda_iam.aws_cloudwatch_log_group.lambda_log_group: Refreshing state... [id=/aws/lambda/sample]
module.lambda_iam.aws_iam_role.lambda_role: Refreshing state... [id=sample-lambda-role]
module.lambda_iam.data.aws_region.current: Read complete after 0s [id=ap-northeast-1]
module.lambda_iam.data.aws_iam_policy_document.lambda_deploy_role_policy: Read complete after 0s [id=1234567]
module.lambda_iam.aws_iam_role.lambda_deploy_role: Refreshing state... [id=sample-lambda-deploy-role]
module.lambda_iam.data.aws_caller_identity.current: Read complete after 0s [id=1234567]

plan結果を確認するためにひたすらスクロールしなくてはならず、それも環境ごとに出力されているので確認するのがとても大変でした。
terraformの標準オプションでこれを出力しない方法があれば良かったですが、そういったオプションはなかったので頑張ってスクロールしていました。

この問題を解決してくれるツールをいくつか検討し、tfactionを利用することを考えました。
導入できれば便利な機能が追加されるのですが、後述するワークフローの共通化を進めていくにあたってうまく動作しない問題があり断念しています。

tfactionにも含まれるtfcmtだけでも十分見やすくなるのでtfcmtだけ導入することにしました。

tfcmt

plan結果や適用されるリソースへのアクションがサマリで表示されていてとても見やすいです。
詳細な変更内容も確認できるのでtfcmtを導入しただけでPRレビューにかかる工数が激減した気がします。
普段不便と感じていても頑張ってスクロールすることに慣れてしまっていたので、こういった改善がいかに大切か実感しました。

4.Actionsのバージョンアップができていない

前述した通り、MICINではインフラのリポジトリが複数存在しワークフローファイルがそれぞれ存在します。
運用していく中でActionsの構文が変わってしまったりバージョンが非推奨になってしまったりしますがアップデートができていない状態になっていました。

破壊的変更があった場合バッチ的に全てのワークフローを修正する必要があり、メンテナンスの負担は大きいです。
terraformやaws providerのアップデートも同様に工数がかかるので、これもツールを導入して自動化することを目指しました。

SREチームではrenovateを導入することにしました。
renovateは依存関係の更新を自動化してくれるツールです。他のチームで導入が進んでいたのが選定の大きな理由です。
Actionsのバージョンアップも自動で更新してくれるのでメンテナンス工数が大幅に削減できています。

renovateの運用

ワークフローとは関係ないですが、terraformのrenovateによる自動更新について少し触れます。

{
    // これを入れるとVSCodeの補完が効くようになります。
    "$schema": "https://docs.renovatebot.com/renovate-schema.json",  
    "extends": [
    // renovateが用意しているプリセット
      "config:recommended",
    // タイムゾーンの設定
      ":timezone(Asia/Tokyo)"
    ],
    // 1時間に作成されるPRの制限を0
    "prHourlyLimit": 0,
    // 毎週末にPRを作成
    "schedule": [
      "every weekend"
    ],
    "automerge": true,
    "packageRules": [
      // リリース直後のライブラリの更新を無効化
      {
        "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"],
        "minimumReleaseAge": "14 days",
      },
      // terraformとterraform providerのアップデートのみをplan, apply
      {
        "matchManagers": ["terraform", "terraform-version"],
        "addLabels": ["terraform"],
      },
      { 
        "matchPackageNames": ["hashicorp/terraform"],
        "addLabels": ["terraform"],
      },
    ]
}

renovateによるPRの作成は週末に行うようにしています。週明けにレビュアーにアサインされたメンバーが確認します。 自動マージを有効にしているのでterraformに関しては差分がない限り自動でマージされます。
このプリセットを共通化したことで各リポジトリでPRが自動で作成され、差分がない場合マージされるのでバージョンアップの対応がとても楽になりました。

5.リポジトリによっては改善されているが全体に展開できていない

ここまで触れたtfcmtやrenovateによる運用は特定のリポジトリで実験的に運用していました。
リポジトリが分散しているので、これらのツール導入による設定ファイルの追加なども各リポジトリに分散します。
ワークフローの共通化についてはいくつか種類があります。

terraformのplanとapply以外にもtfsecやSBOM生成などのワークフローも共通化したい思いがあったのでreusable actionを採用することとしました。

name: "Terraform Plan"
on:
  workflow_call:
    inputs:
      root_module:
        required: true
        type: string
      role_arn:
        required: true
        type: string
      workload_identity_provider:
        type: string
      timeout:
        type: number
        default: 30
jobs:
  terraform:
    name: "Terraform Validate/Plan"
    runs-on: ubuntu-latest
    # renovateによるterraformの更新、もしくは人によるPR作成で動作する
    if: contains(github.event.pull_request.labels.*.name, 'terraform') || github.actor != 'renovate[bot]'
    timeout-minutes: ${{ inputs.timeout }}
    outputs:
      exitcode: ${{ steps.plan.outputs.exitcode }}
    steps:
      - name: "Checkout"
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ inputs.role_arn }}
          role-session-name: ci-session
          aws-region: ap-northeast-1

      - name: "Detect terraform version"
        id: get_tf_version
        working-directory: "./${{ inputs.root_module }}"
        run: echo "tf_version=$(cat .terraform-version)" >> $GITHUB_OUTPUT

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.get_tf_version.outputs.tf_version }}

      - name: Terraform fmt
        id: fmt
        working-directory: "./${{ inputs.root_module }}"
        run: terraform fmt -check
        continue-on-error: true

      - name: Terraform Init
        id: init
        working-directory: "./${{ inputs.root_module }}"
        run: terraform init

      - name: Terraform Validate
        id: validate
        working-directory: "./${{ inputs.root_module }}"
        run: terraform validate -no-color

      - name: setup tfcmt
        env:
          TFCMT_VERSION: v4.10.0
        run: |
          wget "https://github.com/suzuki-shunsuke/tfcmt/releases/download/${TFCMT_VERSION}/tfcmt_linux_amd64.tar.gz" -O /tmp/tfcmt.tar.gz
          tar xzf /tmp/tfcmt.tar.gz -C /tmp
          mv /tmp/tfcmt /usr/local/bin
          tfcmt --version

      - name: Terraform Plan
        id: plan
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        working-directory: "./${{ inputs.root_module }}"
        run: tfcmt -var target:${{inputs.root_module}} plan -patch=false --skip-no-changes -- terraform plan -no-color -detailed-exitcode -out=tfplan.plan
        continue-on-error: true

      - name: Check on failures
        if: steps.plan.outputs.status == 'failure'
        run: exit 1

  automerge:
    name: Deny Auto-Merge
    runs-on: ubuntu-latest
    needs: terraform

    steps:
      - name: Deny Auto-Merge
        # renovateによるPR作成においてterraform planに差分があったらexit 1としてマージさせない
        if: contains(github.event.pull_request.labels.*.name, 'terraform') && github.actor == 'renovate[bot]' && needs.terraform.outputs.exitcode != '0'
        run: exit 1

再利用するワークフロー(yaml)をまとめたリポジトリを作成して、各リポジトリからはこのyamlを呼び出すようにします。
ワーキングディレクトリやAWSのCredentialを呼び出し側のワークフローから受け取り、planを実行します。

呼び出し側のリポジトリで以下のようなワークフローファイルを用意します。

name: "Terraform Plan"
on:
  pull_request:
    types: [opened, synchronize]
    branches: [main]
permissions:
  id-token: write
  contents: read
  pull-requests: write
jobs:
  terraform:
    name: "Terraform Validate/Plan"
    strategy:
      fail-fast: false
      matrix:
        include:
          - root_module: terraform/environment/staging
            role_arn: arn:aws:iam::12345678910:role/staging-terraform-plan
          - root_module: terraform/environment/production
            role_arn: arn:aws:iam::12345678911:role/production-terraform-plan

    uses: <共通ワークフローのリポジトリ>/.github/workflows/terraform_plan.yml@<コミットハッシュ>
    with:
      root_module: ${{ matrix.root_module }}
      role_arn: ${{ matrix.role_arn }}

ブランチ名(main)を指定する形で運用を開始したのですが、共通ワークフローに何か変更を加えた際に問題が起きると参照しているリポジトリでも問題が起きてしまいます。
これを避けるため、コミットハッシュを指定することで、利用するワークフローのバージョンを固定しています。
このコミットハッシュもrenovateで検知して更新対象としてくれるので、各リポジトリを更新して回る必要はありません。

この共通ワークフローのリポジトリには前述したRenovateのプリセットを用意していて、各リポジトリからは以下のように参照しています。

{ 
    "extends": [
     // shared-workflowsのプリセット
      "github><共通ワークフローのリポジトリ>:renovate_terraform.json5",
    ],
    // PRのレビュワーを設定
    "reviewers": ["team:sre"],
    // 更新を行うディレクトリの指定
    includePaths: [
        "**/.github/workflows/**",
        "**/terraform/env/**"
    ],
}

共通のワークフローリポジトリにはterraform以外にもSBOMの生成やtfsecのワークフローも配置していて再利用しています。

6.おわりに

ワークフローを改善したことでPRレビューがしやすくなり、依存関係のあるモジュールを個々にバージョンアップする作業に時間を割かなくてもよくなりました。
変更箇所が共通化されたことでterraformの実行環境に統制が効くようになり、新規リポジトリの作成も容易です。
とはいえまだ改善したい箇所はあり、PRの中で動的にplanやapplyが実行できるトリガーを用意したり、tfactionで採用されている機能もいくつか取り入れたいと思っています。


MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!

https://recruit.micin.jp/

株式会社MICIN

Discussion