はじめに
以前、初心者ながらTerraformを使ってみた記事(「TROCCO®のTerraform Provider(β版)ができたので最速で触ってみる」)を書きました。このときはローカルでの実行をベースにしていたのですが、これほど便利で浸透してきたツールはきちんと理解しておかないといけないだろうということで、もう少しまともな環境構築を模索してみます。
なお、一度に色々詰め込みながら試行錯誤したので、手順が整理仕切れていないのはご容赦ください。。。
まだTerraformやCI/CD初心者なので、もっとこうした方がいいという点があればご指摘いただけると嬉しいです!セキュリティ周りはしっかりできるようになりたいです。
前提
TerraformはIaC(Infrastructure as Code)のツールで、これを利用するとインフラ環境を宣言的に定義して構築することができます。
TerraformやIaCに詳しくない方は、Software Designの記事を転載しているCaddiのシリーズ記事が非常に参考になるので、読んでみてください。
また、CI/CDとは何かを基礎から抑えたい方は、以下の書籍がCI/CDの概念理解も含めておすすめです。
さて、Terraformを利用する上では、現在管理されているリソース情報を保持するstateファイルをいかに齟齬なく管理するかが重要になります。今回は、
- ファイルの保管場所をローカルではなくGoogle Cloud Storageに指定する
- tfactionを利用して、下記を行う
- Pull Request作成時にGitHub Actionsで
terraform plan
の結果をコメントする - mainブランチへのマージ時に、GitHub Actionsで
terraform apply
を実行し適用する - GitHub Actionsの実行にあたり、Google Cloudとの認証をWorkload Identityの直接アクセスで行う
- Pull Request作成時にGitHub Actionsで
までを実装します。
tfactionとは何か
tfactionとは、TROCCO®︎のインフラでも利用している、TerraformのCI/CDを実装するためのGitHub Actionsのツールです。
GitHubでPRを作成するとPlan結果がコメントに記載され、マージをすることでApplyされるなど、コード管理をしつつTerraformの運用を堅牢にしてくれます。
作成者の記事として以下のようなものがあります。
また利用者の記事としては以下のようなものがあります。
TROCCO®での利用記事としては以下のものがあります。
自分がやりたいことど真ん中の記事が出てこなかったので、今回は上の記事などを参考にしつつ、具体の設定を進めていきます。
TerraformのバックエンドをGCSに設定する
ローカル環境でterraform init
を実行すると、stateファイルはローカル環境に保持されます。まずは、ローカル環境でバックエンド用のGCSバケットを作成し、バックエンドをGCSに移行していきます。
手順は以下の公式ドキュメントをベースに、少し調整して進めていきます。
具体的には、以下の通りです。
- CLIでログインする(いずれWorkload Identityに移行するので、ここではユーザー認証でも構わない)
- Project IDを指定する
- Cloud Storage APIを有効化する
この状態で、Providerの設定とCloud Storageの設定を作成します。
terraform {
required_providers {
google = {
source = "hashicorp/google"
}
}
}
provider "google" {
project = "{YOUR_PROJECT}"
region = "asia-northeast1"
}
# 通常のGCSと区別するために、_backendで切り分けた
module "_backend" {
source = "./modules/_backend"
}
resource "google_storage_bucket" "terraform-backend" {
name = "{YOUR_BACKET_NAME}"
project = "{YOUR_PROJECT}"
location = "ASIA1"
force_destroy = false
public_access_prevention = "enforced"
uniform_bucket_level_access = true
versioning {
enabled = true
}
}
バケットは東京+大阪のデュアルリージョンとして、バージョン管理の設定を付与しておきます。
この状態でterraform init
> terraform apply
> yes
と実行していくと、バックエンド用のGCSが作成されます。このとき、stateファイルはローカルに生成されます。
次に、バックエンドの設定を追記して、stateファイルをGCSに移行します。
terraform {
required_providers {
google = {
source = "hashicorp/google"
}
}
backend "gcs" {
bucket = "{YOUR_BACKET_NAME}"
prefix = "google"
}
}
provider "google" {
project = "{YOUR_PROJECT}"
region = "asia-northeast1"
}
# 通常のGCSと区別するために、_backendで指定した
module "_backend" {
source = "./modules/_backend"
}
このときprefix
という変数について、公式ドキュメントでは<prefix>/<name>.tfstate
とあるのでファイル名を指定できるのかと思ったのですが、<name>
にはworkspaceの名称が反映されるとのことでした。そこで、workspaceを設定していない状態ではdefault.tfstate
というファイル名になります。
個人的にはファイル名を調整したい気持ちになったのですが、そのためにworkspaceを活用するのも変な話なので、prefix
をgoogle
として、{YOUR_BACKET_NAME}/google/default.tsftate
として保存されるようにしておきます。
移行時には再度init
が必要ですが、確かterraform init -migrate-state
だったはず・・・(バックエンドの設定が変わっているので、普通にterraform init
すると怒られます)
ちなみに、stateの競合を防ぐためにこれまではS3ではDynamoDBを併用する必要があったのですが(ただし近いうちになくなるらしい)、GCSではデフォルトで利用できます。
【参考】
Google CloudでWorkload Identityを設定する
次に、GitHubでTerraformを実行する際に、GCSのstateファイルを参照するためのWorkload Identityを設定します。ユーザー認証では権限が大きすぎる、サービスアカウントでは鍵の管理が懸念になるのに対して、Workload Identityは実行された場所ベースで認証をかけます。
つまり、GitHubから、どのオーナーのどのリポジトリで、さらにはどのブランチで、などの条件で実行されたときに、その実行に対して限定された権限で認証を与えることができます。そのときシークレット管理をせずにセキュアなアクセスを担保することができます。
Googleでは、このWorkload Identityという仕組みを使って、かつサービスアカウントの権限借用ではなく直接アクセスで設定することを推奨としています。仕組みが分からない人は、下記ドキュメントをはじめ、関連ドキュメントを一通り読んでみてください。
以下の記事は、サービスアカウントではあるものの、手順の確認として参考になりました。
また、いきなりCLIで進めるのは自分の理解度的に難しく、コンソールで設定することになりましたが、以下の記事が直接アクセスの設定方法をまとめていました。
では具体的な手順を確認していきます。以下の公式ドキュメントに沿って進めていきました。
まず、「Workload Identity プールとプロバイダの管理に専用のプロジェクトを使用することをおすすめします」とのことなので、Workload Identity用のプロジェクトを作成します。
Workload Identity連携でプールを作成し、プロバイダを設定します。このとき、入力項目の一部は以下の通りです。このあたりは公式ドキュメントを見てもいまいちピンと来ず、苦労しました。
- 発行元:
https://token.actions.githubusercontent.com/
- 属性のマッピング
- 1:
google.subject
,assertion.sub
- 2:
attribute.repository_id
,assertion.repository_id
- 3:
attribute.repository
,assertion.repository
- 1:
- 属性条件
- 条件CEL:
assertion.repository=="{YOUR_GITHUB_ID}/{YOUR_REPOSITORY_NAME}" && assertion.repository_id=="{YOUR_REPOSITORY_ID}"
- 条件CEL:
なお属性条件はIDのみでいいはずですが、後で見てよくわからなくなりそうだったので2つ入れました。IDを設定している背景は下記の通りであり、IDを調べたいときにはGitHubの該当のレポジトリでHTMLソースを見ると簡単に確認することができます。
重要: repository や repository_owner などの名前フィールドを使用すると、サイバースクワッティングやタイポスクワッティングなどの攻撃を受けるリスクが高くなります。GitHub リポジトリまたは GitHub 組織を削除すると、誰かが同じ名前で ID を確立できる可能性があります。この状況を防ぐには、代わりに数値 *_id フィールドを使用してください。これは一意であり、再利用できません。
(出典:公式ドキュメント)
これでWorkload Identityの設定ができたので、ここからGCSにアクセスするための権限を付与します。この権限付与の際の書き方がなかなかわからずだったのですが、下記2通りの書き方ができるようです。
- プールに付与する
PrincipalsSet://iam.googleapis.com/projects/{YOUR_PROJECT_NUMBER}/locations/global/workloadIdentityPools/{YOUR_POOL_ID}/attribute.repository_id/{YOUR_REPOSITORY_ID}
- 実行条件で分ける
- Plan用:
principal://iam.googleapis.com/projects/{YOUR_PROJECT_NUMBER}/locations/global/workloadIdentityPools/{YOUR_POOL_ID}/subject/repo:{YOUR_OWNER_NAME}/{YOUR_REPOSITORY_NAME}:pull_request
- Apply用:
principal://iam.googleapis.com/projects/{YOUR_PROJECT_NUMBER}/locations/global/workloadIdentityPools/{YOUR_POOL_ID}/attribute.repository/repo:{YOUR_OWNER_NAME}/{YOUR_REPOSITORY_NAME}:ref:refs/heads/main
- Plan用:
前者の設定方法が楽ではありますが、後者はPlanとApplyで権限を使い分けることができるので、おそらく後者の使い方の方がいいのではと考えています。
なお、GitHub Actions側の設定には、Workload Identityのプロバイダの編集画面にある「オーディエンス」に記載されている値を利用することになります。デフォルトのオーディエンスを利用する場合は、以下のようなものになっています。
https://iam.googleapis.com/projects/{YOUR_PROJECT_NUMBER}/locations/global/workloadIdentityPools/{YOUR_POOL_ID}/providers/{YOUR_PROVIDER_ID}
このうち、projects
以下を利用することになります。
GitHub Appsを作成する
続いて、実行にはGitHub Appsを利用するようなので、GitHub Appsを作成します。作成にあたっては以下の公式ドキュメントを参考にしてください。
作成画面はどこにあるのかという感じでしたが、Settings > 最下部のDeveloper SettingsからNew GitHub Appで新規作成ができます。私は個人環境で試しているので、非公開にして個人環境のみで利用しています。
設定内容として必要事項を埋めた後、権限はRepository permissionsとして以下の通りで設定します。
- Actions: Read-only
- Contents: Read and write
- Metadata: Read-only
- Pull Requests: Read and write
Planの際にはContentsはRead-onlyでいいのではと思っていましたが、初期実行時にhclファイルが追記されたのに対して書き込み権限がなくエラーになったためご注意ください。
GitHub Appが作成できたら対象のリポジトリにインストールし、APP_IDとPRIVATE_KEYを対象リポジトリのSettings > Secrets and variables > ActionsでTERRAFORM_APP_ID
, TERRAFORM_APP_PRIVATE_KEY
として登録します。
【参考】
tfactionを設定する
ここまでで事前準備は完了です。最後にtfactionを設定します。このとき、
- GitHubのtoken生成を公式のGitHub Actionsに切り替える
- Workload Identityを利用できるようにする
ための設定が調整しないといけないポイントになります。
設定ファイルは以下の通りです。
plan_workflow_name: terraform-plan
target_groups:
- working_directory: google
target: google
registries:
- type: standard
ref: v4.246.0 # renovate: depName=aquaproj/aqua-registry
packages: # おそらく不要なものがあるが、何が最低限なのか確かめる時間がなく
- name: cli/cli@v2.60.1
- name: int128/ghcp@v1.13.4
- name: minamijoyo/tfmigrate@v0.3.24
- name: reviewdog/reviewdog@v0.20.2
- name: suzuki-shunsuke/ci-info@v2.3.1
- name: suzuki-shunsuke/github-comment@v6.3.0
- name: suzuki-shunsuke/tfcmt@v4.14.0
- name: terraform-docs/terraform-docs@v0.19.0
- name: rhysd/actionlint@v1.7.3
- name: koalaman/shellcheck@v0.10.0
- name: open-policy-agent/conftest@v0.56.0
- name: terraform-linters/tflint@v0.53.0
- name: aquasecurity/trivy@v0.57.0
- name: hashicorp/terraform-config-inspect
version: "a34142ec2a72dd916592afd3247dd354f1cc7e5c" # https://github.com/hashicorp/terraform-config-inspect/commit/a34142ec2a72dd916592afd3247dd354f1cc7e5c
# terraform
terraform.tfvars
terraform.tfstate
terraform.tfstate.backup
.terraform
{}
name: terraform-plan
on:
pull_request:
branches:
- main
permissions:
id-token: write
contents: read # 実行内容によってはwriteの必要がある
pull-requests: write
jobs:
# setup で変更のあった作業ディレクトリを取得する
setup:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.list-targets.outputs.targets }}
steps:
- uses: actions/checkout@v4.2.2
# aqua を使って必要なツールをインストールする
- uses: aquaproj/aqua-installer@v3.0.1
with:
aqua_version: v2.37.0
# 変更のあった作業ディレクトリを取得する
- uses: suzuki-shunsuke/tfaction/list-targets@v1.11.2
id: list-targets
# plan で setup で取得した変更のある作業ディレクトリを並列に実行する
plan:
name: "terraform plan (${{ matrix.target.target }})"
runs-on: ${{ matrix.target.runs_on }}
needs: setup
# setup で取得した変更のある作業ディレクトリが空の場合は実行しない
if: join(fromJSON(needs.setup.outputs.targets), '') != ''
strategy:
fail-fast: false
matrix:
target: ${{ fromJSON(needs.setup.outputs.targets) }}
concurrency: ${{matrix.target.target}}
env:
TFACTION_TARGET: ${{ matrix.target.target }}
TFACTION_JOB_TYPE: terraform
steps:
- uses: actions/checkout@v4.2.2
- uses: aquaproj/aqua-installer@v3.0.1
with:
aqua_version: v2.37.0
- id: github_app_token # ref: https://zenn.dev/momosuke/articles/test-create-github-app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.TERRAFORM_APP_ID }}
private-key: ${{ secrets.TERRAFORM_APP_PRIVATE_KEY }}
- id: google_cloud_workload_identity_auth
uses: google-github-actions/auth@v2
with:
project_id: {YOUR_PROJECT_ID}
workload_identity_provider: projects/{YOUR_PROJECT_NUMBER}/locations/global/workloadIdentityPools/{YOUR_POOL_ID}/providers/{YOUR_PROVIDER_ID}
# terraform init などの準備を行う Action
- uses: suzuki-shunsuke/tfaction/setup@v1.11.2
with:
github_token: ${{ steps.github_app_token.outputs.token }}
# terraform plan を実行する Action
- uses: suzuki-shunsuke/tfaction/plan@v1.11.2
with:
github_token: ${{ steps.github_app_token.outputs.token }}
name: terraform-apply
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
pull-requests: write
actions: read # artifact を取得するために必要
jobs:
# plan.yaml と同様に setup で変更のあった作業ディレクトリを取得し、outputs で出力する
setup:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.list-targets.outputs.targets }}
steps:
- uses: actions/checkout@v4.2.2
# aqua を使って必要なツールをインストールする
- uses: aquaproj/aqua-installer@v3.0.1
with:
aqua_version: v2.37.0
# 変更のあった作業ディレクトリを取得する
- uses: suzuki-shunsuke/tfaction/list-targets@v1.11.2
id: list-targets
# apply で setup で取得した変更のある作業ディレクトリを並列に実行する
apply:
name: "terraform apply (${{ matrix.target.target }})"
runs-on: ${{ matrix.target.runs_on }}
needs: setup
# setup で取得した変更のある作業ディレクトリが空の場合は実行しない
if: join(fromJSON(needs.setup.outputs.targets), '') != ''
strategy:
fail-fast: false
matrix:
target: ${{ fromJSON(needs.setup.outputs.targets) }}
env:
TFACTION_IS_APPLY: "true" # apply する場合は TFACTION_IS_APPLY を "true" に指定
TFACTION_TARGET: ${{ matrix.target.target }}
TFACTION_WORKING_DIR: ${{ matrix.target.working_directory }}
TFACTION_JOB_TYPE: ${{ matrix.target.job_type }}
steps:
- uses: actions/checkout@v4.2.2
- uses: aquaproj/aqua-installer@v3.0.1
with:
aqua_version: v2.37.0
- id: github_app_token # ref: https://zenn.dev/momosuke/articles/test-create-github-app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.TERRAFORM_APP_ID }}
private-key: ${{ secrets.TERRAFORM_APP_PRIVATE_KEY }}
- id: google_cloud_workload_identity_auth
uses: google-github-actions/auth@v2
with:
project_id: {YOUR_PROJECT_ID}
workload_identity_provider: projects/{YOUR_PROJECT_NUMBER}/locations/global/workloadIdentityPools/{YOUR_POOL_ID}/providers/{YOUR_PROVIDER_ID}
# terraform init などの準備を行う Action
- uses: suzuki-shunsuke/tfaction/setup@v1.11.2
with:
github_token: ${{ steps.github_app_token.outputs.token }}
# terraform apply を実行する Action
- uses: suzuki-shunsuke/tfaction/apply@v1.11.2
with:
github_token: ${{ steps.github_app_token.outputs.token }}
実行する
ここまでの設定をした上で、ブランチを切ってプルリクエストを作成すると、PlanのGitHub Actionsが実行されてプルリクエストにPlan結果がコメントで付与されるようになります。変更がある状態でmainブランチにマージすると、Planで発生していた差分についてApplyがされていきます。
さいごに
色々詰め込みすぎましたが、自分で進めてみてハマったところを書き出してみました。tfactionにはパッケージの更新やtfdocsの利用などの機能もあるようですが、そこまでは時間が足らずだったので追って試してみようと思います。
おまけ
Terraformで迷いがちなディレクトリ構成について、参考になった記事を記載しておきます。moduleや変数の使い方もまだまだ奥が深いですね・・・