Bài trước ta thấy unshare --pid tạo một PID namespace mới và shell trở thành PID 1. Trong Docker, điều này xảy ra với ứng dụng của bạn: tiến trình chính của container luôn là PID 1 — vị trí thường dành cho init của cả một hệ điều hành. Nghe vô hại, nhưng nó kéo theo một hành vi kernel đặc biệt khiến rất nhiều container "không chịu tắt" khi docker stop, bị giết cứng và mất dữ liệu. Bài này (phần 2 loạt Docker) chạy thật để thấy cạm bẫy PID 1 và cách sửa đúng.

Mỗi container một cây tiến trình riêng

PID namespace khiến các tiến trình trong container được đánh số độc lập với host. Tiến trình chính thành PID 1, và cùng một tiến trình có PID khác nhau nhìn từ trong và ngoài:

docker run alpine sh -c 'echo $$'          # -> 1 (nhìn từ trong container)
docker inspect -f '{{.State.Pid}}' <ct>    # -> 75513 (chính nó, nhìn từ host)

Đây là cơ chế cô lập: container thấy cây tiến trình của riêng mình, bắt đầu từ 1, không thấy (và không giết được) tiến trình của host hay container khác.

Ảnh chụp đoạn mã nền tối minh hoạ PID namespace ứng dụng trong container là PID 1 và điều đó nguy hiểm, mỗi container một cây tiến trình riêng tiến trình chính của container thành PID 1 như init của cả một hệ cùng một tiến trình có PID khác nhau trong và ngoài ns docker run alpine sh echo đô đô ra 1 trong container docker inspect State.Pid ra 75513 trên host, bẫy kernel bỏ qua tín hiệu không có handler gửi tới PID 1 cơ chế bảo vệ init SIGTERM SIGINT mặc định không giết PID 1 app không tự xử lý SIGTERM sẽ phớt lờ docker stop docker gửi TERM chờ hết grace mặc định 10s rồi SIGKILL tắt cứng mất dữ liệu không kịp đóng kết nối flush, hai cách sửa một ứng dụng tự xử lý SIGTERM đóng gọn rồi thoát trap cleanup exit 0 TERM hoặc signal.Notify trong Go hai dùng init nhỏ làm PID 1 docker run --init chèn tini chuyển tiếp tín hiệu cộng thu tiến trình mồ côi zombie, vì sao PID 1 còn phải thu tiến trình mồ côi tiến trình cha chết con mồ côi được PID 1 nhận nuôi PID 1 phải wait chúng không thì tích tụ zombie shell app thường không làm việc này tini --init lo hộ

Hình 1: Tiến trình chính của container là PID 1 (PID khác host); kernel bỏ qua tín hiệu không-handler cho PID 1 nên app không xử lý SIGTERM sẽ phớt lờ docker stop; sửa bằng cách tự xử lý SIGTERM hoặc dùng --init (tini).

Đo thật: PID 1 phớt lờ SIGTERM

Kernel Linux có một cơ chế bảo vệ đặc biệt cho PID 1: nó không áp hành vi mặc định của các tín hiệu (như SIGTERM giết tiến trình) lên PID 1. Chỉ những tín hiệu mà PID 1 cài handler mới có tác dụng. Ý định là bảo vệ init khỏi bị giết nhầm — nhưng trong container, "init" chính là app của bạn. Đo thật:

Ảnh chụp bảng kết quả chạy thật docker cộng PID namespace output thật, một app là PID 1 cùng tiến trình PID khác trong ngoài trong container echo đô đô ra 1 ps ra 1 sleep cùng sleep đó trên host docker inspect State.Pid ra 75513, hai PID 1 sleep không handler phớt lờ SIGTERM docker kill signal TERM container chờ 4s State.Running bằng true vẫn sống kernel bỏ qua TERM cho PID 1 tiến trình con thường bị TERM thì chết ngay exit 143, ba hệ quả docker stop phải chờ grace rồi SIGKILL docker stop PID1 không xử lý TERM bằng 3.1s chờ rồi giết cứng docker stop -t 2 bằng 2.1s grace do -t quyết định, bốn sửa bằng --init tini làm PID 1 docker run --init PID 1 bằng docker-init tini docker stop --init bằng 0.1s tini nhận TERM dừng ngay cộng thu zombie

Hình 2: Chạy thật — app là PID 1 (PID host 75513); gửi SIGTERM cho PID 1 (sleep, không handler) → sau 4s State.Running=true (vẫn sống!); docker stop mất 3.1s (chờ grace rồi SIGKILL), -t 2 còn 2.1s; --init cho PID 1 = docker-init (tini) → docker stop chỉ 0.1s.

  • PID 1 sống sót SIGTERM: chạy alpine sleep 300 (busybox exec luôn sleep thành PID 1), gửi SIGTERM, chờ 4 giây — container vẫn Running=true. Cùng tín hiệu đó gửi cho một tiến trình con thường sẽ giết nó ngay (exit 143). Đây là bằng chứng kernel bảo vệ PID 1.
  • Hệ quả cho docker stop: docker stop gửi SIGTERM rồi chờ một khoảng ân hạn trước khi SIGKILL. Vì PID 1 không xử lý TERM, nó phớt lờ và bị giết cứng sau khi hết grace — đo thật 3.1s mặc định, và docker stop -t 2 xuống 2.1s (grace do -t quyết định). Bị SIGKILL nghĩa là app không kịp đóng kết nối, flush dữ liệu, dọn dẹp — nguy cơ mất dữ liệu.

Hai cách sửa

  • Ứng dụng tự xử lý SIGTERM: cài handler để đóng gọn rồi thoát — trap 'cleanup; exit 0' TERM trong shell, signal.Notify trong Go, process.on('SIGTERM') trong Node. Khi đó docker stop dừng ngay và sạch.
  • Dùng init nhỏ làm PID 1: docker run --init chèn tini làm PID 1. tini nhận tín hiệu và chuyển tiếp xuống app, đồng thời thu tiến trình mồ côi (zombie). Đo thật: với --init, PID 1 là docker-init và docker stop chỉ mất 0.1s.

Vì sao PID 1 còn phải "thu" tiến trình mồ côi

PID 1 mang thêm một trách nhiệm của init: khi một tiến trình cha chết, các con mồ côi được PID 1 "nhận nuôi", và PID 1 phải wait() chúng khi chúng kết thúc — nếu không, chúng thành zombie tích tụ trong bảng tiến trình. Một app bình thường (hay shell) không làm việc reaping này, nên trong container chạy nhiều tiến trình con (ví dụ shell gọi nhiều lệnh) có thể rò rỉ zombie. tini/--init lo hộ việc đó — thêm một lý do nên dùng khi app của bạn không phải là một init thực thụ.

Đánh đổi cần cân nhắc

Không phải app nào cũng cần --init. Nếu app của bạn đã xử lý SIGTERM đúng và không sinh tiến trình con mồ côi (nhiều server viết tốt làm được), thì --init là thừa. --init cần thiết nhất khi PID 1 là một script/shell, hoặc app không reaping con. Biết app mình thuộc loại nào; đừng thêm --init như một câu thần chú.

exec trong entrypoint script quan trọng. Một lỗi phổ biến: entrypoint là sh -c "myapp" — shell thành PID 1, myapp thành con, và tín hiệu gửi cho shell (PID 1) không tới myapp. Dùng exec myapp để myapp thay thế shell và trở thành PID 1 trực tiếp nhận tín hiệu. Đây là lý do nhiều Dockerfile viết ENTRYPOINT ["myapp"] (dạng exec) thay vì dạng shell.

Grace period phải đủ cho app đóng gọn. docker stop mặc định chờ 10s (theo tài liệu; đo trong môi trường này ~3s) rồi SIGKILL. Nếu app cần lâu hơn để flush (ghi buffer lớn, đóng nhiều kết nối), tăng bằng --time/STOPSIGNAL/stop_grace_period. Ngược lại, đặt quá dài làm deploy/restart chậm. Cân theo thời gian shutdown thật của app.

Ba ý mang về

  1. App trong container là PID 1: đo thật, tiến trình chính có PID 1 trong container nhưng PID khác (75513) trên host — cô lập bằng PID namespace, mỗi container một cây tiến trình.
  2. Kernel bỏ qua tín hiệu không-handler cho PID 1: đo thật, PID 1 (sleep) sống sót SIGTERM sau 4s, khiến docker stop phải chờ grace rồi SIGKILL (3.1s) — app không xử lý SIGTERM bị tắt cứng, nguy cơ mất dữ liệu.
  3. Sửa bằng xử lý SIGTERM hoặc --init: đo thật --init (tini làm PID 1) kéo docker stop xuống 0.1s và thu zombie; nhớ dùng exec trong entrypoint để app thật sự thành PID 1 nhận tín hiệu.

Nguồn

Phần sau ta xem container có hệ thống file riêng thế nào: mount namespace, chroot/pivot_root, và vì sao mỗi container thấy một cây thư mục gốc / của riêng nó dù dùng chung kernel.