Java Деплой и наблюдаемость: сборка, контейнеры, метрики, трейсинг
0%

Деплой и наблюдаемость: сборка, контейнеры, метрики, трейсинг

Деплой и наблюдаемость: сборка, контейнеры, метрики, трейсинг

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

Смысл разбиения — частота изменений. Зависимости меняются раз в спринт, ваш код — по десять раз в день. Если положить их в разные слои образа, на каждый коммит по сети поедет пара мегабайт вместо восьмидесяти.

Fat jar одним слоем против слоёного образа

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, потому что вне кучи живёт ещё половина потребления.

Бюджет памяти Java-процесса в контейнере

Вторая — процессоры. 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

  1. Exit code 137 против OutOfMemoryError. 137 = 128 + 9 (SIGKILL): память кончилась у контейнера, JVM ничего не успела записать. OutOfMemoryError в логе — кончилась куча внутри лимита. Это два разных диагноза с разным лечением: первый — про non-heap и лимит, второй — про утечку или неверный Xmx.
  2. Alpine и musl. Образ -alpine использует musl libc; сборки Temurin под Alpine существуют, но часть нативных библиотек (некоторые драйверы, netty-transport-native-epoll, старые версии snappy) собраны под glibc и падают с UnsatisfiedLinkError. Экономия 40 МиБ редко того стоит; -jammy или distroless надёжнее.
  3. DNS-кэш. JVM кэширует успешные резолвы (networkaddress.cache.ttl, по умолчанию 30 секунд без SecurityManager, «навсегда» при его наличии). В Kubernetes, где IP пода меняется при рестарте, стоит выставить -Dnetworkaddress.cache.ttl=10.
  4. Часовой пояс и локаль. В slim-образах нет tzdata, TZ не задан, и логи уезжают в UTC, а String.format("%,.2f", x) даёт другой разделитель, чем на машине разработчика. Задавайте TZ=UTC и -Duser.language=en -Duser.country=US явно, а форматирование для пользователя — через явную Locale.
  5. Сигналы и 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 — «сейчас готов принимать трафик».

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 десятки МиБ в час

Правило старшинства: алертим по метрикам, локализуем по трейсам, докапываемся по логам и профилям. Обратный порядок — «сделаем алерт по строке в логе» — приводит к системе, которая шумит и ничего не объясняет.

Отдельный 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 — иначе часть логов библиотек просто исчезает.

Три требования к прод-логам:

  1. JSON, а не человекочитаемый текст. Grep по многострочному stack trace — это боль; структурное поле level, logger, trace_id, order_id — запрос в один клик.
  2. stdout, а не файлы. Ротация, сбор и хранение — работа платформы, не приложения. Файл внутри контейнера переживёт рестарт ровно ноль раз.
  3. Корреляция. В каждой строке должны быть 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)

В 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 и на тренд).

Типичные грабли этого этапа

  1. -Xmx задан, MaxRAMPercentage игнорируется — и наоборот. Явный -Xmx перекрывает проценты; смешивать оба в разных местах (JAVA_TOOL_OPTIONS, JAVA_OPTS, аргументы ENTRYPOINT) — гарантированная путаница. Проверяйте jcmd VM.flags.
  2. Настройки в образе, а не в конфигурации. Пересобирать образ ради смены таймаута — антипаттерн; конфигурация приходит из переменных окружения и ConfigMap, образ один и тот же во всех средах (см. Архитектуру прод-приложений).
  3. latest в теге образа. Невозможно понять, что развёрнуто, и невозможно откатиться. Тег — git sha или семантическая версия, всегда.
  4. Health-эндпоинт наружу. /actuator на публичном порту раздаёт env, heapdump, threaddump — карту вашей инфраструктуры и содержимое памяти. Отдельный порт плюс сетевая политика.
  5. Liveness ходит в базу. Каскадное убийство всех подов при деградации зависимости.
  6. Нет preStop и graceful shutdown. 502 у пользователей на каждом деплое.
  7. Логи в файл внутри контейнера. Теряются при рестарте, забивают overlay-слой, иногда переполняют диск узла.
  8. Кардинальность метрик по userId/orderId/сырому URI. Убитый Prometheus, счёт от облачного провайдера.
  9. Перцентили, посчитанные в процессе, агрегируются по кластеру. Числа выглядят правдоподобно и при этом неверны.
  10. Разные версии JDK на сборке и в рантайме. Собрали на 21, запустили на 17 — UnsupportedClassVersionError при первом же обращении к классу. Фиксируйте maven.compiler.release и базовый образ (см. Установку и инструментарий).
  11. Ассерты и debug-флаги в проде. -XX:+PrintCompilation, -verbose:class, org.hibernate.SQL=DEBUG — по отдельности мелочь, вместе съедают десятки процентов CPU.
  12. Отсутствие 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.

Источники

Что дальше

Мы довели сервис до состояния «работает в проде и всё про себя рассказывает». Осталось замкнуть цикл разработки: как это собирается и выкатывается автоматически, как управлять деревом зависимостей и уязвимостями в нём, как не превратить обновление версии JDK в квартальный проект — и где брать знания дальше.

Java в SDLC: CI/CD, зависимости, безопасность и лучшие ресурсы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов