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

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

Probes: три вопроса с разными последствиями

Kubernetes задаёт приложению три вопроса, и путать их опасно, потому что последствия у ответов разные:

  • startup — «ты уже запустился?» Пока нет — остальные probes не задаются. Защищает медленно стартующие приложения (прогрев кэша, миграции) от преждевременного убийства.
  • readiness — «тебе можно слать трафик?» Нет — под выводится из endpoints, но живёт. Это пауза, не казнь.
  • liveness — «ты вообще жив?» Нет — под перезапускается. Это казнь, и назначать её надо за то, что лечится перезапуском: deadlock, зависший event loop — а не за временные трудности.

Spring Boot Actuator делает разделение из коробки:

management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: readinessState, db
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

Главная ошибка probes — внешние зависимости в liveness. Если liveness проверяет БД, то отказ базы перезапускает все поды сервиса по кругу: к инциденту с БД добавляется самострел из рестартов, прогревов и потерянных соединений. Правило: liveness — только внутреннее состояние процесса (дефолтный livenessState); зависимости — максимум в readiness, и то осознанно: сервис, у которого недоступна одна из трёх зависимостей, возможно, должен отвечать деградированно, а не выпадать из балансировки целиком.

Ресурсы и JVM: почему OOMKilled

resources:
  requests: { cpu: "500m", memory: "768Mi" }
  limits: { memory: "768Mi" }

Requests — сколько гарантированно резервируется при планировании пода на ноду. Limits — потолок: превышение по памяти карается мгновенным убийством процесса (OOMKilled, exit 137), превышение по CPU — троттлингом.

С JVM это взаимодействует нетривиально. Память процесса — это не только heap: metaspace, стеки потоков, direct buffers, код JIT — ещё сотни мегабайт. Современная JVM видит лимит контейнера и берёт от него долю под heap:

-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError

60–75% лимита под heap — рабочий диапазон; остальное — той самой «не-heap» памяти. Диагностический признак: OOMKilled (контейнер убит, exit 137, в логах тишина) — процесс превысил limit, чаще всего из-за не-heap потребления; OutOfMemoryError в логах — кончился heap внутри JVM. Лечатся они по-разному: первый — пересмотром доли heap и лимита, второй — поиском утечки или ростом heap.

По CPU практичный паттерн: requests честные, limit по CPU не ставить (или ставить высоким) — CPU-троттлинг бьёт по GC-паузам и латентности хвоста, а изоляцию нагрузок даёт планирование по requests. По памяти limit обязателен и равен request — без сюрпризов от соседей по ноде.

Graceful shutdown: не терять запросы на деплое

При остановке пода (deploy, scale down) происходит: под помечен на удаление → выведен из endpoints → контейнеру послан SIGTERM → через terminationGracePeriodSeconds — SIGKILL. Две проблемы и два решения:

  1. Вывод из endpoints не мгновенный — несколько секунд после SIGTERM трафик ещё может приходить. Решение — preStop-пауза: контейнер ждёт, пока балансировка про него забудет, и только потом начинает останавливаться.
  2. Активные запросы надо дорабатывать. Решение — graceful shutdown в самом Spring.
lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]
terminationGracePeriodSeconds: 30
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s

Бюджет должен сходиться: preStop + время дорабатывания < terminationGracePeriodSeconds, иначе SIGKILL оборвёт транзакции на середине. Полный разбор — включая Kafka-листенеры, пулы и идемпотентность прерванных операций — в Graceful Shutdown Style Guide.

Конфигурация: ConfigMap, Secret и профили

Образ один на все среды; различия — снаружи, в ConfigMap (настройки) и Secret (пароли, токены). В Spring это попадает двумя путями:

envFrom:
  - configMapRef: { name: order-service-config }
  - secretRef: { name: order-service-secrets }

Переменные окружения — простой и достаточный дефолт (APP_CLICKHOUSE_URLapp.clickhouse.url). Второй путь — смонтировать ConfigMap файлом и подключить spring.config.import: optional:configtree:/etc/config/ — удобен, когда конфиг большой и структурный. Правила гигиены: секреты не в ConfigMap и тем более не в образ; профиль (SPRING_PROFILES_ACTIVE) задаёт среда, не Dockerfile; обязательные настройки валидируются на старте через @Validated @ConfigurationProperties — упасть при деплое лучше, чем удивить в рантайме (fail fast).

HPA: автомасштабирование с оговорками

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-метрика для I/O-bound сервисов малоинформативна — масштабироваться по RPS или длине очереди честнее (custom metrics через адаптер Prometheus). И HPA конфликтует с наивным пониманием «нагрузка выросла → подкинем CPU limit»: масштабирование в Kubernetes горизонтальное по природе.

Чек-лист сервиса перед продом

  1. Три probes разведены по смыслу; liveness без внешних зависимостей.
  2. MaxRAMPercentage согласован с memory limit; limit = request по памяти.
  3. server.shutdown: graceful + preStop; бюджет завершения сходится.
  4. Секреты — в Secret, конфиг — в ConfigMap, образ один на все среды.
  5. @Validated на @ConfigurationProperties — кривой конфиг валит старт.
  6. Логи в stdout в JSON (observability) — собирать их иначе в k8s неоткуда.
  7. minReplicas >= 2 и PodDisruptionBudget — деплой ноды не должен ронять сервис в ноль.

Что почитать дальше

  • Graceful Shutdown Style Guide — полный разбор корректного завершения с правилами R-SHUT-*.
  • Деплой и конфигурация — как эти манифесты доезжают до кластера.
  • Эксплуатация и отладка — OOMKilled и CrashLoopBackOff на практике.
  • Spring Actuator, Micrometer — health groups и метрики, на которых стоят probes и HPA.