Docker Compose healthcheck cho Postgres và app
Hiểu Docker Compose đánh giá healthcheck ra sao, vì sao depends_on không chờ readiness, và cách viết lệnh kiểm tra đúng cho Postgres cùng app.
Healthcheck của Docker Compose thực sự làm gì
Healthcheck của Docker Compose là một lệnh được Docker chạy bên trong container theo chu kỳ. Docker không đọc log, theo dõi port hoặc kiểm tra danh sách process của bạn. Docker chạy lệnh, đọc exit code và 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à container healthy. Mọi exit code khác đều có nghĩa là unhealthy. Docker dành riêng exit code 2, vì vậy không bao giờ cố ý trả về mã này.
Đó là toàn bộ cơ chế. Hầu hết vấn đề với healthcheck đều xuất phát từ cùng một nguyên nhân: lệnh bạn viết trả lời một câu hỏi khác với câu hỏi bạn thực sự muốn 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à tiếp tục 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 lệnh 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 chuỗi cho /bin/sh -c bên trong container. Đây là dạng cần dùng khi healthcheck cần cú pháp shell. Một chuỗi đơn được xử lý như CMD-SHELL. Một list chỉ gồm ["NONE"] sẽ xóa healthcheck mà image đã tạo sẵn thông qua Dockerfile.
Healthcheck chạy bên trong container, vì vậy mọi binary được gọi phải tồn tại trong image đó. Hãy kiểm tra điều này trước. Một image tối giản không có curl sẽ tạo ra container luôn ở trạng thái unhealthy, trong khi nguyên nhân không bao giờ xuất hiện trong application log. Hãy chạy thử bằng tay:
docker compose exec api curl --versionNếu thiếu binary, lệnh 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 healthcheck sẽ trở thành ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
wget, ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"] và interval kết hợp như thế nào
Có năm thiết lập kiểm soát thời gian. Giá trị mặc định của chúng đến từ Docker Engine, không phải Compose.
interval: khoảng thời gian giữa hai lần kiểm tra sau khi container vượt qua thời gian khởi động. Mặc định là 30s.timeout: thời gian tối đa cho một lần 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 thànhunhealthy. 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 trong thời gian khởi động. Mặc định là 5s và yêu cầu Docker Engine 25.0 trở lên.
Quy tắc quan trọng là: trong thời gian khởi động, 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à thời gian khởi động kết thúc ngay, ngay cả khi phần lớn thời gian vẫn chưa được sử dụng. Nếu thời gian khởi động 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, và container cần retries lần thất bại liên tiếp trước khi được đánh dấu là unhealthy.
Vì vậy, thời gian tối đa từ lúc container khởi động đến khi đạt 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, kết quả 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 deploy timeout, vì một lần 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.
Lỗi phổ biến là tăng retries để bù cho thời gian khởi động chậm. Cách này chỉ hiệu quả một lần rồi gây tác động xấu về sau: một service cần 8 lần retry để khởi động sẽ cho phép 8 lần thất bại liên tiếp trong production trước khi có cảnh báo. Thay vào đó, hãy dùng start_period vì thiết lập này chỉ áp dụng trước lần thành công đầu tiên.
Vì sao tự dùng depends_on không đảm bảo điều gì
Dạng ngắn của depends_on là nguyên nhân chính gây 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 khởi tạo lần đầu, và cũng không chờ port 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 port 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 phục hồi.
Dạng đầy đủ mới là dạng 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 healthcheck, trong compose file hoặc trong image của nó. service_completed_successfully chờ một container chạy một lần, chẳng hạn container migration database, thoát với status 0.
Có thêm hai trường bên cạnh condition. restart: true yêu cầu Compose restart service này sau khi cập nhật dependency service. required: false hạ mức độ của dependency bị thiếu từ lỗi xuống cảnh báo.
Bây giờ là giới hạn thường gây 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 quy tắc giám sát. Nếu database restart lúc 3 giờ sáng, không có thành phần nào đánh giá lại service_healthy, và cũng không có thành phần nào restart app của bạn để đá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 kiểm tra để xác nhận trạng thái sẵn sàng, không chỉ kiểm tra tiến trình tồn tại
Một kiểm tra như pgrep nginx chỉ chứng minh rằng có một mục trong bảng tiến trình. Nó không chứng minh dịch vụ có thể trả lời yêu cầu. Một ứng dụng web có thể giữ socket listening mở rất lâu sau khi connection pool đến database bị lỗi, còn kiểm tra tiến trình vẫn báo đạt trong suốt thời gian gián đoạn.
Hãy yêu cầu container thực hiện đúng công việc của nó:
- Với dịch vụ HTTP, hãy gọi một endpoint thực.
curl -fsStrả về mã thoát khác 0 với mọi status từ 400 trở lên do-f, nên phản hồi 500 từ ứng dụng bị lỗi sẽ làm kiểm tra thất bại. - Với PostgreSQL, dùng
pg_isready. Lệnh này trả về 0 khi server chấp nhận kết nối, 1 khi server từ chối kết nối, 2 khi server hoàn toàn không phản hồi và 3 khi các tham số bạn truyền vào không đúng. - Với Redis, dùng
redis-cli ping. Lệnh này inPONGvà trả về mã thoát 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 hướng dẫn sử dụng.
pg_isready có một điểm cần lưu ý. Trong lần khởi động đầu tiên với data directory trống, official postgres image 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ó tham số host sẽ dùng socket đó, nên có thể trả lời “accepting connections” trong khi TCP port 5432 vẫn đóng với ứng dụng của bạn. Hãy chỉ định rõ TCP cho kiểm tra để giải quyết vấn đề này, vì server tạm thời không trả lời trên đó.
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ự mở rộng $VAR khi đọc file, khiến một giá trị từ host environment bị ghi cố định vào kiểm tra. $$ escape giá trị đó thành một $ duy nhất, để shell bên trong container mở rộng nó theo environment của chính container.
Một stack postgres và app khởi động đú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 chứa trạng thái health trong ngoặc vuông. Một cặp khỏe mạnh hiển thị Up 41 seconds (healthy) ở cả hai dòng. Khi database vẫn đang khởi tạo, db hiển thị Up 4 seconds (health: starting) và api chưa có trong danh sách vì Compose chưa tạo nó.
Để biết một lần kiểm tra thành công hay thất bại vì sao, hãy đọc health log:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker giữ lại vài 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 trữ sẽ bị cắt ngắn. Vì vậy, một lần kiểm tra in ra phần nội dung trang lớn sẽ tạo ra log entry không hữu ích. Hãy giữ cho các lần kiểm tra không tạo nhiều output.
Docker làm gì khi một container chuyển sang trạng thái không lành mạnh
Không làm gì cả. Đây là câu trả lời khiến nhiều người bất ngờ nhất.
Docker Engine trên một host duy nhất không khởi động lại container không lành mạnh. Policy restart: unless-stopped phản ứng khi process chính thoát, còn container không lành mạnh thì chưa thoát. Container có thể ở trạng thái unhealthy suốt một tuần trong khi Compose vẫn để nguyên. Swarm mode thay thế các task không lành mạnh, nhưng một stack Compose thông thường trên một server không làm vậy.
Có 2 lựa chọn thực tế. Cho process thoát khi nó biết mình đã hỏng, để restart policy có điều kiện thực hiện. Hoặc theo dõi trạng thái từ bên ngoài và cảnh báo khi trạng thái đó xuất hiện. Trỏ một monitor Uptime Kuma vào cùng endpoint mà healthcheck gọi sẽ khiến dependency bị hỏng xuất hiện ở cả 2 nơi, và bạn nhận được cảnh báo từ monitor thay vì từ người dùng. Nếu traffic đi qua một reverse proxy Traefik, hãy nhớ rằng cách proxy đánh giá backend 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 kia.
Gỡ lỗi kiểm tra không bao giờ chuyển sang trạng thái healthy
Tự chạy đúng lệnh đó trong cùng container và kiểm tra exit code:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 ở đây trong khi container vẫn báo unhealthy nghĩa là test của bạn khác với nội dung bạn vừa nhập, thường là 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. Lỗi đầu tiên là sai port. Healthcheck chạy bên trong container, nên phải dùng port của container, không bao giờ dùng port host đã publish. Với ports: - "8080:3000", ứng dụng lắng nghe trên port 3000. Nếu kiểm tra http://localhost:8080, lệnh sẽ thất bại mãi dù website vẫn hoạt động bình thường trên browser. Lỗi thứ hai là sai host. Bên trong healthcheck, localhost là chính container đó. Cách này đúng khi kiểm tra container hiện tại nhưng sai khi kiểm tra container khác. Khi đó, bạn cần dùng tên service, ví dụ db.
Có một trường hợp cuối cần gọi tên riêng: healthcheck thành công nhưng người dùng vẫn thấy lỗi. Điều này xảy ra khi endpoint trả về mã 200 cố định mà không kiểm tra thành phần thực tế nào. Một endpoint readiness không bao giờ truy vấn database sẽ không phát hiện database đã ngừng hoạt động. Hãy cho endpoint thực hiện một truy vấn thực tế đơn giản.
FAQ
Tại sao ứng dụng của tôi vẫn không kết nối được dù depends_on cho biết cơ sở dữ liệu đã khỏe mạnh?
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 cơ sở dữ liệu khởi động lại, Compose không khởi động lại ứng dụng để đáp ứng điều kiện lần nữa. Vì vậy, code ứng dụng cần tự xử lý việc kết nối lại và retry. Đ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 là không. Việc ghi đè thường làm cấu hình kém đi, 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 môi trường của bạn, chẳng hạn khi nó kiểm tra một port mà bạn đã thay đổi. Để tắt healthcheck của image, đặt test: ["NONE"] hoặc disable: true cho 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 nếu phần mềm đã có client riêng, chẳng hạn pg_isready hoặc redis-cli.
Container không khỏe có tự động được khởi động lại không?
Docker Engine không tự làm việc đó trên một host riêng lẻ. Restart policy phản ứng khi process thoát, không phải khi trạng thái health thay đổi. Vì vậy, container không khỏe vẫn tiếp tục chạy và vẫn 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 theo trạng thái.
start_period nên dài bao lâu?
Đặt đủ dài cho lần khởi động đầu tiên hợp lệ chậm nhất mà bạn đã đo được, cộng thêm một khoảng đệm. Đo thời gian bằng docker compose up với volume rỗng, vì lần khởi động đầu tiên của cơ sở dữ liệu 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 kết luận 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 mới là lỗi nghiêm trọng hơn.