Probe: liveness, readiness và startup
Bài 18 dựng ra một checklist condition của pod và còn nợ lại một mục: Ready. Bài 19 đã trả Initialized; bài này trả nốt Ready, và thứ đứng sau nó là probe. Probe là cách kubelet định kỳ "khám" container để trả lời ba câu hỏi khác nhau, mỗi câu ứng với một loại probe:
- liveness: container còn sống không? Treo thì giết đi rồi restart.
- readiness: container đã sẵn sàng nhận lưu lượng chưa? Chưa thì gỡ khỏi Service.
- startup: app bên trong container đã khởi động xong chưa? Chưa xong thì khoan động vào hai probe kia.
Ba câu này hay bị gộp làm một ("nó chạy chưa?") nhưng hậu quả của mỗi câu hoàn toàn khác nhau. Cả bài là đi tách bạch chúng, và kiểm chứng từng cái trên cluster thật.
Bốn cách "khám" và ba kết cục
Trước khi vào từng loại, cần biết kubelet khám bằng cách nào. Tài liệu liệt kê bốn cơ chế (check mechanism), tức phần exec/httpGet/tcpSocket/grpc trong khai báo probe:
exec: chạy một lệnh bên trong container; thoát mã 0 là thành công, khác 0 là thất bại.httpGet: gửi HTTP GET tới container; mã trạng thái từ 200 đến dưới 400 là thành công.tcpSocket: thử mở socket TCP tới một cổng; mở được là thành công.grpc: gọi health check theo chuẩn gRPC.
Mỗi lần khám cho ra một trong ba kết cục: Success, Failure, hoặc Unknown (kubelet không khám được, ví dụ container chưa kịp lên). Ba loại probe ở trên chia sẻ cùng bốn cơ chế này; chúng chỉ khác nhau ở chỗ kubelet làm gì khi nhận kết cục Failure. Đó mới là phần đáng học.
Cả ba loại cũng dùng chung một bộ trường điều chỉnh nhịp khám (giá trị mặc định trích từ tài liệu):
| Trường | Mặc định | Ý nghĩa |
|---|---|---|
initialDelaySeconds |
0 | Chờ bao lâu rồi mới khám lần đầu |
periodSeconds |
10 | Khoảng cách giữa các lần khám |
timeoutSeconds |
1 | Chờ trả lời tối đa bao lâu trước khi coi là thất bại |
successThreshold |
1 | Cần mấy lần thành công liên tiếp để coi là khỏe lại |
failureThreshold |
3 | Cần mấy lần thất bại liên tiếp trước khi ra tay |
Trong các test dưới đây tôi sẽ chỉnh các trường này cho nhanh và dễ quan sát, nhưng mặc định ở trên là thứ áp dụng nếu bạn không khai gì.
Liveness: còn sống không, treo thì giết
Định nghĩa trong tài liệu gọn một câu: "livenessProbe: Indicates whether the container is running. If the liveness probe fails, the kubelet kills the container, and the container is subjected to its restart policy." Tức probe này không hỏi "tiến trình còn chạy không" (cái đó kubelet biết rồi) mà hỏi "tiến trình còn đáp ứng không", bắt đúng cái bệnh app kẹt cứng nhưng tiến trình vẫn chưa chết. Tài liệu nói rõ khi nào nó hữu ích: "useful for catching bugs where an application gets stuck in an unresponsive state and cannot recover except by being restarted", và cũng nói thẳng khi nào không cần: "a liveness probe is not needed if the container will simply exit if the process crashes", vì khi đó restartPolicy (Bài 18) đã lo việc restart rồi.
Thử một liveness probe kiểu exec. Container dưới đây tạo file /tmp/healthy, sống khỏe 30 giây rồi tự xóa file đi, mô phỏng một app đang chạy thì lăn ra treo:
apiVersion: v1
kind: Pod
metadata:
name: liveness-exec
labels: {test: liveness}
spec:
containers:
- name: liveness
image: busybox:1.36
args: ["/bin/sh","-c","touch /tmp/healthy; sleep 30; rm -f /tmp/healthy; sleep 600"]
livenessProbe:
exec:
command: ["cat","/tmp/healthy"] # cat ra 0 = sống; file mất => khác 0 = chết
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
Trong 30 giây đầu, cat /tmp/healthy thoát mã 0 → probe Success, pod chạy bình thường. Sau giây thứ 30, file biến mất, probe bắt đầu thất bại. Với periodSeconds: 5 và failureThreshold: 3, kubelet cần ba lần thất bại liên tiếp (≈15 giây) rồi mới ra tay. Bắt pod sau khoảng phút rưỡi:
kubectl get pod liveness-exec
kubectl get pod liveness-exec -o jsonpath='restartCount={.status.containerStatuses[0].restartCount}{"\n"}lastState={.status.containerStatuses[0].lastState.terminated.reason} exitCode={.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
NAME READY STATUS RESTARTS AGE
liveness-exec 1/1 Running 1 (29s ago) 105s
restartCount=1
lastState=Error exitCode=137
Container đã bị restart một lần (RESTARTS 1), và lastState ghi lại lần chết trước: Error, exitCode 137. Số 137 là 128 + 9, tức SIGKILL. Sở dĩ là KILL chứ không phải kết thúc êm vì PID 1 trong container (cái sh của busybox) không cài bộ xử lý SIGTERM nên phớt lờ tín hiệu xin-tắt-êm; kubelet chờ hết grace period rồi mới SIGKILL. Một app thật nên bắt SIGTERM để tắt gọn, nhưng ở đây cái 137 lại tiện cho ta thấy rõ kubelet cưỡng chế giết container hỏng.
Sự thật nằm ở phần events:
kubectl events --for pod/liveness-exec
LAST SEEN TYPE REASON MESSAGE
60s (x3 over 70s) Warning Unhealthy Liveness probe failed: cat: can't open '/tmp/healthy': No such file or directory
60s Normal Killing Container liveness failed liveness probe, will be restarted
30s (x2 over 105s) Normal Pulled Container image "busybox:1.36" already present on machine ...
30s (x2 over 105s) Normal Created Container created
30s (x2 over 105s) Normal Started Container started
Đọc từ trên xuống là cả câu chuyện: Liveness probe failed ... (x3), đúng ba lần như failureThreshold đặt; rồi Killing ... will be restarted; rồi Created/Started lần thứ hai (x2). Đó là vòng đời restart của Bài 18, nhưng lần này thủ phạm bấm nút restart là liveness probe, không phải tiến trình tự chết. Đó là khác biệt cốt lõi: liveness biến "treo nhưng chưa chết" thành "chết hẳn rồi restart".
Readiness: sẵn sàng nhận lưu lượng chưa
Liveness hỏi còn sống không. Readiness hỏi câu khác hẳn: đã sẵn sàng nhận request chưa. Một container có thể đang sống rất khỏe nhưng chưa nạp xong cache, chưa kết nối được database, đang khởi động lại pool: sống, nhưng chưa nên nhận lưu lượng. Tài liệu: "readinessProbe: Indicates whether the container is ready to receive traffic. If the readiness probe fails, the endpoints controller removes the Pod's IP address from the endpoints of all Services that match the Pod."
Để ý hậu quả khác hẳn liveness: readiness thất bại không giết container. Nó chỉ gỡ IP pod khỏi endpoint của Service; pod vẫn chạy, chỉ là không ai gửi request tới nữa qua Service. Đây là cơ chế đứng sau condition Ready của Bài 18, và là cánh cổng vào EndpointSlice mà Bài 16 đã thấy. Ghép luôn với một Service để thấy đường đi trọn vẹn:
apiVersion: v1
kind: Pod
metadata:
name: ready-demo
labels: {app: ready-demo}
spec:
containers:
- name: app
image: registry.k8s.io/e2e-test-images/agnhost:2.52
args: ["netexec","--http-port=8080"]
readinessProbe:
exec:
command: ["cat","/tmp/ready"] # /tmp/ready chưa tồn tại => chưa ready
initialDelaySeconds: 2
periodSeconds: 3
failureThreshold: 1
---
apiVersion: v1
kind: Service
metadata:
name: ready-demo
spec:
selector: {app: ready-demo}
ports:
- {port: 80, targetPort: 8080}
File /tmp/ready cố tình chưa có, nên readiness thất bại ngay. Pod sẽ Running (container vẫn sống) nhưng 0/1 (chưa ready):
kubectl get pod ready-demo
kubectl get pod ready-demo -o jsonpath='Ready={..conditions[?(@.type=="Ready")].status} ContainersReady={..conditions[?(@.type=="ContainersReady")].status}{"\n"}'
kubectl get endpointslices -l kubernetes.io/service-name=ready-demo \
-o jsonpath='{range .items[*]}endpoints={.endpoints[*].addresses} ready={.endpoints[*].conditions.ready}{"\n"}{end}'
kubectl get endpoints ready-demo
NAME READY STATUS RESTARTS AGE
ready-demo 0/1 Running 0 22s
Ready=False ContainersReady=False
endpoints=["10.200.1.13"] ready=false
NAME ENDPOINTS AGE
ready-demo 23s
Ba quan sát khớp nhau: pod 0/1 Running; condition Ready=False và ContainersReady=False; và quan trọng nhất, EndpointSlice có ghi địa chỉ pod (10.200.1.13) nhưng gắn cờ ready=false, còn đối tượng Endpoints kiểu cũ thì cột ENDPOINTS rỗng. Nói cách khác, kube-proxy sẽ không gửi lưu lượng Service tới pod này. Đây là điều readiness probe canh giữ: một pod chưa sẵn sàng thì không nằm trong danh sách nhận request, dù nó đang chạy.
Giờ làm cho nó "ready" bằng cách tạo file mà probe đang tìm, rồi xem endpoint lật:
kubectl exec ready-demo -- touch /tmp/ready
sleep 6
kubectl get pod ready-demo
kubectl get endpointslices -l kubernetes.io/service-name=ready-demo \
-o jsonpath='{range .items[*]}endpoints={.endpoints[*].addresses} ready={.endpoints[*].conditions.ready}{"\n"}{end}'
kubectl get endpoints ready-demo
NAME READY STATUS RESTARTS AGE
ready-demo 1/1 Running 0 43s
endpoints=["10.200.1.13"] ready=true
NAME ENDPOINTS AGE
ready-demo 10.200.1.13:8080 44s
Probe Success → condition Ready lật True → pod thành 1/1 → EndpointSlice đổi ready=true → đối tượng Endpoints giờ liệt kê 10.200.1.13:8080. Từ giây này kube-proxy mới định tuyến lưu lượng Service vào pod. Cùng một địa chỉ pod, cùng một container đang chạy, chỉ riêng kết cục readiness quyết định nó có trong tầm lưu lượng hay không.
So sánh thẳng hai probe cho khắc sâu: cùng một kết cục Failure, liveness giết container, còn readiness chỉ gỡ khỏi endpoint. Dùng nhầm thì hậu quả nặng: đặt một check "kết nối được database không" vào liveness sẽ khiến app bị restart hàng loạt khi database chớp tắt (mà restart chẳng giúp gì); đặt vào readiness thì app chỉ tạm rời endpoint rồi tự quay lại khi database hồi phục. Đó là lý do phải tách bạch.
Startup: gác cổng cho app chậm khởi động
Còn một tình huống nữa: app khởi động chậm, như JVM nạp, cache ấm lên, migration chạy mất cả phút. Nếu cắm liveness vào ngay, probe sẽ thất bại trong lúc app còn đang dậy và kubelet giết oan nó trước khi nó kịp lên. Ta có thể nới initialDelaySeconds của liveness thật to, nhưng thế thì khi app đã chạy ổn định, liveness lại phản ứng chậm với sự cố thật. Startup probe sinh ra để gỡ đúng mâu thuẫn này.
Định nghĩa trong tài liệu cực ngắn mà gánh nhiều ý: "startupProbe: Indicates whether the application within the container has started. All other probes are disabled if a startup probe is provided, until it succeeds." Tức là chừng nào startup probe chưa thành công, liveness và readiness bị tắt hoàn toàn — app được yên thân khởi động. Một khi startup probe thành công lần đầu, nó nghỉ vĩnh viễn và nhường cho liveness/readiness tiếp quản.
Để chứng minh "hai probe kia bị tắt", tôi cài một cái bẫy: container mất 25 giây mới tạo /tmp/started, gắn startup probe canh đúng file đó, và gắn một liveness probe cũng canh file đó nhưng cấu hình cực gắt là periodSeconds: 2, failureThreshold: 1. Nếu liveness mà chạy trong lúc app đang khởi động, nó sẽ giết container chỉ sau 2 giây (file chưa có). Container sống sót qua 25 giây hay không sẽ nói lên liveness có bị tắt thật không:
apiVersion: v1
kind: Pod
metadata:
name: startup-demo
spec:
containers:
- name: app
image: busybox:1.36
args: ["/bin/sh","-c","sleep 25; touch /tmp/started; sleep 600"]
startupProbe:
exec:
command: ["cat","/tmp/started"]
periodSeconds: 3
failureThreshold: 20 # cho phép tối đa 20 x 3 = 60s để khởi động
livenessProbe:
exec:
command: ["cat","/tmp/started"]
periodSeconds: 2
failureThreshold: 1 # gắt: 1 lần fail là giết — NHƯNG bị startup tắt
Trường failureThreshold: 20 × periodSeconds: 3 cho biết startup probe chấp nhận app dậy chậm tới 60 giây mới bỏ cuộc (công thức failureThreshold × periodSeconds = thời gian khởi động tối đa). App của ta dậy ở giây 25 nên nằm trong hạn. Quan sát:
kubectl get pod startup-demo
kubectl get pod startup-demo -o jsonpath='restartCount={.status.containerStatuses[0].restartCount} started={.status.containerStatuses[0].started}{"\n"}'
kubectl events --for pod/startup-demo
NAME READY STATUS RESTARTS AGE
startup-demo 1/1 Running 0 118s
restartCount=0 started=true
LAST SEEN TYPE REASON MESSAGE
119s Normal Started Container started
118s Normal Scheduled Successfully assigned default/startup-demo to worker-0
95s (x8 over 116s) Warning Unhealthy Startup probe failed: cat: can't open '/tmp/started': No such file or directory
Bằng chứng nằm ở đây. restartCount=0: container không bị restart lần nào, dù liveness gắt tới mức 1-lần-fail-là-giết. Events chỉ ghi Startup probe failed (x8) trong suốt ~25 giây đầu, tuyệt nhiên không có dòng Liveness probe failed hay Killing nào. Liveness đã bị startup probe tắt đúng như tài liệu nói. Tới khi /tmp/started xuất hiện ở giây 25, startup probe Success, started=true, và từ đó liveness mới tiếp quản (giờ nó cũng pass vì file đã có). Nếu không có startup probe, cái liveness failureThreshold: 1 kia đã giết container ở giây thứ 2 và pod sẽ kẹt trong vòng CrashLoopBackOff vô tận, không bao giờ kịp khởi động.
Một lưu ý vận hành từ tài liệu: đừng để nhiều container trong cùng pod dùng chung một endpoint cho startup probe — "this can cause the startup sequence to fail if the endpoint does not return success".
Ba probe cạnh nhau
thời gian ───────────────────────────────────────────────►
|== container khởi động ==|========= chạy ổn định =========>
STARTUP [khám... khám... ✓] (xong thì nghỉ hẳn)
└ chặn liveness/readiness suốt giai đoạn này
LIVENESS (bị tắt) [khám đều... ✗✗✗ → KILL + restart]
còn sống không? treo thì giết
READINESS (bị tắt) [khám đều... ✗ → gỡ khỏi Service]
sẵn sàng chưa? chưa thì rời endpoint
(container VẪN sống)
Ba câu hỏi, ba hậu quả: startup gác cổng cho tới khi app lên; liveness giết và restart container treo; readiness gỡ khỏi endpoint container chưa sẵn sàng mà không đụng tới nó. Cùng dùng chung exec/httpGet/tcpSocket/grpc và cùng bộ trường nhịp khám, khác nhau chỉ ở việc kubelet làm gì khi probe Failure.
🧹 Dọn dẹp
kubectl delete pod liveness-exec ready-demo startup-demo --now
kubectl delete svc ready-demo
Tất cả là object trong cluster, xóa là sạch, cụm trở lại chỉ còn hai pod CoreDNS thường trú. Manifest ở github.com/nghiadaulau/kubernetes-from-scratch, thư mục 20-probes.
Tổng kết
Probe là cách kubelet định kỳ khám container, và mấu chốt là ba loại probe trả lời ba câu khác nhau với ba hậu quả khác nhau khi thất bại. Liveness hỏi còn sống không; thất bại thì kubelet giết container và để restartPolicy restart (ta đã thấy exitCode 137 và events Killing). Readiness hỏi sẵn sàng nhận lưu lượng chưa; thất bại thì endpoints controller gỡ IP pod khỏi Service mà không giết container (ta đã thấy condition Ready lật và EndpointSlice đổi ready true/false, đúng cơ chế đứng sau condition Ready của Bài 18 và EndpointSlice của Bài 16). Startup hỏi app khởi động xong chưa; nó vô hiệu hóa hai probe kia cho tới khi thành công, để app chậm có thời gian dậy mà không bị giết oan (ta đã thấy restartCount=0 xuyên qua cửa sổ khởi động dù liveness cấu hình cực gắt). Bốn cơ chế khám và bộ trường periodSeconds/failureThreshold/... là chung; chọn đúng loại probe cho đúng ý định mới là phần khó.
Tới đây checklist condition của Bài 18 đã có lời giải đầy đủ: Initialized (Bài 19) và Ready (bài này). Bài 21 đi tiếp vào phần gỡ rối pod khi không có sẵn shell: ephemeral container và kubectl debug, cách nhét một container công cụ vào pod đang chạy mà không phải restart hay sửa image của nó.