בדיקות healthcheck שעובדות ב-Docker Compose
כך Docker Compose מעריך healthcheck, מדוע depends_on לבדו אינו ממתין למוכנות, ואיך לכתוב בדיקות readiness אמינות ל-Postgres ולאפליקציה.
מה בדיקת healthcheck של Docker Compose עושה בפועל
בדיקת healthcheck של Docker Compose היא פקודה אחת ש-Docker מריץ בתוך הקונטיינר לפי לוח זמנים. Docker אינו קורא את קובצי היומן, אינו מנטר את הפורט ואינו בודק את רשימת התהליכים. הוא מריץ את הפקודה, קורא את קוד היציאה ושומר בקונטיינר מצב יחיד: starting, healthy או unhealthy. קוד יציאה 0 מציין שהקונטיינר תקין. כל קוד יציאה אחר מציין שהקונטיינר אינו תקין. קוד יציאה 2 שמור ל-Docker, ולכן לעולם אין להחזיר אותו בכוונה.
זהו כל המנגנון. כמעט כל בעיה בבדיקת healthcheck נובעת מאותה סיבה: הפקודה שכתבת עונה על שאלה אחרת מזו שהתכוונת לשאול. מדריך זה מניח שכבר ידוע לך כיצד לכתוב קובץ compose ב-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 מקבל שתי צורות שימושיות. רשימה שמתחילה ב-CMD מריצה את הפקודה ישירות, ללא shell, ולכן צינורות, && והרחבת משתנים אינם פועלים. רשימה שמתחילה ב-CMD-SHELL מעבירה את שאר הרשימה כמחרוזת אחת אל /bin/sh -c בתוך הקונטיינר. בצורה זו יש להשתמש כאשר הבדיקה דורשת תחביר של shell. מחרוזת רגילה מטופלת כ-CMD-SHELL. רשימה שמכילה בדיוק ["NONE"] מסירה בדיקת healthcheck שהוגדרה מראש ב-image באמצעות Dockerfile.
הבדיקה רצה בתוך הקונטיינר, ולכן כל קובץ בינארי שהיא מפנה אליו חייב להימצא ב-image. יש לוודא זאת תחילה, משום ש-image מצומצם ללא curl יוצר קונטיינר שמסומן באופן קבוע כלא תקין, מסיבה שאינה מופיעה כלל ביומן היישום. בדוק זאת ידנית:
docker compose exec api curl --versionקובץ בינארי חסר מחזיר OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Images המבוססים על Alpine כוללים בדרך כלל את wget של BusyBox במקום זאת, ולכן הבדיקה הופכת ל-["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
כיצד interval, retries ו-start_period משתלבים
חמש הגדרות קובעות את התזמון. ערכי ברירת המחדל שלהן מגיעים מ-Docker Engine, ולא מ-Compose.
interval: הזמן בין שתי בדיקות, לאחר שהמכולה עברה את תקופת ההפעלה. ברירת המחדל היא 30s.timeout: משך הזמן המרבי של הרצת בדיקה אחת, לפני ש-Docker מפסיק אותה וסופר את ההרצה ככישלון. ברירת המחדל היא 30s.retries: מספר הכישלונות הרצופים הנדרש לפני שהמצב משתנה ל-unhealthy. ברירת המחדל היא 3.start_period: חלון חסד לאחר הפעלת המכולה. ברירת המחדל היא 0s.start_interval: התדירות שבה הבדיקה מתבצעת במהלך תקופת ההפעלה. ברירת המחדל היא 5s, ונדרש Docker Engine בגרסה 25.0 או חדשה יותר.
הכלל החשוב הוא שבמהלך תקופת ההפעלה, בדיקה שנכשלה אינה נספרת במסגרת retries, והמכולה נשארת במצב starting. בפעם הראשונה שהבדיקה מצליחה, המכולה עוברת למצב healthy, ותקופת ההפעלה מסתיימת מיד, גם אם רוב הזמן שהוקצה לה לא נוצל. אם תקופת ההפעלה מסתיימת כשהבדיקה עדיין נכשלת, מתחילה הספירה הרגילה, ועל המכולה לצבור retries כישלונות רצופים לפני שהיא מסומנת כ-unhealthy.
לכן, הזמן המרבי מהפעלת המכולה ועד unhealthy הוא start_period בתוספת retries כפול interval, ועוד timeout. לפי הערכים שבקובץ שלעיל, מדובר ב-30 ועוד 5 כפול 13, כלומר 95 שניות. רשמו את המספר הזה לפני שתגדירו פסק זמן לפריסה, מכיוון שפריסה שמוותרת לאחר 60 שניות לעולם לא תראה את המכולה מגיעה למצב סופי.
הטעות הנפוצה כאן היא להגדיל את retries כדי לאפשר הפעלה איטית. הדבר פותר את הבעיה פעם אחת, ולאחר מכן גורם לנזק באופן קבוע: שירות שנזקק ל-8 ניסיונות כדי לעלות מאפשר כעת 8 כישלונות רצופים בסביבת הייצור לפני שמישהו מבחין בכך. השתמשו במקום זאת ב-start_period, מכיוון שהוא חל רק לפני ההצלחה הראשונה.
מדוע depends_on לבדו אינו מבטיח דבר
הצורה המקוצרת של depends_on היא המקור לרוב הבלבול.
api:
depends_on:
- dbמשמעות הדבר היא אחת: להפעיל את קונטיינר db לפני קונטיינר api. Compose ממתין ליצירה ולהפעלה של הקונטיינר. הוא אינו ממתין לסיום האתחול הראשון של PostgreSQL, ואינו ממתין לכך שפורט 5432 יקבל חיבור. האפליקציה שלך מופעלת כעבור כשנייה, מתחברת לפורט שעדיין אין שום שירות שמאזין בו, ויוצאת. ביומן תראה Connection refused, או FATAL: the database system is starting up כאשר השרת פעיל אך עדיין בתהליך התאוששות.
הצורה המלאה היא זו שנדרשת בפועל:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullyל-condition יש שלושה ערכים. service_started זהה לצורה המקוצרת. service_healthy מעכב את השירות התלוי עד שהשירות שהוא תלוי בו מדווח כתקין, והדבר משמעותי רק כאשר התלות מגדירה healthcheck, בקובץ Compose או באימג' שלה. service_completed_successfully ממתין לקונטיינר חד-פעמי, כגון קונטיינר שמבצע מיגרציה של מסד נתונים, עד שהוא מסתיים עם סטטוס 0.
לצד condition יש שני שדות נוספים. restart: true מורה ל-Compose להפעיל מחדש את השירות לאחר שהוא מעדכן את שירות התלות. required: false הופך תלות חסרה משגיאה לאזהרה.
כאן נמצאת המגבלה שמבלבלת משתמשים רבים. תנאים אלה נבדקים כאשר הסטאק עולה. הם קובעים את סדר ההפעלה, ואינם כלל לפיקוח רציף. אם מסד הנתונים מופעל מחדש בשלוש לפנות בוקר, אין הערכה מחדש של service_healthy, והאפליקציה שלך אינה מופעלת מחדש כדי לעמוד בתנאי זה פעם נוספת. קוד האפליקציה עדיין חייב להתחבר מחדש בעצמו. docker compose up --no-deps api עוקף את המנגנון כולו בכוונה, וכך גם הפעלה ישירה של קונטיינר באמצעות docker start.
כתבו בדיקה שבוחנת מוכנות, ולא רק את קיומו של תהליך
בדיקה כמו pgrep nginx מוכיחה שקיימת רשומה של תהליך בטבלת התהליכים. היא אינה מוכיחה שהשירות יכול להשיב לבקשה. יישום אינטרנט יכול להשאיר את socket ההאזנה שלו פתוח זמן רב לאחר שמאגר החיבורים למסד הנתונים שלו הפסיק לפעול, ובדיקת התהליך תמשיך להחזיר מצב תקין לאורך כל התקלה.
בקשו מהקונטיינר לבצע את הפעולה שלשמה הוא קיים:
- עבור שירות HTTP, בקשו endpoint אמיתי.
curl -fsSמחזיר קוד יציאה שאינו אפס עבור כל קוד מצב 400 ומעלה, בגלל-f, ולכן תשובת 500 מיישום תקול נחשבת לבדיקה שנכשלה. - עבור PostgreSQL, השתמשו ב-
pg_isready. הוא מחזיר 0 כאשר השרת מקבל חיבורים, 1 כאשר הוא דוחה אותם, 2 כאשר הוא אינו מגיב כלל, ו-3 כאשר הפרמטרים שהעברתם שגויים. - עבור Redis, השתמשו ב-
redis-cli ping. הוא מדפיסPONGומחזיר קוד יציאה 0. - עבור MariaDB, ה-image הרשמי כולל סקריפט
healthcheck.sh, ו-healthcheck.sh --connect --innodb_initializedהוא התחביר שמתועד על ידי המתחזקים שלו.
ל-pg_isready יש מלכודת אחת שכדאי להכיר. בהפעלה הראשונה, כאשר ספריית הנתונים ריקה, ה-image הרשמי של postgres מפעיל את תהליך האתחול מול שרת זמני שמאזין רק ב-Unix socket. pg_isready ללא ארגומנט host משתמש ב-socket הזה, ולכן הוא יכול להשיב "מקבל חיבורים" בזמן שפורט TCP 5432 עדיין סגור בפני היישום שלכם. הפנו את הבדיקה במפורש אל 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 שלכם עלול להיכלל בבדיקה. $$ מונע זאת ומעביר אותו כ-$ יחיד, כך שה-shell בתוך הקונטיינר מרחיב אותו בהתאם לסביבה של הקונטיינר עצמו.
מחסנית 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) בשתי השורות. בזמן שבסיס הנתונים עדיין מאותחל, db מציג Up 4 seconds (health: starting), ו-api חסר ברשימה, מכיוון ש-Compose עדיין לא יצר אותו.
כדי להבין מדוע בדיקה הצליחה או נכשלה, קראו את יומן התקינות:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker שומר את התוצאות האחרונות, וכל אחת מהן כוללת זמן התחלה, זמן סיום, ExitCode ואת Output של הפקודה. הפלט המאוחסן נחתך, ולכן בדיקה שמדפיסה גוף דף גדול יוצרת רשומת יומן שאינה מועילה. השאירו את הבדיקות שקטות.
מה Docker עושה כאשר מצב המכל נעשה לא תקין
דבר אינו קורה. זו התשובה שהכי מפתיעה אנשים.
Docker Engine במארח יחיד אינו מפעיל מחדש מכל שמצבו לא תקין. המדיניות restart: unless-stopped מגיבה כאשר התהליך הראשי מסתיים, ומכל שמצבו לא תקין לא הסתיים. הוא יכול להישאר במצב unhealthy במשך שבוע, בעוד Compose אינו מבצע בו שום פעולה. מצב Swarm מחליף משימות שמצבן לא תקין, אך מחסנית Compose רגילה בשרת יחיד אינה עושה זאת.
נותרות שתי אפשרויות מעשיות. לגרום לתהליך להסתיים כאשר הוא מזהה שהוא פגום, כדי שלמדיניות ההפעלה מחדש יהיה אירוע שעליו לפעול. לחלופין, לנטר את המצב מחוץ למכל ולהפיק התראה כאשר הוא אינו תקין. הגדרת ניטור של Uptime Kuma לאותה נקודת קצה שאליה פונה בדיקת תקינות המכל גורמת לכך שתלות פגומה תופיע בשני המקומות, ושתקבלו עליה הודעה מהניטור במקום ממשתמש. אם תעבורת הרשת מגיעה ליישום דרך פרוקסי הפוך של Traefik, זכרו שהתצוגה העצמאית של הפרוקסי לגבי הקצה העורפי נפרדת ממצב התקינות של Docker, ולכן האחד אינו מחליף את האחר.
איתור תקלות בבדיקת תקינות שאינה נעשית תקינה
הריצו בעצמכם את הפקודה המדויקת, באותו container, ובדקו את קוד היציאה:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 כאן, בזמן שה-container עדיין מדווח שאינו תקין, פירושו שה-test של compose שלכם שונה ממה שהקלדתם זה עתה. לרוב הסיבה היא שימוש ב-CMD במקום בתחביר של shell.
שתי טעויות אחראיות לרוב המקרים הנותרים. הראשונה היא שימוש בפורט שגוי. בדיקת תקינות פועלת בתוך ה-container, ולכן עליה להשתמש בפורט של ה-container, ולא בפורט המפורסם של ה-host. עם ports: - "8080:3000" היישום מאזין בפורט 3000, ובדיקה מול http://localhost:8080 נכשלת ללא הפסקה, אף שהאתר פועל כראוי בדפדפן. הטעות השנייה היא שימוש ב-host שגוי. בתוך הבדיקה, localhost הוא אותו container עצמו. זה נכון כאשר בודקים את ה-container עצמו, אך שגוי כאשר בודקים שכנים. במקרה כזה יש להשתמש בשם השירות, לדוגמה db.
יש מקרה אחרון שראוי לציין: בדיקת התקינות עוברת, אך המשתמשים רואים שגיאות. הדבר קורה כאשר נקודת הקצה מחזירה 200 סטטי בלי לבדוק דבר אמיתי. נקודת קצה לבדיקת מוכנות שאינה מבצעת שאילתה למסד הנתונים אינה יכולה להודיע שמסד הנתונים אינו זמין. גרמו לה לבצע שאילתה אמיתית וזולה אחת.
FAQ
מדוע היישום שלי עדיין אינו מצליח להתחבר כאשר depends_on מציין שמסד הנתונים תקין?
מכיוון ש-condition: service_healthy נבדק פעם אחת, כאשר המחסנית מופעלת. לאחר מכן הוא אינו מפקח על דבר. אם מכל מסד הנתונים מופעל מחדש בהמשך, Compose אינו מפעיל מחדש את היישום כדי לעמוד שוב בתנאי. לכן קוד היישום זקוק למנגנון התחברות מחדש ולוגיקת ניסיונות חוזרים משלו. התנאי גם אינו עושה דבר כאשר מפעילים מכל יחיד באמצעות docker start או באמצעות docker compose up --no-deps.
האם נדרש healthcheck אם התמונה כבר מגדירה אחד?
בדרך כלל לא. החלפתו היא לעיתים קרובות צעד לאחור, משום שמתחזק התמונה יודע מהי מוכנות עבור אותה תוכנה. הוסיפו בדיקה משלכם רק כאשר בדיקת התמונה אינה מתאימה לסביבה שלכם, למשל כאשר היא בודקת יציאה שהעברתם. כדי להשבית healthcheck של תמונה, הגדירו test: ["NONE"] או disable: true בשירות.
האם healthcheck צריך להשתמש ב-curl או ב-wget?
השתמשו בזה שכבר קיים בתמונה, ואמתו את קיומו באמצעות docker compose exec <service> curl --version לפני שתסתמכו עליו. בתמונות רבות המבוססות על Debian אין אף אחד מהם. בתמונות המבוססות על Alpine קיים wget של BusyBox. אל תוסיפו חבילה לתמונה רק כדי להפעיל healthcheck כאשר התוכנה כוללת לקוח משלה, כגון pg_isready או redis-cli.
האם מכל שאינו תקין מופעל מחדש באופן אוטומטי?
לא על ידי Docker Engine במארח יחיד. מדיניות ההפעלה מחדש מגיבה לסיום התהליך, ולא למצב התקינות. לכן מכל שאינו תקין נשאר פעיל ונשאר תקול עד שרכיב אחר מטפל בכך. לחלופין, גרמו לתהליך להסתיים כאשר הוא מזהה את הכשל, או הפעילו מנגנון ניטור חיצוני שמתריע על המצב.
כמה זמן צריך להגדיר עבור start_period?
זמן ארוך מספיק להפעלה הראשונה והאיטית ביותר שנמדדה אצלכם, בתוספת מרווח ביטחון. מדדו את הזמן באמצעות docker compose up מול אמצעי אחסון ריק, משום שההפעלה הראשונה של מסד נתונים איטית בהרבה מכל הפעלה שלאחר מכן. start_period ארוך מדי רק מעכב את תוצאת unhealthy הראשונה. מספר ניסיונות חוזרים גבוה מדי מחליש את הבדיקה במשך כל חיי המכל, וזו התקלה החמורה יותר.