Docker Compose healthcheck đúng cho Postgres và app
Hiểu Docker Compose đánh giá healthcheck, vì sao depends_on không chờ app sẵn sàng, và cách viết readiness check đúng cho Postgres cùng ứng dụng.
Healthcheck của Docker Compose thực sự làm gì
Healthcheck của Docker Compose là một command mà Docker chạy bên trong container theo chu kỳ. Docker không đọc log, không monitor port và không kiểm tra danh sách process của bạn. Docker chạy command, đọc exit code rồi lưu một trạng thái duy nhất trên container: starting, healthy hoặc unhealthy. Exit code 0 có nghĩa là healthy. Mọi exit code khác đều có nghĩa là unhealthy, còn exit code 2 được Docker dành riêng, vì vậy không bao giờ cố ý trả về mã này.
Đó là toàn bộ cơ chế. Gần như mọi vấn đề với healthcheck đều xuất phát từ cùng một nguyên nhân: command bạn viết trả lời một câu hỏi khác với câu hỏi bạn định kiểm tra. Hướng dẫn này giả định bạn đã biết cách viết file compose trên VPS, và bắt đầu từ thời điểm stack khởi động sai thứ tự.
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30sGiá trị test có hai dạng hữu ích. Một list bắt đầu bằng CMD sẽ chạy command trực tiếp, không qua shell, nên pipe, && và việc mở rộng biến sẽ không hoạt động. Một list bắt đầu bằng CMD-SHELL sẽ truyền phần còn lại dưới dạng một string cho /bin/sh -c bên trong container. Đây là dạng cần dùng khi check yêu cầu cú pháp shell. Một string thuần được xử lý như CMD-SHELL. Một list chỉ gồm ["NONE"] sẽ xóa healthcheck mà image đã khai báo trong Dockerfile.
Check chạy bên trong container, nên mọi binary được gọi trong đó phải tồn tại trong image. Hãy kiểm tra trước, vì một image tối giản không có curl sẽ tạo ra container luôn ở trạng thái unhealthy, với nguyên nhân không bao giờ xuất hiện trong application log. Chạy thử thủ công:
docker compose exec api curl --versionBinary không tồn tại sẽ trả về OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Các image dựa trên Alpine thường có BusyBox wget, vì vậy check sẽ trở thành ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Cách interval, retries và start_period kết hợp
Có 5 tùy chọn kiểm soát thời gian. Giá trị mặc định của chúng do Docker Engine cung cấp, không phải Compose.
interval: khoảng thời gian giữa 2 lần kiểm tra sau khi container kết thúcstart_period. Mặc định là 30s.timeout: thời gian tối đa cho một lần chạy kiểm tra trước khi Docker dừng lần chạy đó và tính là một lần thất bại. Mặc định là 30s.retries: số lần thất bại liên tiếp cần có trước khi trạng thái chuyển sangunhealthy. Mặc định là 3.start_period: khoảng thời gian ân hạn sau khi container khởi động. Mặc định là 0s.start_interval: tần suất chạy kiểm tra trongstart_period. Mặc định là 5s và cần Docker Engine 25.0 trở lên.
Quy tắc quan trọng là: trong start_period, một lần kiểm tra thất bại không được tính vào retries và container vẫn ở trạng thái starting. Ngay khi lần kiểm tra đầu tiên thành công, container chuyển sang healthy và start_period kết thúc ngay, kể cả khi phần lớn thời gian vẫn chưa được sử dụng. Nếu start_period kết thúc trong khi lần kiểm tra vẫn thất bại, bộ đếm thông thường bắt đầu chạy và container cần retries lần thất bại liên tiếp trước khi được đánh dấu unhealthy.
Vì vậy, thời gian tối đa từ lúc container khởi động đến khi chuyển sang unhealthy là start_period cộng với retries nhân interval rồi cộng timeout. Với các giá trị trong file ở trên, đó là 30 cộng 5 nhân 13, bằng 95 giây. Hãy ghi lại con số này trước khi đặt timeout cho lần deploy, vì một rollout dừng sau 60 giây sẽ không bao giờ thấy container này đạt trạng thái cuối cùng.
Sai lầm phổ biến là tăng retries để bao quát thời gian khởi động chậm. Cách này có tác dụng một lần rồi gây hại về sau: một service cần 8 lần retry để khởi động sẽ chịu được 8 lần thất bại liên tiếp trong môi trường production trước khi có bất kỳ cảnh báo nào. Thay vào đó, hãy dùng start_period vì tùy chọn này chỉ áp dụng trước lần kiểm tra thành công đầu tiên.
Vì sao chỉ dùng depends_on không đảm bảo gì
Dạng ngắn của depends_on là nguyên nhân gây ra phần lớn nhầm lẫn.
api:
depends_on:
- dbĐiều này chỉ có một ý nghĩa: khởi động container db trước container api. Compose chờ container được tạo và khởi động. Nó không chờ PostgreSQL hoàn tất quá trình khởi tạo lần đầu và cũng không chờ cổng 5432 chấp nhận kết nối. Ứng dụng của bạn khởi động muộn hơn khoảng một giây, kết nối đến một cổng chưa có tiến trình nào lắng nghe rồi thoát. Trong log, bạn sẽ thấy Connection refused hoặc FATAL: the database system is starting up khi server đã hoạt động nhưng vẫn đang khôi phục.
Dạng đầy đủ mới là thứ mọi người thực sự cần:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition có ba giá trị. service_started giống với dạng ngắn. service_healthy giữ service phụ thuộc ở trạng thái chờ cho đến khi dependency báo healthy. Điều này chỉ có ý nghĩa khi dependency đó định nghĩa một healthcheck trong compose file hoặc trong image của nó. service_completed_successfully chờ container chạy một lần, chẳng hạn container thực hiện database migration, thoát với status 0.
Có thêm hai field nằm cạnh condition. restart: true yêu cầu Compose restart service này sau khi cập nhật dependency service. required: false chuyển dependency bị thiếu từ lỗi thành cảnh báo.
Bây giờ là giới hạn thường khiến mọi người gặp vấn đề. Các điều kiện này được đánh giá khi stack khởi động. Chúng chỉ quy định thứ tự khởi động, không phải rule giám sát. Nếu database restart lúc 3 giờ sáng, không có gì đánh giá lại service_healthy và cũng không có gì restart app để đáp ứng điều kiện đó lần nữa. Code của ứng dụng vẫn phải tự reconnect. docker compose up --no-deps api cố ý bỏ qua toàn bộ cơ chế này. Việc khởi động container trực tiếp bằng docker start cũng vậy.
Viết check để kiểm tra trạng thái sẵn sàng, không chỉ kiểm tra tiến trình có tồn tại
Check như pgrep nginx chỉ chứng minh rằng có một entry trong bảng tiến trình. Nó không chứng minh service có thể trả lời request. Một web application có thể giữ socket listening mở rất lâu sau khi database pool đã dừng, trong khi process check vẫn báo đạt trong suốt thời gian outage.
Hãy yêu cầu container thực hiện đúng công việc của nó:
- Với HTTP service, hãy request một endpoint thực tế.
curl -fsSexit với mã khác 0 khi status là 400 trở lên do-f, vì vậy status 500 từ app bị lỗi sẽ làm check fail. - Với PostgreSQL, dùng
pg_isready. Lệnh này exit 0 khi server đang chấp nhận connection, exit 1 khi server từ chối connection, exit 2 khi server hoàn toàn không phản hồi và exit 3 khi các parameter đã truyền không đúng. - Với Redis, dùng
redis-cli ping. Lệnh này inPONGvà exit 0. - Với MariaDB, official image có sẵn script
healthcheck.sh, vàhealthcheck.sh --connect --innodb_initializedlà dạng mà maintainer của image này tài liệu hóa.
pg_isready có một điểm dễ gây nhầm lẫn. Trong lần start đầu tiên với data directory trống, image postgres chính thức chạy quá trình khởi tạo trên một server tạm thời chỉ listen trên Unix socket. pg_isready không có host argument sẽ dùng socket đó, nên có thể trả lời “accepting connections” trong khi TCP port 5432 vẫn đóng với application của bạn. Hãy chỉ định rõ TCP cho check để tránh vấn đề này, vì server tạm thời không trả lời trên TCP.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30sHai dấu dollar liên tiếp không phải lỗi đánh máy. Compose tự expand $VAR khi đọc file, việc này sẽ ghi cố định giá trị từ host environment vào check. $$ escape nó thành một $ duy nhất, để shell bên trong container expand nó theo environment của chính container đó.
Một stack postgres và app khởi động theo đúng thứ tự
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:Khởi động stack và theo dõi trạng thái thay đổi:
docker compose up -d
docker compose psCột STATUS hiển thị trạng thái health trong ngoặc vuông. Khi cả hai thành phần đều healthy, cả hai dòng đều hiển thị Up 41 seconds (healthy). Trong lúc database vẫn đang khởi tạo, db hiển thị Up 4 seconds (health: starting) và api chưa xuất hiện trong danh sách, vì Compose chưa tạo container đó.
Để biết một lần kiểm tra thành công hay thất bại vì sao, hãy xem health log:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker lưu lại một số kết quả gần nhất. Mỗi kết quả có thời gian bắt đầu, thời gian kết thúc, một ExitCode và Output của command. Output được lưu sẽ bị cắt bớt, nên một check in ra toàn bộ nội dung trang lớn sẽ tạo ra log entry không hữu ích. Hãy giữ cho các check không in output không cần thiết.
Docker xử lý thế nào khi một container chuyển sang trạng thái không khỏe
Không làm gì cả. Đây là điều khiến nhiều người bất ngờ nhất.
Docker Engine chạy trên một host đơn lẻ không restart container không khỏe. Chính sách restart: unless-stopped phản ứng khi tiến trình chính thoát, còn container không khỏe thì chưa thoát. Container có thể nằm ở trạng thái unhealthy suốt một tuần mà Compose vẫn không xử lý. Swarm mode thay thế các task không khỏe, nhưng một stack Compose thông thường trên một server không làm vậy.
Có hai lựa chọn thực tế. Cho tiến trình tự thoát khi phát hiện nó đã hỏng, để restart policy có điều kiện hoạt động. Hoặc theo dõi trạng thái từ bên ngoài và cảnh báo khi trạng thái đó xảy ra. Trỏ một monitor Uptime Kuma đến cùng endpoint mà healthcheck gọi sẽ giúp dependency bị hỏng xuất hiện ở cả hai nơi, đồng thời bạn nhận được cảnh báo từ monitor thay vì từ người dùng. Nếu traffic đến app thông qua một reverse proxy Traefik, hãy nhớ rằng trạng thái của backend theo góc nhìn của proxy là độc lập với trạng thái health của Docker. Vì vậy, một bên không thay thế được bên còn lại.
Gỡ lỗi khi check không bao giờ chuyển sang trạng thái healthy
Tự chạy đúng command trong cùng container, rồi kiểm tra exit code:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 tại đây trong khi container vẫn báo unhealthy nghĩa là test của bạn khác với nội dung vừa nhập, thường do đã dùng CMD ở nơi cần cú pháp shell.
Hai lỗi còn lại chiếm phần lớn các trường hợp khác. Lỗi đầu tiên là dùng sai port. Healthcheck chạy bên trong container, nên phải dùng port của container, không được dùng port đã publish trên host. Với ports: - "8080:3000", ứng dụng lắng nghe trên port 3000, nên check tới http://localhost:8080 sẽ fail mãi dù website vẫn hoạt động bình thường trong trình duyệt. Lỗi thứ hai là dùng sai host. Bên trong check, localhost là chính container đó, phù hợp khi kiểm tra container hiện tại nhưng không phù hợp khi kiểm tra container khác. Khi đó, bạn cần dùng service name, chẳng hạn db.
Có một trường hợp cuối cùng cần được gọi tên: healthcheck pass nhưng người dùng vẫn thấy lỗi. Điều này xảy ra khi endpoint trả về 200 tĩnh mà không thực sự kiểm tra thành phần nào. Readiness endpoint không bao giờ truy vấn database thì không thể cho biết database đã bị lỗi. Hãy để endpoint chạy một truy vấn thực tế, đơn giản và ít tốn tài nguyên.
FAQ
Vì sao app của tôi vẫn không kết nối được dù depends_on cho biết database đã healthy?
Vì condition: service_healthy chỉ được đánh giá một lần khi stack khởi động. Sau đó, nó không giám sát gì nữa. Nếu container database khởi động lại sau đó, Compose không khởi động lại app để đánh giá lại điều kiện. Vì vậy, code của app cần có logic reconnect và retry riêng. Điều kiện này cũng không có tác dụng khi bạn khởi động một container riêng lẻ bằng docker start hoặc docker compose up --no-deps.
Tôi có cần healthcheck nếu image đã định nghĩa sẵn một healthcheck không?
Thông thường là không. Việc ghi đè thường làm tình hình tệ hơn, vì maintainer của image biết phần mềm đó cần điều kiện nào để sẵn sàng. Chỉ thêm healthcheck riêng khi healthcheck của image không phù hợp với cấu hình của bạn, chẳng hạn khi nó kiểm tra một port mà bạn đã đổi. Để tắt healthcheck của image, đặt test: ["NONE"] hoặc disable: true trên service.
Healthcheck nên dùng curl hay wget?
Dùng công cụ đã có sẵn trong image và xác nhận bằng docker compose exec <service> curl --version trước khi phụ thuộc vào nó. Nhiều image dựa trên Debian không có cả hai công cụ này. Image dựa trên Alpine có wget của BusyBox. Không nên thêm package vào image chỉ để chạy healthcheck khi phần mềm đã có client riêng, chẳng hạn pg_isready hoặc redis-cli.
Container không healthy có được tự động khởi động lại không?
Docker Engine không làm vậy trên một host đơn lẻ. Restart policy phản ứng khi process thoát, không phản ứng với trạng thái health. Vì vậy, container không healthy vẫn chạy và vẫn bị lỗi cho đến khi có thành phần khác xử lý. Bạn có thể cho process thoát khi phát hiện lỗi, hoặc chạy một monitor bên ngoài để cảnh báo dựa trên trạng thái đó.
start_period nên dài bao lâu?
Dài đủ để bao phủ lần khởi động đầu tiên hợp lệ chậm nhất mà bạn đã đo, cộng thêm một khoảng đệm. Đo thời gian bằng docker compose up trên một volume trống, vì lần khởi động đầu tiên của database chậm hơn nhiều so với các lần khởi động sau. start_period quá dài chỉ trì hoãn phán định unhealthy đầu tiên. Số lần retry quá cao làm healthcheck kém hiệu quả trong toàn bộ thời gian container chạy. Đây là lỗi nghiêm trọng hơn.