Hình dung finance cấp cho bạn một cái thẻ chi tiêu, hạn mức 500 nghìn một ngày — thật, và được ép chặt. Nhưng mọi màn hình bạn nhìn đều hiện hạn mức của cả công ty, 2 tỉ. Bạn lên kế hoạch mua món 5 triệu vì tưởng có 2 tỉ, quẹt thẻ, và bị từ chối ở 500 nghìn — không một lời báo trước vì sao. Muốn biết hạn mức thật của mình, bạn phải hỏi thẳng finance, chứ không đọc con số trên màn hình. Giới hạn tài nguyên của Docker đúng như cái thẻ đó: nó hoạt động chính xác, nhưng container không nhìn thấy mình bị giới hạn — nó vẫn thấy con số của cả máy. Khoảng cách giữa hai điều đó là nguồn của một lớp sự cố rất khó chẩn đoán.

--cpus giới hạn đúng như khai

Bốn luồng tính toán, đo từ ngoài container:

Thời gian
--cpus 0.5 19,67 s
--cpus 1 9,77 s
--cpus 2 4,58 s
--cpus 4 2,33 s
--cpus 8 2,40 s
không giới hạn 2,40 s

Tỉ lệ nghịch gần như hoàn hảo: mỗi lần gấp đôi hạn mức thì thời gian giảm một nửa, cho tới khi chạm số luồng công việc (4). Từ đó trở đi thêm CPU không giúp gì — đúng như đường cong đã thấy ở phần 23.

Cơ chế bên dưới là quota theo chu kỳ:

--cpus 0.5 -> cgroup cpu.max = 50000 100000
--cpus 2   -> cgroup cpu.max = 200000 100000

Nghĩa là "được dùng 50 000 micro giây CPU trong mỗi chu kỳ 100 000 micro giây". Với --cpus 2 thì quota lớn hơn chu kỳ — hai nhân chạy song song.

Nhưng container nhìn thấy cả máy

--cpus 0.5 : nproc bao 16
--cpus 1   : nproc bao 16
--cpus 4   : nproc bao 16
-m 64m  : /proc/meminfo bao MemTotal: 16354680 kB
-m 256m : /proc/meminfo bao MemTotal: 16354680 kB

nproc báo 16 nhân dù hạn mức là nửa nhân. /proc/meminfo báo 16 GB dù hạn mức là 64 MB.

Đây không phải lỗi — /proc là của nhân, và nhân thì thật sự có 16 nhân với 16 GB. Không gian tên không ảo hoá /proc/cpuinfo hay /proc/meminfo.

Hậu quả thì rất thật: mọi thư viện tự cấu hình theo số nhân đều đọc sai. Số luồng worker, kích thước pool kết nối, kích thước heap — tất cả tính theo 16 nhân và 16 GB trong khi container chỉ có nửa nhân và 64 MB. Đó là chủ đề của phần sau.

Con số đúng nằm ở cgroup:

cat /sys/fs/cgroup/cpu.max      # "50000 100000"
cat /sys/fs/cgroup/memory.max   # so byte

Vượt hạn mức bộ nhớ

Một chương trình cấp phát dần 500 MB:

Mã thoát OOMKilled Dừng ở
-m 128m 137 true 200 MB
-m 256m 137 true 350 MB
-m 1g 0 false xong 500 MB

Không có cảnh báo, không có lỗi từ ứng dụng — tiến trình bị SIGKILL giữa chừng.

Chú ý cột cuối: với hạn mức 128 MB, chương trình cấp phát được tới 200 MB rồi mới chết. Nhân đẩy trang cũ ra và thu hồi bộ nhớ đệm trước khi giết, nên ngưỡng chết không trùng khít với hạn mức. Đừng đặt hạn mức sát mức dùng thật rồi tin rằng nó sẽ chết đúng ở đó.

Mã 137 có hai nghĩa

Phần 3 đã gặp mã 137 khi docker stop hết thời gian ân hạn. Giờ nó lại xuất hiện khi OOM. Cách phân biệt:

het gio dung : ExitCode=137  OOMKilled=false
bi OOM       : ExitCode=137  OOMKilled=true
docker inspect <ten> --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'

Hai nguyên nhân hoàn toàn khác nhau và cách chữa cũng khác: một bên là ứng dụng không xử lý SIGTERM, một bên là thiếu bộ nhớ. Nhìn mã thoát thôi thì không phân biệt được.

Trường hợp nguy hiểm nhất: thoát 0 mà vẫn bị OOM

Nếu container chạy nhiều tiến trình, OOM killer chọn tiến trình ngốn nhất, không nhất thiết là PID 1. Tôi cho PID 1 là một script nhỏ, và tiến trình con là chương trình ăn RAM:

PID1=1 con=7
Killed
con thoat voi ma 137
PID 1 VAN SONG
ket qua: ExitCode=0 OOMKilled=true

Tiến trình con bị giết. PID 1 sống sót, chạy nốt script, và container thoát với mã 0.

Từ bên ngoài, đây là một container chạy thành công. Mọi cảnh báo dựa trên mã thoát đều im lặng. Chỉ có OOMKilled=true nói lên sự thật, và trường đó không xuất hiện trong docker ps, không xuất hiện trong log, không xuất hiện ở bất cứ đâu bạn hay nhìn.

Đây là biến thể container của đúng luật đã đo suốt sê-ri Vert.x: thứ giết dịch vụ của bạn thường không sinh ra lỗi nào.

Kịch bản thật hay gặp: container chạy một entrypoint script gọi ứng dụng làm tiến trình con — đúng cái mẫu mà phần 8 đã chỉ ra là thiếu exec. Ứng dụng bị OOM giết, script chạy tiếp và thoát bình thường, và bạn có một container "khoẻ mạnh" không phục vụ ai.

Một lý do nữa để dòng cuối entrypoint script luôn bắt đầu bằng exec.

--cpu-shares khác --cpus

Hai cờ dễ nhầm:

Chạy một mình Thời gian
--cpu-shares 512 2,30 s
--cpu-shares 2048 2,33 s
--cpus 1 4,54 s

--cpu-shares không giới hạn gì cả khi container chạy một mình — hai giá trị cách nhau bốn lần cho ra cùng thời gian. Nó chỉ là trọng số cho bộ lập lịch khi có tranh chấp.

Cho hai container tranh nhau hai nhân:

it-uu-tien   (512) : 8,67 s
nhieu-uu-tien(2048): 8,12 s

Chênh khoảng 7%, không phải bốn lần như tỉ lệ trọng số gợi ý. Trọng số ảnh hưởng tới việc chia thời gian CPU nhưng không phải một đảm bảo tỉ lệ, và kết quả phụ thuộc nhiều vào hình dạng tải.

Luật thực dụng: dùng --cpus khi cần một giới hạn chắc chắn, dùng --cpu-shares khi chỉ muốn nói "cái này quan trọng hơn cái kia" trên một máy chạy nhiều thứ.

Nên đặt gì

docker run \
  --cpus 2 \
  --memory 512m \
  --memory-swap 512m \
  --pids-limit 200 \
  ...
  • --memory-swap bằng --memory để tắt swap. Không đặt thì container được dùng swap gấp đôi hạn mức, và ứng dụng chậm thảm hại thay vì chết dứt khoát — chậm thì khó chẩn đoán hơn nhiều.
  • --pids-limit chặn fork bomb, kể cả vô tình. Không tốn gì để đặt.
  • Luôn đặt --memory cho mọi container chạy thật. Không đặt thì một rò rỉ bộ nhớ sẽ ăn hết RAM máy chủ và OOM killer của nhân sẽ chọn nạn nhân theo tiêu chí của nó — có thể là một dịch vụ khác hoàn toàn.

Muốn biết container nào trên máy mình chưa có hạn mức, và cái nào từng bị OOM mà bạn không hay, quét hai vòng:

docker ps -q | while read c; do
  printf "%-28s cpu=%s mem=%s\n" \
    "$(docker inspect $c --format '{{.Name}}')" \
    "$(docker inspect $c --format '{{.HostConfig.NanoCpus}}')" \
    "$(docker inspect $c --format '{{.HostConfig.Memory}}')"
done
docker ps -aq | while read c; do
  docker inspect $c --format '{{.Name}} {{.State.ExitCode}} {{.State.OOMKilled}}'
done | grep true

Giá trị 0 ở cột hạn mức nghĩa là không giới hạn; dòng nào grep true in ra là một container đã bị OOM mà có thể bạn chưa từng biết.

Mẫu số chung

Bài học đầu tiên, chính là cái thẻ chi tiêu: bị ép một giới hạn khác với nhìn thấy nó — cái ép sống ở một chỗ (cgroup), con số bạn ngây thơ đọc sống ở chỗ khác (/proc hiện cả máy) — nên bất cứ thứ gì tự cấu hình theo con số nhìn thấy đều sai. Một container nửa nhân/64 MB mà nproc báo 16, meminfo báo 16 GB, thì mọi thư viện tự tính số luồng, kích thước pool, kích thước heap theo 16 nhân đều vống lên gấp bội. Cùng khoảng cách "ép nhưng không cho thấy" ở khắp nơi: JVM/Node đọc số nhân của máy chủ trong container (đúng chủ đề phần sau), một tiến trình đọc tổng RAM thay vì rlimit của nó, giá trị hiệu dụng khác giá trị tổng. Nguyên tắc: đọc cái giới hạn thật sự ràng buộc bạn, từ đúng nơi sở hữu nó — góc nhìn môi trường xung quanh nói dối, và một hệ thống tự-điều-chỉnh phải được chỉ tới nguồn có thẩm quyền, không phải con số ngẫu nhiên nó vớ được.

Điều thứ hai, đọc từ "137 có hai nghĩa" và "thoát 0 mà vẫn OOM": một triệu chứng chung che nhiều nguyên nhân khác nhau, và nguyên nhân thật nấp trong một trường bạn không nhìn tới. Mã 137 vừa là OOM vừa là hết-giờ-dừng; và một tiến trình con bị giết trong khi PID 1 sống sót cho ra ExitCode=0 trông "thành công" hoàn hảo — chỉ OOMKilled=true mới nói thật, mà trường đó không hiện trong docker ps, trong log, ở bất cứ chỗ nào bạn hay nhìn. Cùng cái bẫy "một mã, nhiều nguyên nhân, sự thật ở trường ít ai đọc" ở khắp nơi: HTTP 500 gộp đủ loại lỗi, một thông báo ngoại lệ chung chung, errno khác cái lỗi thực, một status catch-all. Nguyên tắc: chẩn đoán bằng trường gọi tên nguyên nhân (OOMKilled), đừng dừng ở cái triệu chứng dùng chung — và nhớ rằng thứ giết dịch vụ của bạn thường không sinh ra một dòng lỗi nào, nên phải chủ động đi soi đúng cái trường nói thật.

Phần sau đo chuyện JVM và Node đọc sai số nhân với RAM trong container, và cấu hình sai gây ra gì.