К этому моменту myapp уже крутится в локальном кластере k3d (кластер dev, namespace myapp), Tilt пересобирает образ на каждое изменение, есть Ingress и PostgreSQL рядом. Технически всё работает. Но «работает у меня на ноуте» и «переживёт прод» — это две разные вещи.

Хорошая новость: почти всё, что отделяет учебный манифест от production-ready, проверяется прямо на локалке. Поведение проб, рестарты по liveness, как идёт rolling update, как приложение завершается по сигналу — всё это k3d воспроизводит так же, как настоящий кластер. Значит, эти грабли можно поймать у себя, а не на проде в пятницу вечером.

Это та самая глава, на которую ссылались первая глава, глава про production-like окружения и глава про манифесты: именно здесь мы наконец добавляем к myapp пробы и requests/limits, о которых там говорилось как о признаке production-ready. По очереди добавим в Deployment то, что делает сервис устойчивым: лимиты ресурсов, три вида проб, несколько реплик, аккуратное завершение и стратегию выката без даунтайма. В конце — чек-лист.

Resource requests/limits: CPU и память, OOMKilled

Каждый контейнер можно (и нужно) описать двумя наборами чисел:

  • requests — сколько ресурсов контейнеру гарантированно нужно. Эти числа использует планировщик (scheduler — компонент, который решает, на какую ноду поставить под): он ищет ноду, где хватает свободных requests.
  • limitsпотолок, который контейнеру нельзя превышать. Их принуждает kubelet (агент Kubernetes на каждой ноде) вместе с ядром Linux.
yaml
1resources:
2  requests:
3    cpu: 100m
4    memory: 128Mi
5  limits:
6    cpu: 500m
7    memory: 256Mi

Единицы. CPU: 1 — это одно ядро, 500m — «500 милли», то есть пол-ядра, минимальная точность 1m. Память — в байтах, удобнее писать с суффиксами: бинарные Ki/Mi/Gi (1 Mi = 1024 Ki) или десятичные M/G.

Ключевая разница между CPU и памятью — в том, что происходит при превышении лимита, и она объясняет половину всех загадочных рестартов:

  • CPU — компрессируемый ресурс. Если контейнер упёрся в cpu limit, его просто троттлят (замедляют). Контейнер живёт, просто работает медленнее.
  • Память — НЕ компрессируема. Отнять уже выданную память нельзя. Поэтому при превышении memory limit ядро убивает процесс — это и есть знаменитый OOMKilled (Out Of Memory) с кодом выхода 137.

Поэтому путать поведение нельзя: заниженный CPU-лимит даёт тормоза, заниженный memory-лимит — мгновенную смерть пода. Подробности в официальной документации Resource Management for Pods and Containers.

Маленькая ловушка: если задать limit без request, Kubernetes молча проставит request = limit. Иногда это раздувает требования к планировщику сильнее, чем хотелось.

QoS-классы: кого убьют первым

По наличию requests/limits Kubernetes присваивает поду QoS-класс (Quality of Service), который определяет, кого вытеснят (evict) первым под давлением памяти:

  • Guaranteed — у всех контейнеров request = limit и по CPU, и по памяти. Вытесняется последним.
  • Burstable — есть requests, но это не Guaranteed. Типичный случай.
  • BestEffort — нет ни requests, ни limits. Вытесняется первым.

То есть под совсем без ресурсов (BestEffort) — первый кандидат на выселение, когда ноде не хватает памяти. Это ещё одна причина всегда указывать хотя бы requests.

Как подобрать числа

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

bash
1kubectl top pods -n myapp

(Для работы kubectl top нужен metrics-server; в k3d он обычно уже включён.)

Практический подход: стартовать с разумного минимума — 100m CPU и 128Mi памяти — и подкрутить по факту. По памяти держите запас (например, request около P99-потребления плюс ~20%), потому что промах = OOMKilled. По CPU можно быть скромнее (ориентир P95), ведь худшее, что грозит, — троттлинг, а не смерть.

Liveness-проба: «приложение зависло — перезапусти»

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

Liveness-проба (liveness probe — «проба живости») решает именно это. Kubelet периодически дёргает контейнер, и если проба проваливается failureThreshold раз подряд, kubelet убивает контейнер и перезапускает его (по умолчанию restartPolicy: Always).

Для myapp сделаем в FastAPI лёгкий эндпоинт:

python
1from fastapi import FastAPI
2
3app = FastAPI()
4
5@app.get("/healthz")
6def healthz():
7    # Проверка должна быть простой: жив ли сам процесс.
8    return {"status": "ok"}

И пропишем пробу в Deployment:

yaml
1livenessProbe:
2  httpGet:
3    path: /healthz
4    port: 8080
5  initialDelaySeconds: 15
6  periodSeconds: 10
7  failureThreshold: 3

Главное правило (и его особо подчёркивает блог Kubernetes): держите liveness простой. Не ходите из неё в базу или во внешние API. Сложная проверка даёт ложные срабатывания: упала зависимость — liveness провалилась — kubelet перезапустил совершенно рабочий контейнер. Для проверки зависимостей есть readiness (ниже). Поведение проб подробно описано в Configure Liveness, Readiness and Startup Probes.

Readiness-проба: «не готов принимать трафик»

Readiness-проба (readiness probe — «проба готовности») отвечает на другой вопрос: готов ли под прямо сейчас обслуживать запросы?

Отличие от liveness — принципиальное:

  • liveness провалилась → контейнер перезапускают;
  • readiness провалилась → под помечается unready и убирается из endpoints Service (трафик через Service на него не идёт), но контейнер НЕ перезапускают.

То есть readiness — это «временно отойдём от приёма трафика, но рестартить не надо». Идеально для: прогрева/инициализации после старта и для случая, когда зависимость временно недоступна (например, PostgreSQL мигает).

Для myapp readiness логично завязать на готовность к работе — в том числе на доступность базы:

python
1@app.get("/ready")
2def ready():
3    # Здесь уместно проверить, что соединение с PostgreSQL живо.
4    # Если БД недоступна — вернуть 503, под уйдёт из endpoints,
5    # но НЕ будет перезапущен.
6    ...
7    return {"status": "ready"}
yaml
1readinessProbe:
2  httpGet:
3    path: /ready
4    port: 8080
5  periodSeconds: 5

Readiness и liveness можно (и нужно) использовать вместе для одного контейнера: liveness смотрит на сам процесс по /healthz, readiness — на готовность обслуживать запросы по /ready. Набор полей у проб одинаковый, отличается только ключ (livenessProbe / readinessProbe).

Проверить, что readiness реально управляет трафиком, легко на локалке: kubectl get endpointslices -n myapp — под появляется и исчезает из списка в зависимости от состояния пробы.

Startup-проба: для тех, кто стартует медленно

Бывает, приложение долго поднимается: прогревает кэш, накатывает миграции, читает большой конфиг. Если на такой контейнер сразу повесить агрессивную liveness, получится петля рестартов: приложение ещё стартует, а liveness уже решила, что оно мертво, и убила его.

Раньше это лечили большим initialDelaySeconds у liveness, но это костыль: задержка фиксированная и одинаковая для всех. Правильный инструмент — startup-проба (startup probe).

Логика: пока startup-проба не прошла успешно, liveness и readiness полностью отключены. Приложению дают спокойно подняться. Как только startup прошла — включаются остальные пробы. А если startup так и не прошла за отведённое число попыток (failureThreshold), контейнер убивают и рестартят.

yaml
1startupProbe:
2  httpGet:
3    path: /healthz
4    port: 8080
5  failureThreshold: 30
6  periodSeconds: 10

Этот пример даёт приложению до 30 × 10 = 300 секунд на старт, и при этом liveness может остаться частой и строгой — она просто не вмешается, пока сервис не поднимется.

Для справки — общие поля проб и их дефолты (одинаковы для всех трёх типов):

ПолеДефолтЧто значит
initialDelaySeconds0пауза перед первой проверкой
periodSeconds10интервал между проверками
timeoutSeconds1таймаут одной проверки
failureThreshold3сколько провалов подряд = провал
successThreshold1сколько успехов = снова «ок»

Типы проб: httpGet (успех — код ответа 200–399), tcpSocket (открылся порт), exec (команда вернула 0), grpc. Всё это — из официальной документации по пробам.

Несколько реплик и graceful shutdown (SIGTERM)

Одна реплика (replicas: 1) означает, что любой рестарт, выкат или сбой ноды = простой. Для отказоустойчивости нужно больше одной:

yaml
1spec:
2  replicas: 2

Но просто «добавить реплик» мало — нужно, чтобы под умел аккуратно завершаться. Иначе при каждом выкате часть запросов будет рваться.

Что происходит при удалении пода

Когда под удаляют (выкат, скейл вниз, drain ноды), последовательность такая (см. Pod Lifecycle и разбор CNCF):

  1. У пода появляется deletionTimestamp, статус → Terminating.
  2. Выполняется хук preStop (если задан).
  3. Kubelet шлёт SIGTERM в PID 1 контейнера.
  4. Kubelet ждёт terminationGracePeriodSeconds (по умолчанию 30 секунд).
  5. Если процесс не вышел — SIGKILL (жёсткое убийство).

Здесь два важных нюанса.

Нюанс первый: приложение обязано ловить SIGTERM. Получив сигнал, myapp должен перестать брать новые запросы, доработать текущие, закрыть соединения с PostgreSQL и выйти — всё в рамках grace period. Частая ошибка: запуск приложения через shell-обёртку без exec. Тогда PID 1 — это шелл, он SIGTERM не пробрасывает, приложение сигнала не видит и получает SIGKILL по истечении grace period. В Dockerfile запускайте процесс «начисто» (exec-форма CMD), чтобы uvicorn стал PID 1 — об этом мы говорили в главе про контейнеризацию. Современный uvicorn по SIGTERM завершается корректно.

Нюанс второй: гонка с endpoints. Удаление пода из endpoints Service и отправка SIGTERM происходят параллельно, асинхронно. Это значит, что kube-proxy/Ingress могут ещё какое-то время слать трафик на под, который уже получил SIGTERM и начал завершаться, — и эти запросы упадут с 5xx прямо во время деплоя.

Лекарство от гонки: preStop sleep

Классическое решение (см. Graceful shutdown in Kubernetes от Learnk8s) — добавить preStop-хук с небольшим sleep. Под уже помечен на удаление и выбывает из endpoints, но за время сна маршрутизация успевает обновиться по всему кластеру, и только потом приложение начинает завершаться.

yaml
1spec:
2  terminationGracePeriodSeconds: 30
3  containers:
4    - name: myapp
5      # ...
6      lifecycle:
7        preStop:
8          exec:
9            command: ["sh", "-c", "sleep 15"]

Рекомендуемый порядок завершения: preStop sleep → SIGTERM → доработать in-flight запросы → закрыть долгие соединения (БД, WebSocket) → выйти.

Важно: preStop входит в общий бюджет terminationGracePeriodSeconds. Если поставить sleep 30 при grace period 30 секунд, на само завершение приложения времени не останется — и его прибьют SIGKILL. Держите preStop заметно меньше grace period (например, sleep 15 при terminationGracePeriodSeconds: 30).

Rolling update и почему readiness тут ключевой

У Deployment по умолчанию стратегия RollingUpdate: старые поды заменяются новыми постепенно, а не все разом. Управляется двумя параметрами (см. Deployments):

  • maxSurge (по умолчанию 25%) — сколько подов можно поднять сверх желаемого числа во время выката;
  • maxUnavailable (по умолчанию 25%) — сколько подов может быть недоступно одновременно.

Альтернатива — Recreate: сначала убить все старые поды, потом поднять новые. Это гарантированный даунтайм на каждый деплой, в проде так почти никогда не делают.

Почему здесь критична readiness. Rollout ждёт, пока новый под пройдёт readiness-пробу, и только потом убирает старый. Если readiness-пробы нет, Kubernetes считает под готовым сразу после старта контейнера — и начинает слать на него трафик ещё до того, как myapp реально готов отвечать. Результат — ошибки при каждом выкате. То есть без readiness zero-downtime невозможен в принципе.

Для выката без даунтайма для myapp:

yaml
1spec:
2  replicas: 2
3  strategy:
4    type: RollingUpdate
5    rollingUpdate:
6      maxSurge: 1
7      maxUnavailable: 0
8  minReadySeconds: 5

maxUnavailable: 0 гарантирует, что во время выката не уменьшится число рабочих подов; maxSurge: 1 поднимает один новый под, ждёт его readiness, гасит один старый — и так по кругу. minReadySeconds — дополнительный буфер: под считается доступным только спустя N секунд после того, как стал ready (защита от подов, которые «готовы» и тут же падают).

Проверить выкат на локалке:

bash
1kubectl rollout status deployment/myapp -n myapp
2kubectl get endpointslices -n myapp -w   # видно, как поды входят/выходят

Сделайте это у себя в k3d под нагрузкой (хоть простым while true; do curl ...): если в момент выката появляются 5xx — значит, чего-то из связки readiness + preStop sleep + maxUnavailable: 0 не хватает.

Чек-лист production-readiness

Прогоните myapp по списку — всё это проверяемо локально в k3d ещё до прода:

  • requests и limits заданы на каждый контейнер; числа подобраны по kubectl top, а не на глаз.
  • memory limit с запасом — промах = OOMKilled (137). Помните: CPU троттлит, память убивает.
  • Под не BestEffort — есть хотя бы requests (иначе вытеснят первым).
  • livenessProbe — простая, без походов в БД; перезапускает зависший процесс.
  • readinessProbe — гейт для трафика и для rolling update; здесь уместна проверка зависимостей.
  • startupProbe — если приложение стартует долго (вместо большого initialDelaySeconds).
  • replicas > 1 — иначе любой рестарт = простой.
  • Приложение ловит SIGTERM и завершается аккуратно; uvicorn — PID 1 (exec-форма CMD, без shell-обёртки).
  • terminationGracePeriodSeconds + preStop sleep против гонки с endpoints; preStop меньше grace period.
  • RollingUpdate с maxUnavailable: 0 и maxSurge: 1 для zero-downtime.
  • Паритет с продом: те же манифесты (Helm/Kustomize), та же мажорная версия Kubernetes на локалке и в проде — об упаковке манифестов будет в главе про подготовку к деплою и CI.

Если по всем пунктам стоят галочки — myapp уже не «работает у меня на ноуте», а «переживёт прод». И, что важно, вы это проверили заранее, на дешёвом локальном кластере.

Источники

Хотите локалку, которая переживёт прод?
Нужна помощь, чтобы сделать локальное окружение по-настоящему похожим на прод — пробы, лимиты ресурсов, graceful shutdown и выкат без даунтайма? Помогу собрать setup, который переживёт прод.