GitHub Actions で Terraform の CI/CD を構築する

タクシーアプリ「GO」、法人向けサービス「GO BUSINESS」、タクシーデリバリーアプリ「GO Dine」の分析基盤を開発運用している伊田です。GitHub Actions から OIDC トークンを利用し、サービスアカウントキーなしで GCP に認証した上で Terraform の CI/CD を構築する方法を紹介します。

※ 対象読者は分析基盤を管理しているデータエンジニア、または Terraform を管理しているエンジニアです

はじめに

本記事では、なぜ Terraform の CI/CD を構築したのか、まず分析基盤について簡単に説明し、次に分析基盤のうち Terraform で何を管理しているのか説明します。その上で現状抱えていた課題とそれに対する対応案を説明させて頂き、実際に構築に使用した技術要素やコード、運用にあたって気をつけたことについて紹介させて頂きます。

分析基盤について

分析基盤_2022.drawio.png

上記はBigQueryへのデータ連携とBIツールであるLookerが参照するまでの流れを簡単に記した図です。

データ連携のパイプラインは大まかに

  1. Embulk を使ったデータ連携
  2. Debezium を使ったデータ連携
  3. 各種イベントログのデータ連携

上記の3種類で、これらの後続でデータ加工を行っています。ジョブ管理ツールは Cloud Composer で、GKE 上でデータ加工および SQL を発行しています。

Looker からは連携後のデータを直接参照させず、個人情報保護や権限管理などの要求に応えるために分析用の別のプロジェクトを用意しています。便宜上、以後はデータを格納するプロジェクトをソースプロジェクト、分析用のプロジェクトを分析プロジェクトと呼びます。

分析プロジェクトでよくあるパターンは、ソースプロジェクトへの View を作成し、参照権限を Authorized View によって認可させています。さらに強力に保護する必要があるデータについては、Data Catalog を用いて列レベルの管理をしているものもあります。

Terraform で管理しているもの

上記で説明したように

  • PubSub
  • Dataflow
  • Cloud Composer
  • GKE
  • BigQuery
  • GCS (BigQuery の外部テーブル)
  • Data Catalog

など、様々なリソースを Terraform で管理しています。これらのリソースのうち、1回構築するとほとんど変更がないものもあれば、新規の分析要件ごとに追加、修正が必要なリソースがあります。

主にBIの文脈が後者で、

  • BigQuery
    • データセット
    • テーブル
    • View
    • Authorized View
    • 権限管理
  • GCS
  • Data Catalog

のリソースを管理する Terraform の CI/CD を今回構築しました。

尚、GCP は本番、開発、QA環境を持っており、Terraform も本番、開発、QA環境の3つの Terraform を管理しています。

また、Terraform および Provider のバージョンは下記の通りです。

  • Terraform 1.1.5
  • google 4.10.0

課題

「業務委託さんや別チームが、分析基盤チームが管理している Terraform に触りづらい (Pull Request を出しづらい)」

terraform plan を実施するには Terraform が管理しているリソースの参照権限が、 terraform apply を実施するには、(BigQuery管理者権限など)強い権限を持つ必要があります。結果として、業務委託さんに Terraform の作業を割り振ることが難しかったり、簡単な修正で済むのに別チームから Pull Request を受けられず、分析基盤チームが対応依頼を受けて作業が完了するまで、データを利用したい人は待つ必要がありました。

例として、分析プロジェクトからソースプロジェクトへの View 設置および Authorized View の設定がそれにあたります。

(分析要件が出てくるごとに対応が必要なのでしばしば運用が発生する)

対応案

分析基盤の開発、運用をスケールさせるには、権限移譲を積極的にする必要があると考えています。そのうちの一つが Terraform の運用の効率化です。ただし、上記で示したとおり、 Terraform を運用するには強めの権限が必要です。そこで、Terraform の CI/CD を構築することで、権限を個人に持たせるのではなくサービスアカウントに権限を持たせることでスケールさせることができると考えました。

具体的には下記の仕様を検討しました。

  • GitHub 上でレビューする
  • main ブランチへの Pull Request で、 terraform plan が実行される
  • main ブランチへの Pull Request がマージされると、 terraform apply が実行される

これらを実現するために GitHub 上でやりたいことが完結できる GitHub Actions で Terraform の CI/CD を構築することにしました。

技術要素

GitHub Actions で GCP 上のリソースを操作するためには、通常サービスアカウントのキーを GitHub 上に登録する必要がありました。現在は GitHub が OIDC トークンを導入したことにより、GCP Workload Identity 連携を利用することで、サービスアカウントのキーなしで GCP に認証できるようになりました。これにより、サービスアカウントのキーを管理することから開放されます。

詳しい解説はこちらをご確認ください。

本記事では GitHub Actions + OIDC トークン + GCP Workload Identity 連携 を利用した CI/CD を構築します。

構築

GCP Workload Identity 連携

こちらのモジュールを参考に必要な部分を実装しました。

次に紹介するコードを実行すると、

  • サービスアカウントの作成
  • Workload Identity Pool の作成
  • Workload Identity Pool Provider の作成
  • GitHub リポジトリ → プロバイダ → サービスアカウントの紐付け

が行われます。

また、GCP Workload Identity を確認すると、プール、プロバイダが作成され、そしてサービスアカウントが紐付いていることが確認できます。

Untitled

※ 本番、開発、QA環境それぞれで作成

※ サービスアカウントへの権限設定は必要分だけ付与します

module

main.tf

# サービスアカウント
resource "google_service_account" "sa" {
  account_id   = var.service_account_name
  display_name = var.service_account_name
  description  = "service account for github actions"
}

# プール
resource "google_iam_workload_identity_pool" "main" {
  provider                  = google-beta
  project                   = var.project_id
  workload_identity_pool_id = var.pool_id
  description               = "workload identity pool for github actions"
  disabled                  = false
}

# プロバイダ
resource "google_iam_workload_identity_pool_provider" "main" {
  provider                           = google-beta
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.main.workload_identity_pool_id
  workload_identity_pool_provider_id = var.provider_id
  description                        = "workload identity pool provider for github actions"

  # https://cloud.google.com/iam/docs/configuring-workload-identity-federation
  # ID プロバイダの認証情報を外部 ID にマッピングする属性マッピングを定義
  # google.subject : ユーザーの一意の識別子。ロールバインディングで使用され、Cloud Logging のログに表示される
  # atribute. : 特定の属性を持つすべての ID にアクセス権を付与
  attribute_mapping                  = {
    "google.subject"       = "assertion.sub"        # リポジトリ名と Git リファレンス
    "attribute.actor"      = "assertion.actor"      # Github Actions を実行したユーザーアカウント
    "attribute.repository" = "assertion.repository" # オーナーとリポジトリ名
  }
  oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
}

# 借用を許可するリポジトリ定義。「your-organization/your-repository」で固定
data "google_iam_policy" "workload_identity_user" {
  binding {
    role = "roles/iam.workloadIdentityUser"
    members = ["principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.main.name}/attribute.repository/your-organization/your-repository"]
  }
}

# リポジトリとサービスアカウントの紐付け
resource "google_service_account_iam_policy" "binding_sa_and_wi" {
  service_account_id = google_service_account.sa.name
  policy_data        = data.google_iam_policy.workload_identity_user.policy_data
}

variables.tf

variable "service_account_name" {
  type = string
}

variable "project_id" {
  type = string
}

variable "pool_id" {
  type = string
}

variable "provider_id" {
  type = string
}

GitHub Actions

GitHub Actions で作成する機能は下記の2つです。

  • main ブランチへの Pull Request で、 terraform plan が実行される
  • main ブランチへの Pull Request がマージされると、 terraform apply が実行される

この時、誰でもマージができてしまうと、意図していないコードが実行されることになるため、 Branch protection rulesCODEOWNERS を定義し、未レビューのマージを防止します。

また、本番、開発、QA環境ごとに plan / apply を実行するためのコードが必要です。コアとなるコードは共通なので、 Reusing workflows を用いてコードの再利用を行います。

よって、作成するコードは

.github
├── CODEOWNERS
└── workflows
    ├── README.md
    ├── _tf_apply.yml
    ├── _tf_plan.yml
    ├── tf_apply_dev.yml 
    ├── tf_apply_prod.yml
    ├── tf_apply_qa.yml
    ├── tf_plan_dev.yml
    ├── tf_plan_prod.yml
    └── tf_plan_qa.yml

です。

実装にあたり、こちらのコードを参考にしました。

Branch protection rules と CODEOWNERS

Branch protection rules

Branch protection rules では、意図していないコードがマージされるのを防ぐことができます。

現在の実施している設定は

  • main ブランチへの直 Push 禁止
  • 最低1人の approve が必須
  • approve 後に新規コミットが Push されたときに approve の取り消し
  • コードオーナーのレビュー必須

です。

※ Settings > Branches > Branch protection rules から設定できます

Untitled

CODEOWNERS

* @your-organization/your-team

CODEOWNERS は上記のように書くことができます。

現在、コードオーナーには分析基盤チームを設定しており、Branch protection rules と組み合わせることで、分析基盤チームの approve なしに main ブランチにマージすることはできません。つまり、terraform apply コマンドが分析基盤チームの意図しないところで実行されることはありません。

_tf_plan.yml

この workflow は、Reusing workflows です。

  • tf_plan_prod.yml
  • tf_plan_dev.yml
  • tf_plan_qa.yml

から呼び出されます。

この workflow のポイントは下記です。

  • permissions
    • OIDC トークンを発行するために id-token: write を設定する
    • リポジトリのコンテンツを読むために、 contents: read を設定する
    • Pull Request のコメント欄に書き込むために、 pull-requests: write を設定する
  • OIDC トークンで認証するため、プロバイダとサービスアカウントを渡す(キーは使わない)
  • terraform plan の結果を Pull Request のコメント欄に書き込む
    • Pull Request のコメント欄は 65536 文字という制約があるので予め plan 結果を削っておく
    • 削り方はレビュワーが見る必要のない Refreshing state 行を grep コマンドで削除する
    • 時々、スクリプトで大量に生成された SQLスキーマファイルがコミットされることがある
    • この場合、Refreshing state 行を削除しても文字数オーバーになる場合がある
    • こういった時は、plan コマンドによる差分を見たいのではなく plan コマンドの成否が見たい
    • これらを考慮して tail コマンドで文字数を 65000 文字に収める
    • 残りの536文字は後続のステップで追加される文字数分の余力を持っておく
  • GitHub Actions が起動して終わるまでに1〜2分掛かるので、完了後に Slack に通知する

※ 65536文字の制限については、こちらの issue を参考にしました

※ パラメータは呼び出し側で解説

_tf_plan.yml

name: callee terraform plan workflow
on:
  workflow_call:
    inputs:
      SLACK_MESSAGE_TARGET_ENV:
        type: string
        required: true
      TF_VERSION:
        type: string
        required: true
      TF_WORK_DIR:
        type: string
        required: true
    secrets:
      SLACK_WEBHOOK:
        required: true
      WORKLOAD_IDENTITY_PROVIDER:
        required: true
      SERVICE_ACCOUNT:
        required: true

jobs:
  tf_plan:
    runs-on: ubuntu-18.04
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - name: authenticate to google cloud
        uses: google-github-actions/auth@v0.4.0
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: checkout
        uses: actions/checkout@v2.1.0

      - name: setup terraform
        uses: hashicorp/setup-terraform@v1.3.2
        with:
          terraform_version: ${{ inputs.TF_VERSION }}

      - name: terraform init
        id: init
        working-directory: ${{ inputs.TF_WORK_DIR }}
        run: |
          terraform init

      - name: terraform plan
        id: plan
        working-directory: ${{ inputs.TF_WORK_DIR }}
        run: |
          terraform plan -no-color
        continue-on-error: true

      # 1. PRのコメント欄に65536文字数制限がある
      # 2. github-script もしくは GitHub Actions Workflow 内にも文字数制限がある
      # よって、terraform plan/apply の結果を予め削る必要がある
      # 大量に差分が出た場合は差分を見るのではなく plan/apply の成否を見たい
      # これらを考慮して65000文字に制限する
      - 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: \`${{ inputs.TF_WORK_DIR }}\`, Workflow: \`${{ github.workflow }}\`*`;

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

      # workflow が成功したとき
      # terraform plan のステップで、continue-on-error: true としているので、
      # plan がエラーになってもここのステップを通る
      - name: notice completed workflow
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_MESSAGE: "[your-repository] [${{ inputs.SLACK_MESSAGE_TARGET_ENV }}] terraform plan (${{ steps.plan.outcome }})"

      # workflow が失敗したとき
      - name: notice failed workflow
        if: failure()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_COLOR: danger
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_MESSAGE: "[your-repository] [${{ inputs.SLACK_MESSAGE_TARGET_ENV }}] terraform plan (workflow failed)"

実際に書き込まれるコメント

Untitled

Show Plan をクリックすると、 terraform plan の結果が展開されます

Untitled

_tf_apply.yml

この workflow は、Reusing workflows です。

  • tf_apply_prod.yml
  • tf_apply_dev.yml
  • tf_apply_qa.yml

から呼び出されます。やっている内容は _tf_plan.yml とほとんど変わりません。

_tf_apply.yml

name: callee terraform apply workflow
on:
  workflow_call:
    inputs:
      SLACK_MESSAGE_TARGET_ENV:
        type: string
        required: true
      TF_VERSION:
        type: string
        required: true
      TF_WORK_DIR:
        type: string
        required: true
    secrets:
      SLACK_WEBHOOK:
        required: true
      WORKLOAD_IDENTITY_PROVIDER:
        required: true
      SERVICE_ACCOUNT:
        required: true

jobs:
  tf_apply:
    runs-on: ubuntu-18.04
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - name: authenticate to google clod
        uses: google-github-actions/auth@v0.4.0
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: checkout
        uses: actions/checkout@v2.1.0

      - name: setup terraform
        uses: hashicorp/setup-terraform@v1.3.2
        with:
          terraform_version: ${{ inputs.TF_VERSION }}

      - name: terraform init
        working-directory: ${{ inputs.TF_WORK_DIR }}
        run: |
          terraform init

      - name: terraform apply
        id: apply
        working-directory: ${{ inputs.TF_WORK_DIR }}
        run: |
          terraform apply -auto-approve -no-color
        continue-on-error: true

      # 1. PRのコメント欄に65536文字数制限がある
      # 2. github-script もしくは GitHub Actions Workflow 内にも文字数制限がある
      # よって、terraform plan/apply の結果を予め削る必要がある
      # 大量に差分が出た場合は差分を見るのではなく plan/apply の成否を見たい
      # これらを考慮して65000文字に制限する
      - name: truncate terraform apply result
        run: |
          apply=$(cat <<'EOF'
          ${{ format('{0}{1}', steps.apply.outputs.stdout, steps.apply.outputs.stderr) }}
          EOF
          )
          echo "APPLY<<EOF" >> $GITHUB_ENV
          echo "${apply}" | grep -v 'Refreshing state' | tail -c 65000 >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV

      - name: create comment from apply result
        uses: actions/github-script@0.9.0
        if: github.event_name == 'pull_request'
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Apply 🤖\`${{ steps.apply.outcome }}\`
            
            <details><summary>Show Apply</summary>
            
            \`\`\`\n
            ${ process.env.APPLY }
            \`\`\`
            
            </details>
            
            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Working Directory: \`${{ inputs.TF_WORK_DIR }}\`, Workflow: \`${{ github.workflow }}\`*`;

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

      # workflow が成功したとき
      # terraform apply のステップで、continue-on-error: true としているので、
      # apply がエラーになってもここのステップを通る
      - name: notice completed workflow
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_MESSAGE: "[your-repository] [${{ inputs.SLACK_MESSAGE_TARGET_ENV }}] terraform apply (${{ steps.apply.outcome }})"

      # workflow が失敗したとき
      - name: notice failed workflow
        if: failure()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_COLOR: danger
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_MESSAGE: "[your-repository] [${{ inputs.SLACK_MESSAGE_TARGET_ENV }}] terraform apply (workflow failed)"

tf_plan_qa.yml

この workflow から _tf_plan.yml が実行されます。

workflow の起動条件は下記の通りです。

  • main ブランチで
  • 該当パスの更新があり
  • Pull Request が作られた or 再 Push された

時に起動します。

デバッグ用に、

  • Push だけで起動する
  • main ではなく、作業ブランチの _tf_plan.yml を見る

というコードも残しています。

パラメータは

  • 呼び出し先の workflow
  • Slack 通知で使用する文字列
  • terraform のバージョン
  • terraform の作業ディレクト

Secret パラメータは

  • Slack Webhook
  • プロバイダ: (projects/xxxxxxxxxx/locations/global/workloadIdentityPools/your-pool/providers/your-provider)
  • サービスアカウント: your-serviceaccount@your-project.iam.gserviceaccount.com

です。

tf_plan_qa.yml

name: caller terraform plan workflow (qa)
on:
  pull_request:
    branches:
      - main
    paths:
      - 'your-terraform-dir/qa/**'
    types:
      - opened
      - synchronize

  # test用
  # push:
  #   branches:
  #     - github-actions

jobs:
  call_workflow:
    uses: your-organization/your-repository/.github/workflows/_tf_plan.yml@main
    # test用
    # uses: your-organization/your-repository/.github/workflows/_tf_plan.yml@github-actions
    with:
      SLACK_MESSAGE_TARGET_ENV: qa
      TF_VERSION: 1.1.5
      TF_WORK_DIR: your-terraform-dir/qa
    secrets:
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
      WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.QA_WORKLOAD_IDENTITY_PROVIDER }}
      SERVICE_ACCOUNT: ${{ secrets.QA_SERVICE_ACCOUNT }}

tf_apply_qa.yml

この workflow から _tf_apply.yml が実行されます。

workflow の起動条件は下記の通りです。

  • main ブランチで
  • 該当パスの更新があり
  • Pull Request がマージされた

時に起動します。

やっている内容は tf_plan_qa.yml とほとんど変わりません。

tf_apply_qa.yml

name: caller terraform apply workflow (qa)
on:
  pull_request:
    branches:
      - main
    paths:
      - 'your-terraform-dir/qa/**'
    types:
      - closed

  # test用
  # push:
  #   branches:
  #     - github-actions

jobs:
  call_workflow:
    uses: your-orgnization/your-repository/.github/workflows/_tf_apply.yml@main
    if: github.event.pull_request.merged == true
    # test用
    # uses: your-organization/your-repository/.github/workflows/_tf_apply.yml@github-actions
    with:
      SLACK_MESSAGE_TARGET_ENV: qa
      TF_VERSION: 1.1.5
      TF_WORK_DIR: your-terraform-dir/qa
    secrets:
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
      WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.QA_WORKLOAD_IDENTITY_PROVIDER }}
      SERVICE_ACCOUNT: ${{ secrets.QA_SERVICE_ACCOUNT }}

運用

注意事項

運用するにあたり注意事項としていくつか考えていることがあります。

  • 必ずしも GitHub Actions による terraform 反映が必須ではありません
    • 運用負荷が軽減されるのが主目的です(業務委託さんへの権限移譲 / 別チームの PR を受けるなど)
  • GitHub Actions を通さずにローカル反映するものとしては次のようなものを想定しています
    • Github Actions のエラー等で tfstate ファイルに不整合が発生したとき
    • 緊急対応で PR のレビューを待っていられないとき
    • 検証目的で terraform を先行反映させるとき(特に dev / qa 環境)

運用負荷を軽減することが主目的なので、CI/CD で Terraform を動かすことにこだわる必要はないと考えています。その上で、やっぱりルールを決めたほうが良いとなればアップデートしていきたいと思います。

実際に運用してみて

実際に CI/CD を導入してからは、

  • 業務委託さんに仕事を振れることで別のタスクに対応できるようになった
  • terraform plan の結果が Pull Request のコメントに書き込まれるのが地味に便利だった
  • Pull Request のマージ = terraform apply となったことで、
    • マージから反映までのラグがなくなった
    • GitHub 上のコードと実態の乖離がなくなった

のがチームとして良い体験になっていると思います。

※ 別チームから Pull Request を受ける運用はこれから整備していく予定です

おわりに

本記事では、Terraform の CI/CD 構築について説明しました。まず、現行の分析基盤と Terraform 管理の課題を取り上げ、その課題に対してどう対応するべきか紹介させて頂きました。その上で、CI/CD の導入によって課題を解決し、さらに運用はどうなったのか説明させて頂きました。

分析基盤の新規開発だけでなく、今回の記事のように、どうやって開発、運用をスケールさせるかもセットで今後も考えていきたいと思います。