← назад к разделу

Когда приложение падает в продакшене, первое, на что смотрит Kubernetes — это health-эндпоинты. Если они настроены неправильно, одна лагающая база данных может положить весь сервис целиком, даже если с процессом всё в порядке. Разберём, как работают health checks в Spring Boot и как их настроить правильно.

Цена неверной проверки считается просто — на одном потоке заказов и четырёх репликах.

Порог «в очереди больше 1000 заказов → DOWN» в readiness-проверкепоток 400 заказов/с; квадрат — реплика, пустой = снята из балансировки заказов/с на живую реплику0100200300400 в строю 4 из 4100/с в строю 3 из 4133/с в строю 2 из 4200/с в строю 1 из 4400/с в строю 0 из 4нет живых реплик → 503 на весь сервис Как надо: health UP, очередь — в Prometheusалерт → +1 реплика, нагрузка падаетв строю 5 из 580/с DOWN по бизнес-порогу не гасит поток, а сгущает его100 → 133 → 200 → 400 заказов/с на живую реплику, дальше 503health — техническое состояние процесса, а не бизнес-число

Бизнес-порог в health-проверке снимает реплику, но не снимает работу: её поток переезжает к соседям и роняет их по очереди. Очередь заказов — повод для алерта, а не для DOWN.

Обязательно

Что такое health checks и зачем их два

Kubernetes задаёт каждому поду два вопроса:

  1. Жив ли процесс? Если нет — перезапустить.
  2. Готов ли он принимать трафик? Если нет — убрать из балансировки.

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.

Что нужно, по порядку:

  1. Сначала перестать быть готовым, потом останавливаться. Spring Boot умеет это сам: management.endpoint.health.probes.enabled=true вместе с корректной остановкой переводит состояние готовности в REFUSING_TRAFFIC при получении сигнала завершения, и /actuator/health/readiness начинает отвечать 503 раньше, чем закрываются соединения. Тогда проба снимает реплику из балансировки, а процесс в это время досчитывает начатое.
  2. Дать этому время. Даже отвечая 503, реплика уходит из балансировки не мгновенно: нужно дождаться, пока это заметят все слои. Поэтому в манифесте ставят паузу перед остановкой процесса (preStop с ожиданием 5–10 секунд) — за это время проба успевает провалиться дважды и трафик перестаёт приходить.
  3. Корректно завершить начатое. 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 все подряд /health/readiness состав группы /health/liveness livenessState свой индикатор в общем health есть в readiness нет

Пробы смотрят не на общий /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 при выкате лечатся порядком: сначала отказ от трафика через состояние готовности, потом пауза перед остановкой, потом корректное завершение начатых запросов.

Что почитать дальше