1. Graceful shutdown у контейнері
Коли сервіс запускається в контейнері, дуже легко потрапити в наївну пастку: «головне, щоб просто стартував». Але в реальному житті не менш важливо те, як він зупиняється. Контейнери створюються, запускаються знову, оновлюються, а іноді їх просто вимикаєте ви вручну. Якщо зупинка відбувається різко, сервіс може обірвати важливі операції (наприклад, експорт у файл), залишити ресурси «брудними» та зробити логи такими, ніби застосунок просто розчинився в тумані.
Graceful shutdown — це сценарій, у якому сервіс отримує сигнал «завершуй роботу», перестає приймати нові запити, акуратно завершує поточні операції, закриває контекст Spring і виходить. Це виглядає нудно — а нудно в інфраструктурі майже завжди означає добре. Наша мета — звʼязати три речі в один причинно-наслідковий ланцюжок: як Docker надсилає сигнал зупинки, як цей сигнал має дійти до Java-процесу і як Spring Boot у відповідь коректно закриває застосунок.
2. docker stop(...): сигнали та таймаут
Команда docker stop звучить як «зупини контейнер», але технічно Docker робить дещо конкретніше: він намагається попросити головний процес контейнера завершитися. У Linux-світі (а Docker усередині — це переважно про Linux-модель процесів) таке прохання виражається сигналом. Якщо процес не реагує, Docker через деякий час переходить до силового варіанта. Як ввічлива людина, яка спочатку стукає у двері, а потім — у найгіршому разі — кличе рятувальників.
У спрощеному вигляді послідовність така: Docker надсилає сигнал завершення (зазвичай SIGTERM), чекає невеликий інтервал, а якщо процес не завершився — надсилає SIGKILL, який уже не «просить», а просто закриває тему. На практиці це означає: якщо ваш застосунок не вміє реагувати на сигнал зупинки або сигнал до нього не доходить, ви майже гарантовано побачите різке завершення без нормального shutdown-шляху.
Невелика схема, щоб не тримати це в голові як магію:
sequenceDiagram
participant You as Ви
participant Docker as Docker
participant P1 as PID 1 у контейнері
participant App as Spring Boot
You->>Docker: "docker stop <container>"
Docker->>P1: SIGTERM
P1->>App: "сигнал має дійти до застосунку"
App->>App: "контекст Spring закривається + @PreDestroy"
App-->>Docker: "процес завершено (код завершення)"
Docker-->>You: "контейнер зупинено"
Note over Docker,P1: "якщо App зависне — після timeout Docker надішле SIGKILL"
У цій схемі ключовий персонаж — PID 1. І саме тут ми підходимо до того, чому форма ENTRYPOINT — це не «косметика в Dockerfile», а частина інженерного здоровʼя сервісу.
3. PID 1 і форма запуску
У контейнері є один головний процес — той, який запущено як процес №1 усередині namespace контейнера. Docker вважає його основним: він надсилає йому сигнали, за ним визначає, чи живий контейнер, і після його завершення завершує роботу контейнера. Якщо ваш Spring Boot-сервіс — це PID 1, то сигнал SIGTERM надходить безпосередньо в JVM, і Spring Boot отримує шанс коректно завершитися.
Проблема виникає, коли між Docker і Java-процесом ви ставите «прокладку» — найчастіше оболонку (/bin/sh). У такому разі PID 1 — це shell, а Java-процес — його нащадок. І далі починається зоопарк: оболонка може не передати сигнал коректно, може завершитися сама, не давши JVM акуратно закритися, а може поводитися по-різному залежно від дрібних нюансів. Для навчального й практичного базового рівня це занадто багато випадковості.
Тому для Java-застосунку потрібен exec-form ENTRYPOINT, щоб JVM була тим самим головним процесом контейнера. Важливо це не лише для запуску: від тієї ж форми ENTRYPOINT залежить, чи побачить застосунок коректний сигнал на зупинку.
4. Shell-form і exec-form ENTRYPOINT
Коли ви пишете ENTRYPOINT у Dockerfile, у вас є два зовні схожі варіанти. Один виглядає коротшим і «людянішим», другий — трохи громіздкішим. І майже завжди громіздкіший у Dockerfile виявляється надійнішим у реальності. Так, Dockerfile теж любить драму. Тут важливо не запамʼятати «як правильно», а зрозуміти чому.
Порівняймо два варіанти. Спочатку — правильний базовий варіант (exec-form), який робить Java процесом №1:
FROM eclipse-temurin:25-jre
# Робочий каталог усередині контейнера для передбачуваних шляхів
WORKDIR /app
# Кладемо зібраний JAR в образ
COPY build/libs/catalog-service.jar app.jar
# Документація: сервіс слухає 8080 (не «відкриває порт», але допомагає читабельності образу)
EXPOSE 8080
# Важливо: exec-form, щоб JVM стала PID 1 і отримувала SIGTERM безпосередньо
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Тепер — варіант, який виглядає привабливо коротким, але запускає через shell (shell-form):
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY build/libs/catalog-service.jar app.jar
EXPOSE 8080
# Важливо: shell-form запускає /bin/sh -c ..., і PID 1 стає shell, а не JVM
ENTRYPOINT java -jar /app/app.jar
З погляду «воно ж однаково запускається» — так, часто запуститься. З погляду коректної доставки сигналів — це два різні світи. У другому випадку Docker фактично запускає не java, а оболонку, яка вже запускає java. І ви втрачаєте гарантію, що SIGTERM дійде туди, куди вам потрібно. Іноді воно «якось працює», але це той самий випадок, коли «якось» — головний ворог інженерії.
Щоб закріпити, ось невелика таблиця без спроби стати енциклопедією — лише найпрактичніше:
| Питання | exec-form ENTRYPOINT ["java", ...] | shell-form ENTRYPOINT java ... |
|---|---|---|
| Хто є PID 1? | JVM (java) | shell (/bin/sh -c ...) |
| Чи доходять сигнали Docker до JVM? | Передбачувано | Може бути, а може й ні |
| Чи стабільне завершення в логах? | Зазвичай так | Іноді «зникає» |
| Чи підходить як базовий варіант для курсу? | Так | Ні |
І тепер логічне наступне запитання: якщо сигнал усе-таки дійшов, що робить Spring Boot? І як нам це побачити?
Spring Boot shutdown і @PreDestroy
Коли Spring Boot-застосунок коректно завершує роботу, він закриває Spring Context, зупиняє вбудований web server і викликає lifecycle-колбеки бінів. Для новачків це часто виглядає як «воно просто завершилося», тому ми додамо в проєкт маленький спостережуваний маркер: шматок коду, який гарантовано виконається під час коректного завершення контексту.
Найпростіший навчальний спосіб — використати @PreDestroy. Це анотація, якою ви позначаєте метод, що Spring викличе перед знищенням біна під час закриття контексту. Сенс тут не в тому, щоб «робити в shutdown щось велике», а в тому, щоб побачити: сигнал зупинки дійшов, контекст справді почав закриватися, і застосунок не зник раптово.
Важливо тримати код @PreDestroy коротким. Shutdown — це не місце для «давайте я ще хвилинку походжу по інтернету, запишу звіт у десять систем і заодно врятую світ». У вас є обмежений таймаут, і ви не хочете опинитися в ситуації, де Docker змушений застосувати SIGKILL, бо застосунок не встиг.
5. ShutdownLogger у Catalog Service
Тут достатньо невеликої, але корисної зміни. У структурі проєкту в нас є пакет ops/ — гарне місце для технічних маркерів: логів про старт, health-перевірок та інших операційних речей. Туди й покладемо компонент, який логує початок зупинки.
Приклад класу. Зверніть увагу на jakarta.annotation.PreDestroy, тому що ми у світі Spring 7 / Boot 4:
package com.example.catalog.ops;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component // Реєструємо бін: Spring створить його і зможе викликати lifecycle-колбеки
public class ShutdownLogger {
private static final Logger log = LoggerFactory.getLogger(ShutdownLogger.class);
@PreDestroy // Цей метод викличеться під час закриття Spring Context (graceful shutdown)
public void onShutdown() {
// Тримаємо логіку максимально легкою: це маркер, а не бізнес-операція
log.info("Завершення роботи розпочато: контекст Spring закривається");
}
}
Тут спеціально мінімум логіки й максимум сенсу. Ми не чіпаємо домен, не додаємо нових залежностей, не будуємо складні lifecycle-машини. Ми просто робимо shutdown спостережуваним через логи, які й так читаємо через docker logs.
Якщо хочеться додати ще один маленький маркер, а це корисно для розуміння, можна зробити симетричний лог на старті застосунку. Але в межах цієї лекції ми тримаємо фокус на зупинці: нам важливо побачити, що в контейнері shutdown справді проходить через Spring, а не обривається.
6. Перевірка shutdown у Docker
Зараз важливий момент: graceful shutdown — це не теорія. Його сенс у тому, що ви можете довести, що він працює, просто подивившись на поведінку контейнера та логи. Перевірка тут дуже приземлена: запускаємо контейнер, зупиняємо його і дивимося, чи дійшов shutdown до застосунку.
Запускаємо контейнер. Команда умовна — імʼя і тег підставте під свій проєкт:
# --rm видалить контейнер після зупинки (зручно для швидких перевірок)
docker run --rm --name catalog-service -p 8080:8080 docker-java-catalog-service:latest
В іншому терміналі зупиняємо:
# docker stop надішле SIGTERM і зачекає, доки процес завершиться
docker stop catalog-service
І читаємо логи (якщо контейнер не --rm, або якщо ви спочатку дивитеся логи, а потім зупиняєте). Наприклад, в окремому вікні — ще до зупинки:
# -f (follow) дозволить побачити рядки завершення прямо в момент shutdown
docker logs -f catalog-service
Що ви очікуєте побачити? У якийсь момент поруч із завершенням застосунку має зʼявитися ваш рядок:
... INFO ... ShutdownLogger : Завершення роботи розпочато: контекст Spring закривається
Якщо ви цього не бачите, це не «містика Spring». Це майже завжди означає одне з двох: або сигнал не дійшов до JVM, або застосунок було вбито силою (наприклад, через docker kill, або Docker не дочекався і застосував SIGKILL).
Щоб побачити, чи був контейнер зупинений «красиво», корисно дивитися не лише на логи, а й на факт завершення процесу. Якщо контейнер іще працює, значить процес не завершився. Якщо контейнер швидко зникає (коли ввімкнено --rm) — процес вийшов, і docker stop спрацював.
7. Час shutdown і таймаути
Коли ви вперше додаєте @PreDestroy, виникає спокуса використати його як «останній шанс зробити все». Але контейнерний світ любить передбачувані сценарії. У @PreDestroy ви маєте робити лише те, що справді потрібно під час зупинки, і робити це швидко: закрити ресурс, зупинити фоновий виконавець, записати один фінальний лог.
У Docker є таймаут очікування зупинки: він не може чекати вічно. У якийсь момент він вирішує, що вмовляння не працюють, і застосовує SIGKILL. Це нормально як аварійний механізм, але погано як ваш звичайний шлях. Тому правильна стратегія тут найчастіше не «збільшити таймаут до нескінченності», а зробити shutdown-логіку простішою.
Якщо вам усе ж потрібно ввімкнути коректніший режим shutdown на стороні Spring Boot, існує зрозуміле налаштування в application.yml, яке допомагає сервісу завершуватися акуратніше. У мінімальному вигляді це виглядає так:
server:
# Увімкнути graceful shutdown на рівні вбудованого вебсервера
shutdown: graceful
spring:
lifecycle:
# Максимальний час на shutdown-фази Spring (після його спливу застосунок усе одно завершать)
timeout-per-shutdown-phase: 20s
Ця конфігурація не робить магію, вона просто дає Spring Boot явну вказівку завершуватися в режимі graceful shutdown і задає обмеження на час shutdown-фази. Не потрібно заглиблюватися в тонке налаштування всіх фаз — це вже окрема історія. Але важливо зрозуміти: graceful shutdown — це договір між Docker, який надсилає сигнал, JVM, яка його отримує, і Spring, який коректно закриває контекст.
Саме так і збирається зрілий базовий варіант одного контейнера: логи залишаються читабельними, health-перевірка не вводить в оману, а зупинка не рве процес на півслові. Щойно сервіс живе поруч із базою даних, кешем або брокером, без цього все швидко перетворюється на хаос: один контейнер ще запускається, другий уже вважає сусіда мертвим, а третій обриває роботу в найнеприємніший момент.
І ще одна практична порада: якщо у вас є тривалі операції (наприклад, експорт великого файла), краще проєктувати їх так, щоб вони були або короткими, або мали можливість коректно перериватися й безпечно завершуватися. Shutdown не має перетворюватися на «чекаємо, доки закінчиться вічність».
8. Типові помилки під час зупинки контейнера
Помилка №1: використовувати shell-form ENTRYPOINT, бо «так коротше».
Це одна з найчастіших причин дивної зупинки контейнера. На запуску все може виглядати нормально, але під час docker stop застосунок не пише shutdown-логи, ніби Spring узагалі не бачив зупинки. Причина зазвичай банальна: сигнал пішов у shell, а JVM його коректно не отримала. Лікується це не шаманством, а поверненням до exec-form ENTRYPOINT.
Помилка №2: писати в @PreDestroy важку бізнес-логіку.
Інколи хочеться в shutdown-код засунути «останню синхронізацію», «фінальне вивантаження», «ще одну перевірку мережі». У контейнерному середовищі це майже гарантований спосіб натрапити на ситуацію, коли Docker не дочекається й завершить процес силою. У @PreDestroy краще залишити лише швидкі операції та один-два логи, які допомагають зрозуміти, що shutdown узагалі почався.
Помилка №3: перевіряти зупинку лише за фактом «контейнер зник», ігноруючи логи.
Контейнер справді може зникнути швидко, але це не доводить graceful shutdown. Його могли завершити через SIGKILL, він міг упасти через помилку, міг завершитися через OOM. Надійний маркер — побачити в логах зрозумілі рядки зупинки (наприклад, наш Завершення роботи розпочато...).
Помилка №4: намагатися «полагодити graceful shutdown» скриптами навколо Java замість коректного запуску процесу.
Періодично можна побачити підхід «давайте запустимо shell-скрипт, який зловить сигнали і вручну завершить Java». Іноді це потрібно в специфічних образах, але для нашого навчального базового варіанта це зайва складність. Для Spring Boot-сервісу нормою є запуск Java як PID 1 через exec-form, і цього достатньо для передбачуваної поведінки.
Помилка №5: використовувати docker kill як «звичайну зупинку».
docker kill — це силовий інструмент. Він корисний, коли контейнер справді завис і вам потрібно звільнити ресурси, але в ролі регулярного stop-сценарію він убиває саму ідею graceful shutdown. Якщо ви постійно «рубите живлення», не дивно, що ви ніколи не бачите нормальний shutdown-шлях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ