Chuyển tới nội dung chính

18.13 — Ví dụ thực tế nhanh

Tóm tắt

Một ví dụ ngắn nhưng gây sự cố lớn hơn mọi thứ khác trong bài này: database chậm 12 giây vì một lần chuyển đổi dự phòng, và toàn bộ cụm 6 service sập trong 4 phút — trong khi database đã hồi phục sau 12 giây. Nguyên nhân là một dòng cấu hình: livenessProbe trỏ vào endpoint có kiểm tra database. Khi database chậm, Kubernetes kết luận mọi pod đã chết và giết hết, rồi pod mới khởi động lại cũng không qua được probe, và chu kỳ đó tự lặp. Bài học: liveness kiểm tra tiến trình, readiness kiểm tra phụ thuộc — nhầm hai thứ này biến một sự cố thoáng qua thành sự cố toàn hệ thống.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Phân biệt liveness, readiness và startup probe.
  • Cấu hình health check đúng cho từng loại.
  • Nhận ra vòng lặp khởi động lại do probe sai.
  • Kiểm tra cấu hình probe của dự án trong vài phút.

Nội dung bài học​

18.13.1 — Sự cố​

14:02:10  Database chuyển dự phòng, mọi truy vấn chậm ~12 giây
14:02:25 livenessProbe của mọi pod TIMEOUT (probe gọi /health có check DB)
14:02:40 Kubernetes giết TẤT CẢ pod của 6 service
14:02:55 Pod mới khởi động, lại gọi /health, lại timeout
14:03:10 Kubernetes giết tiếp — CrashLoopBackOff
14:02:22 <- database ĐÃ HỒI PHỤC từ lúc này
14:06:30 Sau khi sửa cấu hình probe, hệ thống trở lại

Điểm đau nhất: database chỉ chậm 12 giây, nhưng hệ thống sập 4 phút, và toàn bộ phần kéo dài đó là do chính cơ chế tự phục hồi gây ra.

Cấu hình gây lỗi:

livenessProbe:
httpGet:
path: /health # <- endpoint này kiểm tra CẢ database
port: 8080
periodSeconds: 10
failureThreshold: 3
// Program.cs — một endpoint duy nhất cho mọi thứ
builder.Services.AddHealthChecks()
.AddSqlServer(connectionString)
.AddRedis(redisConnection)
.AddRabbitMQ(rabbitConnection);

app.MapHealthChecks("/health");

18.13.2 — Vì sao sai​

ProbeTrả lời câu hỏiHành động khi thất bại
livenessTiến trình còn sống không?Giết và khởi động lại pod
readinessSẵn sàng nhận request chưa?Gỡ khỏi load balancer
startupKhởi động xong chưa?Hoãn hai probe kia

Hành động khác nhau hoàn toàn, nên điều kiện cũng phải khác.

Khởi động lại một pod không sửa được database chậm — pod mới cũng gặp đúng database đó. Vì vậy liveness không bao giờ được phụ thuộc vào hệ thống ngoài: nó chỉ trả lời "tiến trình này có còn xử lý được request không", và câu trả lời gần như luôn là có, trừ khi deadlock hoặc hết bộ nhớ.

Ngược lại, gỡ pod khỏi load balancer là phản ứng đúng khi phụ thuộc chết — request sẽ đi tới pod khác (nếu có), hoặc ít nhất người dùng nhận lỗi nhanh thay vì đợi timeout.

18.13.3 — Cấu hình đúng​

builder.Services.AddHealthChecks()
// Chỉ tag "ready" — KHÔNG đưa vào liveness
.AddSqlServer(connectionString, tags: ["ready"])
.AddRedis(redisConnection, tags: ["ready"])
.AddRabbitMQ(rabbitConnection, tags: ["ready"]);

// Liveness: KHÔNG kiểm tra gì cả — trả 200 là đủ
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});

// Readiness: kiểm tra mọi phụ thuộc
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
livenessProbe:
httpGet:
path: /health/live # không chạm database
port: 8080
periodSeconds: 10
failureThreshold: 3

readinessProbe:
httpGet:
path: /health/ready # có chạm database
port: 8080
periodSeconds: 5
failureThreshold: 2

startupProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 5
failureThreshold: 30 # cho tới 150 giây để khởi động

Với cấu hình này, cùng sự cố database chậm 12 giây sẽ diễn ra như sau: readiness thất bại → pod bị gỡ khỏi load balancer → không pod nào bị giết → database hồi phục → readiness thành công → pod quay lại phục vụ. Tổng gián đoạn: 12 giây, đúng bằng thời gian database chậm.

startupProbe giải quyết một vấn đề riêng: ứng dụng .NET khởi động chậm (JIT, dựng EF Core model, chạy migration). Không có nó, bạn buộc phải đặt initialDelaySeconds lớn cho liveness — và khi đó pod thật sự chết sẽ mất rất lâu mới được phát hiện.

18.13.4 — Lỗi phổ biến thứ hai​

// SAI — readiness check chay truy van nang
.AddCheck("database", async ct =>
{
var count = await _db.Leads.CountAsync(ct); // quet bang!
return HealthCheckResult.Healthy();
});

Health check chạy mỗi 5 giây trên mỗi pod. Với 12 pod, đó là 144 lần đếm bảng mỗi phút — health check tự nó trở thành vấn đề hiệu năng.

// ĐÚNG — kiểm tra kết nối, không kiểm tra dữ liệu
.AddCheck("database", async ct =>
{
await _db.Database.ExecuteSqlRawAsync("SELECT 1", ct);
return HealthCheckResult.Healthy();
}, tags: ["ready"]);

Health check phải rẻ. Mục đích là biết kết nối còn dùng được, không phải xác nhận dữ liệu đúng.

18.13.5 — Tự kiểm tra trong vài phút​

1. Liveness có chạm hệ thống ngoài không?

kubectl get deploy -o json | jq -r '
.items[] | .metadata.name + " -> " +
(.spec.template.spec.containers[0].livenessProbe.httpGet.path // "không có")'

Bất kỳ đường dẫn nào kiểm tra database, Redis hay broker đều là rủi ro của sự cố ở trên.

2. Có startupProbe không?

kubectl get deploy -o json | jq -r '
.items[] | .metadata.name + " startup: " +
(if .spec.template.spec.containers[0].startupProbe then "có" else "KHÔNG" end)'

Thiếu nó thường dẫn tới initialDelaySeconds lớn cho liveness, làm chậm việc phát hiện pod chết thật.

3. Health check có rẻ không?

time curl -s http://localhost:8080/health/ready

Trên 100ms là dấu hiệu có truy vấn nặng bên trong.

18.13.6 — Rà lại code của bạn​

Danh sách rà soát health check

  • •livenessProbe không kiểm tra database, Redis hay broker.
  • •readinessProbe kiểm tra đủ mọi phụ thuộc bắt buộc.
  • •Có startupProbe để ứng dụng khởi động chậm không bị giết oan.
  • •Health check chỉ kiểm tra kết nối, không chạy truy vấn nặng.
  • •Endpoint readiness trả về dưới 100ms.
  • •failureThreshold của readiness nhỏ hơn của liveness.
  • •Gateway trỏ health check vào readiness, không phải liveness.
  • •Đã thử tắt database và quan sát pod có bị giết hay không.

Bài tập áp dụng​

Bài 1 — Tái hiện vòng lặp​

Cấu hình liveness trỏ vào endpoint có kiểm tra database, tắt database, và quan sát pod chuyển sang CrashLoopBackOff.

Tiêu chí hoàn thành: bạn thấy pod bị giết chứ không phải bị gỡ, và giải thích được vì sao thời gian gián đoạn dài hơn thời gian database hỏng.

Gợi ý và lời giải — Bài 1

Gợi ý. Cần một cụm để thấy hành vi thật. kind dựng được một cụm trên máy cá nhân trong khoảng một phút.

Lời giải — dựng cụm và tái hiện:

kind create cluster --name thu-nghiem-probe

Ứng dụng có đúng một endpoint health, và nó chạm database:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Db")!);

var app = builder.Build();
app.MapHealthChecks("/health"); // <- dùng cho CẢ liveness lẫn readiness
app.MapGet("/api/ping", () => "pong"); // endpoint không chạm database
app.Run();
# sai.yaml — cấu hình gây lỗi
apiVersion: apps/v1
kind: Deployment
metadata:
name: crm-api
spec:
replicas: 3
selector:
matchLabels: { app: crm-api }
template:
metadata:
labels: { app: crm-api }
spec:
containers:
- name: api
image: crm-api:thu-nghiem
ports: [{ containerPort: 8080 }]
livenessProbe:
httpGet: { path: /health, port: 8080 } # <- chạm database
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /health, port: 8080 }
periodSeconds: 5
failureThreshold: 2
kubectl apply -f sai.yaml
kubectl get pods -w &

# Tắt database — ở đây là dừng container postgres trong cụm
kubectl scale statefulset postgres --replicas=0

Trình tự quan sát được:

t+0s    postgres dừng
t+5s readiness thất bại lần 1
t+10s readiness thất bại lần 2 -> 3/3 pod bị GỠ khỏi service (đúng)
t+10s liveness thất bại lần 1
t+20s liveness thất bại lần 2
t+30s liveness thất bại lần 3 -> vượt failureThreshold
t+30s Kubernetes GIẾT cả 3 pod (sai)
t+35s pod mới khởi động, chạy migration -> hỏng vì không có database
t+40s CrashLoopBackOff, chờ 10 giây rồi thử lại
t+60s CrashLoopBackOff, chờ 20 giây
t+90s CrashLoopBackOff, chờ 40 giây
kubectl get pods
NAME                       READY   STATUS             RESTARTS      AGE
crm-api-7d4b9c5f8-2xk4p 0/1 CrashLoopBackOff 4 (31s ago) 3m12s
crm-api-7d4b9c5f8-9mn7q 0/1 CrashLoopBackOff 4 (28s ago) 3m12s
crm-api-7d4b9c5f8-vq2lt 0/1 CrashLoopBackOff 3 (12s ago) 3m12s
kubectl describe pod crm-api-7d4b9c5f8-2xk4p | grep -A3 "Events:"
Events:
Warning Unhealthy 3m kubelet Liveness probe failed: HTTP probe failed with statuscode: 503
Normal Killing 3m kubelet Container api failed liveness probe, will be restarted
Warning BackOff 12s kubelet Back-off restarting failed container

Dòng Killing ... failed liveness probe, will be restarted là bằng chứng trực tiếp: pod bị giết, không phải bị gỡ.

Vì sao gián đoạn dài hơn thời gian database hỏng:

kubectl scale statefulset postgres --replicas=1   # database hồi phục sau 60 giây
t+60s   database hồi phục
t+60s nhưng 3 pod đang ở CrashLoopBackOff với backoff 40 giây
t+100s pod thử lại, khởi động thành công
t+110s readiness thành công, pod quay lại service

Database hỏng: 60 giây
Hệ thống gián đoạn: 110 giây — gấp gần hai lần

Ba cơ chế cộng dồn tạo ra phần chênh đó:

1. Khởi động lại mất thời gian, và thời gian đó không liên quan gì đến database.

Kéo image (nếu chưa có) + khởi động runtime + nạp cấu hình
+ mở pool kết nối + làm nóng JIT
-> mỗi lần khởi động lại là một lần trả toàn bộ chi phí này

2. Backoff tăng theo cấp số nhân, và nó không biết database đã hồi phục.

10s -> 20s -> 40s -> 80s -> 160s -> tối đa 300s

Sau 5 lần thất bại, pod chờ 5 PHÚT trước khi thử lại
-> database hồi phục ở giây thứ 60 cũng phải chờ hết 5 phút đó

Đây là phần nguy hiểm nhất: hệ thống càng hỏng lâu, nó càng chậm phục hồi — ngược hẳn với điều ta muốn.

3. Mất hết trạng thái nóng.

Cache trong bộ nhớ: rỗng
Pool kết nối: phải mở lại
-> pod vừa quay lại đã phải chịu tải lạnh
-> p99 cao trong vài phút đầu, có thể lại làm probe thất bại

Và một hệ quả ở quy mô lớn, tệ hơn cả ba điều trên:

Cả 3 pod cùng bị giết trong cùng một giây
-> 3 pod cùng khởi động lại
-> 3 pod cùng mở pool kết nối tới database vừa hồi phục
-> database nhận cú sốc kết nối ngay lúc yếu nhất
-> có thể chậm lại lần nữa -> vòng lặp lặp lại

Đây là thundering herd (bài 17.6) xuất hiện ở tầng điều phối thay vì tầng ứng dụng, và nó chính là lý do sự cố 12 giây trong bài này kéo thành 4 phút.

Nếu không dựng được cụm, vẫn tái hiện được bằng Docker Compose:

services:
api:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
retries: 3
restart: always # <- tương đương hành vi giết-và-khởi-động-lại
docker compose up -d
docker compose stop postgres
watch -n1 'docker compose ps'

Cột STATUS sẽ nhảy qua lại giữa Up và Restarting — cùng một cơ chế, chỉ khác tên gọi.


Bài 2 — Sửa và so sánh​

Tách thành /health/live và /health/ready, lặp lại bài 1 và xác nhận pod chỉ bị gỡ khỏi service chứ không bị giết.

Tiêu chí hoàn thành: cột RESTARTS không tăng trong suốt sự cố, và bạn giải thích được vì sao startupProbe cần thiết ngay cả khi liveness đã đúng.

Gợi ý và lời giải — Bài 2

Gợi ý. Bằng chứng gọn nhất cho "đã sửa đúng" là một con số không đổi: RESTARTS.

Lời giải — sửa cả code lẫn cấu hình:

builder.Services.AddHealthChecks()
.AddNpgSql(chuoiKetNoi, name: "postgres", tags: ["ready"]);

// Liveness: không kiểm tra gì — chỉ cần tiến trình còn trả lời được HTTP
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });

// Readiness: kiểm tra phụ thuộc thật sự chặn
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
});
# dung.yaml
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
failureThreshold: 3

readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
failureThreshold: 2

startupProbe:
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 5
failureThreshold: 30 # cho tới 150 giây để khởi động
kubectl apply -f dung.yaml
kubectl scale statefulset postgres --replicas=0
kubectl get pods -w
NAME                       READY   STATUS    RESTARTS   AGE
crm-api-6f8d4c7b9-4rt2m 1/1 Running 0 2m14s
crm-api-6f8d4c7b9-8kp5n 1/1 Running 0 2m14s
crm-api-6f8d4c7b9-jw9xc 1/1 Running 0 2m14s

... sau 10 giây ...

crm-api-6f8d4c7b9-4rt2m 0/1 Running 0 2m24s
crm-api-6f8d4c7b9-8kp5n 0/1 Running 0 2m24s
crm-api-6f8d4c7b9-jw9xc 0/1 Running 0 2m24s

Hai cột cần nhìn:

READY    1/1 -> 0/1   : pod bị GỠ khỏi service  (đúng, phản ứng hợp lý)
STATUS Running : pod KHÔNG bị giết
RESTARTS 0 : không tăng, dù database đã tắt 2 phút

Khi database quay lại:

kubectl scale statefulset postgres --replicas=1
NAME                       READY   STATUS    RESTARTS   AGE
crm-api-6f8d4c7b9-4rt2m 1/1 Running 0 4m30s

So sánh hai lần đo:

Cấu hình saiCấu hình đúng
Database hỏng60 s60 s
Hệ thống gián đoạn110 s~65 s
RESTARTS40
Cache trong bộ nhớMất hếtGiữ nguyên
Pool kết nốiMở lại từ đầuGiữ, tự kết nối lại
Phục hồi sau khi DB vềChờ hết backoffNgay lần probe kế tiếp

Phần chênh 45 giây hoàn toàn là do cơ chế tự phục hồi gây ra — và nó biến mất khi probe được phân vai đúng.

Vì sao startupProbe vẫn cần, dù liveness đã đúng:

Ứng dụng khởi động chậm: chạy migration, nạp cấu hình, làm nóng cache
-> có thể mất 40–90 giây trước khi phục vụ được

Không có startupProbe:
liveness bắt đầu đếm ngay từ giây đầu
periodSeconds 10 × failureThreshold 3 = 30 giây
-> pod bị giết ở giây 30, TRONG LÚC đang khởi động bình thường
-> khởi động lại, lại bị giết ở giây 30
-> CrashLoopBackOff mà không có lỗi nào cả

startupProbe tạm dừng cả liveness lẫn readiness cho tới khi nó thành công lần đầu:

startupProbe: 5s × 30 lần = tối đa 150 giây để khởi động
Sau khi nó thành công -> liveness và readiness mới bắt đầu

Cách đặt failureThreshold cho startupProbe, bằng số đo thật:

# Đo thời gian khởi động qua 10 lần gần nhất
kubectl get events --field-selector reason=Started -o json \
| jq -r '.items[] | "\(.involvedObject.name) \(.firstTimestamp)"'
Thời gian khởi động: p50 = 22 giây, p99 = 68 giây

failureThreshold = (p99 × 3) / periodSeconds
= (68 × 3) / 5
≈ 41

Nhân ba cho p99 nghe có vẻ rộng, nhưng đây là ngưỡng một chiều: đặt quá rộng thì một pod hỏng thật mất thêm vài phút mới được phát hiện; đặt quá hẹp thì pod lành bị giết trong lúc khởi động — và điều đó tạo ra CrashLoopBackOff không có nguyên nhân nào để tìm.

Ba lỗi cấu hình probe còn lại, thường gặp hơn người ta nghĩ:

1. readinessProbe gọi service khác.

readinessProbe:
httpGet: { path: /health/ready, port: 8080 } # và /health/ready gọi billing-api
billing-api chậm -> mọi pod của api bị gỡ -> toàn hệ thống ngừng
-> sự cố lan truyền qua health check, nhanh hơn cả qua lưu lượng thật

Đây đúng là điều đã rà ở bài 18.9.

2. periodSeconds của liveness quá ngắn.

livenessProbe:
periodSeconds: 1
failureThreshold: 1 # giết sau MỘT lần thất bại
Một lần GC dừng 1,2 giây -> probe timeout -> pod bị giết
-> pod hoàn toàn khoẻ mạnh bị giết vì một lần GC bình thường

3. Không đặt timeoutSeconds, và mặc định là 1 giây.

/health/ready đo được p99 = 9 ms khi khoẻ
nhưng khi database chậm, nó có thể mất 5–10 giây

timeoutSeconds mặc định = 1
-> probe báo thất bại vì TIMEOUT, không phải vì kết quả
-> và log không cho biết phụ thuộc nào hỏng
readinessProbe:
timeoutSeconds: 3 # đặt tường minh, lớn hơn p99 vài lần

Cuối cùng, một thói quen đáng có: thử probe trước khi tin vào nó.

kubectl exec -it crm-api-6f8d4c7b9-4rt2m -- curl -sv http://localhost:8080/health/live
kubectl exec -it crm-api-6f8d4c7b9-4rt2m -- curl -s http://localhost:8080/health/ready | jq

Nếu /health/live mất hơn vài mili-giây, nó đang kiểm tra thứ gì đó mà nó không nên kiểm tra.


Bài 3 — Đo chi phí health check​

Đo thời gian phản hồi của endpoint readiness hiện tại và tính tổng số lần gọi mỗi phút với số pod đang chạy.

Tiêu chí hoàn thành: bạn có con số đo được cho cả hai endpoint, tính được tải mà probe tạo ra, và nhận ra chi phí thật không nằm ở CPU của chính probe.

Gợi ý và lời giải — Bài 3

Gợi ý. Probe gọi mỗi 5 giây nghe có vẻ rất nhẹ. Nhân nó với số pod, rồi nhân với số phụ thuộc mà mỗi lần gọi chạm tới.

Lời giải — đo hai endpoint:

var http = new HttpClient();

async Task<(double p50, double p99)> DoAsync(string duong, int n)
{
var ms = new List<double>(n);
for (int i = 0; i < n; i++)
{
var sw = Stopwatch.StartNew();
(await http.GetAsync($"http://127.0.0.1:5211{duong}")).EnsureSuccessStatusCode();
ms.Add(sw.Elapsed.TotalMilliseconds);
}
ms.Sort();
return (ms[n / 2], ms[(int)(n * 0.99)]);
}

await DoAsync("/health/ready", 50); // làm nóng
await DoAsync("/health/live", 50);

var r = await DoAsync("/health/ready", 1000);
var l = await DoAsync("/health/live", 1000);
Console.WriteLine($"/health/ready p50={r.p50:F2} ms p99={r.p99:F2} ms");
Console.WriteLine($"/health/live p50={l.p50:F2} ms p99={l.p99:F2} ms");

Đo trên .NET 9.0.203, readiness gồm một truy vấn SQLite thật cộng hai kiểm tra giả lập 1 ms và 2 ms:

/health/ready  p50=2,80 ms  p99=9,07 ms
/health/live p50=0,14 ms p99=0,40 ms
Endpointp50p99Tỉ lệ
/health/ready2,80 ms9,07 ms20×
/health/live0,14 ms0,40 ms1×

Khoảng cách 20 lần này là lý do trực tiếp để tách hai endpoint: liveness chạy thường xuyên hơn và không được trả cái giá của readiness.

Tính tải mà probe tạo ra:

Cấu hình:  liveness mỗi 10s, readiness mỗi 5s
Quy mô: 6 service × 3 pod = 18 pod

Liveness: 18 pod × (60 / 10) = 108 lần/phút
Readiness: 18 pod × (60 / 5) = 216 lần/phút
------------
Tổng: 324 lần/phút = 5,4 lần/giây

Chi phí CPU:

216 × 2,80 ms + 108 × 0,14 ms = 620 ms mỗi phút
-> khoảng 1% của một lõi, trải trên 18 pod
-> không đáng kể

Nhưng chi phí thật không nằm ở đó. Mỗi lần gọi readiness chạm vào các phụ thuộc:

216 lần/phút × 1 truy vấn database = 216 truy vấn/phút
= 311.040 truy vấn/ngày

Chỉ để hỏi "database còn sống không".

Và nếu readiness kiểm tra nhiều phụ thuộc:

216 lần/phút × 4 phụ thuộc (db, redis, rabbit, blob)
= 864 lần gọi phụ thuộc mỗi phút
= 1,24 triệu lần mỗi ngày

Ba cách giảm, theo thứ tự nên làm:

1. Bỏ phụ thuộc không chặn khỏi readiness.

4 phụ thuộc -> 1 phụ thuộc (chỉ database)
= giảm 75% số lần gọi, và không mất gì

Đây là bài tập đã làm ở bài 18.9 — và ở đây nó có thêm một lý do bằng số.

2. Cache kết quả health check trong thời gian ngắn hơn chu kỳ probe.

builder.Services.AddSingleton<IHealthCheckPublisher, CacheKetQua>();
builder.Services.Configure<HealthCheckPublisherOptions>(o =>
{
o.Delay = TimeSpan.FromSeconds(2);
o.Period = TimeSpan.FromSeconds(10); // chạy check nền mỗi 10 giây
});

// Endpoint chỉ đọc kết quả đã có trong bộ nhớ -> không chạm phụ thuộc
app.MapGet("/health/ready", (CacheKetQua c) =>
c.KetQuaMoiNhat?.Status == HealthStatus.Healthy
? Results.Ok("Healthy")
: Results.StatusCode(503));
Probe mỗi 5 giây, check nền mỗi 10 giây
-> số lần chạm database giảm một nửa
-> và endpoint phản hồi ở mức của /health/live

3. Nới periodSeconds của readiness khi hợp lý.

periodSeconds 5  -> phát hiện sau tối đa 10 giây (failureThreshold 2)
periodSeconds 15 -> phát hiện sau tối đa 30 giây

Đánh đổi: chậm phát hiện hơn, đổi lấy ít tải hơn.
Với service nội bộ, 30 giây thường chấp nhận được.
Với service ở đường đi của người dùng, giữ 5 giây.

Và một cạm bẫy đáng nhớ: readiness đắt tiền tự nó gây ra sự cố.

Database bắt đầu chậm
-> readiness chậm theo (vì nó truy vấn database)
-> probe timeout -> pod bị gỡ
-> lưu lượng dồn sang pod còn lại
-> pod còn lại tải nặng hơn -> readiness của nó cũng chậm
-> bị gỡ nốt -> KHÔNG CÒN pod nào phục vụ

Trong khi database chỉ chậm, chưa hề chết.

Cách này hỏng theo kiểu tệ nhất: hệ thống tự gỡ hết pod của mình trong lúc vẫn còn phục vụ được ở mức suy giảm. Ba biện pháp ở trên — bỏ bớt phụ thuộc, cache kết quả, và đặt timeoutSeconds tường minh — đều nhắm vào đúng chuỗi này.

Theo dõi chính probe, để biết khi nào nó thành vấn đề:

builder.Services.AddHealthChecks()
.AddNpgSql(chuoiKetNoi, name: "postgres", tags: ["ready"])
.AddCheck<DoThoiGianCheck>("thoi-gian-health-check", tags: ["ready"]);
# Health check nào đang chậm dần?
histogram_quantile(0.99,
rate(aspnetcore_healthcheck_duration_seconds_bucket[5m])) > 0.1

Ngưỡng 100 ms cho một health check là rộng rãi so với số đo 9,07 ms ở trên — nên khi nó vượt, gần như chắc chắn có một phụ thuộc đang xuống cấp, và cảnh báo đó thường đến trước khi người dùng thấy chậm.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao database chậm 12 giây lại làm hệ thống sập 4 phút?

Vì livenessProbe trỏ vào endpoint có kiểm tra database, nên Kubernetes kết luận mọi pod đã chết và giết hết. Pod mới khởi động lại cũng không qua được probe nên chu kỳ tự lặp, kéo dài rất lâu sau khi database đã hồi phục.

Vì sao liveness không được phụ thuộc hệ thống ngoài?

Vì hành động khi liveness thất bại là giết và khởi động lại pod, mà khởi động lại không sửa được database chậm. Pod mới cũng gặp đúng database đó. Liveness chỉ nên trả lời tiến trình này còn xử lý được request hay không.

Vì sao gỡ pod khỏi load balancer lại là phản ứng đúng khi phụ thuộc chết?

Vì request sẽ đi tới pod khác nếu có, hoặc ít nhất người dùng nhận lỗi nhanh thay vì đợi timeout. Và quan trọng là không pod nào bị giết, nên khi phụ thuộc hồi phục thì mọi thứ trở lại ngay.

startupProbe giải quyết vấn đề gì?

Ứng dụng .NET khởi động chậm vì JIT, dựng EF Core model và chạy migration. Không có startupProbe thì phải đặt initialDelaySeconds lớn cho liveness, và khi đó pod chết thật sẽ mất rất lâu mới được phát hiện.

Vì sao health check phải rẻ?

Vì nó chạy mỗi vài giây trên mỗi pod, nên một truy vấn đếm bảng nhân với số pod trở thành tải đáng kể. Mục đích của health check là biết kết nối còn dùng được, không phải xác nhận dữ liệu đúng.

Gateway nên trỏ health check vào đâu?

Vào readiness, vì gateway cần biết instance nào sẵn sàng nhận request. Trỏ vào liveness sẽ khiến gateway vẫn gửi request tới instance đang khởi động hoặc đang mất kết nối database.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Liveness kiểm tra tiến trình, readiness kiểm tra phụ thuộc. Nhầm là biến sự cố nhỏ thành sự cố toàn hệ thống.
  2. Health check phải rẻ — nó chạy liên tục trên mọi pod.
  3. startupProbe là thứ cho phép liveness phản ứng nhanh mà không giết oan pod đang khởi động.

Tham khảo​

Điều hướng​