Go Runtime Trong Container: GOMAXPROCS, CPU Throttling Và Bẫy Tail Latency Trên Kubernetes
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Có một cái bẫy mà dashboard không bao giờ cho bạn thấy: CPU của pod chỉ 40%, mọi alert đều xanh, mà p99 vẫn nổ. Nguyên nhân không nằm ở chỗ thiếu CPU — nằm ở chỗ CPU bị khoá theo từng khoảng thời gian.
Bài này đi từ hiện tượng đó xuống tận cgroup, rồi quay lên tới cách Go 1.25 tự xử lý.
1. Nghịch lý: CPU chưa tới một nửa mà p99 đã nổ
Đêm đó p99 latency của API nhảy từ 180ms lên 1.4s trong mười lăm phút rồi tự hạ xuống. Không 5xx, không deploy, GC pause dưới 2ms — và điều khiến mình mất thêm hai tiếng là CPU utilization của pod chỉ quanh 40%. Trực giác đầu tiên luôn là “còn dư CPU thì đâu phải tại CPU”. Trực giác đó sai.
Trong container, CPU mà pod được dùng là hạn mức cứng theo từng khoảng thời gian, không phải một tỉ lệ trung bình. Utilization tính trung bình trên cửa sổ vài chục giây. Thứ giết request là hạn mức bị vét sạch trong vài mili giây đầu mỗi chu kỳ, rồi cả tiến trình đóng băng tới hết chu kỳ. Trung bình của mấy lần đóng băng đó vẫn có thể nằm dưới 50%.
Tên của hiện tượng là CPU throttling, và bằng chứng nằm trong cgroup của pod chứ không nằm trong metric ứng dụng.
2. GMP: P chính là giấy phép tiêu CPU
Ba khái niệm của Go scheduler, gọn thôi. G — goroutine — là đơn vị công việc mà runtime quản lý. M — machine — là OS thread, thứ kernel thật sự đem đi schedule. P — processor logic — là ngữ cảnh thực thi đủ để chạy code Go: muốn chạy code Go, một M phải đang giữ một P.
Số P tồn tại đúng bằng GOMAXPROCS. GOMAXPROCS bằng bao nhiêu thì runtime tin nó được phép chạy bấy nhiêu goroutine song song. Mỗi P có hàng đợi cục bộ; hàng đợi rỗng thì P lấy việc từ hàng đợi toàn cục hoặc ăn cắp việc từ P khác. Work stealing chỉ hiệu quả khi số P khớp với số nhân thật.
Goroutine chờ I/O mạng không chiếm P: nó được giao cho netpoller, P nhả ra cho goroutine khác. Goroutine gọi syscall blocking thì M tách khỏi P, và sysmon thu hồi P sau một ngưỡng thời gian.
Còn runtime thì không có kênh thông tin nào về cgroup. Nó hỏi kernel xem máy có bao nhiêu nhân logic, đặt GOMAXPROCS theo con số đó, và xếp đủ M để mọi P cùng chạy.
3. CFS quota: hạn mức cứng theo từng period
Kubernetes không tự chia CPU; nó ghi vài con số vào cgroup rồi để kernel làm phần còn lại. Trên cgroup v2, file cpu.max chứa quota và period, đơn vị micro giây. Limit 2 CPU được dịch thành quota 200000 với period 100000: trong mỗi chu kỳ 100ms, nhóm tiến trình này được tiêu tối đa 200ms thời gian CPU, tương đương hai nhân chạy liên tục. Period mặc định 100ms và Kubernetes không phơi ra tham số để đổi nó — một cửa sổ rất thô so với một request chỉ mất 5ms.
Cơ chế thực thi mới là phần đáng sợ. Kernel cộng dồn CPU time mọi task trong cgroup đã dùng trong period. Vượt quota là cả cgroup bị throttle, chứ không riêng thread tiêu nhiều nhất. Với service Go, mọi M đang chạy — goroutine xử lý request, netpoller, cả sysmon — dừng cùng lúc tới khi period kế tiếp bắt đầu.
Và tiến trình không hề biết mình bị throttle: không syscall trả về lỗi, không signal. Thread chỉ đơn giản là không được chạy. Đo bằng đồng hồ thực thì thấy thời gian trôi qua mà CPU time gần như không tăng; chẳng chỉ số nào ở tầng ứng dụng phân biệt được “chậm vì việc nhiều” với “chậm vì bị cấm chạy”.
Đừng lẫn cpu.weight (CPU request — chỉ là trọng số chia sẻ khi các cgroup tranh nhau) với cpu.max: chỉ cpu.max mới là trần cứng, và cũng chỉ nó mới sinh ra throttling.
4. Vì sao GOMAXPROCS theo số nhân của node lại đốt sạch quota
Giả sử node có 32 nhân logic, pod API có CPU limit 2. Trên Go 1.24 trở về trước, runtime đọc số nhân của máy, thấy 32, và đặt GOMAXPROCS bằng 32. Sự lệch pha bắt đầu từ đây: runtime tin nó có 32 giấy phép, còn cgroup chỉ cho nó 2.
flowchart TB
subgraph TL["Một period 100ms — CPU limit 2 (quota = 200ms CPU time)"]
direction LR
A["t=0ms<br/>32 M chạy song song"] --> B["~t=6ms<br/>quota = 0"]
B --> C["t=6..100ms<br/>THROTTLED<br/>cả cgroup đóng băng"]
C --> D["t=100ms<br/>period mới, quota reset"]
end
R["Request"] --> Q{"Còn quota?"}
Q -- "còn" --> RUN["Chạy ngay trên P/M"]
Q -- "hết" --> WAIT["Xếp hàng tới period sau<br/>+0..94ms latency"]
D -.-> Q
Vào đầu mỗi period, khi một đợt request ập tới, 32 goroutine có thể chạy đồng thời. Kernel cho cgroup tiêu quota theo thời gian CPU chứ không theo số thread, nên 200ms quota có thể bị vét hết trong khoảng sáu mili giây đồng hồ nếu node đang rảnh. Từ mili giây thứ bảy tới thứ một trăm, service đóng băng theo đúng nghĩa đen.
Request đến trong khoảng đó cộng thẳng một khoảng chờ vào latency, và khoảng chờ ấy là bội số của 100ms chứ không phải một phân bố mượt. Đó là lý do p50 gần như không đổi trong khi p99 nhảy theo bậc.
Thiệt hại không dừng ở đó: runtime không biết mình bị chặn nên vẫn làm việc như trên một cái máy 32 nhân — tạo thêm M, đẩy goroutine qua lại giữa các hàng đợi, chạy preemption, khởi động số worker mark của GC theo tỉ lệ phần trăm của GOMAXPROCS. Mỗi đợt GC vì thế là một cú burst tiêu CPU đúng lúc quota đã cạn.
Nói thẳng: giảm GOMAXPROCS về đúng mức limit không xoá throttling, nó chỉ bỏ đi phần vượt trội mang tính hệ thống — từ “cháy trong sáu mili giây” thành “trải đều gần hết period”.
5. Đo lường: chỗ bằng chứng thật sự nằm
Bắt đầu từ file cpu.stat của cgroup, không phải dashboard ứng dụng.
# cgroup v2
cat /sys/fs/cgroup/cpu.max # 200000 100000 => 2 CPU
cat /sys/fs/cgroup/cpu.stat
# usage_usec 48102933
# user_usec 39201104
# system_usec 8901829
# nr_periods 12480
# nr_throttled 3912
# throttled_usec 812340912
# cgroup v1 — tên file khác, cùng ý nghĩa
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.stat # nr_periods, nr_throttled, throttled_time (ns)
Cách đọc: nr_throttled chia nr_periods là tỉ lệ period bị chặn, throttled_usec chia nr_throttled là thời gian mỗi lần chặn kéo dài. Tỉ lệ chặn trên 10% là bất thường với một service nhạy latency, và thời gian chặn trung bình vài chục mili giây nghĩa là request đang bị đóng băng thật.
Trong Prometheus, cAdvisor đã expose sẵn các counter này:
# Tỉ lệ period bị throttle
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
/
sum by (pod) (rate(container_cpu_cfs_periods_total[5m]))
# Thời gian bị chặn so với hạn mức, dùng song song chứ không thay thế nhau
sum by (pod) (rate(container_cpu_cfs_throttled_seconds_total[5m]))
/
sum by (pod) (container_spec_cpu_quota / container_spec_cpu_period)
container_cpu_cfs_throttled_seconds_total là counter theo giây, khác hẳn container_cpu_usage_seconds_total mà phần lớn dashboard đang dùng cho biểu đồ CPU. Đây là chỉ số mình đưa vào alert; utilization chỉ còn để hoạch định dung lượng.
Trong tiến trình, GODEBUG=schedtrace=1000 in mỗi giây một dòng về số P, số M, số P rảnh và hàng đợi toàn cục; dấu hiệu cần tìm là hàng đợi dài trong khi gần như không P nào chạy, hoặc các dòng bị nhảy cách quãng — runtime không kịp in log cũng là bằng chứng nó không được chạy.
Công cụ dùng được lâu dài trong production là runtime/metrics:
var samples = []metrics.Sample{
{Name: "/sched/gomaxprocs:threads"},
{Name: "/sched/goroutines:goroutines"},
{Name: "/sched/latencies:seconds"}, // histogram: runnable -> running
{Name: "/cpu/classes/user:cpu-seconds"},
{Name: "/cpu/classes/idle:cpu-seconds"},
}
func report() {
metrics.Read(samples)
for _, s := range samples {
switch s.Value.Kind() {
case metrics.KindUint64:
log.Printf("%s = %d", s.Name, s.Value.Uint64())
case metrics.KindFloat64Histogram:
log.Printf("%s (ms) = %v", s.Name, s.Value.Float64Histogram().Buckets)
}
}
}
/sched/latencies:seconds là chỉ số hữu ích nhất: nó đo thời gian từ lúc goroutine sẵn sàng chạy tới lúc thật sự được chạy trên một P, nên thời gian bị cgroup chặn nằm trọn trong đó — phân vị cao của nó nhảy lên trong khi số goroutine và số P không đổi nghĩa là vấn đề nằm ngoài tiến trình. Còn /sched/gomaxprocs:threads là con số so nhanh nhất với limit: trên Go 1.25 chúng phải khớp nhau.
6. Trước Go 1.25: tự đọc quota và tự đặt GOMAXPROCS
Trong nhiều năm, cách duy nhất là tự làm:
func gomaxprocsFromQuota() int {
b, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
if err != nil {
return 0
}
f := strings.Fields(string(b))
if len(f) != 2 || f[0] == "max" {
return 0 // không có CPU bandwidth limit
}
quota, err1 := strconv.ParseInt(f[0], 10, 64)
period, err2 := strconv.ParseInt(f[1], 10, 64)
if err1 != nil || err2 != nil || quota <= 0 || period <= 0 {
return 0
}
return int((quota + period - 1) / period) // làm tròn lên
}
func init() {
if n := gomaxprocsFromQuota(); n > 0 {
runtime.GOMAXPROCS(n)
}
}
Bản này đủ dùng cho cgroup v2 nhưng phải xử lý thêm bốn thứ: đường dẫn của cgroup v1 khác hoàn toàn; quota có thể là max nghĩa là không giới hạn; phải đọc đúng cgroup chứa tiến trình chứ không phải của shell đang gõ lệnh; và phải chạy trước mọi thứ khác, vì pool worker hay số shard tính bằng GOMAXPROCS ở init sẽ đóng băng con số cũ.
Thư viện phổ biến nhất cho việc này là automaxprocs của Uber:
import "go.uber.org/automaxprocs/maxprocs"
func main() {
// Trả về hàm undo để khôi phục giá trị cũ, thường dùng trong test.
if _, err := maxprocs.Set(maxprocs.Logger(log.Printf)); err != nil {
log.Printf("automaxprocs: %v", err)
}
}
Nhược điểm nằm ở tính tĩnh: nó chỉ đọc quota một lần lúc khởi động, nên nếu CPU limit của pod đổi sau đó, con số trong runtime không đổi theo. Nó là một dependency phải thêm vào mọi binary, và vì GOMAXPROCS bị set tường minh, mọi hành vi tự động của runtime về sau đều bị vô hiệu — kể cả khi bạn đã nâng lên Go 1.25.
7. Go 1.25: runtime tự biết mình đang ở trong container
Go 1.25 đưa việc này vào runtime. Trên Linux, runtime xét CPU bandwidth limit của cgroup chứa tiến trình; nếu limit đó thấp hơn số nhân logic khả dụng thì GOMAXPROCS mặc định bằng chính limit đó thay vì bằng số nhân của node. Trong Kubernetes, đại lượng này tương ứng trực tiếp với option CPU limit của container. Runtime không xét CPU requests: requests được kernel dịch thành trọng số chia sẻ, không phải trần, nên đưa nó vào công thức sẽ sai về ngữ nghĩa — đây là điểm dễ gây bối rối nhất khi đọc tài liệu.
Runtime còn định kỳ cập nhật GOMAXPROCS khi số nhân logic khả dụng hoặc cgroup limit thay đổi. Từ 1.25, GOMAXPROCS không còn là hằng số của tiến trình mà là một giá trị có thể đổi trong lúc chạy.
Hai hành vi đó tự động bị tắt nếu GOMAXPROCS được set thủ công, qua biến môi trường hoặc qua lời gọi runtime.GOMAXPROCS; chúng cũng tắt tường minh được bằng GODEBUG=containermaxprocs=0 và GODEBUG=updatemaxprocs=0. Hệ quả thực tế: một biến GOMAXPROCS còn sót trong manifest — có thể từ thời phải set tay — âm thầm đưa bạn về hành vi cũ, và không có cảnh báo nào.
Hành vi mới gắn với chỉ thị go trong go.mod chứ không gắn với toolchain dùng để build: chỉ cần đặt Go 1.25.0 trở lên ở đó là nhận hành vi mới. Nói cách khác, “đã cài Go 1.25” chưa đủ — phải kiểm tra go.mod.
Hệ quả thiết kế ít được nhắc: code lấy GOMAXPROCS một lần lúc init rồi dùng để chia shard hay định cỡ worker pool giờ nằm trên một giá trị có thể đổi. Trên Go 1.25, cách đúng là đọc runtime.GOMAXPROCS(0) tại thời điểm cần.
8. Vận hành: những gì cần đổi trong manifest và alert
Thứ nhất, đặt CPU request bằng CPU limit: pod vào lớp QoS Guaranteed và scheduler chỉ đặt nó lên node có đủ chỗ thật. Với Burstable, pod chạy trên node rảnh nên hiếm khi bị chặn, nhưng dưới tranh chấp nó bị ép theo trọng số và có thể throttling nặng. Nếu không cam kết được, lựa chọn lành mạnh hơn là bỏ limit và để requests làm tín hiệu duy nhất.
Thứ hai, trên Go 1.25 trở lên thì đừng set GOMAXPROCS bằng tay nữa: rà soát manifest, Helm values, biến môi trường trong pipeline và cả những con số còn sót trong lệnh khởi động — một biến sót lại sẽ tắt hành vi mới mà không có tín hiệu nào.
Thứ ba, chuyển alert sang throttling: cảnh báo khi tỉ lệ period bị chặn vượt 10% trong mười lăm phút, và khi thời gian chặn trung bình mỗi period vượt vài mili giây. Utilization vẫn hữu ích nhưng cho mục đích khác: quyết định tăng hay giảm limit.
Vẫn có ca set tay là đúng: pod nhiều container dùng chung một cgroup (sidecar cùng hưởng quota) nên trần thật nhỏ hơn limit của pod; workload CPU-bound thuần cần giới hạn số P để giảm chi phí điều phối; tinh chỉnh GC vì số worker mark gắn với GOMAXPROCS; và các môi trường không phải Linux, nơi hành vi mới không áp dụng.
Còn một chỗ nữa: liveness probe cũng bị throttling. Probe tốn vài mili giây CPU có thể vượt timeout một giây khi pod đang đóng băng, kubelet restart pod, và vòng lặp cứ thế quay.
9. Chỉ số nào đo đúng cơ chế thực thi
Chỉ số sức khoẻ nên đo cùng cơ chế mà hệ thống dùng để thực thi giới hạn. CPU utilization là trung bình trên một cửa sổ do bạn chọn; cgroup quota là trần cứng theo từng period do kernel thực thi. Hai đại lượng này lệch nhau rất xa được, và khi lệch thì chỉ một trong hai nói cho bạn biết vì sao người dùng đang phải chờ.
Cùng cách đó áp cho memory limit với OOM kill, connection limit với cạn pool, hay IOPS với hàng đợi block. Con số trung bình luôn là chỉ báo muộn. Với Go trong Kubernetes, từ 1.25 runtime đã tự đọc đúng trần của container. Việc còn lại chỉ là rà manifest xem có dòng cấu hình cũ nào đang vô hiệu hoá nó không.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.