كيف تكتب healthchecks تعمل فعلاً في Docker Compose
افهم كيف يقيّم Docker Compose healthcheck، ولماذا لا ينتظر depends_on الجاهزية، واكتب فحوصاً صحيحة لـ PostgreSQL وتطبيقك مع أمثلة عملية.
ما الذي يفعله healthcheck في Docker Compose فعلياً
healthcheck في Docker Compose هو أمر واحد يشغّله Docker داخل الحاوية وفق مؤقت محدد. لا يقرأ Docker سجلاتك، ولا يراقب المنفذ، ولا يفحص قائمة العمليات. يشغّل الأمر، ويقرأ رمز الخروج، ويخزّن حالة واحدة للحاوية: starting أو healthy أو unhealthy. يعني رمز الخروج 0 أن الحالة سليمة. وأي رمز خروج آخر يعني أن الحالة غير سليمة. ويحجز Docker رمز الخروج 2، لذلك لا تُرجعه عمداً.
هذه هي الآلية كاملة. تكاد كل مشكلة في healthcheck تكون المشكلة نفسها: الأمر الذي كتبته يجيب عن سؤال مختلف عن السؤال الذي قصدت طرحه. يفترض هذا الدليل أنك تعرف مسبقاً كيفية كتابة ملف compose على VPS، ويبدأ من النقطة التي تبدأ فيها الحزمة بالترتيب الخاطئ.
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 الذي أضافته الصورة من خلال Dockerfile.
يُشغَّل الفحص داخل الحاوية، لذلك يجب أن تكون كل ثنائية يذكرها موجودة في تلك الصورة. تحقّق من ذلك أولاً، لأن الصورة الخفيفة التي لا تحتوي على curl تنتج حاوية تظل حالتها غير سليمة دائماً لسبب لا يظهر مطلقاً في سجل التطبيق. اختبر ذلك يدوياً:
docker compose exec api curl --versionيُرجع الملف الثنائي المفقود OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. وعادةً ما توفّر الصور المبنية على 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 وجود إدخال للعملية في جدول العمليات. لكنه لا يثبت قدرة الخدمة على الاستجابة لطلب. يمكن لتطبيق ويب أن يُبقي مقبس الاستماع مفتوحاً بعد تعطل مجموعة اتصالات قاعدة البيانات بوقت طويل، ويبقى فحص العملية ناجحاً طوال فترة الانقطاع.
اطلب من الحاوية تنفيذ الوظيفة التي وُجدت من أجلها:
- بالنسبة إلى خدمة HTTP، اطلب نقطة نهاية فعلية. يخرج
curl -fsSبحالة غير صفرية عند أي رمز حالة يساوي 400 أو يتجاوزه بسبب-f، ولذلك يُعد الرمز 500 الصادر عن تطبيق معطّل فحصاً فاشلاً. - بالنسبة إلى PostgreSQL، استخدم
pg_isready. يخرج بالرمز 0 عندما يكون الخادم مستعداً لقبول الاتصالات، وبالرمز 1 عندما يرفضها، وبالرمز 2 عندما لا يستجيب إطلاقاً، وبالرمز 3 عندما تكون المعلمات التي مررتها غير صحيحة. - بالنسبة إلى Redis، استخدم
redis-cli ping، الذي يطبعPONGويخرج بالرمز 0. - بالنسبة إلى MariaDB، تتضمن الصورة الرسمية البرنامج النصي
healthcheck.sh، وhealthcheck.sh --connect --innodb_initializedهي الصيغة التي توثقها الجهات المشرفة على المشروع.
هناك نقطة مهمة يجب معرفتها بشأن pg_isready. عند أول تشغيل له مع دليل بيانات فارغ، تشغّل الصورة الرسمية postgres عملية التهيئة على خادم مؤقت يستمع عبر Unix socket فقط. يستخدم pg_isready، عند عدم تمرير وسيطة المضيف، ذلك المقبس، ولذلك قد يجيب بأن الخادم «يقبل الاتصالات» بينما يظل منفذ 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 بنفسه أثناء قراءة الملف، ما يؤدي إلى تضمين قيمة من بيئة المضيف في الفحص. أما $$ فيهربها إلى $ واحد داخل الحاوية، ولذلك توسّعها shell داخل الحاوية وفق بيئة الحاوية نفسها.
حزمة Postgres وتطبيق تبدأ بالترتيب الصحيح
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 mode المهام غير السليمة، لكن مكدس Compose عادي على خادم واحد لا يفعل ذلك.
لذلك لديك خياران واضحان. اجعل العملية تخرج عندما تعرف أنّها معطلة، حتى تجد سياسة إعادة التشغيل حدثاً تتعامل معه. أو راقب الحالة من خارج الحاوية وأطلق تنبيهاً عند حدوث ذلك. إن توجيه مراقب Uptime Kuma إلى نقطة النهاية نفسها التي يستدعيها فحص الصحة يعني أنّ تبعية معطلة ستظهر في الموضعين، وستعرف بها من المراقب بدلاً من أن تعرف بها من أحد المستخدمين. إذا مرّت حركة الشبكة إلى التطبيق عبر Reverse Proxy من Traefik، فتذكّر أنّ رؤية الـproxy الخاصّة للواجهة الخلفية منفصلة عن حالة صحة Docker، ولذلك لا يغطي أحدهما الآخر.
تصحيح فحص لا يصبح سليماً أبداً
نفّذ الأمر نفسه يدوياً، داخل الحاوية نفسها، وتحقق من رمز الخروج:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"وجود exit=0 هنا بينما لا تزال الحاوية تبلغ عن عدم سلامتها يعني أن test في Compose يختلف عما كتبته للتو، وغالباً لأنك استخدمت CMD بدلاً من صياغة الصدفة المطلوبة.
هناك خطآن يفسّران معظم الحالات المتبقية. الأول هو المنفذ الخاطئ. يُنفَّذ فحص السلامة داخل الحاوية، لذلك يجب أن يستخدم منفذ الحاوية، وليس منفذ المضيف المنشور. مع ports: - "8080:3000" يستمع التطبيق على المنفذ 3000، ولذلك يفشل الفحص على http://localhost:8080 باستمرار، بينما يعمل الموقع بشكل طبيعي في المتصفح. الثاني هو المضيف الخاطئ. داخل الفحص، يشير localhost إلى الحاوية نفسها، وهذا صحيح عند فحصها، لكنه غير صحيح عند فحص حاوية مجاورة؛ في هذه الحالة تحتاج إلى اسم الخدمة، مثل 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 فتحتوي على BusyBox wget. لا تضف حزمة إلى صورة فقط لتشغيل healthcheck عندما يوفّر البرنامج عميله الخاص، مثل pg_isready أو redis-cli.
هل تُعاد مناظرة الحاوية غير السليمة تلقائياً؟
ليس بواسطة Docker Engine على مضيف واحد. تستجيب سياسات إعادة التشغيل لخروج العملية، لا لحالة الصحة، لذلك تظل الحاوية غير السليمة قيد التشغيل وتظل معطلة إلى أن يتخذ شيء آخر إجراءً بشأنها. إما أن تجعل العملية تخرج عند اكتشاف الفشل، أو شغّل مراقباً خارجياً يرسل تنبيهاً عند تغيّر الحالة.
ما المدة المناسبة لـ start_period؟
اجعلها طويلة بما يكفي لأبطأ بدء أول مشروع قسته، مع هامش إضافي. قِس المدة باستخدام docker compose up مقابل volume فارغ، لأن بدء قاعدة البيانات لأول مرة أبطأ بكثير من كل عملية بدء لاحقة. تؤدي فترة بدء طويلة جداً إلى تأخير حكم unhealthy الأول فقط. أما زيادة عدد مرات إعادة المحاولة أكثر من اللازم فتضعف الفحص طوال عمر الحاوية، وهذا هو الفشل الأسوأ.