具備權限檢查的 Cloud Run 建構

一個只會說 How do you do 的 Go 玩具服務,卻讓我把 Cloud Run IAM、Workload Identity Federation、ID Token vs Access Token 都研究了一遍。

分享
具備權限檢查的 Cloud Run 建構

最近練習把一個 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-unauthenticated

Cloud 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=10

id-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 TokenAccess 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-deployerGitHub Actions 部署到 Cloud RunWIF 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?\' 的玩具,把認證做對還是很有學習價值。