Когда приложение работает в продакшене, нужно понимать, что внутри происходит: сколько запросов обрабатывается, где задержки, что пишется в логах. Всё это называют наблюдаемостью (observability). Чтобы это работало правильно, нужно однажды настроить четыре вещи: отдельный порт для служебных endpoints, явный список того, что открыто, гистограммы задержек и формат логов. Разберём каждую.
Самое наглядное из этих четырёх — метрика задержки: вот что сервис отдаёт в Prometheus до настройки гистограммы и после неё.
Счётчики count и sum дают только среднее — 180 мс, и оно прячет хвост. Гистограмма с порогами SLO отвечает сразу на два вопроса: доля быстрее 500 мс — 96% при цели 99%, а p95 — 470 мс.
Отдельный порт для Actuator
По умолчанию Spring Boot запускает всё на одном порту: и ваш API (/api/orders), и служебные endpoints Actuator (/actuator/health, /actuator/prometheus). Это удобно локально, но создаёт проблемы в продакшене.
Если Actuator на том же порту, что и API, сетевой политикой нельзя запретить доступ к нему извне: Kubernetes Ingress открывает порт целиком, а не отдельные пути. Это главная причина. Есть и вторая, помельче: пока порт один, служебные запросы — Prometheus каждые 15 секунд, зонды каждые 5 — обслуживает тот же пул потоков Tomcat, что и бизнес-трафик.
Решение простое — разные порты:
server:
port: 8080
management:
server:
port: 8081
Теперь Ingress публикует только 8080, а сетевая политика разрешает Prometheus обращаться только на 8081. Служебный трафик не мешает бизнес-логике.
Явный список открытых endpoints
Наружу по HTTP Spring Boot по умолчанию отдаёт один эндпоинт — health; даже info закрыт. Когда вместо явного списка пишут '*', чтобы быстро посмотреть всё сразу, — открывается гораздо больше, чем нужно.
Некоторые endpoints небезопасны для публичного доступа:
/actuator/env— показывает все конфигурационные свойства, включая те, что попали туда через переменные окружения. Если секрет оказался там случайно, он будет виден./actuator/heapdump— полный дамп памяти JVM. Он может содержать JWT-токены, пароли, персональные данные пользователей, которые в данный момент обрабатываются./actuator/threaddump— стек всех потоков. Раскрывает внутреннюю структуру приложения.
Три эндпоинта, которые открывает wildcard: слева имя, дальше что эндпоинт отдаёт и что из этого утекает наружу.
Правило простое: указывать только то, что действительно нужно:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics нужен для ручной отладки через JSON. prometheus — для сборщика Prometheus. Остальное закрыто.
Если очень нужен heapdump в продакшене для отладки — Spring Security с ролью администратора и аудит каждого обращения. По умолчанию — не открывать.
Как именно защищать, когда Actuator на своём порту
Совет «закрыть ролью администратора» оставляет вопрос, где это написать: обычная цепочка фильтров настроена под API и на служебный порт не распространяется так, как ожидается. Правильная форма — отдельная цепочка только для служебного порта:
@Configuration
public class ActuatorSecurity {
@Bean
@Order(1)
SecurityFilterChain actuator(HttpSecurity http) throws Exception {
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to(HealthEndpoint.class,
InfoEndpoint.class)).permitAll()
.requestMatchers(EndpointRequest.to(PrometheusScrapeEndpoint.class))
.hasRole("METRICS")
.anyRequest().hasRole("ACTUATOR_ADMIN"))
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
}
Что здесь важно по частям. EndpointRequest.toAnyEndpoint() — сопоставитель, который знает базовый путь Actuator и работает независимо от того, на каком порту он поднят и не переименовали ли /actuator. Пробы (health) и версия (info) открыты без пароля: их спрашивает оркестратор, который пароля не знает. Сбор метрик — под своей ролью, потому что учётная запись сборщика не должна открывать всё остальное. Всё прочее — только администратору. @Order(1) ставит эту цепочку перед основной, иначе служебные пути перехватит цепочка API.
Одна тонкость, из-за которой это иногда «не работает»: правила Spring Security применяются к служебному порту только если он обслуживается тем же приложением (так и есть при management.server.port), но сетевая изоляция всё равно остаётся первой линией. Пароль на служебном порту — вторая линия, на случай ошибки в сетевой политике; по отдельности каждая из двух ненадёжна.
Что отдаёт /actuator/health и как собрать группы
Настройки проб живут в двух разных статьях раздела, а по-настоящему их место здесь, потому что это конфигурация — и потому что по умолчанию они выключены, и об этом узнают, когда пробы уже настроены.
Подробности скрыты по умолчанию. Голый /actuator/health отдаёт {"status":"UP"} и ни слова о том, какая зависимость упала. Это разумное умолчание (состав зависимостей — тоже информация), но дежурному нужен ответ. Включают выборочно:
management:
endpoint:
health:
show-details: when-authorized # или always, если порт закрыт сетью
show-components: when-authorized
roles: ACTUATOR_ADMIN
when-authorized вместе с ролью — правильная форма для прода: пробы получают короткий ответ, человек с ролью видит разбор по индикаторам. always допустимо, когда служебный порт закрыт сетевой политикой и виден только внутри контура.
Группы для проб не существуют, пока их не объявить. Пути /actuator/health/liveness и /actuator/health/readiness в обычном приложении отдают 404: группы появляются автоматически только в кластере (Spring определяет его по признакам среды), а на своём стенде или в другом окружении их нужно включить. И состав групп задают явно, иначе свои индикаторы в них не попадут:
management:
endpoint:
health:
probes:
enabled: true # включить группы liveness и readiness всегда
group:
liveness:
include: livenessState
readiness:
include: readinessState,db,paymentProvider
additional-path: server:/readyz # опубликовать и на основном порту
Три вещи, которые стоит забрать отсюда. probes.enabled: true — чтобы поведение не зависело от того, узнало ли приложение среду. В liveness не входит ничего внешнего — только состояние самого процесса. additional-path нужен там, где пробы не могут стучаться на служебный порт (например, балансировщик видит только основной): путь публикуется на основном порту, оставаясь тем же самым состоянием готовности.
Что ещё настраивают рядом: срок ожидания и способ сведения ответов для медленных индикаторов, и отдельно — чтобы состояние базы при проверке не выполняло тяжёлый запрос. Разбор того, из чего вообще состоят пробы и как их не сломать, — в статье про проверки здоровья.
Гистограммы задержек и SLO buckets
Среднее время ответа — 180 мс, а пользователи жалуются, что «висит»: среднее прячет хвост, где каждый двадцатый запрос идёт три секунды. Стандартная метрика http.server.requests в Micrometer по умолчанию считает только количество запросов и суммарное время — то есть ровно это среднее. Чтобы знать, сколько запросов уложились в 500 мс или какой p95, нужна гистограмма.
management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
slo:
http.server.requests: 100ms,500ms,1s,5s
tags:
service: ${spring.application.name}
env: ${ENV:dev}
version: ${BUILD_VERSION:unknown}
percentiles-histogram: true заставляет Micrometer публиковать полную гистограмму (~64 bucket'а). В Prometheus по ней считают квантиль через histogram_quantile(). Это всегда приближение: функция знает только границы bucket'ов и считает, что внутри bucket'а значения распределены ровно, — чем больше bucket'ов, тем ближе к правде, но «точным» число не станет.
slo: 100ms,500ms,1s,5s добавляет явные пороговые bucket'ы. В Prometheus появится метрика http_server_requests_seconds_bucket{le="0.5"} — «сколько запросов завершилось быстрее 500 мс». Вот это уже не приближение, а точный счёт, и для SLO-алертов он удобнее квантиля: цель обычно и формулируется как доля («95% быстрее 500 мс»).
tags.service/env/version — глобальные метки, которые добавятся ко всем метрикам автоматически. Без них непонятно, от какого сервиса и окружения пришли данные. BUILD_VERSION проставляется из CI как переменная окружения.
Посчитайте, сколько это рядов
Раздел советует включить гистограмму, и будет честно назвать цену в тех же рядах, которыми пугает статья про кардинальность. Полная гистограмма — это около 65 рядов на каждую комбинацию остальных тегов (по ряду на границу плюс сумма и счёт). Умножаем на реальное число комбинаций: 20 путей × 4 метода × 3 статуса = 240, итого около пятнадцати тысяч рядов с одной метрики. Добавьте глобальные метки — они не множат, они одинаковые, — и получите порядок, который уже виден в памяти сборщика.
Что сокращает счёт, в порядке эффективности:
management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: false # полную сетку выключаем
slo:
http.server.requests: 100ms,500ms,1s,5s # оставляем 4 границы + inf
minimum-expected-value:
http.server.requests: 10ms
maximum-expected-value:
http.server.requests: 10s
Пять границ вместо шестидесяти пяти — в тринадцать раз меньше рядов, и для целей вида «доля быстрее 500 мс» этого достаточно, потому что вопрос про границу, а не про точное значение перцентиля. Диапазон ожидаемых значений отсекает границы, которых в вашем сервисе не бывает. Полную сетку оставляют там, где действительно смотрят распределение, — обычно на одном-двух ключевых путях, а не на всех.
Выключить лишние метрики
Из коробки Micrometer публикует больше, чем читают: метрики пулов, логгеров, кеша, каждой очереди, каждого исполнителя. Когда рядов стало много, это первое место, где их можно убрать — не правя код.
management:
metrics:
enable:
tomcat: false # выключить всё семейство tomcat.*
jvm.gc.pause: true # но это оставить
Правило простое: имя в enable — префикс, и более длинный префикс переопределяет короткий. То есть можно выключить семейство целиком и вернуть из него одну нужную метрику.
Когда нужно не по имени, а по условию (например, отбросить конкретные значения тега или сжать их до общего), в конфигурации объявляют фильтр реестра:
@Bean
MeterFilter dropHealthUris() {
return MeterFilter.deny(id ->
"http.server.requests".equals(id.getName())
&& String.valueOf(id.getTag("uri")).startsWith("/actuator"));
}
@Bean
MeterFilter limitUriCardinality() {
return MeterFilter.maximumAllowableTags(
"http.server.requests", "uri", 100, MeterFilter.deny());
}
Первый фильтр убирает служебные пути из метрик запросов: они многочисленны, всегда успешны и разбавляют SLI. Второй — предохранитель от взрыва кардинальности: больше ста значений тега uri не создаётся вовсе, что бы ни случилось с шаблонами путей. Такой предохранитель стоит поставить заранее: он превращает аварию «сборщик задохнулся» в незаметную потерю части данных.
Два профиля логирования
В разработке удобны однострочные читаемые логи. В продакшене нужен структурированный JSON, который Logstash или Vector умеют парсить и индексировать.
Logback поддерживает Spring-профили прямо в logback-spring.xml:
<configuration>
<springProfile name="!prod & !staging">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %X{traceId:-} %logger{30} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<springProfile name="prod,staging">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
springProfile — это расширение Logback, которое Spring Boot активирует при запуске. Нужный appender включается в зависимости от профиля.
С Spring Boot 3.4 половина этого XML не нужна
Начиная с версии 3.4 структурная запись встроена во фреймворк, и весь блок для прода заменяется одной строкой в обычной конфигурации:
# application-prod.yml
logging:
structured:
format:
console: ecs # или logstash, или gelf
include-application-name: false
# application.yml (локально и в тестах — обычный текст, ничего настраивать не нужно)
Что это даёт: ни сторонней библиотеки, ни logback-spring.xml, ни риска остаться без appender'а из-за условия по профилям. Поля MDC попадают в событие автоматически, без белого списка. Формат выбирается значением: ecs — соглашение Elastic, logstash — то же, что у библиотеки из примера выше, gelf — для Graylog. Есть и logging.structured.format.file, если журнал пишется ещё и в файл.
Когда всё-таки остаётся XML: нужны свои поля в каждом событии сверх MDC, нужен асинхронный appender с настройкой очереди, нужны правила маскирования в кодировщике или своя структура события. Тогда в файле остаётся только блок для прода, а для разработки хватает умолчаний фреймворка — то есть XML становится короче и без условий по профилям, из-за которых и возникает ловушка с пропавшим appender'ом.
Практический совет для нового проекта: начинать со встроенного формата и заводить XML только тогда, когда чего-то действительно не хватает.
Условие первого блока написано как «любой профиль, кроме prod и staging», и это не придирка. <root> ссылается на STDOUT безусловно, а объявлен этот appender только внутри springProfile. Напиши там dev,test — и запуск без активного профиля (локально, в тестах, в сборке) оставит <root> со ссылкой в пустоту: Logback скажет «no appender named STDOUT», и логов не будет вообще.
В dev-паттерне %X{traceId:-} берёт traceId из MDC и подставляет в строку лога. Если трейсинг не настроен — пустая строка, ничего не падает. По этому идентификатору потом ищут запрос в Tempo или Jaeger.
В продакшене LogstashEncoder пишет каждую строку как JSON-объект. Поля MDC он кладёт прямо в корень события — отдельного блока mdc там нет. И важная тонкость: пока <includeMdcKeyName> не указан ни разу, в лог едут все ключи MDC; как только указан хотя бы один — список становится белым, и всё, что в него не попало, потеряется молча.
Настройки трассировки
В разделе есть отдельная статья про трассировку, а её настройки живут здесь: адрес, доля сохраняемых трасс и выбор способа подключения. Это три решения, и каждое принимается один раз на проект.
Чем подключать — два пути. Первый: micrometer-tracing-bridge-otel вместе с экспортёром OTLP. Тогда наблюдения Micrometer (те самые Observation, из которых получаются и метрики, и спаны) превращаются в спаны OpenTelemetry, автоматическое инструментирование ограничено тем, что умеет Spring (входящие и исходящие запросы, база, брокер), а идентификаторы попадают в MDC как traceId/spanId. Второй: стартер OpenTelemetry (opentelemetry-spring-boot-starter) или агент, подключаемый при запуске. Агент инструментирует гораздо больше библиотек, не требует правок кода и сам переносит контекст через пулы потоков, а имена полей в MDC у него trace_id/span_id.
Выбор простой: если проект уже пользуется Observation и вам достаточно стандартного набора — мост Micrometer; если нужно широкое покрытие библиотек без кода — агент. Держать оба одновременно нельзя: получите двойные спаны.
Адрес и доля. Настройки короткие:
management:
tracing:
sampling:
probability: 0.05 # 5 % трасс
otlp:
tracing:
endpoint: http://otel-collector.observability:4318/v1/traces
Три замечания к этим двум строчкам. Адрес — это коллектор, а не хранилище: почему так, разбирает статья про трассировку; при отправке напрямую в хранилище теряются выборка по хвосту и вырезание персональных данных. Доля берётся с учётом родителя (умолчание Spring: если входящий запрос уже помечен как отслеживаемый, сервис его продолжает независимо от своей доли) — иначе трассы разваливались бы посередине. И probability: 1.0 для стенда, где полнота важнее цены, но не для прода с заметным трафиком.
Что ещё стоит настроить рядом. Имя сервиса — из spring.application.name, иначе в хранилище окажется unknown_service; при подключении агентом оно задаётся своей переменной окружения, и это частая причина «трассы есть, а чьи — непонятно». Исключение служебных путей из трассировки, чтобы пробы и сбор метрик не создавали спаны. И для агента — включение или выключение конкретных инструментирований, когда одно из них шумит.
Проверка, что всё сошлось, занимает минуту: сделать запрос с пометкой «отслеживать обязательно» (заголовок traceparent с включённым флагом), затем найти трассу в хранилище, а по её идентификатору — записи журнала. Не нашлось — смотреть по порядку: адрес коллектора, доля, имя поля в MDC.
Частые ошибки
Один порт для API и Actuator в продакшене. Сетевую изоляцию сделать не получится. management.server.port: 8081 решает проблему.
exposure.include: '*'. Открывает env, beans, mappings, loggers, configprops, heapdump. Каждый из них — потенциальная утечка данных. Явный список надёжнее:
# так не надо
management.endpoints.web.exposure.include: '*'
# так правильно
management.endpoints.web.exposure.include: health,info,metrics,prometheus
Один Logback-паттерн для dev и prod. В разработке теряется читаемость JSON, в продакшене теряется структура. springProfile — стандартный способ разделить.
Нет гистограммы для метрик задержки. Тогда квантили либо не считаются, либо только примерные. percentiles-histogram: true для http.server.requests — минимальный набор.
Глубже: цена наблюдаемости: ряд, спан, строка и три способа урезатьрасширенное
Наблюдаемость стоит денег, и это отдельная строка счёта, которую бизнесу приходится объяснять. Считать её умеют по трём единицам.
Ряд метрики. Каждая уникальная комбинация имени и тегов это ряд, и активный ряд занимает в памяти Prometheus несколько килобайт плюс байт-два на точку на диске. Миллион рядов это гигабайты памяти и постоянная нагрузка; ряды взрываются от тегов с высокой кардинальностью, о чём статья про метрики. Гистограмма задержки с двадцатью корзинами это двадцать рядов на каждую комбинацию тегов, и гистограммы по эндпоинтам умножают быстрее всего.
Спан. Спан весит один-два килобайта, и сервис с тысячей запросов в секунду, у каждого из которых пять спанов, даёт полтерабайта в сутки при полном сохранении. Поэтому трассы всегда выборочные: по умолчанию сохраняют долю в проценты, а все ошибки и медленные запросы отдельно, о чём раздел про сэмплирование в статье про трассировку.
Строка лога. Триста байт на строку, приём и индекс по гигабайтам; лишняя запись на каждый запрос при тысяче в секунду это 26 ГБ в сутки, о чём статья про логирование.
Три способа урезать. Выборка: доля трасс и доля успешных строк логов на шумных путях, при полном сохранении ошибок. Агрегация: то, что считают, превращают в метрику вместо строк, а из гистограмм убирают лишние корзины и теги. Отбрасывание на коллекторе: проверки здоровья, отладочный уровень, атрибуты, которые никто не читает, вырезаются до хранилища одним правилом на всех. Сокращение срока хранения четвёртый способ, и он же самый дешёвый.
Как объяснять. Наблюдаемость обычно стоит десятую-пятую часть от инфраструктуры сервиса, и это нормальная доля; выше это повод посмотреть на кардинальность и уровень логов, ниже это слепота. Аргумент для бизнеса не «нам нужны логи», а «час инцидента без логов стоит столько-то, а логи за месяц столько-то», и цифры для первого есть в разборе последнего инцидента.
Глубже: персональные данные в наблюдаемостирасширенное
Запреты на персональные данные разбросаны по статьям раздела: логи в одной, атрибуты спанов в другой, дамп кучи в третьей. Собранные вместе они дают одно правило и четыре следствия.
Правило: хранилища наблюдаемости это хранилища персональных данных, если туда попало хоть одно поле, по которому можно узнать человека, и на них распространяются те же требования, что и на базу: цель, срок, доступ, удаление. Проще не допускать, чем соответствовать.
Что не должно попадать. Email, телефон, имя, адрес, номер карты и документа, токены и куки в логах, атрибутах спанов и тегах метрик. Вместо них идентификаторы: userId как число или UUID, по которому связь с человеком есть только в базе. Строгие команды хешируют и идентификаторы. Дамп кучи содержит всё, что было в памяти, и это персональные данные по определению: файл с дампом хранят как выгрузку базы, с ограниченным доступом, шифрованием и удалением после разбора.
Маскирование на конвейере как вторая линия. Первая линия это код, который не пишет лишнего. Вторая это правила на агенте или коллекторе, которые вырезают по шаблону всё похожее на email, номер карты и телефон до хранилища; они ловят то, что просочилось через исключение с текстом запроса или через библиотеку, и их проверяют тестом, как код.
Срок и доступ. Срок хранения логов и трасс ограничен целью: разбор инцидентов укладывается в недели, аудит в год по закону, дольше это хранение без цели. Доступ к хранилищу через корпоративный вход с ролями, чтение логов прода это право, которое выдают, а не умолчание, и запросы к хранилищу журналируются: кто искал по какому идентификатору. Право на удаление по запросу субъекта распространяется и на логи, и единственный способ его выполнить это короткий срок хранения и отсутствие прямых идентификаторов.
Куда уходят данные. Управляемое хранилище наблюдаемости у иностранного провайдера это трансграничная передача, о чём статьи про облака и про 152-ФЗ; агент, который шлёт логи в чужой регион, попадает под те же требования, что и база.
Коротко
- Запускайте Actuator на отдельном порту (
management.server.port: 8081) — это даёт сетевую изоляцию и не мешает бизнес-трафику. Служебный порт закрывают двумя линиями: сетевой политикой и отдельной цепочкой фильтров поEndpointRequest.toAnyEndpoint(), где открыты только пробы и версия. - Явно перечисляйте открытые endpoints:
health,info,metrics,prometheus. Wildcard'*'— источник утечек./actuator/env,/actuator/heapdump,/actuator/threaddumpне открывают публично — они содержат секреты и дампы памяти. percentiles-histogram: true+slo: 100ms,500ms,1s,5sдляhttp.server.requests— основа для SLO-алертов на задержку. Глобальные тегиservice/env/versionвmanagement.metrics.tagsсразу расставляют контекст по всем метрикам.- Два профиля в
logback-spring.xml: JSON черезLogstashEncoderдляprod,staging, текстовый — для всего остального (условие пишут «не prod и не staging», иначе запуск без профиля останется без appender'а). - Цена считается рядами метрик, спанами и строками: урезают выборкой, агрегацией, отбрасыванием на коллекторе и сроком хранения; норма десятая-пятая часть от инфраструктуры, аргумент это цена часа без логов.
- Хранилища наблюдаемости это хранилища персональных данных: идентификаторы вместо полей человека, маскирование на конвейере второй линией, дамп кучи как выгрузка базы, срок по цели, доступ по ролям с журналом, передача за границу как у базы.
- Подробности health скрыты по умолчанию, а группы
livenessиreadinessотдают 404, пока не включеныprobes.enabledи не перечислен состав. - Полная гистограмма — около 65 рядов на комбинацию тегов: оставляют четыре нужные границы, задают диапазон, лишние семейства метрик гасят через
management.metrics.enable.*, а тегuriстрахуют фильтром на максимум значений. - С Spring Boot 3.4 структурная запись встроена (
logging.structured.format.console), и XML остаётся только под свои поля, асинхронную очередь и маскирование. - Настройки трассировки: либо мост Micrometer, либо агент, но не оба; адрес — коллектор, доля с учётом родителя, имя сервиса обязательно, служебные пути исключены.