簡要

  • 三十天內,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 次。

截至 9 月 22 日的 30 天內每次嘗試的發佈耗時:大多數成功的發佈落在五到八分鐘之間,失敗則散落在一天各時段、各種耗時,而每週中位數線始終平坦 每次嘗試一個標記。圓圈是成功的發佈,叉號是失敗的嘗試,線是每週中位數。

階段 中位數
本地關卡 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。