タクシーアプリ「GO」、法人向けサービス「GO BUSINESS」、タクシーデリバリーアプリ「GO Dine」の分析基盤を開発運用している伊田です。GitHub Actions から OIDC トークンを利用し、サービスアカウントキーなしで GCP に認証した上で Terraform の CI/CD を構築する方法を紹介します。
※ 対象読者は分析基盤を管理しているデータエンジニア、または Terraform を管理しているエンジニアです
はじめに
本記事では、なぜ Terraform の CI/CD を構築したのか、まず分析基盤について簡単に説明し、次に分析基盤のうち Terraform で何を管理しているのか説明します。その上で現状抱えていた課題とそれに対する対応案を説明させて頂き、実際に構築に使用した技術要素やコード、運用にあたって気をつけたことについて紹介させて頂きます。
分析基盤について
上記はBigQueryへのデータ連携とBIツールであるLookerが参照するまでの流れを簡単に記した図です。
データ連携のパイプラインは大まかに
上記の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 を確認すると、プール、プロバイダが作成され、そしてサービスアカウントが紐付いていることが確認できます。
※ 本番、開発、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 rules と CODEOWNERS を定義し、未レビューのマージを防止します。
また、本番、開発、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 から設定できます
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 トークンで認証するため、プロバイダとサービスアカウントを渡す(キーは使わない)
- 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)"
実際に書き込まれるコメント
Show Plan をクリックすると、 terraform plan の結果が展開されます
_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 の導入によって課題を解決し、さらに運用はどうなったのか説明させて頂きました。
分析基盤の新規開発だけでなく、今回の記事のように、どうやって開発、運用をスケールさせるかもセットで今後も考えていきたいと思います。