Cloud Run Runtime Contract: 環境變數注入、Secret-Gated Auth Bypass 和 Auto-Deploy Pipeline
這篇記錄在 Cloud Run 上跑 FastAPI 服務時, 我怎麼定義 runtime contract、設計 dev 環境的 auth bypass、以及自動部署 pipeline 的做法
跟之前寫的 WIF branch boundaries 和 dev/prod isolation 不同, 這篇聚焦在 app 層跟 infra 層之間的「合約」— 哪些東西由 Terraform 注入, 哪些由 app code 消費, 中間的邊界怎麼畫
Runtime Contract: App 不碰 GCP SDK
一開始我覺得 Cloud Run 上的 app 應該自己去 Secret Manager 拿 secret。但後來發現這個做法有幾個問題:
- App code 要 import
google.cloud.secretmanager, 增加 domain/application 層對 GCP 的耦合 - 每次啟動或每次 request 都要做 API call, 增加延遲
- 測試時要 mock GCP SDK
- IAM 權限要從 “runtime SA 能用 secret” 變成 “runtime SA 能 read secret + 能 call Secret Manager API”
Cloud Run 原生就支持把 Secret Manager 的值注入成環境變數。Terraform 設定好之後, app 只要 os.getenv() 就好:
|
|
App code 只看到環境變數, 不知道值從哪裡來。所以我在 config 裡寫了兩個 contract constant:
|
|
再用 test 確保整個 src/ 底下沒有人 import Secret Manager client:
|
|
Domain 層和 application 層也不允許出現任何 GCP identity API:
|
|
這樣即使有人不小心在 domain 層加了 GCP 相關的 import, CI 就會擋住
環境變數的分類
Config 裡把所有環境變數分成明確的類別:
| 類別 | 變數 | 誰注入 |
|---|---|---|
| Required secrets | DATABASE_URL, CLERK_SECRET_KEY, ADMIN_API_KEY |
Secret Manager → env var |
| Optional secrets | RESEND_API_KEY |
Secret Manager → env var |
| Required plain | ENV, BASE_URL |
Terraform env block |
| Optional plain | RESEND_FROM_EMAIL |
Terraform env block |
| Code defaults | SHOPIFY_API_VERSION, SHOPIFY_SCOPES |
App code 有預設值 |
| Dev-only plain | ALLOW_DEV_AUTH_BYPASS |
Terraform env block (conditional) |
| Dev-only secrets | DEV_BYPASS_SECRET |
Secret Manager → env var (conditional) |
每個分類都有對應的 tuple constant, test 會驗證這些 constant 的值沒有被意外修改
Secret-Gated Auth Bypass
在 dev 環境測試 API 時, 每次都要拿到一個真的 Clerk JWT 很麻煩。所以需要 dev bypass — 送一個假的 token 就能模擬登入。但如果 bypass 沒有保護, 任何人都可以假冒任何使用者
三種環境, 三種行為
| 環境 | Bypass 行為 | Token 格式 |
|---|---|---|
| LOCAL | 預設開啟, 不需要 secret | dev:<user_id> |
| DEV | 需要 flag + secret | dev:<secret>:<user_id> |
| PROD | 不可能開啟 (啟動時 crash) | — |
LOCAL 最寬鬆 — 本地開發不需要任何額外設定, dev:user_1 就能用。DEV 多一層保護: token 裡要帶 secret, 用 hmac.compare_digest 做 constant-time 比較
防護層
整個設計有五層防護:
- PROD crash — config.py 在啟動時檢查, PROD 環境設了
ALLOW_DEV_AUTH_BYPASS直接 raise, app 不會啟動 - Secret required — DEV 環境開啟 bypass 但沒設
DEV_BYPASS_SECRET也會 crash - Token validation — ClerkAuthClient 用
hmac.compare_digest驗 secret, 不是用== - Terraform guard —
ALLOW_DEV_AUTH_BYPASS和DEV_BYPASS_SECRET的注入都是 conditional, 只在var.allow_dev_auth_bypass == true時才有 - Platform guard — Secret Manager 裡的
dev-bypass-secretresource 也是 conditional, TF variable 沒開就不存在
|
|
為什麼用 hmac.compare_digest
字串比較 (==) 在第一個不同的字元就會 return, 攻擊者可以透過 response time 猜出 secret 的每一個字元 (timing attack)。hmac.compare_digest 不管內容是否相同, 比較時間都一樣
Single Source of Truth
控制 bypass 開關的 flag 放在 GitHub repo variable (vars.ALLOW_DEV_AUTH_BYPASS), 同時被 infra workflow 和 deploy workflow 讀取:
|
|
Infra workflow 跑 platform Terraform 時, 這個值決定要不要建 Secret Manager resource。Deploy workflow 跑 service Terraform 時, 決定要不要注入環境變數。一個 variable 控制兩條路, 不會出現 “platform 有建 secret 但 service 沒注入” 的不一致
Auto-Deploy Pipeline
Push to dev branch 觸發 deploy-dev, push to main 觸發 deploy-prod。四個 stage:
|
|
Test
跟 CI workflow (PR 時跑的) 完全一樣的 lint + type check + test:
|
|
在 deploy pipeline 裡再跑一次的原因: PR merge 到 dev 或 main 之後, 可能有多個 PR 合在一起, 合併後的狀態不一定通過測試。Deploy pipeline 的 test 是最後防線
Build
Build Docker image, push 到 Artifact Registry, 取得 immutable digest:
|
|
用 sha-${GITHUB_SHA::7} 當 tag 方便人看, 但後面 deploy 時用的是 immutable digest (sha256:...), 不是 tag。Tag 可以被覆蓋, digest 不行
Migrate
用 Cloud SQL Proxy 連到 Cloud SQL, 跑 alembic upgrade head:
|
|
這裡有一個細節: runtime 的 DATABASE_URL 用 Unix socket path (?host=/cloudsql/...) 連到 Cloud SQL Auth Proxy sidecar, 但 CI 裡是透過 TCP (127.0.0.1:5432) 連到本地啟動的 Cloud SQL Proxy。所以要用 sed 把 URL 改掉
Migration 在 deploy 之前跑, 確保 schema 是最新的。如果 migration 失敗, deploy 不會執行
Deploy
用 Terraform 更新 Cloud Run service:
|
|
Image digest 透過 -var 傳入 Terraform, Terraform 更新 Cloud Run revision。用 saved plan file (-out=tfplan), apply 時不會有意外
Health Check
Deploy 之後等 Cloud Run 新 revision 起來, 打 /health 確認:
|
|
Prod 環境多一個 custom domain 的 health check, 但用 warning 而不是 error — 因為 DNS 和 certificate propagation 可能需要更多時間, 不應該因此把整個 deploy 標成失敗
Dev 和 Prod 的差異
| 項目 | Dev | Prod |
|---|---|---|
| Trigger | push to dev |
push to main |
cancel-in-progress |
true |
false |
| Branch validation | 只允許 dev |
只允許 main |
| Health check | Cloud Run URL only | Cloud Run URL + custom domain |
| Secret suffix | database-url-dev |
database-url-prod |
| GitHub environment | dev |
prod |
cancel-in-progress: true 在 dev 上是合理的 — 連續 push 時只需要跑最新的。Prod 用 false, 因為不想在 deploy 到一半的時候被 cancel
踩到的坑
Platform TF 和 Service TF 的順序
Secret Manager resource 是 platform Terraform 建的, env var 注入是 service Terraform 設的。加 bypass secret 時, 我先跑了 service TF 的 deploy — 結果 Cloud Run 說 “Permission denied on secret”, 因為 secret 還不存在
正確順序: platform TF (建 secret + IAM) → 存 secret 值 → service TF (deploy)
Secret 建了但沒有 version
Platform TF google_secret_manager_secret 只建了 secret resource, 但不會存值。Secret Manager 需要至少一個 version 才能被 Cloud Run 讀取。所以 platform apply 之後, 還要手動存一個值:
|
|
這個步驟沒有被自動化, 因為 secret 的值不應該出現在任何 repo 裡
Workflow 檔案不在 PR 裡
改了 .github/workflows/deploy-dev.yml 加 TF_VAR_allow_dev_auth_bypass, 但這個改動沒有包含在當時的 PR 裡。結果 merge 之後跑 infra workflow, TF variable 沒有值, deploy 失敗
教訓: workflow 檔案的改動跟它要服務的 code 改動要放在同一個 PR
經驗總結
- App code 不碰 GCP SDK — 用 env injection, 不做 runtime secret fetch。domain/application 層跟雲平台完全解耦
- Runtime contract 用 test 守 — constant + pattern scan, CI 擋住任何違反
- Dev bypass 要有 secret gate — 不能只靠 flag, 要加 constant-time 比較的 secret
- Pipeline 裡再跑一次測試 — PR CI 通過不代表 merge 後也通過
- Platform TF 先於 Service TF — secret 和 IAM 要先存在, deploy 才能成功
- Workflow 改動要跟 code 一起送 — 不然 merge 後 workflow 拿不到新的設定