タクシーアプリ「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 で管理しています。これらのリソースのうち、1回構築するとほとんど変更がないものもあれば、新規の分析要件ごとに追加、修正が必要なリソースがあります。
主にBIの文脈が後者で、
のリソースを管理する Terraform の CI/CD を今回構築しました。
尚、GCP は本番、開発、QA環境を持っており、Terraform も本番、開発、QA環境の3つの Terraform を管理しています。
また、Terraform および Provider のバージョンは下記の通りです。
「業務委託さんや別チームが、分析基盤チームが管理している Terraform に触りづらい (Pull Request を出しづらい)」
terraform plan を実施するには Terraform が管理しているリソースの参照権限が、 terraform apply を実施するには、(BigQuery管理者権限など)強い権限を持つ必要があります。結果として、業務委託さんに Terraform の作業を割り振ることが難しかったり、簡単な修正で済むのに別チームから Pull Request を受けられず、分析基盤チームが対応依頼を受けて作業が完了するまで、データを利用したい人は待つ必要がありました。
例として、分析プロジェクトからソースプロジェクトへの View 設置および Authorized View の設定がそれにあたります。
(分析要件が出てくるごとに対応が必要なのでしばしば運用が発生する)
分析基盤の開発、運用をスケールさせるには、権限移譲を積極的にする必要があると考えています。そのうちの一つが Terraform の運用の効率化です。ただし、上記で示したとおり、 Terraform を運用するには強めの権限が必要です。そこで、Terraform の CI/CD を構築することで、権限を個人に持たせるのではなくサービスアカウントに権限を持たせることでスケールさせることができると考えました。
具体的には下記の仕様を検討しました。
これらを実現するために 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 を確認すると、プール、プロバイダが作成され、そしてサービスアカウントが紐付いていることが確認できます。
※ 本番、開発、QA環境それぞれで作成
※ サービスアカウントへの権限設定は必要分だけ付与します
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 で作成する機能は下記の2つです。
この時、誰でもマージができてしまうと、意図していないコードが実行されることになるため、 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
Branch protection rules では、意図していないコードがマージされるのを防ぐことができます。
現在の実施している設定は
です。
※ Settings > Branches > Branch protection rules から設定できます
CODEOWNERS
* @your-organization/your-team
CODEOWNERS は上記のように書くことができます。
現在、コードオーナーには分析基盤チームを設定しており、Branch protection rules と組み合わせることで、分析基盤チームの approve なしに main ブランチにマージすることはできません。つまり、terraform apply コマンドが分析基盤チームの意図しないところで実行されることはありません。
この workflow は、Reusing workflows です。
から呼び出されます。
この workflow のポイントは下記です。
※ 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 の結果が展開されます
この workflow は、Reusing workflows です。
から呼び出されます。やっている内容は _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)"
この workflow から _tf_plan.yml が実行されます。
workflow の起動条件は下記の通りです。
時に起動します。
デバッグ用に、
というコードも残しています。
パラメータは
Secret パラメータは
です。
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 }}
この workflow から _tf_apply.yml が実行されます。
workflow の起動条件は下記の通りです。
時に起動します。
やっている内容は 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 }}
運用するにあたり注意事項としていくつか考えていることがあります。
運用負荷を軽減することが主目的なので、CI/CD で Terraform を動かすことにこだわる必要はないと考えています。その上で、やっぱりルールを決めたほうが良いとなればアップデートしていきたいと思います。
実際に CI/CD を導入してからは、
のがチームとして良い体験になっていると思います。
※ 別チームから Pull Request を受ける運用はこれから整備していく予定です
本記事では、Terraform の CI/CD 構築について説明しました。まず、現行の分析基盤と Terraform 管理の課題を取り上げ、その課題に対してどう対応するべきか紹介させて頂きました。その上で、CI/CD の導入によって課題を解決し、さらに運用はどうなったのか説明させて頂きました。
分析基盤の新規開発だけでなく、今回の記事のように、どうやって開発、運用をスケールさせるかもセットで今後も考えていきたいと思います。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!