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: 3

Trang 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:

ProbeCâu hỏiKhi 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)
readinessProbePod có nên nhận traffic lúc này không?Pod bị rút khỏi Endpoints, không restart
livenessProbeTiế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: 30

Thử 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 -w

Kế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-a

Thử 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 -w

Khi 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 timeoutSeconds riê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: 25

Khoá 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::put trả false.
  • Dùng startupProbe khi 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 postStart làm điều kiện sẵn sàng: nó không bảo đảm xong trước ENTRYPOINT.
  • Đặt timeoutSeconds và periodSeconds theo 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.