事情是這樣的。
我的 GitHub Actions 額度不是偶爾不夠,而是連續幾個月,每個月 2,000 分鐘都被燒光。
最後我只好把自己的 Mac 拿來當 self-hosted runner。
但問題沒有因此消失。
改 Backend → BE、FE、App 全跑
改 Frontend → BE、FE、App 全跑
改文件 → 還是全部跑
把 CI 搬到 Mac,只是把 GitHub 分鐘換成另一種成本:
- Mac 必須一直開著
- Backend 和 Frontend 搶 CPU
- VM 偶爾卡住
- Cache 和磁碟會越來越大
- Runner 掛掉後,GitHub job 只會一直排隊
- 不必要的 Build 還是照跑
我後來才發現,真正的問題不是 GitHub 給的分鐘太少,而是 Pipeline 根本不知道這次改了什麼。
先不要急著加 Runner
一開始我也想過增加 cache、CPU、VM,或直接減少測試。
但如果不知道時間花在哪裡,這些都只是在猜。
所以我先把 CI 拆開量:
Runner 排隊
Checkout
Dependencies install
Static checks
Security checks
Tests
Build
Provider deployment
Production readback
這時才發現,CI 慢和部署慢其實是兩件事。
有一次 SpeakUp 完整 Publisher 花了 13 分 34 秒,其中 Railway API 和 Worker publication 約占量測時間的 79%。所以再怎麼優化 Cloudflare,都不會解決主要問題。
Backend 和 Frontend 可以並行
原本的流程比較接近:
Backend → Frontend → Deploy
我先改成:
Backend checks ─┐
├→ Aggregate Gate → Deploy
Frontend checks ─┘
Backend 和 Frontend 使用不同 runner,同時安裝依賴、執行測試。
最近一次 PR exact-head CI:
- Backend:5 分 21 秒
- Frontend:4 分 16 秒
- GitGuardian:15 秒
如果串行執行,Backend 加 Frontend 會接近十分鐘。並行後,主要等待時間是比較慢的 Backend,大約五分多鐘。
但是我沒有拿掉任何 required check。部署仍然要求:
backend-complete = success
frontend-complete = success
GitGuardian = success
任何一條失敗,都不能繼續發布。
Backend 裡面再拆一次
Backend 自己也不是只能從頭排到尾。
我保留一次 dependency install、一個完整 regression suite owner,以及一個 PostgreSQL 測試 owner,再把互不衝突的工作並行:
Dependencies install
├─ Source / static checks
├─ Readiness / security checks
└─ Migration / full regression
三條 lane 最後仍然回到同一個 backend-complete。任何一條失敗,整個 Backend check 就失敗。
一次 main CI 的 Backend 從 6 分 14 秒降到 5 分 05 秒,少了 69 秒。這只是一筆真實樣本,還不能說是穩定提升 18%。要累積相同 workload 的多次結果,才能談 P50 或 P95。
有變更才跑需要的部分
完成並行後,我回頭看最大的浪費:為什麼只改文件,也要跑 3,000 多個 Backend tests 和 700 多個 Frontend tests?
所以我加入 changed-scope classifier:
Backend 變更 → Backend
Frontend 變更 → Frontend
文件變更 → 略過昂貴的產品測試
CI/Release → Backend + Frontend
Shared contract → Backend + Frontend
無法判斷 → Backend + Frontend
Pull Request 使用三點比較:
merge-base...PR-head
Main push 使用這次真正進入 main 的兩點比較:
before..head
如果 SHA 無效、Git history 不完整或 classifier 發生錯誤,我不會把它當成沒有變更,而是全部跑。
if diff_is_unknown:
run_backend = True
run_frontend = True
我寧願多花幾分鐘,也不要因為分類器失敗就漏掉必要測試。
測試不用真的睡在那裡
另一個容易被忽略的浪費,是測試真的等待時間經過。
例如 keepalive 測試為了跨過五秒期限,真的等待十幾秒。但我要驗證的是 timeout 規則,不是電腦能不能等五秒。
後來我改成推進測試使用的 monotonic clock,同時保留真實 socket、TLS、Authentication isolation 和 Connection reuse。
單次結果:
12.44 秒 → 1.73 秒
Linux 完整測試也從 196.36 秒降到 174.72 秒。Production timeout 沒有改,只是測試不用真的睡在那裡。
Retry 測試也使用相同方式。測試環境取消 backoff 等待,但另外保留 production policy test,確認正式程式仍然使用原本的一秒、兩秒 exponential backoff。
四組 retry tests 的本地時間:
15.74 秒 → 1.92 秒
Self-hosted runner 不是免費
Mac runner 解決了 GitHub 分鐘問題,但成本只是換成電力、磁碟空間、VM 維護、Runner recovery、Cache cleanup 和自己的維運時間。
我最後把 Mac 當成 controller,每次 CI 交給獨立 Linux VM:
Mac
├─ Backend Linux runner
└─ Frontend Linux runner
Cache 則分成三類:
可以重新產生 → 定期清理
需要短期保留 → 設定 retention
不能遺失 → 不碰
Dependency cache、Docker build cache 可以重建。VM image、production receipt、資料庫 volume 和專案原始碼不能因為顯示 reclaimable 就直接刪除。
我最後學到的事情
CI/CD 要變快,不是一直增加 cache,也不是直接刪除測試。
我的順序是:
- 先量測時間。
- 找出 critical path。
- Backend 和 Frontend 並行。
- Backend 內部再拆 lane。
- 移除測試中的固定等待。
- 有變更才執行需要的部分。
- 無法判斷時全部執行。
- 最後再處理 runner 和 cache。
我的目標不是少跑測試,而是不要再為完全沒變的東西,重複付出時間、機器和維護成本。
下一篇會繼續談部署正確性: Railway 顯示 SUCCESS,我為什麼還是不讓它上線?
這套寫法整理成可重用的公開規則: Evidence-first CI/CD writing skill