Một website Laravel chạy trên Kubernetes bắt đầu trả 503 liên tục. Pod vẫn Running, không restart, nhưng sự kiện cứ lặp lại Readiness probe failed ... context deadline exceeded. Một cách giải thích hợp lý là probe gọi thẳng trang chủ, một trang nặng, với timeout chỉ một giây; chưa có theo dõi nào xác nhận chắc chắn. Bài này nói về health endpoint: nên đặt route nhẹ nào cho từng probe, endpoint nên kiểm tra những gì, cách chứng minh nó hoạt động bằng những lần phá hỏng có kiểm soát, và khi nào nên bỏ một kiểm tra thay vì giữ.
Bài giả định bạn đã biết Deployment và Service, ví dụ từ Manifest Kubernetes đầu tiên: namespace, Deployment và MySQL StatefulSet. Nếu 503 do timeout ở tầng ingress, hãy đọc thêm Lỗi 413 và 504 trên Kubernetes: giới hạn body, timeout và header ở nhiều lớp.
Triệu chứng: probe gọi trang chủ nặng với timeout ngắn
Cấu hình thường gặp lúc mới triển khai là probe trỏ vào /:
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 3
timeoutSeconds: 1
failureThreshold: 3Trang chủ của một CMS thường truy vấn database, dựng view, gọi cache. Khi chỉ một lần phản hồi vượt một giây, kubelet ghi một lần thất bại. Đủ ba lần liên tiếp, pod bị rút khỏi Endpoints của Service, và nếu mọi pod cùng lúc chậm thì ingress trả 503. Xem chi tiết bằng kubectl describe pod, mục Events:
Warning Unhealthy Readiness probe failed: Get "http://10.0.0.x:80/": context deadline exceeded
(Client.Timeout exceeded while awaiting headers)Hai cách sửa nhanh là nới timeoutSeconds và đổi sang một health endpoint nhẹ. Vì sao trang chủ chậm quá một giây thì cần điều tra riêng (NFS, truy vấn, CPU limit thấp). Nới timeout chỉ che triệu chứng.
Mỗi probe trả lời đúng một câu hỏi
Ba loại probe có ba nhiệm vụ khác nhau, và hậu quả của việc thất bại cũng khác nhau:
| Probe | Câu hỏi | Khi thất bại |
|---|---|---|
startupProbe | Ứng dụng đã khởi động xong chưa? | Kubelet kill container và restart theo restartPolicy (không tạo pod mới) |
readinessProbe | Pod có nên nhận traffic lúc này không? | Pod bị rút khỏi Endpoints, không restart |
livenessProbe | Tiến trình còn sống hay đã treo? | Kubelet restart container |
Khi có startupProbe, liveness và readiness chưa chạy cho tới khi startup thành công. Nó cho ứng dụng khởi động chậm một "ngân sách" thời gian mà không phải nới liveness. Chi tiết nằm trong tài liệu probe của Kubernetes. Từ đó rút ra một nguyên tắc: liveness chỉ hỏi "PHP-FPM còn trả lời không", không hỏi database, còn readiness mới được hỏi các phụ thuộc cần thiết để phục vụ request.
Với khởi động chậm, hãy dùng startupProbe thay vì nhờ hook postStart. Hook này chạy ngay sau khi container được tạo và không bảo đảm xong trước ENTRYPOINT, nên đừng coi nó là điều kiện sẵn sàng. Xem tài liệu container lifecycle hooks.
Thiết kế health endpoint: route nhẹ và các kiểm tra tuỳ chọn
Một health endpoint tốt gồm hai route tách riêng: health endpoint cho liveness chỉ trả 200, health endpoint cho readiness kiểm tra thêm phụ thuộc mà pod không thể thiếu. Một dạng viết đúng cú pháp:
Route::get('/liveness', fn () => response()->json(['status' => 'ok']))
->withoutMiddleware([\Illuminate\Session\Middleware\StartSession::class]);
Route::get('/health', function () {
$checks = ['database' => false];
try {
DB::connection()->getPdo();
$checks['database'] = true;
} catch (\Throwable $e) {
}
// Tuỳ chọn, chỉ với thư mục cục bộ của pod (xem mục sau):
// $file = 'health_' . bin2hex(random_bytes(4)) . '.txt';
// $checks['storage'] = Storage::disk('local')->put($file, 'ok');
// Storage::disk('local')->delete($file);
$healthy = !in_array(false, $checks, true);
return response()->json($checks, $healthy ? 200 : 503);
})->withoutMiddleware([\Illuminate\Session\Middleware\StartSession::class]);Route trong routes/web.php mặc định đi qua nhóm middleware web (có StartSession), nên mẫu trên loại middleware này ra; bạn cũng có thể đặt route trong routes/api.php hoặc dùng route /up tích hợp sẵn của Laravel 11. Khi kiểm tra ghi file, nhớ rằng Storage::put trả false thay vì ném lỗi (trừ khi disk đặt 'throw' => true), và tên file thử nên có hậu tố ngẫu nhiên.
Probe HTTP coi mã từ 200 đến 399 là thành công, nên cả 500 lẫn 503 đều làm readiness thất bại. Cấu hình probe mẫu trỏ tới hai health endpoint này:
livenessProbe:
httpGet: { path: /liveness, port: 80 }
periodSeconds: 10
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /health, port: 80 }
periodSeconds: 10 # 10 đến 15 giây là hợp lý
timeoutSeconds: 5 # route có chạm database
startupProbe: # tuỳ chọn, cho khởi động chậm
httpGet: { path: /liveness, port: 80 }
periodSeconds: 5
failureThreshold: 30Thử phá hỏng 1: đổi quyền storage và quan sát readiness
Cách tốt nhất để tin một health endpoint là phá hỏng thứ nó kiểm tra. Chỉ làm ở môi trường thử: nếu storage nằm trên volume dùng chung (như NFS), mọi pod bị ảnh hưởng và xoá pod không trả lại owner cũ. Đổi owner thư mục storage sang root (cần phiên exec chạy root và volume không bị root squash):
kubectl exec deploy/site-a -- chown -R root:root /var/www/html/storage
kubectl describe pod -l app=site-a | grep -A3 Unhealthy
kubectl get pods -l app=site-a -wKết quả thường thấy: Events ghi Readiness probe failed: HTTP probe failed with statuscode: 500, READY về 0/1, RESTARTS giữ nguyên. Đúng như bảng trên, readiness fail thì pod bị rút khỏi Service nhưng không restart.
Chú ý mã 500, không phải 503 như health endpoint tự trả. Health endpoint đã bắt exception và trả 503, nên 500 gợi ý lỗi xảy ra ngoài route: nhiều khả năng framework không ghi được session, cache view hoặc log vào storage. Đây là suy luận, hãy kiểm chứng:
kubectl logs deploy/site-a --tail=20
kubectl exec deploy/site-a -- wget -qO- -S http://localhost/healthĐừng kỳ vọng thấy lỗi trong storage/logs/laravel.log: chính lỗi ghi file khiến log này thường trống. Nếu image không có wget hay curl, dùng php -r. Sau thử nghiệm, khôi phục owner cho user chạy PHP-FPM (ví dụ www-data) và kiểm tra READY trở lại 1/1:
kubectl exec deploy/site-a -- chown -R www-data:www-data /var/www/html/storage
kubectl get pods -l app=site-aThử phá hỏng 2: kill php-fpm và quan sát liveness
Với liveness, giả lập tiến trình chết:
kubectl exec deploy/site-a -- pkill php-fpm # nếu thiếu pkill, dùng kill <pid>
kubectl get pods -l app=site-a -wKhi thử như vậy, trang thường trả 502 (nginx mất upstream) rồi RESTARTS tăng lên 1. Nếu không tăng, kiểm tra xem supervisord trong container có bật lại php-fpm trước khi probe kịp thất bại không. Thời gian restart xấp xỉ failureThreshold × periodSeconds cộng timeout; con số thật cần tự đo bằng kubectl get events --sort-by=.lastTimestamp.
Chọn phụ thuộc nào đưa vào health endpoint
Nếu health endpoint của readiness kiểm tra một phụ thuộc dùng chung và phụ thuộc đó lỗi, mọi pod thất bại cùng lúc, bị rút khỏi Service và cả website chết, kể cả những trang không cần nó. Nguyên tắc: readiness chỉ kiểm tra thứ pod không thể phục vụ nếu thiếu, và cân nhắc kỹ khi thứ đó dùng chung cho mọi pod.
- Storage ngoài (S3) và volume dùng chung (NFS): bỏ khỏi health endpoint của readiness. Để ứng dụng tự xử lý lỗi thay vì rút pod.
- Thư mục cục bộ của pod (
emptyDir): kiểm tra ghi file hợp lý vì lỗi chỉ ảnh hưởng đúng pod đó. - Database: giữ trong readiness là chấp nhận được, nhưng database chết thì mọi pod cùng rút. Giữ kiểm tra nhẹ (mở kết nối) với
timeoutSecondsriêng.
Việc health endpoint cuối cùng có kiểm tra storage hay database còn tuỳ kiến trúc, và bài này chưa chốt kết luận chung.
Ca replica MySQL CrashLoop: phương pháp chẩn đoán
Cùng tư duy áp dụng được cho database trong cluster, ví dụ replica trong StatefulSet như bài MySQL replication trong StatefulSet. Một pod replica rơi vào CrashLoopBackOff, Events cho thấy:
Startup probe failed: mysqladmin: connect to server at 'localhost' failed
Container mysql failed liveness probe, will be restarted
Liveness probe failed: ... error: 'Access denied for user 'root'@'localhost''Probe dùng mysqladmin với timeoutSeconds: 1. Cách xử lý được thử là nới ngân sách của startup probe (trì hoãn 45 giây, cho phép 25 lần thất bại cách nhau 10 giây, khoảng năm phút). Khối dưới là values của chart Helm cho MySQL (kiểu Bitnami), đặt dưới khoá secondary:, rồi chạy helm upgrade --reuse-values -f values.yaml:
secondary:
startupProbe:
enabled: true
initialDelaySeconds: 45
periodSeconds: 10
failureThreshold: 25Khoá enabled chỉ có trong values của chart. Trong manifest thuần, bỏ nó và khai báo startupProbe.exec hoặc httpGet cùng initialDelaySeconds, periodSeconds, failureThreshold. Sau khi nới, pod ổn định, nhưng nguyên nhân gốc chưa được xác nhận: volume được gắn khá nhanh nên không thể kết luận "PVC chậm", và dòng Access denied chưa được giải thích. Một giả thuyết là PVC cũ còn dữ liệu với mật khẩu root khác (image MySQL thường chỉ khởi tạo mật khẩu khi thư mục dữ liệu trống); cách tự kiểm là xoá PVC của replica (nếu an toàn) rồi cài lại. Startup probe lúc này chỉ là băng dán.
Checklist health endpoint và probe
- Tách health endpoint liveness (
/liveness, chỉ trả 200) khỏi readiness (/health, có kiểm tra phụ thuộc). - Health endpoint không dùng session, không đi qua middleware nặng; probe không gọi trang chủ. Đặt health endpoint ngoài nhóm
web. - Readiness chỉ kiểm tra thứ pod không thể thiếu. Dịch vụ ngoài và volume dùng chung (S3, NFS) thì không đưa vào; database thì cân nhắc đánh đổi.
- Nếu kiểm tra ghi file ở thư mục cục bộ, dùng tên ngẫu nhiên và xử lý cả trường hợp
Storage::puttrảfalse. - Dùng
startupProbekhi khởi động chậm, thay vì nới liveness. - Thử phá hỏng health endpoint thật (đổi quyền, kill tiến trình), có bước khôi phục, và đo bằng timestamp của Events.
- Đừng dùng
postStartlàm điều kiện sẵn sàng: nó không bảo đảm xong trước ENTRYPOINT. - Đặt
timeoutSecondsvàperiodSecondstheo thời gian phản hồi thật, không để mặc định một giây.
Nếu lỗi quyền storage ở thử nghiệm thứ nhất xuất hiện ngoài đời, hãy đọc tiếp loạt bài về owner của volume. Bài Cài mysqld_exporter trên Kubernetes giúp bạn theo dõi số liệu MySQL (kết nối, replication) để so với tình trạng probe.
Bài tiếp theo
Permission denied trên volume: vì sao chown trong Dockerfile không đủ. Vì sao thư mục mount luôn thuộc root và những cách đặt owner đúng cho pod.



