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 на витік.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ