Phần 49 đo docker compose up mất 2,3 giây để lên và down mất 6,4 giây để xuống, nhưng chưa hỏi một câu quan trọng hơn: khi up báo container đã "Started", ứng dụng bên trong đã thật sự nhận được request chưa? Bài này đo đúng khoảng trống đó bằng depends_on có condition: service_healthy, trên Docker Desktop 29.7.2, Docker Compose v5.4.0, macOS aarch64.
"Up" không có nghĩa "sẵn sàng"
depends_on kiểu danh sách (depends_on: [db]) chỉ đảm bảo một thứ: container db được tạo và khởi động trước container phụ thuộc nó. Nó không biết gì về việc tiến trình bên trong db đã mở cổng, đọc xong config, hay chạy xong migration. Với Postgres, MySQL, hay bất kỳ dịch vụ nào có bước khởi tạo, khoảng cách giữa "container Started" và "sẵn sàng nhận kết nối" có thể là vài giây tới vài chục giây.
Để đo chính xác khoảng cách này mà không phụ thuộc mạng để kéo image thật, tôi dựng một service giả lập: container db chạy sleep 6 rồi mới tạo file đánh dấu sẵn sàng — mô phỏng đúng dạng độ trễ khởi tạo mà một database thật cũng có.
services:
db:
image: alpine:3.20
command: sh -c "sleep 6; touch /tmp/ready; sleep 3600"
healthcheck:
test: ["CMD", "test", "-f", "/tmp/ready"]
interval: 2s
timeout: 2s
retries: 20
start_period: 2s
Với Postgres thật, test thường là ["CMD-SHELL", "pg_isready -U postgres"] — cùng nguyên lý, chỉ khác lệnh kiểm tra.
Bốn khoá của healthcheck:
| Khoá | Ý nghĩa |
|---|---|
test |
Lệnh kiểm tra; exit code 0 là qua, khác 0 là fail. CMD chạy trực tiếp (không qua shell), CMD-SHELL chạy qua /bin/sh -c |
interval |
Khoảng cách giữa hai lần kiểm tra |
timeout |
Một lần kiểm tra chạy quá thời gian này thì tính là fail |
retries |
Số lần fail liên tiếp trước khi chuyển trạng thái unhealthy |
start_period |
Khoảng "khởi động" — fail trong lúc này không tính vào retries |
Cú pháp danh sách không nhận condition
Đây là cái bẫy dễ gặp nhất: depends_on: [db] — cú pháp ngắn — chỉ diễn đạt được thứ tự khởi động, không có chỗ nào để khai condition. Muốn chờ theo trạng thái health, bắt buộc chuyển sang cú pháp dài (mapping) cho từng service:
services:
app_wait:
image: alpine:3.20
depends_on:
db:
condition: service_healthy
Ba giá trị hợp lệ cho condition: service_started (mặc định, giống cú pháp ngắn), service_healthy (chờ healthcheck qua), và service_completed_successfully (dành cho container chạy một lần rồi thoát, ví dụ container migration — chờ nó exit code 0 rồi mới chạy tiếp).
Đo thật: hai app, một chờ, một không
Dựng ba service trong cùng một file: db như trên, app_no_wait chỉ depends_on: [db], app_wait dùng condition: service_healthy. Đọc docker inspect sau khi docker compose up -d xong:
docker inspect hc-demo-db-1 --format 'StartedAt={{.State.StartedAt}} Health={{.State.Health.Status}}'
docker inspect hc-demo-app_no_wait-1 --format 'StartedAt={{.State.StartedAt}}'
docker inspect hc-demo-app_wait-1 --format 'StartedAt={{.State.StartedAt}}'
docker inspect hc-demo-db-1 --format '{{range .State.Health.Log}}{{.Start}} exit={{.ExitCode}}{{"\n"}}{{end}}'
Số đo thật, mốc thời gian tính từ lúc db bắt đầu chạy:
| Mốc | Δ so với lúc db start |
|---|---|
db StartedAt |
0,00s |
app_no_wait StartedAt |
0,05s |
Healthcheck lần 1 (fail, chưa có /tmp/ready) |
5,04s |
Healthcheck lần 2 (pass — db chuyển healthy) |
7,09s |
app_wait StartedAt |
7,57s |
app_no_wait khởi động gần như ngay lập tức — nó chỉ đợi container db được tạo, không quan tâm tiến trình bên trong đã làm gì. app_wait đợi tới khi db báo healthy, chậm hơn 7,52 giây so với app_no_wait. Ngay cả docker compose up -d — vốn chỉ tạo container ở chế độ nền — cũng phải đứng chờ đủ chuỗi phụ thuộc trước khi trả lại dấu nhắc: lệnh đo được 7,86 giây từ lúc gõ tới lúc trả về, gần khớp với mốc app_wait bắt đầu.
Đây chính là câu trả lời cho brief "đo thời gian khởi động cả hệ": có healthcheck gated depends_on, thời gian khởi động cả hệ bằng thời gian dịch vụ chậm nhất sẵn sàng, chứ không phải thời gian container cuối cùng được tạo.
Khi healthcheck không bao giờ pass
Đổi test của db thành một lệnh luôn fail (test -f /tmp/never-exists), hạ retries: 3, interval: 1s, start_period: 1s để đo nhanh, rồi chạy docker compose up -d:
Container hc-demo-db_bad-1 Error dependency db_bad failed to start
dependency failed to start: container hc-demo-db_bad-1 is unhealthy
Đo thật: lệnh trả về sau 7,77 giây với exit code 1. Container app_wait dừng lại ở trạng thái Created — không bao giờ Started. db_bad đứng ở Up 7 seconds (unhealthy). Đây là hành vi đáng giá nhất của condition: service_healthy: một dependency hỏng chặn đứng cả chuỗi khởi động, thay vì để app chạy lên rồi crash-loop vì không kết nối được db — lỗi hiện ngay ở bước up, không phải rơi vào log ứng dụng.
Health đổi sau khi đã lên thì sao
Một câu hỏi khác: nếu db đang chạy bình thường, sau đó healthcheck bắt đầu fail (ví dụ ổ đĩa đầy, deadlock), Compose có tự dừng hay khởi động lại app_wait không? Đo thật bằng cách xoá /tmp/ready trong container db đang chạy rồi đợi nó chuyển unhealthy:
docker exec hc-demo-db-1 rm -f /tmp/ready
# chờ ~40 giây (retries=20 × interval=2s)
docker compose ps
Kết quả: db chuyển hẳn sang unhealthy sau 48 giây uptime, nhưng app_wait vẫn Up, không bị dừng, không bị tạo lại. condition: service_healthy chỉ được Compose kiểm tra một lần, lúc khởi động chuỗi phụ thuộc — nó không phải cơ chế giám sát liên tục. Muốn tự động phản ứng khi dependency hỏng giữa chừng (restart, alert) thì phải tự đọc State.Health.Status, ví dụ qua một script polling hoặc công cụ giám sát bên ngoài — healthcheck của Compose không làm việc đó thay bạn.
Trả giá gì khi thêm healthcheck
Không miễn phí. Ba khoản phải trả:
- Thời gian khởi động cả hệ dài hơn, đúng bằng thời gian dependency chậm nhất qua được healthcheck — đo được ở trên là 7,52 giây thêm so với không chờ.
- CPU/IO cho lệnh kiểm tra chạy lặp lại mỗi
intervalgiây suốt vòng đời container, nêntestphải rẻ (pg_isready,test -f,curlmột endpoint nhẹ) — đừng dùng lệnh nặng như chạy lại toàn bộ migration. retries×intervallà thời gian tối đa trước khi báo lỗi khi dependency thật sự hỏng. Đặtretriesquá cao (mặc định Docker gợi ý 3, ví dụ trên dùng 20 để tránh false-positive lúc máy chậm) nghĩa là chờ lâu mới biết hỏng; đặt quá thấp thì một lần chậm bất thường (GC pause, I/O nghẽn) cũng đủ bị đánh rớt oan.
Thử ba mươi giây
Dán thẳng vào một file docker-compose.yml rỗng và chạy — không cần kéo image nào ngoài alpine, đã có sẵn trên hầu hết máy đã từng chạy Docker:
cat > /tmp/hc-test.yml <<'EOF'
services:
db:
image: alpine:3.20
command: sh -c "sleep 6; touch /tmp/ready; sleep 3600"
healthcheck:
test: ["CMD", "test", "-f", "/tmp/ready"]
interval: 2s
timeout: 2s
retries: 20
start_period: 2s
app:
image: alpine:3.20
command: sh -c "echo APP SAN SANG LUC $(date +%T); sleep 3600"
depends_on:
db:
condition: service_healthy
EOF
time docker compose -f /tmp/hc-test.yml up -d
docker compose -f /tmp/hc-test.yml logs app
docker compose -f /tmp/hc-test.yml down -v
time sẽ in ra đúng khoảng chờ trên máy bạn — con số này phụ thuộc CPU và tải máy, không nhất thiết khớp 7,52 giây đo được ở đây.