Cloud Run Runtime Contract: 環境變數注入、Secret-Gated Auth Bypass 和 Auto-Deploy Pipeline

這篇記錄在 Cloud Run 上跑 FastAPI 服務時, 我怎麼定義 runtime contract、設計 dev 環境的 auth bypass、以及自動部署 pipeline 的做法

跟之前寫的 WIF branch boundariesdev/prod isolation 不同, 這篇聚焦在 app 層跟 infra 層之間的「合約」— 哪些東西由 Terraform 注入, 哪些由 app code 消費, 中間的邊界怎麼畫

Runtime Contract: App 不碰 GCP SDK

一開始我覺得 Cloud Run 上的 app 應該自己去 Secret Manager 拿 secret。但後來發現這個做法有幾個問題:

  1. App code 要 import google.cloud.secretmanager, 增加 domain/application 層對 GCP 的耦合
  2. 每次啟動或每次 request 都要做 API call, 增加延遲
  3. 測試時要 mock GCP SDK
  4. IAM 權限要從 “runtime SA 能用 secret” 變成 “runtime SA 能 read secret + 能 call Secret Manager API”

Cloud Run 原生就支持把 Secret Manager 的值注入成環境變數。Terraform 設定好之後, app 只要 os.getenv() 就好:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
dynamic "env" {
  for_each = {
    DATABASE_URL    = "database-url-${var.env}"
    CLERK_SECRET_KEY = "clerk-secret-key"
    ADMIN_API_KEY   = "admin-api-key"
  }
  content {
    name = env.key
    value_source {
      secret_key_ref {
        secret  = env.value
        version = "latest"
      }
    }
  }
}

App code 只看到環境變數, 不知道值從哪裡來。所以我在 config 裡寫了兩個 contract constant:

1
2
GCP_SECRET_DELIVERY_MODE = "environment-injection-only"
GCP_IDENTITY_MODE = "service-account-adc-only"

再用 test 確保整個 src/ 底下沒有人 import Secret Manager client:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FORBIDDEN_RUNTIME_SECRET_PATTERNS = (
    "google.cloud.secretmanager",
    "SecretManagerServiceClient",
)

def test_source_tree_does_not_use_runtime_secret_manager_reads():
    for path in SOURCE_ROOT.rglob("*.py"):
        content = path.read_text()
        for pattern in FORBIDDEN_RUNTIME_SECRET_PATTERNS:
            assert pattern not in content

Domain 層和 application 層也不允許出現任何 GCP identity API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
FORBIDDEN_MODULE_IDENTITY_PATTERNS = (
    "google.cloud",
    "google.auth",
    "secretmanager",
    "GOOGLE_APPLICATION_CREDENTIALS",
)

def test_module_domain_and_application_layers_do_not_depend_on_gcp_identity_apis():
    module_root = SOURCE_ROOT / "modules"
    for layer in ("application", "domain"):
        for path in module_root.glob(f"*/{layer}/**/*.py"):
            content = path.read_text()
            for pattern in FORBIDDEN_MODULE_IDENTITY_PATTERNS:
                assert pattern not in content

這樣即使有人不小心在 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 比較

防護層

整個設計有五層防護:

  1. PROD crash — config.py 在啟動時檢查, PROD 環境設了 ALLOW_DEV_AUTH_BYPASS 直接 raise, app 不會啟動
  2. Secret required — DEV 環境開啟 bypass 但沒設 DEV_BYPASS_SECRET 也會 crash
  3. Token validation — ClerkAuthClient 用 hmac.compare_digest 驗 secret, 不是用 ==
  4. Terraform guardALLOW_DEV_AUTH_BYPASSDEV_BYPASS_SECRET 的注入都是 conditional, 只在 var.allow_dev_auth_bypass == true 時才有
  5. Platform guard — Secret Manager 裡的 dev-bypass-secret resource 也是 conditional, TF variable 沒開就不存在
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def _parse_dev_token(self, token: str) -> str:
    payload = token[4:]
    if self._env == Environment.LOCAL:
        if not payload:
            raise AuthenticationError("dev bypass: empty user id")
        return payload
    parts = payload.split(":", 1)
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise AuthenticationError("dev bypass: expected dev:<secret>:<user_id>")
    secret, user_id = parts
    if not hmac.compare_digest(secret, self._dev_bypass_secret):
        raise AuthenticationError("dev bypass: invalid secret")
    return user_id

為什麼用 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 讀取:

1
2
env:
  TF_VAR_allow_dev_auth_bypass: ${{ vars.ALLOW_DEV_AUTH_BYPASS || 'false' }}

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:

1
test → build → migrate → deploy (+ health check)

Test

跟 CI workflow (PR 時跑的) 完全一樣的 lint + type check + test:

1
2
3
4
5
6
- name: Lint and test
  run: |
    uv run --directory api ruff check src tests
    uv run --directory api ruff format --check src tests
    uv run --directory api pyright
    uv run --directory api pytest tests -q

在 deploy pipeline 裡再跑一次的原因: PR merge 到 devmain 之後, 可能有多個 PR 合在一起, 合併後的狀態不一定通過測試。Deploy pipeline 的 test 是最後防線

Build

Build Docker image, push 到 Artifact Registry, 取得 immutable digest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
- name: Build and push
  run: |
    TAG="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/pebble/pebble-api:sha-${GITHUB_SHA::7}"
    docker build -f api/Dockerfile -t "$TAG" api/
    docker push "$TAG"

- name: Resolve immutable digest
  run: |
    IMAGE=$(docker inspect --format='{{index .RepoDigests 0}}' "${BUILD_TAG}")
    echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

sha-${GITHUB_SHA::7} 當 tag 方便人看, 但後面 deploy 時用的是 immutable digest (sha256:...), 不是 tag。Tag 可以被覆蓋, digest 不行

Migrate

用 Cloud SQL Proxy 連到 Cloud SQL, 跑 alembic upgrade head:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- name: Run database migration
  run: |
    CONN=$(gcloud sql instances describe "${GCP_SQL_INSTANCE}" \
      --project="${GCP_PROJECT_ID}" --format='value(connectionName)')
    cloud-sql-proxy "${CONN}" --port 5432 &
    sleep 2
    SECRET_URL=$(gcloud secrets versions access latest \
      --secret="database-url-${ENV}" --project="${GCP_PROJECT_ID}")
    CI_URL=$(echo "${SECRET_URL}" \
      | sed 's|@/pebble?host=/cloudsql/.*|@127.0.0.1:5432/pebble|')
    DATABASE_URL="${CI_URL}" uv run --directory api 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:

1
2
3
4
5
6
7
8
9
- name: Terraform plan
  run: |
    terraform plan -lock-timeout=5m \
      -var-file=terraform.tfvars \
      -var="api_image=${API_IMAGE}" \
      -out=tfplan

- name: Terraform apply
  run: terraform apply -lock-timeout=5m tfplan

Image digest 透過 -var 傳入 Terraform, Terraform 更新 Cloud Run revision。用 saved plan file (-out=tfplan), apply 時不會有意外

Health Check

Deploy 之後等 Cloud Run 新 revision 起來, 打 /health 確認:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- name: Health check
  run: |
    API_URL=$(terraform output -raw api_url)
    for i in $(seq 1 30); do
      if curl -sf "${API_URL}/health"; then
        echo "Health check passed"
        exit 0
      fi
      sleep 5
    done
    echo "::error::Health check failed after 150 seconds"
    exit 1

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 之後, 還要手動存一個值:

1
echo -n "the-actual-secret" | gcloud secrets versions add dev-bypass-secret --data-file=-

這個步驟沒有被自動化, 因為 secret 的值不應該出現在任何 repo 裡

Workflow 檔案不在 PR 裡

改了 .github/workflows/deploy-dev.ymlTF_VAR_allow_dev_auth_bypass, 但這個改動沒有包含在當時的 PR 裡。結果 merge 之後跑 infra workflow, TF variable 沒有值, deploy 失敗

教訓: workflow 檔案的改動跟它要服務的 code 改動要放在同一個 PR

經驗總結

  1. App code 不碰 GCP SDK — 用 env injection, 不做 runtime secret fetch。domain/application 層跟雲平台完全解耦
  2. Runtime contract 用 test 守 — constant + pattern scan, CI 擋住任何違反
  3. Dev bypass 要有 secret gate — 不能只靠 flag, 要加 constant-time 比較的 secret
  4. Pipeline 裡再跑一次測試 — PR CI 通過不代表 merge 後也通過
  5. Platform TF 先於 Service TF — secret 和 IAM 要先存在, deploy 才能成功
  6. Workflow 改動要跟 code 一起送 — 不然 merge 後 workflow 拿不到新的設定

References

0%