
這篇記錄一件我現在很常回頭看的事: 如果一開始 Terraform 只有一個 root, 後來才想補 `dev / prod`、補 shared 層、補 CI/CD 邊界, 到底該怎麼遷移才不會影響既有環境。

我最後的結論很簡單:

- 不要一次全部重寫
- 不要先切 CI/CD
- 在新 roots 還沒收斂前, 不要先刪掉舊 roots

真正可用的做法是分階段。

## 遷移前的問題長什麼樣

一開始很常見的 Terraform layout 是這種:

```text
infra/terraform/
  bootstrap/
  platform/
  service/
```

這種 layout 在早期階段很好用, 因為快。

但後面需求一多, 問題就會同時冒出來:

- shared resources and env resources live in the same root
- service root depends on platform root assumptions
- secrets are not environment-safe
- production domain logic leaks into dev
- CI/CD is coupled to legacy roots

這時候最危險的念頭就是: 直接把舊 root 刪掉, 換成新結構。

這樣通常不會成功。

## 我的目標結構

目標不是單純換資料夾名稱, 而是換成擁有權模型。

```text
infra/terraform/
  modules/
    shared/
    platform/
    service/
  live/
    shared/
    dev/
      platform/
      service/
    prod/
      platform/
      service/
```

這個目標結構的重點是:

- `modules/*` 負責定義可重用的 infra 契約
- `live/*` 負責真正的 state 與環境落地

## 遷移順序比最終結構更重要

我最後認為最重要的不是終態長什麼樣, 而是遷移順序。

我會把順序寫成這樣:

1. 建立新的 modules 和 live roots
2. 遷移期間保留 legacy roots
3. 把資源 move 或 import 進新的 live roots
4. 讓新 roots 收斂到乾淨狀態
5. 再把 CI/CD 切到新路徑
6. 最後才刪掉 legacy roots

只要把 5 和 6 提前, 很容易出事。

## 什麼用 import, 什麼用 state mv

這是遷移時最實際的一題。

我會這樣分:

### 適合用 `terraform import` 的情況

例如這些資源本來就活在 cloud provider 裡, 只是新的 root 還沒接手:

- Cloud Run service
- Cloud SQL instance
- service account
- IAM member
- Artifact Registry repository

這類資源用 import 最自然:

```hcl
import {
  to = module.service.google_cloud_run_v2_service.api
  id = "projects/my-project/locations/us-east1/services/app-dev"
}
```

### 適合用 `terraform state mv` 的情況

最典型的是 `random_password`。

這種資源在遠端系統裡, 並不會直接保留 state 本身代表的值。你如果重新 import 或重建, 很可能不是「接手既有值」, 而是「產生新值」。

這在 DB password 之類的東西上非常危險。

```sh
terraform state mv \
  -state=old.tfstate \
  -state-out=new.tfstate \
  random_password.db \
  module.platform.random_password.db
```

這種情況真正要保護的不是 resource name, 是那個 value 本身。

## 遷移時最容易漏掉的坑: first deploy exception

理論上我們都希望新 root 一套上去就 `No changes`。

實際上第一次遷移通常會有例外, 尤其是這幾種:

- existing custom domain mapping
- manually populated secrets
- 從來沒有寫進 tfvars 的執行階段值
- old resources with names that no longer match the new contract

這些東西通常不會剛好落在理想化的自動化流程裡。

所以我現在會把 migration 分成兩種步驟:

| Type | Description |
|------|-------------|
| structural migration | modules, roots, state ownership |
| first deploy exception handling | import-only resources, secret copy, runtime value capture |

如果把這兩件事混成一條直線, runbook 很容易寫得太樂觀。

## 切 CI/CD 的時機

我後來很確定一件事:

**CI/CD 要最後再切換。**

原因很簡單。只要新的 live roots 還沒通過這幾條:

- `live/shared` plan is clean
- `live/dev/platform` plan is clean
- `live/dev/service` plan is clean

那 CI/CD 就不該先改成完全依賴新 roots。

不然一旦自動 deploy 跑起來, 你會同時遇到:

- code path changed
- workflow changed
- state ownership changed
- runtime contract changed

這時候根本很難 debug。

比較穩的方式是:

1. make the new roots valid
2. import or move state
3. reconcile to zero drift
4. only then switch workflows over

## 什麼叫 migration done

我自己現在會用很嚴格的標準。

不是 `terraform apply` 成功就算 done。

我會看這幾條:

- new roots can plan with no changes
- old roots no longer own migrated resources
- 現行 workflows 只指向新 roots
- 執行階段行為仍正常
- production-only 資源仍只留在 production 路徑

少一條都不算真的完成。

## 一個很實用的 runbook 思路

如果未來我自己再做一次類似遷移, 我會直接沿用這個 checklist:

```text
Phase 1
- create modules
- create live roots
- keep legacy roots untouched

Phase 2
- import shared resources
- import or move dev platform resources
- import or move dev service resources
- copy secret values if needed

Phase 3
- verify all new roots show no changes
- switch workflows to the new live roots
- verify deploys end to end

Phase 4
- bring up prod
- move prod-only resources like custom domain
- verify domain and runtime behavior

Phase 5
- delete legacy workflow
- delete legacy roots
- remove legacy IAM and secrets
```

這樣寫的好處是, 你可以很清楚知道現在卡在哪一個 phase, 而不是把整個 migration 當成一個模糊的大任務。

## 常用 gcloud 指令

這類遷移文最容易忘的通常不是 Terraform, 而是要去哪裡把現在的執行階段值抓回來。下面這幾條我自己最常回頭查。

### 查目前 Cloud Run service 的 image

```sh
gcloud run services describe app-dev \
  --region us-east1 \
  --format='value(spec.template.spec.containers[0].image)'
```

### 查目前 Cloud Run service 的 URL

```sh
gcloud run services describe app-dev \
  --region us-east1 \
  --format='value(status.url)'
```

### 查目前 service 內的 `BASE_URL`

```sh
gcloud run services describe app-dev \
  --region us-east1 \
  --format=json \
  | jq -r '.spec.template.spec.containers[0].env[]? | select(.name=="BASE_URL") | .value'
```

### 看目前 service 的 env 陣列

```sh
gcloud run services describe app-dev \
  --region us-east1 \
  --format='yaml(spec.template.spec.containers[0].env)'
```

### 複製既有 secret value 到新的 env-specific secret

```sh
gcloud secrets versions access latest --secret=auth-backend-secret \
  | gcloud secrets versions add auth-backend-secret-dev --data-file=-
```

### 查某個 secret 的最新值

```sh
gcloud secrets versions access latest --secret=database-url-dev
```

## 結論

Terraform migration 最怕的不是多寫幾份 HCL, 而是把擁有權變更、workflow 切換、執行階段變更 一次綁在一起。

真正穩的做法是:

- modules first
- live roots next
- state migration after that
- CI/CD 切換放在後面
- legacy deletion last

只要順序對了, `modules + live` 這種重構其實可以做得很穩, 不需要 big bang rewrite。

## References

- [Terraform import](https://developer.hashicorp.com/terraform/language/import)
- [Terraform state commands](https://developer.hashicorp.com/terraform/cli/commands/state)
- [Terraform remote state](https://developer.hashicorp.com/terraform/language/state/remote-state-data)
- [Cloud Run documentation](https://cloud.google.com/run/docs)
