從單一 Terraform Root 到 Modules + Live: 分階段遷移、Import 與 State Surgery

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

我最後的結論很簡單:

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

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

遷移前的問題長什麼樣

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

1
2
3
4
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 刪掉, 換成新結構。

這樣通常不會成功。

我的目標結構

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

 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/

這個目標結構的重點是:

  • 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 最自然:

1
2
3
4
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 之類的東西上非常危險。

1
2
3
4
5
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

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

查目前 Cloud Run service 的 URL

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

查目前 service 內的 BASE_URL

1
2
3
4
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 陣列

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

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

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

查某個 secret 的最新值

1
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

0%