2 giờ 14 phút sáng, database primary failover. Mất khoảng 40 giây. Hơi phiền, nhưng app đã có retry. Có điều quý trước ai đó thêm một câu query database vô liveness probe, "cho health check kỹ hơn". Kubernetes thấy mọi pod fail liveness cùng một lúc và restart hết. Giờ database đã lên lại, nhưng pod nào cũng đang khởi động lạnh, chạy cache config và mở connection cùng lúc, và cú chập 40 giây biến thành 9 phút sập, kèm thêm một đợt thundering herd.
Health check nhìn như chuyện một dòng YAML, nhưng mỗi loại probe trả lời một câu hỏi khác nhau và kích hoạt một hành động khác nhau. Lẫn lộn chúng là cái nền tảng đáng lẽ giúp app tự hồi phục lại khuếch đại mọi sự cố.
Ba probe, ba câu hỏi
Kubernetes có ba loại probe. Docker có một HEALTHCHECK. Quan trọng là mỗi cái hỏi gì và chuyện gì xảy ra khi câu trả lời là "không":
- Liveness: "Process này có hư tới mức không tự sửa được không?" Fail thì kubelet kill container và restart. Chỉ có deadlock, event loop bị kẹt hoặc state trong bộ nhớ bị hỏng mới đáng để restart. Restart không sửa được gì nằm ngoài container.
- Readiness: "Pod này có nên nhận traffic ngay lúc này không?" Fail thì pod bị gỡ khỏi Service endpoints. Không có gì bị restart. Probe pass lại thì traffic quay về. Đây là chỗ để kiểm tra dependency, nhưng phải cẩn thận.
- Startup: "App đã boot xong chưa?" Khi chưa pass, liveness và readiness chưa chạy. Nó bảo vệ mấy app khởi động chậm (warm cache, kiểm tra migration, warm JIT) khỏi bị liveness probe, vốn chỉnh cho lúc chạy ổn định, kill oan.
Giá trị mặc định là periodSeconds: 10, timeoutSeconds: 1, failureThreshold: 3, successThreshold: 1. Cái timeout một giây đó làm khổ app PHP lúc tải cao nhiều hơn mọi setting khác.
Vòng đời của probe
Đọc sơ đồ như hai vòng lặp riêng sau startup: liveness quyết định có restart không, readiness quyết định có route traffic không. Một pod có thể sống nhưng chưa ready (đang drain, dependency chết, đang warm) bao lâu cũng được mà không bị restart, và đó đúng là trạng thái mình muốn trong lúc database failover.
Nên kiểm tra gì, không nên kiểm tra gì
Liveness gần như không nên kiểm tra gì hết. Request tới được code của mình và nhận được response, vậy là đủ. Trong Laravel đó là route /up có sẵn: nó boot framework, bắn event DiagnosingHealth, rồi trả 200, hoặc 500 nếu có gì đó throw. Không database, không Redis, không API bên ngoài. Nếu PHP-FPM hết worker thì probe timeout, và đó là tín hiệu hợp lý cho thấy container bị kẹt.
Readiness có thể kiểm tra những dependency mà pod không thể thiếu, và chỉ những cái đó. Nếu request nào cũng cần MySQL, MySQL fail là lý do để ngưng route vô pod này. Nhưng nghĩ ở mức cả fleet: nếu mọi pod xài chung một database, tất cả cùng unready một lúc và Service còn đúng 0 endpoint. User nhận lỗi connection từ load balancer, chứ không phải một response 503 đàng hoàng từ app. Với dependency dùng chung, nhiều team cố ý giữ readiness xanh và để app tự trả lỗi đúng cách, circuit-break hoặc trả data từ cache.
Đừng bao giờ kiểm tra API bên thứ ba trong probe nào hết. Cổng thanh toán sập thì checkout chạy kém đi, chứ không được kéo cả API ra khỏi vòng phục vụ.
Giữ probe rẻ và có giới hạn. Probe chạy vài giây một lần trên mỗi pod. Một câu SELECT COUNT(*) trên bảng lớn, hay một lần mở connection không giới hạn thời gian phải chờ tới TCP timeout mặc định, sẽ biến probe thành tải và tự nó timeout.
Laravel: /up cho liveness, /ready cho readiness
Từ Laravel 11 trở đi, route health được đăng ký trong bootstrap/app.php. Giữ nó làm endpoint liveness:
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up', // liveness: boots the framework, touches no dependency
)
->withMiddleware(function (Middleware $middleware): void {})
->withExceptions(function (Exceptions $exceptions): void {})
->create();
Với readiness, thêm một invokable controller riêng chạy mấy check nhanh và trả 503 khi một check critical fail:
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Throwable;
final class ReadinessController
{
public function __invoke(): JsonResponse
{
$checks = [
'database' => ['critical' => true, 'run' => fn () => DB::select('select 1')],
'redis' => ['critical' => true, 'run' => fn () => Redis::connection()->ping()],
];
$results = [];
$ready = true;
foreach ($checks as $name => $check) {
try {
($check['run'])();
$results[$name] = 'ok';
} catch (Throwable $e) {
report($e);
$results[$name] = 'fail';
$ready = $ready && ! $check['critical'];
}
}
return response()->json(
['status' => $ready ? 'ready' : 'not_ready', 'checks' => $results],
$ready ? 200 : 503,
);
}
}
Đăng ký bằng Route::get('/ready', ReadinessController::class); nằm ngoài mọi middleware auth hay session, và loại cả hai route khỏi rate limiting. Cờ critical là quyết định thiết kế quan trọng: dependency không critical (search index, service gợi ý sản phẩm) có fail thì pod vẫn ở trong vòng phục vụ. Gọi report() cho exception giúp mình thấy nguyên nhân trong error tracker, chứ không chỉ một cái 503 trơn trong event của kubelet.
Cấu hình Kubernetes
Trỏ liveness và startup vô /up, readiness vô /ready, và tính cửa sổ startup dựa trên thời gian boot đo thật:
containers:
- name: app
image: registry.example.com/shop-api:2026.11.04
ports:
- containerPort: 8080
startupProbe: # gate: liveness/readiness wait until this passes
httpGet: { path: /up, port: 8080 }
periodSeconds: 5
failureThreshold: 24 # up to 120s to boot
livenessProbe: # "is the process wedged?" -> restart
httpGet: { path: /up, port: 8080 }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe: # "can it serve traffic right now?" -> remove from Service
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
lifecycle:
preStop:
exec: { command: ["sleep", "5"] } # let endpoints update before SIGTERM
failureThreshold * periodSeconds của startup probe là thời gian boot tối đa mình cho phép: ở đây 24 x 5 = 120 giây. Lệnh sleep trong preStop che khoảng hở giữa lúc pod bị đánh dấu terminating và lúc proxy trên mọi node gỡ nó khỏi endpoints; không có nó, mỗi lần rolling deploy sẽ có vài request rơi vô container đang tắt. Lệnh này cần image có sẵn binary sleep.
Healthcheck trong Docker và Compose
Docker thuần chỉ có một healthcheck, và nó chỉ mang tính thông tin: Docker đánh dấu container healthy hay unhealthy nhưng không restart nó (Swarm và điều kiện depends_on của Compose thì có xài kết quả này). Xài endpoint liveness:
# Dockerfile
HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/up || exit 1
--start-period là phiên bản startup probe của Docker: fail trong khoảng thời gian đó không bị tính vô --retries. Xài 127.0.0.1 thay vì localhost để khỏi dính chuyện resolve IPv6, và nhớ coi image cuối cùng có curl (hoặc wget) thật không; image distroless hay slim thường thiếu cả hai.
Khi có chuyện, coi nền tảng thấy gì, chứ đừng đoán endpoint trả gì:
docker inspect --format '{{json .State.Health}}' shop-api | jq '.Status, .Log[-1]'
kubectl describe pod shop-api-7c9f8 | grep -A3 -E 'Liveness|Readiness|Startup'
kubectl get endpoints shop-api # unready pods disappear from this list
Kiểm thử health endpoint
Health endpoint cũng là code, và tính chất quan trọng nhất cần test là chiều ngược: liveness phải xanh khi dependency fail. Một feature test Pest với facade mock là đủ:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
it('keeps /up green even when the database is down', function () {
DB::shouldReceive('select')->andThrow(new RuntimeException('db down'));
$this->get('/up')->assertOk();
});
it('reports not ready when a critical dependency fails', function () {
DB::shouldReceive('select')->andThrow(new RuntimeException('db down'));
Redis::shouldReceive('connection->ping')->andReturn(true);
$this->getJson('/ready')
->assertStatus(503)
->assertJsonPath('checks.database', 'fail')
->assertJsonPath('checks.redis', 'ok');
});
it('reports ready when every check passes', function () {
DB::shouldReceive('select')->andReturn([]);
Redis::shouldReceive('connection->ping')->andReturn(true);
$this->getJson('/ready')->assertOk()->assertJsonPath('status', 'ready');
});
Test đầu tiên chính là cái lẽ ra đã chặn được sự cố lúc 2 giờ 14. Nếu sau này ai đó thêm câu gọi database vô một listener của DiagnosingHealth, nó fail trên CI chứ không phải trên production. Thêm một check end-to-end trong pipeline deploy: chạy container, chờ healthcheck báo healthy, và cho pipeline fail nếu nó không bao giờ healthy.
Những lỗi hay gặp
Đưa dependency vô liveness. Kiểu sự cố dây chuyền kinh điển. Một dependency dùng chung fail, mọi pod restart cùng lúc, và hồi phục còn chậm hơn, ồn hơn. Liveness chỉ kiểm tra process, không gì khác.
Xài chung một endpoint cho liveness và readiness. Nó bắt mình chọn giữa bị restart mỗi lần dependency chập chờn hoặc không kiểm soát traffic gì hết. Hai endpoint, hai câu hỏi.
Timeout chặt hơn độ trễ thật. Timeout một giây trên pod đang 90% CPU sẽ fail lúc có lúc không, pod nhảy qua lại giữa ready và unready, tải dồn sang pod khác, và mấy pod đó cũng nhảy theo. Đo p99 của probe lúc có tải rồi đặt timeout cao hơn hẳn.
Không có startup probe cho app boot chậm. Nhiều team bù bằng initialDelaySeconds thật lớn cho liveness, mà vậy thì sau mỗi lần restart, việc phát hiện treo thật cũng bị trễ theo. Xài startup probe.
Probe nằm sau auth, session hay rate limit. Probe đi qua middleware session thì mỗi lần gọi ghi một session; probe nằm sau rate limiter thì fail đúng lúc traffic tăng vọt, là lúc mình cần nó chính xác nhất.
Health check log mọi lần gọi. Ba probe mỗi năm giây nhân 30 pod là access log đầy rác. Loại path của probe khỏi access log hoặc chỉ lấy mẫu.
Khi nào xài probe nào, khi nào không
Xài readiness cho mọi service nhận traffic qua Service hay load balancer. Đây là probe lời nhiều rủi ro ít nhất, vì fail nó không phá hủy thứ gì.
Chỉ xài liveness khi có một kiểu lỗi thực tế mà restart sửa được: memory leak làm worker kẹt, deadlock, event loop ngưng xử lý. Nếu không kể ra được cái nào, bỏ liveness cũng là lựa chọn hợp lệ; thiếu liveness probe còn an toàn hơn có một cái sai.
Xài startup mỗi khi thời gian boot dài hơn hoặc dao động nhiều hơn ngân sách của liveness, chuyện thường gặp với app warm cache hay chờ config lúc boot.
Bỏ kiểm tra dependency trong readiness khi dependency đó cả fleet xài chung và app tự trả lỗi có ý nghĩa được. Bỏ luôn HTTP probe cho queue worker không mở port; một exec probe kiểm tra file heartbeat do vòng lặp worker ghi ra hợp hơn nhiều.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết hàm evaluate(probe, checks) biến kết quả các check thành response của probe. probe là 'liveness' hoặc 'readiness'; giá trị khác thì throw Error (JavaScript) hoặc raise ValueError (Python). checks là danh sách object/dict có name (chuỗi), scope ('process' hoặc 'dependency'), critical (boolean) và ok (boolean). Liveness chỉ xét các check có scope là 'process'; readiness xét mọi check. Trong các check được xét, nếu có check fail mà là process check hoặc dependency critical thì trả status 503 và state 'fail'. Có check fail nhưng không cái nào chặn thì trả status 200 và state 'degraded'. Không có gì fail thì trả status 200 và state 'ok'. Luôn kèm failed: tên các check được xét mà bị fail, giữ đúng thứ tự đầu vào. JavaScript trả object { status, state, failed }, Python trả dict có các key 'status', 'state', 'failed'.
Code khởi tạo
def evaluate(probe: str, checks: list[dict]) -> dict:
"""Return {"status": int, "state": str, "failed": list[str]} for a probe.
probe: "liveness" or "readiness" (anything else raises ValueError)
checks: [{"name": str, "scope": "process" | "dependency", "critical": bool, "ok": bool}, ...]
"""
# TODO: validate the probe name
# TODO: liveness only looks at scope == "process" checks
# TODO: readiness looks at every check; a failing process check or a failing
# critical dependency means 503 / "fail"; only non-critical failures
# mean 200 / "degraded"; no failures means 200 / "ok"
# TODO: "failed" lists the names of failing checks that were considered, in input order
raise NotImplementedError
/**
* Return { status, state, failed } for a probe.
* probe: 'liveness' or 'readiness' (anything else throws an Error)
* checks: [{ name, scope: 'process' | 'dependency', critical, ok }, ...]
*/
function evaluate(probe, checks) {
// TODO: validate the probe name
// TODO: liveness only looks at scope === 'process' checks
// TODO: readiness looks at every check; a failing process check or a failing
// critical dependency means 503 / 'fail'; only non-critical failures
// mean 200 / 'degraded'; no failures means 200 / 'ok'
// TODO: failed lists the names of failing checks that were considered, in input order
throw new Error('TODO');
}
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýLọc trước: liveness chỉ giữ scope 'process', readiness giữ hết
Một lỗi là chặn khi scope là 'process' hoặc nó critical; dependency không critical fail thì chỉ degraded
Tạo failed từ các check được xét, để dependency hư không bao giờ xuất hiện trong response của liveness
def evaluate(probe: str, checks: list[dict]) -> dict:
"""Return {"status": int, "state": str, "failed": list[str]} for a probe.
probe: "liveness" or "readiness" (anything else raises ValueError)
checks: [{"name": str, "scope": "process" | "dependency", "critical": bool, "ok": bool}, ...]
"""
if probe not in ("liveness", "readiness"):
raise ValueError(f"Unknown probe: {probe}")
considered = [c for c in checks if probe == "readiness" or c["scope"] == "process"]
failed = [c for c in considered if not c["ok"]]
blocking = [c for c in failed if c["scope"] == "process" or c["critical"]]
if blocking:
status, state = 503, "fail"
elif failed:
status, state = 200, "degraded"
else:
status, state = 200, "ok"
return {"status": status, "state": state, "failed": [c["name"] for c in failed]}
/**
* Return { status, state, failed } for a probe.
* probe: 'liveness' or 'readiness' (anything else throws an Error)
* checks: [{ name, scope: 'process' | 'dependency', critical, ok }, ...]
*/
function evaluate(probe, checks) {
if (probe !== 'liveness' && probe !== 'readiness') {
throw new Error(`Unknown probe: ${probe}`);
}
const considered = checks.filter((c) => probe === 'readiness' || c.scope === 'process');
const failed = considered.filter((c) => !c.ok);
const blocking = failed.filter((c) => c.scope === 'process' || c.critical);
let status = 200;
let state = 'ok';
if (blocking.length > 0) {
status = 503;
state = 'fail';
} else if (failed.length > 0) {
state = 'degraded';
}
return { status, state, failed: failed.map((c) => c.name) };
}

