遷移一套已經在正式環境運作的系統,和部署一套新系統是兩件不同的工作。
不能遺失資料、不能在使用者正在操作時中斷服務、失敗時要能退回。其餘的做法,都是從這三項限制推導出來的。
遷移前的架構
這套系統的前端是 Next.js、後端是 Flask、資料庫是 PostgreSQL。在 AWS 上的組成如下。
- ECS Fargate — 前端與後端各自為獨立服務,透過 ALB 對外提供。
- Aurora PostgreSQL 14 — 位於私有子網路,無法從網際網路連線。
- Route 53 — 根網域指向前端、api 子網域指向後端,兩者都是指向 ALB 的 ALIAS 記錄。
- ACM — 包含萬用字元的憑證掛在 ALB 上,採用 DNS 驗證方式。
本文將實際的網域名稱替換為 example.com 與 api.example.com 來說明。
把工作切成三個階段
把整個遷移一次做完,失敗時就無從切分原因。這次我們刻意切分,讓「使用者會察覺的操作」只剩一個。
Phase 1 遷移資料庫 無影響(只是複製)
Phase 2 移轉 DNS 代管 無影響(不變更記錄內容)
Phase 3 切換網域 只有這一步會影響使用者Phase 1 與 2 完全不需要碰到運作中的服務。把準備都做完之後,再用 Phase 3 一次切換。這樣一來,退回時也只需要還原 Phase 3。
「不需要維護時間」成立的條件
這裡有一個必須講清楚的前提。pg_dump 取得的是某個時間點的快照,在備份之後才寫入舊環境的資料,不會進到新環境。
服務不會中斷,所以確實是「不停機」,但光是這樣並不等於「沒有資料遺失」。要同時滿足兩者,就必須確保從取得備份到完成切換的這段期間,舊環境不會發生寫入。
達成方式有三種。
- A. 收斂在沒有寫入的時段內 — 把從備份到切換的整個過程,收在經過實測確認沒有寫入的時段裡。不需要停止任何東西,但對於持續有寫入的系統就不適用。
- B. 切換前停止寫入並做最後同步 — 在切換前把應用程式設為唯讀或停止,在該狀態下重新備份並匯入。會產生數分鐘的寫入停止,但可以保證資料一致。
- C. 以邏輯複寫持續同步 — 持續追隨差異直到切換為止。寫入停止時間幾乎為零,但架設與驗證的成本最高。
這次我們選擇 A,前提是已確認對象是只在上班時間使用的內部系統。如後文所述,我們取出四週份的存取實績,找出不會發生寫入的時段,並把備份到切換都收在其中。
採用 A 時,請在切換後確認舊環境沒有收到寫入。查看負載平衡器的請求數,或應用程式記錄中的寫入類請求即可判斷。若真的有寫入進來,就必須手動搬移那些資料,或改用 B 重做一次。
如果是全天候都有寫入的服務,請改以 B 或 C 來規劃。本文後續的步驟在 A、B、C 三種做法下都通用,差別只在於「什麼時候取備份」。
從連不到的資料庫取得備份
Aurora 位於私有子網路,PubliclyAccessible 為 false,安全群組也只允許特定安全群組連線 5432 埠。也就是說,無法直接從本機執行 pg_dump。
這時 Session Manager 的遠端主機連接埠轉送就派上用場。只要 VPC 內有執行 SSM 代理程式的跳板機,跳板機本身並不需要安裝 PostgreSQL 用戶端。我們只借用通道,備份檔則直接寫到本機。
aws ssm start-session \
--target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["mycluster.cluster-ro-xxxxxxxx.ap-northeast-1.rds.amazonaws.com"],
"portNumber":["5432"],
"localPortNumber":["15432"]}'主機指定的是唯讀端點(cluster-ro-)。備份本來就只會讀取,但把連線路徑本身設成無法寫入,就少了一個出錯的空間。
跳板機的安全群組,有時可以沿用既有的應用程式安全群組。只要資料庫端已經允許該群組,整個過程就不必變更任何正式環境的安全群組。
備份階段踩到的三個陷阱
通道打通之後,接下來卡了三次。這些在遷移作業中都相當常見。
1. 主密碼已經輪替過
使用 RDS 代管主密碼時,密碼會存放在 Secrets Manager 並自動輪替。這次的週期是 7 天。
另一方面,應用程式參照的是複製到 Parameter Store 的值,而這份副本不會自動更新。結果就是幾天前還能用的密碼,現在會以 password authentication failed 被拒絕。
SECRET_ARN=$(aws rds describe-db-clusters \
--db-cluster-identifier my-cluster \
--query "DBClusters[0].MasterUserSecret.SecretArn" --output text)
export PGPASSWORD=$(aws secretsmanager get-secret-value \
--secret-id "$SECRET_ARN" --query SecretString --output text \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["password"])')這裡刻意透過變數傳遞 ARN 是有原因的。RDS 代管密鑰的名稱含有 rds!cluster-,而在 zsh 中即使包在雙引號裡,! 仍會觸發歷史展開而出現 event not found。改用單引號,或先存進變數再使用,會比較保險。
2. pg_dump 與 pg_restore 版本不同
自訂格式的備份檔會記錄產生它的 pg_dump 所對應的格式版本。用較舊的 pg_restore 讀取較新的備份檔,會出現以下錯誤。
pg_restore: error: unsupported version (1.16) in file header在安裝了多套 PostgreSQL 用戶端的環境中,pg_dump 與 pg_restore 很容易來自不同的安裝路徑。作業前先確認兩者的 --version 會比較快。
3. localhost 被解析成 IPv6
Session Manager 的外掛只會繫結到 127.0.0.1。在 macOS 上寫 -h localhost 時,會先嘗試連線 ::1 而失敗,讓輸出多出一行錯誤。
connection to server at "localhost" (::1), port 15432 failed: Connection refusedlibpq 會退回 IPv4,所以最終仍能連上,但真正的原因會被藏在第二行之後而不易閱讀。明確寫成 -h 127.0.0.1,排查問題時會輕鬆許多。
還原之後要確認的事
KamuiDash 的資料庫有「外部連線用」與「應用程式用」兩種使用者。用外部連線還原時,建立出來的資料表擁有者會是外部連線用使用者。在這個狀態下,應用程式執行 migration 時會出現擁有者錯誤。
REASSIGN OWNED BY app_external TO app_user;資料表與序列會一起轉移,不會有遺漏。執行條件是執行者必須是目標角色的成員。
另一個一定要看的,是各序列的目前值。這裡如果落後,第一筆 INSERT 就會因主鍵重複而失敗。
SELECT t.tablename,
s.last_value,
(xpath('/row/max/text()',
query_to_xml(format('SELECT max(id) AS max FROM public.%I', t.tablename),
false, true, '')))[1]::text::bigint AS max_id
FROM pg_tables t
JOIN pg_sequences s ON s.sequencename = t.tablename || '_id_seq'
WHERE t.schemaname = 'public'
ORDER BY 1;完整備份含有 setval,因此 last_value 通常會大於 max_id。這項確認完全不需要寫入任何一筆資料。
權限本身也可以用 has_table_privilege 與 has_sequence_privilege 查詢,不必寫入測試資料來驗證。
移轉 DNS,但不移動流量
Route 53 的 ALIAS 記錄只能指向 AWS 資源。移轉到其他 DNS 服務時,無法直接重現 ALIAS。尤其是根網域,依 DNS 規範無法設定 CNAME。
若 DNS 服務支援 CNAME flattening,就能在根網域設定 CNAME。這次我們選擇 Cloudflare 作為移轉目的地。
重點在於,移轉代管與切換流量是兩回事。只要不變更記錄內容,改變的只是「由誰回答 DNS」,指向的位置並不會變。
example.com CNAME dualstack.my-alb-000000.ap-northeast-1.elb.amazonaws.com
api.example.com CNAME dualstack.my-alb-000000.ap-northeast-1.elb.amazonaws.com
_xxxxxxxx.example.com CNAME _yyyyyyyy.acm-validations.aws就算切換了名稱伺服器,流量仍會送到 ALB。在這個狀態下確認解析結果之後,再往下一步走。
自動掃描無法重現 ALIAS
有些 DNS 服務在導入時會自動讀取既有記錄。但 ALIAS 從外部看起來就是 A 記錄,因此掃描結果會變成直接寫入負載平衡器 IP 位址的 A 記錄。
ALB 的 IP 位址並不固定。就在這次作業期間,三個位址中有一個在一天之內換掉了。直接寫死 IP,遲早會安靜地壞掉。請改用指向負載平衡器 DNS 名稱的 CNAME。
請關閉代理
在具備 CDN 功能的 DNS 服務上,建立記錄時代理常常預設為開啟。遷移期間請保持關閉。開啟後 SSL 的終止位置會改變,與來源端的憑證設定組合起來,可能造成錯誤或轉址迴圈。
切換到 KamuiDash 的自訂網域之後,也請維持關閉。代理疊代理的架構並不在支援範圍內。
切換前就能先確認
在變更名稱伺服器之前,可以直接向新的名稱伺服器查詢,確認記錄是否正確。
dig @新的名稱伺服器 example.com +short
dig @新的名稱伺服器 api.example.com +short如果回傳與現況相同的 IP,就代表切換後結果不會改變。先做過這項確認,名稱伺服器變更就只是「換人回答」的作業而已。
若網域已啟用 DNSSEC,變更名稱伺服器前必須先在註冊商端停用。在啟用狀態下切換,可能導致網域無法連線。
為什麼後端也需要自訂網域
KamuiDash 在建立應用程式的當下,就會發行 應用程式名稱.kamui-platform.com 的網址。由於在萬用字元憑證的涵蓋範圍內,建立後立刻就能以 HTTPS 使用。
因此很容易會想「只要前端用自訂網域,後端維持發行的網址就好」。這次我們也評估過,但這個組合行不通。
原因在於工作階段 Cookie。這套系統採用 Cookie 認證,前端在所有 API 呼叫都指定了 credentials: 'include'。
example.com -> api.example.com
可註冊網域相同 第一方 Cookie ○
example.com -> myapp.kamui-platform.com
可註冊網域不同 第三方 Cookie ×Cookie 會不會被送出,取決於 SameSite 屬性與瀏覽器的第三方 Cookie 政策。未設定 SameSite 屬性時,Chrome 會視為 Lax,跨站的 fetch 就不會帶上 Cookie。Safari 則透過 ITP 預設封鎖第三方 Cookie。
允許 CORS 並不能解決
這裡很容易混淆。CORS 與 Cookie 傳送是不同層級的機制。
① CORS 伺服器允許某個來源讀取回應
Access-Control-Allow-Origin / Allow-Credentials
② Cookie 傳送 瀏覽器判斷是否要附上 Cookie
SameSite 屬性 / 第三方 Cookie 政策Access-Control-Allow-Credentials: true 只通過①,並不會強制瀏覽器送出 Cookie。若在②被擋下,抵達伺服器的請求就不帶 Cookie,形成「CORS 標頭正常,卻只有認證過不了」的狀態。
curl 等 HTTP 用戶端工具並未實作 SameSite 或 ITP,因此重現不出這個問題。只要 CORS 通過,看起來就像成功。請用實際瀏覽器驗證,並且要包含 Safari。
結論是,前端與後端必須位於相同的可註冊網域之下。前端要用 example.com,後端就必須是 api.example.com。
正因為是 TXT 驗證,才能不停機切換
在 KamuiDash 註冊自訂網域後,畫面會顯示需要設定的 CNAME 與 TXT。TXT 用於確認網域擁有權與簽發 SSL 憑證。
CNAME api.example.com custom.kamui-platform.com
TXT _acme-challenge.api.example.com 畫面顯示的驗證值這是整個遷移在設計上的關鍵。由於驗證透過 TXT 進行,憑證可以在與 CNAME 指向無關的情況下簽發。
1. 只新增 TXT CNAME 維持原狀 → 無影響
2. 等待驗證完成 這段期間服務仍在舊環境正常運作
3. 切換 CNAME 準備已就緒,切換當下即可正常回應如果是 HTTP 驗證(在指定路徑放置檔案的方式),就必須先切換 CNAME,也就無法避免驗證完成前的停機。採用 TXT 驗證,正是這次不需要維護時間的前提。
也不需要急著切換。今天先加 TXT、隔天再改 CNAME,這樣的步調完全可行。
留意在建置階段就固定下來的設定
Next.js 中帶有 NEXT_PUBLIC_ 前綴的環境變數,會在建置時嵌入 JavaScript bundle。執行階段變更環境變數並不會生效。
若 API 的指向是用這種方式傳入,切換網域時就必須重新建置。這次採用的順序如下。
1. 將 api.example.com 切換到 KamuiDash 的後端
2. 把前端的 API URL 改成 https://api.example.com 並重新部署
3. 將 example.com 切換到 KamuiDash 的前端若把 2 排在 1 之前,前端就會指向尚未切換的 API。反之若跳過 2 直接做到 3,前端會持續呼叫平台發行的網址,於是又回到前面提到的第三方 Cookie 問題。
實際呼叫的位址,可以取得已發布的 bundle 來確認。看正在提供的產出物,會比看設定畫面更可靠。
curl -s https://example.com/ \
| grep -oE '/_next/static/[^"]+\.js' | sort -u \
| while read -r p; do curl -s "https://example.com$p"; done \
| grep -oE 'https://[a-zA-Z0-9._-]+\.example\.com' | sort -u用數據決定切換時段
「週末應該沒人用」這種前提,最好先確認過再採用。這次我們取出四週份的 ALB 請求數來判斷。
結果是週日連續四週都很低,但其中有一個週六的存取量達到平日水準。從時段分布來看,明顯是有人在處理業務。建議不要只看星期別,而要細看到每日、每時段。
如果手上有存取記錄,那就是最可靠的依據。把狀態推進到能說出「這個時段過去四週都是零」,再進行切換。
遷移期間中,同一個 URL 可能指向不同的實體
這次花掉最多時間的就是這一點。
DNS 的切換不是瞬間完成的。名稱伺服器的 TTL 預設為 172800 秒(兩天),而且會因為使用哪一個解析器而在不同時間切換。在這段期間,同一個 URL 可能解析到新環境,也可能解析到舊環境。
更麻煩的是,每筆記錄傳播的時間點並不一致。這次就出現了以下狀態。
example.com → 舊環境(尚未傳播)
api.example.com → 新環境(先完成傳播)舊環境的前端原本就是以呼叫 api.example.com 的方式建置的。因此在這個組合下,
- 頁面由舊環境提供
- API 由新環境回應
- 兩者的可註冊網域相同,Cookie 也正常運作
結果就是畫面上看起來完全正常。 可以登入,Safari 也沒問題,寫入的資料也確實進到新環境的資料庫。看起來就像遷移成功了。
問題在於,這些都不能當作遷移完成的證據。新環境的前端一次都沒有被驗證到。 要等傳播完成、新環境的前端真正出現在使用者面前,缺陷才會浮現。
實際上這次新環境的前端仍然指向舊的 API 位址。由於舊環境的前端持續提供著正確的 bundle,這個事實被隱藏了整整兩天,直到傳播完全結束、無法登入時才被發現。
要確認的是實體,不是 URL
「打開 URL 可以動」並不算驗證。必須確認到究竟是哪一個實體回應的。
# 解析到的位址屬於舊環境還是新環境
dig +short example.com
# 從回應標頭判斷提供者
curl -sI https://example.com/ | grep -iE "server|via|x-powered-by"
# 直接指名新環境(透過平台發行的 URL)
curl -s https://myapp.example-platform.com/ | head最可靠的做法,是完全不經過 DNS 直接連到新環境。如果平台有發行預設 URL 就使用它,沒有的話就直接對容器做連接埠轉送。這樣可以把 DNS 這個不確定因素從路徑中排除。
同時也請在映像檔倉庫確認建置是否真的執行過。設定畫面顯示的環境變數是「已設定的值」,而不是「正在提供的產出物裡的值」。這次的情況是,變更環境變數之後一次都沒有產生新的映像檔。
aws ecr describe-images --repository-name myapp \
--query "sort_by(imageDetails,&imagePushedAt)[-1].[imagePushedAt,to_string(imageTags)]" \
--output text如果沒有比變更環境變數更新的映像檔,代表該變更並未反映到產出物。重新部署的按鈕究竟是「重新啟動」還是「重新建置」,會因平台而異。以是否產生新映像檔來判斷最為確實。
切換後的確認
切換之後,要從外部確認是否如預期移轉完成。以下都是唯讀操作。
- 用多個解析器查詢 — 本機解析器會快取名稱伺服器。指定幾個公共解析器查詢,就能掌握傳播狀況。
- 檢查 bundle 內容 — 從實際提供的檔案,確認前端呼叫的是哪一個 API。
- 確認舊環境的流量是否停止 — 若負載平衡器的請求數降到接近零,代表流量已經移轉。
- 確認 CORS 標頭 — 檢查新的來源是否被允許。即使是需要認證的端點,也能從 401 回應的標頭確認。
為了能退回而保留的東西
即使遷移完成,也請不要立刻刪除舊環境。只要 DNS 的 TTL 夠短,把記錄改回去幾分鐘內就能復原。能維持這個選項多久,就等於遷移有多安全。
其中最容易被忽略的,是 ACM 的 DNS 驗證記錄。
_xxxxxxxx.example.com CNAME _yyyyyyyy.acm-validations.awsACM 的代管更新會在憑證到期前再次參照這筆記錄。若在遷移後判斷它已無用而刪除,更新就會失敗,舊環境的憑證也會過期。既然要把舊環境當作退回目標,這筆記錄就必須一併保留。
判斷標準很簡單。在刪除舊環境的負載平衡器時,一併清理。在那之前都不要動。
總結
這次遷移中真正發揮作用的有三點。
- 把有影響的操作壓到只剩一個 — 先完成資料庫複製與 DNS 移轉,讓會影響使用者的只剩 CNAME 切換。
- 以實測掌握沒有寫入的時段 — 備份是快照,之後的寫入不會跟著過去。一開始就要決定是收在無寫入的時段內,還是在切換前停止寫入。
- 善用 TXT 驗證 — 能把憑證準備與流量切換分開,因此不需要維護時間。
- 維持可以退回的狀態 — 保留舊環境,以及其憑證更新所需的記錄。
遷移作業的大半,其實是調查。資料在哪裡、什麼指向哪裡、哪個設定會在什麼時候固定下來。一旦弄清楚這些,實際執行本身很快就結束了。
關於 KamuiDash 的設計理念,以及從 GitHub 部署的基本流程,請參閱為什麼使用 KamuiDash。