Terraform Bootstrap 層設計: WIF、Artifact Registry 與 GitHub Actions CI/CD

把 GitHub Actions 接到 GCP 推 Docker image, 最常見的做法是建一個 Service Account, 下載 key JSON, 存進 GitHub Secrets。這個方法能跑, 但 key 是長期憑證, 洩漏就完了。

這篇整理我用 Terraform 建出 bootstrap 層的過程: WIF 設定、Artifact Registry、Service Account 最小權限, 以及 GitHub Actions workflow 怎麼把這些串起來, 完全不需要任何 SA key。我實際使用 OpenTofu (Terraform 的開源分支), 語法與 Terraform 完全相容。

Bootstrap 層 vs Environment 層

Terraform 結構常見的分法是把資源按「建立頻率和共用程度」分層。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
terraform/
├── bootstrap/          # one-time setup, shared across environments
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── backend.tf
├── environments/
│   └── dev/            # environment-specific resources
└── modules/
    └── vpc/

bootstrap 放什麼: 只建一次、很少改、整個 project 共用的資源。WIF pool 是 per-project 的, Artifact Registry 各環境共用同一個, Service Account 由 CI 統一使用。這些都適合放在 bootstrap。

environments 放什麼: VPC、Subnet、GKE cluster, 這些會隨 dev/prod 不同而有差異。

bootstrap 的一個特殊性: 它自己的 state 存在哪裡? 如果 GCS bucket 已經存在, 直接用同一個 bucket 不同 prefix 即可。

1
2
3
4
5
6
7
# bootstrap/backend.tf
terraform {
  backend "gcs" {
    bucket = "<project-id>-tfstate"
    prefix = "bootstrap"
  }
}

WIF 的信任鏈

WIF 解決的問題是: GitHub Actions 怎麼向 GCP 證明「這個請求真的來自我的 repo」?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
GitHub Actions executes
  -> GitHub issues a short-lived OIDC token (JWT)
     contains: iss, sub, repository, ref, actor, exp (minutes away)
          |
          v
GCP Workload Identity Pool
  -> validates: is iss from GitHub?
  -> checks attribute_condition: is repository == allowed repo?
          |
          v
impersonate Service Account
  -> SA has only artifactregistry.writer
  -> token expires in minutes, no key ever exists

沒有任何 key 存在任何地方。就算 token 被攔截, 幾分鐘後就過期。

Terraform 設定

需要啟用的 GCP API

這是最容易忘記的部分。GCP 每個服務預設是關閉的, 要明確啟用:

1
2
3
4
5
6
7
resource "google_project_service" "artifact_registry" {
  service = "artifactregistry.googleapis.com"
}

resource "google_project_service" "iam_credentials" {
  service = "iamcredentials.googleapis.com"
}

iamcredentials.googleapis.com 是 WIF impersonate SA 需要的, 不開就會在 CI 跑到一半時出現 403。depends_on 確保資源建立順序正確:

1
2
3
4
5
6
resource "google_artifact_registry_repository" "go_api" {
  depends_on    = [google_project_service.artifact_registry]
  repository_id = "go-api"
  format        = "DOCKER"
  location      = var.region
}

depends_on 是 Terraform 的顯式依賴宣告。通常 Terraform 能從 resource 之間的引用自動推斷順序, 但這裡 google_artifact_registry_repository 並沒有直接引用 google_project_service, 所以需要手動告訴它「先等 API 啟用再建 repository」。

Service Account 與最小權限

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
resource "google_service_account" "github_actions" {
  account_id   = "github-actions-ci"
  display_name = "GitHub Actions CI"
}

resource "google_project_iam_member" "go_devops" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

SA 只給 roles/artifactregistry.writer, 不給 EditorOwner。就算 CI pipeline 被入侵, 最多只能推 image, 不能碰其他 GCP 資源。

WIF Pool 與 Provider

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-actions-pool"
  display_name              = "GitHub Actions Pool"
}

resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-actions-oidc"
  display_name                       = "GitHub Actions OIDC"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }

  attribute_condition = "attribute.repository == '${var.github_repository}'"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

attribute_mapping 的兩側意思不同:

  • 左邊是你在 GCP 這側定義的名字 (attribute.repository)
  • 右邊是 GitHub JWT 裡的原始 claim (assertion.repository)

assertion 就是 GitHub 發出的 JWT token 內容。attribute_mapping 把 JWT claims 映射成 GCP 可以在 condition 裡使用的屬性。

attribute_condition 限制只有指定的 repo 才能通過驗證。沒有這個 condition, 任何 GitHub repo 都可以用這個 WIF pool。

SA Impersonation Binding

1
2
3
4
5
resource "google_service_account_iam_member" "wif_devops" {
  service_account_id = google_service_account.github_actions.id
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repository}"
}

這個 binding 允許「來自指定 repo 的 WIF principal」去 impersonate 這個 SA。principalSet 代表一組符合條件的 principal, 不是單一個。

Outputs

CI workflow 需要這兩個值:

1
2
3
4
5
6
7
output "workload_identity_provider" {
  value = google_iam_workload_identity_pool_provider.github.name
}

output "service_account_email" {
  value = google_service_account.github_actions.email
}

nameemail 是 Attributes Reference 裡的欄位, 是資源建立後 GCP 回傳的值, 不是你傳入的 argument。

GitHub Actions Workflow

bootstrap apply 完, 把兩個 output 值存進 GitHub repo 的 Variables (不是 Secrets, 這兩個不是機密):

  • GCP_WORKLOAD_IDENTITY_PROVIDER
  • GCP_SERVICE_ACCOUNT
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
deploy:
  needs: build
  runs-on: ubuntu-latest
  permissions:
    contents: read
    id-token: write
  env:
    GCP_REGION: us-east1
    GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
  steps:
    - uses: actions/checkout@v4
    - uses: google-github-actions/auth@v2
      with:
        workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
        service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
    - uses: google-github-actions/setup-gcloud@v2
    - name: Build and push to Artifact Registry
      run: |
        gcloud auth configure-docker ${GCP_REGION}-docker.pkg.dev --quiet
        docker build -t ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${{ github.sha }} .
        docker push ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${{ github.sha }}

id-token: write 只開在需要 GCP 認證的 job, 不要在 workflow 頂層給。test 和 build job 不需要 GCP 認證, 就不需要這個權限。

env 區塊定義的變數在 run: 步驟裡用 $VAR_NAME 取用, 不是 ${{ }} syntax。${{ }} 是 GitHub Actions 的 expression syntax, 用來讀 vars.secrets.github. 這些 context。

Arguments vs Attributes

這個概念在寫 Terraform 時很重要:

  • Arguments (Argument Reference): 你傳入 resource 的值, 例如 account_iddisplay_name
  • Attributes (Attributes Reference): 資源建立後可以引用的值, 包含你傳入的和 GCP 自動產生的

email 不在 google_service_account 的 arguments 裡, 你不需要寫它。但 apply 完之後它就存在, 可以用 google_service_account.github_actions.email 引用。查法: Terraform Registry 每個 resource 頁面底部的 Attributes Reference 段落。

References