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

重點不是 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 沒切開, `dev` 和 `prod` 只是看起來分開。

## 真正要切的是哪些邊界

我最後整理成 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 分開。

```text
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

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

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

很多人一開始會這樣寫:

```hcl
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 前綴直接切開:

```text
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

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

```text
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 還叫:

```text
database-url
auth-backend-secret
admin-access-token
```

那 `dev` 和 `prod` 還是在共享執行階段契約。

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

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

把 secret ID 切成這樣:

```text
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 是兩種完全不同的身分。

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

比較乾淨的模型是:

```text
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 必須真的宣告:

```yaml
environment: dev
```

或:

```yaml
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

我最後收斂到這個模型:

```text
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 還沒真正完成。

## 結論

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

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

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

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

## References

- [Cloud Run documentation](https://cloud.google.com/run/docs)
- [Cloud SQL for PostgreSQL](https://cloud.google.com/sql/docs/postgres)
- [Secret Manager documentation](https://cloud.google.com/secret-manager/docs)
- [GitHub Actions documentation](https://docs.github.com/en/actions)
- [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation)
