Có những lỗi mà nguyên nhân không nằm ở dòng nào bạn nhìn thấy, mà ở một quy tắc ngầm bạn không biết là nó tồn tại. Bài này là một ca như vậy: hai cấu hình khác nhau đúng chỗ đặt một lệnh echo — trước hay sau — cho ra thời gian xoá pod chênh nhau năm mươi lần. Và tôi bước vào nó với một niềm tin phổ biến hoá ra là sai. Phần 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ó.

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".

Mẫu số chung: hai cấu hình giống hệt vẫn khác hành vi, và lời đồn chỉ là bản nén của cơ chế

Bài này gói hai bài học vượt khỏi Kubernetes. Thứ nhất: hai thứ trông giống hệt vẫn có thể hành xử khác nhau năm mươi lần, vì lý do nằm ở một quy tắc không hiện ra trong cấu hình — bạn phải biết cơ chế mới giải thích được. Chỗ đặt một lệnh echo lật kết quả, không phải vì echo làm gì, mà vì nó bật/tắt một tối ưu hoá exec của shell, cộng với luật riêng của PID 1. Trong thế giới Java, đây đúng là lý do mọi script khởi động tử tế đều viết exec "$JAVA" -jar app.jar chứ không phải "$JAVA" -jar app.jar: thiếu chữ exec, bash giữ PID 1, SIGTERM của Kubernetes trúng bash chứ không trúng JVM, và graceful shutdown của Spring Boot không bao giờ chạy — container chết cứng đúng 30 giây rồi bị SIGKILL, y hệt bảng trên. Cùng một cơ chế, khác ngôn ngữ.

Thứ hai: những "lời đồn" kỹ thuật ("dùng sh -c là mất tín hiệu") là bản nén mất mát của một quy tắc chính xác hơn — tiện để nhớ, nhưng sai ở đúng những ca ranh giới. Quy tắc thật ở đây tinh hơn nhiều: mất tín hiệu hay không tuỳ shell có exec được lệnh cuối hay không. Khi một câu thần chú kiểu này dẫn bạn đi lạc, cách thoát không phải là tin nó chặt hơn, mà là thay nó bằng cơ chế bên dưới. Cùng họ với "đừng dùng SELECT *" (luật thật về cột và index), với "goroutine rẻ nên cứ đẻ thoải mái" (luật thật về scheduler và bộ nhớ): mỗi câu là một cái nạng, hữu ích cho tới khi nó không còn.

Và một bài học phụ mà phép đo endpoint nhắc lại: khi rút một thành phần đang phục vụ, việc gỡ-khỏi-định-tuyến chạy song song với việc tắt, nên đóng cửa càng nhanh càng mất nhiều khách — cái cứu được là một khoảng chờ (preStop) để mọi bộ định tuyến kịp biết, không phải tắt cho gọn.

Mẫu số chung: khi hai thứ nhìn như nhau mà chạy khác nhau, đừng đổ cho "hên xui" — đi tìm quy tắc ngầm đang phân biệt chúng; và khi một câu quy tắc-ngón-tay-cái làm bạn ngã, hãy thay nó bằng cơ chế mà nó đang xấp xỉ. Trong ví dụ này, cơ chế đó gói gọn trong một lệnh: đọc /proc/1/cmdline để biết ai đang thực sự là PID 1.

Muốn tự soi, 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.