在單一 GCP Project 裡切 Dev / Prod: State、Secrets、IAM 與 Workflow 邊界

這篇記錄我把一套 Cloud Run + Cloud SQL 應用留在同一個 GCP project 裡, 但仍然把 devprod 切乾淨的做法。

重點不是 branch 命名, 也不是多建兩個 GitHub workflow 而已。真正要切的是 state、資源命名、secrets、執行身分、部署身分, 還有 workflow 控管。這幾層如果只漏一層, 最後就還是會互撞。

為什麼很多團隊會想留在同一個 GCP project

最直接的原因通常不是技術, 而是成本和管理負擔。

如果一開始就做成 project-per-environment, 會比較接近 cloud best practice, 但實際上也會多出很多東西:

  • more IAM setup
  • more service accounts
  • more budget tracking
  • more APIs to enable
  • more Terraform bootstrap work
  • more operational overhead

如果產品還在早期階段, 或只是想先把部署路徑、執行階段隔離和 branch 控管建起來, 留在單一 project 其實是合理取捨。

問題是: 同一個 project 代表所有資源都活在同一個 namespace 裡。只要命名、state、或 secrets contract 沒切開, devprod 只是看起來分開。

真正要切的是哪些邊界

我最後整理成 6 個邊界。

Boundary What must be separated
Terraform state Shared, dev platform, dev service, prod platform, prod service
Resource naming DB instance, Cloud Run service, secrets, service accounts
Runtime secrets DATABASE_URL, auth secrets, API keys
Runtime identity Cloud Run runtime service account
Deploy identity CI/CD deploy service account
Workflow governance branch -> environment routing and approval boundary

如果只做了其中 2-3 個, 後面一定還會踩坑。

我最後採用的 Terraform layout

核心做法是把 resource definition 和 environment instantiation 分開。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
infra/terraform/
  modules/
    shared/
    platform/
    service/
  live/
    shared/
    dev/
      platform/
      service/
    prod/
      platform/
      service/

這個 layout 有兩個好處:

  1. modules/* 專心描述 reusable infra contract
  2. live/* 專心描述實際環境的 state、vars、target wiring

這樣 devprod 才是兩個真的 live roots, 不是同一份 root 加一個變數切換。

單一 project 裡最容易漏掉的第一個坑: state collision

很多人一開始會這樣寫:

1
2
3
4
backend "gcs" {
  bucket = "shared-state-bucket"
  prefix = "platform"
}

然後再用 environment = "dev"environment = "prod" 去切資源名稱。

這樣不夠。

因為 state 還是一份。只要這份 state 同時管 shared 資源和 env-specific 資源, 後面你就會碰到:

  • prod apply touches dev resources
  • shared resources and env resources drift together
  • import and migration become messy
  • destroy blast radius becomes unclear

我最後的做法是把 state 前綴直接切開:

1
2
3
4
5
shared
platform/dev
service/dev
platform/prod
service/prod

這樣就算還在同一個 GCS bucket, ownership boundary 也已經清楚很多。

第二個坑: naming isolation 不完整

在單一 project 裡, naming 不是 cosmetic, 是 isolation 的一部分。

例如這幾個東西都不能共名:

  • Cloud SQL instance
  • Cloud Run service
  • Secret Manager secret
  • runtime service account
  • deploy service account

我後來把命名規則收斂成:

1
2
3
4
5
6
app-dev
app-prod
database-url-dev
database-url-prod
deploy-dev-sa
deploy-prod-sa

這聽起來很基本, 但真正會踩坑的是 secret naming。

如果你只把 Cloud Run service 改成 app-dev / app-prod, 但 secret 還叫:

1
2
3
database-url
auth-backend-secret
admin-access-token

devprod 還是在共享執行階段契約。

第三個坑: secret name 分開了, value 不一定分開

這是我覺得最值得記的一點。

把 secret ID 切成這樣:

1
2
3
4
database-url-dev
database-url-prod
admin-access-token-dev
admin-access-token-prod

只代表 resource boundary 分開了。

不代表 value 一定不同。

這在 migration 階段特別容易發生。因為第一次 bring-up prod 時, 很常會先把舊值複製過去, 想說流程先跑通再說。

技術上可行, 但要很清楚這只是 transitional state, 不是完成狀態。

我會把 secret separation 拆成兩層看:

Layer Meaning
ID separation different secret resource
value separation different credential or token

對真正的 environment isolation 來說, 兩層都要成立。

第四個坑: 執行身分和部署身分不能混用

Cloud Run runtime service account 跟 CI/CD deploy service account 是兩種完全不同的身分。

我一開始比較容易把它們想成同一類, 後來才發現這樣很危險。

比較乾淨的模型是:

1
2
3
4
5
runtime-sa-dev   -> used by the running dev service
runtime-sa-prod  -> used by the running prod service

deploy-dev-sa    -> used only by deploy-dev workflow
deploy-prod-sa   -> used only by deploy-prod workflow

對應的權限也不同:

  • runtime SA needs runtime permissions
  • deploy SA needs deployment permissions

這樣好處很直接:

  • deploy credentials cannot silently become runtime credentials
  • prod deploy cannot affect dev by accident
  • IAM review becomes readable

第五個坑: GitHub environment vars 不是全域變數

GitHub Actions 這一段很容易讓人誤會。

很多人會以為只要 repo 裡有 environment-scoped vars, workflow 任何 job 都能讀到。不是。

job 必須真的宣告:

1
environment: dev

或:

1
environment: prod

對應 job 才會讀到那個 environment 的 vars。

所以 environment-scoped variables 和 workflow routing 是綁在一起的。

如果 job 沒有 environment, 你就算在 GitHub UI 裡把變數設好了, workflow 還是拿不到。

第六個坑: branch policy 不是 environment isolation 的替代品

dev branch -> dev main -> prod

這個 policy 很重要, 但它不是 environment isolation 本身。

正確順序應該是:

  1. isolate the environments first
  2. 再加上 branch 控管

也就是先把:

  • state
  • names
  • secrets
  • identities
  • workflow entrypoints

切好, 再來收 branch boundary。

不然就會出現一種假象:

  • 看起來 main 只會 deploy prod
  • 但底下其實還在共用 state 或 secrets

我最後採用的 workflow model

我最後收斂到這個模型:

1
2
3
4
CI             -> pull request validation only
Deploy Dev     -> deploy dev service only
Deploy Prod    -> deploy prod service only
Infrastructure -> shared/dev-platform/prod-platform plan/apply

這樣的好處是操作者看到 workflow list 就知道每條線在做什麼。

我後來刻意避免把 environment routing 做成一支過度聰明的大 workflow。因為那種寫法一開始看起來很 DRY, 但後面 debug、權限檢查、branch guard, 全部都會變難讀。

我會怎麼判斷這套 env split 是否真的成立

最後不是看 repo 長得多漂亮, 而是看這幾條能不能明確回答「是」:

  • dev deploy 是否完全不會碰到 prod state
  • prod deploy 是否完全不會碰到 dev state
  • runtime secrets 是否在名稱和實際值兩層都隔離
  • production domain 是否只屬於 prod
  • CI identity 和 部署身分 是否能分開審查
  • 操作者是否不看原始碼也知道該跑哪條 workflow

如果這幾條還有一條答不出來, 代表 env split 還沒真正完成。

結論

devprod 留在同一個 GCP project 不是不能做, 但不能只靠 branch 或表面上的命名去切。

真正有用的做法是把 isolation 拆成多層邊界:

  • state
  • names
  • secrets
  • 執行身分
  • 部署身分
  • workflow 控管

只要這幾層一起收乾淨, 單一 project 也能跑出一套可維護的 dev / prod 模型。之後真的要升級成 project-per-environment, 也比較像是擴張, 不是推倒重做。

References

0%