まともなTerraform環境構築に向けたあれこれ：バックエンドGCS、Workload Identity直接アクセス、tfactionによるCI/CD

# はじめに

以前、初心者ながらTerraformを使ってみた記事（[「TROCCO®のTerraform Provider（β版）ができたので最速で触ってみる」](https://qiita.com/SoySoySoyB/items/c07ecf7bf199d5730bf2)）を書きました。このときはローカルでの実行をベースにしていたのですが、これほど便利で浸透してきたツールはきちんと理解しておかないといけないだろうということで、もう少しまともな環境構築を模索してみます。

なお、一度に色々詰め込みながら試行錯誤したので、手順が整理仕切れていないのはご容赦ください。。。

:::note warn
まだTerraformやCI/CD初心者なので、もっとこうした方がいいという点があればご指摘いただけると嬉しいです！セキュリティ周りはしっかりできるようになりたいです。
:::


# 前提

TerraformはIaC（Infrastructure as Code）のツールで、これを利用するとインフラ環境を宣言的に定義して構築することができます。

TerraformやIaCに詳しくない方は、Software Designの記事を転載しているCaddiのシリーズ記事が非常に参考になるので、読んでみてください。

https://caddi.tech/archives/4294

また、CI/CDとは何かを基礎から抑えたい方は、以下の書籍がCI/CDの概念理解も含めておすすめです。

https://gihyo.jp/book/2024/978-4-297-14173-8

さて、Terraformを利用する上では、現在管理されているリソース情報を保持するstateファイルをいかに齟齬なく管理するかが重要になります。今回は、

- ファイルの保管場所をローカルではなくGoogle Cloud Storageに指定する
- tfactionを利用して、下記を行う
    - Pull Request作成時にGitHub Actionsで`terraform plan`の結果をコメントする
    - mainブランチへのマージ時に、GitHub Actionsで`terraform apply`を実行し適用する
    - GitHub Actionsの実行にあたり、Google Cloudとの認証をWorkload Identityの直接アクセスで行う

までを実装します。


# tfactionとは何か

tfactionとは、TROCCO®︎のインフラでも利用している、TerraformのCI/CDを実装するためのGitHub Actionsのツールです。

GitHubでPRを作成するとPlan結果がコメントに記載され、マージをすることでApplyされるなど、コード管理をしつつTerraformの運用を堅牢にしてくれます。

作成者の記事として以下のようなものがあります。

https://zenn.dev/shunsuke_suzuki/articles/tfaction-setup

https://suzuki-shunsuke.github.io/tfaction/docs/

https://github.com/suzuki-shunsuke/tfaction-example/blob/main/.github/workflows/apply.yaml

また利用者の記事としては以下のようなものがあります。

https://tech.gunosy.io/entry/tfaction_ci_cd

https://zenn.dev/luup_developers/articles/sre-nakanishi-20221218

https://blog.studysapuri.jp/entry/2022/02/04/080000

TROCCO®での利用記事としては以下のものがあります。

https://zenn.dev/primenumber/articles/096db41ddfe039

自分がやりたいことど真ん中の記事が出てこなかったので、今回は上の記事などを参考にしつつ、具体の設定を進めていきます。


# TerraformのバックエンドをGCSに設定する

ローカル環境で`terraform init`を実行すると、stateファイルはローカル環境に保持されます。まずは、ローカル環境でバックエンド用のGCSバケットを作成し、バックエンドをGCSに移行していきます。

手順は以下の公式ドキュメントをベースに、少し調整して進めていきます。

https://cloud.google.com/docs/terraform/resource-management/store-state?hl=ja

具体的には、以下の通りです。

- CLIでログインする（いずれWorkload Identityに移行するので、ここではユーザー認証でも構わない）
- Project IDを指定する
- Cloud Storage APIを有効化する

この状態で、Providerの設定とCloud Storageの設定を作成します。

```terraform:./google/provider.tf
terraform {
  required_providers {
    google = {
      source = "hashicorp/google"
    }
  }
}

provider "google" {
  project = "{YOUR_PROJECT}"
  region = "asia-northeast1"
}

# 通常のGCSと区別するために、_backendで切り分けた
module "_backend" {
  source = "./modules/_backend"
}
```

```terraform:./google/modules/_backend/gcs.tf
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
  }
}
```

バケットは東京＋大阪のデュアルリージョンとして、バージョン管理の設定を付与しておきます。

https://cloud.google.com/storage/docs/locations?hl=ja

この状態で`terraform init` > `terraform apply` > `yes`と実行していくと、バックエンド用のGCSが作成されます。このとき、stateファイルはローカルに生成されます。

次に、バックエンドの設定を追記して、stateファイルをGCSに移行します。

```terraform:./google/provider.tf
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`として保存されるようにしておきます。

https://developer.hashicorp.com/terraform/language/backend/gcs#prefix

移行時には再度`init`が必要ですが、確か`terraform init -migrate-state`だったはず・・・（バックエンドの設定が変わっているので、普通に`terraform init`すると怒られます）

ちなみに、stateの競合を防ぐためにこれまではS3ではDynamoDBを併用する必要があったのですが（ただし近いうちになくなるらしい）、GCSではデフォルトで利用できます。

https://zenn.dev/terraform_jp/articles/terraform-s3-state-lock

https://developer.hashicorp.com/terraform/language/backend/gcs

【参考】

https://qiita.com/kawakawaryuryu/items/58d8afbb21155c2e9572


# Google CloudでWorkload Identityを設定する

次に、GitHubでTerraformを実行する際に、GCSのstateファイルを参照するためのWorkload Identityを設定します。ユーザー認証では権限が大きすぎる、サービスアカウントでは鍵の管理が懸念になるのに対して、Workload Identityは実行された場所ベースで認証をかけます。

つまり、GitHubから、どのオーナーのどのリポジトリで、さらにはどのブランチで、などの条件で実行されたときに、その実行に対して限定された権限で認証を与えることができます。そのときシークレット管理をせずにセキュアなアクセスを担保することができます。

Googleでは、このWorkload Identityという仕組みを使って、かつサービスアカウントの権限借用ではなく直接アクセスで設定することを推奨としています。仕組みが分からない人は、下記ドキュメントをはじめ、関連ドキュメントを一通り読んでみてください。

https://cloud.google.com/iam/docs/workload-identity-federation?hl=ja

以下の記事は、サービスアカウントではあるものの、手順の確認として参考になりました。

https://zenn.dev/cloud_ace/articles/7fe428ac4f25c8

https://qiita.com/ysmmb12/items/77de059cfcc07c28ac56

また、いきなりCLIで進めるのは自分の理解度的に難しく、コンソールで設定することになりましたが、以下の記事が直接アクセスの設定方法をまとめていました。

https://paper2.hatenablog.com/entry/2024/06/29/143947

では具体的な手順を確認していきます。以下の公式ドキュメントに沿って進めていきました。

https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines?hl=ja

まず、「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`
- 属性条件
    - 条件CEL: `assertion.repository=="{YOUR_GITHUB_ID}/{YOUR_REPOSITORY_NAME}" && assertion.repository_id=="{YOUR_REPOSITORY_ID}"`

なお属性条件はIDのみでいいはずですが、後で見てよくわからなくなりそうだったので2つ入れました。IDを設定している背景は下記の通りであり、IDを調べたいときにはGitHubの該当のレポジトリでHTMLソースを見ると簡単に確認することができます。

> 重要: repository や repository_owner などの名前フィールドを使用すると、サイバースクワッティングやタイポスクワッティングなどの攻撃を受けるリスクが高くなります。GitHub リポジトリまたは GitHub 組織を削除すると、誰かが同じ名前で ID を確立できる可能性があります。この状況を防ぐには、代わりに数値 *_id フィールドを使用してください。これは一意であり、再利用できません。
（出典：[公式ドキュメント](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines?hl=ja#mappings-and-conditions)）

これで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と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を作成します。作成にあたっては以下の公式ドキュメントを参考にしてください。

https://docs.github.com/ja/apps/creating-github-apps/registering-a-github-app/registering-a-github-app

作成画面はどこにあるのかという感じでしたが、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`として登録します。

【参考】

https://zenn.dev/suzutan/articles/how-to-use-github-apps-token-in-github-actions


# tfactionを設定する

ここまでで事前準備は完了です。最後にtfactionを設定します。このとき、

- GitHubのtoken生成を公式のGitHub Actionsに切り替える
- Workload Identityを利用できるようにする

ための設定が調整しないといけないポイントになります。

設定ファイルは以下の通りです。

```yaml:./tfaction-root.yaml
plan_workflow_name: terraform-plan
target_groups:
  - working_directory: google
    target: google
```

```yaml:./aqua.yaml
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
```

```yaml:./.gitignore
# terraform
terraform.tfvars
terraform.tfstate
terraform.tfstate.backup
.terraform
```

```terraform:./google/tfaction.yaml
{}
```

```yaml:./.github/workflows/plan.yaml
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 }}
```

```yaml:./.github/workflows/apply.yaml
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や変数の使い方もまだまだ奥が深いですね・・・

https://tech.guitarrapc.com/entry/2021/05/17/012156

https://zenn.dev/ikedam/articles/7b0d826cbef5cc