← назад к разделу

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 и метрики.