A Kubernetes Resource That Doesn't Exist: Caching CI Images on Ephemeral Runners

Bài tóm tắt Mercari 2026 3. Quy mô lớn Độ khó: Trung bình

Mercari · 2026 · 3. Quy mô lớn · Độ khó: Trung bình — bài gốc

Bối cảnh

  • Mercari tự chạy runner GitHub Actions bằng Actions Runner Controller (ARC) trên GKE, runner tạm (ephemeral), chế độ Docker-in-Docker: mỗi pod runner có container runner và một Docker daemon sidecar, job chạy trong daemon đó.
  • Pipeline bận nhất (monorepo IaC) mỗi job chạy trong image 2,4 GiB (giải nén khoảng 8 GiB). Mỗi job mất 2–3 phút kéo image trên tổng 6–7 phút; một tháng cold start tốn hơn 900 giờ CI.

Các hướng bị loại

  • Thu nhỏ image: image chứa công cụ nội bộ; bỏ bớt thì đẩy độ phức tạp vào pipeline.
  • Kéo nhanh hơn: pull-through cache (docker-registry-proxy) giảm thời gian tải 70–80% nhưng job chỉ nhanh hơn khoảng 15%. Đo kỹ: tải chỉ chiếm khoảng 20% của 2–3 phút, còn lại là giải nén layer. Chiến lược đổi từ "chuyển byte nhanh hơn" sang "giữ byte lại".
  • Đĩa bền dùng chung cho layer: giảm khởi động 80%, nhưng persistent disk GKE là ReadWriteOnce (gắn một node một lúc); muốn chia cho runner co giãn thì cần ReadWriteMany, tức dịch vụ file managed hoặc dòng máy khác, đắt hơn nhiều so với vấn đề.

Thay đổi chính

  • Kubernetes mode của ARC: job chạy trong pod riêng (workflow pod) cạnh pod runner, chung một volume tạm cho workspace. Vì là pod thật, image layer nằm trong container runtime của node như mọi pod khác: job đầu tiên trên node kéo một lần, job sau dùng lại. Trong lab: khởi động còn 10–15 giây.
  • Production sập vì OutOfPods: volume chung là RWO nên hai pod phải cùng node; hook của ARC ép bằng cách đặt spec.nodeName, pod như vậy bỏ qua scheduler, đi thẳng tới kubelet, và bị từ chối khi node đã đủ số pod. Lỗi đã có trong upstream từ 10/2023.
  • Cách cộng đồng (để scheduler đặt workflow pod + volume RWX) không hợp: trên T2D của GKE chỉ có NFS chậm; nhiều báo cáo khác cho thấy RWX làm khởi động 2–3 phút, kẹt Pending, mất GITHUB_ENV khi hai pod khác node.
  • Thử scheduler tự viết: giữ chỗ cho workflow pod sẽ đến, allocatable = (maxPods − podsOnNode − runnerChưaCóWorkflowPod) × 0,5 (mỗi runner sẽ sinh đúng một workflow pod). Hết OutOfPods, nhưng cluster autoscaler mô phỏng thấy node còn tài nguyên nên không scale, runner Pending chờ mãi. Ý định "workflow pod phải cùng node với runner" chỉ hiện ra cho scheduler hoặc autoscaler, không bao giờ cho cả hai.
  • Device plugin cho một tài nguyên không có thật: DaemonSet quảng bá extended resource mercari.com/runner-quota tính bằng đúng công thức trên; pod runner xin 1 đơn vị. Scheduler xếp runner chỉ tới mức quota cho phép; hết quota trên mọi node thì autoscaler hiểu là phải thêm node. Race nhỏ (quota cập nhật trễ, kubelet trả UnexpectedAdmissionError) được chính DaemonSet xử lý bằng cách xoá pod kẹt để nó xếp lại.

Kết quả

  • Kéo image từ 2–3 phút còn khoảng 12 giây p50, 15 giây p95 (khởi động ấm); pipeline nhanh hơn 30–40% kể cả lúc đồng thời cao.
  • Job đầu tiên trên node mới vẫn phải kéo; giữ một số runner rảnh tối thiểu để có node ấm. Cái giá là vài node chạy nhẹ và mật độ pod mỗi node thấp hơn.
  • Rollback: giữ nguyên runner set DinD cũ; đổi một dòng runs-on là quay về.

Vì sao

Bài học cho SWE

  • Đo từng phần trước khi tối ưu: tải nhanh hơn 80% chỉ cho 15% vì nút thắt là giải nén.
  • Cẩn thận với cơ chế bỏ qua scheduler (nodeName): chạy được ở tải thấp, gãy khi node đầy.
  • Khi nền tảng không biểu diễn được ý định của mình, tìm cách dịch ý định sang thứ nó đã quản lý (extended resource) thay vì viết scheduler riêng (Nền tảng nội bộ cho service (đường ray chung)).
  • Giữ đường lui một dòng cấu hình khi đổi hạ tầng CI.

Liên quan