具備權限檢查的 Cloud Run 建構
一個只會說 How do you do 的 Go 玩具服務,卻讓我把 Cloud Run IAM、Workload Identity Federation、ID Token vs Access Token 都研究了一遍。
最近練習把一個 Go 服務部署到 Cloud Run,順便研究 GCP 的 IAM 認證機制。服務本身沒什麼意義——送 Hi 進去就回 How do you do?——但設定存取控制、讓 CI/CD 安全部署、以及讓外部應用程式認證呼叫,才是這個練習真正有趣的地方。
Cloud Run 的安全模型:IAM 不是 VPC
跟傳統 VM 不同,Cloud Run 沒有 port/IP 層級的防火牆。存取控制完全靠 IAM policy binding,核心是部署時加上 --no-allow-unauthenticated:
gcloud run deploy hello-svc \
--source . \
--region asia-east1 \
--no-allow-unauthenticatedCloud Run 支援直接把原始碼推上去,由 Google Cloud Buildpacks 自動偵測語言、編譯、打包成容器。Go 專案只需要有 go.mod,完全不需要 Dockerfile。
--no-allow-unauthenticated 讓 GCP 邊緣在流量到你容器前先做身份驗證,沒有正確 token 的請求直接被擋,你的程式碼根本不會執行。允許特定身份呼叫:
gcloud run services add-iam-policy-binding hello-svc \
--region asia-east1 \
--member "serviceAccount:my-caller@PROJECT_ID.iam.gserviceaccount.com" \
--role roles/run.invoker不想公開存取就不要把 allUsers 加進 roles/run.invoker。就這樣,不需要 Security Group、不需要設 VPC ingress rule。
GitHub Actions 部署:用 WIF 取代 SA JSON 金鑰
最常見的錯誤做法是把 GCP Service Account JSON 金鑰塞進 GitHub Secrets。這種方式的問題:金鑰長期有效、洩漏難以追蹤、輪換繁瑣。更好的做法是 Workload Identity Federation(WIF):讓 GitHub Actions 用原本的 OIDC token 去 GCP 換取短期憑證,整個流程不需要任何長期金鑰。
一次性設定(只做一次):
PROJECT_ID=YOUR_PROJECT_ID
PROJECT_NUMBER=YOUR_PROJECT_NUMBER
REPO=YOUR_GITHUB_ORG/YOUR_REPO_NAME
# 建立 deployer Service Account(只用於 CI 部署)
gcloud iam service-accounts create gh-deployer --project $PROJECT_ID
# 建立 Workload Identity Pool
gcloud iam workload-identity-pools create github-pool \
--location global --project $PROJECT_ID
# 建立 GitHub OIDC Provider,限定只信任你的 repo
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location global \
--workload-identity-pool github-pool \
--issuer-uri "https://token.actions.githubusercontent.com" \
--attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository" \
--attribute-condition "assertion.repository=='${REPO}'"
# 綁定:只有此 repo 的 workflow 能扮演 deployer SA
gcloud iam service-accounts add-iam-policy-binding \
gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
# 授予 deployer SA 部署所需角色
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member "serviceAccount:gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com" \
--role roles/run.admin
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member "serviceAccount:gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com" \
--role roles/cloudbuild.builds.editor
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member "serviceAccount:gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com" \
--role roles/iam.serviceAccountUser--attribute-condition 把信任範圍鎖定在你的 repo,其他 repo 的 workflow 即使取得 GitHub OIDC token 也無法扮演這個 SA。
GitHub Actions workflow:
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # 取得 GitHub OIDC token,WIF 必需
jobs:
test-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: go test ./...
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: gh-deployer@PROJECT_ID.iam.gserviceaccount.com
- uses: google-github-actions/deploy-cloudrun@v2
with:
service: hello-svc
source: .
region: asia-east1
flags: >-
--no-allow-unauthenticated
--memory=128Mi
--cpu=0.3
--min-instances=0
--max-instances=10id-token: write 讓 GitHub Actions 能請求 OIDC token,google-github-actions/auth 再用這個 token 去換 GCP 短期憑證。整個過程沒有任何長期金鑰存在於 GitHub 側。
外部應用程式呼叫:ID Token 不是 Access Token
應用程式跑在 GCP 環境內(例如另一個 Cloud Run、GKE),可以直接用 metadata server 取得 token。但跑在 GCP 外(自建 VPS、其他雲端)就必須用 Service Account JSON 金鑰。
建立 caller SA 並下載金鑰:
# 建立應用程式用的 SA(與 deployer SA 分開,職責單一)
gcloud iam service-accounts create my-caller --project YOUR_PROJECT_ID
# 只授予呼叫此服務的權限
gcloud run services add-iam-policy-binding hello-svc \
--region asia-east1 \
--member "serviceAccount:my-caller@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role roles/run.invoker
# 下載金鑰(這個 JSON 是 secret,絕對不能入 git)
gcloud iam service-accounts keys create caller-key.json \
--iam-account my-caller@YOUR_PROJECT_ID.iam.gserviceaccount.com呼叫 Cloud Run 需要 ID Token,不是 Access Token——這是最常踩的坑:
| ID Token | Access Token | |
|---|---|---|
| 用途 | 識別呼叫者身份(我是誰) | 授權存取 GCP 資源(我能做什麼) |
| 呼叫 Cloud Run IAM | ✅ 可以 | ❌ 回 403 |
| 有效期 | ~1 小時 | ~1 小時 |
ID Token 需帶 audience 參數,值是 Cloud Run service URL(不含 path)。PHP 範例:
use Google\Auth\Credentials\ServiceAccountCredentials;
$saKeyJson = json_decode(env('HELLO_SVC_SA_KEY'), true);
$audience = env('HELLO_SVC_URL'); // https://hello-svc-xxxx.asia-east1.run.app
$credentials = new ServiceAccountCredentials(
scope: null,
jsonKey: $saKeyJson,
targetAudience: $audience, // 指定 audience → 拿到的是 ID Token
);
// Token 有效約 1 小時,放 Cache 避免每次呼叫都重新 mint
$idToken = Cache::remember('gcp_id_token', 3500, function () use ($credentials) {
return $credentials->fetchAuthToken()['id_token'];
});
$response = Http::withToken($idToken)
->withBody('Hi', 'text/plain')
->post($audience);
echo $response->body(); // "How do you do?"兩個 GCP 身份,一定要分開
這個設定裡有兩個用途完全不同的 Service Account:
| SA 名稱 | 用途 | 認證方式 | 存放位置 |
|---|---|---|---|
gh-deployer | GitHub Actions 部署到 Cloud Run | WIF keyless OIDC | 不需要存任何金鑰 |
my-caller | 應用程式執行期呼叫服務 | SA JSON 金鑰 | 環境變數 / Secret Manager |
gh-deployer 不需要也不應該有 JSON 金鑰;my-caller 因為跑在 GCP 外必須用金鑰,但 IAM 權限只有 roles/run.invoker,範圍限縮到最小。
一個坑:/healthz 在 Cloud Run 被保留
Cloud Run 前端會攔截 /healthz 路徑,請求根本不會到你的容器。如果 health check 用了 /healthz,Cloud Run 的 health check 會成功(GCP 前端回了),但實際上沒有在測你的程式。
解法:把 health check 改到其他路徑,例如 /livez:
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})FAQ
WIF 設定好了,GitHub Actions 還是拿不到 GCP 憑證?
確認 workflow 有設 id-token: write permission,且 --attribute-condition 的 repo 名稱與實際 repo 完全一致(含 org 名稱,例如 myorg/myrepo)。
為什麼一定要分兩個 Service Account?
職責分離:gh-deployer 只有部署權限且完全不需要 JSON 金鑰;my-caller 只有 roles/run.invoker,即使金鑰洩漏,攻擊者只能呼叫服務,無法修改任何 GCP 資源。
可以用 gcloud 身份直接測試嗎?
curl -X POST \
-H "Authorization: Bearer $(gcloud auth print-identity-token)" \
-H "Content-Type: text/plain" \
-d "Hi" \
"https://hello-svc-xxxx.asia-east1.run.app/"
# → How do you do?
# 送錯誤內容
curl -X POST \
-H "Authorization: Bearer $(gcloud auth print-identity-token)" \
-d "Hello" \
"https://hello-svc-xxxx.asia-east1.run.app/"
# → 400 I only respond to 'Hi'
# 沒帶 token
curl -X POST -d "Hi" "https://hello-svc-xxxx.asia-east1.run.app/"
# → 403 Forbidden(IAM 擋下,程式碼根本沒執行)Cloud Run 的安全模型跟傳統 VM 差很多,但核心其實很清楚:IAM policy binding 控制存取而非 port/IP 防火牆;WIF 讓 CI/CD 不再需要長期金鑰;外部呼叫用 ID Token 而非 Access Token,audience 必須指定 Cloud Run service URL。就算服務本身只是個說 \'How do you do?\' 的玩具,把認證做對還是很有學習價值。