雲端退場最後一哩:把 140GB 媒體檔和所有 Secrets 搬回自己的 VPS

大體積不可變檔案與高敏感小體積設定,是資料庫遷移沒覆蓋到的兩極:存儲層原地替換、secrets 三層解析順序、半成品考古學,以及「export → 異地備份 → 核對 → 才刪」的刪除紀律。

雲端退場最後一哩:把 140GB 媒體檔和所有 Secrets 搬回自己的 VPS

前言 (Introduction)

資料庫搬完之後,帳單上還剩兩個尾巴:物件儲存裡躺著約 140 GB 的媒體檔案(Podcast 音檔、逐字稿、摘要圖卡),以及雲端秘密管理服務裡的五十多個 secrets。兩者的月費都是百元台幣等級——說實話,放著不管也不會怎樣。

但我還是決定收完最後一哩,理由有二:一是既然目標是「不再依賴這朵雲」,留兩個活著的依賴就等於沒退場;二是這兩個尾巴各自代表一類很有代表性的遷移問題——大體積不可變檔案與高敏感小體積設定——正好是資料庫遷移沒有覆蓋到的兩極。

這篇記錄這兩場小遷移,以及收尾時的刪除紀律。


架構設計 (Architectural Overview)

媒體服務的前後對比:

【前】前端 → 物件儲存的公開 URL(檔案在美國,簽名 URL 有效期管理)
【後】前端 → 反向代理(Caddy)的 /media 路徑 → VPS 本地磁碟 file_server

秘密管理的前後對比:

【前】CI/CD 與服務啟動時,各自呼叫雲端秘密管理 API 拉取
【後】CI secrets 存在 CI 平台的加密儲存;執行期 secrets 存在 VPS 上
      root-only 的 env 檔;程式端解析順序:環境變數 → env 檔 → 雲端(過渡期 fallback)

方法論拆解 (Methodology Breakdown)

媒體:存儲層原地替換,呼叫端一行不動

資料庫遷移用了「同介面替換」,媒體遷移我用了同一招的變體:保留原本物件儲存服務類別的名字和方法簽名,把實作換成本地磁碟的原子寫入(先寫暫存檔再 rename,確保同檔案系統內的原子性)。磁碟目錄布局刻意做到與原儲存桶的路徑逐位元組一致——這讓「舊檔案搬過來」和「新檔案寫進來」落在同一套結構裡,URL 改寫也變成一次純前綴替換。

搬運本身用 rclone 從物件儲存拉到 VPS,可斷點續傳。140 GB 跨雲出口費大約付了五百元台幣——這是離開雲端的「過路費」,只付一次。

URL 改寫:資料層的一次性手術

三千多筆內容文件裡的媒體 URL,全部指向舊的物件儲存網域。改寫的做法保守到近乎無聊:先把整張表備份成一張 backup 表,然後用一條冪等的字串前綴替換 SQL 改寫 JSONB,改完立刻驗證「殘留舊網域筆數 = 0」。

冪等在這裡不是潔癖,是救命索——後面會講到,這條 SQL 因為我自己的時序失誤,總共跑了三次。

半成品考古學:兩套命名慣例的碰撞

動手接反向代理路由時,發現 Caddy 設定裡已經有一條 /media 路由——半年前某次未完成的遷移嘗試留下的,連當時 rsync 到一半的 43 GB 舊檔案樹都還在,而且用的是另一套目錄命名慣例(桶名縮寫 vs 完整桶名)。

這帶來一個典型的「半成品陷阱」:如果我沿用舊慣例,新程式碼要遷就它;如果用新慣例,舊樹就是 43 GB 的死重量,而且藏著一個會把 URL 改寫到「沒人服務的路徑」的舊腳本。我的處置:選定完整桶名為唯一慣例,刪除舊腳本、刪除舊檔案樹、在程式端加桶名白名單(不認識的桶名直接拋錯,永不靜默建目錄)。順帶把磁碟水位從 93% 清回 75%。

教訓:退場型專案最大的敵人常常不是新程式碼,而是以前那個做到一半的自己。

Secrets:有序切換,而不是一刀切

secrets 遷移最怕的是「換到一半,部署掛在沒有值的變數上」。我的解法是讓程式端的解析順序變成環境變數 → env 檔 → 雲端 fallback,然後分三步走:

  1. 先把 CI/CD 需要的九個 secrets 用管道搬進 CI 平台的加密儲存(來源 API 讀出、直接 pipe 進目標 CLI,值不落地、不進終端歷史)。
  2. 執行期的四十多個 secrets,在 VPS 上用一支腳本從雲端 API 拉下來直接寫成 root-only 的 env 檔——值從頭到尾沒離開那台機器。
  3. 觀察服務啟動 log 裡的「來源標記」(每個 secret 解析自哪一層,只記名稱不記值),直到 fallback 命中歸零,才算切換完成。

最難搬的是一個會「寫」秘密的自動化:某個第三方平台的 token 需要每 60 天自動輪換,輪換工作原本把新值寫回雲端秘密管理。CI 平台的 secrets 沒有方便的程式化寫入路徑,這個工作只能留在雲端,成為整場退場最後一個活著的依賴——也提醒了我:盤點 secrets 時,「誰會寫」比「誰會讀」更值得先問。

刪除紀律:export → 異地備份 → 核對 → 才刪

最後的刪除依這個順序執行:

  1. 文件資料庫先做官方 export(155 MB),存進物件儲存
  2. 物件儲存整包(含 export)備份到另一個物理位置(家用機器)——而且備份走 VPS → 本機的路徑,因為 VPS 的出口流量不計費,省下第二次跨雲過路費
  3. 核對:本地與 VPS 的檔案數逐一比對(兩邊都是近三萬個檔案、數目完全一致),大小差異確認為檔案系統計帳方式差異
  4. 核對通過,才依序刪除:資料庫 → 三個儲存桶
  5. 刪除後用 API 列表確認歸零

刪完之後,資料存在兩個物理位置(VPS 為即時副本、本機為冷備份),雲端歸零。


生產環境踩坑與優化 (Production Optimization)

我自己的時序失誤:凍結快照蓋掉了 URL 改寫。 停寫後我補跑了一次「最終全量快照」把文件資料庫的凍結狀態同步進鏡像——但快照裡的 URL 是舊網域,一口氣把三千多筆已改寫的 URL 蓋了回去。所幸舊儲存桶當時還活著(服務未中斷),而改寫 SQL 是冪等的,重跑一次即恢復。教訓:「資料同步」與「資料改寫」兩類操作的先後順序要在計畫裡明寫,尤其當同步來源是一份「舊世界」的凍結態。

磁碟水位管理是自架的隱藏成本。 140 GB 搬進來的當下,VPS 磁碟一度到 97%——而資料庫和它住在同一顆碟上。清理順序:容器建置快取(12 GB)、懸空映像檔、半年前的重複檔案樹(43 GB)。自架把「儲存幾乎無限」這個雲端預設拿走了,水位監控從此是日常。

遷移是最好的安全稽核。 為了畫拓撲圖逐台核對監聽埠時,發現有服務綁在 0.0.0.0 上、從外部可以直接觸達——其中包括一個資料庫和一個帶有寫入端點的內部 API。全部收回 loopback、只留反向代理單一出口。這跟帳單無關,卻可能是整案最有價值的發現:遷移逼你把每一個埠、每一條路由攤在桌上重看一遍,平常沒有任何理由做這件事。

簽名 URL → 公開 URL 是一個姿態決策,不是技術決策。 換到 file server 之後,媒體從「有效期簽名 URL」變成穩定公開 URL。對我的內容型態(本來就要公開發布的媒體)這是可接受的,但它值得被明說並簽核,而不是在遷移中默默發生——這也是對抗審查者堅持要我補一個明確決定的點。同場加映它抓到的另一個問題:上傳端丟棄了已驗證的 Content-Type、改信任客戶端檔名的副檔名,在公開網域上等於開了一個 stored XSS 的門;修法是副檔名一律從驗證過的 MIME type 推導。


總結 (Conclusion)

三篇寫完,把整個事件濃縮成一個決策流程:

拆帳單到 SKU → 用監控曲線找行為特徵 → 建成本模型 → 先砍係數(止血)→ 再砍結構(搬遷)→ 分階段、每階段可回滾 → 不可逆點集中紀律 → 備份核對後才刪除。

至於「該不該上雲」,我的答案變得很具體:當你的工作負載是讀取重、資料小、流量可預測、團隊就一個人,一台固定月費的 VPS 加上 PostgreSQL,幾乎在每個維度都贏——前提是你願意接手水位監控、備份紀律和安全稽核這些雲端原本替你做的事。雲端真正賣的從來不是機器,是這些看不見的運維;這次退場等於是我把它們一件一件接回自己手上,並且用兩個制度(階段化旗標推進、對抗審查)把風險鎖在可控範圍。

每月帳單從四萬多回到零,資料回到自己手上,順手修掉兩個安全漏洞——這一哩路,值得走完。


Reference