Bài trước đo được hạn ân hạn 30 giây cộng thẳng vào thời lượng Job vì sleep của busybox không bắt SIGTERM. Bài này đào vào chính chỗ đó: tín hiệu đi tới đâu, và ai chặn nó.

Bốn cách khai lệnh với thời gian xoá, và kết quả đo cuộc đua khi pod bị xoá

Cùng một chương trình, bốn cách khai

Chương trình là mười dòng Python bắt SIGTERM rồi thoát. Tôi gắn nó vào bốn pod, chỉ khác cách khai lệnh.

Lệnh trong pod PID 1 Xoá hết
["python3","/code/app.py"] python3 1,0 s
sh -c "echo bat dau; python3 /code/app.py" python3 0,6 s
sh -c "python3 /code/app.py; echo da thoat" sh 31,0 s
sh -c "python3 /code/app.py & wait" sh 30,4 s

Tôi vào bài này với niềm tin quen thuộc: "dùng sh -c là mất tín hiệu". Dòng thứ hai bác bỏ nó — có sh, có cả một lệnh echo chạy trước, mà vẫn dừng trong 0,6 giây.

Dòng thứ ba mới là chỗ hỏng. Khác biệt giữa dòng hai và dòng ba chỉ là chỗ đặt lệnh echo: đằng trước thì không sao, đằng sau thì mất 31 giây.

Vì sao vị trí lại quyết định

Shell có một tối ưu hoá: nếu lệnh cuối cùng của đoạn script không còn gì phải làm sau nó, shell tự thay chính nó bằng lệnh đó (exec) thay vì tạo tiến trình con. Shell biến mất, ứng dụng trở thành PID 1, và SIGTERM đi thẳng tới nó.

Có bất kỳ thứ gì phía sau thì tối ưu hoá đó không dùng được: shell phải ở lại để chạy tiếp. Nó giữ PID 1, ứng dụng thành tiến trình con.

Và PID 1 là chỗ đặc biệt của Linux: kernel bỏ qua mọi tín hiệu mà PID 1 không đăng ký xử lý. Shell không đăng ký SIGTERM, nên tín hiệu rơi vào hư không. Ứng dụng con thì không nhận được gì cả — tín hiệu chỉ gửi cho PID 1.

Kubernetes chờ hết hạn ân hạn rồi SIGKILL cả cgroup. Đó là 30 giây trong bảng trên.

Kiểm tra một dòng:

kubectl exec <pod> -- cat /proc/1/cmdline | tr '\0' ' '

Ra tên ứng dụng thì yên tâm. Ra sh -c ... thì mỗi lần dừng pod đều tốn nguyên hạn ân hạn — và mọi kết nối đang mở bị cắt đột ngột chứ không được đóng tử tế.

Ba cách chữa, theo thứ tự nên chọn: bỏ shell đi và dùng dạng danh sách; nếu buộc phải có shell thì viết exec python3 /code/app.py cho lệnh cuối; nếu vẫn cần shell chạy nhiều thứ thì đặt một init thật (tini) làm PID 1.

Cuộc đua thứ hai: endpoint

Ứng dụng bắt SIGTERM và đóng cổng ngay lập tức — nghe có vẻ đúng. Tôi đo xem có mất yêu cầu nào không: một pod client bắn liên tục vào Service trong 25 giây, giữa chừng cho rollout.

lần 1 lần 2 lần 3
Không preStop 3 lỗi 1 lỗi 3 lỗi
preStop: sleep 5 0 lỗi 0 lỗi 0 lỗi

Mỗi lần khoảng 450 yêu cầu. Lỗi đầu tiên luôn là Connection refused hoặc timed out, xuất hiện đúng lúc rollout bắt đầu.

Nguyên nhân là hai việc chạy song song chứ không nối tiếp:

  • kubelet gửi SIGTERM → ứng dụng đóng cổng ngay.
  • Endpoint controller gỡ pod khỏi Endpoints → kube-proxy trên từng node cập nhật iptables.

Việc thứ hai chậm hơn, và chậm không đều: mỗi node cập nhật vào một thời điểm khác nhau. Trong khe hở đó, iptables vẫn còn trỏ tới một pod đã đóng cổng.

Điều đáng nhớ: ứng dụng dừng càng nhanh thì càng mất nhiều yêu cầu. Nó ngược với trực giác "dừng gọn là dừng nhanh".

preStop không phải để dọn dẹp

lifecycle:
  preStop:
    exec: {command: ["sleep","5"]}

kubelet chạy preStop xong mới gửi SIGTERM. Nên năm giây sleep này không làm gì ngoài việc trì hoãn — vừa đủ để mọi kube-proxy cập nhật xong.

Trong lúc đó pod vẫn phục vụ bình thường, vì ứng dụng chưa nhận được tín hiệu nào. Đó chính là điều ta muốn.

Ba điều dễ nhầm:

  • preStop nằm trong hạn ân hạn. preStop 5 giây với hạn 30 nghĩa là ứng dụng chỉ còn 25 giây để dừng. Chỉnh preStop thì nhớ nâng hạn.
  • Đừng dùng preStop để xả log hay đóng kết nối — việc đó thuộc về bộ xử lý SIGTERM của ứng dụng. preStop chỉ mua thời gian cho hạ tầng.
  • 5 giây là con số đo trên cụm này. Cụm lớn hàng trăm node thì kube-proxy lâu hơn; hãy đo bằng chính phép thử trên rồi cộng biên.

Thứ tự đầy đủ

1. Pod chuyển sang Terminating, endpoint controller bắt đầu gỡ
2. preStop chạy                     <- pod vẫn phục vụ
3. SIGTERM tới PID 1                <- ứng dụng ngừng nhận mới, xử nốt cái đang có
4. hết hạn ân hạn -> SIGKILL cả cgroup

Bước 1 và 2 chạy song song với nhau; bước 3 chỉ bắt đầu sau khi bước 2 xong. Hiểu đúng chỗ song song này giải thích được gần hết mọi lỗi "mất vài yêu cầu mỗi lần deploy".

Thử ba mươi giây

Tìm mọi container mà PID 1 là shell:

for p in $(kubectl get pods -A -o jsonpath='{range .items[?(@.status.phase=="Running")]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'); do
  ns=${p%%/*}; n=${p##*/}
  c=$(kubectl exec -n "$ns" "$n" -- cat /proc/1/cmdline 2>/dev/null | tr '\0' ' ')
  case "$c" in
    sh*|/bin/sh*|bash*|/bin/bash*) echo "$p -> $c" ;;
  esac
done

Mỗi dòng in ra là một pod mất trọn hạn ân hạn mỗi lần dừng. Sửa cái nào đang phục vụ lưu lượng trước — đó là chỗ vừa chậm vừa cắt kết nối của người dùng.

Phần sau: giới hạn số tiến trình và số tệp mở — hai cái trần mà container chạm phải trước khi chạm trần RAM.