Сервис прошёл тесты, образ собрался, kubectl apply отработал, и первые недели всё хорошо. Потом база уходит на две минуты, а сервис не возвращается ещё десять: поды перезапускаются по кругу. Потом под гибнет с кодом 137 без единой строки в логе. Потом каждый выкат приносит потребителям пачку 502. Kubernetes раскладывает поды по узлам и перезапускает упавшие, но что считать «упавшим», сколько памяти нужно JVM и как остановить процесс, не оборвав запросы, он не знает — об этом ему должен рассказать разработчик.
Начнём с самого частого инцидента: база недоступна две минуты, а дальше всё решает то, в какой группе стоит её проверка.
Один и тот же отказ базы: в группе 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 ещё до всякой утечки.
Одна и та же 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.
Список 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.cpuJVM округляет квоту вверх (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, скрипт находит восемь замечаний.
Что почитать дальше
- Graceful shutdown в Kubernetes — бюджет остановки по секундам, maxSurge и maxUnavailable со стороны Spring и выката.
- JVM в контейнере — лимиты cgroup, MinRAMPercentage на маленьких лимитах и availableProcessors.
- Эксплуатация и отладка — OOMKilled, CrashLoopBackOff и мигающая readiness глазами kubectl.
- Деплой и конфигурация — как эти манифесты доезжают до кластера и что делает выкат без простоя.