Health indicator và hai loại probe
Readiness trả lời nhận request được chưa, liveness trả lời có cần khởi động lại không. Nhầm hai cái này là cách phổ biến để tự gây restart loop.
TL;DR: Liveness hỏi "tiến trình còn cứu được không, hay phải giết đi tạo lại" — chỉ fail khi tiến trình tự kẹt cứng, dependency ngoài không thuộc liveness. Readiness hỏi "ngay lúc này có nhận request được không" — fail thì bị rút khỏi load balancer nhưng tiến trình vẫn sống, đây mới là chỗ đúng để kiểm tra dependency ngoài. Nhét dependency vào liveness là lỗi phổ biến nhất, và nó tự tạo ra restart loop.
Một dịch vụ TaskFlow chạy 6 pod trên Kubernetes, liveness probe trỏ thẳng vào /actuator/health — endpoint kiểm tra luôn kết nối PostgreSQL bằng SELECT 1, bình thường chỉ mất 5ms. Một đêm, job đồng bộ chiếm hết connection pool, độ trễ vọt lên 4 giây. Probe timeout 1 giây, 3 lần fail liên tiếp trong 15 giây thì Kubernetes coi tiến trình đã chết — giết cả 6 pod gần như cùng lúc rồi khởi động lại. Job đồng bộ vẫn chạy, nên pod mới cũng gọi đúng database đang chậm, cũng bị giết tiếp, cứ thế mỗi 20 giây — sự cố database vài phút biến thành mất dịch vụ toàn bộ gần nửa giờ.
Bài này giải thích vì sao nhầm hai probe gây ra sự cố trên, cách Spring Boot hiện thực chúng qua Actuator, và cách viết health indicator riêng cho một dependency downstream mà không lặp lại lỗi này.
1. Nhắc lại actuator bạn đã cấu hình ở bài trước
Trước khi đọc tiếp, thử tự trả lời: mặc định Actuator chỉ expose công khai đúng một endpoint, đó là endpoint nào? Property nào bạn dùng để mở thêm những endpoint khác mà vẫn kiểm soát được lộ ra bao nhiêu?
Gợi ý: liên quan tới security-by-default và một property tên management.endpoints.web.exposure.include.
Bài trước dựng ba việc: thêm spring-boot-starter-actuator, mở thêm endpoint qua management.endpoints.web.exposure.include, và tách management.server.port sang cổng riêng có SecurityFilterChain bảo vệ. Property management.endpoint.health.show-details quyết định response trả chi tiết hay chỉ trạng thái tổng.
Điều bài trước chưa mổ: trạng thái tổng đó — {"status": "UP"} — được tính ra sao. Actuator gom kết quả của nhiều health indicator (mỗi indicator kiểm tra một thành phần: database, đĩa còn trống, Redis...) rồi lấy trạng thái xấu nhất làm trạng thái tổng — database down kéo cả health xuống DOWN, dù tiến trình Java vẫn chạy bình thường.
Đây là chỗ sự cố mở bài bắt nguồn: health gộp chung tình trạng mọi dependency, và "cái gộp chung này có nên quyết định sống-chết của cả pod hay không" chính là câu hỏi liveness so với readiness.
2. Liveness và readiness khác nhau ở đâu?
Kubernetes (k8s) điều phối nhiều bản sao ứng dụng — gọi là pod — tự khởi động lại pod hỏng và định tuyến request tới pod sẵn sàng. Để biết pod nào hỏng, nó gọi định kỳ vào hai endpoint riêng biệt: hai loại probe.
- Liveness probe: "tiến trình này còn cứu được không, hay đã kẹt cứng tới mức chỉ có cách giết đi tạo lại?" Fail thì Kubernetes giết container và khởi động cái mới, như tắt bật máy khi nó treo.
- Readiness probe: "ngay lúc này, pod có nhận request được không?" Fail thì pod bị rút khỏi danh sách phục vụ — vẫn sống, chỉ tạm ngừng nhận request, tự quay lại khi phục hồi.
flowchart TB
APP["App dang chay trong 1 pod"]
APP --> RQ{"Readiness: nhan request duoc chua?"}
APP --> LQ{"Liveness: tien trinh con cuu duoc khong?"}
RQ -- "khong" --> ROUT["Rut khoi load balancer, pod van song"]
ROUT -. "phuc hoi" .-> RQ
RQ -- "co" --> RIN["Nhan request binh thuong"]
LQ -- "khong" --> LKILL["Kubernetes giet pod, tao pod moi"]
LQ -- "co" --> LOK["Giu nguyen, khong can thiep"]Hệ quả khi gộp nhầm: nếu liveness phụ thuộc thứ ngoài tiến trình (database, service khác), một sự cố tạm thời ở đó bị hiểu nhầm thành "tiến trình chết" dù tiến trình hoàn toàn khoẻ. Kubernetes giết nhầm, và vì nguyên nhân gốc không biến mất, pod mới cũng gặp y hệt vấn đề.
Bảng sau giúp tự phân loại:
| Sự cố | Thuộc probe nào | Vì sao |
|---|---|---|
| Database chậm hoặc mất kết nối tạm thời | Readiness | Ứng dụng không tự sửa được database, chỉ nên tạm ngừng nhận request |
| Deadlock nội bộ, thread pool cạn kiệt không tự giải phóng | Liveness | Chỉ restart mới thoát được trạng thái này |
| Dịch vụ downstream (ví dụ notification-service) đang bảo trì | Readiness | Lỗi ở ngoài, giết pod mình không sửa được gì |
| Heap kẹt cứng, GC chạy liên tục mà không giải phóng được | Liveness | Tiến trình JVM đã ở trạng thái không tự hồi phục |
| Ứng dụng vừa khởi động, đang nạp cache chưa xong | Readiness | Tiến trình khoẻ, chỉ chưa sẵn sàng nhận traffic |
Quy tắc gọn: nếu "khởi động lại tiến trình có sửa được vấn đề này không" là không, thì đó là readiness, không phải liveness.
3. Vì sao sự cố mở bài là lỗi liveness?
Áp bảng trên vào sự cố mở đầu: "database chậm vì job đồng bộ chiếm connection pool" khớp hàng đầu tiên — thuộc readiness, vì giết pod không giúp database nhanh lên. Nhưng cấu hình thực tế trỏ endpoint kiểm tra database vào liveness, nên Kubernetes hiểu nhầm "database chậm" thành "tiến trình chết":
flowchart TB
A["Database cham vi job dong bo chiem pool"] --> B["Liveness probe goi endpoint kiem tra DB"]
B --> C["Probe timeout 1s, 3 lan fail lien tiep"]
C --> D["Kubernetes giet pod"]
D --> E["Tao pod moi, khoi dong lai"]
E --> F["Pod moi cung goi database dang cham"]
F --> BVòng lặp không tự dừng cho tới khi database nhanh lại — sự cố vài phút biến thành mất khả năng phục vụ kéo dài, vì không pod nào sống đủ lâu để ổn định. Cách sửa không phải tăng timeout hay giảm ngưỡng fail — mà bỏ hẳn kiểm tra database ra khỏi liveness.
4. Spring Boot hiện thực hai probe qua Actuator
Spring Boot theo dõi trạng thái qua hai khái niệm riêng: LivenessState (tiến trình còn chạy bình thường hay không) và ReadinessState (ứng dụng có đang chấp nhận traffic hay không). Actuator expose hai trạng thái này thành hai endpoint khi bạn bật property:
management:
endpoint:
health:
probes:
enabled: true
Bật xong, hai endpoint sau xuất hiện, tách biệt hoàn toàn khỏi /actuator/health tổng hợp:
/actuator/health/liveness— chỉ phản ánhLivenessState./actuator/health/readiness— chỉ phản ánhReadinessState.
Bạn thường không cần tự set property này — Spring Boot phát hiện Kubernetes lúc khởi động và tự bật management.endpoint.health.probes.enabled. Chạy local hay Docker Compose thuần thì hai endpoint này mặc định không xuất hiện; muốn thấy khi phát triển local, set thủ công thành true.
Cấu hình probe phía Kubernetes trỏ đúng đường dẫn này (cổng management.server.port đã tách ở bài trước; readinessProbe viết y hệt, chỉ đổi path):
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 9090
periodSeconds: 5
failureThreshold: 3
5. Health indicator có sẵn — Boot tự đăng ký gì
Recall autoconfiguration ở spring-core: bean được bật khi thấy đúng class trên classpath. Actuator áp cùng cơ chế — có DataSource thì tự đăng ký db, có Redis client thì tự đăng ký redis, luôn có sẵn diskSpace. Mỗi indicator cộng vào /actuator/health tổng hợp, nhưng chưa vào liveness/readiness — gán nhóm là việc bạn tự làm ở mục 7.
Tắt bớt indicator không cần thiết bằng property riêng từng cái:
| Indicator | Tắt bằng |
|---|---|
Database (DataSource) | management.health.db.enabled=false |
| Dung lượng đĩa còn trống | management.health.diskspace.enabled=false |
| Redis | management.health.redis.enabled=false |
6. Viết HealthIndicator riêng cho dependency downstream
TaskFlow gọi sang notification-service mỗi khi một task đổi trạng thái, để gửi email cho người được giao việc. Đây là dependency ngoài đúng nghĩa — cần kiểm tra, nhưng phải kiểm tra đúng cách. Interface HealthIndicator chỉ có một method là health(), implement cho notification-service:
@Component
public class NotificationServiceHealthIndicator implements HealthIndicator {
private static final Duration PING_TIMEOUT = Duration.ofMillis(300);
private final NotificationClient client;
public NotificationServiceHealthIndicator(NotificationClient client) {
this.client = client;
}
@Override
public Health health() {
try {
boolean reachable = client.ping(PING_TIMEOUT);
return reachable ? Health.up().build() : Health.down().build();
} catch (Exception ex) {
return Health.down(ex).build();
}
}
}
Cách NotificationClient.ping(...) gọi HTTP cụ thể là chủ đề của một bài khác. Hai điều bắt buộc với mọi health indicator tự viết, bất kể gọi bằng cách nào:
- Timeout ngắn, bắt hết exception. 300ms hợp lý cho gọi nội bộ cùng cluster; không set thì
health()thừa hưởng timeout mặc định của client bên dưới, có thể vài chục giây — kéo theo response/actuator/health/readinesscủa TaskFlow bị treo. - Không gọi dây chuyền sang health check của service khác. Nếu
ping()gọi vào/actuator/healthcủanotification-service— endpoint đó lại tự kiểm tra database riêng — một sự cố ở tầng sâu nhất lan ngược lên TaskFlow, rồi lan tiếp lên bất cứ ai gọi TaskFlow.
// SAI - ping() goi thang vao "/actuator/health" cua service kia, keo theo ca DB cua no
// DUNG - route heartbeat rieng, chi tra 200 neu tien trinh con song:
public boolean ping(Duration timeout) {
var response = restClient.get()
.uri("http://notification-service/heartbeat")
.retrieve()
.toEntity(Void.class);
return response.getStatusCode().is2xxSuccessful();
}
7. Gán health indicator vào đúng probe
7.1 Gán indicator vào health group
Viết xong indicator, bước quyết định của cả bài này là gán nó vào đúng nhóm — health group readiness mặc định chỉ chứa readinessState, không tự động kéo theo indicator bạn vừa viết:
management:
endpoint:
health:
group:
readiness:
include: readinessState, notificationService
include thay thế danh sách mặc định, không cộng thêm — bỏ readinessState nghĩa là nhóm readiness không còn phản ánh trạng thái sẵn sàng nội tại của Boot, chỉ còn notificationService. Luôn liệt kê readinessState cùng indicator bạn thêm. Nhóm liveness để nguyên mặc định — chỉ livenessState, không thêm indicator phụ thuộc bên ngoài, đúng nguyên tắc ở mục 2.
7.2 Readiness lúc tắt ứng dụng (graceful shutdown)
Một tình huống readiness khác: lúc ứng dụng chuẩn bị tắt. Pod bị giết ngay khi nhận tín hiệu shutdown thì request đang xử lý dở bị cắt ngang — Spring Boot xử lý bằng graceful shutdown:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
Khi đó ReadinessState tự chuyển sang REFUSING_TRAFFIC — readiness probe fail, pod bị rút khỏi load balancer ngay, nhưng tiến trình chưa bị giết. Request đang bay xử lý nốt trong tối đa 20 giây rồi mới thật sự đóng. Thứ tự này quan trọng: rút khỏi load balancer trước, đóng tiến trình sau.
Pitfall thường gặp
❌ Nhầm 1 — trỏ liveness vào endpoint kiểm tra database, đúng sự cố mở bài:
✅ Database, Redis, service ngoài luôn thuộc readiness. Liveness chỉ nên dùng livenessState mặc định.
❌ Nhầm 2 — health indicator tự viết không set timeout:
✅ Mọi cuộc gọi ra ngoài trong health() phải có timeout tường minh, ngắn hơn nhiều lần chu kỳ probe.
❌ Nhầm 3 — thêm indicator vào readiness.include mà quên giữ readinessState:
✅ include thay thế toàn bộ danh sách mặc định. Luôn viết readinessState là phần tử đầu tiên rồi mới thêm indicator riêng.
Đào sâu
Spec / reference chính thức:
- Spring Boot Reference — Actuator Endpoints — property
management.endpoint.health.probes.enabled, auto-detect Kubernetes, và cáchincludecủa health group thay thế danh sách mặc định. - Kubernetes — Configure Liveness, Readiness and Startup Probes — tài liệu gốc: giết container khi liveness fail so với rút khỏi Service endpoints khi readiness fail.
Ghi chú: trang Spring Boot còn liệt kê property tắt từng indicator có sẵn (management.health.<tên>.enabled).
Liên hệ các bài khác
- Bài 01 — Actuator: bề mặt vận hành — endpoint
healthtổng hợp là nền cho hai probe riêng ở bài này. - Bài 03 — Micrometer, metric và tag — sau khi probe biết pod có sống và sẵn sàng, bài tiếp theo đo hiệu năng bên trong pod bằng metric.
Tóm tắt
- Liveness hỏi "còn cứu được không", readiness hỏi "nhận request được chưa" — nhầm hai probe (thường là nhét dependency vào liveness) tạo ra restart loop.
- Boot phát hiện đang chạy trong Kubernetes qua biến môi trường service account tự động mount vào container — chỉ là một điều kiện autoconfiguration bình thường, override được bằng set
probes.enabled=truethủ công khi chạy local. - Mốc timeout hợp lý cho một health indicator gọi nội bộ cùng cluster là khoảng 300ms — không set thì
health()thừa hưởng timeout mặc định của client bên dưới, kéo theo/actuator/health/readinesstreo theo. management.endpoint.health.group.readiness.includethay thế toàn bộ danh sách mặc định — luôn giữ lạireadinessState.- Graceful shutdown chuyển
ReadinessStatesangREFUSING_TRAFFICtrước khi tiến trình đóng, để không mất request đang xử lý dở.
Tự kiểm tra
Q1Vì sao đặt kiểm tra kết nối database vào liveness probe lại nguy hiểm hơn đặt vào readiness, ngay cả khi cả hai đều phát hiện đúng là database đang có vấn đề?▸
Readiness fail chỉ rút pod khỏi load balancer — tiến trình vẫn sống, tự quay lại khi database hồi phục. Liveness fail thì Kubernetes giết và tạo container mới; giết pod không sửa được database chậm, nên pod mới cũng gọi đúng database đang chậm, cũng bị giết tiếp — thành restart loop tốn tài nguyên mà vấn đề gốc vẫn nguyên.
Q2Một health indicator tự viết gọi sang service B, và health check của B lại tự kiểm tra database riêng của B. Điều gì xảy ra khi database của B chậm, và cách sửa đúng là gì?▸
Database của B chậm khiến health check của B trả DOWN. Vì indicator của A gọi thẳng vào endpoint đó, A cũng báo DOWN theo dù chính A không hỏng gì — sự cố lan tiếp lên bất cứ ai gọi A cùng kiểu. Cách sửa: đừng gọi vào endpoint health tổng hợp của service khác, dùng route heartbeat riêng chỉ trả lời "tiến trình còn sống".
Q3Bạn set management.endpoint.health.group.readiness.include=notificationService (không có readinessState). Ứng dụng đang khởi động, nội bộ Spring Boot chưa sẵn sàng, nhưng notification-service vẫn reachable. Readiness probe báo gì, và vì sao đó là sai?▸
management.endpoint.health.group.readiness.include=notificationService (không có readinessState). Ứng dụng đang khởi động, nội bộ Spring Boot chưa sẵn sàng, nhưng notification-service vẫn reachable. Readiness probe báo gì, và vì sao đó là sai?Readiness probe báo UP, vì nhóm readiness chỉ còn chứa notificationService và indicator này pass — readinessState nội tại, thứ đáng lẽ phản ánh "ứng dụng khởi động xong chưa", đã bị loại khi set include mà không giữ lại nó. Pod bị đưa vào load balancer khi còn khởi động dở. Cách đúng: luôn liệt kê readinessState,notificationService.
Q4Giải thích cơ chế graceful shutdown theo đúng thứ tự thời gian: điều gì xảy ra với ReadinessState và với tiến trình khi ứng dụng nhận tín hiệu tắt?▸
Đầu tiên ReadinessState chuyển sang REFUSING_TRAFFIC — readiness probe fail ngay, Kubernetes rút pod khỏi load balancer, nhưng tiến trình vẫn chạy. Request đang xử lý dở tiếp tục cho tới khi xong hoặc hết timeout-per-shutdown-phase, rồi tiến trình mới thật sự đóng. Liveness không liên quan — tiến trình vẫn được coi là sống tới lúc đóng thật, nên Kubernetes không giết pod sớm hơn cần thiết.
Q5Đoạn code sau vẫn compile và chạy được, nhưng vi phạm nguyên tắc health indicator ở bài này. Vấn đề là gì?public boolean ping() {
return restClient.get()
.uri("http://notification-service/heartbeat")
.retrieve()
.toEntity(Void.class)
.getStatusCode()
.is2xxSuccessful();
}
▸
public boolean ping() {
return restClient.get()
.uri("http://notification-service/heartbeat")
.retrieve()
.toEntity(Void.class)
.getStatusCode()
.is2xxSuccessful();
}Method không truyền timeout — nó thừa hưởng timeout mặc định của client bên dưới, thường dài hơn nhiều chu kỳ probe. Nếu notification-service treo, lời gọi đứng chờ hàng chục giây, kéo theo response /actuator/health/readiness của TaskFlow bị treo. Cần truyền một Duration timeout ngắn (ví dụ 300ms) ở tầng HTTP client.
Bài tiếp theo: Micrometer, metric và tag
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên