healthcheck در Docker Compose؛ تنظیم درست PostgreSQL و اپ
یاد بگیرید Docker Compose، healthcheck را چگونه ارزیابی میکند، چرا depends_on بهتنهایی برای آمادگی سرویس کافی نیست و چگونه برای PostgreSQL و اپ check درست بنویسید.
کاری که healthcheck در Docker Compose واقعاً انجام میدهد
healthcheck در Docker Compose یک فرمان است که Docker آن را طبق زمانبندی، داخل container اجرا میکند. Docker لاگهای شما را نمیخواند، port شما را monitor نمیکند و فهرست فرایندها را بررسی نمیکند. فرمان را اجرا میکند، کد خروجی را میخواند و یک وضعیت واحد را روی container ذخیره میکند: starting، healthy یا unhealthy. کد خروجی 0 یعنی سالم. هر کد خروجی دیگر یعنی ناسالم؛ کد خروجی 2 توسط Docker رزرو شده است، بنابراین هرگز عمداً آن را برنگردانید.
این تمام سازوکار است. تقریباً همه مشکلات 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 دو شکل کاربردی دارد. فهرستی که با CMD شروع شود، فرمان را مستقیماً و بدون shell اجرا میکند؛ بنابراین pipeها، && و گسترش متغیرها کار نمیکنند. فهرستی که با CMD-SHELL شروع شود، بخش باقیمانده را بهصورت یک رشته به /bin/sh -c داخل container میدهد. هر زمان که check به نحو shell نیاز داشته باشد، باید از همین شکل استفاده کنید. یک رشته ساده بهصورت CMD-SHELL پردازش میشود. فهرستی که دقیقاً شامل ["NONE"] باشد، healthcheckای را حذف میکند که image آن را از طریق Dockerfile خود ایجاد کرده است.
check داخل container اجرا میشود؛ بنابراین هر binary که در آن نام برده میشود باید در همان image وجود داشته باشد. ابتدا این موضوع را بررسی کنید، زیرا یک image کمحجم و فاقد curl، containerای ایجاد میکند که بهدلیلی دائماً ناسالم میماند و این دلیل هرگز در application log ظاهر نمیشود. آن را بهصورت دستی آزمایش کنید:
docker compose exec api curl --versionbinary موجودنباشد، با OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown پاسخ میدهد. imageهای مبتنی بر Alpine معمولاً بهجای آن BusyBox wget را ارائه میکنند؛ بنابراین check به ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"] تبدیل میشود.
نحوه ترکیب interval، retries و start_period
پنج تنظیم، زمانبندی را کنترل میکنند. مقادیر پیشفرض آنها از Docker Engine میآیند، نه از Compose.
interval: فاصله زمانی بین دو بررسی، پس از پایان start period کانتینر. مقدار پیشفرض 30s است.timeout: حداکثر زمانی که یک اجرای بررسی میتواند طول بکشد؛ پس از آن Docker آن اجرا را متوقف میکند و آن را شکستخورده محسوب میکند. مقدار پیشفرض 30s است.retries: تعداد شکستهای متوالی لازم پیش از تغییر وضعیت بهunhealthy. مقدار پیشفرض 3 است.start_period: یک بازه ارفاقی پس از شروع کانتینر. مقدار پیشفرض 0s است.start_interval: دفعات اجرای بررسی در طول start period. مقدار پیشفرض 5s است و به Docker Engine 25.0 یا جدیدتر نیاز دارد.
قاعده مهم این است: در طول start period، یک بررسی ناموفق در retries محاسبه نمیشود و کانتینر در وضعیت starting باقی میماند. نخستین بار که بررسی موفق شود، کانتینر به healthy تبدیل میشود و start period بلافاصله پایان مییابد؛ حتی اگر بیشتر زمان آن استفاده نشده باشد. اگر start period در حالی پایان یابد که بررسی همچنان ناموفق است، شمارش معمول آغاز میشود و کانتینر باید retries شکست متوالی داشته باشد تا با وضعیت unhealthy علامتگذاری شود.
بنابراین، بدترین زمان از شروع کانتینر تا unhealthy برابر است با start_period بهعلاوه retries ضربدر interval، بهعلاوه timeout. با مقادیر موجود در فایل بالا، این مقدار برابر است با 30 بهعلاوه 5 ضربدر 13، یعنی 95 ثانیه. پیش از تنظیم مهلت زمانی استقرار، این عدد را یادداشت کنید؛ زیرا rollout که پس از 60 ثانیه متوقف شود، هرگز نمیبیند این کانتینر به وضعیت نهایی برسد.
اشتباه رایج در اینجا، افزایش retries برای پوشش دادن شروع کند است. این کار یک بار مؤثر است و پس از آن همیشه مشکل ایجاد میکند: سرویسی که برای راهاندازی به 8 تلاش مجدد نیاز داشت، اکنون پیش از آگاه شدن هر مؤلفهای، 8 شکست متوالی را در محیط production تحمل میکند. در عوض از start_period استفاده کنید، زیرا فقط پیش از نخستین موفقیت اعمال میشود.
چرا depends_on بهتنهایی هیچ تضمینی ایجاد نمیکند
شکل کوتاه `depends_on` منشأ اصلی بیشتر سردرگمیها است.
api:
depends_on:
- db
````
این فقط یک معنا دارد: کانتینر ``db`` را پیش از کانتینر ``api`` راهاندازی کنید. Compose منتظر میماند تا کانتینر ایجاد و راهاندازی شود. اما منتظر نمیماند تا PostgreSQL نخستین مقداردهی اولیه خود را کامل کند و همچنین منتظر نمیماند تا پورت `5432` اتصال را بپذیرد. برنامه شما حدود 1 ثانیه بعد راهاندازی میشود، به پورتی متصل میشود که هنوز هیچ سرویسی روی آن در حال گوشدادن نیست و خارج میشود. در گزارش، ``Connection refused`` را میبینید؛ یا اگر سرور فعال شده باشد اما هنوز در حال بازیابی باشد، ``FATAL: the database system is starting up`` نمایش داده میشود.
شکل بلند همان چیزی است که کاربران واقعاً نیاز دارند:
````yaml
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfully
````
``condition`` سه مقدار دارد. ``service_started`` همان مقدار شکل کوتاه است. ``service_healthy`` سرویس وابسته را تا زمانی متوقف نگه میدارد که وابستگی، سالمبودن خود را گزارش کند. این گزینه فقط زمانی معنا دارد که آن وابستگی، یک ``healthcheck`` در فایل Compose یا در image خود تعریف کرده باشد. ``service_completed_successfully`` منتظر میماند تا یک کانتینر یکباراجرا، مانند کانتینر migration پایگاه داده، با status برابر با 0 خارج شود.
دو فیلد اضافی در کنار ``condition`` قرار دارند. ``restart: true`` به Compose میگوید پس از بهروزرسانی سرویس وابستگی، این سرویس را دوباره راهاندازی کند. ``required: false`` وابستگی مفقود را از یک خطا به یک هشدار تبدیل میکند.
اکنون به محدودیتی میرسیم که بسیاری را گرفتار میکند. این شرایط هنگام بالا آمدن stack ارزیابی میشوند. آنها ترتیب راهاندازی هستند، نه یک قانون نظارت. اگر پایگاه داده ساعت 3 بامداد restart شود، هیچچیز ``service_healthy`` را دوباره ارزیابی نمیکند و برنامه شما را برای برآوردهکردن دوباره آن راهاندازی نمیکند. کد برنامه همچنان باید خودش دوباره متصل شود. ``docker compose up --no-deps api`` عمداً کل این سازوکار را نادیده میگیرد؛ راهاندازی مستقیم یک کانتینر با ``docker start`` نیز همین رفتار را دارد.
## فرایندی بنویسید که آمادگی را بررسی کند، نه صرفاً وجود فرایند را
بررسیای مانند `pgrep nginx` فقط ثابت میکند که ورودیای برای یک فرایند در جدول فرایندها وجود دارد. این بررسی هیچ چیزی درباره توانایی سرویس برای پاسخدادن به درخواست ثابت نمیکند. یک برنامه وب میتواند مدت زیادی پس از ازکارافتادن connection pool پایگاه داده، سوکت listening خود را باز نگه دارد و بررسی فرایند در تمام مدت قطعی همچنان موفق باشد.
از container بخواهید کاری را انجام دهد که برای آن ایجاد شده است:
- برای یک سرویس HTTP، یک endpoint واقعی را درخواست کنید. `curl -fsS` بهدلیل `-f`، در صورت دریافت هر status برابر با 400 یا بیشتر، با کد خروجی غیرصفر پایان مییابد؛ بنابراین دریافت 500 از یک برنامه خراب، بررسی ناموفق محسوب میشود.
- برای PostgreSQL از `pg_isready` استفاده کنید. این دستور وقتی server در حال پذیرش connection باشد با کد 0، هنگام ردکردن آنها با کد 1، وقتی اصلاً پاسخ ندهد با کد 2، و در صورت نادرستبودن پارامترهای ارسالشده با کد 3 پایان مییابد.
- برای Redis از `redis-cli ping` استفاده کنید. این دستور `PONG` را چاپ میکند و با کد خروجی 0 پایان مییابد.
- برای MariaDB، image رسمی یک اسکریپت `healthcheck.sh` دارد و `healthcheck.sh --connect --innodb_initialized` شکلی است که maintainers آن مستند کردهاند.
`pg_isready` یک نکته مهم دارد. در نخستین راهاندازی با یک data directory خالی، image رسمی `postgres` فرایند initialization را روی یک server موقت اجرا میکند که فقط روی Unix socket به درخواستها گوش میدهد. `pg_isready` بدون آرگومان host از همان socket استفاده میکند؛ بنابراین ممکن است در حالی پاسخ «در حال پذیرش connection» را بدهد که TCP port 5432 هنوز برای application شما بسته است. بررسی را صراحتاً به TCP متصل کنید تا مشکل برطرف شود، زیرا server موقت در آنجا پاسخ نمیدهد.
```yaml
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دو علامت dollar پشتسرهم اشتباه تایپی نیستند. Compose هنگام خواندن فایل، خودش $VAR را expand میکند و در نتیجه مقداری از environment میزبان شما داخل بررسی ثبت میشود. $$ آن را به یک $ تبدیل میکند؛ بنابراین shell داخل container آن را بر اساس environment خود container expand میکند.
یک پشته 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 نمیکند. سیاست restart: unless-stopped به خروج فرایند اصلی واکنش نشان میدهد، اما کانتینر ناسالم خارج نشده است. این کانتینر میتواند یک هفته در وضعیت unhealthy باقی بماند و Compose آن را نادیده بگیرد. حالت Swarm وظایف ناسالم را جایگزین میکند، اما یک stack ساده Compose روی یک سرور چنین کاری انجام نمیدهد.
در این شرایط، دو گزینه صادقانه وجود دارد. وقتی فرایند تشخیص میدهد خراب شده است، آن را خارج کنید تا سیاست restart چیزی برای واکنش نشان دادن داشته باشد. یا وضعیت را از بیرون پایش کنید و برای آن هشدار تنظیم کنید. قرار دادن یک مانیتور Uptime Kuma روی همان endpointی که healthcheck شما فراخوانی میکند، باعث میشود خرابی یک dependency در هر دو محل نمایان شود و بهجای کاربر، مانیتور شما از آن مطلعتان کند. اگر ترافیک از طریق یک reverse proxy از نوع Traefik به برنامه میرسد، به خاطر داشته باشید که دید خود proxy از backend جدا از وضعیت health در Docker است؛ بنابراین یکی جای دیگری را پوشش نمیدهد.
اشکالزدایی بررسیای که هرگز سالم نمیشود
دستور دقیق را خودتان، در همان container، اجرا کنید و کد خروجی را بررسی کنید:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"وجود exit=0 در اینجا، در حالی که container همچنان وضعیت ناسالم را گزارش میکند، یعنی compose test شما با چیزی که بهتازگی تایپ کردهاید تفاوت دارد؛ معمولاً به این دلیل که بهجای نیاز به نحو shell، از CMD استفاده شده است.
دو اشتباه دیگر بیشتر موارد باقیمانده را تشکیل میدهند. نخست، استفاده از port نادرست است. healthcheck داخل container اجرا میشود؛ بنابراین باید از port مربوط به container استفاده کند، نه port منتشرشده روی host. در ports: - "8080:3000"، برنامه روی 3000 در حال گوشدادن است و بررسی http://localhost:8080 برای همیشه شکست میخورد، در حالی که سایت در مرورگر بهدرستی کار میکند. دوم، host نادرست است. درون بررسی، localhost همان container است؛ این مقدار برای بررسی خود container درست است، اما برای بررسی یک container همسایه نادرست است. در حالت دوم باید از نام service استفاده کنید؛ برای مثال db.
یک حالت دیگر نیز باید جداگانه نامگذاری شود: healthcheck موفق میشود، اما کاربران خطا میبینند. این وضعیت زمانی رخ میدهد که endpoint یک 200 ایستا برگرداند، بدون اینکه واقعاً چیزی را بررسی کند. endpoint مربوط به آمادگی که هرگز database را query نمیکند، نمیتواند قطعشدن database را تشخیص دهد. کاری کنید که endpoint یک query واقعی و کمهزینه اجرا کند.
FAQ
چرا برنامه من همچنان نمیتواند متصل شود، با اینکه در depends_on وضعیت پایگاهداده سالم اعلام شده است؟
زیرا condition: service_healthy فقط یکبار، هنگام راهاندازی stack، ارزیابی میشود. پس از آن، هیچ نظارتی انجام نمیدهد. اگر کانتینر پایگاهداده بعداً دوباره راهاندازی شود، Compose برنامه شما را برای برقرار کردن دوباره این شرط راهاندازی نمیکند. بنابراین، کد برنامه باید منطق مخصوص خود را برای اتصال مجدد و تلاش دوباره داشته باشد. این شرط هنگام راهاندازی یک کانتینر منفرد با docker start یا docker compose up --no-deps نیز هیچ اثری ندارد.
اگر image از قبل یک healthcheck تعریف کرده باشد، آیا به healthcheck نیاز دارم؟
معمولاً خیر. بازنویسی آن نیز اغلب تصمیم مناسبی نیست، زیرا نگهدارنده image میداند آمادهبودن آن نرمافزار چه معنایی دارد. فقط زمانی healthcheck اختصاصی اضافه کنید که بررسی image با محیط شما سازگار نباشد؛ برای مثال، وقتی پورتی را جابهجا کردهاید که healthcheck آن را بررسی میکند. برای غیرفعال کردن healthcheck مربوط به image، test: ["NONE"] یا disable: true را روی service تنظیم کنید.
healthcheck باید از curl استفاده کند یا wget؟
از ابزاری استفاده کنید که از قبل در image وجود دارد و پیش از اتکا به آن، وجودش را با docker compose exec <service> curl --version تأیید کنید. بسیاری از imageهای مبتنی بر Debian هیچکدام را ندارند. imageهای مبتنی بر Alpine دارای wget از BusyBox هستند. فقط برای اجرای healthcheck، package به image اضافه نکنید؛ اگر نرمافزار client مخصوص خود را دارد، مانند pg_isready یا redis-cli، از همان استفاده کنید.
آیا کانتینر ناسالم بهصورت خودکار دوباره راهاندازی میشود؟
در یک میزبان منفرد، Docker Engine چنین کاری انجام نمیدهد. سیاستهای راهاندازی مجدد به خارج شدن process واکنش نشان میدهند، نه به وضعیت سلامت. بنابراین، کانتینر ناسالم همچنان فعال و خراب باقی میماند تا عامل دیگری اقدامی انجام دهد. یا کاری کنید process هنگام تشخیص خطا خارج شود، یا monitor خارجیای اجرا کنید که تغییر وضعیت را گزارش دهد.
start_period چقدر باید باشد؟
این زمان باید برای طولانیترین راهاندازی اولیه معتبر که اندازهگیری کردهاید کافی باشد و مقداری حاشیه نیز داشته باشد. زمان آن را با docker compose up و در برابر volume خالی اندازهگیری کنید، زیرا راهاندازی اولیه پایگاهداده بسیار کندتر از راهاندازیهای بعدی است. start period بیشازحد طولانی فقط صدور نخستین verdict مربوط به unhealthy را به تأخیر میاندازد. تعداد retry بیشازحد، این بررسی را در تمام طول عمر کانتینر ضعیف میکند و این شکست بدتری است.