1. Вступ
Якщо ви колись раділи рядку "Started CatalogApplication in 2.3 seconds" і думали: «ну все, можна в прод», то вітаю — ви справжній оптиміст. У контейнерному світі «контейнер запущено» — це приблизно як «чайник увімкнено». Вода при цьому може бути і холодною, і киплячою, а чайник узагалі може виявитися порожнім. Таке теж трапляється.
Docker (і будь-які системи навколо нього) хочуть знати не лише факт «процес ще дихає», а й більш практичну відповідь: сервіс справді здатний працювати. Саме для цього існує HEALTHCHECK. Це не панацея і не самодіагностика, а дуже конкретний механізм: Docker періодично запускає команду всередині контейнера й за її результатом позначає контейнер як healthy або unhealthy. Тобто ми додаємо ще один сигнал, який добре поєднується з логами та Actuatorʼом.
2. Механіка Docker HEALTHCHECK: команда та exit code
Щоб HEALTHCHECK перестав виглядати як магічне заклинання, корисно побачити його як дуже приземлену річ. Docker запускає контейнер і всередині нього періодично виконує команду. Це може бути curl, wget, невеликий скрипт або що завгодно, що є в образі. Результат команди Docker оцінює так само, як будь-який Unix-процес, — за exit code.
Якщо команда завершилася з кодом 0, значить усе добре, контейнер вважається healthy. Якщо код не 0, Docker збільшує «лічильник невдач», і коли невдач накопичиться достатньо (у межах --retries), контейнер стане unhealthy. Важливо: Docker при цьому не лагодить сервіс і не обовʼязково його перезапускає. Він просто чесно ставить ярлик: «схоже, всередині щось не так».
Ось проста схема циклу healthcheckʼа — без зайвої теорії, але з чітким причинно-наслідковим звʼязком:
flowchart TD
A[Контейнер запущено] --> B[Docker чекає інтервалу]
B --> C[Запускає команду healthcheck всередині контейнера]
C --> D{Код завершення = 0?}
D -- так --> E[Статус: healthy]
D -- ні --> F[Лічильник невдач +1]
F --> G{Лічильник невдач >= retries?}
G -- ні --> B
G -- так --> H[Статус: unhealthy]
E --> B
Тут є одна важлива «психологічна пастка новачка». Багато хто думає, що healthcheck — це «особлива перевірка Docker», яка десь зовні чіпає ваш сервіс. Насправді все простіше: Docker запускає команду всередині контейнера, тобто перевірка залежить від того, що реально лежить у вашому образі, які там права, які утиліти є і який порт слухає застосунок.
3. /actuator/health як ціль для healthcheck у Spring Boot
Тепер логічне питання: «Гаразд, Docker уміє запускати команду. А яку саме команду ми хочемо запускати, щоб це мало сенс?» І тут Spring Boot робить нам подарунок — Actuator. У ньому /actuator/health якраз призначений для короткої відповіді на запитання: «сервіс у порядку?».
Важливий момент: /actuator/health відрізняється від випадкового бізнес-ендпойнта типу GET /api/catalog/items. Бізнесовий ендпойнт може бути важким, може ходити до бази, може залежати від даних і може бути під навантаженням — тоді healthcheck почне стріляти собі в ногу. А /actuator/health зазвичай короткий, стабільний і не повинен мати побічних ефектів.
Типова відповідь health endpoint виглядає так:
# Перевіряємо health endpoint напряму — локально, без Docker
curl -s http://localhost:8080/actuator/health
# {"status":"UP"}
Нам у healthcheckʼі не потрібен увесь світ. Нам потрібен найпростіший, найшвидший і найбільш передбачуваний сигнал: сервіс піднявся й відповідає на HTTP. Тому для Docker healthcheck ми зазвичай звертаємося до локальної адреси всередині контейнера:
- не до localhost на вашій машині,
- не до імені контейнера,
- не до зовнішнього опублікованого порту,
- а до 127.0.0.1:8080 всередині контейнера.
І так, це зручно: healthcheck не залежить від пробросу портів назовні. Навіть якщо ви забудете -p 8080:8080, healthcheck усе одно зможе перевірити сервіс, тому що він запускається зсередини контейнера.
4. HEALTHCHECK у Dockerfile
Зараз ми зробимо найпрактичніший крок: додамо HEALTHCHECK у Dockerfile нашого навчального сервісу Container-Ready Catalog Service. Важливо зробити це так, щоб перевірка була корисною, але не перетворювалася на вічного стукача, який кожні 200 мілісекунд смикає застосунок. Нам потрібна перевірка з людською частотою, коротким таймаутом і розумним стартовим вікном, поки Spring Boot ще запускається.
Почнемо з самої суті — одного рядка HEALTHCHECK. Ось мінімальний варіант (зверніть увагу на exec-form у CMD — у квадратних дужках):
# Docker періодично запускатиме команду всередині контейнера
# Важливо: нас цікавить exit code команди (0 = healthy, не 0 = unhealthy)
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["curl", "-fsS", "http://127.0.0.1:8080/actuator/health"]
У цього рецепта є дві тихі, але важливі опори. Перша — стабільний внутрішній порт сервісу: всередині контейнера ми тримаємо 8080, а назовні змінюємо лише прив’язку порту на кшталт -p 18080:8080. Тому зовнішній порт можна змінювати як завгодно, а URL healthcheck у Dockerfile залишається тим самим. Якщо ж ви свідомо змінюєте саме server.port усередині контейнера, URL перевірки теж треба змінювати синхронно — Docker не вміє здогадуватися за нас.
Друга — --start-period. Це стартове вікно, у якому застосунок може спокійно піднятися, а перші невдалі перевірки не вважаються ознакою поломки. retries після цього вже відповідають за реальні збої, а не замінюють час на старт. Для нашого standalone-сервісу 20 секунд зазвичай достатньо, але якщо старт важчий, це вікно теж треба збільшити.
Розберімо, що тут відбувається, мовою «не треба бути Docker-магістром»:
| Параметр | Що означає в реальності | Чому нам це важливо |
|---|---|---|
| --interval=30s | раз на 30 секунд Docker перевірятиме стан контейнера | не створюємо зайвого навантаження і шуму |
| --timeout=3s | якщо перевірка зависла довше 3 секунд — вважаємо це помилкою | зависла перевірка гірша за чесну помилку |
| --start-period=20s | дає застосунку час спокійно піднятися після старту контейнера | ранні перевірки не перетворюються на хибний unhealthy |
| --retries=3 | потрібно 3 підряд невдачі, щоб контейнер став unhealthy | фільтруємо випадкові «чхання» |
| curl -fsS | -f змушує команду завершуватися з помилкою на HTTP 4xx/5xx, -sS робить вивід акуратним | Docker дивиться на exit code, а не на «красу тексту» |
Тепер важлива практична деталь, яку легко пропустити: утиліта має існувати всередині образу. Якщо у вашому runtime-образі немає curl, то healthcheck падатиме не тому, що сервіс хворіє, а тому, що «лікар» не прийшов: команди просто немає.
Тому в Dockerfile найчастіше йдуть одним із двох шляхів: або беруть runtime-образ, де вже є curl/wget, або встановлюють curl у runtime-стадії. Для навчального проєкту підійде варіант із встановленням curl на Debian/Ubuntu-подібному образі. Тут шматок Dockerfile саме для runtime stage — його достатньо, щоб ви зрозуміли ідею:
FROM eclipse-temurin:25-jre-jammy
WORKDIR /app
# Встановлюємо curl, інакше HEALTHCHECK падатиме через відсутність команди
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY app.jar app.jar
# Перевіряємо локальний HTTP-ендпойнт застосунку всередині контейнера
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["curl", "-fsS", "http://127.0.0.1:8080/actuator/health"]
# Запуск застосунку (HEALTHCHECK описує поведінку саме поруч із runtime-запуском)
ENTRYPOINT ["java", "-jar", "app.jar"]
Якщо у вашому проєкті multi-stage (а в нас він уже має бути), то замість COPY app.jar app.jar буде копіювання з builder-стадії, приблизно так:
# Беремо зібраний jar із builder stage і кладемо його в runtime-образ
COPY --from=builder /workspace/build/libs/catalog-service.jar app.jar
Сенс той самий: healthcheck живе поруч із фінальним запуском, тому що він описує runtime-поведінку контейнера.
5. Перевірка: docker ps і docker inspect
Після зміни Dockerfile хочеться не вірити на слово, а побачити на власні очі: Docker справді виконує перевірку і змінює статус контейнера. Хороша новина: це легко перевірити стандартними командами, без жодних «секретних прапорців для обраних». Погана новина: вперше ви все одно оновлюватимете docker ps так само часто, як сторінку з результатами іспиту. Це нормально.
Спочатку збираємо образ і запускаємо контейнер (назви підлаштуйте під свій проєкт, але ідея така):
# Збираємо образ із HEALTHCHECK у Dockerfile
docker build -t catalog-service:healthcheck .
# Запускаємо контейнер (проброс порту потрібен для зручності зовнішніх тестів, але не для самого HEALTHCHECK)
docker run --name catalog-service -p 8080:8080 -d catalog-service:healthcheck
Майже одразу після старту в docker ps ви побачите, що контейнер живий, але health status може ще не бути healthy — у нього є проміжний стан starting:
# Дивимося статус контейнера, зокрема health
docker ps --filter name=catalog-service
# ... STATUS: Up 10 seconds (health: starting)
За деякий час (зазвичай після першого успішного проходження healthcheck) статус стане healthy:
# Через 1-2 інтервали перевірок статус має стати healthy
docker ps --filter name=catalog-service
# ... STATUS: Up 40 seconds (healthy)
Якщо вам хочеться точнішої відповіді без візуального шуму, можна витягнути статус через docker inspect:
# Дістаємо рівно health-статус (starting/healthy/unhealthy) без решти JSON
docker inspect --format='{{.State.Health.Status}}' catalog-service
# healthy
І ось дуже корисний прийом, коли щось пішло не так: healthcheck зберігає історію спроб. У inspect є блок .State.Health.Log, де лежать останні результати: час, exit code, шматок виводу команди. Не потрібно перетворювати пошук причини на курс із Go-шаблонів усередині docker inspect; тут корисно памʼятати сам факт: якщо контейнер unhealthy, дивіться вивід healthcheck-команди.
Простий сценарій «сам собі ламає» — виключно для розуміння симптомів — виглядає так: ви випадково зробили описку в URL і вказали /actuator/healt замість /actuator/health. Тоді застосунок працює, порт відкрито, але healthcheck постійно падає — і контейнер стане unhealthy. Це як ідеально працюючий ресторан, на дверях якого висить табличка "Closed" через описку. Ззовні здається, що все погано, а всередині кухарі вже третю годину страждають.
6. Корисний healthcheck замість декоративного
Коли ви вперше дізналися про HEALTHCHECK, дуже хочеться додати хоч щось, щоб контейнер був «як дорослий». І тут Docker іноді стає схожим на дипломну роботу: «Головне, щоб було, а що саме — потім розберемося». Але з healthcheck це погано працює, тому що декоративна перевірка шкідливіша за її відсутність.
Якщо healthcheck завжди повертає успіх (наприклад, echo ok), ви отримуєте хибне відчуття безпеки. Контейнер буде healthy, навіть якщо застосунок не слухає порт, упав усередині або відповідає помилками. У підсумку ви бачите зелену галочку там, де потрібна червона лампочка, — і це прямий шлях до зайвого часу на діагностику.
Якщо healthcheck занадто важкий (наприклад, він викликає бізнес-операцію, яка пише до бази або запускає експорт файлів), він починає впливати на систему. Виходить класика: «перевірка здоровʼя сама погіршує здоровʼя». Healthcheck має бути дешевим, швидким і не змінювати стан застосунку.
І ще важливий нюанс про порти. У контейнерному світі набагато зручніше мати стабільний внутрішній порт застосунку (зазвичай 8080 для Spring Boot) і змінювати лише зовнішній проброс -p hostPort:8080. Тоді HEALTHCHECK у Dockerfile залишається коректним завжди. Якщо ж ви починаєте змінювати внутрішній порт через SERVER_PORT, то ваш Dockerfile-healthcheck про це не дізнається і буде чесно перевіряти старий порт. З точки зору Docker це не баг — він робить рівно те, що ви йому написали. З точки зору людини це виглядає як «чому unhealthy, якщо я ж сам змінив порт?» — тому що ви перевіряєте здоровʼя в іншому місці.
І якщо сервіс запускається помітно довше зазвичай, краще лікувати це --start-period, а не нескінченним збільшенням retries: інакше ви маскуєте фазу ініціалізації під серію «поломок».
7. Типові помилки
Будь-яка тема в Docker гарна тим, що помилки зазвичай дуже буквальні: ви написали одне — Docker зробив саме це. Погана новина в тому, що на новачка це діє як злий жарт: одна описка перетворює все на unhealthy і створює відчуття, що зламалося все. Зараз розберемо кілька типових пасток, щоб у вас було менше таких моментів.
Помилка № 1: healthcheck перевіряє не те, тому що команда не падає на HTTP-помилці.
Якщо ви пишете curl http://127.0.0.1:8080/actuator/health без -f, то навіть при відповіді 500 curl може завершитися з кодом 0. Docker побачить exit code 0 і скаже healthy, хоча застосунок реально повертає помилку. Тому прапорець -f (або еквівалентна логіка) — не косметика, а суть перевірки.
Помилка № 2: у runtime-образі немає утиліти, якою ви перевіряєте здоровʼя.
Дуже поширена історія: ви додали HEALTHCHECK CMD ["curl", ...], зібрали образ, а контейнер одразу став unhealthy. При цьому застосунок працює, docker logs показують нормальний старт. Причина проста: curl відсутній, команда падає, Docker чесно маркує контейнер як «поганий». Лікується так: або ставите curl/wget, або обираєте відповідний runtime image.
Помилка № 3: переплутано порт або шлях, бо ви подумки живете «зовні контейнера».
Healthcheck працює всередині контейнера. Йому не потрібен localhost:hostPort, йому потрібен 127.0.0.1:containerPort. Якщо ви випадково перевіряєте не той порт або вказуєте зовнішній, healthcheck падатиме. Це типова плутанина «я перевіряю як користувач» проти «я перевіряю як контейнер».
Помилка № 4: занадто короткий --timeout або занадто частий --interval.
Якщо поставити --timeout=1s і перевіряти кожні 2 секунди, можна отримати нестабільний health навіть у нормального сервісу: інколи JVM у цей момент зайнята GC або просто процесор трохи завантажений. Healthcheck перетворюється на генератор хибних тривог. Найчастіше достатньо інтервалу 20–60 секунд і таймауту 2–5 секунд для локального backend-сервісу.
Помилка № 5: очікування, що unhealthy автоматично полагодить контейнер.
Docker health status — це сигнал, а не механізм ремонту. Він допомагає побачити проблему й дає зачіпки системам навколо Docker, але не гарантує, що контейнер сам перезапуститься або вилікується. Тому, якщо контейнер став unhealthy, ваша наступна реакція — дивитися docker logs, дивитися результати healthcheck у docker inspect і шукати причину, а не чекати, що Docker сам здогадається.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ