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

Выкатили сервис, и через час дежурный задаёт три вопроса подряд: он вообще жив, почему заказы стали медленнее и можно ли на минуту включить DEBUG для одного пакета, не перезапуская под. Без подготовки на каждый вопрос уходит по получасу: смотреть логи, гадать по графикам, готовить выкат с новым уровнем лога. Spring Boot отвечает на все три одним стартером, и эта статья про него: что Actuator умеет, что из этого стоит отдавать наружу и кому, и где он начинает стоить дорого. Метрики, трассировка и логи разобраны в отдельных статьях этой фазы, здесь вход в них.

Дороже всего в этом стеке обходится не Actuator, а одна ошибка в метриках: число рядов в Prometheus задаёт не поток заказов, а множество значений тегов.

Одна метрика orders.created: рядов столько, сколько комбинаций теговряд = одна комбинация значений тегов; каждый ряд Prometheus держит в памяти counter("orders.created", "category", c, "status", s)8 категорий × 3 статуса 24 ряда в памятии при 100 заказах в день, и при 10 млн — те же 24 + "user_id", order.userId()50 000 покупателей за месяц 8 × 3 × 50 000 = 1 200 000 рядовновый покупатель — новый ряд, и он остаётсябыло 24 ряда на метрику, стало 1,2 млн — из-за одного тега 01234 ГБпамять Prometheus24 ряда72 КБ — на этой шкале неразличимо 3,6 ГБ — по 3 КБ на активный ряд1,2 млн рядов 3,6 ГБ на одну метрику — память кончится и в сервисе, и в Prometheus Тег — категория с десятком значений, а не ключ записиразрез по пользователю — это лог с traceId, там рядов нет

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

/actuator health платформа: пробы, балансировщик metrics, prometheus сборщик метрик loggers дежурный: уровень лога на лету threaddump, heapdump разбор инцидента env, beans, mappings разработчик при отладке

У каждого эндпоинта свой читатель, и этим задан доступ: 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, где он породил бы ряд на каждый заказ.

Observation «order.process» Timer order.process Prometheus: только low-cardinality теги Span order.process Tempo: low- и high-cardinality теги

Одно наблюдение расходится на два выхода с одинаковым именем и одинаковыми тегами. Тег высокой кардинальности (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 — пробы и метрики не настроены, тест красный.

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