JavaRush /Курсы /Spring Boot /Custom HealthIndicator

Custom HealthIndicator и liveness/readiness

Spring Boot
23 уровень , 4 лекция
Открыта

1. Ограничения /actuator/health со статусом UP

После runtime-диагностики остаётся ещё один важный вопрос: как machine-readable сказать, что сервис не просто запущен, а реально готов выполнять свою функцию. Здесь уже нужен health-сигнал, который смотрит на доменное состояние сервиса, а не только на факт живого процесса. С чем мы и начнем работать.

Если вы раньше думали, что health-check — это что-то в стиле «включён ли компьютер», то вы не одиноки: это очень распространённая ментальная модель. Но в реальном backend-мире сервис может быть “жив” (процесс работает, порт слушает), и при этом быть абсолютно бесполезным (данные не загрузились, конфиг кривой, доменная инвариантность нарушена). И тогда UP превращается в «ну да, он существует… и что?»

Давайте представим наш catalog-service. Он read-only, без БД, данные приходят из конфигурации и in-memory слоя. Такой сервис может стартовать и даже отдавать HTML landing page, но при этом каталог курсов может быть пустым. С точки зрения JVM всё “норм”, DispatcherServlet поднят, Tomcat бодр и весел, Actuator отвечает. Но с точки зрения пользователя это магазин без товаров: двери открыты, свет горит, касса пищит, а купить нечего.

Вот тут и появляется custom HealthIndicator: маленькая доменная проверка, которая отвечает не на вопрос «процесс жив?», а на вопрос «сервис в адекватном состоянии для выполнения своей функции?»

2. Сборка health: индикаторы, статус и детали

Важно понимать, что health в Actuator — это не одна магическая проверка «внутри Spring». Это агрегированный результат, который строится из набора маленьких компонентов: health indicators. Каждый из них может сказать «я ок» или «у меня проблема», а Spring Boot потом собирает общую картину и отдаёт JSON.

У Health-ответа есть две полезные части. Первая — статус (чаще всего UP или DOWN). Вторая — details (детали), то есть маленькие объяснения: что именно пошло не так или какие числа характеризуют состояние.

Для новичка удобно держать в голове примерно такую таблицу:

Статус Как читать по-человечески Когда уместен
UP «всё выглядит нормально» сервис готов и полезен
DOWN «сервис не в порядке» обнаружена проблема, из-за которой сервис нельзя считать рабочим
OUT_OF_SERVICE «сервис намеренно выключен из работы» например, режим обслуживания (не обязателен в нашем сценарии)
UNKNOWN «непонятно» редкий случай, обычно лучше вернуть DOWN с причиной

В нашем курсе мы не строим сложную систему статусов. Нам достаточно честного и понятного правила: если наш каталог в доменном смысле не годится, мы возвращаем DOWN и объясняем причину в деталях.

3. Проверки здоровья catalog-service

Прежде чем писать код, полезно договориться с собой, что мы вообще считаем «здоровьем» сервиса каталога. Мы же не будем проверять температуру воздуха в серверной или настроение Java-машины — у сервиса есть смысловая функция: отдавать каталог курсов.

В рамках проекта требования очень приземлённые и доменные: каталог должен быть загружен (то есть у нас есть данные), в каталоге должен быть хотя бы один опубликованный курс (иначе API фактически «пустое» для пользователей), и в каталоге не должно быть дублирующихся slug (потому что GET /api/catalog/courses/{slug} тогда превращается в лотерею).

Обратите внимание на важный момент: часть этих проблем мы уже ловим на старте через validation конфигурации. Это правильно и хорошо, но health-check всё равно полезен как runtime-сигнал. Во-первых, он фиксирует «текущее состояние» и показывает его без чтения логов. Во-вторых, он остаётся полезным даже если источник данных когда-нибудь станет внешним (сейчас — YAML, потом — что угодно). И в-третьих, он даёт нам правильную инженерную привычку: здоровье сервиса — это не только «порт открылся».

4. Реализация HealthIndicator

Минимальный каркас

Когда вы впервые пишете HealthIndicator, есть соблазн сразу начать городить “умную” проверку на 50 строк. Я предлагаю сделать по-взрослому, но по-человечески: сначала собрать самый простой каркас, чтобы понять форму, а потом постепенно добавлять смысл.

Создадим класс в пакете com.example.catalogservice.actuator. Это соответствует нашей структуре: всё actuator-специфичное лежит отдельно и не смешивается с доменом или web-слоем.

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
class CatalogDataHealthIndicator implements HealthIndicator {

    @Override
    public Health health() {
        // На старте делаем «каркас»: просто отдаём UP и пример детали.
        // Дальше заменим «число с потолка» на реальные данные из каталога.
        return Health.up()
                .withDetail("publishedCourses", 12) // полезно видеть хоть какие-то метрики
                .build();
    }
}

Да, число 12 тут взято «с потолка» — и это нормально на этапе каркаса. Этот пример важен тем, что показывает форму: метод health() возвращает Health, а внутри мы строим его через builder (Health.up() / Health.down()), добавляя детали через withDetail(...).

Подключаем реальные данные

Теперь сделаем индикатор полезным: он должен смотреть на реальный каталог, который использует наше приложение. Самый прямой вариант — взять CourseCatalogRepository (или сервис) и вытащить список курсов. Важно, чтобы индикатор не начинал «вычислять бизнес-логику», а просто аккуратно проверял состояние данных.

Пусть у нас есть репозиторий с методом findAll(). Если вы забыли, как выглядит минимальный контракт — вот очень короткая форма (она не обязана совпадать 1-в-1 с вашим кодом, это просто напоминание модели):

import java.util.List;

interface CourseCatalogRepository {

    // Возвращаем «срез данных для чтения» — health-check не должен менять состояние.
    List<CourseCard> findAll();
}

Теперь внедрим репозиторий через constructor injection (наш стиль курса).

import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
class CatalogDataHealthIndicator implements HealthIndicator {

    private final CourseCatalogRepository repository;

    CatalogDataHealthIndicator(CourseCatalogRepository repository) {
        // Репозиторий даём через конструктор: проще тестировать и нет магии с полями.
        this.repository = repository;
    }
}

Обратите внимание: мы ничего не делаем в конструкторе, не «грузим данные», не запускаем проверки. Health-check должен выполняться по запросу к /actuator/health, а не на старте приложения.

Доменные проверки: загружено, есть published, нет дублей

Теперь собираем саму логику в health(). Здесь важно держать баланс: проверка должна быть достаточно информативной, но не превращаться в мини-сервис внутри сервиса. Health-check вызывается часто (иногда очень часто), поэтому он должен быть быстрым и предсказуемым.

Сначала проверим самое базовое: есть ли вообще курсы.

import java.util.List;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;

@Override
public Health health() {
    // Получаем текущий «снимок» каталога.
    List<CourseCard> courses = repository.findAll();

    // Если данных нет — сервис, по сути, не может выполнять свою функцию.
    if (courses == null || courses.isEmpty()) {
        return Health.down()
                .withDetail("reason", "Catalog is empty") // причина нужна для диагностики
                .build();
    }

    return Health.up().build();
}

Дальше добавим проверку «есть хотя бы один published». Тут мы используем CourseCard::published — предполагая, что у CourseCard есть булево поле published. Для record это будет метод published().

import java.util.List;
import org.springframework.boot.actuate.health.Health;

// Считаем опубликованные курсы: это уже доменная полезность сервиса.
long publishedCount = courses.stream()
        .filter(CourseCard::published)
        .count();

if (publishedCount == 0) {
    return Health.down()
            .withDetail("reason", "No published courses")
            .withDetail("totalCourses", courses.size()) // добавляем контекст: сколько всего
            .build();
}

Теперь проверка дублей slug. Я специально сделаю её «в лоб» через HashSet, потому что это читается легче, чем хитрый stream-трик. В health-check’ах читаемость обычно важнее, чем победа в чемпионате по стримам.

import java.util.HashSet;
import java.util.List;
import java.util.Set;

private boolean hasDuplicateSlugs(List<CourseCard> courses) {
    // `seen` хранит уже встреченные slug'и.
    Set<String> seen = new HashSet<>();

    for (CourseCard c : courses) {
        // add() вернёт false, если элемент уже был в set — значит, нашли дубль.
        if (!seen.add(c.slug())) {
            return true;
        }
    }

    return false;
}

И теперь используем эту проверку внутри health():

if (hasDuplicateSlugs(courses)) {
    return Health.down()
            .withDetail("reason", "Duplicate slugs") // объясняем, что именно не так
            .build();
}

На этом этапе у нас уже получается честный, доменно осмысленный сигнал: если данные плохие, сервис считает себя «нездоровым», даже если процесс жив.

Итоговый health(): статус + полезные детали

Теперь хочется сделать две вещи. Во-первых, собрать всё в одну понятную реализацию health(), чтобы не было ощущения «кусочки логики раскиданы где попало». Во-вторых, добавить детали, которые помогают диагностировать проблему (но не превращают ответ в “дамп всего на свете”).

Хороший компромисс — всегда возвращать пару базовых чисел (total/published), а при проблеме — добавлять reason.

import java.util.List;
import org.springframework.boot.actuate.health.Health;

@Override
public Health health() {
    // В этом примере считаем, что репозиторий возвращает не-null список.
    List<CourseCard> courses = repository.findAll();

    // Сколько курсов реально доступно пользователю.
    long published = courses.stream()
            .filter(CourseCard::published)
            .count();

    // Короткие ветки: при проблеме сразу отдаём DOWN с причиной.
    if (courses.isEmpty()) return Health.down().withDetail("reason", "Catalog is empty").build();
    if (published == 0) return Health.down().withDetail("reason", "No published courses").build();
    if (hasDuplicateSlugs(courses)) return Health.down().withDetail("reason", "Duplicate slugs").build();

    // В норме отдаём UP и базовые метрики для наблюдаемости.
    return Health.up()
            .withDetail("totalCourses", courses.size())
            .withDetail("publishedCourses", published)
            .build();
}

Да, тут немного «однострочных if’ов». Для учебного примера это нормально: у нас короткие ветки и понятный смысл. В реальном проекте можно развернуть в обычные блоки { ... }, если так читается легче.

5. /actuator/health: компоненты и имя индикатора

Когда вы добавляете HealthIndicator, он появляется как компонент в health-ответе. Обычно это видно, когда включены детали (про конфигурацию мы поговорим чуть позже). Но даже если детали выключены, общий статус будет учитывать ваш индикатор.

Есть любопытный нюанс: как называется ваш вклад в health? В ответе /actuator/health Spring Boot показывает компоненты с именами вроде diskSpace, ping и т.д. Для пользовательских индикаторов имя выводится на основе bean name. И Spring Boot умеет «культурно» обрезать суффикс HealthIndicator.

То есть если ваш бин называется catalogDataHealthIndicator, то компонент в health обычно будет catalogData. Именно поэтому имя класса CatalogDataHealthIndicator получается удачным: оно автоматически даёт человеку понятный ключ в JSON.

Представим, что вы включили детали, и всё хорошо. Тогда ответ может выглядеть примерно так (сильно упрощённо, чтобы не тонуть в JSON):

{
  "status": "UP",
  "components": {
    "catalogData": {
      "status": "UP",
      "details": {
        "totalCourses": 10,
        "publishedCourses": 8
      }
    }
  }
}

А если, например, published курсов нет, вы увидите:

{
  "status": "DOWN",
  "components": {
    "catalogData": {
      "status": "DOWN",
      "details": {
        "reason": "No published courses"
      }
    }
  }
}

Смысл здесь в том, что status без деталей — это “красная лампочка”, а детали — это “почему лампочка загорелась”.

6. Liveness и readiness: «жив» и «готов»

Слова liveness и readiness часто звучат страшнее, чем они есть. На самом базовом уровне это просто два разных вопроса к системе. Liveness — «процесс вообще жив?»; readiness — «сервис готов обслуживать запросы корректно?». И это действительно разные вещи, примерно как «я проснулся» и «я готов к экзамену». Между ними обычно лежит кофе.

В контексте Spring Boot идея такая: приложение может быть живо (JVM не умерла, контекст не рухнул), но быть неготово принимать трафик, потому что не закончило инициализацию, или потому что важные зависимости/данные в плохом состоянии. Для больших систем это критично: если внешний мир начнёт слать трафик, пока сервис не готов, вы получите лавину ошибок и странную деградацию.

В нашем catalog-service связь особенно простая. Если каталог пустой или “сломанный” (например, дубли slug), то сервис, по сути, не готов быть полезным: он либо ничего не отдаёт, либо отдаёт непредсказуемые данные. Это уже ближе к readiness, чем к liveness.

Spring Boot умеет показывать эти сигналы отдельными “probe”-эндпоинтами через health-группы, но мы держим тему в маленьких, понятных границах: мы не строим сложную оркестрацию, мы просто включаем эти сигналы и понимаем их смысл.

Здесь легко смешать два слоя. Текущий CatalogDataHealthIndicator уже участвует в aggregate /actuator/health, то есть влияет на общий health-ответ. А liveness/readiness probes — это отдельные built-in группы вокруг availability state. То, что проверка каталога по смыслу ближе к readiness, ещё не означает, что она автоматически окажется в /actuator/health/readiness: для этого её нужно явно включить в readiness group.

7. Конфигурация: health details и probes

Чтобы увидеть пользу HealthIndicator, обычно хочется видеть детали. Но в production-подобной среде детали health иногда скрывают, чтобы не раскрывать лишнего. Поэтому самый здравый подход для учебного проекта: включить детали в local/dev, но не делать это «везде и навсегда».

В локальном профиле можно добавить:

management:
  endpoint:
    health:
      show-details: "always" # показываем details, чтобы видеть reason/метрики при отладке
      probes:
        enabled: true # включаем liveness/readiness probes (удобно для локальной проверки)

Здесь show-details: always делает ответ health информативным (вы увидите reason, totalCourses, publishedCourses). А probes.enabled: true включает built-in сигналы liveness/readiness, чтобы вы могли увидеть их рядом с общей health-картиной и перестали воспринимать health как одну кнопку «жив/мертв».

Важно: show-details управляет только разговорчивостью health-ответа, а probes.enabled — появлением built-in probe-групп. Если хочется, чтобы readiness учитывал и наш доменный индикатор, это задаётся отдельно. Например, так:

management:
  endpoint:
    health:
      group:
        readiness:
          include: "readinessState,catalogData"

Для текущей лекции достаточно зафиксировать границу: общий /actuator/health и built-in probes — не одно и то же.

При этом здравый смысл остаётся главным фильтром. Даже в local-режиме не стоит складывать в детали что-то чувствительное. Health-ответ должен быть диагностическим, а не исповедью сервиса о всех его секретах.

8. Типичные ошибки при написании HealthIndicator

Ошибка №1: превращать health-check в тяжёлую бизнес-операцию.
Новички иногда пишут health-check так, как будто это основной endpoint сервиса: делают сложные фильтры, пытаются «пройти по всем данным», могут даже вызвать внешний HTTP. В результате health становится медленным и нестабильным. Health-check должен быть быстрым: маленькая проверка инвариантов, а не мини-ETL.

Ошибка №2: возвращать только UP/DOWN без объяснения причины.
Статус без деталей — это как сообщение «ошибка» без текста. Формально оно правдивое, но бесполезное. Если вы делаете DOWN, добавьте хотя бы reason. Вы удивитесь, насколько это сокращает время диагностики, особенно когда проблема проявляется не у вас на ноутбуке, а “где-то в окружении”.

Ошибка №3: путать liveness и readiness в голове.
Иногда разработчик пытается «запихнуть всё» в liveness: если данные не те — значит сервис «не жив». Но «не жив» — это про то, что процесс должен быть перезапущен, а «не готов» — про то, что трафик лучше не давать, пока не исправим состояние. Даже если вы не настраиваете группы явно, держать это различие в голове полезно.

Ошибка №4: бросать исключения вместо контролируемого Health.down().
Если внутри health() вы кидаете исключение, вы усложняете картину: иногда Actuator его поймает, иногда вы получите странный ответ, а иногда — просто шум в логах. Лучше обрабатывать ожидаемые ситуации и возвращать понятный Health.down().withDetail("reason", "..."). Исключения оставьте для действительно неожиданных аварий.

Ошибка №5: добавлять в details то, что вы не готовы показать наружу.
Health часто оказывается доступен не только разработчику. Даже если сейчас это “локальный сервис”, привычка «положить в details всё подряд» плохо переносится дальше. Не кладите туда секреты, токены, полный дамп конфигурации, большие списки сущностей. Детали должны помогать быстро понять состояние, а не превращать endpoint в утечку.

1
Задача
Spring Boot, 23 уровень, 4 лекция
Недоступна
Собственный `HealthIndicator` со статусом `UP`
Собственный `HealthIndicator` со статусом `UP`
1
Задача
Spring Boot, 23 уровень, 4 лекция
Недоступна
Пустой каталог как `DOWN` и отдельные probes
Пустой каталог как `DOWN` и отдельные probes
1
Опрос
Actuator Эндпоинты, 23 уровень, 4 лекция
Недоступен
Actuator Эндпоинты
Мониторинг и диагностика приложения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ