JavaRush /Курси /Spring Boot /Користувацький HealthIndic...

Користувацький HealthIndicator та liveness/readiness

Spring Boot
Рівень 23 , Лекція 4
Відкрита

1. Обмеження /actuator/health зі статусом UP

Після діагностики під час виконання залишається ще одне важливе питання: як машинно повідомити, що сервіс не просто запущений, а справді готовий виконувати свою функцію. Тут і потрібен health-сигнал, який дивиться на доменний стан сервісу, а не лише на факт живого процесу. З цього й почнемо.

Якщо ви раніше думали, що health-check — це щось у стилі «комп’ютер увімкнено чи ні», то ви не самі: це дуже поширена ментальна модель. Але в реальному світі backend-сервіс може бути «живим» (процес працює, порт слухає) і водночас бути зовсім марним (дані не завантажилися, конфігурація хибна, доменні інваріанти порушено). І тоді UP перетворюється на «ну так, він існує… і що з того?»

Уявімо наш catalog-service. Він лише для читання, без БД, дані приходять із конфігурації та in-memory шару. Такий сервіс може стартувати й навіть віддавати HTML-сторінку-заглушку, але каталог курсів при цьому може лишатися порожнім. З погляду 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} тоді перетворюється на лотерею.

Зверніть увагу на важливий момент: частину цих проблем ми вже ловимо на старті через валідацію конфігурації. Це правильно й добре, але 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", "Каталог порожній") // причина потрібна для діагностики
                .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", "Немає опублікованих курсів")
            .withDetail("totalCourses", courses.size()) // додаємо контекст: скільки всього
            .build();
}

Тепер перевірка дублікатів slug. Я спеціально зроблю її «в лоб» через HashSet, бо це читається легше, ніж хитрий stream-трюк. У health-check’ах читабельність зазвичай важливіша, ніж перемога в чемпіонаті зі Stream API.

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", "Дубльовані slug-значення") // пояснюємо, що саме не так
            .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", "Каталог порожній").build();
    if (published == 0) return Health.down().withDetail("reason", "Немає опублікованих курсів").build();
    if (hasDuplicateSlugs(courses)) return Health.down().withDetail("reason", "Дубльовані slug-значення").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. І Spring Boot уміє акуратно обрізати суфікс HealthIndicator.

Тобто якщо ваш бін називається catalogDataHealthIndicator, то компонент у health зазвичай буде catalogData. Саме тому назва класу CatalogDataHealthIndicator виходить вдалою: вона автоматично дає людині зрозумілий ключ у JSON.

Уявімо, що ви ввімкнули деталі, і все добре. Тоді відповідь може виглядати приблизно так — сильно спрощено, щоб не тонути в JSON:

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

А якщо, наприклад, опублікованих курсів немає, ви побачите:

{
  "status": "DOWN",
  "components": {
    "catalogData": {
      "status": "DOWN",
      "details": {
        "reason": "Немає опублікованих курсів"
      }
    }
  }
}

Сенс тут у тому, що 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 — це окремі вбудовані групи навколо availability state. Те, що перевірка каталогу за змістом ближча до readiness, ще не означає, що вона автоматично опиниться в /actuator/health/readiness: для цього її потрібно явно включити до readiness-групи.

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 вмикає вбудовані сигнали liveness/readiness, щоб ви могли побачити їх поруч із загальною health-картиною й перестали сприймати health як одну кнопку «живий/мертвий».

Важливо: show-details керує лише багатослівністю health-відповіді, а probes.enabled — появою вбудованих probe-груп. Якщо хочеться, щоб readiness враховував і наш доменний індикатор, це задається окремо. Наприклад, так:

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

Для поточної лекції достатньо зафіксувати межу: загальний /actuator/health і вбудовані probes — це не одне й те саме.

Водночас здоровий глузд залишається головним фільтром. Навіть у local-режимі не варто складати в деталі щось чутливе. Health-відповідь має бути діагностичною, а не сповіддю сервісу про всі його таємниці.

8. Типові помилки під час написання HealthIndicator

Помилка №1: перетворювати health-check на важку бізнес-операцію.
Новачки іноді пишуть health-check так, ніби це основна кінцева точка сервісу: роблять складні фільтри, намагаються пройтися по всіх даних, можуть навіть викликати зовнішній 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 Endpoints, рівень 23, лекція 4
Недоступний
Actuator Endpoints
Моніторинг і діагностика застосунку
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ