Có một cái bẫy trong việc đo lường mà bài này là ví dụ sạch nhất: chọn nhầm chỉ số thành công. "Dừng nhanh" nghe như một điều tốt — ai chẳng muốn container tắt gọn — cho tới khi bạn nhận ra cách nhanh nhất để rời khỏi phòng là nhảy qua cửa sổ, bỏ lại đèn bật, bếp cháy, cửa mở. Dừng êm không phải dừng nhanh; nó là dừng có kịp khoá cửa tắt bếp. Mọi container đều bị dừng, thường xuyên hơn nhiều so với bị lỗi, nên đây là đường nóng ai cũng đi qua — và bài này đo sáu cách đóng gói cùng một ứng dụng để xem cái nào thực sự dừng êm.

(Các con số là thời gian dừng và mã thoát đo trong container, không phải perf counter phần cứng.)

Bảng

Ứng dụng là một script có trap TERM dọn dẹp 1 giây rồi thoát. docker stop -t 10.

Cách chạy Dừng sau Mã thoát Dọn dẹp có chạy?
PID 1 = ứng dụng, không bắt SIGTERM 10.160 ms 137 không
PID 1 = ứng dụng, có bắt SIGTERM 1.150 ms 0 có
sh -c "/app" — ứng dụng là con 10.150 ms 137 không
sh -c "exec /app" — thêm một từ 1.149 ms 0 có
--init + sh -c "/app" 113 ms 143 không
--init + /app trực tiếp 1.152 ms 0 có

Dòng nhanh nhất là dòng tệ nhất

113 mili giây — nhanh gấp mười lần cấu hình đúng. Và bước dọn dẹp không chạy: nhật ký chỉ có dòng khởi động, không có dòng "nhận SIGTERM". Trên bảng điều khiển, cấu hình này trông tốt nhất — container dừng gần như tức thì, không có cảnh báo timeout, không có SIGKILL — nhưng nó là cấu hình duy nhất trong bảng mất dữ liệu. Bài học gọn: thời gian dừng ngắn không phải chỉ số của việc dừng êm; chỉ số đúng là mã thoát 0 cộng với bằng chứng trong nhật ký rằng bước dọn dẹp đã chạy xong.

Hai lỗi độc lập, cùng một triệu chứng

Lỗi thứ nhất là ứng dụng không bắt SIGTERM. Nhân có một quy tắc đặc biệt cho PID 1: tín hiệu không có trình xử lý thì bị bỏ qua, chứ không giết tiến trình như với mọi PID khác — quy tắc đó tồn tại để bảo vệ init của hệ thống khỏi bị giết nhầm. Trong container, ứng dụng của bạn là PID 1, nên SIGTERM không làm gì cả, và mười giây sau SIGKILL — thứ không thể bỏ qua — kết thúc mọi thứ giữa chừng.

Lỗi thứ hai là ứng dụng bị bọc trong một shell:

ENTRYPOINT /app          # dang shell -> chay thanh: /bin/sh -c "/app"
ENTRYPOINT ["/app"]      # dang JSON  -> chay thang

Dạng shell làm PID 1 trở thành /bin/sh. Shell nhận SIGTERM, và không chuyển tiếp cho tiến trình con — ứng dụng có trình xử lý hoàn hảo và không bao giờ nhận được tín hiệu để chạy nó. Thêm đúng một từ:

ENTRYPOINT exec /app

exec thay thế shell bằng ứng dụng, nên ứng dụng trở thành PID 1 — dòng thứ tư trong bảng: 1.149 ms, mã thoát 0. Hai lỗi này cho cùng một triệu chứng (container mất mười giây để dừng và thoát với mã 137), nên chẩn đoán sai rất dễ.

--init sửa được gì và không sửa được gì

docker run --init chèn tini làm PID 1. Nó giải quyết đúng hai việc: thu hồi tiến trình mồ côi (không có PID 1 tử tế thì tiến trình con chết để lại xác zombie mãi mãi), và chuyển tiếp tín hiệu (nó gửi SIGTERM xuống dưới). Dòng thứ sáu cho thấy nó hoạt động: --init + ứng dụng trực tiếp cho 1.152 ms và mã thoát 0. Nhưng dòng thứ năm cho thấy giới hạn: --init + sh -c vẫn hỏng, vì tini gửi tín hiệu tới nhóm tiến trình nên cả shell lẫn ứng dụng cùng nhận SIGTERM — và ứng dụng chết vì tín hiệu (mã 143) trước khi kịp chạy trình xử lý. --init không thay thế được việc đóng gói đúng: nó bù cho việc thu hồi zombie, không bù cho một tầng shell thừa.

Mã thoát đọc thế nào:

Mã Nghĩa
0 Thoát sạch, tự nguyện
137 128 + 9 — bị SIGKILL. Hết thời gian chờ hoặc OOM.
143 128 + 15 — chết vì SIGTERM, không tự thoát
139 128 + 11 — SIGSEGV

143 là mã dễ hiểu nhầm nhất: nó không nghĩa là dừng êm — nó nghĩa là tiến trình bị tín hiệu giết vì không có trình xử lý, chỉ khác 137 ở chỗ tín hiệu là SIGTERM chứ không phải SIGKILL. Dừng êm thật sự luôn cho mã 0.

Dừng êm nên làm gì

Thứ tự đúng cho một dịch vụ mạng: (1) ngừng nhận yêu cầu mới — đóng cổng lắng nghe để bộ cân bằng tải thấy dịch vụ đã rời hàng; (2) báo kiểm tra sức khoẻ là không sẵn sàng và chờ đủ lâu để bộ cân bằng tải kịp nhận ra (với Kubernetes là periodSeconds × failureThreshold); (3) xử lý xong các yêu cầu đang dở, có thời hạn; (4) đóng kết nối tới cơ sở dữ liệu, xả bộ đệm log (phần 41); (5) thoát với mã 0. Bước 2 hay bị bỏ qua nhất — nếu bạn đóng cổng lắng nghe ngay lập tức, bộ cân bằng tải vẫn gửi yêu cầu tới trong vài giây nữa và người dùng nhận lỗi kết nối, dù dịch vụ của bạn "dừng êm" hoàn hảo. Trong Kubernetes:

lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]      # cho bo can bang tai kip cap nhat
terminationGracePeriodSeconds: 30  # phai LON HON tong thoi gian tren

preStop chạy trước SIGTERM, và thời gian của nó nằm trong thời hạn ân hạn — đặt preStop 5 giây với ân hạn 30 giây nghĩa là ứng dụng còn 25 giây.

Mẫu số chung

Hai ý cốt lõi — tín hiệu dừng phải tới được đúng người biết dọn, và tốc độ dừng không phải thước đo thành công, việc-dọn-có-xong mới là — lặp lại ở mọi runtime, chỉ đổi tên cái công tắc.

Trong thế giới Java, đây đúng là Runtime.getRuntime().addShutdownHook() và, cấp cao hơn, server.shutdown=graceful của Spring Boot: nhận SIGTERM, ngừng nhận request mới, để request đang chạy xong trong một timeout-per-shutdown-phase rồi mới tắt. Và một JVM chạy qua ENTRYPOINT java -jar app.jar dạng shell dính đúng cái bẫy PID-1/shell ở trên — shutdown hook không bao giờ chạy vì tín hiệu chết ở /bin/sh.

Trong Go, khuôn chuẩn là signal.Notify(ch, syscall.SIGTERM) rồi gọi http.Server.Shutdown(ctx) để rút cạn (drain) kết nối đang mở trước khi thoát — chính là "xử lý xong yêu cầu đang dở, có thời hạn". Ai quên bắt SIGTERM thì binary Go làm PID 1 cũng bị SIGKILL sau ân hạn, mất connection đang phục vụ.

Còn ở cơ sở dữ liệu, PostgreSQL là ví dụ đẹp nhất vì nó có ba chế độ tắt đúng theo trục này: smart (chờ mọi client tự ngắt — êm nhất, chậm nhất), fast (đóng kết nối, rollback giao dịch dở, checkpoint rồi tắt — cân bằng), và immediate (giết ngay, không checkpoint, lần khởi động sau phải phục hồi crash — nhanh nhất, bẩn nhất). immediate chính là dòng 113 ms: nhanh vì bỏ qua việc dọn, và trả giá ở lần sau. Chọn chế độ tắt DB là chọn đúng đánh đổi "nhanh vs êm" ở đây.

Mẫu số chung: dừng êm là một chuỗi việc dọn (rút cạn kết nối, xả bộ đệm, ghi checkpoint) chỉ chạy khi tín hiệu dừng tới được đúng nơi có trình xử lý — nên thước đo thành công không phải 'dừng nhanh cỡ nào' mà là 'việc dọn có xong và thoát mã 0 không', còn dừng nhanh bất thường thường là dấu hiệu đã bỏ qua việc dọn. Vì thế câu hỏi đúng khi một dịch vụ "tắt gọn" không phải "nó dừng nhanh không" mà là "tín hiệu có tới được trình xử lý của tôi không, và có bằng chứng bước dọn đã chạy xong không" — vì cái tắt nhanh nhất thường là cái chưa kịp dọn gì.

Muốn tự kiểm dịch vụ của mình có dừng êm không, trong ba mươi giây:

c=<ten-container>
t0=$(date +%s%N)
docker stop -t 30 $c
t1=$(date +%s%N)
echo "dung sau $(( (t1-t0)/1000000 )) ms"
echo "ma thoat: $(docker inspect $c --format '{{.State.ExitCode}}')"
docker logs --tail 20 $c 2>&1 | tail -5

echo "--- PID 1 la gi ---"
docker inspect $c --format 'Entrypoint: {{.Config.Entrypoint}}  Cmd: {{.Config.Cmd}}'

Cần cả ba dấu hiệu: dừng trước thời hạn ân hạn, mã thoát 0, và nhật ký có dòng chứng minh bước dọn dẹp đã chạy. Thiếu dòng thứ ba thì hai dòng đầu không có nghĩa gì — đó chính là dòng 113 mili giây trong bảng.

Phần sau: bẫy khi đo — những sai lầm tôi đã mắc trong bốn mươi bốn phần trước.