接手專案,先看帳單

因為老闆信用卡到期了要換新卡,我順便看了一下 AWS 帳單金額,發現比預期高。之前詢問外包商技術長(已離職),得到的回覆是:「服務都已經從新加坡遷移到台北了,除了 S3 有保留做備份,其他都刪除了。」

身為工程師,最不能接受的就是「應該是這樣」。我決定親自盤點。

名詞解釋

在繼續之前,先解釋一下會提到的 AWS 服務:

服務說明費用特性
S3 (Simple Storage Service)物件儲存服務,用來存放檔案、圖片、影片按儲存容量和請求次數計費
NAT Gateway讓私有子網路的資源能存取網際網路按小時計費,即使沒流量也要錢
Elastic IP固定的公開 IP 位址使用中免費,未關聯則收費
VPC (Virtual Private Cloud)虛擬私有網路,隔離你的雲端資源VPC 本身免費,但相關資源收費
Network Load Balancer負載平衡器,分散流量到多台伺服器按小時和處理的資料量計費
ECR (Elastic Container Registry)Docker 映像檔儲存庫按儲存容量計費

重點是:有些資源即使沒有流量,只要存在就會收費。NAT Gateway 和未關聯的 Elastic IP 就是典型的「隱形殺手」。

盤點遺留資源

# 檢查 EKS 叢集(Kubernetes 服務)
aws eks list-clusters --region ap-southeast-1
# 結果:空的 ✓

# 檢查 RDS(資料庫)
aws rds describe-db-instances --region ap-southeast-1
# 結果:空的 ✓

# 檢查 NAT Gateway
aws ec2 describe-nat-gateways --region ap-southeast-1 \
  --filter "Name=state,Values=available"
# 結果:2 個還在跑

完整盤點結果:

資源類型數量月費估算
NAT Gateway2~$65
Network LB1~$16
Elastic IP (未關聯)2~$7
VPC4-
ECR Repository5-
S3 Bucket1~$2
合計~$90/月

EKS 和 RDS 確實刪了,但網路層的資源全部還在。這就是口頭交接的風險——沒有文件記錄,就會有遺漏。

清理閒置資源

確認這些資源沒有在用(DNS 已指向台北、Target Group 是空的),開始清理:

# 刪除 Load Balancer
aws elbv2 delete-load-balancer --load-balancer-arn $LB_ARN

# 刪除 NAT Gateway
aws ec2 delete-nat-gateway --nat-gateway-id nat-07fb8958f69654f50

# 釋放 Elastic IP(NAT Gateway 刪除後才能釋放)
aws ec2 release-address --allocation-id eipalloc-xxxxx

# 刪除 VPC(需按相依性順序)
# Subnet → Internet Gateway → Route Table → Security Group → VPC

S3 的部分,先確認台北資料完整:

# 台北 S3:72.9 GiB,316,000 個檔案
# 新加坡 S3:49.6 GiB,2,300 個檔案
# 台北資料更完整,可以清理新加坡的
aws s3 rb s3://prod-s3-singapore --force

發現 Strapi 的 URL 儲存機制

清理完成後,我主動檢查網站功能,發現部分圖片載入失敗。打開 DevTools 查看:

GET https://prod-s3-singapore.s3.ap-southeast-1.amazonaws.com/image_abc123.png
→ 404 Not Found

有些 URL 還是指向舊的 S3。

排查資料庫

這個專案使用 Strapi CMS,檔案資訊存在 PostgreSQL。先檢查主要的 files 表:

SELECT COUNT(*) FROM files
WHERE url LIKE '%ap-southeast-1%';
-- 結果:0

主要 URL 欄位是乾淨的。但問題出在 Strapi 的縮圖機制

Strapi 的多層 URL 儲存

Strapi 上傳圖片時,會自動產生多種尺寸的縮圖。這些 URL 存在 formats 欄位(JSONB 格式):

{
  "thumbnail": {
    "url": "https://prod-s3-singapore.s3.../thumbnail_abc.png",
    "width": 156,
    "height": 156
  },
  "small": {
    "url": "https://prod-s3-singapore.s3.../small_abc.png",
    "width": 500,
    "height": 500
  }
}

系統性搜尋後,找到 URL 分散在四個地方:

位置筆數說明
files.url0主要 URL(遷移時已處理)
files.formats419縮圖 URL(JSONB)
layout_d_singles.content12頁面內容(HTML)
layout_d_singles.content_searchable12搜尋索引

這就是遷移時的技術債——只更新了表面的 URL,沒有處理到 JSONB 內的巢狀資料和富文本內容。

批次修復

針對不同的資料類型,使用對應的更新語法:

-- JSONB 欄位:需要轉型處理
UPDATE files
SET formats = REPLACE(
  formats::text,
  'prod-s3-singapore.s3.ap-southeast-1.amazonaws.com',
  'prod-s3-taipei.s3.ap-east-2.amazonaws.com'
)::jsonb
WHERE formats::text LIKE '%ap-southeast-1%';
-- UPDATE 419

-- TEXT 欄位:直接替換
UPDATE layout_d_singles
SET content = REPLACE(
  content,
  'prod-s3-singapore.s3.ap-southeast-1.amazonaws.com',
  'prod-s3-taipei.s3.ap-east-2.amazonaws.com'
)
WHERE content LIKE '%ap-southeast-1%';
-- UPDATE 12

重啟服務清除快取:

kubectl rollout restart deployment/strapi-prod
kubectl rollout restart deployment/web-prod

全部功能恢復正常。

Strapi 遷移檢查清單

基於這次經驗,整理出 Strapi 專案遷移時需要檢查的 URL 位置:

-- 1. 主要 URL
SELECT COUNT(*) FROM files WHERE url LIKE '%舊網域%';

-- 2. 縮圖 URL(JSONB)
SELECT COUNT(*) FROM files WHERE formats::text LIKE '%舊網域%';

-- 3. 富文本內容(各種 layout 表)
SELECT COUNT(*) FROM layout_a_singles WHERE content LIKE '%舊網域%';
SELECT COUNT(*) FROM layout_b_singles WHERE block_1 LIKE '%舊網域%';
SELECT COUNT(*) FROM layout_c_singles WHERE content LIKE '%舊網域%';
SELECT COUNT(*) FROM layout_d_singles WHERE content LIKE '%舊網域%';

-- 4. 搜尋索引
SELECT COUNT(*) FROM layout_d_singles WHERE content_searchable LIKE '%舊網域%';

遷移的正確順序

下次遷移時,記得按這個順序:

  1. 確認新區域資料完整
  2. 搜尋並更新所有舊 URL ← 這步最容易漏掉
  3. 測試所有功能正常
  4. 最後才刪除舊資源

總結

這次技術債清理的收穫:

  1. 每月省下 $90:清掉遺留的 NAT Gateway、LB、EIP
  2. 完成遷移收尾:修復 500+ 筆遺漏的 URL
  3. 建立檢查清單:未來 Strapi 遷移有標準流程

接手專案時,帳單是最好的健檢報告。有疑慮就自己查,不要只靠口頭確認。