從 ECS Fargate 與 Aurora PostgreSQL
搬到 KamuiDash。

我們把一套執行於 ECS Fargate 與 Aurora PostgreSQL 的業務系統遷移到 KamuiDash。透過實測找出不會發生寫入的時段並在其中完成切換,因此不需要安排維護時間。這篇文章記錄資料庫、DNS、網域這三個階段,以及途中踩到的陷阱。

遷移一套已經在正式環境運作的系統,和部署一套新系統是兩件不同的工作。
不能遺失資料、不能在使用者正在操作時中斷服務、失敗時要能退回。其餘的做法,都是從這三項限制推導出來的。

遷移前的架構

這套系統的前端是 Next.js、後端是 Flask、資料庫是 PostgreSQL。在 AWS 上的組成如下。

本文將實際的網域名稱替換為 example.comapi.example.com 來說明。

遷移前後的架構
遷移前 / AWS Route 53(ALIAS) Application Load Balancer ECS Fargate 前端 ECS Fargate 後端 私有子網路 Aurora PostgreSQL 14 無法從網際網路連線 遷移 遷移後 / KamuiDash Cloudflare DNS(CNAME) custom.kamui-platform.com Web 應用程式 example.com Web 應用程式 api.example.com 東京區域 Postgres 於同一專案內管理 由單一負載平衡器承接兩個主機名稱 兩個應用程式各自擁有自訂網域
遷移前由單一 ALB 承接根網域與 api 兩個主機名稱,再轉送到 ECS Fargate。遷移後每個應用程式各自擁有自訂網域,由 KamuiDash 的邊緣依主機名稱分流。資料庫則移到同一專案內的 Postgres。

把工作切成三個階段

把整個遷移一次做完,失敗時就無從切分原因。這次我們刻意切分,讓「使用者會察覺的操作」只剩一個。

階段
Phase 1  遷移資料庫          無影響(只是複製)
Phase 2  移轉 DNS 代管        無影響(不變更記錄內容)
Phase 3  切換網域             只有這一步會影響使用者

Phase 1 與 2 完全不需要碰到運作中的服務。把準備都做完之後,再用 Phase 3 一次切換。這樣一來,退回時也只需要還原 Phase 3。

作業順序與產生影響的位置
仍由舊環境(AWS)提供服務 — 不影響使用者 由新環境提供服務 PHASE 1 匯出與還原資料庫 確認擁有者與序列值 PHASE 2 移轉 DNS 代管 不變更記錄內容 PHASE 3-a 新增 TXT 並等待驗證 先不要動 CNAME PHASE 3-b 切換 CNAME 只有這裡會有影響 退回時只要把 CNAME 改回去 前提是舊環境仍然保留 所有準備都在事前完成
Phase 1 到 3-a 都不會碰到運作中的服務,可以慢慢進行。唯一會影響使用者的是 Phase 3-b 的 CNAME 切換,即使失敗也只要把記錄改回去就能復原。不過資料的新鮮度還有下一節說明的條件。

「不需要維護時間」成立的條件

這裡有一個必須講清楚的前提。pg_dump 取得的是某個時間點的快照,在備份之後才寫入舊環境的資料,不會進到新環境。

服務不會中斷,所以確實是「不停機」,但光是這樣並不等於「沒有資料遺失」。要同時滿足兩者,就必須確保從取得備份到完成切換的這段期間,舊環境不會發生寫入

達成方式有三種。

這次我們選擇 A,前提是已確認對象是只在上班時間使用的內部系統。如後文所述,我們取出四週份的存取實績,找出不會發生寫入的時段,並把備份到切換都收在其中。

採用 A 時,請在切換後確認舊環境沒有收到寫入。查看負載平衡器的請求數,或應用程式記錄中的寫入類請求即可判斷。若真的有寫入進來,就必須手動搬移那些資料,或改用 B 重做一次。

如果是全天候都有寫入的服務,請改以 B 或 C 來規劃。本文後續的步驟在 A、B、C 三種做法下都通用,差別只在於「什麼時候取備份」。

從連不到的資料庫取得備份

Aurora 位於私有子網路,PubliclyAccessible 為 false,安全群組也只允許特定安全群組連線 5432 埠。也就是說,無法直接從本機執行 pg_dump

這時 Session Manager 的遠端主機連接埠轉送就派上用場。只要 VPC 內有執行 SSM 代理程式的跳板機,跳板機本身並不需要安裝 PostgreSQL 用戶端。我們只借用通道,備份檔則直接寫到本機。

port-forward.sh
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 被拒絕。

get-password.sh
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_dumppg_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 refused

libpq 會退回 IPv4,所以最終仍能連上,但真正的原因會被藏在第二行之後而不易閱讀。明確寫成 -h 127.0.0.1,排查問題時會輕鬆許多。

還原之後要確認的事

KamuiDash 的資料庫有「外部連線用」與「應用程式用」兩種使用者。用外部連線還原時,建立出來的資料表擁有者會是外部連線用使用者。在這個狀態下,應用程式執行 migration 時會出現擁有者錯誤。

reassign.sql
REASSIGN OWNED BY app_external TO app_user;

資料表與序列會一起轉移,不會有遺漏。執行條件是執行者必須是目標角色的成員。

另一個一定要看的,是各序列的目前值。這裡如果落後,第一筆 INSERT 就會因主鍵重複而失敗。

check-sequences.sql
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_privilegehas_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 的自訂網域之後,也請維持關閉。代理疊代理的架構並不在支援範圍內。

切換前就能先確認

在變更名稱伺服器之前,可以直接向新的名稱伺服器查詢,確認記錄是否正確。

verify.sh
dig @新的名稱伺服器 example.com +short
dig @新的名稱伺服器 api.example.com +short

如果回傳與現況相同的 IP,就代表切換後結果不會改變。先做過這項確認,名稱伺服器變更就只是「換人回答」的作業而已。

若網域已啟用 DNSSEC,變更名稱伺服器前必須先在註冊商端停用。在啟用狀態下切換,可能導致網域無法連線。

為什麼後端也需要自訂網域

KamuiDash 在建立應用程式的當下,就會發行 應用程式名稱.kamui-platform.com 的網址。由於在萬用字元憑證的涵蓋範圍內,建立後立刻就能以 HTTPS 使用。

因此很容易會想「只要前端用自訂網域,後端維持發行的網址就好」。這次我們也評估過,但這個組合行不通。

原因在於工作階段 Cookie。這套系統採用 Cookie 認證,前端在所有 API 呼叫都指定了 credentials: 'include'

Cookie 送不送得到
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 來確認。看正在提供的產出物,會比看設定畫面更可靠。

check-bundle.sh
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 的方式建置的。因此在這個組合下,

結果就是畫面上看起來完全正常。 可以登入,Safari 也沒問題,寫入的資料也確實進到新環境的資料庫。看起來就像遷移成功了。

問題在於,這些都不能當作遷移完成的證據。新環境的前端一次都沒有被驗證到。 要等傳播完成、新環境的前端真正出現在使用者面前,缺陷才會浮現。

實際上這次新環境的前端仍然指向舊的 API 位址。由於舊環境的前端持續提供著正確的 bundle,這個事實被隱藏了整整兩天,直到傳播完全結束、無法登入時才被發現。

要確認的是實體,不是 URL

「打開 URL 可以動」並不算驗證。必須確認到究竟是哪一個實體回應的

verify-origin.sh
# 解析到的位址屬於舊環境還是新環境
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 這個不確定因素從路徑中排除。

同時也請在映像檔倉庫確認建置是否真的執行過。設定畫面顯示的環境變數是「已設定的值」,而不是「正在提供的產出物裡的值」。這次的情況是,變更環境變數之後一次都沒有產生新的映像檔。

check-image.sh
aws ecr describe-images --repository-name myapp \
  --query "sort_by(imageDetails,&imagePushedAt)[-1].[imagePushedAt,to_string(imageTags)]" \
  --output text

如果沒有比變更環境變數更新的映像檔,代表該變更並未反映到產出物。重新部署的按鈕究竟是「重新啟動」還是「重新建置」,會因平台而異。以是否產生新映像檔來判斷最為確實。

切換後的確認

切換之後,要從外部確認是否如預期移轉完成。以下都是唯讀操作。

為了能退回而保留的東西

即使遷移完成,也請不要立刻刪除舊環境。只要 DNS 的 TTL 夠短,把記錄改回去幾分鐘內就能復原。能維持這個選項多久,就等於遷移有多安全。

其中最容易被忽略的,是 ACM 的 DNS 驗證記錄。

不要刪除的記錄
_xxxxxxxx.example.com  CNAME  _yyyyyyyy.acm-validations.aws

ACM 的代管更新會在憑證到期前再次參照這筆記錄。若在遷移後判斷它已無用而刪除,更新就會失敗,舊環境的憑證也會過期。既然要把舊環境當作退回目標,這筆記錄就必須一併保留。

判斷標準很簡單。在刪除舊環境的負載平衡器時,一併清理。在那之前都不要動。

總結

這次遷移中真正發揮作用的有三點。

遷移作業的大半,其實是調查。資料在哪裡、什麼指向哪裡、哪個設定會在什麼時候固定下來。一旦弄清楚這些,實際執行本身很快就結束了。

關於 KamuiDash 的設計理念,以及從 GitHub 部署的基本流程,請參閱為什麼使用 KamuiDash