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

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

Начнём с самого частого инцидента: база недоступна две минуты, а дальше всё решает то, в какой группе стоит её проверка.

База лежит 120 с. Итог зависит от того, в какой группе стоит проверка dbstartupProbe 30 × 2 с = 60 с на старт: пока он идёт, другие probe молчат 0 с4080120160 сt, с от отказа db в группе readinessperiodSeconds: 5 под жив: кэши, пул, прогретый JIT15 с: 3 проверки по 5 с — трафик снят база вернуласьв строю0 рестартов — назад в балансировку с первой успешной проверки db в группе livenessperiodSeconds: 10, failureThreshold: 33 × 10 с startupProbe 30 × 2 сbackoffстарт с нулярестартрестартбаза вернулась 2 рестарта за 120 с — и так на каждой реплике сервисапосле рестарта startupProbe бьёт в тот же эндпоинт — и валит под сновабаза вернулась, а под ещё ждёт паузу backoff и прогрев JVMliveness проверяет только внутреннее состояние процессазависимости — в readiness: она снимает трафик, но не убивает под

Один и тот же отказ базы: в группе readiness под выходит из балансировки и возвращается сам с первой успешной проверки, в группе liveness — два рестарта за две минуты на каждой реплике, потому что после рестарта в ту же проверку бьёт ещё и startupProbe. Перезапуск лечит внутренние сбои процесса, отказ соседа им не лечится.

Обязательно

Три probe: что лечится перезапуском, а что нет

Kubernetes не видит, что происходит внутри контейнера: процесс может стоять в дедлоке, исчерпать пул потоков или зависнуть на старте, а снаружи всё это выглядит одинаково. Поэтому Kubernetes задаёт контейнеру три вопроса и на каждый реагирует по-своему.

«Ты уже запустился?» — startup probe: пока ответа «да» нет, два других вопроса не задаются. Без неё liveness проверяет ещё стартующую JVM, трёх неудач хватает на перезапуск, и приложение с сорокасекундным стартом не стартует никогда.

«Тебе можно слать трафик?» — readiness. Ответ «нет» выводит под из балансировки, но не трогает его: кэши, пул соединений и прогретый JIT целы, и с первой удачной проверкой трафик возвращается.

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

Actuator, запускаясь в Kubernetes, узнаёт платформу по переменным окружения и включает группы liveness и readiness сам. В liveness по умолчанию лежит только livenessState — флаг «контекст Spring поднят». Дедлок в вашем пуле потоков он не заметит: о такой беде приложение должно сообщить само, опубликовав AvailabilityChangeEvent с LivenessState.BROKEN, например из сторожевого потока, который раз в десять секунд спрашивает ThreadMXBean.findDeadlockedThreads().

management:
  server:
    port: 8081            # actuator на отдельном порту, не наружу
  endpoint:
    health:
      probes:
        enabled: true
      group:
        liveness:
          additional-path: "server:/livez"
        readiness:
          include: readinessState, db
          additional-path: "server:/readyz"

probes.enabled нужен, если хотите те же эндпоинты локально. А additional-path — про порт. Actuator уносят на отдельный порт, чтобы не показывать наружу, но отдельный порт — это отдельный встроенный сервер со своим пулом потоков: когда основной Tomcat исчерпал все двести потоков и не отвечает потребителям, проба на 8081 бодро отвечает 200, и Kubernetes видит здоровый под. additional-path публикует ту же группу на основном порту, проба идёт через тот же пул, что и запросы: пул встал — проба не ответила — под перезапущен. Порт в пробах обязан совпадать с тем, где эндпоинт висит, иначе Connection refused и бесконечный перезапуск на ровном месте.

containers:
  - name: app
    ports:
      - { name: http, containerPort: 8080 }
      - { name: management, containerPort: 8081 }
    startupProbe:
      httpGet: { path: /livez, port: 8080 }
      failureThreshold: 30
      periodSeconds: 2
      timeoutSeconds: 3
    readinessProbe:
      httpGet: { path: /readyz, port: 8080 }
      periodSeconds: 5
      timeoutSeconds: 3
    livenessProbe:
      httpGet: { path: /livez, port: 8080 }
      periodSeconds: 10
      failureThreshold: 3
      timeoutSeconds: 3

timeoutSeconds почти всегда забывают, а по умолчанию это одна секунда. Паузы сборщика мусора хватает, чтобы ответ задержался на полторы, и здоровый под начинает мигать: то выпадает из балансировки, то возвращается. Три секунды дают запас, за которым уже стоит реальная проблема.

Даже в readiness базу кладут с оглядкой: readiness одинакова во всех репликах, поэтому подмигнувшая на секунду база выведет из балансировки сразу все поды, и потребители получат 503 там, где сервис мог бы ещё отвечать. Если зависимость всё-таки нужна в readiness, её ответ кэшируют на несколько секунд и ставят failureThreshold побольше; а если без базы сервис способен отдавать хоть что-то, из readiness её убирают. Как это выглядит со стороны потребителя, разобрано в эксплуатации и отладке.

Сервис без HTTP: exec, tcpSocket и gRPC

У потребителя Kafka веб-сервера нет, и httpGet бить некуда. Чаще всего добавляют spring-boot-starter-web и Actuator ради порта управления, но такая проба отвечает только за контекст Spring: слушатель, который завис на poll или встал в дедлоке, для неё здоров.

exec запускает команду внутри контейнера и смотрит на код возврата — единственный способ проверить работу, а не порт: обработчик после каждого удачного цикла обновляет файл, проба проверяет, что файл свежее минуты.

livenessProbe:
  exec:
    command: ["sh", "-c", "find /tmp/heartbeat -mmin -1 | grep -q ."]
  periodSeconds: 30

Цена двойная: каждая проверка — отдельный процесс, запущенный через API среды выполнения контейнеров, поэтому период ставят щедрый. И в образе должны быть sh и find: в distroless их нет, проба не пройдёт ни разу, и здоровый сервис уйдёт в CrashLoopBackOff. tcpSocket проверяет только, что порт принимает соединения, а Tomcat принимает их и с намертво вставшими рабочими потоками — для JVM это самая слабая проверка. Для gRPC-сервисов есть grpc: { port: 9090 }: kubelet сам вызывает стандартный grpc.health.v1.Health, который надо реализовать. Готовность потребителя Kafka выражают своим HealthIndicator поверх KafkaListenerEndpointRegistry: запущены ли контейнеры слушателей.

CPU: старт стоит дороже работы

Под в CrashLoopBackOff, лог каждый раз обрывается на середине старта, строки Started ... in нет, ошибок тоже. Причина — limits.cpu: 500m. На старте Spring Boot загружает десять-двадцать тысяч классов, а JIT в несколько потоков компилирует горячие методы: процессу нужно два-четыре ядра всплеском, хотя в работе хватит половины. Лимит в половину ядра — это квота 50 миллисекунд процессорного времени на каждые 100, всё сверх ядро отбирает. Старт, который на свободном узле занимает восемь секунд, растягивается до минуты, окно startup probe в 30 × 2 = 60 секунд закрывается раньше, и под перезапускают, хотя в нём ничего не сломано.

Второй эффект того же лимита: JVM подстраивается под число процессоров, которое видит. С limits.cpu: 1 это один процессор, а G1 включается только от двух — значит, сборщик будет однопоточный SerialGC, JIT и общий ForkJoinPool тоже в один поток. Как считается availableProcessors, разобрано в JVM в контейнере. А requests.cpu — это место на узле и вес при дележе процессора, когда его не хватает на всех; всё свободное время под получает сверх request. Изоляцию дают requests, поэтому CPU-лимит для JVM либо не ставят, либо ставят от двух ядер.

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

Окно старта считают от замера: в логе есть Started OrderApplication in 23.4 seconds — берут цифру с самого слабого узла кластера, умножают на два-три и раскладывают на failureThreshold × periodSeconds.

Память: почему JVM не вписывается в лимит

Лимит памяти 768 MiB не значит, что heap может быть 768: heap — только часть процесса. Без флага JVM возьмёт под него четверть лимита, с неудачным флагом — почти весь, и остальному места не останется.

Остальное складывается из шести кусков, у каждого свой порядок величины. Metaspace хранит загруженные классы: у приложения на Spring Boot это 60–150 MiB. Кэш кода — скомпилированные JIT методы, 30–80 MiB. Стеки потоков: под каждый резервируется 1 MiB, реально трогается 100–300 KiB, но потоков сотни — двести у Tomcat, ещё пул Hikari, клиент Kafka, потоки сборщика, итого 50–100 MiB. Сборщик мусора держит свои таблицы: у G1 до десятой части heap. Прямые буферы ввода-вывода, которыми пользуются Netty и клиент Kafka, ограничены MaxDirectMemorySize, а он по умолчанию равен размеру heap — в худшем случае процесс вправе занять два heap. Итого 280–400 MiB сверх heap ещё до всякой утечки.

Лимит контейнера 768 MiB: heap — только часть, остальное тоже надо куда-то класть limit 768 MiB за чертой ядро убивает процесс: exit 137 MaxRAMPercentage=60: heap 460 + остальное 280 = 740 MiB, влезает heap 460 metaspace ~100 · кэш кода ~50 · стеки ~60 · direct, GC, JIT ~70 MaxRAMPercentage=90: heap 690 + те же 280 = 970 MiB — за чертой heap 690 137 в логах тишина: JVM ещё не знала, что памяти нет — heap не был полон Утечка при 60 %: heap полон, лимит цел — OutOfMemoryError в логе heap 460 — полон

Одна и та же JVM при трёх настройках. Ядру всё равно, что внутри процесса: оно считает контейнер целиком, поэтому убитый по лимиту под молчит в логах. OutOfMemoryError, наоборот, случается внутри JVM, когда до лимита ещё далеко.

Из чего сложились ваши мегабайты, показывает учёт нативной памяти: -XX:NativeMemoryTracking=summary при запуске и jcmd 1 VM.native_memory summary внутри контейнера печатают committed-память по категориям — heap, Class, Thread, Code, GC, Internal; сумму сравнивают с kubectl top pod. Учёт стоит 5–10 % производительности, поэтому его включают на одной реплике.

Размер heap JVM выбирает сама от лимита контейнера. Управляет этим флаг:

-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError

60–75 % под heap — рабочий диапазон: при 768 MiB и 60 % heap получит около 460 MiB, остальное — на список выше. Как формула ведёт себя на маленьких лимитах и почему -Xmx хуже — в JVM в контейнере.

Второй флаг, ExitOnOutOfMemoryError, нужен вот зачем. По умолчанию OutOfMemoryError прилетает одному потоку — тому, кому не хватило памяти. Остальные продолжают работать, и приложение превращается в полуживое: процесс запущен, порт слушает, liveness отвечает «всё хорошо», а половина запросов падает или зависает. Kubernetes такой под не тронет. Флаг ставит точку: при первой же OutOfMemoryError JVM пишет в лог Terminating due to java.lang.OutOfMemoryError, выходит с кодом 3, и Kubernetes поднимает свежий контейнер.

Поэтому два симптома различают по коду выхода: 137 и тишина в логах — процесс целиком вышел за лимит, виновата обычно не heap, лечится уменьшением MaxRAMPercentage или увеличением лимита; OutOfMemoryError в логах — кончился heap при целом лимите, ищут утечку. По памяти лимит ставят равным request: под получает ровно то, что заказано, без сюрпризов от соседей по узлу.

Остановка: preStop → SIGTERM → drain → SIGKILL

Когда Kubernetes останавливает под — при выкате, масштабировании вниз или переезде с узла, — в нулевой момент запускаются две независимые вещи. Kubelet начинает останавливать контейнер: выполняет хук preStop, потом шлёт SIGTERM. А параллельно кластер вычёркивает под из списка endpoints и рассылает это по узлам. Никто никого не ждёт, и спустя terminationGracePeriodSeconds от того же нуля приходит SIGKILL.

Остановка пода: kubelet и рассылка endpoints стартуют в один момент terminationGracePeriodSeconds: 60 — от того же нуля, независимо от preStop 0 с102030405060 с t, с сеть кластера рассылка до 15 с запросы ещё приходят на под без preStop 502 первые 15 с 502 дорабатывает SIGTERM в 0 с приём закрыт, а запросы ещё идут с preStop 10 с preStop дорабатывает активные, до 30 с запас 20 с SIGTERM 10 с вышел сам, ~40 с SIGKILL 60 с

Список endpoints обновляется по узлам параллельно с остановкой контейнера, и первые секунды запросы всё ещё приходят. Без preStop SIGTERM закрывает приём сразу, и потребители получают 502; десять секунд preStop держат под работающим, пока маршруты обновляются. Бюджет 60 секунд считается от нуля, поэтому 10 + 30 обязаны в него влезать.

Рассылка нового списка endpoints занимает от секунды до пятнадцати, и если SIGTERM придёт немедленно, Tomcat закроет приём, пока трафик ещё идёт — вот и 502. preStop держит под работающим эти первые секунды.

lifecycle:
  preStop:
    exec:
      command: ["sleep", "10"]
terminationGracePeriodSeconds: 60

В distroless-образе команды sleep нет, и хук молча не сработает; с Kubernetes 1.30 есть встроенное действие preStop: { sleep: { seconds: 10 } }, которому бинарник не нужен.

Второе место потерь — активные запросы; Spring Boot умеет дорабатывать их перед выключением:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Бюджет должен сходиться: preStop (10s) + дорабатывание (30s) < terminationGracePeriodSeconds (60s) — в дефолтные 30 секунд сорок не влезают, и SIGKILL прилетит в середину дорабатывания. Разбор по секундам, включая пулы, планировщик и Kafka, — в Graceful shutdown в Kubernetes.

Конфигурация: образ один, настройки снаружи

Три стенда — dev, staging и prod — и на каждом свой адрес базы. Если настройки лежат внутри образа, образов тоже три, и в прод едет тот, который никто не проверял. Поэтому образ один, а различия между средами живут снаружи, в объектах Kubernetes: адреса, таймауты и имена топиков — в ConfigMap, пароли и ключи — в Secret.

В Spring Boot они попадают через переменные окружения:

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

Переменную APP_DATASOURCE_URL подхватит свойство app.datasource.url. Kubernetes тут ни при чём: он лишь кладёт переменные в окружение процесса, а сопоставляет их со свойствами Spring Boot, закрывая глаза на регистр, подчёркивания и дефисы. Поэтому тот же образ с теми же переменными одинаково запускается и в docker run, и под systemd.

Типовое «поменял ConfigMap, а ничего не изменилось» растёт из двух фактов. Deployment реагирует только на изменения собственного шаблона пода, а ConfigMap в шаблон не входит — поды после правки никто не перезапускает. А переменные окружения читаются один раз при запуске процесса, обновить их в живом контейнере нельзя в принципе. Смонтированный файлом ConfigMap kubelet обновит сам, с задержкой до пары минут, а через subPath — никогда; но и обновлённый файл Spring Boot перечитывать не станет: конфигурация собирается на старте.

Рабочих способов два. kubectl rollout restart deployment/order-service — руками. Или сделать так, чтобы правка ConfigMap меняла шаблон пода: положить в аннотацию шаблона хэш содержимого ConfigMap (в Helm это приём checksum/config), и Deployment сам начнёт выкат. То же даёт неизменяемый ConfigMap с версией в имени — order-service-config-v7 и immutable: true: новая версия конфига становится новым объектом и новой строкой в шаблоне.

Когда настроек много или это целый YAML, ConfigMap монтируют файлом через volumes и volumeMounts, а Spring Boot подключает его переменной SPRING_CONFIG_IMPORT=optional:file:/etc/config/application.yml.

Профиль (SPRING_PROFILES_ACTIVE) задаёт среда, не Dockerfile. Обязательные настройки валидируют на старте через @Validated @ConfigurationProperties: под с кривым конфигом не проходит startup probe, и выкат останавливается на первой реплике, а не всплывает сюрпризом в рантайме на всех.

Secret — это не шифрование

Самое частое заблуждение — «пароли лежат в Secret, значит, они зашифрованы». Нет: содержимое закодировано в base64, а base64 декодируется одной командой: kubectl get secret order-service-secrets -o jsonpath='{.data.DB_PASSWORD}' | base64 -d. По умолчанию Secret отличается от ConfigMap тем, что на него можно выдать отдельные права.

Первый слой настоящей защиты — шифрование хранилища кластера, etcd. Без него секреты лежат открытым текстом и в etcd, и в каждой его резервной копии, а копии хранятся куда небрежнее кластера.

Второй слой — права: get или list на секреты namespace — это все пароли namespace разом, включая чужие сервисы, поэтому такое право не выдают «команде» целиком, а сервисному аккаунту пода оно не нужно вовсе: kubelet подставляет секреты в под сам.

Третий слой — секреты вне кластера: Vault или менеджер секретов облака. В манифестах остаются ссылки, значения доставляет CSI-драйвер или External Secrets Operator, и ротация пароля становится операцией в одном месте, а не поиском по десяти репозиториям.

И гигиена: секреты монтируют файлами, а не переменными окружения. Переменная живёт во всём дереве процессов, попадает в дампы и отчёты диагностики, и обновить её без перезапуска нельзя. Файл в томе kubelet обновляет после ротации сам, а defaultMode: 0400 оставляет его только процессу приложения.

Автомасштабирование: HPA против JVM

Днём запросов в пять раз больше, чем ночью, и держать дневное число реплик круглые сутки дорого. Horizontal Pod Autoscaler обещает добавлять и убирать поды по метрике:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service
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 }
  behavior:
    scaleUp:
      policies:
        - { type: Pods, value: 2, periodSeconds: 60 }
    scaleDown:
      stabilizationWindowSeconds: 300

averageUtilization: 70 — это 70 % от requests.cpu, не от лимита и не от ядра: при request 500m порог — 350m на под, а под без request HPA масштабировать не сможет. Поскольку CPU-лимита у JVM нет, под в пике съест целое ядро и покажет 200 % — HPA отреагирует резко.

Резкая реакция для JVM и есть проблема: новая реплика стартует и прогревается 30–60 секунд, всё это время нагрузку держат старые поды, а под до readiness HPA учитывает как нулевую нагрузку. Правило scaleUp по умолчанию разрешает удваивать число подов каждые 15 секунд — с медленным стартом это волна холодных JVM, которые упираются в CPU узла и стартуют ещё медленнее. Отсюда policies «два пода в минуту» и minReplicas, которого хватает пережить первую минуту всплеска без HPA. Окно scaleDown в 300 секунд лучше не уменьшать: при пилообразной нагрузке поды будут появляться и исчезать, а каждый новый — снова холодный старт.

С GitOps есть ещё конфликт: если в манифесте Deployment стоит replicas: 3, каждая синхронизация возвращает три реплики, HPA через минуту снова поднимает до шести, и так по кругу. У Deployment под HPA поле replicas из манифеста убирают: число реплик — собственность HPA.

Память как метрика для JVM не годится: heap растёт до потолка и назад не возвращается, и масштабирование идёт только вверх, до maxReplicas. CPU не отражает нагрузку сервиса, который в основном ждёт базу или партнёра — процессор простаивает, а очередь растёт; для таких честнее запросы в секунду или отставание потребителя Kafka, метрики из Prometheus через адаптер или KEDA. Итог: HPA берут, когда нагрузка меняется в разы в течение суток, а старт короче минуты, и дают ему minReplicas, которых хватает на всплеск в одиночку; при ровной нагрузке и долгом старте фиксированное число реплик предсказуемее.

PodDisruptionBudget: drain узла не должен гасить сервис

При обновлении узла kubectl drain выселяет с него все поды. Если три реплики сервиса оказались на этом узле, все три уйдут одновременно, и на 40 секунд холодного старта JVM сервис отвечает 503. Kubernetes не знает, сколько реплик вам нужно живыми.

PodDisruptionBudget это и говорит:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: order-service
spec:
  minAvailable: 2
  selector:
    matchLabels: { app: order-service }

Выселение идёт через Eviction API, который отказывает, если готовых подов останется меньше двух: drain выселяет по одному, дожидаясь, пока замена на другом узле пройдёт readiness.

Границы у PDB чёткие: он действует только на добровольные нарушения — drain и вытеснение автомасштабировщиком узлов; отказ узла или OOMKilled разрешения не спрашивают, а выкат Deployment слушается своего maxUnavailable в стратегии. Считает он готовые поды, поэтому мигающая readiness заставит drain ждать. Ловушка: replicas: 1 с minAvailable: 1 означает, что узел не выселить никогда — обновление кластера встанет из-за вашего сервиса, и PDB снимут за вас. Чтобы бюджет не растягивал потерю, а спасал, реплики раскладывают по разным узлам через topologySpreadConstraints: иначе три пода на одном узле уйдут по очереди, но всё равно все.

Дополнительно: при первом чтении можно пропустить

Глубже: сколько ядер видит JVM при cpu: 500mрасширенное

Память в этой статье разобрана, а вторая половина resources даёт не менее странные эффекты. JVM спрашивает у системы число процессоров и по нему выбирает сборщик мусора, число потоков JIT, размер общего ForkJoinPool и пулов, которые считают от availableProcessors(). В контейнере ответ зависит от того, что написано в limits.

С limits.cpu JVM берёт квоту и округляет вверх: 500m это один процессор, 1500m два. Приложение с limits.cpu: 500m считает, что у него одно ядро, включает SerialGC вместо G1, а parallelStream и CompletableFuture без своего пула работают в один поток. Без limits вовсе JVM видит все ядра узла, тридцать два на большой машине, и заводит пулы под тридцать два потока, хотя по requests ей обещали половину ядра; requests современные версии JDK при подсчёте не учитывают. Оба варианта неверны, и правильный это явно сказать JVM, сколько считать: -XX:ActiveProcessorCount=2 в JAVA_TOOL_OPTIONS, число берут из того, сколько ядер приложению реально нужно под нагрузкой, а не из лимита.

Пул Tomcat от процессоров не зависит (двести потоков по умолчанию), а вот пулы, которые вы задаёте сами (spring.task.execution.pool), стоит задавать числом, а не «по числу ядер». Проверяют одной строкой в логе при старте: Runtime.getRuntime().availableProcessors() рядом с именем сборщика из -Xlog:gc; расхождение с ожиданием видно сразу. Как ядро считает квоту и почему при limits.cpu возможно троттлинг при простаивающем узле, разбирает статья про JVM в контейнерах.

Глубже: где окажутся реплики: affinity, taints и topologySpreadConstraintsрасширенное

Три реплики подняты, и все три оказались на одном узле, потому что планировщику там было удобнее. Узел ушёл на обслуживание, и сервис лёг целиком, при трёх репликах и PodDisruptionBudget. Где окажется под, решают четыре механизма, и по умолчанию не включён ни один.

nodeSelector и nodeAffinity говорят, на каких узлах поду можно: метка узла disktype=ssd или node-pool=java. nodeAffinity умеет «обязательно» и «желательно», и второе не роняет под в Pending, если подходящих узлов нет. podAntiAffinity говорит, рядом с кем нельзя: «не на одном узле с другой репликой меня же», с ключом топологии kubernetes.io/hostname; в мягкой форме (preferred) планировщик старается, в жёсткой (required) при нехватке узлов третья реплика останется Pending, что для прода обычно хуже, чем два пода на одном узле.

topologySpreadConstraints делает то же самое проще и по зонам: maxSkew: 1 по ключу topology.kubernetes.io/zone требует, чтобы разница в числе реплик между зонами была не больше единицы, и whenUnsatisfiable: ScheduleAnyway разрешает нарушить, если иначе никак. Это ответ на «все реплики уехали в одну зону, и зона упала», и для сервиса с тремя репликами в трёх зонах он обязателен.

Taints и tolerations работают наоборот: узел помечен «сюда только с допуском» (NoSchedule), и под без соответствующего toleration на него не попадёт. Так отделяют узлы под базы, прерываемые узлы облака (спот), узлы с GPU. NoExecute ещё и выселяет уже работающие поды без допуска, и так же Kubernetes помечает узел, который перестал отвечать, после чего поды с него переезжают через tolerationSeconds, пять минут по умолчанию.

Минимум для сервиса в проде: topologySpreadConstraints по зоне и по узлу с ScheduleAnyway, а required-правила только там, где нарушение действительно недопустимо. Под в Pending с событием didn't match Pod's node affinity или had untolerated taint это первое, куда смотрят после этих правил, о чём статья про эксплуатацию.

Глубже: securityContext в обычном кластерерасширенное

Статья про OpenShift подаёт запрет на root и ограничение возможностей как его особенность. В ванильном Kubernetes те же ограничения не включены по умолчанию, но это не значит, что так и надо: их задают в манифесте, а с версии 1.25 кластер умеет требовать их от каждого пода.

На уровне пода securityContext задаёт, от кого работает процесс и какой профиль системных вызовов у него есть; на уровне контейнера, может ли он получить больше прав и писать в свою файловую систему:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    seccompProfile: { type: RuntimeDefault }
  containers:
    - name: app
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: [ALL] }
      volumeMounts:
        - { name: tmp, mountPath: /tmp }
  volumes:
    - { name: tmp, emptyDir: {} }

runAsNonRoot откажет запускать образ, у которого процесс стартует от root, поэтому USER в Dockerfile обязателен; readOnlyRootFilesystem требует emptyDir под /tmp, куда Tomcat пишет свои файлы; drop: [ALL] для сервиса на 8080 ничего не ломает. Что означает каждый флаг и почему docker.sock в поде это root на узле, разбирает статья про безопасность образов в разделе Docker.

Требовать это от всех подов позволяет Pod Security Admission: метка на namespace, pod-security.kubernetes.io/enforce: restricted, отвергает поды без этих настроек, warn и audit сначала лишь предупреждают, и по ним ловят приложения, которые не готовы. Профиль baseline запрещает откровенно опасное (привилегированные контейнеры, сеть хоста), restricted требует всего из примера выше. Разумный путь: warn=restricted на все namespace сегодня, enforce=restricted после того, как предупреждения кончились. OpenShift делает то же самое своими SCC, и манифест с securityContext выше проходит у обоих.

Коротко

  • Liveness — только то, что лечится перезапуском: процесс, а не соседи. База — максимум в readiness, с кэшем и мягким порогом: readiness одинакова во всех репликах.
  • Actuator на отдельном порту не видит исчерпанный пул основного Tomcat; additional-path: "server:/livez" возвращает пробу в тот же пул. У сервиса без HTTP работу проверяет exec по свежести файла-сердцебиения.
  • CPU-лимит в половину ядра растягивает старт JVM до минуты и срывает startup probe; изоляцию дают requests, лимит — никакого или от двух ядер. С limits.cpu JVM округляет квоту вверх (500m это одно ядро и SerialGC), без лимита видит весь узел; число задают явно через -XX:ActiveProcessorCount, пулы числом, а не от ядер.
  • Сверх heap у процесса 280–400 MiB, direct-память по умолчанию равна heap. Код 137 — ядро убило контейнер; OutOfMemoryError — кончился heap при целом лимите.
  • Endpoints и SIGTERM стартуют в один момент: preStop 10 с закрывает окно 502, а 10 + 30 обязаны влезть в terminationGracePeriodSeconds.
  • Правка ConfigMap не перезапускает поды, переменные окружения не обновляются никогда; выкат запускают хэшем конфига в шаблоне. Secret — base64; защита — шифрование etcd, права и внешний менеджер секретов.
  • HPA считает утилизацию от requests, не считает поды до readiness и конфликтует с replicas в манифесте; память как метрика для JVM ведёт только вверх.
  • Без PodDisruptionBudget drain узла гасит все реплики разом; minAvailable работает только против добровольных нарушений и требует реплик на разных узлах.
  • Где окажутся реплики: topologySpreadConstraints по зоне и узлу с ScheduleAnyway, podAntiAffinity мягкое, nodeAffinity и tolerations для выделенных узлов; жёсткие правила дают Pending.
  • securityContext в обычном кластере: runAsNonRoot, readOnlyRootFilesystem с emptyDir под /tmp, drop: [ALL], allowPrivilegeEscalation: false; требовать от всех через Pod Security Admission с warn, потом enforce.

Что пощупать

Манифест стартового каталога в практикуме remodov/marketplace-system закрывает всё, о чём статья: две пробы на разные вопросы, запросы и лимиты, preStop перед остановкой, образ с версией, не root. И у него есть тест с другой стороны: ObservabilityTest в самом сервисе проверяет, что пробы отвечают, а tools/check-deploy.py — что манифест их спрашивает.

Код: deploy/k8s, check-deploy.py.

Сделаем сами

Ветка step-15-delivery-and-observability — манифест без проб, лимитов и preStop, скрипт находит восемь замечаний.

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