JVM в production: конфигурация, graceful shutdown, observability и GC

Production-дисциплина для JVM-сервиса: конфигурация из env, graceful shutdown под оркестратором, observability через OpenTelemetry/Micrometer и понимание GC — что настроить, чтобы сервис жил под нагрузкой

Язык выбран, сервис написан и упакован — остаётся довести его до эксплуатации. Для JVM это отдельная дисциплина: правильная конфигурация, корректное завершение под Kubernetes, наблюдаемость и понимание сборщика мусора. Не «фичи», а то, без чего сервис не переживёт первый пик нагрузки.

По аналогии с production-паттернами Rust и C++Скоро, но со спецификой JVM.

В этой статье — не про GC-флаги (они разобраны отдельно, вместе с профилированием, в профилировании JVM), а про границу сервиса с внешним миром: нестабильный downstream, сигнал SIGTERM от оркестратора и вопрос «а сервис вообще жив?». Три вещи, которые вместе решают, переживёт ли сервис реальную эксплуатацию: resilience4j-цепочка (retry, circuit breaker, rate limiter, bulkhead), честный drain in-flight запросов при остановке и health-эндпоинт, которому балансировщик может доверять. Всё ниже — с реального прогона на нестабильном downstream: полный цикл circuit breaker, сырые и финальные счётчики отказов, дословный лог graceful shutdown.

Production-паттерны на JVM: resilience4j и graceful shutdown

В статье

Resilience4j: почему порядок декораторов не мелочь

Стенд — plain-Java (без Spring Boot: resilience4j-core API вызывается напрямую, без autoconfigure-магии, так виден реальный порядок декораторов) и нестабильный downstream с двумя управляемыми фазами: outage — первые recoveryAfterMillis (в прогоне — 5000мс) все вызовы падают, и recovered — дальше вызовы в основном успешны, но с остаточными 8% ошибок и сетевой задержкой 40–240мс. Обе фазы реально спят (Thread.sleep), не мгновенны — retry, backoff и конкуренция за bulkhead-слоты ведут себя как на настоящем I/O.

<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-circuitbreaker</artifactId>
</dependency>
<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-retry</artifactId>
</dependency>
<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-ratelimiter</artifactId>
</dependency>
<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-bulkhead</artifactId>
</dependency>

Цепочка декораторов собирается снаружи внутрь: Retry → CircuitBreaker → RateLimiter → Bulkhead → downstream. Порядок не случаен:

  • Retry снаружи всех остальных — иначе повторная попытка не пройдёт заново через CB/RateLimiter/Bulkhead, а просто повторит вызов downstream в обход всей защиты.
  • CircuitBreaker раньше RateLimiter/Bulkhead — breaker должен видеть только реальные отказы downstream, а не собственную перегрузку клиента.
  • RateLimiter и Bulkhead ближе к вызову — они защищают downstream от самого клиента, а не друг от друга.
flowchart LR T["Task"] --> R["Retry\nmaxAttempts=3\nbackoff 300ms×2.0"] R --> CB["CircuitBreaker\nslidingWindow=10\nfailureRateThreshold=50%"] CB --> RL["RateLimiter\n5 вызовов / 1с"] RL --> BH["Bulkhead\nmaxConcurrentCalls=3"] BH --> D["UnstableDownstream"] R -. "попытка #2, #3 — заново через CB→RL→BH" .-> CB style T fill:#f9f3e3,stroke:#8b7355 style R fill:#c9e4c5,stroke:#5b8a5e style CB fill:#f5d6d0,stroke:#b05050 style RL fill:#c9e4c5,stroke:#5b8a5e style BH fill:#c9e4c5,stroke:#5b8a5e style D fill:#f9f3e3,stroke:#8b7355

flowchart LR
  T["Task"] --> R["Retry\nmaxAttempts=3\nbackoff 300ms×2.0"]
  R --> CB["CircuitBreaker\nslidingWindow=10\nfailureRateThreshold=50%"]
  CB --> RL["RateLimiter\n5 вызовов / 1с"]
  RL --> BH["Bulkhead\nmaxConcurrentCalls=3"]
  BH --> D["UnstableDownstream"]
  R -. "попытка #2, #3 — заново через CB→RL→BH" .-> CB
  style T fill:#f9f3e3,stroke:#8b7355
  style R fill:#c9e4c5,stroke:#5b8a5e
  style CB fill:#f5d6d0,stroke:#b05050
  style RL fill:#c9e4c5,stroke:#5b8a5e
  style BH fill:#c9e4c5,stroke:#5b8a5e
  style D fill:#f9f3e3,stroke:#8b7355
Порядок декораторов: каждая retry-попытка заново проходит весь путь
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
        .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
        .slidingWindowSize(10)
        .minimumNumberOfCalls(5)
        .failureRateThreshold(50.0f)
        .waitDurationInOpenState(Duration.ofSeconds(4))
        .permittedNumberOfCallsInHalfOpenState(3)
        .automaticTransitionFromOpenToHalfOpenEnabled(true)
        .ignoreExceptions(RequestNotPermitted.class, BulkheadFullException.class)
        .build();
circuitBreaker = CircuitBreaker.of("downstream", cbConfig);

RetryConfig retryConfig = RetryConfig.custom()
        .maxAttempts(3)
        .intervalFunction(IntervalFunction.ofExponentialBackoff(Duration.ofMillis(300), 2.0))
        .ignoreExceptions(CallNotPermittedException.class)
        .build();
retry = Retry.of("downstream", retryConfig);

RateLimiterConfig rlConfig = RateLimiterConfig.custom()
        .limitForPeriod(5)
        .limitRefreshPeriod(Duration.ofSeconds(1))
        .timeoutDuration(Duration.ZERO)
        .build();
rateLimiter = RateLimiter.of("downstream", rlConfig);

BulkheadConfig bhConfig = BulkheadConfig.custom()
        .maxConcurrentCalls(3)
        .maxWaitDuration(Duration.ZERO)
        .build();
bulkhead = Bulkhead.of("downstream", bhConfig);

Две детали в конфигурации сделаны честными, а не «удобными»:

  • CircuitBreaker игнорирует RequestNotPermitted и BulkheadFullException в своей статистике — перегрузка нашего же клиента (лимитер, бухгалтерия слотов) не должна маскироваться под деградацию downstream. Если считать эти отказы как failure, breaker откроется от собственной перегрузки, а не от реальной проблемы на той стороне.
  • Retry игнорирует CallNotPermittedException — если breaker уже открыт, повторять вызов бессмысленно: это только добавляет нагрузку, единственная попытка должна быстро провалиться.

Собственно порядок декораторов — это одна функция, но композиция читается изнутри наружу:

public <T> Supplier<T> decorate(Supplier<T> supplier) {
    Supplier<T> withBulkhead = Bulkhead.decorateSupplier(bulkhead, supplier);
    Supplier<T> withRateLimiter = RateLimiter.decorateSupplier(rateLimiter, withBulkhead);
    Supplier<T> withCircuitBreaker = CircuitBreaker.decorateSupplier(circuitBreaker, withRateLimiter);
    return Retry.decorateSupplier(retry, withCircuitBreaker);
}

Bulkhead.decorateSupplier вызывается первым, значит выполняется последним (ближе всего к вызову) — а Retry.decorateSupplier вызывается последним, значит выполняется первым (снаружи всех). Каждая retry-попытка — это отдельный проход через CB, RateLimiter и Bulkhead заново, а не один сквозной вызов с ретраем только downstream. Так ведёт себя реальный клиент: вторая попытка так же может упереться в открытый breaker или в исчерпанный лимит.

Circuit breaker: от чистого цикла до честного edge-case

За один непрерывный прогон (docker run -d ... до docker stop -t 20) breaker прошёл полный цикл состояний, включая не самый частый в демо-материалах случай — «лишний» отскок обратно в OPEN:

20:57:17.686 [work] WARN >>> [CircuitBreaker] CLOSED -> OPEN at 2026-07-07T20:57:17.686769202Z
20:57:22.088 [work] WARN >>> [CircuitBreaker] OPEN -> HALF_OPEN at 2026-07-07T20:57:22.088210245Z
20:57:22.477 [work] WARN >>> [CircuitBreaker] HALF_OPEN -> OPEN at 2026-07-07T20:57:22.477823046Z
20:57:26.478 [CircuitBreakerAutoTransitionThread] WARN >>> [CircuitBreaker] OPEN -> HALF_OPEN at 2026-07-07T20:57:26.478189032Z
20:57:27.034 [work] WARN >>> [CircuitBreaker] HALF_OPEN -> CLOSED at 2026-07-07T20:57:27.034011170Z
stateDiagram-v2 [*] --> CLOSED CLOSED --> OPEN: 5 вызовов подряд, failureRate=100% OPEN --> HALF_OPEN: waitDurationInOpenState=4s HALF_OPEN --> OPEN: проба на границе recovery, elapsed=4967ms OPEN --> HALF_OPEN: ещё 4s, auto-transition HALF_OPEN --> CLOSED: пробы прошли, downstream восстановлен

stateDiagram-v2
  [*] --> CLOSED
  CLOSED --> OPEN: 5 вызовов подряд, failureRate=100%
  OPEN --> HALF_OPEN: waitDurationInOpenState=4s
  HALF_OPEN --> OPEN: проба на границе recovery, elapsed=4967ms
  OPEN --> HALF_OPEN: ещё 4s, auto-transition
  HALF_OPEN --> CLOSED: пробы прошли, downstream восстановлен
Состояния circuit breaker в этом прогоне — включая честный отскок HALF_OPEN → OPEN

Первые пять вызовов пришлись на фазу outage (100% отказ) — при minimumNumberOfCalls=5 и slidingWindowSize=10 этого достаточно, чтобы failureRate дошёл до 100% и breaker открылся: CLOSED -> OPEN уже на 17.686, меньше секунды с начала прогона.

Через waitDurationInOpenState=4s breaker переходит в HALF_OPEN и пропускает пробные вызовы — до permittedNumberOfCallsInHalfOpenState=3 (первый — в 22.088). Дальше — честный edge-case, ради которого этот прогон стоит показывать: все пробные вызовы пришлись на узкое окно 22.088–22.477, то есть на границу восстановления downstreamrecoveryAfterMillis=5000мс с момента старта UnstableDownstream, а пробы состоялись при elapsed≈4967мс, за ~33мс до восстановления. Downstream формально ещё в фазе outage, пробные вызовы падают, half-open-буфер заполняется отказами, и breaker честно возвращается в OPEN (22.477) вместо того, чтобы закрыться раньше времени. Это не баг демо-стенда: breaker не умеет предсказывать «downstream вот-вот восстановится», он может только пробовать и реагировать на факт.

Второй HALF_OPEN (26.478, после ещё одного waitDurationInOpenState) приходится уже на реально восстановившийся downstream — пробные вызовы (до permittedNumberOfCallsInHalfOpenState=3) проходят, и в 27.034 breaker закрывается.

Retry: экспоненциальный backoff и путь одной задачи

20:57:17.255 [work] INFO >>> [Retry] [task-1] attempt #1 after: downstream unavailable (call #1, outage, elapsed=189ms)
20:57:17.381 [work] INFO >>> [Retry] [task-2] attempt #1 after: downstream unavailable (call #2, outage, elapsed=312ms)
20:57:17.653 [work] INFO >>> [Retry] [task-1] attempt #2 after: downstream unavailable (call #4, outage, elapsed=581ms)
20:57:17.684 [work] INFO >>> [Retry] [task-2] attempt #2 after: RateLimiter 'downstream' does not permit further calls

maxAttempts=3 с экспоненциальным backoff 300мс × 2.0: пауза примерно 300мс перед второй попыткой, 600мс — перед третьей. У task-2 вторая попытка уже упирается не в downstream, а в RateLimiter — ретрай честно проходит всю цепочку заново, а не только вызов downstream, и лимитер может отказать раньше, чем дело дойдёт до реального вызова. Номер call #N — это глобальный счётчик вызовов UnstableDownstream.call(), общий на все задачи, поэтому он не совпадает с номером попытки конкретной задачи.

Строки лога привязаны к task-N, хотя Retry-объект общий на всё приложение (одна цепочка декораторов на все задачи, не по объекту на задачу), а все воркер-потоки называются одинаково — "work". Атрибуция к задаче держится на ThreadLocal (ResilienceStack.currentTaskId): Main выставляет id вокруг decorated.get() и снимает его в finally, а onRetry-листенер синхронно выполняется в том же потоке — decorated.get() вызывается синхронно, включая Thread.sleep между попытками. Изначально для этого пытались использовать MDC — не сработало: slf4j-simple не предоставляет MDCAdapter, MDC.put/get там no-op.

Похожий паттерн — retry с backoff, но в персистентной форме, переживающей рестарт процесса, с dead-letter-очередью и идемпотентностью — разобран в очередях задач и фоновых джобахготовится, с 17 сентября. Здесь весь прогресс ретрая живёт в памяти одного JVM-процесса: упадёт процесс — задача просто исчезнет.

Rate limiter и bulkhead: сырые события против финальных отказов

RateLimiter (5 вызовов/1с) и Bulkhead (3 одновременных вызова) защищают downstream от собственного клиента — и оба логируют каждое отклонение, включая внутри одной retry-попытки:

20:57:17.684 [work] INFO >>> [Retry] [task-2] attempt #2 after: RateLimiter 'downstream' does not permit further calls
20:57:28.387 [work] WARN >>> [Bulkhead] rejected — 3 concurrent slots full
20:57:30.486 [work] WARN >>> [Bulkhead] rejected — 3 concurrent slots full

За прогон это даёт две пары чисел, которые легко перепутать:

Сырые события за прогон Финальные отказы (drain completed)
RateLimiter 738 202 (rlRejected)
Bulkhead 33 10 (bhRejected)

Разница — не ошибка счётчика, а разный уровень наблюдения. Сырые события — это строки [RateLimiter] rejected/[Bulkhead] rejected в логе, по одной на каждую проверку лимита/слота, в том числе внутри одной retry-попытки: задача могла упереться в лимитер на попытке #1, получить отказ, подождать backoff и на попытке #2 всё-таки пройти. Финальные отказы (rlRejected/bhRejected в сводке drain completed, см. следующий раздел) — это только те задачи, которые исчерпали все 3 попытки retry и так и не дождались свободного лимита или слота. Разница между сырыми и финальными числами (738 против 202, 33 против 10) — это как раз те ретраи, которые в итоге прошли лимитер или bulkhead на второй или третьей попытке. Считать сырые события метрикой «сколько задач реально отвалилось для клиента» — значит завышать проблему в разы.

Graceful shutdown: drain in-flight на SIGTERM

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    Instant sigAt = Instant.now();
    log.warn("=== SIGTERM received at {} — starting graceful shutdown ===", sigAt);
    health.setReady(false);
    acceptingNewWork.set(false);
    scheduledFuture.cancel(false);
    scheduler.shutdown();
    workExecutor.shutdown();
    try {
        boolean drained = workExecutor.awaitTermination(15, TimeUnit.SECONDS);
        log.warn("=== drain {} in {}ms (submitted={}, succeeded={}, failed={}, cbRejected={}, rlRejected={}, bhRejected={}) ===",
                drained ? "completed" : "TIMED OUT",
                Duration.between(sigAt, Instant.now()).toMillis(),
                submitted.get(), succeeded.get(), failedFinal.get(),
                cbRejected.get(), rlRejected.get(), bhRejected.get());
        if (!drained) {
            workExecutor.shutdownNow();
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        workExecutor.shutdownNow();
    }
    health.stop();
    log.warn("=== shutdown finished at {} (cbState={}) ===", Instant.now(), resilience.cbState());
    shutdownComplete.countDown();
}, "shutdown-hook"));

Порядок действий в хуке важен сам по себе: readiness падает первой строкой, ещё до того, как что-либо реально остановлено. Дальше — acceptingNewWork.set(false) (новые задачи больше не сабмитятся), остановка планировщика и, наконец, workExecutor.shutdown() + awaitTermination(15s), который ждёт завершения уже запущенных задач, не обрывая их.

docker stop -t 20 pp-demo
20:59:03.999 [work] INFO [task-629] start
20:59:04.148 [work] INFO >>> [Retry] [task-629] attempt #1 after: downstream transient error (call #418, recovered, elapsed=94361ms)
20:59:04.299 [work] INFO [task-631] start
20:59:04.300 [work] INFO >>> [Retry] [task-631] attempt #1 after: RateLimiter 'downstream' does not permit further calls
20:59:04.448 [work] INFO >>> [Retry] [task-629] attempt #2 after: RateLimiter 'downstream' does not permit further calls
20:59:04.449 [work] INFO [task-632] start
20:59:04.449 [work] INFO >>> [Retry] [task-632] attempt #1 after: RateLimiter 'downstream' does not permit further calls
20:59:04.495 [shutdown-hook] WARN === SIGTERM received at 2026-07-07T20:59:04.493717580Z — starting graceful shutdown ===
20:59:04.495 [shutdown-hook] WARN [Health] readiness = false
20:59:04.600 [work] INFO >>> [Retry] [task-631] attempt #2 after: RateLimiter 'downstream' does not permit further calls
20:59:04.806 [work] INFO [task-632] OK: OK#421 (356ms)
20:59:05.107 [work] INFO [task-629] OK: OK#422 (1107ms)
20:59:05.369 [work] INFO [task-631] OK: OK#423 (1069ms)
20:59:05.370 [shutdown-hook] WARN === drain completed in 876ms (submitted=632, succeeded=377, failed=255, cbRejected=42, rlRejected=202, bhRejected=10) ===
20:59:05.370 [shutdown-hook] WARN === shutdown finished at 2026-07-07T20:59:05.370624990Z (cbState=CLOSED) ===
sequenceDiagram participant O as Оркестратор participant H as shutdown-hook participant W as workExecutor (8 воркеров) participant HS as HealthServer O->>H: SIGTERM (docker stop -t 20) H->>HS: setReady(false) Note over HS: /health/ready -> 503 немедленно H->>W: shutdown() — новые задачи не принимаются Note over W: task-629, 631, 632 дорабатывают (retry + backoff) W-->>H: awaitTermination(15s) — drain 876ms H->>HS: stop() H-->>O: exit 143 (128+SIGTERM)

sequenceDiagram
  participant O as Оркестратор
  participant H as shutdown-hook
  participant W as workExecutor (8 воркеров)
  participant HS as HealthServer
  O->>H: SIGTERM (docker stop -t 20)
  H->>HS: setReady(false)
  Note over HS: /health/ready -> 503 немедленно
  H->>W: shutdown() — новые задачи не принимаются
  Note over W: task-629, 631, 632 дорабатывают (retry + backoff)
  W-->>H: awaitTermination(15s) — drain 876ms
  H->>HS: stop()
  H-->>O: exit 143 (128+SIGTERM)
Graceful drain: readiness падает сразу, in-flight задачи дорабатывают

Три задачи — task-629, task-631, task-632 — стартовали до сигнала (20:59:04.495) и были in-flight в момент SIGTERM, каждая на своей стадии retry: task-629 шла на вторую попытку, task-631 тоже на второй, task-632 — на первой. Все три завершились успехом после сигнала — awaitTermination дождался их, не оборвал.

Числа в скобках — это Duration.between(start, Instant.now()), посчитанная на реальных Instant, не декоративная: task-63204.806 − 04.449 = 357мс (в логе 356мс), task-62905.107 − 03.999 = 1108мс (в логе 1107мс), task-63105.369 − 04.299 = 1070мс (в логе 1069мс). Расхождение в 1мс — обычное округление между моментом вычисления Duration и моментом печати строки логгером, не повод для тревоги. Длительность включает все retry-попытки и паузы backoff между ними — отсюда у task-629/task-631 больше секунды, хотя каждый отдельный вызов downstream быстрее.

Дренаж уложился в 876мс — далеко меньше 15-секундного awaitTermination и 20-секундного таймаута docker stop -t 20, поэтому до SIGKILL не дошло: контейнер вышел кодом 143 (128+SIGTERM — штатная обработка сигнала JVM через встроенный signal handler, не форс-килл).

Health и readiness: два разных вопроса

server.createContext("/health/live", ex -> respond(ex, 200, "LIVE"));
server.createContext("/health/ready", ex -> {
    boolean r = ready.get();
    respond(ex, r ? 200 : 503, r ? "READY" : "NOT_READY (draining)");
});

/health/live и /health/ready отвечают на разные вопросы, и путать их — типичная ошибка. Liveness — «процесс жив, не завис, не нужно его убивать»: отвечает 200, пока JVM вообще способна обработать HTTP-запрос, не зависит от готовности принимать нагрузку. Readiness — «сервис готов принимать трафик прямо сейчас»: в этом стенде она становится false первой строкой в shutdown hook — за секунды до того, как реально прекратится приём новых задач и завершится дренаж.

Порядок важен: если бы readiness падала только когда drain уже завершился, оркестратор не успел бы вывести под из ротации балансировщика до того, как под перестанет отвечать — в реальной системе это означало бы ошибки на стороне клиента в окне между «под ещё в ротации» и «под уже недоступен». Здесь readiness падает раньше, чем что-либо реально останавливается — балансировщику даётся время среагировать, пока in-flight запросы дорабатывают.

Всё вместе: 12-factor

Ничего из разобранного выше не работает в отрыве от остального. Resilience4j-цепочка, graceful shutdown и health-эндпоинт — это конкретная реализация нескольких принципов 12-factor app, сформулированных не для JVM, но особенно ощутимых именно на ней:

  • IX. Disposability — процесс должен быстро стартовать и корректно завершаться по SIGTERM. Стенд стартует и сразу начинает принимать нагрузку, а завершается не мгновенным обрывом соединений, а дренажом in-flight задач через awaitTermination — 876мс на реальном прогоне, далеко не 15-секундный лимит. Тот же принцип на уровне деплоя, а не одного процесса, разобран в zero-downtime деплоеготовится, с 25 октября: expand-contract защищает схему БД так же, как graceful shutdown защищает in-flight запросы, только на другом уровне.
  • VIII. Concurrency — масштабирование через процессы/потоки, а не ручное управление состоянием одного гигантского обработчика. Пул из 8 платформенных потоков (newFixedThreadPool) плюс явные лимиты (RateLimiter, Bulkhead) — это настраиваемая модель конкурентности, а не «сколько выдержит»; если бы вместо фиксированного пула стояли virtual threads, модель была бы другой — подробнее в конкурентности на JVM.
  • XI. Logs — структурированный поток событий в stdout (в стенде — slf4j-simple, в продакшене обычно JSON + trace_id), а не файлы, которые сервис сам ротирует и архивирует. Весь разбор в этой статье строится на логе — при правильном подходе лог и есть источник истины о поведении сервиса под нагрузкой.
  • III. Config — конфигурация (адрес downstream, лимиты, таймауты) должна приходить снаружи процесса и валидироваться на старте, а не быть зашита в код. В демо-стенде для простоты это позиционные аргументы (recoveryAfterMillis, healthPort, submitIntervalMillis), в продакшен-сервисе — env-переменные с fail-fast валидацией: сервис, который падает через 30 секунд работы из-за отсутствующей переменной окружения, лучше сервиса, который падает через 30 секунд работы на старте.

Resilience4j-цепочка не заменяет мониторинг и не гарантирует, что downstream оживёт — она гарантирует, что сервис не усугубит проблему (не будет долбить упавший downstream, не съест все потоки на ожидании) и что деградация видна снаружи (readiness, счётчики отказов) раньше, чем становится инцидентом.

Итог

Три механики из этой статьи решают разные проблемы, но работают вместе: resilience4j-цепочка защищает downstream от клиента и клиента от downstream (в правильном порядке — retry снаружи, bulkhead и rate limiter ближе всего к вызову), graceful shutdown превращает SIGTERM из обрыва соединений в управляемый дренаж, а честный health-эндпоинт даёт оркестратору и балансировщику достаточно сигнала, чтобы среагировать до того, как под откажет. По отдельности каждая из них — несколько десятков строк кода поверх resilience4j и Runtime.addShutdownHook. Вместе — разница между сервисом, который переживает пик нагрузки и рестарт, и сервисом, который роняет запросы каждый раз, когда downstream икает или Kubernetes решает передеплоить под.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии