Когда приложение падает в продакшене, первое, на что смотрит Kubernetes — это health-эндпоинты. Если они настроены неправильно, одна лагающая база данных может положить весь сервис целиком, даже если с процессом всё в порядке. Разберём, как работают health checks в Spring Boot и как их настроить правильно.
Цена неверной проверки считается просто — на одном потоке заказов и четырёх репликах.
Бизнес-порог в health-проверке снимает реплику, но не снимает работу: её поток переезжает к соседям и роняет их по очереди. Очередь заказов — повод для алерта, а не для DOWN.
Что такое health checks и зачем их два
Kubernetes задаёт каждому поду два вопроса:
- Жив ли процесс? Если нет — перезапустить.
- Готов ли он принимать трафик? Если нет — убрать из балансировки.
Spring Boot Actuator отвечает на эти вопросы через два отдельных эндпоинта: /actuator/health/liveness и /actuator/health/readiness.
Включить их в application.yml:
management:
endpoint:
health:
probes:
enabled: true
show-details: when_authorized
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
show-details: when_authorized — не перестраховка. В деталях ответа лежат сообщения исключений от базы и внешних систем: адреса, имена схем, иногда куски запроса. Пробам Kubernetes детали не нужны вовсе, им хватает кода ответа, а показывать их всем, кто дотянулся до эндпоинта, незачем.
Теперь доступны два эндпоинта с разной семантикой:
| Эндпоинт | Что означает UP | Что делает K8s при DOWN |
|---|---|---|
/actuator/health/liveness | процесс жив, JVM отвечает | перезапускает pod |
/actuator/health/readiness | сервис готов: БД подключена, прогрев завершён | снимает pod из балансировки |
Почему liveness и readiness нельзя смешивать
Представьте: база данных подлагивает 30 секунд из-за технического обслуживания. Что должно произойти?
- Readiness должен стать DOWN — трафик уйдёт на другие реплики, которые работают нормально.
- Liveness должен остаться UP — перезапуск пода не исправит ситуацию с базой, а только добавит хаоса.
Если liveness зависит от базы данных, происходит следующее: база лагает → liveness DOWN → Kubernetes убивает pod → новый pod стартует, та же база всё ещё лагает → DOWN снова → убивает снова. За минуту все реплики падут, сервис недоступен.
Liveness проверяет только сам процесс: JVM жива, потоки не заморожены, диск доступен. Внешние зависимости — только в readiness.
Пример опасного кода, который нельзя использовать для liveness:
// НЕЛЬЗЯ — liveness упадёт вместе с базой
@Component
@RequiredArgsConstructor
public class CustomLivenessIndicator implements HealthIndicator {
private final JdbcTemplate jdbcTemplate;
@Override
public Health health() {
try {
jdbcTemplate.execute("SELECT 1");
return Health.up().build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
Kubernetes manifests для probes
spec:
containers:
- name: order-service
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8081
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 2
Порт 8081 — отдельный management port (management.server.port). Это позволяет ограничить /actuator/* сетевой политикой так, чтобы он был доступен только внутри кластера.
Откуда взялись эти числа
В манифесте семь чисел, и каждое стоит прочитать не как «так принято», а как ответ на вопрос «через сколько секунд сбоя мы что-то сделаем». Арифметика простая: реакция = период × порог.
У readiness: periodSeconds: 5 и failureThreshold: 2 дают 10 секунд. То есть база, подлагивающая 30 секунд из примера выше, снимет реплику из балансировки через десять; короткая «икота» на 3–5 секунд не снимет вообще, и это правильно — снимать реплику из-за одного неудачного запроса дороже, чем подождать. Хотите реагировать быстрее — уменьшайте период, а не порог: порог 1 делает проверку чувствительной к единичному сетевому сбою.
У liveness: periodSeconds: 10 и failureThreshold: 3 дают 30 секунд до перезапуска. Здесь длиннее сознательно: перезапуск — крайняя мера, и цена ошибочного срабатывания высока. Добавьте сюда timeoutSeconds (по умолчанию 1 секунда, и это самое коварное число в манифесте: проверка, отвечающая 1,2 секунды под нагрузкой, считается неудачной, а в журнале это выглядит как «под убили без причины»).
initialDelaySeconds: 30 — это костыль, и у него есть штатная замена. Тридцать секунд взяты из предположения «за столько сервис точно поднимется». Предположение ломается в обе стороны: сервис, поднимающийся 45 секунд (прогрев кеша, миграции, медленный старт на загруженном узле), будет убит liveness-проверкой на тридцатой секунде — и так по кругу, никогда не запустившись; а сервис, поднимающийся за 5 секунд, 25 секунд ждёт зря при каждом выкате.
Штатное решение — проба запуска:
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
periodSeconds: 5
failureThreshold: 60 # до 5 минут на старт, без вреда для liveness
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
periodSeconds: 10
failureThreshold: 3
# initialDelaySeconds больше не нужен
Работает она так: пока проба запуска не прошла, liveness и readiness не опрашиваются вовсе, и убить под они не могут. Как только она прошла один раз, она больше не выполняется, а в дело вступают обычные пробы. То есть допустимое время старта задаётся щедро (periodSeconds × failureThreshold = 5 минут), а время реакции на зависание остаётся коротким — раньше эти два требования конфликтовали, и initialDelaySeconds был компромиссом между ними.
Readiness при остановке: откуда берутся 502 при выкате
Самая частая жалоба после «всё настроено правильно» звучит так: при каждом выкате в графиках всплеск 502 и 504. Причина лежит ровно в этой теме, и она не в пробах, а в порядке событий при остановке.
Что происходит без настройки. Оркестратор решает погасить под: одновременно посылает процессу сигнал завершения и начинает удалять адрес пода из списка получателей трафика. Второе занимает секунды (список готовых адресов, правила на узлах, таблицы балансировщика — в каждом слое своя задержка). Процесс в это время уже закрывает соединения, а трафик на него ещё идёт: каждый такой запрос и есть 502.
Что нужно, по порядку:
- Сначала перестать быть готовым, потом останавливаться. Spring Boot умеет это сам:
management.endpoint.health.probes.enabled=trueвместе с корректной остановкой переводит состояние готовности вREFUSING_TRAFFICпри получении сигнала завершения, и/actuator/health/readinessначинает отвечать503раньше, чем закрываются соединения. Тогда проба снимает реплику из балансировки, а процесс в это время досчитывает начатое. - Дать этому время. Даже отвечая
503, реплика уходит из балансировки не мгновенно: нужно дождаться, пока это заметят все слои. Поэтому в манифесте ставят паузу перед остановкой процесса (preStopс ожиданием 5–10 секунд) — за это время проба успевает провалиться дважды и трафик перестаёт приходить. - Корректно завершить начатое.
server.shutdown=gracefulи срок ожидания дают начатым запросам доработать.
Состоянием готовности можно управлять и из кода — например, чтобы не принимать трафик, пока не прогрелся кеш:
@Component
@RequiredArgsConstructor
public class WarmupListener {
private final ApplicationEventPublisher events;
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
events.publishEvent(new AvailabilityChangeEvent<>(this,
ReadinessState.REFUSING_TRAFFIC));
warmUpCaches();
events.publishEvent(new AvailabilityChangeEvent<>(this,
ReadinessState.ACCEPTING_TRAFFIC));
}
}
Текущее состояние читается через ApplicationAvailability — это полезно и в собственном коде: «не брать задачу из очереди, если мы уже отказываемся от трафика».
Проверяется всё это не глазами: держите на сервис ровный поток запросов и выкатитесь. Ноль ошибок в отчёте — настроено; всплеск — ищите, какой из трёх пунктов пропущен. Подробный разбор остановки по слоям — в разделе про корректную остановку.
Custom HealthIndicator для внешних систем
Spring Boot автоматически проверяет базу данных и Redis, если они подключены. Для остальных внешних систем нужно писать свой HealthIndicator.
И сразу то, на чём спотыкаются почти все. Написанный вами индикатор появится в общем /actuator/health — и не появится в /actuator/health/readiness. Группы liveness и readiness Spring Boot собирает не из всех индикаторов подряд, а из двух своих: livenessState и readinessState. То есть по умолчанию ваша проверка не снимает реплику из балансировки и вообще ни на что не влияет — её никто не спрашивает. Состав группы нужно перечислить явно:
management:
endpoint:
health:
group:
readiness:
include: readinessState,db,paymentProvider
paymentProvider здесь — имя индикатора: Spring берёт имя класса без суффикса HealthIndicator, так что PaymentProviderHealthIndicator превращается в paymentProvider сам.
Пробы смотрят не на общий /actuator/health, а на группы: в liveness попадает только livenessState, в readiness только то, что перечислено явно, поэтому свой индикатор без такой записи виден в общем health и на балансировку не влияет.
И обратная ошибка, которая выглядит как упрощение: повесить пробу на общий /actuator/health вместо группы. Общий эндпоинт агрегирует все индикаторы по правилу «худший из всех»: стоит любому из них уйти в DOWN — и весь ответ становится 503, а вместе с ним падает проба. Дальше сценарий предсказуем: подлагивает второстепенная зависимость (кеш, поиск, отчётный сервис) → общий health отдаёт 503 → readiness снимает все реплики из балансировки → сервис недоступен целиком из-за системы, без которой он вообще-то может работать. Если то же повесить на liveness, добавится каскадный перезапуск.
Поэтому правило такое: пробы всегда смотрят на группы (/actuator/health/liveness и /actuator/health/readiness), состав групп перечислен явно, и в readiness попадает только то, без чего сервис не может обслуживать запросы. Кеш, из которого можно читать не всегда, поиск с запасным путём, отчёты — в readiness не входят: их состояние видно в общем health и в метриках, и на них ставят тревогу, а не снятие трафика.
А вот теперь, когда индикатор в группе, его начнут опрашивать по-настоящему — и появляется вторая проблема. Kubernetes опрашивает readiness каждые 5 секунд, и при 10 репликах это 2 запроса в секунду только от health checks. Если каждый из них реально обращается к внешнему провайдеру — получается непрерывная нагрузка, которая может исчерпать лимиты API.
Решение — TTL-кеш: проверяем провайдера не чаще раза в 10-30 секунд, остальным запросам отдаём сохранённый результат.
@Component
@RequiredArgsConstructor
public class PaymentProviderHealthIndicator implements HealthIndicator {
private final PaymentProviderClient client;
private final AtomicReference<CachedHealth> cache = new AtomicReference<>();
private static final Duration TTL = Duration.ofSeconds(10);
@Override
public Health health() {
var cached = cache.get();
if (cached != null && cached.expiresAt().isAfter(Instant.now())) {
return cached.health();
}
var health = checkProvider();
cache.set(new CachedHealth(health, Instant.now().plus(TTL)));
return health;
}
private Health checkProvider() {
try {
client.ping();
return Health.up().withDetail("provider", "payment").build();
} catch (Exception e) {
return Health.down(e).withDetail("provider", "payment").build();
}
}
private record CachedHealth(Health health, Instant expiresAt) {}
}
ping() должен быть лёгким запросом — GET /health или OPTIONS /. Не нужно создавать тестовые данные или выполнять реальные бизнес-операции: каждые 5 секунд на 10 репликах это уже 120 реальных бизнес-операций в минуту от одних лишь проверок здоровья.
У этого кеша есть два незакрытых места, и оба всплывают именно в тот момент, когда провайдеру плохо.
Первый запрос после протухания. Как написано, поток пробы сам идёт к провайдеру, когда срок истёк. Значит, при недоступном провайдере проба ждёт таймаута — а у пробы свой срок ожидания, по умолчанию секунда: она провалится не потому, что провайдер лежит, а потому, что проверка не успела. И на десяти репликах все десять пойдут к провайдеру одновременно, добавив нагрузки тому, кому и так плохо.
Правильнее не проверять внутри пробы вообще: проверку выполняет планировщик по расписанию и складывает результат, а индикатор только читает готовое значение из памяти. Тогда проба отвечает мгновенно и всегда, а к провайдеру ходит один поток раз в десять секунд:
@Component
@RequiredArgsConstructor
public class PaymentProviderHealthIndicator implements HealthIndicator {
private final PaymentProviderClient client;
private final AtomicReference<Health> last =
new AtomicReference<>(Health.unknown().build());
@Scheduled(fixedDelay = 10_000)
void refresh() {
try {
client.ping();
last.set(Health.up().build());
} catch (Exception e) {
last.set(Health.down(e).build());
}
}
@Override
public Health health() {
return last.get();
}
}
Что отдавать, когда проверка не удалась. Одна неудачная проверка — это ещё не DOWN: сеть моргает, провайдер перезапускает копию, срок ожидания истёк на пике. Индикатор, падающий от каждого чиха, снимает реплики из балансировки по чужой мелкой неприятности. Поэтому в DOWN переводят не по первой ошибке, а по нескольким подряд (два-три раза), а до этого отдают последнее известное состояние. Обратный переход — сразу: первая успешная проверка возвращает UP.
И третье, менее очевидное: у пробы должен быть свой срок ожидания короче, чем у пробы оркестратора. Индикатор, который может думать три секунды при односекундном сроке пробы, гарантированно её роняет — независимо от состояния провайдера.
/actuator/info: какая версия сейчас в продакшене
Пробы отвечают «жив» и «готов», но при инциденте первым делом спрашивают третье: «какая версия сейчас задеплоена?» Без специальной настройки ответ приходится искать в логах CI/CD. Гораздо удобнее — спросить у самого сервиса.
Плагин для Gradle называется com.gorylenko.gradle-git-properties — он кладёт в jar файл git.properties, который Spring Boot и показывает. (git-commit-id-maven-plugin, который часто встречается в примерах, — это его ровесник для Maven, в Gradle он не подключается.) Дописываем в application.yml:
management:
endpoints:
web:
exposure:
include: health,info # info по умолчанию закрыт, его надо открыть
info:
git:
mode: full
build:
enabled: true
env:
enabled: true # без этого блок info.* в ответ не попадёт
info:
service:
name: ${spring.application.name}
Две строки с комментариями — ровно те, из-за которых раздел обычно не работает с первого раза. Наружу по HTTP Spring Boot по умолчанию отдаёт один только health, а произвольные свойства info.* перестали попадать в ответ ещё с версии 2.6, пока их не разрешить явно.
После этого /actuator/info отвечает:
{
"git": {
"commit": {
"id": "5380f21abc...",
"time": "2026-05-25T22:24:00Z"
},
"branch": "main"
},
"build": {
"version": "1.4.2",
"time": "2026-05-25T22:25:30Z",
"artifact": "order-service"
},
"service": {
"name": "order-service"
}
}
Когда всё это не нужно
Две ситуации, где половину статьи можно не применять, и лучше это знать, чтобы не строить лишнего.
Нет внешних зависимостей — readiness совпадает с liveness. Сервис, который считает и отвечает, не обращаясь ни к базе, ни к соседям, готов ровно тогда, когда жив. Держать две разные проверки не нужно: обе смотрят на состояние процесса, и /actuator/health/liveness годится в обе пробы. Что при этом всё равно стоит сделать — пробу запуска, если старт не мгновенный, и отдельную готовность на время прогрева, если есть что прогревать.
Нет оркестратора — пробы некому спрашивать. Сервис, запущенный как системная служба на машине, перезапускает не проба, а диспетчер служб, и по факту завершения процесса, а не по ответу эндпоинта. Здесь /actuator/health остаётся полезным как инструмент дежурного (открыть и посмотреть, какая зависимость упала) и как цель внешней проверки, но настройки периодов и порогов из этой статьи никуда не подключаются. То же в схеме с балансировщиком без оркестратора: балансировщик обычно умеет проверять адрес и выводить машину из оборота — там пригодится только группа готовности, и настраивается она в самом балансировщике.
Чего не стоит делать в обоих случаях — выдумывать «свой health» в виде контроллера, отдающего {"status":"ok"}. Такая проверка отвечает 200, пока в процессе жив поток обработки запросов, то есть почти всегда, — и не отвечает ни на один вопрос, ради которых пробы существуют.
Частые ошибки
Бизнес-метрики вместо технического состояния. «Если накопилось больше 1000 необработанных заказов — выставить DOWN» — это не про здоровье процесса. Health DOWN приводит к тому, что K8s снимает реплику из балансировки, оставшиеся реплики получают ещё больше заказов, накопление растёт быстрее. Спираль, которая заканчивается полной недоступностью сервиса.
Бизнес-метрики отслеживают через Prometheus + алертинг, отдельно от health checks.
HealthIndicator без кеша. Если убрать TTL-кеш из примера выше — каждый вызов probe будет реально ходить к провайдеру. При многих репликах и частых проверках это создаёт нагрузку, которая мешает реальным запросам.
Liveness зависит от внешних систем. Это самая опасная ошибка: приводит к каскадному перезапуску всех pod при любой проблеме с зависимостями.
Глубже: проверка снаружи: синтетические пробы и расхождение с внутренними метрикамирасширенное
Всё в разделе измеряет систему изнутри: пробы спрашивает kubelet, метрики снимает Prometheus по внутренней сети. Пользователь при этом может не видеть сервис вовсе: истёк сертификат, сломался DNS, упал ingress, сеть доставки отдаёт старую версию, а все внутренние графики зелёные, потому что до сервиса запросы не доходят.
Синтетические пробы это запросы к сервису из внешней точки по расписанию, как их сделал бы пользователь: раз в минуту открыть главную, пройти вход, положить товар в корзину. Самый простой вариант это blackbox exporter для Prometheus, который проверяет HTTP-ответ, код, время и срок сертификата с указанного адреса; у облаков и коммерческих сервисов мониторинга есть пробы из десятков точек мира со сценариями в браузере. Результат это отдельный SLI «доступность снаружи», и он расходится с внутренним ровно в тех инцидентах, которые внутренний не видит.
Что ловит только снаружи. Сертификат, срок которого истекает через три дня (проба отдаёт дни до истечения, и на них ставят тревогу). DNS, который перестал отвечать или указывает не туда. Ingress и балансировщик, у которых закончились соединения. Сеть доставки контента с устаревшим кэшем. Ошибку на клиенте: страница отдаётся, а скрипт падает, и это видит только мониторинг реального пользователя (RUM), который собирает ошибки и время загрузки из браузера.
Как не ловить ложное. Одна точка проверки ошибается из-за собственной сети, поэтому пробы идут из двух-трёх точек, и тревога срабатывает, когда падают две из трёх. Пробу помечают заголовком, чтобы исключить из бизнес-метрик и лимитов, и она не создаёт настоящих заказов: тестовый пользователь, тестовый товар, отмена в конце сценария.
Расхождение как сигнал. Внутренний SLI 99,99 %, внешний 99,5 %: разница и есть слой между сервисом и пользователем, и её разбирают отдельно от кода. Обе цифры стоят рядом на одном экране, а тревога на внешнюю срабатывает независимо от внутренней, потому что «у нас всё зелёное» это худший ответ пользователю, который не может открыть сайт.
Коротко
- Health checks бывают двух видов: liveness (жив ли процесс) и readiness (готов ли принимать трафик).
- Liveness зависит только от самого процесса — JVM, потоки, диск. Никаких внешних систем. Readiness проверяет доступность зависимостей: базу, кеши, внешние API.
- Для каждой внешней системы пишем свой
HealthIndicatorс TTL-кешем, чтобы не нагружать провайдера частыми проверками. Probe-метод должен быть лёгким:GET /health,OPTIONS /. Не бизнес-операция. - Health — техническое состояние процесса и его зависимостей. Бизнес-метрики — в Prometheus.
- Свой
HealthIndicatorсам по себе в группуreadinessне попадает — состав группы перечисляют явно черезmanagement.endpoint.health.group.readiness.include. Пробы смотрят только на группы, а не на общий/actuator/health: он падает от любого индикатора и снимает из балансировки все реплики из-за второстепенной зависимости. /actuator/infoс плагиномgradle-git-propertiesпозволяет мгновенно ответить на вопрос «что сейчас в продакшене» — не забыв открыть сам эндпоинт.- Изнутри не видно сертификата, DNS, ingress и ошибок в браузере: синтетические пробы из двух-трёх внешних точек по сценарию пользователя дают отдельный SLI, тревога по двум из трёх, расхождение с внутренним разбирают как отдельный слой.
- Время реакции = период × порог: readiness 5×2 = 10 секунд, liveness 10×3 = 30;
timeoutSecondsпо умолчанию 1 секунда, и проверка, отвечающая дольше, считается неудачной. initialDelaySecondsзаменяет проба запуска: пока она не прошла, остальные пробы не опрашиваются, поэтому старт получает щедрый срок, а реакция на зависание остаётся короткой.- 502 при выкате лечатся порядком: сначала отказ от трафика через состояние готовности, потом пауза перед остановкой, потом корректное завершение начатых запросов.
Что почитать дальше
- Метрики и Micrometer — как мониторить бизнес-метрики через Prometheus.
- Трассировка запросов — как связать логи и запросы через trace ID.
- SLO и алерты — как ставить цели по доступности и настраивать алерты.