Mọi người chạy CI/CD trên kubernetes mà trên hạ tầng cloud đã biết chi phí scale cũng rất đáng để nói. Như bên mình có lúc cao điểm pipeline treo khá dài, queue time đẩy lên vài phút, hóa đơn cloud thì nhích đều. Thử tăng số runner lên thì rẻ đâu chưa thấy, chỉ thấy nhiều pod lúc nhàn là cũng phí. Mình có note lại các phần việc phải làm như: runner autoscaling hợp lý, có warm pool, có pre-pull image, có registry cache và biết đo số liệu.
Làm xong thì queue time p95 giảm mạnh, chi phí trên mỗi 1000 jobs cũng dễ dự báo hơn. Mọi người xem thử sau gặp thì có thể tham khảo xem dùng được phần nào nhé.
Mục tiêu
- Queue time p95 dưới 30 giây với workload có peak, không giữ runner rảnh quá 2 phút.
- Chi phí mỗi 1000 jobs giảm 20 đến 40 phần trăm nhờ warm pool hợp lý và cache đúng chỗ.
- Pipeline duration ổn định nhờ pre-pull image và reuse layer.
- Có dashboard quan sát, có runbook khi autoscaler gặp sự cố scale-out hoặc tscale-in.
Kiến trúc để làm việc
- GitLab Runner dùng Kubernetes executor cài bằng Helm trên một cluster k8s.
- Cluster Autoscaler của cloud hoặc Karpenter nắm quyền scale node.
- Runner autoscaling cấp pod; Cluster Autoscaler cấp node.
- Warm pool giữ sẵn một lượng pod runner ở trạng thái idle.
- Registry có cache hoặc mirror; image phổ biến được pre-pull.
- Artifact và cache dùng object storage.
Checklist trước khi bắt đầu
- Có namespace riêng cho runner.
- Có quyền tạo node qua Cluster Autoscaler hoặc Karpenter.
- Có storage cho artifact và cache.
- Có metrics Prometheus.
- Có registry cache hoặc mirror gần cluster.
Helm values mẫu cho GitLab Runner
Dưới đây là một values.yaml tối giản nhưng đủ ý: autoscaling theo concurrency, warm pool, pre-pull image, network, security, node selector và tolerations để tách runner ra node rẻ (chú ý thay value vào nhé m.n)
gitlabUrl: https://gitlab.example.com
runnerRegistrationToken: "
<REGISTER_TOKEN>"
rbac:
create: true
metrics:
enabled: true
serviceMonitor:
enabled: true
runners:
name: "k8s-autoscale-runner"
executor: "kubernetes"
tags: ["k8s", "autoscale"]
requestConcurrency: 1 # mỗi pod chạy 1 job
outputLimit: 4096
cache:
type: s3
shared: true
s3ServerAddress: s3.example.com
s3BucketName: gitlab-runner-cache
s3BucketLocation: ap-southeast-1
s3CachePath: "runner"
s3AccessKey: "
<ACCESS_KEY>"
s3SecretKey: "
<SECRET_KEY>"
s3StorageClass: "STANDARD"
config: |
[[runners]]
[runners.kubernetes]
host = ""
namespace = "ci-runners"
image = "registry.example.com/buildkit:latest"
helper_image = "registry.gitlab.com/gitlab-org/gitlab-runner/gitlab-runner-helper:x86_64-latest"
poll_timeout = 600
cpu_request = "200m"
memory_request = "512Mi"
cpu_limit = "1000m"
memory_limit = "2Gi"
service_cpu_request = "100m"
service_memory_request = "256Mi"
service_cpu_limit = "500m"
service_memory_limit = "1Gi"
service_account = "gitlab-runner"
privileged = true # cần cho Docker-in-Docker hoặc buildkit privileged
helper_cpu_request = "50m"
helper_memory_request = "128Mi"
helper_cpu_limit = "200m"
helper_memory_limit = "256Mi"
[runners.kubernetes.pod_annotations]
"container.apparmor.security.beta.kubernetes.io/build" = "unconfined"
[runners.cache]
MaxUploadedArchiveSize = 0
[runners.kubernetes.node_selector]
"workload" = "ci"
[runners.kubernetes.tolerations]
[[runners.kubernetes.tolerations]]
key = "workload"
operator = "Equal"
value = "ci"
effect = "NoSchedule"
[[runners]]
environment = ["FF_USE_FASTZIP=true","BUILDKIT_PROGRESS=plain","DOCKER_BUILDKIT=1"]
# Autoscaling ở layer runner controller
concurrent: 200 # max hệ thống, đừng quá cao nếu node ít
checkInterval: 3
# Pod template cho warm pool
podAnnotations:
prepull/images: "alpine:3.20,registry.example.com/buildkit:latest"
Gợi ý cài đặt
helm repo add gitlab https://charts.gitlab.io
helm upgrade --install runners gitlab/gitlab-runner -n ci-runners -f values.yaml
Cluster Autoscaler hoặc Karpenter
- Nếu dùng Cluster Autoscaler, gắn node group dành riêng cho runner, instance rẻ như spot hoặc preemptible.
- Nếu dùng Karpenter, khai báo Provisioner để scale theo pending pod với label
workload=ci, đặt TTL cho empty node.
Ví dụ Provisioner Karpenter basic
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: ci
spec:
template:
metadata:
labels:
workload: ci
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c","m"]
nodeClassRef:
name: default
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 120s
Chiến lược warm pool
- Mục đích giữ vài pod runner ở trạng thái idle để đón burst đầu tiên.
- Quy tắc đơn giản:
warm = max(ceil(p95_queue_rps * startup_time), min_idle). Ví dụ p95 có 10 job/s và startup_time 6 giây thì warm ≈ 60 pod. - Không để warm quá nhiều vì node sẽ không co được. Đặt TTL cho node rỗng.
Registry cache và pre-pull image
- Dùng registry mirror gần cluster hoặc trong cùng VPC.
- Pre-pull các image nền phổ biến vào DaemonSet trên node chạy runner.
Ví dụ DaemonSet pre-pull
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: prepull
namespace: ci-runners
spec:
selector:
matchLabels: {app: prepull}
template:
metadata:
labels: {app: prepull}
spec:
nodeSelector:
workload: ci
tolerations:
- key: workload
operator: Equal
value: ci
effect: NoSchedule
containers:
- name: prepull
image: registry.example.com/utility:latest
command: ["/bin/sh","-c"]
args:
- |
set -e
ctr -n k8s.io images pull docker.io/library/alpine:3.20 || true
ctr -n k8s.io images pull registry.example.com/buildkit:latest || true
sleep 3600
securityContext:
privileged: true
terminationGracePeriodSeconds: 5
Build engine và cache layer
- Nếu pipeline chủ yếu build container, dùng BuildKit với registry cache.
- Với GitLab Runner, có thể chạy
buildkitdsidecar hoặc sử dụng DinD nhưng BuildKit nhanh và ít IO hơn. - Bật cache mount để reuse layer giữa jobs khi có thể.
Pipeline template gợi ý
stages: [build, test]
variables:
DOCKER_BUILDKIT: "1"
BUILDKIT_INLINE_CACHE: "1"
GIT_DEPTH: "20"
build:
stage: build
tags: ["k8s","autoscale"]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build --pull --cache-from "$CI_REGISTRY_IMAGE:cache" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE:cache"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$CI_REGISTRY_IMAGE:cache"
test:
stage: test
tags: ["k8s","autoscale"]
script:
- ./run_tests.sh
Đo lường và dashboard
M.n cần 3 chỉ số lõi:
queue_time_secondsp95 và p99 của jobpipeline_duration_secondsp95cost per 1000 jobs
Prometheus metrics
- GitLab Runner exporter bật sẵn khi
metrics.enabled. - Gợi ý rule alert:
Gợi ý rule alert
groups:
- name: ci-autoscale
rules:
- alert: RunnerQueueP95High
expr: histogram_quantile(0.95, sum by (le) (gitlab_runner_queue_duration_seconds_bucket)) > 30
for: 5m
labels: {severity: warning}
- alert: RunnerSaturationHigh
expr: sum(gitlab_runner_jobs{state="running"}) / sum(gitlab_runner_concurrent) > 0.9
for: 10m
labels: {severity: warning}
Kịch bản test thực tế
- Baseline
- Tắt warm pool, để autoscaler tự co giãn.
- Đẩy 1000 jobs trong 10 phút, ghi queue p95, pipeline p95, số node tạo, thời gian chờ node.
- Bật warm pool
- Giữ 50 đến 100 pod idle.
- Lặp lại workload, so sánh queue p95, số node tạo, thời gian chờ.
- Pre-pull image
- Giữ warm như trên, bật DaemonSet pre-pull.
- Đo pipeline p95, đặc biệt step build.
- Chọn cấu hình tốt nhất theo chi phí.
Công thức chi phí đơn giản
Cost per 1000 jobs=VM_cost_per_hour×runtime_hours_per_1000_jobs+Egress_cost+Storage_costruntime_hours_per_1000_jobs≈(Σ job_duration_seconds + Σ queue_overhead_seconds)÷ 3600- Giảm
queue_overheadvàpull image timesẽ giảm runtime nên giảm chi phí.
Makefile để tạo tải và thu thập số liệu
SHELL := /usr/bin/env bash
.PHONY: burst baseline warm prepull report
burst:
@echo "Trigger pipelines in parallel"
@python3 scripts/trigger_jobs.py --count 1000 --concurrency 50
baseline:
kubectl scale deploy gitlab-runner --replicas=1 -n ci-runners
$(MAKE) burst
warm:
kubectl scale deploy gitlab-runner --replicas=0 -n ci-runners
kubectl apply -f manifests/warmpool-100.yaml
$(MAKE) burst
prepull:
kubectl apply -f manifests/prepull-daemonset.yaml
report:
python3 scripts/export_metrics.py --from -15m --to now > results/run.json
Sự cố thường gặp và cách xử lý
- Pod runner Pending lâu: kiểm tra Cluster Autoscaler có scale node không, quota vCPU còn không, có PodDisruptionBudget chặn không.
- Node co chậm dù rảnh: bật consolidation ở Karpenter hoặc set
scale-down-delayngắn hơn ở Cluster Autoscaler. - Build kéo image chậm: kiểm tra registry mirror, băng thông, bật pre-pull và inline cache.
- Spot node bị thu hồi: cho phép retry job, tăng warm pool một chút để hấp thụ cú sốc, cấu hình PDB cho workload khác để runner không chiếm tài nguyên nhầm chỗ.
Kết bài
Cứ rảnh ngồi viết chia sẻ có bài rời rạc viết xong còn ném vào chatGPT format thấy còn sai cả chính tả và nhiều lúc cũng hơi nhàm nhưng note nhiều thứ nghĩ chia sẻ ra có thể hữu ích với anh em chưa biết với xem report thấy view cũng nhiều nên là lại cố viết :))
Cơ bản thì autoscaling runner theo tôi là hiểu độ trễ lúc khởi động pod và node, giữ một warm pool đủ dùng, kéo image nhanh bằng cache và mirror, rồi đo đúng số liệu. Khi m.n thấy queue p95 xuống dưới 30 giây ổn định theo giờ cao điểm và chi phí mỗi 1000 jobs có xu hướng giảm, nghĩa là cụm đã vào guồng.




