آموزش تنظیم دقیق Healthcheck در Docker Compose
بسیاری تصور میکنند depends_on برای آمادهسازی کافی است. در این مقاله یاد میگیرید چرا این دستور شکست میخورد و چگونه با healthcheck واقعی، وضعیت Postgres و اپلیکیشن را مدیریت کنید.
عملکرد واقعی healthcheck در Docker Compose
یک healthcheck در Docker Compose دستوری است که Docker آن را در فواصل زمانی مشخص داخل container اجرا میکند. Docker لاگهای شما را نمیخواند، پورت را مانیتور نمیکند و لیست پردازشها را بررسی نمیکند. Docker فقط دستور را اجرا کرده، کد خروجی (exit code) را میخواند و یک وضعیت واحد را برای container ذخیره میکند: starting، healthy یا unhealthy. کد خروجی 0 به معنای سالم (healthy) بودن است. هر کد خروجی دیگری به معنای ناسالم (unhealthy) بودن است و کد خروجی 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 اجرا میکند؛ بنابراین pipeها، && و جایگذاری متغیرها (variable expansion) کار نمیکنند. لیستی که با CMD-SHELL شروع میشود، بقیه دستور را به عنوان یک رشته به /bin/sh -c داخل container میفرستد که هر زمان نیاز به سینتکس shell داشته باشید، این همان چیزی است که به آن نیاز دارید. یک رشته ساده به عنوان CMD-SHELL در نظر گرفته میشود. لیستی که دقیقاً شامل ["NONE"] باشد، healthcheckای را که در Dockerfile تصویر (image) تعبیه شده است، حذف میکند.
این بررسی داخل container اجرا میشود، بنابراین هر باینری که در دستور نام میبرید باید در آن image وجود داشته باشد. ابتدا این مورد را بررسی کنید، زیرا یک image بسیار سبک (slim) که فاقد curl است، containerای ایجاد میکند که به دلیلی که هرگز در لاگ برنامه ظاهر نمیشود، دائماً در وضعیت ناسالم باقی میماند. آن را به صورت دستی تست کنید:
docker compose exec api curl --versionفقدان یک باینری با کد 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"] تغییر یابد.
نحوه ترکیب 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 ثانیه است. پیش از تعیین deploy timeout، این عدد را یادداشت کنید، زیرا فرآیند rollout که پس از 60 ثانیه متوقف شود، هرگز موفقیت این کانتینر در رسیدن به وضعیت نهایی را نخواهد دید.
اشتباه رایج در اینجا، افزایش retries برای پوشش دادن شروع کند است. این کار یک بار جواب میدهد اما همیشه آسیبرسان است: سرویسی که برای بالا آمدن به 8 تلاش مجدد نیاز داشت، اکنون 8 شکست متوالی را در محیط production تحمل میکند پیش از آنکه سیستم متوجه مشکلی شود. از start_period استفاده کنید، زیرا این تنظیم فقط پیش از اولین موفقیت اعمال میشود.
چرا depends_on به تنهایی هیچ تضمینی ایجاد نمیکند
شکل کوتاه depends_on منبع اصلی اکثر سردرگمیهاست.
api:
depends_on:
- dbاین دستور فقط یک معنا دارد: کانتینر db را پیش از کانتینر api اجرا کن. ابزار Compose منتظر میماند تا کانتینر ایجاد و اجرا شود. این ابزار منتظر نمیماند تا PostgreSQL مقداردهی اولیه (initialisation) خود را به پایان برساند و منتظر نمیماند تا پورت 5432 آماده پذیرش اتصال شود. برنامه شما حدود یک ثانیه بعد شروع به کار میکند، به پورتی متصل میشود که هنوز هیچ سرویسی روی آن گوش نمیدهد و سپس متوقف میشود. در لاگها، پیام Connection refused یا در صورتی که سرور بالا آمده اما در حال بازیابی باشد، پیام FATAL: the database system is starting up را مشاهده خواهید کرد.
شکل طولانی همان چیزی است که کاربران در واقع به آن نیاز دارند:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition دارای سه مقدار است. service_started همان عملکرد شکل کوتاه را دارد. service_healthy سرویس وابسته را تا زمانی که سرویس اصلی وضعیت healthy را گزارش نکند، متوقف نگه میدارد؛ این وضعیت تنها زمانی معنا دارد که سرویس اصلی یک healthcheck تعریف کرده باشد (چه در فایل compose و چه در image خود). service_completed_successfully منتظر میماند تا یک کانتینر one-shot، مانند عملیات migration دیتابیس، با وضعیت 0 خارج شود.
دو فیلد اضافی در کنار condition قرار دارند. restart: true به Compose دستور میدهد که پس از بهروزرسانی سرویس وابسته، این سرویس را نیز restart کند. required: false نبودِ یک وابستگی را از یک خطا به یک هشدار تقلیل میدهد.
حال به محدودیتی میرسیم که بسیاری از کاربران را دچار مشکل میکند. این شرایط تنها زمانی ارزیابی میشوند که stack بالا میآید. اینها صرفاً ترتیب شروع (start ordering) هستند و نه یک قانون نظارتی (supervision rule). اگر دیتابیس ساعت 3 صبح restart شود، هیچچیز service_healthy را دوباره ارزیابی نمیکند و هیچچیز برنامه شما را برای برقراری مجدد این شرط restart نخواهد کرد. کد برنامه شما همچنان باید بهطور مستقل قابلیت اتصال مجدد (reconnect) داشته باشد. docker compose up --no-deps api طبق طراحی از کل این مکانیزم صرفنظر میکند و اجرای مستقیم کانتینر با docker start نیز همینطور است.
نوشتن بررسیکنندهای که آمادگی را بسنجد، نه صرفاً وجود یک پردازش
بررسیکنندهای مانند pgrep nginx تنها اثبات میکند که یک ورودی در جدول پردازشها وجود دارد. این بررسی هیچ چیزی درباره اینکه آیا سرویس میتواند به یک درخواست پاسخ دهد یا خیر، نمیگوید. یک برنامه وب میتواند سوکت شنونده خود را مدتها پس از از کار افتادن استخر دیتابیس باز نگه دارد و بررسی پردازش در تمام طول مدت قطعی، وضعیت سبز را نشان دهد.
از کانتینر بخواهید کاری را انجام دهد که برای آن ساخته شده است:
- برای یک سرویس HTTP، یک endpoint واقعی را درخواست کنید. دستور
curl -fsSبه دلیل-fبرای هر وضعیت 400 یا بالاتر با کد خروجی غیر صفر پایان مییابد، بنابراین خطای 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 گوش میدهد. دستور pg_isready بدون آرگومان host از همان سوکت استفاده میکند، بنابراین میتواند پاسخ "پذیرش اتصالات" را بدهد در حالی که پورت 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 داخل کانتینر آن را بر اساس محیط خودِ کانتینر بسط دهد.
یک پشته 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 دستور است. خروجی ذخیرهشده کوتاه میشود، بنابراین بررسیای که بدنه یک صفحه بزرگ را چاپ کند، یک ورودی لاگ بیفایده به شما میدهد. بررسیها را کمحرف (quiet) نگه دارید.
وقتی یک کانتینر در وضعیت unhealthy قرار میگیرد، Docker چه میکند
هیچکاری. این پاسخی است که بیش از همه باعث تعجب کاربران میشود.
Docker Engine روی یک میزبان واحد، کانتینر unhealthy را مجدداً راهاندازی نمیکند. سیاست restart: unless-stopped تنها زمانی واکنش نشان میدهد که پردازش اصلی (main process) متوقف شود، در حالی که یک کانتینر unhealthy متوقف نشده است. این کانتینر میتواند یک هفته در وضعیت unhealthy باقی بماند و Compose هیچ دخالتی در آن نکند. حالت Swarm وظایف (tasks) ناسالم را جایگزین میکند، اما یک stack سادهٔ Compose روی یک سرور این کار را انجام نمیدهد.
بنابراین دو گزینهٔ منطقی وجود دارد. یا کاری کنید که پردازش در صورت بروز خطا متوقف شود تا سیاست restart بتواند بر اساس آن عمل کند، یا وضعیت را از بیرون زیر نظر بگیرید و در صورت بروز مشکل هشدار دریافت کنید. تنظیم یک مانیتور Uptime Kuma روی همان endpoint که healthcheck شما فراخوانی میکند، باعث میشود وابستگیهای معیوب در هر دو جا مشخص شوند و شما بهجای کاربران، از طریق مانیتور از مشکل باخبر شوید. اگر ترافیک از طریق یک reverse proxy مانند Traefik به برنامه میرسد، به یاد داشته باشید که دیدگاه خودِ پروکسی نسبت به یک backend، جدا از وضعیت سلامت Docker است؛ بنابراین یکی جایگزین دیگری نخواهد بود.
عیبیابی بررسی سلامت (healthcheck) که هرگز به وضعیت سالم نمیرسد
دستور دقیق را خودتان در همان container اجرا کنید و کد خروجی (exit code) را بررسی نمایید:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 در اینجا، در حالی که container همچنان وضعیت unhealthy را گزارش میکند، به این معنی است که test در فایل compose شما با آنچه تایپ کردید تفاوت دارد؛ این معمولاً به دلیل استفاده از CMD در جایی است که به syntax شل (shell) نیاز بوده است.
دو اشتباه، دلیل اکثر موارد باقیمانده هستند. اولی، پورت اشتباه است. healthcheck در داخل container اجرا میشود، بنابراین باید از پورت container استفاده کند و هرگز نباید از پورت منتشرشده (published) روی host استفاده کرد. با ports: - "8080:3000"، برنامه روی پورت 3000 گوش میدهد و بررسی روی http://localhost:8080 برای همیشه شکست میخورد، در حالی که سایت در مرورگر بهدرستی کار میکند. دومی، host اشتباه است. در داخل بررسی، localhost همان container است که برای بررسی خودِ آن درست است، اما برای بررسی یک سرویس همسایه اشتباه است؛ در آنجا شما به نام سرویس نیاز دارید، برای مثال db.
یک مورد آخر هم شایان ذکر است: healthcheck موفقیتآمیز است اما کاربران خطا میبینند. این اتفاق زمانی میافتد که endpoint یک پاسخ 200 ثابت برمیگرداند بدون اینکه هیچ عملیات واقعی انجام دهد. یک endpoint آمادهسازی (readiness) که هرگز دیتابیس را پرسوجو نمیکند، نمیتواند به شما بگوید که دیتابیس از دسترس خارج شده است. آن را طوری تنظیم کنید که یک پرسوجوی واقعی و کمهزینه اجرا کند.
FAQ
چرا برنامه من با وجود اینکه در depends_on وضعیت دیتابیس سالم گزارش شده، باز هم در اتصال ناموفق است؟
زیرا condition: service_healthy فقط یک بار و در زمان شروع stack ارزیابی میشود. این دستور پس از آن هیچ نظارتی بر وضعیت ندارد. اگر کانتینر دیتابیس بعداً ریاستارت شود، Compose برنامه شما را برای برقراری مجدد شرط ریاستارت نمیکند؛ بنابراین کد برنامه شما باید منطق اتصال مجدد و تلاش دوباره (retry) مخصوص به خود را داشته باشد. همچنین این شرط هنگام اجرای یک کانتینر تکی با docker start یا docker compose up --no-deps هیچ تأثیری ندارد.
آیا اگر image از قبل healthcheck دارد، باز هم به یکی نیاز دارم؟
معمولاً خیر، و بازنویسی آن اغلب یک گام به عقب است، زیرا نگهدارنده image بهتر میداند که وضعیت آمادهبهکار (readiness) برای آن نرمافزار به چه معناست. فقط زمانی healthcheck خود را اضافه کنید که بررسی پیشفرض image برای تنظیمات شما اشتباه باشد؛ مثلاً زمانی که پورت مورد بررسی را تغییر دادهاید. برای غیرفعال کردن healthcheck یک image، مقدار test: ["NONE"] یا disable: true را روی سرویس تنظیم کنید.
آیا healthcheck باید از curl استفاده کند یا wget؟
از هر کدام که در image موجود است استفاده کنید و پیش از تکیه بر آن، وجودش را با docker compose exec <service> curl --version تأیید کنید. بسیاری از imageهای مبتنی بر Debian هیچکدام را ندارند. imageهای مبتنی بر Alpine دارای wget از نوع BusyBox هستند. اگر نرمافزار کلاینت مخصوص خود را دارد (مانند pg_isready یا redis-cli)، برای اجرای healthcheck هیچ پکیج اضافهای به image اضافه نکنید.
آیا کانتینر ناسالم بهطور خودکار ریاستارت میشود؟
خیر، توسط Docker Engine روی یک میزبان تکی این اتفاق نمیافتد. سیاستهای ریاستارت به خروج پردازش واکنش نشان میدهند، نه به وضعیت سلامت؛ بنابراین کانتینر ناسالم تا زمانی که عامل دیگری مداخله نکند، بالا و در وضعیت خراب باقی میماند. یا کاری کنید که پردازش در صورت تشخیص خطا خارج شود، یا یک مانیتور خارجی اجرا کنید که در صورت بروز این وضعیت هشدار دهد.
مقدار start_period باید چقدر باشد؟
به اندازهای طولانی باشد که کندترین شروع قانونی که اندازهگیری کردهاید را پوشش دهد، به اضافه مقداری حاشیه اطمینان. زمان آن را با docker compose up روی یک volume خالی بسنجید، زیرا اولین شروع یک دیتابیس بسیار کندتر از دفعات بعدی است. طولانی بودن بیش از حد start_period فقط اولین نتیجه unhealthy را به تأخیر میاندازد. تعداد تلاشهای مجدد (retries) بیش از حد، بررسی سلامت را در کل طول عمر کانتینر ضعیف میکند که این وضعیت بدتری است.