每次部署都要編譯六個西洋棋引擎
簡要
- 三十天內,mistboard 發佈一次的中位耗時是 5 分 31 秒,479 次嘗試中有 148 次在進入生產環境前就失敗。
- 每次推送,部署都會從原始碼編譯六個西洋棋引擎:每次建置花掉兩分半鐘,而這些程式碼一年只改幾次。
- 現在改由一個 GitHub Actions 工作流程建置一次,並以其 pin 與 patch 的雜湊值發佈。部署只需 19 秒即可下載並驗證。
- 推送到上線從 5 分 36 秒降到 2 分 19 秒,代管 CI 從 215 秒降到 132 秒,因第二次推送而中斷的發佈從每月 11 次降到零。
mistboard 只從一個分支出貨。哪個 Claude Code 工作階段先完成,就由它推送 main,而每一次推送都是一次生產發佈:本地測試關卡、代管 CI、Railway 部署、對線上網站的煙霧測試。9 月 22 日那天共發生了 35 次。
問題:發佈要六分鐘,三次中有一次失敗
每次發佈都會為每個階段列印一行計時,而每個工作階段的紀錄都會保留下來。解析截至 9 月 22 日的 30 天紀錄,得到 479 次發佈嘗試,一天 16 次。
每次嘗試一個標記。圓圈是成功的發佈,叉號是失敗的嘗試,線是每週中位數。
| 階段 | 中位數 |
|---|---|
| 本地關卡 | 64 秒 |
| 代管 CI | 約 3.5 分鐘 |
| CI 之後,等待生產環境開始提供該 commit | 11 秒,p90 為 52 秒,最差 35 分鐘 |
| 煙霧測試 | 25 秒 |
| 整次發佈 | 5 分 31 秒,p90 為 7 分 48 秒 |
479 次嘗試中有 148 次在進入生產環境前失敗,而失敗原因和耗時一樣說明了問題:
| 嘗試失敗的原因 | 嘗試次數 |
|---|---|
| 本地關卡:格式錯誤或某個測試,在 60 到 90 秒時發現 | 41 |
推送被拒,因為關卡執行期間 main 已前進 |
30 |
| 代管 CI 亮紅燈 | 17 |
| 第二次推送取消了 CI 執行,因此該次發佈沒有結果 | 11 |
| 線上對局的排空失敗或沒有 token | 10 |
| 某個生產環境煙霧測試 | 4 |
| 輸出在說明原因前就被截斷 | 29 |
CI 之後那 11 秒的等待就是線索。它意味著在清閒時段,部署大約和 CI 同時完成;而 52 秒的 p90 與 35 分鐘的最差情況,意味著在忙碌的下午它是在 CI 之後才完成。兩份 Railway 建置日誌說明了原因:
fairy-stockfish-xiangqi fetch + make 12 s
fairy-stockfish-duck apply patch + make 13 s
fairy-stockfish-atomic apply patch + make 11 s
stockfish make, downloads nets 25 s
pikafish-jieqi make 13 s
pikafish make 40 s
npm run build 28 s
伺服器執行六個引擎二進位檔,每個都用一個 .ref 檔釘在某個 commit 上,而建置步驟在每次部署時都把六個全部編譯一遍:在 5 分 36 秒的部署中佔掉兩分半鐘,而這些程式碼一年只改幾次。這層永遠無法快取,因為建置步驟在複製原始碼之後執行,而每次推送都會改動原始碼。
解法:引擎只建置一次,依雜湊值取用
當某個 pin 或 patch 變動時,一個 GitHub Actions 工作流程會編譯這六個二進位檔,並以部署過去採用的方式逐一驗證(Fairy-Stockfish 對 uci 回應變體清單;每個打過 patch 的建置在三個局面上都與遊戲核心的合法著法數相符),然後以 engines-<hash> 為發佈名稱publish出來。該雜湊涵蓋 pin、patch 與一個 recipe 版本:
recipe_hash() {
{
echo "recipe-version=$RECIPE_VERSION"
echo "arch=$ARCH"
for input in $RECIPE_INPUTS; do
case "$input" in
*.ref) echo "$input=$(head -1 "$ROOT/$input" | tr -d '[:space:]')" ;;
*) echo "$input=$(sha256sum "$ROOT/$input" | cut -c1-64)" ;;
esac
done
} | sha256sum | cut -c1-12
}
部署會計算同一個雜湊值,下載該發佈,檢查 SHA256SUMS,並執行同樣的驗證步驟。沒有任何東西會解析成「latest」:若某個 pin 被更新但還沒人發佈,部署就會失敗並指出它想要的標籤。這些二進位檔採靜態連結,因此 runner 的 libc 與映像檔的不必一致。有一個測試負責讓工作流程的觸發路徑、Railway 的監看模式與部署步驟,和 recipe 的輸入保持同步。
同一份剖析結果還帶出四個較小的修正:
- 被另一次推送超越的發佈會改跟隨它。 當
main上較新的 commit 包含了已推送的那個,並且有自己的 CI 執行時,發佈會等待那次執行並對那次部署做煙霧測試,而不是失敗後把一切重跑一遍。 - Biome 在 commit 時對已暫存的檔案執行。 pre-commit hook 原本只跑型別檢查器;單是 9 月 22 日就有三個純格式的 commit,每一個都帶來一次推送、一次 CI 執行與一次部署。
- 推送關卡只執行該變更會觸及的網頁測試,透過
vitest --changed:改一個末端檔案時只跑 6 個測試、4 秒,而不是 3,358 個。代管 CI 仍然全部執行。 - CI 工作的大小依實測決定。 有一個走遍 280 萬個局面的一致性測試佔了遊戲測試套件 34 秒,已移到自己的工作中。網頁、伺服器與 Postgres 套件都經過分片,讓每個工作落在 64 到 98 秒之間,而且只要必要的工作轉綠,發佈就算通過,不必等到該次執行的回報工作結束。
成果
| 九月 | 現在 | |
|---|---|---|
| 部署,推送到上線 | 清閒時 4 分 18 秒,一般 5 分 36 秒 | 2 分 19 秒 |
| 建置中的引擎步驟 | 約 150 秒的編譯 | 19 秒下載並驗證 |
| 代管 CI 執行 | 175 到 215 秒 | 132 秒 |
| 本地關卡,僅網頁變更 | 64 秒 | 不到 20 秒 |
| 因第二次推送而中斷的發佈 | 每月 11 次 | 0 |
現在一次僅含網頁變更的發佈,約花 20 秒在本地關卡、兩分半鐘在 CI(部署在其底下同時完成)、25 秒在煙霧測試。引擎建置只跑了一次,在 runner 上花 153 秒,而新路徑上的第一次部署下載了 218 MB,並在 19 秒內驗證完六個二進位檔。
還剩下什麼
GitHub 的 runner 佇列,在我觀察的那些執行中每個工作 0 到 40 秒;以及 Railway 的固定成本:映像檔設定、網頁建置、推送與啟動映像檔。這兩項都不是我能縮短的。
本地關卡是那個在負載沉重的機器上執行的關卡。一台筆電上跑八個工作階段、平均負載超過 100 時,有兩個測試只在那裡失敗、其他地方都不會:一個只給假引擎 200 毫秒啟動時間,另一個為每個變更日誌連結各開一個 git 程序,共 137 個。兩者在閒置機器上都已連續幾週通過。現在它們的預算是按忙碌狀態來設定的。
148 次失敗中有三十次是因為關卡執行期間 main 前進而被拒的推送。20 秒的關卡讓這個空窗變窄,但沒有關上;佇列可以關上它,而那是下一個該去量測、而非直接動手蓋的東西。
這些紀錄本來就已包含本文中的每個數字,正是 peers 工具用來得知誰在做什麼的同一批檔案。現在每次發佈會自己寫下紀錄,一行 JSON,含各階段耗時與失敗原因,而 npm run release:profile 會印出中位數、失敗表格與上面那張圖。十月底我會再跑一次,並貼出有降幅的那張圖,或者沒有降幅的。recipe、工作流程與發佈腳本都在 brianhliou/mistboard:scripts/engine-assets.sh、.github/workflows/build-engines.yml、scripts/release-prod.mjs。