- Ngay cả khi đặt giới hạn CPU cho container, runtime Go mặc định vẫn không nhận biết được điều đó, nên có thể tạo luồng dựa trên toàn bộ số lõi của host và làm tăng độ trễ
- GC của Go phần lớn chạy đồng thời với ứng dụng, nhưng ở các pha Sweep Termination và Mark Termination vẫn cần các đoạn stop-the-world(STW) để dừng toàn bộ goroutine
- Linux CFS phân bổ thời gian CPU theo số lõi trên mỗi giây, và
--cpus=4 có nghĩa là cấp cho container 4 giây CPU trong mỗi giây thực
- Khi chạy container bị giới hạn 4 lõi trên host 16 lõi, Go có thể đưa goroutine lên 16 luồng OS, khiến STW kéo dài sau khi quota CPU bị dùng hết
- Khi đặt
GOMAXPROCS khớp với giới hạn CPU của container, trong ví dụ thời gian chu kỳ GC giảm từ dưới 2.5ms xuống dưới 1ms, và STW giảm còn khoảng 26μs
Sự lệch nhau giữa giới hạn CPU của container và runtime Go
- Khi chạy ứng dụng Go trong container, giới hạn CPU là cơ chế để ngăn không cho ứng dụng tiêu thụ toàn bộ CPU của host
- Vấn đề là runtime Go mặc định không nhận biết giới hạn CPU của container
- Sự lệch nhau này khiến runtime cho rằng nó có thể dùng nhiều CPU hơn quota thực tế, từ đó dẫn tới độ trễ cao hơn
Những điểm phát sinh STW trong GC của Go
- Trình gom rác của Go trong phần lớn thời gian chạy đồng thời với ứng dụng
- Tuy vậy, trong quá trình GC có hai đoạn bắt buộc phải dừng toàn bộ goroutine
- Giai đoạn dừng trước Mark Phase để áp dụng write barrier được gọi là Sweep Termination
- Giai đoạn dừng sau Mark Phase để gỡ write barrier được gọi là Mark Termination
- Các đoạn STW thường ở mức vài chục micro giây
- Ứng dụng ví dụ là một ứng dụng web đơn giản cấp phát nhiều bộ nhớ, mã nguồn có tại go-cfs-blog
- Container được chạy với giới hạn 4 CPU
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
- Có thể thu thập trace bằng gói
runtime/trace và phân tích bằng go tool trace
- Trong lần chạy này, chu kỳ GC dưới 2.5ms nhưng gần 10% trong số đó là thời gian STW
- Với các ứng dụng nhạy cảm với độ trễ, tỷ lệ này cũng có thể trở thành vấn đề
Giới hạn CPU của Docker và cách Linux CFS hoạt động
- Giới hạn CPU
--cpus của Docker là hard limit
- Cũng có thể cấu hình
--cpu-shares, nhưng nó chỉ được cưỡng chế khi host bị thiếu CPU
- Nếu host còn dư tài nguyên, container có thể dùng nhiều hơn số lõi CPU được phân bổ
- Khi host rơi vào trạng thái bị ràng buộc tài nguyên, ứng dụng sẽ bị giới hạn
- Linux Completely Fair Scheduler(CFS) được giới thiệu từ Linux 2.6.23 và là scheduler mặc định cho đến trước Linux 6.6
- CFS là một proportional share scheduler, trong đó weight của tiến trình tỷ lệ với số lõi CPU mà nó có thể sử dụng
- Weight của tiến trình có thể dùng 4 lõi CPU là 4
- Weight của tiến trình có thể dùng 2 lõi CPU là 2
- CFS phân chia và cấp phát thời gian CPU
- Một hệ thống 4 lõi có thể phân bổ 4 giây CPU trong mỗi giây thực
- Việc cấp
n lõi CPU cho container tương đương với yêu cầu Linux scheduler cấp lượng thời gian bằng n CPU
--cpus=4 nghĩa là container nhận được 4 giây CPU trong mỗi giây thực
Vì sao STW kéo dài
- Runtime Go khi khởi động sẽ tạo một luồng OS cho mỗi lõi CPU
- Trên máy 16 lõi, nó có thể tạo 16 luồng OS bất kể giới hạn CPU của CGroup
- Runtime sẽ lập lịch goroutine lên các luồng OS này
- Dù container chỉ bị giới hạn ở 4 lõi CPU, Go vẫn có thể đưa goroutine lên toàn bộ 16 luồng OS
- Trong trạng thái này, runtime sẽ kỳ vọng nó có thể dùng tới 16 giây CPU trong mỗi giây thực
- Thời gian STW kéo dài vì phải chờ dừng cả các goroutine nằm trên những luồng đang chờ Linux scheduler cho chạy lại
- Sau khi container đã dùng hết CPU quota, các luồng đó sẽ không còn được scheduler cấp chạy nữa
Điều chỉnh GOMAXPROCS theo CPU quota
- Go cho phép dùng biến môi trường
GOMAXPROCS để giới hạn số luồng CPU mà runtime sử dụng
- Với container có quota CPU là 4, hãy đặt thêm
GOMAXPROCS=4
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
- Với cùng ứng dụng và cùng tải, khi đặt
GOMAXPROCS khớp với CPU quota thì thời gian GC giảm xuống
- Trong trace, chu kỳ GC giảm xuống dưới 1ms và đoạn STW là 26μs
- So với thời gian STW khi không giới hạn
GOMAXPROCS, con số này chỉ còn khoảng 1/10
GOMAXPROCS nên được đặt bằng số lõi CPU mà container có thể sử dụng
- Khi cấp fractional CPU thì làm tròn xuống
- Khi cấp dưới 1 CPU thì làm tròn lên
- Công thức là
GOMAXPROCS=max(1, floor(CPUs))
- automaxprocs của Uber là thư viện mã nguồn mở tự động tính giá trị này từ cgroups của container
- Đã có GitHub Issue nhằm hỗ trợ điều này mặc định trong runtime Go
Những điều cần kiểm tra trong dịch vụ Go chạy container
- Chỉ đặt giới hạn CPU là chưa đủ; cũng cần chỉnh GOMAXPROCS để runtime Go phản ánh đúng giới hạn đó
- Nếu khó tự tính, có thể dùng thư viện như automaxprocs để tự động đặt giá trị dựa trên cgroups
- Với dịch vụ Go nhạy cảm với độ trễ, nên kiểm tra thời gian STW trong GC trace để xác minh CPU quota và cấu hình runtime không bị lệch nhau
1 bình luận
Các ý kiến trên Hacker News
Một vấn đề thường thấy ở nhiều ngôn ngữ là ứng dụng nhìn vào /proc/cpuinfo để phát hiện số lõi của máy
Nhưng bên trong container Docker hoặc các công nghệ container khác, tệp này trông giống hệt như trên host của container và liệt kê tất cả các lõi, bất kể thực tế container chỉ được cấp phát vài lõi
Trong một thời gian, tôi từng nghĩ liệu Docker có thể tạo một /proc/cpuinfo giả chỉ liệt kê các “Docker CPU” được cấp cho tác vụ hay không, nhưng nghĩ lại thì có vẻ sẽ không ổn vì nhiều lý do
Thứ bị giới hạn là có thể sử dụng các lõi đó trong bao lâu
Cũng có ngoại lệ, và tài liệu ở đây: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
nproc, và cũng thấy các container khác dùng kiểubundle install -j $(nproc)Cái này tôn trọng phần CPU được cấp phát, nên cung cấp đúng chức năng đang tìm
Tôi không biết các ứng dụng tùy ý có dùng nproc khi có thể hay không
“In ra số đơn vị xử lý hiện có cho tiến trình hiện tại, con số này có thể nhỏ hơn số bộ xử lý đang online. Nếu không lấy được thông tin này, in ra số bộ xử lý đã cài đặt”
https://www.gnu.org/software/coreutils/manual/html_node/npro...
https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
Go xem số lượng trong CPU mask tại thời điểm khởi động, rồi sau đó không xem lại nữa
Trong Kubernetes, các CPU mà tiến trình nhìn thấy có thể thay đổi trong lúc tiến trình đang chạy, nên điều này trở thành vấn đề
lxcfs là một hệ thống tệp FUSE suy luận các giá trị cgroup để mô phỏng /proc, giúp ứng dụng và thư viện không cần quan tâm liệu chúng có đang chạy trong container hay không
Ví dụ /proc/uptime nên phản ánh thời gian hoạt động của container chứ không phải của host, còn /proc/cpuinfo phản ánh giới hạn thấp hơn trong tổ hợp cpu.max và cpuset.cpus dưới dạng số CPU
Việc suy luận số CPU cũng có thể làm bằng system call
sched_getaffinity, cách này không phụ thuộc vào /proc/cpuinfoVì vậy tùy thư viện bạn dùng mà có thể rơi vào tình huống khó xử
Giải thích này sai một cách tinh vi
Từ góc nhìn Docker, phần mở rộng CFS cgroup có nhiều núm điều chỉnh:
cfs_quota_us,cfs_period_us(giá trị mặc định phổ biến là 100ms chứ không phải 1 giây), và sharesNếu đặt shares thì áp dụng lập lịch tỉ lệ dựa trên trọng số, nhưng nó chỉ có ý nghĩa khi có tranh chấp tài nguyên
Hai giá trị đầu áp đặt quota nghiêm ngặt
Thay vì cờ
--cpucủa Docker, tốt hơn nên dùng--cpu-sharesđể tránh việc áp quota phần lớn là vô dụngTheo tài liệu Linux,
cpu.shareslà trọng số của từng nhóm ở cùng một cấp,cpu.cfs_period_uslà chu kỳ của scheduler để đánh giá băng thông, và mặc định là 100000us hoặc 100mscpu.cfs_quota_uslà thời gian tối đa mà nhóm hiện tại có thể chạy trong mỗicfs_period_us, và giá trị này là tổng thời gian cộng dồn trên toàn bộ CPU của hệ thống, nên nếu muốn dùng trọn vẹn 2 CPU thì phải đặt bằng hai lầncfs_period_us--cpucủa Docker, thay vào đó…” là quá mạnh nếu không có thêm ngữ cảnhTuyệt đối không thể xem nó là “phần lớn vô dụng”
shares và quota phục vụ các trường hợp sử dụng khác nhau, nên hãy hiểu trường hợp của mình và chọn cho phù hợp
--cpu, ứng dụng có thể phát hiện ra điều đóCó lẽ là vì nó dùng cpuset
Nếu dùng quota, ứng dụng không phát hiện được nên rất dễ tạo ra nhiều thread hơn cần thiết
Tôi sẽ cố làm rõ hơn phần này
Tôi nghĩ triệu chứng sẽ xuất hiện theo kiểu như vậy, nhưng cần diễn đạt rõ ràng hơn
Ứng dụng phải hoạt động đúng
Nếu dùng CPU reservations thay cho CPU limits thì không cần những điều chỉnh như vậy: https://home.robusta.dev/blog/stop-using-cpu-limits
CPU reservations thực ra cũng gần như là limits, nhưng được khai báo như một giới hạn ngầm định đồng thời là một bảo đảm
Vì vậy cứ để Go runtime dùng tất cả CPU có thể dùng, và khi xảy ra tranh chấp CPU thì để Linux scheduler giới hạn theo các reservations đã khai báo là được
Mà là vì không muốn quen với trạng thái có thể dùng phần CPU vượt mức không được bảo đảm
Khi node dần được lấp đầy bởi các pod khác, một pod vừa mới chạy ổn có thể đột ngột chậm lại
Dùng limits có thể mô phỏng hành vi tương tự và chuẩn bị bằng việc hoạch định dung lượng đúng
Đây không phải cách duy nhất, nhưng là cách đơn giản nhất
Tôi muốn biết thêm về cuộc thảo luận này, nhưng bài viết được liên kết có vẻ chỉ nói về việc mọi người nghĩ cần limit để bảo đảm CPU cho tất cả pod
Bài này bản thân nó không sai và nhìn chung gần với content marketing, nhưng các kết luận quá rộng và bỏ qua nhiều lý do chính đáng để đặt limits
Một số bài cùng trang đó thì đơn giản là sai: https://home.robusta.dev/blog/containers-dont-use-chroot
Có những workload dùng hết toàn bộ dung lượng burst dù chỉ thu được lợi ích nhỏ, và cũng có lúc cần ưu tiên dung lượng burst cho HTTP server hơn là cronjob sẽ kết thúc trong thời gian đã định
Cũng từng có trường hợp developer không cập nhật requests dù nhu cầu của app đã tăng, rồi sự cố xảy ra khi thời gian CPU dư thừa đột ngột không còn đủ
Về lý thuyết đó là tài nguyên tối thiểu được bảo đảm, nhưng nếu các container bận rộn cùng chạy trên một host, tail latency và latency trung bình có thể tăng bất thường
Trên một instance EC2 4 core, latency khi mức sử dụng CPU là 50% và 90% khác nhau khá nhiều
Với reservations cũng tương tự: dù mỗi container được bảo đảm reservation của nó, mức sử dụng CPU tương đối vẫn trở nên rất cao vì các tiến trình bận rộn khác trên cùng host
OOMKiller có thể xử lý nó
Nếu không có cả CPU lẫn memory limits thì không thể có Guaranteed QoS class, nên đến một lúc nào đó pod có thể bị evict
Tôi đã nhiều lần bị CFS scheduler làm khó khi dùng container và cgroup
Tôi tò mò scheduler mới là gì
Ở đây có ai đã dùng nó trong production cluster chưa?
Chúng ta đã lãng phí core gần 20 năm rồi: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
Vấn đề là container đã đặt giới hạn tài nguyên, nhưng Go, tiến trình bên trong container, lại không kiểm tra tính năng hệ điều hành dùng cho giới hạn đó khi tính toán lượng song song có thể dùng
Ngoài GOMAXPROCS, các bản phát hành Go gần đây còn có GOMEMLIMIT
Dùng https://github.com/KimMachineGun/automemlimit có thể tự động thiết lập giới hạn này, tương tự https://github.com/uber-go/automaxprocs
Năm ngoái, ở công ty cũ, khi làm platform engineer quản lý cụm Kubernetes on-premise và hạ tầng pipeline CI/CD, tôi đã phát hiện ra điều này
Tôi thấy sự không khớp giữa CPU thực tế và CPU được cấp phát đặc biệt gây ra các vấn đề như CPU throttling, nhưng rất khó tìm một giải pháp có thể mở rộng để tác động tới mọi deployment Go trong cluster
Bắt tất cả developer của hàng trăm dự án thêm dependency autoprocs không phải là một lựa chọn
Phương án khác là đặt mọi CPU request/limit thành số nguyên rồi đưa giá trị đó vào biến môi trường GOMAXPROCS trong Kubernetes manifest, nhưng cách này cũng rườm rà và không khả thi
Cuối cùng, chúng tôi chỉ áp dụng biến GOMAXPROCS cho một số ứng dụng dùng nhiều đa luồng và thu được cải thiện, nhưng vẫn chưa tìm được giải pháp có thể áp dụng cho mọi deployment trong kiến trúc microservices, nơi nhu cầu CPU thay đổi lớn theo từng dự án
Giới hạn GOMAXPROCS có thể gây ra vấn đề latency nghiêm trọng khi lưu lượng dồn vào tiến trình và hàng đợi đơn giản
Bất kể bạn nghĩ tiến trình trung bình sẽ dùng bao nhiêu thời gian, trên thực tế tốt nhất là đặt GOMAXPROCS theo giá trị phần cứng cung cấp
Với tư cách người không quen Docker hay Go, tôi tò mò hành vi này có phải là cố ý không
Có thể làm cho Go team nhận biết CGroups limit không?
Các runtime khác cũng hoạt động tương tự à?
Hay ý bạn là các runtime như containerd?
Khi đó là với Scala
Cũng có các kỹ thuật GC giúp thời gian tạm dừng ngắn hơn
Ví dụ như thực hiện đồng thời những việc cần làm trong lúc tạm dừng, rồi lặp lại ở điểm an toàn
Kỳ vọng là nhờ công việc đồng thời, việc tại điểm an toàn sẽ trở thành một phép kiểm tra đơn giản rằng “không còn gì để làm”
Nếu làm gấp đôi công việc thì throughput của GC có thể kém đi
Bài viết này nói về container, nhưng có vẻ vấn đề sẽ xuất hiện bất cứ khi nào Go chỉ có thể tiếp cận ít thời gian CPU hơn kỳ vọng
Chẳng phải điều tương tự cũng xảy ra khi chạy Go trên một hệ thống có các tiến trình khác đang dùng CPU sao?
Thậm chí chỉ cần chạy đồng thời hai chương trình Go cũng có thể như vậy, phải không?