MAATRIX / Блог / Миф: Kubernetes сам обеспечивает отказоустойчивость

Миф: Kubernetes сам обеспечивает отказоустойчивость

MAATRIX

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

Что Kubernetes умеет сам, а что нужно настраивать руками

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

Из коробки, без единой дополнительной настройки, Kubernetes даёт:

  • Перезапуск упавшего процесса. Если процесс в контейнере завершается с ненулевым кодом, kubelet перезапускает его согласно restartPolicy (по умолчанию Always). Это работает всегда, даже без единой пробы.
  • Планирование пода на другой узел при полной недоступности исходного. Если узел перестаёт отвечать control plane дольше node-monitor-grace-period (обычно 40 секунд) и затем pod-eviction-timeout (обычно 5 минут по умолчанию), поды с него планируются заново — но только если у вас достаточно реплик и ресурсов на других узлах.
  • Базовый service discovery. Service и встроенный DNS находят живые поды по label-селектору без ручной правки конфигов на каждое изменение состава реплик.

А вот что Kubernetes не делает сам, пока вы явно это не опишете:

  • Не определяет, что процесс жив, но не отвечает (завис в дедлоке, ждёт таймаута к базе, застрял в бесконечном цикле) — без проб он считает под здоровым, пока процесс просто существует.
  • Не гарантирует, что при обновлении узла у вас останется хотя бы одна работающая реплика сервиса.
  • Не распределяет реплики по разным физическим узлам — планировщик по умолчанию смотрит только на свободные ресурсы, а не на отказоустойчивость.
  • Не обрабатывает корректное завершение соединений при остановке пода — это обязанность самого приложения.

Дальше — про каждый из этих пунктов подробно и с конкретными манифестами, потому что именно здесь чаще всего рождается разрыв между «мы на Kubernetes» и «у нас реально ничего не падает».

Readiness и liveness: без проб Kubernetes не видит, что под завис

Это, пожалуй, самое частое и самое дорогое заблуждение. Без явно заданных проб под считается готовым принимать трафик сразу после старта контейнера и живым, пока процесс просто не завершился. Приложение может зависнуть в дедлоке базы данных, забить очередь до отказа или упасть в бесконечный retry-цикл — с точки зрения Kubernetes это по-прежнему «здоровый» под, потому что процесс technically жив.

Разница между двумя пробами принципиальная, и путать их — отдельная частая ошибка:

  • readinessProbe отвечает за вопрос «готов ли под принимать трафик прямо сейчас». Если проба не проходит, под убирается из endpoints у Service — трафик перестаёт на него литься, но сам под не перезапускается. Это то, что нужно во время прогрева приложения (миграции, прогрев кэша) или временной недоступности зависимости (например, базы).
  • livenessProbe отвечает за вопрос «жив ли процесс вообще, или его пора перезапустить». Если проба не проходит несколько раз подряд, kubelet убивает контейнер и запускает заново.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: api
          image: myapp:2026.08
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz/live
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 10
            failureThreshold: 3
            timeoutSeconds: 2

Частая ошибка — сделать оба эндпойнта идентичными или завязать /healthz/live на доступность базы данных. Если база временно недоступна, livenessProbe начинает убивать и перезапускать под, хотя проблема не в приложении, а во внешней зависимости, — под входит в цикл CrashLoopBackOff, и вы своими руками усиливаете инцидент. Правильная практика: /healthz/live проверяет только то, что сам процесс отвечает, а /healthz/ready — уже с учётом зависимостей (БД, кэш, очередь).

Без этих проб симптом типичен: kubectl get pods показывает под в статусе Running, метрики CPU/RAM в норме, а пользователи получают таймауты — потому что трафик продолжает литься в под, который фактически не отвечает.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

PodDisruptionBudget и replica count: почему обновление узла может обнулить сервис

Обновление узла (патч ОС, замена инстанса, плановое обслуживание) в Kubernetes выполняется через kubectl drain — команда аккуратно выселяет все поды с узла перед тем, как он уйдёт на обслуживание. Проблема в том, что «аккуратно» здесь означает лишь «через API, а не убийством процессов», а не «с гарантией, что у сервиса останется хотя бы одна живая реплика».

Если у вас replicas: 1 — на время эвакуации пода сервис просто недоступен, это очевидно. Но и replicas: 3 не спасает автоматически: если все три реплики по стечению обстоятельств оказались на одном узле (см. следующий раздел про anti-affinity), drain этого узла эвакуирует все три сразу, и пока новые поды не поднимутся и не пройдут readinessProbe в другом месте, сервис недоступен целиком.

PodDisruptionBudget (PDB) — это ограничение, которое не даёт drain и другим добровольным эвакуациям (voluntary disruptions — обновление узла, автомасштабирование вниз, ручной drain) выселить больше подов, чем вы считаете безопасным одновременно:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api

При таком PDB и replicas: 3 kubectl drain не тронет узел, пока не убедится, что после эвакуации подов с него останется минимум 2 живых реплики — сам процесс эвакуации растянется, но полного простоя не будет. Важная оговорка: PDB защищает только от добровольных нарушений — от планового drain, но не от внезапного аппаратного отказа узла или OOM-killer. Для настоящих сбоев железа единственная защита — реальное распределение реплик по разным физическим узлам, а не просто нужное число реплик в манифесте.

minAvailable можно задать и в процентах (minAvailable: 50%) — это удобнее при HorizontalPodAutoscaler, где число реплик меняется динамически и фиксированное число теряет смысл.

Anti-affinity: как все реплики оказываются на одном физическом узле

Планировщик Kubernetes по умолчанию распределяет поды по узлам, ориентируясь в первую очередь на свободные ресурсы (CPU, память) и ограничения вроде taints/tolerations. Он ничего не знает про вашу цель «реплики должны быть на разных физических машинах для отказоустойчивости», если вы не сказали ему об этом явно. На практике это означает, что три реплики одного Deployment вполне могут оказаться на одном и том же узле — просто потому что на нём было больше всего свободных ресурсов в момент планирования.

Итог предсказуем: узел уходит в отказ (железо, сеть, гипервизор) — и вместе с ним падают сразу все реплики сервиса, несмотря на то что в манифесте честно указано replicas: 3. Формально отказоустойчивость была настроена, по факту единая точка отказа осталась — просто переехала с уровня «один под» на уровень «один физический узел».

Решается через podAntiAffinity:

spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchExpressions:
              - key: app
                operator: In
                values:
                  - api
          topologyKey: kubernetes.io/hostname

requiredDuringSchedulingIgnoredDuringExecution — жёсткое правило: под просто не запустится, если на подходящем по topologyKey домене уже есть под с тем же лейблом. Это надёжно, но может привести к тому, что под зависнет в Pending, если узлов физически меньше, чем реплик. Более мягкий вариант — preferredDuringSchedulingIgnoredDuringExecution: планировщик старается разнести поды, но не блокирует запуск, если разнести некуда.

Начиная с относительно современных версий Kubernetes удобнее использовать topologySpreadConstraints — он даёт более гибкий контроль равномерности распределения по узлам или зонам доступности одновременно, без необходимости жёстко исключать совместное размещение:

spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: ScheduleAnyway
      labelSelector:
        matchLabels:
          app: api

Для кластеров, растянутых по нескольким зонам доступности, тот же принцип стоит применить и на уровне topologyKey: topology.kubernetes.io/zone — иначе все реплики могут оказаться в одной зоне, и её отказ положит сервис так же полностью, как отказ одного узла.

Приложение должно быть готово к отказоустойчивости, а не только кластер

Даже идеально настроенный кластер с пробами, PDB и anti-affinity не спасёт, если само приложение не спроектировано с расчётом на то, что его контейнер может быть в любой момент убит и перезапущен в другом месте. Это уже не зона ответственности Kubernetes, а зона ответственности разработки.

Два требования здесь фундаментальны:

Stateless-архитектура. Приложение не должно хранить состояние, которое не переживёт перезапуск контейнера или недоступно из других реплик — сессии в памяти процесса, файлы на локальном диске без persistent volume, локальные очереди в оперативной памяти. Если пользователь A на первом запросе попал в реплику 1, а на втором — в реплику 2 (что при масштабировании и ребалансировке неизбежно), состояние должно быть доступно из обеих. На практике это означает хранение сессий в Redis, файлов — в S3-совместимом хранилище или на shared-volume, а не в локальной файловой системе контейнера.

Graceful shutdown. Когда Kubernetes решает остановить под (при обновлении, масштабировании вниз, эвакуации), он посылает процессу сигнал SIGTERM и ждёт terminationGracePeriodSeconds (по умолчанию 30 секунд), прежде чем прислать SIGKILL. Если приложение игнорирует SIGTERM и просто продолжает работать до принудительного убийства — активные запросы обрываются посреди выполнения, а не завершаются штатно.

spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 10"]

Здесь есть тонкость с таймингом: под убирается из endpoints Service почти сразу после команды на остановку, но kube-proxy на разных узлах узнаёт об этом не мгновенно — какое-то время новые соединения всё ещё могут приходить в под, который уже готовится завершиться. preStop-хук с небольшой паузой (sleep) даёт этому обновлению долиться по кластеру, прежде чем процесс получит SIGTERM, и снижает число оборванных запросов при штатных перевыкатках. Само приложение при этом должно на SIGTERM перестать принимать новые соединения, но дать доработать уже начатым — эта логика есть почти во всех современных фреймворках, но её нужно явно включить.

Чек-лист: минимальный набор настроек для реальной отказоустойчивости

Если собрать всё вместе, получается конкретный список того, что нужно проверить в каждом Deployment, прежде чем считать сервис отказоустойчивым, а не просто «развёрнутым в Kubernetes»:

НастройкаЧто защищаетЧто будет без неё
readinessProbeТрафик не идёт в незапустившийся/зависший подОшибки у пользователей при живом, но неотвечающем процессе
livenessProbeЗависший процесс перезапускаетсяПод вечно числится Running, ничего не делая
replicas >= 2Есть кому принять трафик, пока одна реплика недоступнаЛюбой сбой = полный простой
PodDisruptionBudgetПлановое обслуживание не эвакуирует всё разомПростой при каждом обновлении узла
podAntiAffinity / topologySpreadConstraintsРеплики физически на разных узлахЕдиная точка отказа на уровне узла
terminationGracePeriodSeconds + preStopАктивные запросы дорабатывают перед остановкойОбрыв соединений при каждом деплое
Stateless-приложениеЛюбая реплика может обслужить любой запросПривязка пользователя к конкретному поду, потеря данных при рестарте

Проверить, что настройки реально работают, а не просто присутствуют в YAML, стоит не в проде во время реального инцидента, а заранее: вручную выполнить kubectl drain <node> на тестовом кластере и посмотреть, действительно ли сервис остаётся доступным, или намеренно убить под (kubectl delete pod <pod>) и замерить, за сколько секунд трафик перестаёт идти в старую реплику и начинает идти в новую. Если решаете, стоит ли вообще заводить кластер под конкретный проект — полезно сначала честно свериться со списком сигналов в статье про переход с docker-compose на Kubernetes и отдельно с разбором мифа о том, что Kubernetes нужен любому проекту — иногда искомая отказоустойчивость дешевле и надёжнее собирается без оркестратора вовсе.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Managed Kubernetes (например, у облачного провайдера) снимает часть этой ответственности?

Managed-сервис снимает заботу о control plane — его обновлениях и отказоустойчивости etcd, — но readiness/liveness пробы, PDB, anti-affinity и graceful shutdown приложения остаются полностью на вашей стороне вне зависимости от того, кто управляет control plane.

Если у меня всего один узел, есть ли смысл во всех этих настройках?

Readiness/liveness и graceful shutdown — да, они защищают от зависшего процесса и обрыва соединений при деплое независимо от числа узлов. А вот PDB и anti-affinity по определению требуют нескольких узлов — на одном узле реплики физически некуда разносить, и здесь отказоустойчивость на уровне узла нужно обеспечивать иначе, например резервным сервером.

Сколько узлов минимально нужно для честной отказоустойчивости?

Для защиты от отказа одного физического узла — минимум два узла и replicas: 2 с anti-affinity между ними. Для сохранения кворума самого control plane при самостоятельно развёрнутом кластере (не managed) — отдельная тема, там etcd обычно требует нечётного числа узлов, начиная с трёх.

HorizontalPodAutoscaler сам решает проблему единой точки отказа?

Нет, HPA решает задачу масштабирования по нагрузке (добавить реплики при росте CPU/памяти), а не задачу распределения по физическим узлам. Без anti-affinity даже пять реплик от HPA могут оказаться на одном и том же узле.

Можно ли протестировать всё это без риска для прод-кластера?

Да, и это стоит делать на отдельном тестовом или staging-кластере с той же топологией узлов: kubectl drain конкретного узла и намеренное удаление подов — стандартная практика проверки перед тем, как объявлять сервис отказоустойчивым в проде.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →