2 Giờ Sáng. Hàng Đợi Build Tắc 47 Phút.
Tôi đang nhìn chằm chằm vào dashboard GitHub Actions, theo dõi con số cứ tăng dần. Bốn mươi bảy phút trước khi bất kỳ developer nào nhận được phản hồi CI. Kênh Slack on-call thì đang nổ tung. Một kỹ sư vừa push hotfix lên — và nó cứ nằm đó, chờ một runner GitHub-hosted được giải phóng.
Chúng tôi đang đốt khoảng $800/tháng cho Actions minutes với một team 12 người. Mỗi ngày release, mỗi lần merge PR lớn, hàng đợi lại tắc nghẽn. GitHub-hosted runners không có làn ưu tiên. Bạn chờ. Tất cả đều chờ.
Đêm đó tôi quyết định không trì hoãn nữa và làm điều lẽ ra phải làm từ ba tháng trước: chuyển toàn bộ CI/CD runner vào Kubernetes cluster của chính mình bằng Actions Runner Controller (ARC).
Đây là những gì tôi học được — kể cả những phần không có trong tài liệu chính thức.
Tại Sao GitHub-Hosted Runners Chết Đứng Khi Có Tải Lớn
GitHub-hosted runners rất tốt — cho đến khi team bạn thực sự phát triển. Đây là những điểm yếu chết người:
- Giới hạn concurrency: Gói Free và Team giới hạn số job chạy đồng thời. Bạn sẽ chạm trần đúng vào ngày release — lúc bạn không thể chấp nhận chậm trễ nhất.
- Cold start mỗi lần chạy: Mỗi runner khởi động từ đầu. Docker layer cache, pip packages, npm modules — tất cả bay hết. Bạn phải trả tiền để build lại trong mỗi lần chạy, trừ khi bạn dùng remote caching và Docker BuildKit.
- Lộ secrets: Runners chạy trên hạ tầng của GitHub. Với các ngành healthcare hay fintech, đây có thể là điểm chặn cứng về compliance.
- Không truy cập được internal network: Muốn chạy integration test với staging database? Bạn phải dùng VPN tunnel — và không có cái nào thực sự gọn gàng.
- Chi phí leo thang nhanh: Một team chạy 200+ phút workflow mỗi ngày có thể dễ dàng tốn $200–$1.000/tháng.
Self-hosted runners giải quyết tất cả những vấn đề này. Vấn đề là quản lý thủ công — tạo VM, rotate registration token, dọn dẹp runner cũ — cũng không scale được. Đó chính xác là khoảng trống ARC lấp đầy.
Actions Runner Controller Thực Sự Làm Gì
Actions Runner Controller (ARC) là một Kubernetes operator quản lý vòng đời runner từ đầu đến cuối. Nó theo dõi repository hoặc organization GitHub của bạn để phát hiện job đang chờ, tự động spin up runner pod, rồi xóa chúng khi job hoàn thành.
Không có zombie runner. Không cần rotate token thủ công. Mỗi job đều có pod sạch, isolated. Điểm cuối này quan trọng hơn nghe có vẻ — runner dùng chung lâu dài sẽ tích lũy state, và state là nguyên nhân gây flaky test.
ARC hỗ trợ hai chế độ scaling:
- RunnerDeployment: Số replica cố định — runner luôn chạy, capacity dự đoán được
- RunnerSet với HorizontalRunnerAutoscaler: Scale từ 0 đến N dựa theo độ sâu hàng đợi — chỉ trả tiền cho những gì bạn dùng
Trong môi trường production, bạn hầu như luôn muốn dùng autoscaler.
So Sánh Ba Phương Án
Trước khi chọn ARC, tôi đã xem xét ba lựa chọn:
Phương án 1: Self-Hosted VM (Thủ công)
Đăng ký runner trên EC2 hoặc GCP VM. Cực kỳ đơn giản để setup, toàn quyền kiểm soát. Nhưng bạn phải tự lo OS patching, token rotation và scaling. Một VM bằng một job đồng thời. Mười job đồng thời nghĩa là mười VM. Chi phí tăng tuyến tính rất nhanh, và đến runner thứ năm thì bắt đầu không quản lý được nữa.
Phương án 2: GitHub Actions Larger Runners
GitHub hiện bán runner hosted 4–64 core. Tiện lợi, nhưng đắt — runner 16 core tốn gấp khoảng 8 lần mức giá per-minute tiêu chuẩn. Vẫn cold-start, vẫn trên hạ tầng GitHub, và vẫn chịu giới hạn hàng đợi. Với workload nặng không thường xuyên thì hợp lý; với build hàng ngày thì quá tốn kém.
Phương án 3: ARC trên Kubernetes
Runner chạy dưới dạng pod trong cluster của bạn. Nhiều runner có thể pack trên một node. Cache tồn tại giữa các job. Build có thể truy cập trực tiếp internal services. Scaling gắn với hàng đợi job thực tế của GitHub. Setup ban đầu mất vài giờ — nhưng khi đã chạy, bạn không cần nghĩ đến nó nữa.
Nếu bạn đã dùng Kubernetes, đây là lựa chọn tự nhiên. Chi phí vận hành sau khi setup ban đầu gần như bằng không.
Thiết Lập ARC: Từng Bước Một
Yêu Cầu Trước Khi Bắt Đầu
- Một Kubernetes cluster đang chạy (EKS, GKE, k3s — đều dùng được)
kubectlđã cấu hình và trỏ vào cluster của bạn- Helm 3 đã cài đặt
- GitHub Personal Access Token với scope
repovàadmin:org— hoặc GitHub App (khuyến nghị mạnh cho production, chi tiết bên dưới)
Bước 1: Cài ARC qua Helm
# Thêm Helm repo của ARC
helm repo add actions-runner-controller https://actions-runner-controller.github.io/actions-runner-controller
helm repo update
# Cài controller vào namespace riêng
helm install arc \
--namespace actions-runner-system \
--create-namespace \
actions-runner-controller/actions-runner-controller \
--set authSecret.create=true \
--set authSecret.github_token="ghp_YOUR_PAT_HERE"
Chờ controller pod khởi động:
kubectl get pods -n actions-runner-system
# NAME READY STATUS RESTARTS
# arc-actions-runner-controller-xxxx-yyyy 1/1 Running 0
Bước 2: Tạo RunnerDeployment
Bắt đầu với deployment cố định. Hai replica, xác nhận mọi thứ đã đăng ký, rồi mới thêm autoscaling:
# runner-deployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: my-runners
namespace: actions-runner-system
spec:
replicas: 2
template:
spec:
repository: your-org/your-repo # hoặc dùng 'organization: your-org' cho toàn org
image: summerwind/actions-runner:latest
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
kubectl apply -f runner-deployment.yaml
# Xác nhận runner đã đăng ký với GitHub
kubectl get runners -n actions-runner-system
# NAME REPOSITORY STATUS
# my-runners-xxxx your-org/your-repo Running
Mở GitHub repo tại Settings → Actions → Runners. Hai runner đang idle sẽ xuất hiện trong khoảng 30 giây.
Bước 3: Thêm Autoscaling
Replica cố định lãng phí tiền lúc 2 giờ sáng. HorizontalRunnerAutoscaler theo dõi độ sâu hàng đợi và điều chỉnh số lượng runner theo thời gian thực — tương tự cách KEDA thực hiện event-driven autoscaling cho các workload Kubernetes khác:
# runner-autoscaler.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: my-runners-autoscaler
namespace: actions-runner-system
spec:
scaleTargetRef:
name: my-runners
minReplicas: 0 # scale về 0 trong giờ nhàn rỗi
maxReplicas: 10
metrics:
- type: PercentageRunnersBusy
scaleUpThreshold: '0.75'
scaleDownThreshold: '0.25'
scaleUpFactor: '1.5'
scaleDownFactor: '0.5'
kubectl apply -f runner-autoscaler.yaml
Bước 4: Trỏ Workflow Sang Self-Hosted Runners
Chỉ cần thay một dòng trong file workflow. Nếu bạn có nhiều workflow lặp lại cấu hình tương tự, đây cũng là thời điểm tốt để xem xét tập trung hóa GitHub Actions với Reusable Workflows:
# .github/workflows/ci.yml
jobs:
build:
runs-on: self-hosted # hoặc dùng custom labels
steps:
- uses: actions/checkout@v4
- name: Build và test
run: make test
Cần điều phối job đến các runner pool khác nhau — tài nguyên khác nhau, team khác nhau? Dùng labels:
# Trong spec của RunnerDeployment:
spec:
template:
spec:
labels:
- large
- gpu
# Trong workflow:
runs-on: [self-hosted, large]
Hardening Cho Production: Những Gì Tài Liệu Bỏ Qua
GitHub Apps Luôn Tốt Hơn PAT
PAT có hạn sử dụng. Chúng cũng gắn với tài khoản của một người cụ thể — nếu kỹ sư đó nghỉ việc, mọi workflow dùng token của họ sẽ vỡ lúc 3 giờ sáng thứ Sáu. Đừng tự làm khổ mình như vậy. Nếu bạn chưa có chiến lược quản lý credentials bài bản, hãy xem qua hướng dẫn quản lý Secret trong CI/CD và Kubernetes trước.
Hãy tạo GitHub App thay thế. ARC hỗ trợ đầy đủ, và việc rotate token được xử lý tự động:
# Tạo secret từ thông tin xác thực GitHub App
kubectl create secret generic arc-github-app \
--namespace actions-runner-system \
--from-literal=github_app_id="YOUR_APP_ID" \
--from-literal=github_app_installation_id="YOUR_INSTALL_ID" \
--from-literal=github_app_private_key="$(cat private-key.pem)"
Docker Build: Tránh Privileged Container
DinD (Docker-in-Docker) hoạt động được, nhưng yêu cầu privileged container. Đây là attack surface thực sự bên trong cluster. Dùng Kaniko hoặc BuildKit dưới dạng pod riêng biệt — rootless, không cần leo thang đặc quyền:
# Build Docker image mà không cần privileged mode
- name: Build image
uses: int128/kaniko-action@v1
with:
push: true
tags: ghcr.io/your-org/your-app:latest
Persistent Cache: Cải Tiến Đơn Lẻ Có Tác Động Lớn Nhất
Đây chính là lúc self-hosted runner thực sự vượt trội so với GitHub-hosted. Mount PVC vào runner pod và giữ cache giữa các lần chạy:
spec:
template:
spec:
volumeMounts:
- mountPath: /home/runner/.cache
name: runner-cache
volumes:
- name: runner-cache
persistentVolumeClaim:
claimName: runner-cache-pvc
Docker layers, npm modules, pip packages — tất cả đều warm cho lần chạy tiếp theo. Build của chúng tôi giảm từ 14 phút xuống còn 4 phút chỉ nhờ thay đổi này. Không có gì khác có tác động gần bằng.
Kết Quả Sau 3 Tháng Chạy Production
Chúng tôi đã chuyển 80% workflow sang self-hosted runners. Sau ba tháng với traffic thực tế:
- Chi phí: Tiền GitHub Actions giảm từ ~$800/tháng xuống dưới $120/tháng — phần còn lại dùng cho một số workflow vẫn cần GitHub-hosted runner để truy cập external network
- Thời gian chờ: P95 wait time giảm từ 12 phút vào ngày bận xuống dưới 90 giây
- Bảo mật: Runner giờ có thể truy cập trực tiếp staging environment nội bộ — không cần VPN tunnel, không lộ ra ngoài
- Độ tin cậy: Hai runner pod bị lỗi trong ba tháng. ARC tự khởi động lại cả hai. Không developer nào nhận ra cả hai lần.
Cuộc khủng hoảng hàng đợi lúc 2 giờ sáng là cú hích khởi đầu cho toàn bộ dự án này? Từ đó đến nay không xảy ra nữa.
Nếu bạn đã chạy Kubernetes và đang trả tiền thật cho GitHub Actions, ARC hoàn vốn thời gian setup trong vài tuần. Vài giờ để cấu hình. Lợi ích thấy ngay từ ngày release tiếp theo.

