Язык выбран, сервис написан и упакован — остаётся довести его до эксплуатации. Для 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.
В статье
- Resilience4j: почему порядок декораторов не мелочь
- Circuit breaker: от чистого цикла до честного edge-case
- Retry: экспоненциальный backoff и путь одной задачи
- Rate limiter и bulkhead: сырые события против финальных отказов
- Graceful shutdown: drain in-flight на SIGTERM
- Health и readiness: два разных вопроса
- Всё вместе: 12-factor
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
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 восстановлен
Первые пять вызовов пришлись на фазу 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, то есть на границу восстановления downstream — recoveryAfterMillis=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 callsmaxAttempts=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-demo20: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)
Три задачи — 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-632 — 04.806 − 04.449 = 357мс (в логе 356мс), task-629 — 05.107 − 03.999 = 1108мс (в логе 1107мс), task-631 — 05.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 решает передеплоить под.
Комментарии