Деплой и наблюдаемость: сборка, контейнеры, метрики, трейсинг
До этого момента мы разбирали программу как текст: типы, коллекции, потоки, транзакции. Теперь программа становится процессом на чужой машине, у которого есть лимит памяти, пятьдесят соседей по узлу, дежурный в три часа ночи и SLO на 99-й перцентиль.
У Java здесь очень специфичный профиль. Артефакт — не машинный код, а байткод плюс рантайм, который сам решает, сколько взять памяти и когда скомпилировать метод. Это даёт переносимость («собрал один раз — запустил везде, где есть JVM нужной версии») и две регулярные боли: процесс стартует секунды, а не миллисекунды, и процесс потребляет заметно больше памяти, чем размер кучи. Обе боли лечатся, но только если понимать механику — она подробно разобрана в статье про JVM изнутри, и здесь мы опираемся на неё как на фундамент.
Зато в наблюдаемости JVM — платформа-чемпион. Она инструментирует сама себя: JMX даёт
живые метрики без единой строчки вашего кода, -javaagent позволяет переписать байткод
любой библиотеки на лету и вставить трассировку, а JFR пишет профиль в проде с накладными
расходами порядка процента. Ни в Go, ни в Python, ни в Node такого набора «из коробки» нет.
План: сначала артефакт (что мы вообще деплоим), потом контейнер и жизненный цикл процесса, потом четыре сигнала телеметрии — метрики, логи, трейсы и профили — и наконец честный разговор о том, где Java-деплой проигрывает и что с этим делать.
Часть 1. Артефакт: что именно уезжает в прод
Jar — это zip с манифестом
jar — обычный ZIP-архив: .class-файлы в структуре пакетов, ресурсы и
META-INF/MANIFEST.MF. Манифест может содержать Main-Class и Class-Path, и тогда
архив запускается через java -jar. Никакой магии, всё проверяется руками:
# Что внутри артефакта на самом деле
unzip -l target/orders-1.4.0.jar | head -20
unzip -p target/orders-1.4.0.jar META-INF/MANIFEST.MF
# Под какую версию Java скомпилирован класс (65 = Java 21, 61 = Java 17)
javap -verbose -cp target/orders-1.4.0.jar com.acme.orders.Application | grep major
Проблема в том, что одного jar мало: нужны ещё сотни jar-зависимостей. Отсюда три исторических способа их собрать.
Thin jar + каталог lib/. Классика: артефакт маленький, зависимости лежат рядом,
запуск через java -cp 'lib/*:app.jar' com.acme.Main. Живо в мире серверов приложений
и в связке с jlink.
Uber/fat jar через shade/shadow. Все зависимости распаковываются и складываются в один архив. Просто — и ровно поэтому опасно:
- Коллизии ресурсов. Два jar содержат
META-INF/services/java.sql.Driver— при наивной распаковке один затрёт другой, иServiceLoaderперестанет видеть половину реализаций. Лечится трансформерами:ServicesResourceTransformerв maven-shade,mergeServiceFiles()в Gradle Shadow. - Сломанные подписи. Если исходный jar был подписан, файлы
META-INF/*.SF,*.DSA,*.RSAв uber-jar приведут кSecurityException: Invalid signature file digest. Их нужно исключать явно. - Тихая подмена версий. Два класса с одинаковым FQCN из разных библиотек: в архив попадёт один, какой — зависит от порядка. Диагностируется только в рантайме и обычно в проде.
Executable jar Spring Boot. Boot решает те же задачи иначе: вложенные jar не
распаковываются, а лежат внутри как есть (в BOOT-INF/lib/), а MANIFEST.MF указывает
на org.springframework.boot.loader.launch.JarLauncher. Лаунчер ставит свой
ClassLoader, умеющий читать классы из вложенных архивов. Плюс: никаких коллизий,
исходные jar сохраняются побайтно. Минус: свой загрузчик классов и чуть более медленный
старт, а инструменты, ожидающие «плоский» classpath, могут удивиться.
Слоёный jar и извлечение
Начиная с Spring Boot 2.3 артефакт умеет раскладываться по слоям — специально ради Docker. Это ключ ко всему разделу про контейнеры:
# Spring Boot 3.3+ — актуальный способ
java -Djarmode=tools -jar app.jar list-layers
# dependencies
# spring-boot-loader
# snapshot-dependencies
# application
java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
# В Boot 2.3–3.2 то же делалось через -Djarmode=layertools ... extract
Смысл разбиения — частота изменений. Зависимости меняются раз в спринт, ваш код — по десять раз в день. Если положить их в разные слои образа, на каждый коммит по сети поедет пара мегабайт вместо восьмидесяти.
Дальше по шкале: jlink, CDS, AOT-кэш, native-image
Java 9+ дала возможность не тащить весь JDK, а собрать свой рантайм только из нужных модулей. Java 24/25 добавили кэш прогретого состояния. GraalVM — компиляцию в нативный бинарь. Это шкала «размер и скорость старта против гибкости и пиковой производительности».
# 1. Какие модули JDK реально нужны приложению
jdeps --print-module-deps --ignore-missing-deps --multi-release 21 \
--recursive target/orders-1.4.0.jar
# java.base,java.instrument,java.management,java.naming,java.sql,jdk.unsupported
# 2. Собираем минимальный рантайм (~50–70 МиБ против ~180 у полного JRE)
jlink --add-modules java.base,java.management,java.naming,java.sql,jdk.unsupported \
--strip-debug --no-header-files --no-man-pages --compress zip-6 \
--output /opt/jre-min
AppCDS (Class Data Sharing) — заранее записанный, разобранный образ метаданных
классов, который JVM мапит в память вместо парсинга тысяч .class. Экономит 20–40 %
времени старта практически бесплатно:
# JDK 19+: архив создаётся сам при первом запуске и переиспользуется дальше
java -XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=/cache/app.jsa -jar app.jar
# Spring Boot 3.3+ умеет прогреть контекст и выйти — архив получается «жирнее» и полезнее
java -Dspring.context.exit=onRefresh -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
AOT-кэш проекта Leyden (JDK 24+, в JDK 25 LTS доступен с упрощённой эргономикой) идёт дальше: сохраняет не только классы, но и результаты линковки, а также профили для JIT:
java -XX:AOTCacheOutput=app.aot -jar app.jar # тренировочный прогон
java -XX:AOTCache=app.aot -jar app.jar # прод: старт заметно быстрее
GraalVM Native Image компилирует всё приложение в нативный исполняемый файл: старт за десятки миллисекунд, RSS в разы меньше, но closed-world-анализ требует, чтобы вся рефлексия, прокси и ресурсы были известны на этапе сборки. Spring Boot закрывает это своей AOT-обработкой и метаданными сообщества:
mvn -Pnative native:compile # сборка идёт 3–10 минут и ест 8–16 ГиБ RAM
./target/orders # старт ~40 мс вместо ~2,5 с
Читается диаграмма так: до правого верхнего угла честно доходит только CRaC (снимок прогретого процесса, требует поддержки со стороны платформы), а native-image покупает мгновенный старт ценой пиковой пропускной способности — у C2 на долгоживущем процессе больше информации для оптимизаций, чем у статического компилятора. Для сервиса, который живёт неделями, обычный слоёный образ плюс AppCDS — рациональный дефолт, а native-image оправдан там, где процесс живёт секунды или платят за мегабайт-секунды.
Часть 2. Контейнер: где Java ведёт себя не так, как вы ожидаете
Правильный Dockerfile
# --- Этап сборки: тяжёлый образ с JDK и Maven, в прод не попадает ---
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /src
# Сначала только дескрипторы — слой с зависимостями кэшируется отдельно от кода
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw -B dependency:go-offline
COPY src src
RUN ./mvnw -B -DskipTests package
# --- Этап разбора: раскладываем fat jar на слои ---
FROM eclipse-temurin:21-jre-jammy AS extract
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination .
# --- Этап рантайма ---
FROM eclipse-temurin:21-jre-jammy
# Непривилегированный пользователь: контейнер под root — находка для эксплойта
RUN useradd -r -u 10001 -m app
WORKDIR /app
# Порядок COPY = порядок слоёв: снизу редко меняющееся, сверху ваш код
COPY --from=extract --chown=app:app /app/dependencies/ ./
COPY --from=extract --chown=app:app /app/spring-boot-loader/ ./
COPY --from=extract --chown=app:app /app/snapshot-dependencies/ ./
COPY --from=extract --chown=app:app /app/application/ ./
USER app
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=55.0 \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps \
-XX:StartFlightRecording=settings=profile,maxsize=256m,dumponexit=true,filename=/dumps/app.jfr \
-Xlog:gc*:file=/proc/1/fd/1:time,uptime,level,tags"
EXPOSE 8080
# Только exec-форма: иначе PID 1 станет /bin/sh, и SIGTERM не дойдёт до JVM
ENTRYPOINT ["java", "-jar", "app.jar"]
Альтернатива Dockerfile — сборка образа прямо из системы сборки: Jib
(mvn com.google.cloud.tools:jib-maven-plugin:build) или Cloud Native Buildpacks
(mvn spring-boot:build-image). Обе умеют слои «из коробки», собирают воспроизводимо
и не требуют демона Docker. Подробнее о реестрах, кэше и подписи образов — в статье
Контейнеры и реестры.
Эргономика JVM в cgroup: главный источник продовых сюрпризов
JVM при старте сама выбирает размер кучи, число потоков GC и размер пулов ForkJoin.
До JDK 8u191 она смотрела на всю физическую память узла — и приложение в контейнере
с лимитом 1 ГиБ радостно ставило Xmx в 8 ГиБ, после чего его убивало ядро. Сейчас
-XX:+UseContainerSupport включён по умолчанию и JVM читает лимиты cgroup, но
остались две ловушки.
Первая — доля кучи от лимита. По умолчанию MaxRAMPercentage = 25 %, что для контейнера
на 2 ГиБ даёт всего 512 МиБ кучи. Инженер видит это, ставит 75 % — и получает
OOMKilled, потому что вне кучи живёт ещё половина потребления.
Вторая — процессоры. Runtime.availableProcessors() возвращает значение, выведенное из
cgroup-квоты, и от него зависят размеры ForkJoinPool.commonPool, пулов GC, планировщика
виртуальных потоков и десятка библиотек. При cpu.limit = 500m получится 1 — и
параллельные стримы деградируют до последовательных. При отсутствии лимита — число
ядер узла, то есть 64 GC-потока на маленьком сервисе.
# Что JVM реально решила — не гадаем, а спрашиваем
docker run --rm -m 2g --cpus 1.5 eclipse-temurin:21-jre-jammy \
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|ActiveProcessorCount|UseG1GC'
# Внутри работающего пода
jcmd 1 VM.flags
jcmd 1 VM.native_memory summary # требует -XX:NativeMemoryTracking=summary
Практическое правило: задавайте память явно, не полагаясь на дефолты. Для сервисов
меньше 1 ГиБ лимита — MaxRAMPercentage порядка 50 %, для 4 ГиБ и больше можно 65–70 %.
Если используете виртуальные потоки (см. Конкурентность),
помните: они экономят стеки, но каждая заблокированная операция всё равно держит объекты
в куче.
Ещё пять контейнерных грабель, специфичных именно для Java
- Exit code 137 против
OutOfMemoryError. 137 = 128 + 9 (SIGKILL): память кончилась у контейнера, JVM ничего не успела записать.OutOfMemoryErrorв логе — кончилась куча внутри лимита. Это два разных диагноза с разным лечением: первый — про non-heap и лимит, второй — про утечку или неверныйXmx. - Alpine и musl. Образ
-alpineиспользует musl libc; сборки Temurin под Alpine существуют, но часть нативных библиотек (некоторые драйверы,netty-transport-native-epoll, старые версии snappy) собраны под glibc и падают сUnsatisfiedLinkError. Экономия 40 МиБ редко того стоит;-jammyили distroless надёжнее. - DNS-кэш. JVM кэширует успешные резолвы (
networkaddress.cache.ttl, по умолчанию 30 секунд без SecurityManager, «навсегда» при его наличии). В Kubernetes, где IP пода меняется при рестарте, стоит выставить-Dnetworkaddress.cache.ttl=10. - Часовой пояс и локаль. В slim-образах нет
tzdata,TZне задан, и логи уезжают в UTC, аString.format("%,.2f", x)даёт другой разделитель, чем на машине разработчика. ЗадавайтеTZ=UTCи-Duser.language=en -Duser.country=USявно, а форматирование для пользователя — через явнуюLocale. - Сигналы и graceful shutdown. JVM ставит обработчик SIGTERM и запускает shutdown
hooks. Но если ENTRYPOINT записан в shell-форме, PID 1 — это
/bin/sh, который сигнал никуда не форвардит, и черезterminationGracePeriodSecondsприходит SIGKILL посреди транзакции.
Часть 3. Жизненный цикл инстанса: пробы, прогрев, слив трафика
Java-процесс проходит фазы, которых нет у нативного бинаря: загрузка классов, поднятие контекста DI, а затем длинный прогрев JIT, когда код исполняется интерпретатором и C1, пока C2 не соберёт профиль. Ранние запросы медленнее установившихся в 5–20 раз.
Отсюда правило: startupProbe обязателен, initialDelaySeconds у liveness — плохая
замена, а livenessProbe не должна ходить в базу. Классический инцидент: liveness
проверяет доступность БД, база на минуту деградирует — Kubernetes убивает все поды
разом, они одновременно стартуют, добивают базу пулом соединений, и сервис лежит
не минуту, а полчаса. Liveness отвечает на вопрос «процесс жив и не завис навсегда»,
readiness — «сейчас готов принимать трафик».
startupProbe ok Прогревается --> Готов : readiness = UP,
под добавлен в endpoints Готов --> ВременноНеГотов : БД недоступна,
readiness = DOWN ВременноНеГотов --> Готов : зависимость вернулась Готов --> Сливает : SIGTERM,
readiness = DOWN сразу ВременноНеГотов --> Сливает : SIGTERM Сливает --> Останавливается : активные запросы
дозавершены Останавливается --> [*] : shutdown hooks,
пулы закрыты Готов --> Завис : дедлок / GC-шторм Завис --> [*] : liveness провалена → SIGKILL
Spring Boot Actuator даёт эти состояния готовыми — через группы liveness и readiness:
# application.yaml
management:
server.port: 9090 # actuator на отдельном порту, наружу не публикуется
endpoints.web.exposure.include: health,prometheus,info,metrics,threaddump,heapdump
endpoint.health:
probes.enabled: true # включает /actuator/health/liveness и /readiness
show-details: when-authorized
health.livenessstate.enabled: true
health.readinessstate.enabled: true
server:
shutdown: graceful # перестаём принимать новые запросы, дозавершаем текущие
spring:
lifecycle.timeout-per-shutdown-phase: 25s
# deployment.yaml — существенные части
spec:
template:
spec:
terminationGracePeriodSeconds: 45 # > timeout-per-shutdown-phase + preStop
containers:
- name: orders
startupProbe: # 12 × 5 с = 60 с на самый медленный старт
httpGet: { path: /actuator/health/readiness, port: 9090 }
periodSeconds: 5
failureThreshold: 12
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 9090 }
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 9090 }
periodSeconds: 10
failureThreshold: 6 # 60 с — переживёт длинную GC-паузу
lifecycle:
preStop:
exec:
# Даём kube-proxy и ingress убрать под из балансировки ДО SIGTERM
command: ["sh", "-c", "sleep 8"]
Пауза в preStop — не суеверие: удаление пода из Endpoints и SIGTERM происходят
параллельно, и без задержки часть запросов прилетит в уже закрывающийся процесс.
О стратегиях выкатки (rolling, blue-green, canary) подробно — в статье
CD и стратегии релиза.
Прогрев тоже стоит сделать явным: после старта прогнать пару сотен синтетических вызовов самых горячих путей, и только потом объявлять readiness.
/** Прогрев горячего пути до того, как под попадёт в балансировку. */
@Component
class WarmupRunner implements ApplicationRunner {
private final PriceCalculator calculator;
private final ApplicationEventPublisher events;
private final ApplicationContext context;
WarmupRunner(PriceCalculator calculator, ApplicationEventPublisher events,
ApplicationContext context) {
this.calculator = calculator;
this.events = events;
this.context = context;
}
@Override
public void run(ApplicationArguments args) {
// Пока readiness = REFUSING_TRAFFIC, трафик на под не идёт
AvailabilityChangeEvent.publish(events, context, ReadinessState.REFUSING_TRAFFIC);
Order sample = Order.sample();
for (int i = 0; i < 2_000; i++) { // 2000 итераций хватает,
calculator.total(sample); // чтобы C2 скомпилировал горячие методы
}
AvailabilityChangeEvent.publish(events, context, ReadinessState.ACCEPTING_TRAFFIC);
}
}
Часть 4. Наблюдаемость: модель и конвейер
Три классических сигнала — метрики, логи, трейсы — в Java дополняются четвёртым: непрерывным профилированием через JFR. Различие между ними не в формате, а в вопросе, на который каждый отвечает:
| Сигнал | Отвечает на вопрос | Стоимость | Типичный объём |
|---|---|---|---|
| Метрики | «Что и насколько сломано прямо сейчас?» | Низкая, агрегировано | сотни рядов на инстанс |
| Логи | «Что именно произошло с этим заказом?» | Высокая, растёт с трафиком | ГиБ в день |
| Трейсы | «Где в цепочке из семи сервисов ушло время?» | Средняя, обычно сэмплируется | доли процента запросов |
| Профили (JFR) | «Какой код жжёт CPU и аллоцирует?» | ~1 % CPU | десятки МиБ в час |
Правило старшинства: алертим по метрикам, локализуем по трейсам, докапываемся по логам и профилям. Обратный порядок — «сделаем алерт по строке в логе» — приводит к системе, которая шумит и ничего не объясняет.
Observation API, MDC"] MM["Micrometer
MeterRegistry"] AG["OpenTelemetry Java agent
-javaagent, инструментирует байткод"] LOG["Logback / Log4j2
JSON на stdout"] JFR["JFR
непрерывная запись"] end COL["OpenTelemetry Collector
batch, memory_limiter,
tail sampling, атрибуты"] PRM[("Prometheus / VictoriaMetrics
метрики")] TMP[("Tempo / Jaeger
трейсы")] LOKI[("Loki / ELK
логи")] PROF[("Pyroscope / Cryostat
профили")] GRF["Grafana:
дашборды, алерты,
переходы метрика → трейс → лог"] APP --> MM APP -.контекст.-> AG AG --> COL MM -->|scrape /actuator/prometheus| PRM MM -->|или OTLP| COL LOG -->|stdout → сборщик узла| LOKI JFR --> PROF COL --> PRM COL --> TMP COL --> LOKI PRM --> GRF TMP --> GRF LOKI --> GRF PROF --> GRF
Отдельный Collector между приложением и хранилищами — не лишний слой, а точка, где можно поменять бэкенд, добавить атрибуты (кластер, версия), урезать кардинальность и сделать tail-based sampling, ничего не пересобирая. Общая теория сигналов и SLO разобрана в Наблюдаемости распределённых систем, здесь — только Java-специфика.
Метрики: Micrometer
Micrometer — то же для метрик, что SLF4J для логов: фасад с реализациями под Prometheus,
OTLP, Datadog, CloudWatch. Spring Boot настраивает его автоматически, но модель стоит
понимать: MeterRegistry — реестр, Meter — именованная метрика с набором тегов, и
имя плюс комбинация значений тегов задают отдельный временной ряд.
@Service
class PaymentService {
private final Counter attempts;
private final Timer gatewayLatency;
private final DistributionSummary amounts;
PaymentService(MeterRegistry registry, PaymentGateway gateway) {
this.attempts = Counter.builder("payments.attempts")
.description("Число попыток провести платёж")
.tag("gateway", "acquirer-a") // низкая кардинальность: 3–5 значений
.register(registry);
this.gatewayLatency = Timer.builder("payments.gateway.latency")
.publishPercentileHistogram() // ГИСТОГРАММА, а не готовые перцентили
.serviceLevelObjectives(Duration.ofMillis(200), Duration.ofMillis(800))
.register(registry);
this.amounts = DistributionSummary.builder("payments.amount")
.baseUnit("RUB").register(registry);
// Gauge всегда держит слабую ссылку — объект не должен собираться GC
Gauge.builder("payments.queue.depth", gateway, PaymentGateway::queueDepth)
.register(registry);
}
PaymentResult pay(Order order) {
attempts.increment();
amounts.record(order.total().doubleValue());
// record() измеряет время и пробрасывает исключения наружу без изменений
return gatewayLatency.record(() -> gateway.charge(order));
}
}
Ключевая тонкость, на которой ошибаются почти все: publishPercentiles(0.95, 0.99)
считает перцентили внутри процесса и публикует их как отдельные ряды. Такие значения
нельзя складывать между инстансами — среднее от p99 десяти подов не равно p99
кластера. Правильный путь — publishPercentileHistogram(): наружу уходят бакеты
(..._bucket{le="0.1"}), а перцентиль считает Prometheus по агрегированным бакетам:
# p99 по кластеру, честно посчитанный из бакетов
histogram_quantile(0.99,
sum by (le, uri) (rate(http_server_requests_seconds_bucket{app="orders"}[5m])))
# RED: доля ошибок
sum(rate(http_server_requests_seconds_count{app="orders", status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count{app="orders"}[5m]))
# Доля времени в GC-паузах: выше 5 % — куча мала или утечка
sum(rate(jvm_gc_pause_seconds_sum{app="orders"}[5m]))
/ count(jvm_gc_pause_seconds_count{app="orders"})
Spring Boot из коробки даёт огромный набор JVM- и инфраструктурных метрик. Те, за которыми стоит следить всегда:
| Метрика | Что означает | Порог для внимания |
|---|---|---|
jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes |
заполнение кучи после сборок | устойчиво > 85 % |
jvm_gc_pause_seconds (sum/count) |
доля времени в паузах | > 5 % времени |
jvm_memory_used_bytes{id="Metaspace"} |
утечка загрузчиков классов | монотонный рост |
jvm_threads_live_threads |
утечка потоков | монотонный рост |
hikaricp_connections_pending |
голод по соединениям к БД | > 0 устойчиво |
executor_queued_tasks |
переполнение пула задач | растёт без спада |
tomcat_threads_busy_threads |
насыщение веб-сервера | > 80 % от max |
http_server_requests_seconds |
RED-метрики HTTP | по SLO |
Полезные мелочи: метрики Tomcat не публикуются, пока не включён
server.tomcat.mbeanregistry.enabled=true; общие теги (application, version,
env) удобнее задать один раз через MeterFilter; JVM-метрики виртуальных потоков
появляются при подключённом micrometer-java21.
Главный риск метрик — кардинальность. Один ряд Prometheus стоит примерно 3–4 КиБ
резидентной памяти. Тег с userId на сервисе с миллионом пользователей — это гигабайты
и упавший Prometheus. В Spring это чаще всего происходит из-за URI: если писать метрику
по request.getRequestURI(), каждый /orders/8f1c… станет отдельным рядом. Boot по
умолчанию использует шаблон маршрута (uri="/orders/{id}") — не ломайте это. Плюс
страховка на уровне реестра:
@Bean
MeterFilter cardinalityGuard() {
// Не больше 200 значений тега uri; остальное схлопывается в "other"
return MeterFilter.maximumAllowableTags("http.server.requests", "uri", 200,
MeterFilter.deny());
}
Логи: структурные, на stdout, с trace id
Java-логирование — это зоопарк фасадов (SLF4J, JCL, JBoss Logging) и реализаций
(Logback, Log4j2, JUL). Практический выбор: SLF4J как API + Logback (дефолт Boot) или
Log4j2 (если нужен асинхронный disruptor-аппендер). Все прочие фасады мостятся к SLF4J
через jcl-over-slf4j, jul-to-slf4j — иначе часть логов библиотек просто исчезает.
Три требования к прод-логам:
- JSON, а не человекочитаемый текст. Grep по многострочному stack trace — это боль;
структурное поле
level,logger,trace_id,order_id— запрос в один клик. - stdout, а не файлы. Ротация, сбор и хранение — работа платформы, не приложения. Файл внутри контейнера переживёт рестарт ровно ноль раз.
- Корреляция. В каждой строке должны быть
traceIdиspanId— иначе трейсы и логи живут в разных вселенных.
Начиная с Spring Boot 3.4 структурное логирование встроено:
logging:
structured.format.console: ecs # либо logstash, либо gelf, либо свой формат
level:
root: INFO
org.hibernate.SQL: DEBUG # только в dev-профиле!
Контекст запроса кладётся в MDC — ThreadLocal-хранилище пар ключ-значение:
/** Прокидываем бизнес-идентификаторы в каждую строку лога этого запроса. */
@Component
class MdcFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String tenant = Objects.requireNonNullElse(req.getHeader("X-Tenant-Id"), "unknown");
MDC.put("tenant_id", tenant);
try {
chain.doFilter(req, res);
} finally {
MDC.clear(); // ОБЯЗАТЕЛЬНО: платформенные потоки переиспользуются пулом,
} // иначе чужой tenant_id утечёт в лог следующего запроса
}
}
Грабли логирования, которые видно в каждом втором проекте:
- MDC не переезжает в другой поток.
@Async,CompletableFuture, реактивные цепочки теряют контекст. Лечение:TaskDecoratorв Spring,ContextExecutorServiceиз Micrometer Context Propagation, а в реактивных стеках —ContextвместоThreadLocal. С виртуальными потоками MDC работает (у каждого свойThreadLocal), аScopedValue(финализирован в JDK 25) — более дешёвая и безопасная замена там, где значение действительно неизменяемо. - Синхронный аппендер под нагрузкой. Запись в stdout — блокирующий syscall; при
10 000 строк/с он становится узким местом.
AsyncAppenderспасает, но по умолчанию при заполнении очереди на 80 % молча выбрасывает события уровня ниже WARN (discardingThreshold), а при полном заполнении блокирует поток. Настраивайте осознанно:queueSize,discardingThreshold=0,neverBlock=true. - Логирование в цикле по коллекции. Строка на элемент при 100k элементов — это минуты CPU и гигабайты диска. Логируйте агрегаты.
- Дорогая конкатенация.
log.debug("state " + heavyObject)вычисляется даже при выключенном DEBUG. Плейсхолдерыlog.debug("state {}", heavyObject)— нет. - Секреты и персональные данные в логах. Токен, попавший в лог, считается скомпрометированным. См. Управление секретами.
- История Log4Shell (CVE-2021-44228) — напоминание, что логгер это код, исполняющий
ваши данные: подстановка
${jndi:...}в сообщении приводила к RCE. Логгер надо обновлять так же дисциплинированно, как фреймворк.
Трейсинг: контекст, который переживает границу процесса
Трассировка отвечает на вопрос «куда ушли 1200 мс», когда запрос прошёл через шлюз, два сервиса, кэш и базу. Технически всё держится на одном заголовке — W3C Trace Context:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ версия ^^ trace-id (16 байт) ^^ parent span ^^ флаги (01 = sampled)
решает: сэмплировать? GW->>O: traceparent: 00-4bf9…-00f0…-01 Note over O: агент восстанавливает контекст
из заголовка в ThreadLocal O->>DB: SELECT … (span: db.query, db.statement) DB-->>O: rows O->>K: publish OrderCreated
traceparent в headers сообщения O-->>GW: 201 Created GW-->>C: 201 Created K->>B: consume OrderCreated Note over B: новый span с тем же trace-id,
parent = span продюсера B->>B: списание средств Note over C,B: один trace-id связал синхронную и асинхронную части
В Java есть три способа получить трейсы, и выбор между ними — реальное архитектурное решение.
1. OpenTelemetry Java agent — «нулевой код». Подключается флагом, инструментирует байткод более чем 100 библиотек (Servlet, JDBC, Kafka, Redis, gRPC, HTTP-клиенты) через ByteBuddy на этапе загрузки классов:
java -javaagent:/opt/opentelemetry-javaagent.jar \
-Dotel.service.name=orders \
-Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
-Dotel.traces.sampler=parentbased_traceidratio \
-Dotel.traces.sampler.arg=0.05 \
-Dotel.metrics.exporter=none \
-jar app.jar
Плюс: работает с чужим кодом и легаси без единой правки. Минус: +1–3 секунды к старту, несколько сотен МиБ Metaspace на инструментированные классы, несовместимость с native-image и иногда конфликт с другими агентами (APM-вендоры).
2. Micrometer Observation API — «одна абстракция на метрику и спан». Spring Boot 3 построен на нём: одно наблюдение порождает и таймер, и спан, и лог-контекст.
@Service
class ShippingService {
private final ObservationRegistry registry;
private final CarrierClient carrier;
ShippingService(ObservationRegistry registry, CarrierClient carrier) {
this.registry = registry;
this.carrier = carrier;
}
Label createLabel(Order order) {
return Observation.createNotStarted("shipping.label.create", registry)
// lowCardinality → попадает и в теги метрики, и в атрибуты спана
.lowCardinalityKeyValue("carrier", order.carrier().code())
.lowCardinalityKeyValue("country", order.address().country())
// highCardinality → только в спан, в метрику НЕ идёт
.highCardinalityKeyValue("order.id", order.id().toString())
.observe(() -> carrier.requestLabel(order));
}
}
Зависимости: micrometer-tracing-bridge-otel плюс opentelemetry-exporter-otlp;
частота сэмплирования — management.tracing.sampling.probability.
3. Ручной SDK — когда нужно инструментировать нестандартный протокол или добавить события внутрь спана. Пишется редко и точечно.
Про сэмплирование стоит понимать главное: head-based (решение на входе, как в примере
выше) дёшево, но теряет именно редкие медленные запросы. Tail-based (решение после
завершения трейса, в Collector) позволяет оставить 100 % ошибок и медленных запросов и
1 % нормальных — но требует, чтобы все спаны трейса пришли в один экземпляр Collector.
Практика: head-based на 5–10 % плюс parentbased_always_on для запросов с флагом
отладки — и tail sampling в Collector, когда объёмы вырастут.
Exemplars связывают два мира: в бакеты гистограммы Prometheus прикладывается trace-id конкретного медленного запроса, и из графика p99 в Grafana можно кликом провалиться в трейс. Micrometer поддерживает их при активном трейсинге и включённом OpenMetrics.
Четвёртый сигнал: JFR как непрерывный профилировщик
Java Flight Recorder — встроенный в JVM низкооверхедный профайлер. Он не «инструмент для расследования постфактум», а сигнал, который должен быть включён всегда: когда инцидент случится, запись за последние часы уже будет.
# Включено флагом при старте (см. Dockerfile выше), либо на живом процессе:
jcmd 1 JFR.start name=adhoc settings=profile maxsize=256m maxage=2h
jcmd 1 JFR.dump name=adhoc filename=/dumps/incident-$(date +%s).jfr
jcmd 1 JFR.stop name=adhoc
# Быстрый разбор без GUI
jfr summary /dumps/app.jfr
jfr print --events jdk.ExecutionSample,jdk.ObjectAllocationSample /dumps/app.jfr | head -60
Накладные расходы профиля default — менее 1 % CPU, profile — обычно 1–2 %.
Кроме сэмплов стека JFR пишет события GC, safepoint, блокировок монитора, работы с
сокетами и файлами, компиляции — то, чего нет ни в одном внешнем профайлере. Для
непрерывного профилирования кластера есть Cryostat (автоматический сбор JFR из подов) и
Pyroscope/Grafana Profiles. Детали разбора флеймграфов и JMH-микробенчмарков — в статье
Производительность.
Отдельно про дампы кучи: -XX:+HeapDumpOnOutOfMemoryError бесполезен, если писать дамп
в слой контейнера — под перезапустится, и файл исчезнет. Смонтируйте том (emptyDir не
переживает рестарт пода, но переживает рестарт контейнера) или persistent volume, и
помните, что дамп кучи на 4 ГиБ пишется десятки секунд и содержит все персональные
данные, которые были в памяти.
Часть 5. Минимальный, но настоящий стенд
Собрать локально весь конвейер — полчаса работы и лучшая инвестиция в понимание.
# docker-compose.yaml
services:
orders:
build: .
environment:
JAVA_TOOL_OPTIONS: >-
-javaagent:/opt/opentelemetry-javaagent.jar
-Dotel.service.name=orders
-Dotel.exporter.otlp.endpoint=http://collector:4317
-Dotel.traces.sampler=parentbased_traceidratio
-Dotel.traces.sampler.arg=1.0
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,prometheus,metrics
ports: ["8080:8080", "9090:9090"]
deploy:
resources:
limits: { memory: 1g, cpus: "1.5" } # воспроизводим продовые лимиты локально!
collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel.yaml"]
volumes: ["./otel.yaml:/etc/otel.yaml:ro"]
prometheus:
image: prom/prometheus:latest
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml:ro"]
ports: ["9091:9090"]
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
grafana:
image: grafana/grafana:latest
ports: ["3000:3000"]
# otel.yaml — конфигурация коллектора
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
memory_limiter: # коллектор не должен умереть раньше приложения
check_interval: 1s
limit_percentage: 75
batch:
timeout: 5s
send_batch_size: 512
resource:
attributes:
- { key: deployment.environment, value: local, action: upsert }
exporters:
otlp/tempo:
endpoint: tempo:4317
tls: { insecure: true }
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [prometheus]
Тот же приём, что и в Тестировании: поднимайте реальную
инфраструктуру, а не заглушки. Обратите внимание на limits: memory: 1g — половина
проблем с эргономикой JVM видна уже локально, если не забыть выставить лимит.
Часть 6. По каким метрикам будить дежурного
Алертить надо по симптомам, которые чувствует пользователь, а не по каждому всплеску CPU. Базовый набор для Java-сервиса:
- Бюджет ошибок SLO (burn rate). Быстрое сжигание (2 % бюджета за час) — страница дежурному, медленное (10 % за сутки) — тикет. Это главный алерт, остальные — вспомогательные.
- Насыщение пулов.
hikaricp_connections_pending > 0пять минут подряд означает, что запросы стоят в очереди за соединением: скоро вырастет p99 всего сервиса. См. Работу с данными. - GC overhead. Больше 5–10 % времени в паузах — приложение работает вхолостую.
Отдельный алерт на
Full GCв G1: это почти всегда симптом, а не норма. - Metaspace растёт монотонно — утечка загрузчиков классов; типична при горячей перезагрузке и динамическом создании прокси.
- Рестарты пода с exit 137 — не «просто перезапустился», а неверный бюджет памяти.
- Рост
jvm_threads_live_threads— где-то создаются потоки без пула.
Чего не стоит делать: алерт на «CPU > 80 %» (для JVM под нагрузкой это норма), алерт на каждую строчку ERROR (шум), алерт на абсолютное значение занятой кучи (после сборки она всё равно упадёт — смотрите на заполнение после Full GC и на тренд).
Типичные грабли этого этапа
-Xmxзадан,MaxRAMPercentageигнорируется — и наоборот. Явный-Xmxперекрывает проценты; смешивать оба в разных местах (JAVA_TOOL_OPTIONS,JAVA_OPTS, аргументыENTRYPOINT) — гарантированная путаница. Проверяйтеjcmd VM.flags.- Настройки в образе, а не в конфигурации. Пересобирать образ ради смены таймаута — антипаттерн; конфигурация приходит из переменных окружения и ConfigMap, образ один и тот же во всех средах (см. Архитектуру прод-приложений).
latestв теге образа. Невозможно понять, что развёрнуто, и невозможно откатиться. Тег — git sha или семантическая версия, всегда.- Health-эндпоинт наружу.
/actuatorна публичном порту раздаётenv,heapdump,threaddump— карту вашей инфраструктуры и содержимое памяти. Отдельный порт плюс сетевая политика. - Liveness ходит в базу. Каскадное убийство всех подов при деградации зависимости.
- Нет
preStopи graceful shutdown. 502 у пользователей на каждом деплое. - Логи в файл внутри контейнера. Теряются при рестарте, забивают overlay-слой, иногда переполняют диск узла.
- Кардинальность метрик по
userId/orderId/сырому URI. Убитый Prometheus, счёт от облачного провайдера. - Перцентили, посчитанные в процессе, агрегируются по кластеру. Числа выглядят правдоподобно и при этом неверны.
- Разные версии JDK на сборке и в рантайме. Собрали на 21, запустили на 17 —
UnsupportedClassVersionErrorпри первом же обращении к классу. Фиксируйтеmaven.compiler.releaseи базовый образ (см. Установку и инструментарий). - Ассерты и debug-флаги в проде.
-XX:+PrintCompilation,-verbose:class,org.hibernate.SQL=DEBUG— по отдельности мелочь, вместе съедают десятки процентов CPU. - Отсутствие
build-info. Без версии в/actuator/infoи в теге метрик невозможно сопоставить всплеск p99 с конкретным релизом. Один плагин — и на графике появляется аннотация версии.
Честно: где Java выигрывает и где проигрывает
Выигрывает там, где процесс живёт долго и держит нагрузку. Через минуту после старта
C2 выдаёт код, конкурирующий с C++, GC-паузы у ZGC измеряются долями миллисекунды на
кучах в сотни гигабайт, а наблюдаемость получается почти даром: JMX, JFR и агент-инструментация
дают глубину, ради которой в Go или Python пришлось бы вручную обвешивать код.
Диагностика живого продового процесса без рестарта — jcmd, jstack, JFR.dump,
динамическое изменение уровня логирования — это то, чего у большинства платформ просто нет.
Проигрывает там, где важен старт и след в памяти. Go-бинарь — 10–20 МиБ образа, старт 5 мс, RSS 15 МиБ. Java: образ 250+ МиБ, старт 1–4 секунды, RSS 400 МиБ на скромный сервис. Для CLI-утилит, лямбд с холодным стартом, sidecar-контейнеров и IoT это плохой выбор — и native-image спасает не всегда: сборка минутами, closed-world ломает динамические фреймворки, а пиковая пропускная способность обычно ниже, чем у прогретого C2.
С .NET ситуация зеркально похожая: те же слоёные образы, тот же порядок величин по
старту и памяти, тот же набор сигналов. Различия: dotnet publish даёт self-contained
артефакт из коробки (в Java это jlink, который нужно настраивать), Native AOT в .NET
проще в применении, чем GraalVM, а dotnet-counters/dotnet-trace ближе к jcmd+JFR,
но JFR богаче по событиям и дешевле по накладным расходам. В метриках .NET использует
System.Diagnostics.Metrics поверх OpenTelemetry, Java — Micrometer как фасад; итог
сопоставим. Подробности соседнего трека — в статье
Деплой и наблюдаемость .NET.
Вывод без пафоса: если сервис живёт неделями и обрабатывает тысячи запросов в секунду, Java-деплой — отличный выбор, надо лишь честно посчитать бюджет памяти и не забыть про пробы. Если процесс живёт 200 мс и запускается по событию — возьмите другой инструмент или согласитесь на компромиссы native-image.
Мини-итог
- Артефакт Java — это байткод плюс рантайм. Выбор упаковки (fat jar → слоёный образ → jlink → AppCDS → AOT-кэш → native-image) — это выбор точки на шкале «старт против пиковой производительности и гибкости».
- Порядок
COPYв Dockerfile определяет трафик деплоя сильнее, чем базовый образ: слоёный jar превращает 78 МиБ на коммит в 1,8 МиБ. - В контейнере JVM живёт по правилам cgroup. Куча — только часть RSS;
OOMKilled(137) иOutOfMemoryError— разные диагнозы. Меряйте через NMT, а не угадывайте. - Пробы разделены по смыслу:
startupProbeтерпит медленный старт,readinessуправляет трафиком,livenessловит зависание и не ходит во внешние зависимости. - Четыре сигнала: метрики (Micrometer, гистограммы а не перцентили, следите за кардинальностью), логи (JSON на stdout с trace id, чистый MDC), трейсы (W3C traceparent, агент или Observation API, осознанное сэмплирование) и профили (JFR всегда включён).
- Алертить по бюджету ошибок и насыщению пулов, а не по CPU и строчкам ERROR.
Источники
- Spring Boot Reference: Container Images —
слоёные jar,
jarmode, buildpacks, CDS. - Spring Boot Actuator — health-группы, метрики, трейсинг, эндпоинты.
- Micrometer Documentation —
реестры, гистограммы,
MeterFilter, Observation API. - OpenTelemetry Java и Java agent configuration — инструментирование, сэмплеры, экспортёры.
- W3C Trace Context — спецификация
traceparent. - JDK Flight Recorder guide —
jcmd, JFR, дампы, NMT. - JEP 483: Ahead-of-Time Class Loading & Linking — AOT-кэш Leyden.
- GraalVM Native Image и Spring Boot GraalVM support.
- Kubernetes: Configure Liveness, Readiness and Startup Probes.
- Prometheus: histograms and summaries — почему перцентили нельзя усреднять.
- Betsy Beyer et al., Site Reliability Engineering — SLO, бюджеты ошибок, alerting on burn rate (доступна бесплатно).
- Charity Majors et al., Observability Engineering — про события высокой кардинальности и пределы дашбордов.
- Ben Evans, James Gough, Chris Newland, Optimizing Java — прогрев, GC и измерения в проде.
Что дальше
Мы довели сервис до состояния «работает в проде и всё про себя рассказывает». Осталось замкнуть цикл разработки: как это собирается и выкатывается автоматически, как управлять деревом зависимостей и уязвимостями в нём, как не превратить обновление версии JDK в квартальный проект — и где брать знания дальше.
Java в SDLC: CI/CD, зависимости, безопасность и лучшие ресурсы