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 ждёт interval]
B --> C[Запускает healthcheck-команду внутри контейнера]
C --> D{Exit code = 0?}
D -- да --> E[Статус: healthy]
D -- нет --> F[Failing streak +1]
F --> G{Failing streak >= retries?}
G -- нет --> B
G -- да --> H[Статус: unhealthy]
E --> B
Тут есть одна важная «психологическая ловушка новичка». Многие думают, что healthcheck — это «особая проверка Docker’а», которая где‑то снаружи трогает ваш сервис. На деле всё проще: Docker запускает команду внутри контейнера, то есть проверка зависит от того, что реально лежит в вашем image, какие там права, какие утилиты есть и какой порт слушает приложение.
3. /actuator/health как цель для healthcheck в Spring Boot
Теперь логичный вопрос: «Окей, Docker умеет запускать команду. А какую команду мы хотим запускать, чтобы это имело смысл?» И вот тут Spring Boot делает нам подарок: Actuator. У 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, а снаружи меняем только host mapping вроде -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, а не «красоту текста» |
Теперь важная практическая деталь, которую легко пропустить: утилита должна существовать внутри image. Если в вашем runtime-образе нет curl, то healthcheck будет падать не потому, что сервис «болеет», а потому что «доктор не пришёл»: команды просто нет.
Поэтому в Dockerfile чаще всего делается один из двух путей: либо выбирать runtime image, где уже есть curl/wget, либо установить curl в runtime stage. Для учебного проекта подойдёт вариант с установкой 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 stage, примерно так:
# Берём собранный 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 статус может быть ещё не 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 image нет утилиты, которой вы проверяете здоровье.
Очень распространённая история: вы добавили HEALTHCHECK CMD ["curl", ...], собрали образ, а контейнер тут же стал unhealthy. При этом приложение работает, docker logs показывают нормальный старт. Причина простая: curl отсутствует, команда падает, Docker честно маркирует контейнер как “плохой”. Лечится тем, что вы либо ставите curl/wget, либо выбираете подходящий runtime image.
Ошибка №3: перепутан порт или путь, потому что вы мысленно живёте “снаружи контейнера”.
Healthcheck работает внутри контейнера. Ему не нужен localhost:hostPort, ему нужен 127.0.0.1:containerPort. Если вы случайно проверяете не тот порт или пишете внешний порт, healthcheck будет падать. Это типичная путаница “я проверяю как пользователь” vs “я проверяю как контейнер”.
Ошибка №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 «сам догадается».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ