Выкатили сервис, и через час дежурный задаёт три вопроса подряд: он вообще жив, почему заказы стали медленнее и можно ли на минуту включить DEBUG для одного пакета, не перезапуская под. Без подготовки на каждый вопрос уходит по получасу: смотреть логи, гадать по графикам, готовить выкат с новым уровнем лога. Spring Boot отвечает на все три одним стартером, и эта статья про него: что Actuator умеет, что из этого стоит отдавать наружу и кому, и где он начинает стоить дорого. Метрики, трассировка и логи разобраны в отдельных статьях этой фазы, здесь вход в них.
Дороже всего в этом стеке обходится не Actuator, а одна ошибка в метриках: число рядов в Prometheus задаёт не поток заказов, а множество значений тегов.
Число рядов задают теги, а не трафик: 24 ряда живут и при сотне заказов, и при десяти миллионах, а один тег user_id превращает их в 1,2 млн и 3,6 ГБ памяти. Разрез по конкретному пользователю — это лог с traceId, а не метрика.
Зачем Actuator, если можно написать /health руками
Контроллер /health, который возвращает 200 OK, пишется за минуту, и в этом его проблема: он отвечает «жив», пока жив процесс. Пул соединений к базе исчерпан, диск кончился, брокер недоступен, а контроллер продолжает отвечать двухсоткой, и балансировщик шлёт трафик в сервис, который ничего не может.
Actuator собирает состояние из индикаторов, которые приносят с собой стартеры: подключили JDBC, появилась проверка базы; подключили Kafka или Redis, появились их индикаторы; свободное место на диске проверяется всегда. Один эндпоинт агрегирует их в общий статус, и платформа получает ответ в формате, который понимает без договорённостей. Это первое, ради чего ставят стартер:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
}
Второе то, что кроме health стартер приносит два десятка эндпоинтов, которые руками никто не напишет: метрики JVM и HTTP, управление уровнями логов, дампы потоков и кучи, список бинов и маршрутов. Каждый из них нужен своему читателю, и от этого зависит, кому его открывать.
У каждого эндпоинта свой читатель, и этим задан доступ: health отдают платформе без пароля, метрики только сборщику, всё остальное только своим и лучше на отдельном порту.
Что отдано наружу и кому
По умолчанию по HTTP отдан ровно один эндпоинт, /actuator/health. Даже /actuator/info закрыт, пока его не открыли явно. Список задаётся одной строкой, и первое искушение написать в ней *:
management.endpoints.web.exposure.include=health,info,prometheus,loggers
Со звёздочкой наружу уезжают env со всеми настройками, heapdump со снимком памяти и shutdown, если его включили. Значения в env Spring Boot 3 по умолчанию прячет за звёздочками (show-values=never), но имена ключей, версии библиотек и внутренние адреса видны, а heapdump отдаёт память процесса целиком, с паролями и данными пользователей внутри. Открытый на весь интернет Actuator это не «утечка настроек», а полный слепок сервиса.
Правило простое и совпадает со схемой выше. health и info отдают платформе без пароля: пробы Kubernetes и балансировщик не умеют авторизоваться. Метрики отдают сборщику, и лучше с отдельного порта. Всё остальное закрывают ролью:
@Bean
SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**", "/actuator/info").permitAll()
.anyRequest().hasRole("ACTUATOR"))
.httpBasic(Customizer.withDefaults())
.build();
}
Отдельный порт, management.server.port=9090, решает ту же задачу на уровне сети: пользовательский ingress ведёт на 8080 и о 9090 не знает, сборщик метрик и пробы ходят внутрь кластера напрямую. На большом парке сервисов это обязательное правило, а не улучшение: одна ошибка в списке exposure у одного сервиса не становится дырой наружу. Как такой порт настраивают и что с ним не так у проб, разобрано в статье про конфигурацию наблюдаемости.
Жив и готов: не одно и то же
Kubernetes задаёт сервису два разных вопроса, и Actuator отвечает на них разными адресами. /actuator/health/liveness значит «процесс в порядке, перезапускать не надо»; если он падает, под перезапустят. /actuator/health/readiness значит «готов принимать трафик»; если он падает, под просто выводят из балансировки, а перезапуска нет. Прогрев кэша при старте, потеря соединения с базой, слишком длинная очередь внутри: всё это поводы сказать «не готов», но не «перезапусти меня».
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
Внутри Kubernetes эти группы включаются сами; на обычном сервере их включают вручную свойством management.endpoint.health.probes.enabled=true, иначе адреса отвечают 404, и проба вечно красная.
Свой индикатор пишется в несколько строк и появляется в общем /actuator/health под своим именем:
@Component
class PricingServiceHealthIndicator implements HealthIndicator {
private final PricingClient client;
PricingServiceHealthIndicator(PricingClient client) { this.client = client; }
@Override
public Health health() {
try {
client.ping();
return Health.up().build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
Здесь две ловушки подряд. Первая: в readiness этот индикатор не попадает. Группа readiness по умолчанию содержит только внутреннее состояние приложения, и чтобы проверка попала в пробу, её добавляют явно: management.endpoint.health.group.readiness.include=readinessState,pricingService. Вторая ловушка в том, что делать этого обычно не стоит. Если сервис цен упал, а все его потребители одновременно сообщили «не готов», из балансировки выпадает весь слой, и вместо одной сломанной функции не работает ничего. Внешняя зависимость в readiness оправдана только когда без неё сервис бесполезен целиком. Как выбирать, что класть в пробы, разобрано в статье про health-проверки.
Loggers: DEBUG на минуту без перезапуска
Самый прикладной эндпоинт для дежурного и самый редко упоминаемый. Уровень лога любого пакета меняется на живом сервисе одним запросом, и через минуту возвращается обратно, без выката и без перезапуска:
POST /actuator/loggers/ru.shop.orders
Content-Type: application/json
{"configuredLevel": "DEBUG"}
Значение null вместо уровня возвращает пакет к унаследованному. GET /actuator/loggers/ru.shop.orders показывает, что настроено и что действует. Изменение живёт только в этом экземпляре и только до перезапуска, и это правильно: постоянный DEBUG на проде это выкат конфигурации, а не кнопка.
Цена у кнопки есть: DEBUG на горячем пакете при тысяче запросов в секунду за минуту пишет гигабайт логов и разгоняет сборщик. Поэтому включают не корень, а конкретный пакет, и на время, которое засекли. Почему выключенный уровень иногда всё равно стоит процессора, разобрано в статье про логи.
Метрики из коробки и их цена
Со стартером и зависимостью micrometer-registry-prometheus сервис без единой строки кода отдаёт на /actuator/prometheus метрики JVM (jvm.memory.used, jvm.gc.pause), процессора, пула соединений (hikaricp.connections.active), потребителя Kafka (kafka.consumer.fetch.manager.records.lag) и главную, http.server.requests: время каждого запроса с тегами uri, method, status, outcome, exception.
Тег uri здесь не адрес запроса, а шаблон маршрута: /orders/{id}, а не /orders/42. Так Spring защищает от взрыва рядов, который показан на вводной схеме: миллион заказов дают один ряд, а не миллион. Запросы, не попавшие ни в один маршрут, собираются в UNKNOWN по той же причине. Если в сервисе есть путь, где идентификатор попал в шаблон, например /files/{name} с произвольными именами, это уже тег высокой кардинальности, и его надо схлопывать.
Число рядов считается как произведение значений тегов: тридцать маршрутов на четыре метода на десять статусов это тысяча двести рядов, и это нормально. Дорого становится, когда включают гистограмму: management.metrics.distribution.percentiles-histogram.http.server.requests=true добавляет к каждому ряду несколько десятков корзин, и тысяча двести превращаются в десятки тысяч. Гистограмма нужна, чтобы считать процентили на стороне Prometheus, но почти всегда хватает нескольких корзин под целевые пороги: management.metrics.distribution.slo.http.server.requests=50ms,200ms,1s даёт три корзины вместо десятков и отвечает на вопрос «какая доля запросов уложилась в двести миллисекунд».
Свои метрики пишут через MeterRegistry: счётчик для событий, таймер для длительностей, gauge для текущего значения. Правило тегов одно на все три: тег это категория с десятком значений, а не ключ записи.
registry.counter("orders.created", "category", order.category().name()).increment();
Разбор трёх типов, процентилей и того, что именно смотреть на графике, в статье про метрики.
Observation: один вызов, и таймер, и span
Когда бизнес-операцию нужно и измерить, и увидеть в трассе, раньше писали дважды: таймер через MeterRegistry и span через Tracer, с одинаковыми именами и одинаковыми тегами, которые со временем расходились. Observation делает это одним вызовом:
Observation.createNotStarted("order.process", registry)
.lowCardinalityKeyValue("category", order.category().name())
.highCardinalityKeyValue("orderId", order.id().toString())
.observe(() -> process(order));
Из одного наблюдения получаются таймер order.process в метриках и span order.process в трассе, с одним именем и одними тегами. Разница между двумя видами ключей и есть главный урок: lowCardinalityKeyValue уходит и в метрику, и в span, highCardinalityKeyValue только в span. Идентификатор заказа попадает в трассу, где ему место, и не попадает в Prometheus, где он породил бы ряд на каждый заказ.
Одно наблюдение расходится на два выхода с одинаковым именем и одинаковыми тегами. Тег высокой кардинальности (orderId) уходит только в span: в метрику он не попадает, и рядов не становится больше.
Тот же результат даёт аннотация @Observed на методе, но ей нужен бин ObservedAspect, иначе она молча ничего не делает. Стандартные наблюдения Spring, от HTTP-запросов до вызовов RestClient, устроены так же, поэтому свои операции встают в ту же трассу и в те же графики.
Трассировка и логи: где искать дальше
Трассировку подключают мостом micrometer-tracing-bridge-otel и экспортёром opentelemetry-exporter-otlp; после этого каждый входящий запрос получает traceId, исходящие вызовы RestClient и сообщения Kafka несут его дальше в заголовке traceparent, а management.tracing.sampling.probability задаёт долю записанных трасс, в проде обычно десятую часть или меньше. Схема пути запроса через три сервиса, дерево спанов и выбор между мостом Micrometer и стартером OpenTelemetry в статье про трассировку.
В логах traceId и spanId появляются сами: Spring Boot добавляет в шаблон строки блок [имя-приложения,traceId,spanId], по нему логи всех сервисов одного запроса склеиваются в одну историю:
2026-05-18T10:23:45.120+03:00 INFO [orders,64f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5,a8b9c0d1e2f3a4b5] ... : Processing order 42
Для сборщика логов удобнее JSON, и с Spring Boot 3.4 он включается без сторонних библиотек: logging.structured.format.console=ecs. Что писать в строку, чтобы она была полезна через месяц, в статье про логи; что делать, когда логи уже не помогают, в статьях про профилирование и дампы, тот же Actuator отдаёт их на /actuator/threaddump и /actuator/heapdump.
Глубже: инструменты дежурного: дампы и проверка применённой конфигурациирасширенное
Раздел про то, что отдано наружу, называет env, threaddump и heapdump опасными. Они же главные инструменты дежурного на живом сервисе без перезапуска, если открыты правильно: на отдельном порту, с аутентификацией и явным списком.
Смена уровня журнала уже разобрана выше: POST /actuator/loggers/ru.shop.payments с уровнем DEBUG на время разбора и обратно после; забытый DEBUG заливает хранилище логов, о чём статья про логирование.
Дамп потоков. GET /actuator/threaddump отдаёт стеки всех потоков (JSON, а с заголовком Accept: text/plain в привычном текстовом виде). Три снимка с интервалом в десять секунд отвечают на вопрос «на чём висит сервис»: потоки веб-сервера в WAITING на пуле соединений означают базу, в BLOCKED на одном мониторе означают блокировку в коде, все в RUNNABLE внутри одной функции означают горячий цикл. Как читать состояния и что там делают виртуальные потоки, разбирает статья про дампы потоков и кучи.
Дамп кучи. GET /actuator/heapdump выгружает всю память JVM файлом в гигабайты, останавливает приложение на секунды и содержит всё, что было в памяти, включая персональные данные и секреты. Поэтому его снимают с реплики, выведенной из-под трафика, через jcmd <pid> GC.heap_dump /tmp/heap.hprof на том, а не через HTTP наружу, и удаляют после разбора; открывают локально анализатором. Что искать в дампе, в статье про профилирование и утечки.
Что реально применилось. Половина «настроили, а не работает» решается двумя запросами. GET /actuator/env/spring.datasource.hikari.maximum-pool-size показывает итоговое значение свойства и источник, откуда оно взято: переменная окружения, профиль, умолчание; так видно, что прод-профиль не подхватился или переменная перекрыла файл. GET /actuator/configprops показывает уже связанные объекты настроек с фактическими значениями, включая те, что не задавали. Значения секретов там замаскированы, и показ по запросу для авторизованных включают отдельным свойством, иначе видны звёздочки.
Правило открытия: отдельный порт, недоступный снаружи кластера, аутентификация, список эндпоинтов явно (health, prometheus для всех, остальное для роли дежурного), и журнал обращений к ним, потому что дамп кучи это доступ к данным.
Коротко
- Actuator ставят не ради
/health, а ради двух десятков эндпоинтов, которые руками не напишешь; у каждого свой читатель, и этим задан доступ. - Наружу по умолчанию только
health;*в проде отдаёт слепок сервиса вместе сheapdump; метрики и дампы на отдельный порт. - Liveness отвечает «не перезапускай», readiness отвечает «не шли трафик»; свой индикатор в readiness не попадает сам, и внешнюю зависимость туда кладут только когда без неё сервис бесполезен целиком.
/actuator/loggersменяет уровень лога на живом экземпляре до перезапуска: пакет, а не корень, и на засечённое время.- Тег
uriэто шаблон маршрута, а не адрес; гистограмма умножает ряды на десятки, корзины под пороги дешевле. Observationдаёт таймер и span одним вызовом; ключи высокой кардинальности уходят только в span.- Инструменты дежурного без перезапуска: уровень журнала через
loggers, три дампа потоков подряд, дамп кучи только с реплики без трафика черезjcmdна том,env/<свойство>иconfigpropsдля вопроса «что реально применилось»; всё на отдельном порту с аутентификацией.
Что пощупать
Пробы готовности и живости, метрики в формате Prometheus с меткой сервиса и тест, который проверяет всё это через MockMvc, есть в стартовом каталоге практикума remodov/marketplace-system. Грабля из статьи там настоящая: без отдельного ключа включения экспорта /actuator/prometheus отвечает 404, хотя зависимость на месте и путь открыт.
Код: application.yml, ObservabilityTest.
Сделаем сами
Ветка step-15-delivery-and-observability — пробы и метрики не настроены, тест красный.
Что почитать дальше
- Метрики: Counter, Timer, Gauge и процентили — как считать и что смотреть на графике.
- Health-проверки — что класть в liveness и readiness, а что нет.
- Трассировка — путь запроса через сервисы, спаны и выбор моста.
- Конфигурация наблюдаемости — отдельный порт, exposure и то, как это живёт в Kubernetes.
- Thread dump и heap dump — те же
/actuator/threaddumpи/actuator/heapdumpв деле.