Khi bạn chạy docker run --cpus=1 hay đặt CPU limit trong Kubernetes, container được cấp một phần CPU. Nhưng "một phần CPU" nghĩa là gì, và điều gì xảy ra khi ứng dụng đòi hơn thế? Câu trả lời — cơ chế cfs_quota của cgroup — có một hệ quả tinh vi khiến rất nhiều dịch vụ bị đuôi trễ (tail latency) thảm họa mà không ai hiểu tại sao. Bài này đo trực tiếp hiện tượng đó, gọi là throttling, và vấp đúng cái bẫy đã hành hạ vô số hệ thống production: cấp nguyên một CPU mà ứng dụng vẫn đứng hình.
cgroup giới hạn CPU thế nào
Nhân Linux giới hạn CPU của một cgroup bằng hai con số, đọc ở cpu.max: quota và period. Nghĩa là: trong mỗi chu kỳ dài period micro giây, các tiến trình trong cgroup được dùng tối đa quota micro giây thời gian CPU. Ví dụ --cpus=1 của Docker đặt cpu.max = 100000 100000 — mỗi chu kỳ 100 mili giây, cgroup được 100 mili giây CPU, tức đúng một CPU.
Chuyện gì xảy ra khi cgroup dùng hết quota trước khi chu kỳ kết thúc? Nhân đóng băng (throttle) toàn bộ tiến trình trong cgroup — treo chúng lại, không cho chạy — cho tới khi chu kỳ mới bắt đầu và quota được nạp lại. Đây là cách giới hạn CPU hoạt động: không phải làm chậm đều, mà là cho chạy hết tốc rồi đóng băng. Với một CPU limit trên phần lẻ, đây chính là nguồn gốc của những cú khựng bí ẩn. Tôi muốn đo: đóng băng đó dài bao nhiêu, và điều gì quyết định nó?
Đo: cấp 1 CPU, nhưng vẫn đóng băng
Tôi viết một chương trình chạy T luồng, mỗi luồng lặp một vòng bận đọc đồng hồ liên tục; khi thấy hai lần đọc cách nhau hơn 4 mili giây, tôi biết luồng đó vừa bị đóng băng một khoảng. Chạy trong container --cpus=1 (đúng một CPU quota), tăng dần số luồng:
| Số luồng | Gap lớn nhất | Thời gian đóng băng (trên 3 s) |
|---|---|---|
| 1 luồng | 6 ms | ~1% (mượt) |
| 2 luồng | 62 ms | ~50% |
| 4 luồng | 86 ms | ~75% |
| 8 luồng | 106 ms | ~87% |
Với một luồng, mọi thứ mượt: khe hở lớn nhất chỉ 6 ms, gần như không bị đóng băng. Một luồng bận dùng đúng 100% của một CPU, khớp quota, chạy trơn tru. Nhưng khi tăng lên bốn luồng — vẫn trong đúng ngân sách một CPU đó — ứng dụng bị đóng băng những khoảng dài tới 86 mili giây, và tổng cộng nó đứng hình 75% thời gian. Tám luồng thì đóng băng tới 106 ms mỗi lần, đứng hình 87% thời gian. Càng nhiều luồng, đóng băng càng nặng, dù tổng CPU dùng vẫn bị chặn ở đúng một CPU.
"Một CPU" không phải "mượt như một CPU"
Tôi vào bài với một giả định tưởng chừng hiển nhiên: cấp cho container nguyên một CPU thì nó chạy mượt. Và với một luồng, đúng là mượt — nên tôi suýt dừng ở đó, kết luận "1 CPU là đủ, chạy trơn tru". Nhưng con số bốn luồng đóng băng 86 ms lật ngược tất cả.
Điều tôi bỏ sót: quota được chia cho tất cả các luồng cộng lại, và nhiều luồng đốt nó nhanh hơn hẳn. Bốn luồng chạy song song trên bốn nhân (máy có nhiều nhân) sẽ tiêu 100 mili giây quota chỉ trong khoảng 25 mili giây thực — bốn luồng, mỗi luồng 25 ms, cộng lại 100 ms quota, cạn sạch. Sau 25 ms đầu chu kỳ, quota hết, và cả bốn luồng cùng bị đóng băng suốt 75 ms còn lại của chu kỳ 100 ms, cho tới khi chu kỳ mới nạp quota. Kết quả: ứng dụng giật cục — chạy 25 ms, chết 75 ms, chạy 25 ms, chết 75 ms — dù mức CPU trung bình của nó không hề vượt quá một CPU.
Đây là chỗ bẫy chí mạng: CPU trung bình dưới hạn mức, mà ứng dụng vẫn đứng hình phần lớn thời gian. Hai tín hiệu mâu thuẫn — "không vượt quota" và "đóng băng 75%" — là dấu hiệu tôi đang nhìn nhầm đại lượng. Tôi nhìn thông lượng trung bình (ổn) mà quên độ trễ đuôi (thảm họa). Một request rơi trúng lúc đóng băng sẽ chờ tới 75 mili giây không vì lý do gì trong code cả — chỉ vì cgroup đang treo mọi luồng. Điều rút ra: "nhanh" (hay "đủ CPU") vô nghĩa nếu chưa hỏi "có mượt, có đều không" — và số luồng là biến ẩn quyết định, "có một CPU quota" hoàn toàn không bằng "chạy mượt như một CPU thật".
Cách chữa cũng lộ ra ngay khi hiểu nguyên nhân. Tôi đo lại bốn luồng nhưng cấp --cpus=4 (quota bằng số luồng): khe hở lớn nhất trở về 6 ms, mượt hoàn toàn. Khi quota đủ cho mức song song thật sự của ứng dụng, không còn cạn quota sớm, không còn đóng băng.
Về đến code hằng ngày
Điều đầu tiên là CPU limit trên ứng dụng đa luồng là nguồn đuôi trễ số một trong container. Rất nhiều dịch vụ Java, Go, Node bị "thỉnh thoảng chậm 50–100 ms không rõ lý do" trong Kubernetes, và thủ phạm thường là CFS throttling: ứng dụng có nhiều luồng (thread pool, GC, runtime) hơn số CPU mà limit cho phép, nên đốt quota sớm rồi bị đóng băng. Khi thấy đuôi trễ dạng bậc thang đúng bằng bội số của period (100 ms), hãy kiểm cpu.stat: nr_throttled và throttled_usec tăng đều là bằng chứng.
Điều thứ hai là khớp số luồng với CPU limit, hoặc nới limit cho khớp song song. Nếu bạn giới hạn ứng dụng ở 1 CPU nhưng nó chạy 8 luồng, bạn đang tự tạo đóng băng. Hai hướng chữa: giảm số luồng runtime cho khớp limit (ví dụ đặt GOMAXPROCS, số luồng GC, hay pool size = số CPU limit), hoặc nới limit lên cho khớp mức song song thật. Với nhiều dịch vụ, bỏ hẳn CPU limit (chỉ giữ CPU request để đảm bảo tối thiểu) là lựa chọn đúng — vì limit chỉ chặn phần bùng phát vốn vô hại khi máy còn rảnh, đổi lại gây đóng băng có hại.
Điều cuối, về đo lường: đừng đo một tài nguyên bằng mức trung bình khi cái đau nằm ở phân bố. Gói lại bằng số: cgroup giới hạn CPU bằng quota/period; dùng hết quota giữa chu kỳ thì MỌI luồng bị đóng băng tới chu kỳ sau — nên một container cấp 1 CPU vẫn đứng hình tới ~85 ms mỗi 100 ms nếu nó chạy 4 luồng (đốt chung quota nhanh gấp 4, cạn trong 25 ms), dù CPU trung bình không vượt hạn; chữa bằng cách khớp số luồng với quota (cấp --cpus=4 thì gap về 6 ms). Mức trung bình nói "ổn"; độ trễ đuôi nói "thảm họa" — và người dùng cảm nhận cái sau. Đo đúng đại lượng mới thấy đúng vấn đề.
Cái bẫy "runtime tự đếm số nhân của cả máy rồi mở đúng ngần ấy luồng, trong khi cgroup chỉ cho một phần CPU" này đặc biệt tàn khốc với các nền tảng có runtime tự động scale theo CPU, và nó đã sinh ra cả một thế hệ bug production. JVM là ví dụ kinh điển: trước khi có -XX:+UseContainerSupport (mặc định từ JDK 10+), JVM đọc Runtime.availableProcessors() ra số nhân của máy chủ chứ không phải CPU limit, rồi mở ForkJoinPool, GC thread, JIT compiler thread theo con số to đó — và tất cả cùng đốt quota, gây throttling nặng; đó là lý do container Java hay "khựng khi GC chạy". Go dính đúng chuyện: GOMAXPROCS mặc định bằng số nhân máy, nên một binary Go trong container 1 CPU vẫn spawn hàng chục goroutine chạy song song trên nhiều nhân rồi bị đóng băng — người ta phải dùng automaxprocs của Uber để đọc đúng CPU limit. Ngay cả connection pool và thread pool của web framework cũng thường được cấu hình "theo số nhân" mà quên rằng số nhân thấy được khác số nhân được phép dùng. Mẫu số chung, đúng như bài này: trong container, số nhân vật lý của máy và số CPU cgroup cho phép là hai con số khác nhau, và mọi thứ tự scale theo "số nhân" phải đọc con số sau (từ cgroup), không phải con số trước (từ phần cứng) — lẫn hai cái là tự gieo throttling mà biểu đồ CPU trung bình sẽ không bao giờ chỉ ra.
Muốn tự kiểm thì nếu bạn có một dịch vụ trong container hay Kubernetes bị chậm thất thường: cat /sys/fs/cgroup/cpu.stat và nhìn nr_throttled (số lần bị đóng băng) và throttled_usec (tổng thời gian bị đóng băng). Chạy lại sau vài giây; nếu hai con số tăng nhanh, ứng dụng của bạn đang bị bóp CPU và đó chính là nguồn đuôi trễ. Xem hạn mức hiện tại bằng cat /sys/fs/cgroup/cpu.max (dạng quota period; max nghĩa là không giới hạn). Muốn thử tận tay, chạy docker run --cpus=1 ... một chương trình nhiều luồng bận và xem cpu.stat: bạn sẽ thấy nr_throttled nhảy vọt — đúng cái đóng băng bài này đo, và là lý do "cấp 1 CPU" không đủ để một ứng dụng đa luồng chạy mượt.