Kubernetes берёт на себя запуск контейнеров, распределение по нодам и перезапуск упавших подов. Но четыре вещи за разработчика не сделает никто: правильно настроить health-проверки, честно выделить память под JVM, корректно завершить работу без потери запросов и передать конфигурацию снаружи образа. Без этого сервис будет работать «почти нормально» — до первого деплоя в нагрузке.
Три probe: зачем Kubernetes спрашивает «ты жив?»
Kubernetes не знает, что происходит внутри вашего контейнера. Чтобы понять, стоит ли слать туда трафик и надо ли перезапускать под, он задаёт три вопроса — probe. Каждый вопрос обрабатывается по-своему:
- startup probe — «ты уже запустился?» Пока приложение не ответит «да», два других вопроса не задаются. Это защита для медленно стартующих приложений: без startup probe Kubernetes может решить, что под завис, и убить его прямо во время первого запуска.
- readiness probe — «тебе можно слать трафик?» Если «нет» — под исключается из балансировки, но продолжает жить. Это пауза, а не приговор: бывает нужно, пока прогреваются кэши или восстанавливается соединение с базой.
- liveness probe — «процесс вообще отвечает?» Если «нет» — под перезапускается. Это крайняя мера: назначайте её только за то, что действительно лечится перезапуском (дедлок, зависший поток), а не за временные трудности.
Spring Boot Actuator поддерживает разделение из коробки. Нужно включить:
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: readinessState, db
Тогда /actuator/health/liveness и /actuator/health/readiness — два независимых эндпоинта. В манифесте Kubernetes они прописываются отдельно:
containers:
- name: app
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8081 }
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8081 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8081 }
periodSeconds: 10
failureThreshold: 3
Самая частая ошибка: включить проверку базы данных в liveness probe. Звучит логично — «если база недоступна, перезапустим». На деле: база легла → liveness probe провалилась → все поды сервиса перезапускаются по кругу → к инциденту с базой добавляется непрерывный шторм рестартов, JVM прогревов и потерянных соединений. Правило простое: liveness проверяет только внутреннее состояние процесса (дефолтный livenessState); зависимости от внешних сервисов — максимум в readiness.
Память: почему JVM не вписывается в лимит
Когда задаёшь лимит памяти контейнеру, кажется, что всё понятно: поставил 512 MiB, больше не съест. С JVM это не так.
Память процесса JVM — это не только heap. Кроме heap есть metaspace (классы, код), стеки потоков, JIT-компилятор, direct buffers — в сумме ещё несколько сотен мегабайт. Если отдать весь лимит под heap, остального не хватит, и контейнер будет убит операционной системой — без предупреждения, без записи в логах, просто exit 137.
Современная JVM умеет видеть лимит контейнера и сама выбирает размер heap. Управляет этим флаг:
-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError
60–75% лимита под heap — рабочий диапазон. Остаток идёт на накладные расходы JVM. Пример: при лимите 768 MiB heap получит ~460 MiB, остальное — для metaspace и прочего.
Два разных симптома нехватки памяти:
- OOMKilled (exit 137, в логах тишина) — контейнер убит ядром ОС: процесс в целом вышел за лимит. Чаще всего причина — не-heap потребление. Лечится: увеличить лимит или уменьшить
MaxRAMPercentage. - OutOfMemoryError в логах — кончился heap внутри JVM. Лечится: искать утечку или увеличивать heap.
Практические правила по ресурсам:
resources:
requests: { cpu: "500m", memory: "768Mi" }
limits: { memory: "768Mi" }
По памяти: лимит равен request — тогда нет сюрпризов от соседей по ноде. По CPU: лимит лучше не ставить или ставить высоким. CPU-троттлинг вредит сборщику мусора и увеличивает задержки — изоляцию нагрузок обеспечивают requests, а не limits.
Graceful shutdown: не терять запросы при деплое
Когда Kubernetes останавливает под (при деплое, масштабировании вниз), происходит следующее: под помечается на удаление → исключается из балансировки → контейнеру посылается SIGTERM → спустя terminationGracePeriodSeconds — SIGKILL.
Два места, где можно потерять запросы:
Первое: исключение пода из балансировки происходит не мгновенно. Несколько секунд после SIGTERM трафик ещё может приходить на останавливающийся под. Решение — небольшая пауза перед началом остановки:
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
terminationGracePeriodSeconds: 30
Второе: активные запросы нужно дождаться. Spring Boot умеет дорабатывать текущие запросы перед выключением:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
Бюджет должен сходиться: preStop (5s) + время дорабатывания (20s) < terminationGracePeriodSeconds (30s). Если нет — SIGKILL оборвёт запросы на середине.
Конфигурация: образ один, настройки снаружи
Принцип контейнеров: один и тот же образ запускается на dev, staging и prod. Различия между средами — снаружи образа, в Kubernetes-объектах:
- ConfigMap — обычные настройки (URLs, таймауты, имена топиков).
- Secret — пароли, токены, ключи.
В Spring Boot они попадают через переменные окружения:
envFrom:
- configMapRef: { name: order-service-config }
- secretRef: { name: order-service-secrets }
Kubernetes автоматически преобразует переменную APP_DATASOURCE_URL в свойство app.datasource.url — Spring читает это без дополнительной настройки.
Несколько правил:
- Секреты не кладут в ConfigMap и тем более в образ.
- Профиль (
SPRING_PROFILES_ACTIVE) задаёт среда, не Dockerfile. - Обязательные настройки лучше валидировать на старте через
@Validated @ConfigurationProperties— упасть при деплое лучше, чем получить сюрприз в рантайме.
Автомасштабирование (HPA)
Horizontal Pod Autoscaler следит за метриками и добавляет или убирает поды:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: order-service }
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
Несколько особенностей для JVM-сервисов:
- Новая реплика не помогает мгновенно: запуск JVM, прогрев JIT и пулов соединений занимают десятки секунд. При резком всплеске нагрузки HPA опаздывает. Поэтому
minReplicasдолжен давать запас ёмкости заранее. - CPU — плохая метрика для сервисов, которые большую часть времени ждут ответа от базы или API (I/O-bound). Масштабироваться по числу запросов или длине очереди честнее, но это требует custom metrics.
Коротко
- startup probe защищает от убийства при медленном старте; readiness выводит из балансировки без перезапуска; liveness — перезапускает, только за внутренние сбои.
- Включать базу в liveness — опасно: отказ одной зависимости вызовет шторм рестартов всего сервиса.
- Память JVM > heap: metaspace, стеки, JIT.
MaxRAMPercentage=60-75оставляет место для накладных расходов. - OOMKilled (exit 137) — это ядро ОС убило процесс за превышение лимита; OutOfMemoryError — кончился heap внутри JVM.
server.shutdown: graceful+ preStop-пауза 5 секунд = запросы не теряются при деплое.- Образ один на все среды; конфиг — в ConfigMap, секреты — в Secret, профиль задаёт среда.
@Validated @ConfigurationProperties— кривой конфиг валит старт, а не ломает рантайм.- HPA с CPU-метрикой опаздывает при всплесках;
minReplicasдолжен давать буфер.
Что почитать дальше
- Деплой и конфигурация — как манифесты доезжают до кластера.
- Эксплуатация и отладка — OOMKilled и CrashLoopBackOff на практике.
- Spring Actuator и Micrometer — health groups и метрики.