Docker Compose healthcheck เขียนอย่างไรให้พร้อมใช้งาน
เข้าใจการประเมิน healthcheck ของ Docker Compose เหตุผลที่ depends_on ไม่ได้รอความพร้อม และตัวอย่าง readiness check สำหรับ Postgres กับแอป พร้อมข้อผิดพลาดที่พบบ่อย
Docker Compose healthcheck ทำงานอย่างไรจริง
Docker Compose healthcheck คือคำสั่งหนึ่งรายการที่ Docker เรียกใช้ภายในคอนเทนเนอร์ตามช่วงเวลาที่กำหนด Docker จะไม่อ่าน log ไม่ตรวจสอบ port และไม่ตรวจสอบรายการ process แต่จะเรียกใช้คำสั่ง อ่าน exit code และจัดเก็บสถานะเดียวไว้บนคอนเทนเนอร์ ได้แก่ starting, healthy หรือ unhealthy ค่า exit code 0 หมายถึงสถานะ healthy ค่า exit code อื่นหมายถึงสถานะ unhealthy และ Docker สงวน exit code 2 ไว้ ดังนั้นอย่าส่งค่าดังกล่าวโดยตั้งใจ
นี่คือกลไกทั้งหมด ปัญหา healthcheck เกือบทั้งหมดมีสาเหตุเดียวกัน คือคำสั่งที่เขียนขึ้นตอบคำถามคนละข้อกับคำถามที่ต้องการตรวจสอบ คู่มือนี้ถือว่าคุณทราบวิธี เขียน compose file บน VPS แล้ว และเริ่มอธิบายต่อจากจุดที่ stack เริ่มทำงานผิดลำดับ
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: 30sค่า test มีรูปแบบที่ใช้งานได้ 2 แบบ รายการที่เริ่มด้วย CMD จะเรียกใช้คำสั่งโดยตรงโดยไม่ผ่าน shell ดังนั้น pipe, && และการขยายค่าตัวแปรจะไม่ทำงาน รายการที่เริ่มด้วย CMD-SHELL จะส่งส่วนที่เหลือเป็นสตริงเดียวให้กับ /bin/sh -c ภายในคอนเทนเนอร์ ซึ่งเป็นรูปแบบที่ต้องใช้เมื่อการตรวจสอบต้องอาศัยไวยากรณ์ของ shell สตริงทั่วไปจะถูกตีความเป็น CMD-SHELL รายการที่มี ["NONE"] เพียงรายการเดียวจะลบ healthcheck ที่ image กำหนดไว้ใน Dockerfile
การตรวจสอบจะทำงานภายในคอนเทนเนอร์ ดังนั้น binary ทุกตัวที่ระบุในคำสั่งต้องมีอยู่ใน image นั้น ตรวจสอบเรื่องนี้ก่อน เพราะ image ขนาดเล็กที่ไม่มี curl จะทำให้คอนเทนเนอร์มีสถานะ unhealthy อย่างถาวร โดยสาเหตุจะไม่ปรากฏใน application log ทดสอบด้วยตนเองดังนี้:
docker compose exec api curl --versionหากไม่มี binary ระบบจะแสดง OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown โดยทั่วไป image ที่ใช้ Alpine จะมี BusyBox wget มาให้แทน ดังนั้นคำสั่งตรวจสอบจะเป็น ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"]
, และ ทำงานร่วมกันอย่างไร
การตั้งค่า 5 รายการจะควบคุมเวลา ค่าเริ่มต้นมาจาก Docker Engine ไม่ใช่ Compose
interval: ระยะเวลาระหว่างการตรวจสอบ 2 ครั้ง หลังจากคอนเทนเนอร์พ้นช่วงเริ่มต้นแล้ว ค่าเริ่มต้นคือ 30stimeout: ระยะเวลาสูงสุดที่การตรวจสอบ 1 ครั้งใช้ได้ ก่อนที่ Docker จะยุติการตรวจสอบและนับครั้งนั้นเป็นความล้มเหลว ค่าเริ่มต้นคือ 30sretries: จำนวนครั้งที่ล้มเหลวติดต่อกันก่อนที่สถานะจะเปลี่ยนเป็นunhealthyค่าเริ่มต้นคือ 3start_period: ช่วงเวลาเผื่อหลังจากคอนเทนเนอร์เริ่มทำงาน ค่าเริ่มต้นคือ 0sstart_interval: ความถี่ที่การตรวจสอบทำงานระหว่างช่วงเริ่มต้น ค่าเริ่มต้นคือ 5s และต้องใช้ Docker Engine 25.0 หรือใหม่กว่า
กฎสำคัญคือ ระหว่างช่วงเริ่มต้น การตรวจสอบที่ล้มเหลวจะ ไม่ นับรวมใน retries และคอนเทนเนอร์จะยังคงอยู่ในสถานะ starting เมื่อการตรวจสอบสำเร็จเป็นครั้งแรก คอนเทนเนอร์จะเปลี่ยนเป็น healthy และช่วงเริ่มต้นจะสิ้นสุดทันที แม้เวลาส่วนใหญ่ของช่วงดังกล่าวจะยังไม่ได้ใช้ หากช่วงเริ่มต้นสิ้นสุดลงขณะที่การตรวจสอบยังล้มเหลว การนับตามปกติจะเริ่มขึ้น และคอนเทนเนอร์ต้องล้มเหลวติดต่อกัน retries ครั้งก่อนที่จะถูกกำหนดเป็น unhealthy
ดังนั้น เวลาสูงสุดนับจากคอนเทนเนอร์เริ่มทำงานจนถึง unhealthy คือ start_period บวกกับ retries คูณด้วย interval แล้วบวก timeout เมื่อใช้ค่าจากไฟล์ข้างต้น จะเท่ากับ 30 บวก 5 คูณ 13 ซึ่งเท่ากับ 95 วินาที จดตัวเลขนี้ไว้ก่อนกำหนด timeout สำหรับ deploy เพราะ rollout ที่ยุติหลังจาก 60 วินาทีจะไม่มีทางเห็นคอนเทนเนอร์นี้เข้าสู่สถานะสุดท้ายได้
ข้อผิดพลาดที่พบบ่อยคือเพิ่มค่า retries เพื่อรองรับการเริ่มทำงานที่ช้า วิธีนี้ใช้ได้ในครั้งแรก แต่จะส่งผลเสียตลอดไป: service ที่ต้องใช้ retries 8 ครั้งจึงเริ่มทำงานได้ จะยอมให้เกิดความล้มเหลวติดต่อกัน 8 ครั้งใน production ก่อนที่ระบบจะตรวจพบ ใช้ start_period แทน เพราะค่านี้มีผลเฉพาะก่อนการตรวจสอบจะสำเร็จเป็นครั้งแรก
เหตุใด depends_on เพียงอย่างเดียวจึงไม่รับประกันอะไร
รูปแบบย่อของ depends_on เป็นสาเหตุหลักของความสับสนส่วนใหญ่
api:
depends_on:
- dbความหมายมีเพียงอย่างเดียว: ให้เริ่มคอนเทนเนอร์ db ก่อนคอนเทนเนอร์ api Compose จะรอให้สร้างและเริ่มคอนเทนเนอร์แล้ว แต่จะไม่รอให้ PostgreSQL เริ่มต้นระบบครั้งแรกเสร็จ และจะไม่รอให้พอร์ต 5432 ยอมรับการเชื่อมต่อ แอปพลิเคชันของคุณเริ่มทำงานหลังจากนั้นประมาณ 1 วินาที เชื่อมต่อไปยังพอร์ตที่ยังไม่มีโปรแกรมใดกำลังรอรับการเชื่อมต่อ แล้วจบการทำงาน ใน log คุณจะเห็น Connection refused หรือ FATAL: the database system is starting up เมื่อเซิร์ฟเวอร์ทำงานแล้วแต่ยังอยู่ระหว่างการกู้คืน
รูปแบบเต็มคือสิ่งที่ผู้ใช้ต้องการจริง:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition มี 3 ค่า service_started มีความหมายเดียวกับรูปแบบย่อ service_healthy จะหน่วงการเริ่มบริการที่ขึ้นต่อกันไว้จนกว่าบริการที่เป็น dependency จะรายงานสถานะ healthy ซึ่งจะมีความหมายก็ต่อเมื่อ dependency นั้นกำหนด healthcheck ไว้ในไฟล์ compose หรือใน image ของบริการนั้น service_completed_successfully จะรอคอนเทนเนอร์แบบทำงานครั้งเดียว เช่น การย้ายฐานข้อมูล จนกว่าจะจบการทำงานด้วยสถานะ 0
มีฟิลด์เพิ่มเติมอีก 2 รายการอยู่ถัดจาก condition restart: true จะสั่งให้ Compose เริ่มบริการนี้ใหม่หลังจากอัปเดตบริการที่เป็น dependency แล้ว required: false จะเปลี่ยน dependency ที่หายไปจากข้อผิดพลาดให้เป็นคำเตือน
ขณะนี้มาถึงข้อจำกัดที่ทำให้หลายคนเข้าใจผิด เงื่อนไขเหล่านี้จะถูกประเมินเมื่อเริ่ม stack เงื่อนไขเหล่านี้กำหนดลำดับการเริ่มต้น ไม่ใช่กฎสำหรับควบคุมดูแล หากฐานข้อมูลเริ่มต้นใหม่ตอน 3 ในตอนเช้า จะไม่มีการประเมิน service_healthy ใหม่ และจะไม่มีการเริ่มแอปพลิเคชันของคุณใหม่เพื่อให้เป็นไปตามเงื่อนไขนั้นอีกครั้ง โค้ดแอปพลิเคชันยังคงต้องเชื่อมต่อใหม่ด้วยตนเอง docker compose up --no-deps api จะข้ามกลไกทั้งหมดนี้โดยออกแบบไว้แล้ว และการเริ่มคอนเทนเนอร์โดยตรงด้วย docker start ก็เช่นกัน
เขียนการตรวจสอบที่ทดสอบความพร้อม ไม่ใช่เพียงตรวจว่ามีโปรเซสอยู่
การตรวจสอบเช่น pgrep nginx ยืนยันเพียงว่ามีรายการของโปรเซสอยู่ในตารางโปรเซส ไม่ได้ยืนยันว่า service สามารถตอบสนองคำขอได้ แอปพลิเคชันเว็บอาจเปิด socket ที่กำลังรับฟังไว้ต่อไป แม้ connection pool ของฐานข้อมูลจะหยุดทำงานแล้ว และการตรวจสอบโปรเซสก็ยังแสดงผลผ่านตลอดช่วงที่ระบบหยุดให้บริการ
ให้ container ทำงานที่มีหน้าที่ต้องทำ:
- สำหรับ HTTP service ให้เรียก endpoint จริง
curl -fsSจะออกด้วยสถานะที่ไม่ใช่ 0 เมื่อได้รับ status ตั้งแต่ 400 ขึ้นไป เนื่องจาก-fดังนั้น status 500 จากแอปที่ทำงานผิดพลาดจึงทำให้การตรวจสอบล้มเหลว - สำหรับ PostgreSQL ให้ใช้
pg_isreadyซึ่งออกด้วยค่า 0 เมื่อ server ยอมรับการเชื่อมต่อ ค่า 1 เมื่อ server ปฏิเสธการเชื่อมต่อ ค่า 2 เมื่อ server ไม่ตอบสนองเลย และค่า 3 เมื่อพารามิเตอร์ที่ส่งไม่ถูกต้อง - สำหรับ Redis ให้ใช้
redis-cli pingซึ่งพิมพ์PONGและออกด้วยค่า 0 - สำหรับ MariaDB อิมเมจอย่างเป็นทางการมีสคริปต์
healthcheck.shและhealthcheck.sh --connect --innodb_initializedคือรูปแบบที่ผู้ดูแลโครงการระบุไว้ในเอกสาร
pg_isready มีข้อควรระวังหนึ่งประการ ในการเริ่มต้นครั้งแรกที่ data directory ว่าง อิมเมจ postgres อย่างเป็นทางการจะดำเนินการเริ่มต้นระบบกับ server ชั่วคราวที่รับฟังเฉพาะ Unix socket เท่านั้น pg_isready ที่ไม่มีอาร์กิวเมนต์ host จะใช้ socket นี้ จึงอาจตอบว่า "กำลังยอมรับการเชื่อมต่อ" ทั้งที่ TCP port 5432 ยังปิดอยู่สำหรับแอปพลิเคชันของคุณ ให้ระบุ TCP ในการตรวจสอบอย่างชัดเจน ปัญหานี้จะหมดไป เพราะ server ชั่วคราวไม่ตอบสนองผ่าน 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: 30sเครื่องหมายดอลลาร์ที่เขียนซ้ำสองครั้งไม่ใช่การพิมพ์ผิด Compose จะขยาย $VAR เองขณะอ่านไฟล์ ซึ่งจะฝังค่าสภาพแวดล้อมจาก host ของคุณลงในการตรวจสอบ $$ จะ escape ค่าให้เหลือ $ เดียว เพื่อให้ shell ภายใน container ขยายค่าจากสภาพแวดล้อมของ container เอง
PostgreSQL และสแตกแอปพลิเคชันที่เริ่มทำงานตามลำดับที่ถูกต้อง
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:เริ่มระบบและตรวจสอบการเปลี่ยนแปลงของสถานะ:
docker compose up -d
docker compose psคอลัมน์ STATUS แสดงสถานะสุขภาพในวงเล็บ สแตกที่พร้อมใช้งานจะแสดง Up 41 seconds (healthy) ในทั้ง 2 แถว ขณะที่ฐานข้อมูลยังอยู่ระหว่างการเริ่มต้น db จะแสดง Up 4 seconds (health: starting) และไม่มี api ในรายการ เนื่องจาก Compose ยังไม่ได้สร้างรายการดังกล่าว
หากต้องการดูว่าเหตุใดการตรวจสอบจึงผ่านหรือล้มเหลว ให้อ่าน health log:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker จะเก็บผลลัพธ์ล่าสุดไว้ 2-3 รายการ โดยแต่ละรายการมีเวลาเริ่มต้น เวลาเสร็จสิ้น ExitCode และผลลัพธ์ของคำสั่งใน Output ระบบจะตัดทอนเอาต์พุตที่จัดเก็บไว้ ดังนั้นการตรวจสอบที่แสดงเนื้อหาหน้าเว็บจำนวนมากจะสร้างรายการ log ที่ใช้ประโยชน์ไม่ได้ ควรให้การตรวจสอบแสดงผลให้น้อยที่สุด
สิ่งที่ Docker ทำเมื่อคอนเทนเนอร์มีสถานะไม่พร้อมใช้งาน
ไม่มีอะไรเกิดขึ้น นี่คือคำตอบที่ทำให้คนส่วนใหญ่ประหลาดใจมากที่สุด
Docker Engine บนโฮสต์เดียวจะไม่เริ่มคอนเทนเนอร์ที่มีสถานะไม่พร้อมใช้งานใหม่ นโยบาย restart: unless-stopped จะตอบสนองเมื่อโพรเซสหลักสิ้นสุดลง แต่คอนเทนเนอร์ที่มีสถานะไม่พร้อมใช้งานยังไม่ได้สิ้นสุดการทำงาน คอนเทนเนอร์อาจค้างอยู่ที่ unhealthy เป็นเวลาหนึ่งสัปดาห์ โดยที่ Compose ไม่ดำเนินการใดกับคอนเทนเนอร์นั้น โหมด Swarm จะแทนที่ task ที่มีสถานะไม่พร้อมใช้งาน แต่สแต็ก Compose แบบปกติบนเซิร์ฟเวอร์เครื่องเดียวจะไม่ทำเช่นนั้น
จึงมีทางเลือกที่เหมาะสมอยู่ 2 ทาง ให้โพรเซสสิ้นสุดการทำงานเมื่อโพรเซสรู้ว่าตนเองทำงานผิดพลาด เพื่อให้นโยบายการเริ่มใหม่มีเหตุให้ทำงาน หรือเฝ้าดูสถานะจากภายนอกและแจ้งเตือนเมื่อพบสถานะดังกล่าว การกำหนด มอนิเตอร์ Uptime Kuma ให้ตรวจสอบ endpoint เดียวกับที่ healthcheck เรียกใช้ จะทำให้ dependency ที่ขัดข้องแสดงขึ้นในทั้งสองส่วน และคุณจะได้รับแจ้งจากมอนิเตอร์แทนที่จะได้รับแจ้งจากผู้ใช้ หากทราฟฟิกไปยังแอปผ่าน reverse proxy Traefik โปรดจำไว้ว่ามุมมองของ proxy ที่มีต่อ backend แยกจากสถานะ health ของ Docker ดังนั้นการตรวจสอบอย่างหนึ่งจึงไม่ครอบคลุมอีกอย่างหนึ่ง
การแก้ไขการตรวจสอบที่ไม่เคยมีสถานะ healthy
เรียกใช้คำสั่งที่เหมือนกันทุกประการด้วยตนเองใน container เดียวกัน แล้วตรวจสอบ exit code:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 ขณะที่ container ยังคงรายงานสถานะ unhealthy แสดงว่า compose test ของคุณแตกต่างจากที่เพิ่งพิมพ์ โดยทั่วไปเกิดจากใช้ CMD ในจุดที่ต้องใช้ไวยากรณ์ของ shell
ข้อผิดพลาดที่เหลือส่วนใหญ่เกิดจาก 2 กรณี กรณีแรกคือใช้ port ไม่ถูกต้อง healthcheck ทำงานภายใน container จึงต้องใช้ port ของ container และห้ามใช้ port ของ host ที่เผยแพร่ไว้ เมื่อใช้ ports: - "8080:3000" แอปพลิเคชันจะรับการเชื่อมต่อที่ port 3000 และการตรวจสอบที่ http://localhost:8080 จะล้มเหลวตลอด แม้เว็บไซต์จะทำงานได้ตามปกติในเบราว์เซอร์ กรณีที่สองคือใช้ host ไม่ถูกต้อง ภายในการตรวจสอบ localhost หมายถึง container เดียวกัน ซึ่งเหมาะสำหรับตรวจสอบ container นั้นเอง แต่ไม่เหมาะสำหรับตรวจสอบ container อื่น หากต้องตรวจสอบ container อื่น ให้ใช้ชื่อ service เช่น db
ยังมีอีกกรณีหนึ่งที่ควรระบุแยกต่างหาก: healthcheck ผ่าน แต่ผู้ใช้ยังพบข้อผิดพลาด กรณีนี้เกิดขึ้นเมื่อ endpoint ส่งค่า 200 แบบคงที่โดยไม่ได้ตรวจสอบการทำงานจริงใด ๆ readiness endpoint ที่ไม่เคย query ฐานข้อมูลจะไม่สามารถบอกได้ว่าฐานข้อมูลหยุดทำงานแล้ว ให้ endpoint เรียกใช้ query จริงที่มีค่าใช้จ่ายต่ำ 1 รายการ
FAQ
เหตุใดแอปของฉันจึงยังเชื่อมต่อไม่สำเร็จ ทั้งที่ depends_on ระบุว่าฐานข้อมูลพร้อมใช้งานแล้ว
เนื่องจาก condition: service_healthy จะถูกประเมินเพียงครั้งเดียวเมื่อเริ่มต้น stack หลังจากนั้นจะไม่ตรวจสอบต่อ หาก container ของฐานข้อมูลเริ่มต้นใหม่ในภายหลัง Compose จะไม่เริ่มแอปพลิเคชันของคุณใหม่เพื่อทำตามเงื่อนไขอีกครั้ง ดังนั้นโค้ดของแอปพลิเคชันต้องมีตรรกะสำหรับการเชื่อมต่อใหม่และการลองซ้ำของตนเอง เงื่อนไขนี้ไม่มีผลเมื่อคุณเริ่มต้น container เดียวด้วย docker start หรือ docker compose up --no-deps
จำเป็นต้องมี healthcheck หรือไม่ หาก image กำหนด healthcheck ไว้อยู่แล้ว
โดยทั่วไปไม่จำเป็น และการเขียนทับมักทำให้การทำงานแย่ลง เนื่องจากผู้ดูแล image ทราบว่า readiness ของซอฟต์แวร์นั้นหมายถึงอะไร ให้เพิ่ม healthcheck ของคุณเองเฉพาะเมื่อ healthcheck ของ image ไม่เหมาะกับการตั้งค่าของคุณ เช่น ตรวจสอบ port ที่คุณเปลี่ยนไปแล้ว หากต้องการปิด healthcheck ของ image ให้กำหนด test: ["NONE"] หรือ disable: true ใน service
healthcheck ควรใช้ curl หรือ wget
ให้ใช้ตัวที่มีอยู่แล้วใน image และตรวจสอบด้วย docker compose exec <service> curl --version ก่อนนำไปใช้งาน หลาย image ที่ใช้ Debian ไม่มีทั้งสองตัว ส่วน image ที่ใช้ Alpine มี BusyBox wget อย่าเพิ่ม package ลงใน image เพียงเพื่อเรียกใช้ healthcheck หากซอฟต์แวร์มี client ของตนเองอยู่แล้ว เช่น pg_isready หรือ redis-cli
container ที่มีสถานะ unhealthy จะถูกเริ่มต้นใหม่โดยอัตโนมัติหรือไม่
Docker Engine จะไม่ทำเช่นนั้นบน host เดียว นโยบายการเริ่มต้นใหม่จะตอบสนองเมื่อ process สิ้นสุดการทำงาน ไม่ใช่เมื่อสถานะ health เปลี่ยนเป็น unhealthy ดังนั้น container ที่มีสถานะ unhealthy จะยังทำงานอยู่และยังขัดข้อง จนกว่าจะมีสิ่งอื่นดำเนินการ ให้ process สิ้นสุดการทำงานเมื่อพบข้อผิดพลาด หรือใช้ monitor ภายนอกเพื่อแจ้งเตือนเมื่อสถานะเปลี่ยนแปลง
start_period ควรมีระยะเวลาเท่าใด
ควรนานพอสำหรับการเริ่มต้นครั้งแรกที่ถูกต้องและใช้เวลานานที่สุดตามที่คุณวัดได้ แล้วเผื่อเวลาเพิ่มเติมอีกเล็กน้อย ให้จับเวลาด้วย docker compose up โดยใช้ volume ว่าง เนื่องจากการเริ่มต้นฐานข้อมูลครั้งแรกช้ากว่าการเริ่มต้นครั้งต่อ ๆ ไปมาก หาก start period นานเกินไป จะเพียงทำให้ผลการตัดสิน unhealthy ครั้งแรกล่าช้าเท่านั้น แต่หากกำหนดจำนวนครั้งที่ลองซ้ำสูงเกินไป จะทำให้การตรวจสอบอ่อนลงตลอดอายุการทำงานของ container ซึ่งเป็นความล้มเหลวที่ร้ายแรงกว่า