把 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 被攔截, 幾分鐘後就過期。
需要啟用的 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, 不給 Editor 或 Owner。就算 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
}
name 和 email 是 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_id、display_name
Attributes (Attributes Reference): 資源建立後可以引用的值, 包含你傳入的和 GCP 自動產生的
email 不在 google_service_account 的 arguments 裡, 你不需要寫它。但 apply 完之後它就存在, 可以用 google_service_account.github_actions.email 引用。查法: Terraform Registry 每個 resource 頁面底部的 Attributes Reference 段落。
References