Анатомия Pod и Node: Глубокий разбор минимальной единицы K8s

Курс посвящен детальному устройству Pod как атомарной единицы оркестрации. Вы изучите механизмы совместного использования ресурсов контейнерами, роль pause-контейнера и принципы управления ресурсами на уровне Node.

Pod как логический хост: общие Namespaces и разделяемые ресурсы

Pod как логический хост: общие Namespaces и разделяемые ресурсы

Парадокс: Kubernetes называют оркестратором контейнеров, но в нём невозможно запустить контейнер. Если вы попросите Kubernetes запустить образ Nginx, он обернёт его в абстракцию под названием Pod, и только потом отправит на Worker Node. Зачем нужна эта лишняя сущность? Почему нельзя управлять контейнерами напрямую, как это делает Docker?

Чтобы стать профессионалом в Kubernetes, нужно перестать мыслить категориями отдельных контейнеров и понять главную идею: Pod — это логический сервер.

Зачем Kubernetes придумал Pod?

В мире до контейнеров приложения работали на физических серверах или виртуальных машинах (VM). На одной VM часто крутилось несколько тесно связанных процессов. Например: главный веб-сервер (Nginx) и фоновый агент, который собирает его логи и отправляет их в центральное хранилище.

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

Когда мы переходим к чистым контейнерам, возникает проблема. Контейнер — это жестко изолированная среда. Если мы запустим Nginx в одном контейнере, а сборщик логов в другом, они окажутся в разных изолированных мирах. У них будут разные IP-адреса, и они не смогут просто так прочитать файлы друг друга.

Pod (в переводе «стая китов» или «стручок») был придуман для того, чтобы вернуть разработчикам удобство виртуальной машины, сохранив при этом легковесность контейнеров.

Pod объединяет один или несколько контейнеров в единую группу, которая делит общее окружение. Они всегда запускаются вместе, на одном и том же Worker Node, и никогда не размазываются по разным серверам кластера.

Анатомия иллюзии: как Pod манипулирует Namespaces

Мы помним, что контейнеризация в Linux строится на механизме Namespaces (пространств имен), который изолирует ресурсы. Когда kubelet через CRI запускает контейнеры для одного Pod'а, он настраивает их Namespaces особым образом: часть из них он делает общими, а часть оставляет изолированными.

1. Что объединяется (Shared)

  • Network Namespace (Сеть): Все контейнеры в Pod'е получают один общий IP-адрес и один MAC-адрес. Они делят общую таблицу маршрутизации и порты. Это значит, что контейнеры внутри одного Pod'а могут общаться друг с другом по адресу localhost.
  • IPC Namespace (Межпроцессное взаимодействие): Контейнеры могут использовать механизмы разделяемой памяти (System V IPC или POSIX) для сверхбыстрого обмена данными, как если бы они были процессами в одной ОС.
  • UTS Namespace (Имя хоста): Все контейнеры внутри Pod'а видят одно и то же имя хоста (hostname), которое по умолчанию совпадает с именем самого Pod'а.

2. Что остается изолированным (Isolated)

  • Mount Namespace (Файловая система): По умолчанию каждый контейнер видит только свою собственную файловую систему, запакованную в его Docker-образ. Контейнер A не видит файлы контейнера B.
  • PID Namespace (Дерево процессов): Каждый контейнер видит только свои процессы. Процесс Nginx в первом контейнере будет иметь PID 1, и процесс сборщика логов во втором контейнере тоже будет иметь PID 1. Они не видят процессы друг друга (хотя Kubernetes позволяет отключить эту изоляцию через параметр shareProcessNamespace: true, это используется редко, в основном для глубокого дебага).

Обратная сторона общей сети: конфликт портов

Поскольку Network Namespace общий, все порты делятся между контейнерами. Это работает точно так же, как на вашем ноутбуке: если вы запустите две программы, пытающиеся слушать порт 8080, вторая выдаст ошибку Address already in use.

Внутри Pod'а действует то же правило. Вы не можете запустить два контейнера с Nginx в одном Pod'е, если они оба настроены на прослушивание порта 80. Один из них запустится, а второй упадет с ошибкой привязки к порту.

Как обойти изоляцию Mount: общие Volumes

Если Mount Namespace изолирован, как нашему сборщику логов прочитать файлы веб-сервера? Для этого Kubernetes вводит абстракцию Volume (том) на уровне самого Pod'а.

Volume — это директория, которая создается для Pod'а и монтируется внутрь нужных контейнеров. Самый простой тип тома — emptyDir. Это временная пустая папка, которая создается при запуске Pod'а и удаляется при его уничтожении.

Давайте посмотрим, как это выглядит на практике.

Практика: YAML-манифест мультиконтейнерного Pod'а

Ниже представлен манифест Pod'а, реализующий паттерн Sidecar (коляска). Основной контейнер — веб-сервер, а sidecar-контейнер — утилита, которая что-то делает с его данными.

apiVersion: v1
kind: Pod
metadata:
  name: web-with-logger
spec:
  # Объявляем тома на уровне Pod'а
  volumes:
  - name: shared-logs
    emptyDir: {}

  containers:
  # Основной контейнер
  - name: nginx-web
    image: nginx:alpine
    volumeMounts:
    - name: shared-logs
      mountPath: /var/log/nginx # Nginx пишет логи сюда

  # Sidecar-контейнер
  - name: log-reader
    image: busybox
    # Простой скрипт, читающий лог каждую секунду
    command: ["/bin/sh", "-c", "tail -f /var/log/app/access.log"]
    volumeMounts:
    - name: shared-logs
      mountPath: /var/log/app # Тот же том, но смонтирован в другой путь!

Обратите внимание на важный нюанс: сам том shared-logs один, но внутри контейнеров он может быть примонтирован по разным путям. Nginx думает, что пишет в /var/log/nginx, а busybox читает из /var/log/app. Под капотом это одни и те же файлы на Worker Node.

Ошибки и диагностика (Troubleshooting)

Когда вы работаете с мультиконтейнерными Pod'ами, стандартные команды kubectl требуют уточнения.

Если вы напишете kubectl logs web-with-logger, Kubernetes выдаст ошибку: он не знает, логи какого именно контейнера вы хотите посмотреть. Вам нужно явно указать имя контейнера с помощью флага -c:

kubectl logs web-with-logger -c log-reader

Аналогично с выполнением команд внутри контейнера: kubectl exec -it web-with-logger -c nginx-web -- /bin/sh

Типичный сценарий сбоя: CrashLoopBackOff из-за Sidecar'а

Статус Pod'а вычисляется на основе статусов всех его контейнеров. Если основной веб-сервер работает отлично, но sidecar-контейнер постоянно падает (например, из-за опечатки в команде запуска), весь Pod не перейдет в статус Running.

Вы увидите статус NotReady или CrashLoopBackOff. В этом случае нужно проверять события Pod'а (kubectl describe pod <name>) и искать, какой именно контейнер не смог стартовать, а затем смотреть его логи через kubectl logs <name> -c <failed-container>.

Мостик к следующей теме

Мы выяснили, что Pod объединяет контейнеры общим сетевым пространством (Network Namespace). Но здесь кроется техническая загадка.

Контейнеры внутри Pod'а могут падать и перезапускаться (kubelet делает это автоматически). Если контейнер Nginx упал, его процесс завершился, а значит, его Namespaces должны быть уничтожены ядром Linux. Как в таком случае Pod сохраняет свой IP-адрес неизменным, пока перезапускаются его рабочие контейнеры? Кто «держит» сеть, пока остальные контейнеры лежат?

Ответ кроется в невидимом компоненте, который kubelet запускает самым первым — pause-контейнере. Его роль и внутреннее устройство мы разберем в следующей главе.

Секретный ингредиент: роль pause-контейнера в жизненном цикле Pod

Секретный ингредиент: роль pause-контейнера в жизненном цикле Pod

Представьте, что вы написали YAML-манифест для Pod'а, в котором всего один контейнер — веб-сервер Nginx. Вы применяете его в кластере, подключаетесь по SSH к Worker Node и заглядываете под капот среды выполнения (например, через ctr для containerd или docker ps на старых версиях). И тут вы видите странную картину: вместо одного контейнера для вашего Pod'а запущено два. Один — ваш Nginx, а второй — крошечный контейнер с образом registry.k8s.io/pause. Вы его не заказывали, в манифесте его нет. Кто его создал, зачем он нужен и почему без него Kubernetes просто не смог бы работать?

Уязвимость общих Namespaces

Мы уже знаем, что Pod — это логический хост, внутри которого контейнеры делят общие Linux Namespaces (в первую очередь Network и IPC). Благодаря этому они могут общаться по localhost.

Но в архитектуре Linux есть базовое правило: Namespace по умолчанию существует только до тех пор, пока к нему привязан хотя бы один процесс (или пока он не закреплен в файловой системе через bind mount).

Допустим, в нашем Pod'е работает только один контейнер — Nginx. Сетевой плагин (CNI) выделил для него IP-адрес, прописал маршруты и правила iptables внутри его Network Namespace. Внезапно процесс Nginx падает из-за нехватки памяти или ошибки в коде. Контейнер завершается.

Если бы Nginx был единственным владельцем этого Network Namespace, ядро Linux мгновенно уничтожило бы этот Namespace вместе со всеми сетевыми настройками и IP-адресом. Когда kubelet перезапустил бы Nginx, контейнеру пришлось бы запрашивать новый IP-адрес, заново инициализировать сеть, а другие приложения в кластере потеряли бы с ним связь. Абстракция единого и стабильного логического хоста была бы разрушена.

Чтобы этого избежать, Kubernetes вводит концепцию якоря.

Pause-контейнер как фундамент Pod'а

Этим якорем и является pause-контейнер. Это базовый контейнер, который запускается при создании любого Pod'а, до старта ваших пользовательских (и даже init) контейнеров.

Его главная и единственная задача — захватить нужные Linux Namespaces, удерживать их открытыми и позволить пользовательским контейнерам присоединяться к ним.

PodSandbox — это низкоуровневый термин в Container Runtime Interface (CRI), обозначающий изолированную среду для Pod'а. На практике в containerd и CRI-O эта среда реализуется именно через запуск pause-контейнера.

Поскольку pause-контейнер никогда не падает по вине пользовательского кода, он гарантирует, что IP-адрес, маршруты и IPC-очереди останутся на месте, даже если все ваши рабочие контейнеры внутри Pod'а уйдут в циклический рестарт (CrashLoopBackOff).

Что внутри: анатомия минимализма

Если заглянуть в исходный код образа pause (он написан на C), мы увидим предельно простую логику. По сути, он делает следующее:

  1. Регистрирует обработчики сигналов операционной системы (чтобы корректно завершаться при получении SIGTERM от kubelet).
  2. Вызывает системный вызов pause(), который переводит процесс в состояние глубокого сна до получения любого прерывания.
  3. Опционально: работает как процесс с PID 1 внутри Pod'а, собирая «зомби-процессы» (zombie reaping), если вы включили shareProcessNamespace: true в манифесте Pod'а.

Этот контейнер потребляет доли миллисекунды CPU при старте и считанные килобайты оперативной памяти. Он абсолютно невидим для пользователя, но критически важен для системы.

Жизненный цикл: как CRI и CNI собирают матрешку

Чтобы понять всю глубину интеграции, давайте разберем, что именно происходит на Worker Node, когда kubelet решает запустить Pod. Этот процесс жестко стандартизирован через интерфейсы CRI (Container Runtime Interface) и CNI (Container Network Interface).

  1. Запрос песочницы (CRI): Kubelet отправляет gRPC-запрос RunPodSandbox к среде выполнения (например, containerd).
  2. Подготовка пространств: Containerd создает новые Linux Namespaces (в первую очередь Network и IPC) для будущей песочницы.
  3. Настройка сети (CNI): Containerd вызывает сетевой плагин (CNI), передавая ему путь к созданному Network Namespace. CNI создает виртуальный Ethernet-интерфейс (veth pair), присваивает IP-адрес и настраивает маршрутизацию внутри этого пространства.
  4. Запуск якоря: Containerd скачивает образ pause и запускает его внутри этих подготовленных Namespaces, чтобы зафиксировать их состояние.
  5. Запуск рабочих контейнеров (CRI): Только теперь kubelet отправляет запросы CreateContainer и StartContainer для Nginx. Containerd запускает Nginx, инструктируя ядро Linux: «помести этот процесс в уже существующие Namespaces песочницы, которые удерживает pause-контейнер».

Практика: смотрим под капот

Давайте убедимся в этом на практике. Инструмент kubectl скрывает от нас инфраструктурные детали, поэтому нам нужно спуститься на уровень узла и использовать crictl — утилиту для работы напрямую с CRI.

Если мы выполним команду просмотра Pod'ов (песочниц):

crictl pods

Мы увидим список именно PodSandbox:

POD ID              CREATED             STATE               NAME                NAMESPACE
a1b2c3d4e5f6g       2 minutes ago       Ready               nginx-pod           default

А если мы посмотрим список запущенных контейнеров через crictl ps -a, мы увидим только наш Nginx. Почему? Потому что спецификация CRI четко разделяет понятия «песочница» (Sandbox) и «рабочий контейнер» (Container), и crictl намеренно фильтрует pause-контейнеры из выдачи.

Чтобы увидеть реальную картину всех процессов, включая pause, нам нужно обратиться напрямую к среде выполнения (например, containerd) через ее нативную утилиту ctr:

ctr -n k8s.io containers ls

Результат покажет скрытую правду:

CONTAINER           IMAGE                            RUNTIME
9f8e7d6c5b4a3...    docker.io/library/nginx:latest   io.containerd.runc.v2
a1b2c3d4e5f6g...    registry.k8s.io/pause:3.9        io.containerd.runc.v2

Обратите внимание: pause-контейнер имеет тот же идентификатор (или префикс), что и POD ID нашей песочницы.

Если вы ради эксперимента убьете процесс Nginx (crictl stop 9f8e7d6c5b4a3), kubelet просто создаст новый контейнер Nginx и подселит его в ту же песочницу. IP-адрес Pod'а не изменится. Но если вы остановите саму песочницу (crictl stopp a1b2c3d4e5f6g или принудительно убьете процесс pause в ОС), containerd сообщит kubelet'у, что песочница разрушена. Kubelet будет вынужден убить все оставшиеся рабочие контейнеры в этом Pod'е и инициировать процесс RunPodSandbox с самого начала. Pod получит новый IP-адрес.

Pause-контейнер — это элегантный хак, который позволил Kubernetes использовать стандартные механизмы изоляции Linux для создания сложной абстракции логического хоста. Он обеспечивает стабильность сети и разделяемых ресурсов, пока ваши приложения перезапускаются, обновляются или падают.

Однако стабильный фундамент — это лишь половина дела. Когда несколько контейнеров живут на одном физическом узле, они начинают конкурировать за реальные процессорные такты и мегабайты памяти. Если один контейнер попытается забрать всю память Node, пострадают все остальные. О том, как Kubernetes управляет жадностью процессов с помощью Requests, Limits и классов обслуживания (QoS), мы поговорим на следующем шаге.

Управление ресурсами Node: Requests, Limits и Quality of Service (QoS)

Управление ресурсами Node: Requests, Limits и Quality of Service (QoS)

Представьте Worker Node с 16 ядрами CPU и 64 ГБ оперативной памяти. Разработчик разворачивает приложение, не указывая никаких требований к ресурсам. Всё работает отлично, пока не наступает «Черная пятница». Приложение начинает потреблять 60 ГБ памяти, вытесняя системные процессы. Внезапно Node перестает отвечать на запросы Control Plane, получает статус NotReady, и кластер начинает хаотично переносить Pod'ы, создавая каскадный сбой.

Почему это произошло? Потому что по умолчанию Kubernetes позволяет контейнерам использовать все доступные ресурсы узла. Чтобы превратить кластер из Дикого Запада в предсказуемую систему, необходимо понимать, как Kubernetes считает ресурсы и как ограничивает их на уровне ядра Linux.

Анатомия ресурсов узла: Capacity vs Allocatable

Когда вы добавляете сервер в кластер Kubernetes, kube-scheduler не может распределять Pod'ы, опираясь на физический объем его ресурсов (Capacity). Узлу нужны ресурсы для работы самой операционной системы и компонентов Kubernetes (например, kubelet и среды выполнения контейнеров, о которых мы говорили ранее).

Kubernetes оперирует понятием Allocatable — это объем ресурсов, реально доступный для запуска пользовательских Pod'ов.

Формула расчета выглядит так: Allocatable=CapacityKube_ReservedSystem_ReservedEviction_ThresholdAllocatable = Capacity - Kube\_Reserved - System\_Reserved - Eviction\_Threshold

  • Capacity: Физические ресурсы железа или виртуальной машины (например, 64 ГБ RAM).
  • Kube-Reserved: Ресурсы, зарезервированные для демонов Kubernetes (kubelet, kube-proxy, containerd).
  • System-Reserved: Ресурсы для системных демонов ОС (SSH, systemd, udev).
  • Eviction Threshold: Неприкосновенный запас памяти. Если свободная память падает ниже этого порога (обычно 100 МБ), kubelet начинает агрессивно убивать Pod'ы, чтобы спасти узел от зависания.

Важный инсайт: Если вы не настроите Kube-Reserved и System-Reserved (а во многих self-hosted инсталляциях по умолчанию они равны нулю), Kubernetes будет считать, что AllocatableCapacityAllocatable \approx Capacity. Это прямой путь к падению узла под нагрузкой, так как Pod'ы «съедят» ресурсы, необходимые самому kubelet для отправки Heartbeat-сигналов.

Requests и Limits: язык общения с кластером

Чтобы контролировать потребление ресурсов, в спецификации контейнера используются два параметра: Requests (запросы) и Limits (лимиты). Они задаются для CPU и Memory (RAM).

CPU измеряется в ядрах или милликорах (m). «1000m» равно 1 ядру. Память измеряется в байтах (обычно используются мегабайты Mi или гигабайты Gi).

apiVersion: v1
kind: Pod
metadata:
  name: resource-demo
spec:
  containers:
  - name: app
    image: nginx
    resources:
      requests:
        memory: "256Mi"
        cpu: "200m"
      limits:
        memory: "512Mi"
        cpu: "500m"

Requests: Гарантия и метрика для планировщика

Requests — это минимальный гарантированный объем ресурсов, который нужен контейнеру для работы.

Этот параметр используется только компонентом kube-scheduler на этапе принятия решения (Filtering). Планировщик суммирует Requests всех уже запущенных Pod'ов на узле и добавляет Request нового Pod'а. Если сумма превышает Allocatable узла, Pod туда не попадет.

Даже если прямо сейчас запущенные Pod'ы простаивают и потребляют 0% CPU, планировщик всё равно считает их Requests «занятыми». Это логическое резервирование.

Limits: Жесткий потолок и cgroups

Limits — это максимальный объем ресурсов, который контейнер может потребить. В отличие от Requests, Limits обеспечиваются не планировщиком, а ядром Linux на самом узле через механизм cgroups (Control Groups).

Реакция системы на достижение лимита кардинально отличается для CPU и памяти:

  1. CPU — это сжимаемый ресурс (Compressible). Если контейнер пытается превысить Limit по CPU, ядро Linux не убивает процесс. Оно начинает его «троттлить» (искусственно замедлять), выделяя процессорное время строго дозированно. Приложение продолжает работать, но медленнее (возрастает latency).
  2. Memory — это несжимаемый ресурс (Incompressible). Память нельзя «замедлить». Если контейнер пытается выделить больше памяти, чем указано в его Limit, ядро Linux вызывает OOM Killer (Out Of Memory Killer) и принудительно завершает процесс. Контейнер падает с ошибкой OOMKilled и перезапускается kubelet'ом.

Как это выглядит под капотом Linux

Когда kubelet получает YAML-манифест, он передает эти цифры в Container Runtime (например, containerd), а тот настраивает cgroups:

  • CPU Requests превращаются в параметр cpu.shares. Это относительный вес. Если процессор перегружен, время делится между контейнерами пропорционально их shares.
  • CPU Limits превращаются в связку cpu.cfs_period_us и cpu.cfs_quota_us (Completely Fair Scheduler). Это жесткие квоты времени в микросекундах.
  • Memory Limits записываются в memory.limit_in_bytes. Превышение этого значения триггерит OOM Killer.
  • Memory Requests в старых версиях cgroups (v1) почти ни на что не влияли, но в современных системах с cgroups v2 они транслируются в memory.min, защищая память контейнера от выгрузки в swap.

Quality of Service (QoS): Классовое неравенство Pod'ов

Kubernetes не требует указывать Requests и Limits для каждого контейнера. В зависимости от того, как вы их настроили, kubelet автоматически присваивает Pod'у один из трех классов обслуживания — QoS Class.

Этот класс определяет «ценность» Pod'а для системы. Когда на узле заканчиваются ресурсы (например, память узла исчерпана, несмотря на лимиты), kubelet должен решить, кого принести в жертву (Eviction). Класс QoS определяет очередь на выселение.

QoS Класс Условие присвоения Приоритет выживания
Guaranteed Для всех контейнеров в Pod'е заданы Requests и Limits, и они равны друг другу (и для CPU, и для RAM). Высокий. Убиваются в самую последнюю очередь.
Burstable Заданы хотя бы какие-то Requests или Limits, но условия для Guaranteed не выполнены (например, Request < Limit). Средний. Могут потреблять ресурсы сверх Request, но рискуют быть убитыми.
BestEffort Не заданы ни Requests, ни Limits ни для одного контейнера. Низкий. Главные кандидаты на выселение при нехватке ресурсов узла.

Практический совет: Базы данных и критичные in-memory кэши (Redis) всегда должны иметь класс Guaranteed (Requests = Limits). Web-серверы и фоновые воркеры чаще всего делают Burstable, чтобы они могли утилизировать простаивающие мощности узла при всплесках трафика, но не гарантируют их наличие.

Базовая диагностика

Если вы понимаете разницу между Requests и реальным потреблением, диагностика становится предсказуемой.

Сценарий 1: Pod завис в статусе Pending. Выполняем kubectl describe pod <name>. Видим ошибку FailedScheduling: 0/3 nodes are available: 3 Insufficient cpu. Причина: Вы смотрите в Grafana и видите, что узлы загружены на 10%. Почему ошибка? Потому что сумма Requests всех Pod'ов на этих узлах достигла предела Allocatable. Планировщику неважно реальное потребление, для него места нет.

Сценарий 2: Постоянные рестарты Pod'а (CrashLoopBackOff). Выполняем kubectl describe pod <name> и ищем секцию State: Terminated. Если там Reason: OOMKilled, значит приложение превысило свой Memory Limit. Решение: Оптимизировать потребление памяти в коде или увеличить Limit.

Сценарий 3: Приложение работает, но отвечает очень медленно. Проверяем потребление: kubectl top pod. Если потребление CPU близко к значению Limit, ваш контейнер подвергается жесткому троттлингу (CPU Throttling). Графики реального потребления могут даже не показывать 100% утилизацию ядра, так как троттлинг происходит микросекундными интервалами.

В следующей главе мы детально разберем, что происходит, когда ресурсов не хватает на уровне всего узла, как работает механизм Eviction, и почему даже Pod с классом Guaranteed иногда может быть убит OOM Killer'ом.

Механизмы вытеснения: Eviction, OOM Killer и приоритеты Pod'ов

Механизмы вытеснения: Eviction, OOM Killer и приоритеты Pod'ов

Контейнер завершается со статусом OOMKilled, но графики мониторинга показывают, что он потреблял всего 200 МБ памяти при установленном Limit в 1 ГБ. Как процесс мог превысить лимит, не достигнув даже четверти разрешенного объема? Ответ кроется в том, что лимит исчерпал не сам контейнер, а физический узел, на котором он работал.

Когда ресурсы узла (память, дисковое пространство, PID) подходят к концу, Kubernetes и ядро операционной системы переходят в режим выживания. Их главная задача — спасти Node от полного зависания, даже если для этого придется пожертвовать пользовательскими приложениями.

Две линии обороны узла

В Kubernetes существует два независимых механизма, которые защищают узел от ресурсного голодания. Они работают на разных уровнях абстракции и с разной скоростью реакций.

  1. Kubelet Eviction (Проактивная защита). Работает в user-space. Kubelet постоянно опрашивает cAdvisor (обычно каждые 10 секунд), оценивая свободную память и диск. Если метрики падают ниже заданных порогов, Kubelet начинает плавно завершать Pod'ы, чтобы высвободить ресурсы.
  2. Kernel OOM Killer (Последний рубеж). Работает в kernel-space. Если память заполняется мгновенно (например, утечка памяти со скоростью 1 ГБ/сек), Kubelet просто не успеет проснуться и отреагировать. В этот момент в дело вступает ядро Linux, которое жестко убивает процессы (SIGKILL), чтобы спасти саму операционную систему.

Kubelet Eviction: Управляемое вытеснение

Kubelet отслеживает состояние узла через механизм Eviction Thresholds (пороги вытеснения). Они делятся на Soft (мягкие) и Hard (жесткие).

  • Soft Thresholds имеют grace period (период отсрочки). Если свободной памяти меньше 1.5 ГБ в течение 60 секунд, Kubelet инициирует вытеснение. Это защищает от кратковременных всплесков потребления.
  • Hard Thresholds действуют мгновенно. Если памяти стало меньше 100 МБ, Kubelet немедленно начинает убивать Pod'ы.

Пример конфигурации Kubelet (часто задается через параметры запуска или ConfigMap):

evictionHard:
  memory.available: "100Mi"
  nodefs.available: "10%"
  nodefs.inodesFree: "5%"
evictionSoft:
  memory.available: "1.5Gi"
evictionSoftGracePeriod:
  memory.available: "1m"

Когда порог пробит, узел переходит в состояние MemoryPressure или DiskPressure. Планировщик (kube-scheduler) мгновенно перестает назначать новые Pod'ы на этот узел.

Далее Kubelet должен выбрать жертву. Процесс выбора строго детерминирован:

  1. Сравнение потребления с Requests. Первыми под удар попадают Pod'ы, которые потребляют больше ресурсов, чем запросили в requests.
  2. Приоритет (PriorityClass). Если есть несколько кандидатов из первого пункта, Kubelet смотрит на их бизнес-ценность. Pod'ы с меньшим приоритетом вытесняются раньше.
  3. QoS-классы. При прочих равных Kubelet убивает Pod'ы в следующем порядке: BestEffort \rightarrow Burstable \rightarrow Guaranteed.

Вытеснение (Eviction) — это грациозный процесс на уровне Kubernetes. Kubelet отправляет контейнерам сигнал SIGTERM, дает им время на завершение (согласно terminationGracePeriodSeconds), обновляет статус Pod'а в API на Failed с причиной Evicted и удаляет локальные данные.

Kernel OOM Killer: Жесткая математика ядра

Если Kubelet не справился или память закончилась слишком быстро, ядро Linux вызывает OOM Killer (Out Of Memory Killer). Ядро ничего не знает о Pod'ах, абстракциях K8s или QoS-классах. Оно видит только процессы (PID) и их метрики.

Чтобы OOM Killer убивал именно те процессы, которые наименее важны для Kubernetes, Kubelet использует механизм ядра oom_score_adj (OOM Score Adjust).

Каждому процессу в Linux ядро присваивает oom_score\text{oom\_score} — число от 0 до 1000. Чем выше балл, тем больше вероятность, что процесс будет убит. Базовый балл зависит от процента потребляемой памяти. Kubelet искусственно сдвигает этот балл, устанавливая значение oom_score_adj\text{oom\_score\_adj} (от -1000 до 1000) в зависимости от QoS-класса Pod'а:

  • Системные демоны (Kubelet, sshd, container runtime): oom_score_adj=999\text{oom\_score\_adj} = -999. Они практически бессмертны.
  • Guaranteed Pods: oom_score_adj=997\text{oom\_score\_adj} = -997. Ядро убьет их только в том случае, если на узле больше не осталось никаких других процессов.
  • BestEffort Pods: oom_score_adj=1000\text{oom\_score\_adj} = 1000. Их итоговый балл всегда максимален, они идут под нож первыми.
  • Burstable Pods: Вычисляется динамически по формуле. Чем меньше памяти запросил контейнер (Request) относительно общей памяти узла, тем выше будет его штраф. Значение варьируется от 2 до 999.

Итоговый балл, который видит ядро, вычисляется так: oom_score=базовый балл потребления+oom_score_adj\text{oom\_score} = \text{базовый балл потребления} + \text{oom\_score\_adj}

Когда ядро убивает процесс, статус Pod'а меняется на OOMKilled. Но важно различать две совершенно разные ситуации, приводящие к этому статусу.

PriorityClasses и Preemption (Вытеснение планировщиком)

QoS-классы защищают технические гарантии (Requests/Limits). Но они ничего не знают о бизнес-логике. Что если у нас есть аналитический скрипт с классом Guaranteed и критически важный микросервис авторизации с классом Burstable? При нехватке ресурсов Kubelet убьет авторизацию, спасая аналитику.

Для решения этой проблемы введены PriorityClasses — глобальные объекты кластера, задающие числовой вес бизнес-ценности.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority-auth
value: 1000000
globalDefault: false
description: "Критичный сервис авторизации"

Этот класс указывается в спецификации Pod'а в поле priorityClassName. Приоритеты меняют правила игры на двух этапах:

  1. Preemption (на уровне kube-scheduler). Если в кластере нет свободного места для запуска нового Pod'а с высоким приоритетом, планировщик найдет узел, выберет на нем запущенные Pod'ы с более низким приоритетом и принудительно удалит их (Preemption), чтобы освободить место для нового.
  2. Eviction (на уровне Kubelet). Как мы рассматривали ранее, при исчерпании ресурсов узла Kubelet использует приоритет как второй критерий выбора жертвы (после проверки превышения Requests).

Как диагностировать причину смерти Pod'а

Если Pod исчез или перезапустился, инженеру необходимо точно определить, на каком уровне сработала защита.

Признаки Kubelet Eviction: Pod не удаляется полностью, он остается в системе в статусе Evicted. Вы можете увидеть причину через описание объекта:

kubectl describe pod <pod-name>

В событиях (Events) будет запись: The node was low on resource: memory. Container was using X, which exceeds its request of Y.

Признаки Node-level OOM Killer: Pod переходит в статус OOMKilled, но метрики контейнера перед смертью показывают, что он не достиг своего лимита. В событиях Kubernetes об этом записей не будет (так как убивало ядро, а не Kubelet). Искать правду нужно в логах ядра на самом Worker Node:

dmesg -T | grep -i oom

Вывод покажет системный лог: Out of memory: Killed process 12345 (java) total-vm:..., anon-rss:... oom_score_adj: 999. Это неопровержимое доказательство того, что сработала защита ядра.

Практикум: Глубокая диагностика и отладка состояний Pod и Node

Практикум: Глубокая диагностика и отладка состояний Pod и Node

Представьте ситуацию: 3 часа ночи, дежурство. Критический сервис недоступен. Вы вводите kubectl get pods — статус Running. Вы пытаетесь проверить сеть внутри Pod'а через kubectl exec -it my-pod -- curl ..., но команда просто зависает. Control Plane говорит, что всё отлично, но приложение мертво, а стандартные инструменты Kubernetes отказываются с вами разговаривать.

В этот момент заканчивается работа пользователя Kubernetes и начинается работа инженера. Когда абстракции ломаются, мы должны спуститься на уровень операционной системы Linux.

В этой главе мы свяжем воедино знания о Namespaces, pause-контейнерах, cgroups и механизмах вытеснения, чтобы научиться диагностировать кластер так, как это делают суровые SRE.

Уровень 1: Проникновение в Namespace в обход Kubernetes API

Если kubectl exec зависает, это означает, что цепочка apiserver → kubelet → CRI (containerd) где-то порвалась. Чаще всего это происходит, когда Worker Node перегружена (высокий CPU load) или сам Container Runtime завис на обработке IO-операций.

Но ядро Linux продолжает работать. И если мы имеем SSH-доступ к Worker Node, мы можем попасть внутрь сетевого пространства Pod'а напрямую, используя утилиту nsenter.

Вспомним из прошлых глав: сетевой Namespace удерживается pause-контейнером (PodSandbox). Наша задача — найти его PID (Process ID) в операционной системе хоста и "войти" в его сеть.

Шаг 1. Находим ID песочницы через crictl Подключаемся к проблемной ноде по SSH и ищем наш Pod:

crictl pods --name my-broken-pod

Команда вернет ID песочницы (например, a1b2c3d4e5f6g).

Шаг 2. Узнаем PID pause-контейнера в ядре Linux Инспектируем песочницу, чтобы вытащить info.pid — это реальный идентификатор процесса pause в дереве процессов Worker Node:

crictl inspectp a1b2c3d4e5f6g | grep pid

Допустим, мы получили PID 14320.

Шаг 3. Входим в Network Namespace Теперь мы используем nsenter (Namespace Enter). Эта утилита позволяет запустить команду в контексте Namespaces другого процесса.

nsenter -t 14320 -n ip addr

Флаг -t указывает целевой PID, а -n означает "войти только в Network Namespace".

Что здесь произошло? Мы запустили утилиту ip addr, которая физически находится на Worker Node, но заставили ядро Linux выполнить её в сетевой изоляции нашего Pod'а. Нам не нужно, чтобы внутри контейнера был установлен curl, ping или iproute2 (которых часто нет в distroless-образах). Мы используем инструменты хоста, глядя на сеть глазами Pod'а.

Уровень 2: Тихий OOM и процессы-зомби

В главе об управлении ресурсами мы разобрали, что превышение Memory Limit приводит к статусу OOMKilled. Но в production часто встречается "тихий OOM".

Симптом: Pod находится в статусе Running, счетчик рестартов равен нулю. Но приложение не обрабатывает запросы.

Причина: Архитектурная ошибка при упаковке контейнера. Часто точкой входа (PID 1 внутри контейнера) делают bash-скрипт entrypoint.sh, который подготавливает конфиги, а затем запускает само приложение (например, Java-процесс) в фоне или как дочерний процесс.

Если Java-процесс превысит лимит памяти, cgroup убьет именно его (как главного потребителя). Но entrypoint.sh (PID 1) продолжит работать! Для kubelet контейнер жив, так как PID 1 жив. Kubernetes ничего не заметит.

Как диагностировать? Идем на Worker Node и смотрим логи ядра (dmesg). Ядро Linux протоколирует каждое срабатывание OOM Killer.

dmesg -T | grep -i oom

Вывод покажет правду:

[Tue Oct 24 03:14:15 2023] Memory cgroup out of memory: Killed process 14500 (java) total-vm:4500000kB, anon-rss:2048000kB...

Мы видим, что сработал Memory cgroup out of memory (превышен K8s Limit, а не физическая память ноды), и убит дочерний процесс java.

Золотое правило SRE: Контейнер должен содержать только один логический процесс. Приложение должно запускаться через exec в bash-скрипте (exec java -jar app.jar), чтобы оно заместило собой PID 1. Тогда при убийстве приложения умрет весь контейнер, kubelet это увидит и перезапустит его.

Уровень 3: Скрытое вытеснение (Disk Pressure)

Мы обсуждали вытеснение (Eviction) из-за CPU и RAM. Но есть третий, самый коварный ресурс — эфемерное хранилище (ephemeral-storage). Это место на диске Worker Node, куда пишутся логи контейнера, слои overlayfs и тома emptyDir.

Если приложение начинает бешено писать временные файлы в emptyDir или просто сыпать гигабайты логов в stdout, место на диске ноды тает.

Kubelet постоянно мониторит файловую систему ноды (nodefs). Как только доступное место падает ниже жесткого порога (по умолчанию Available<10%Available < 10\%), kubelet включает состояние DiskPressure.

Симптом: Pod'ы массово переходят в статус Evicted. Нода может временно получать статус NotReady.

Диагностика:

  1. Проверяем события ноды: kubectl describe node <node-name> | grep -i pressure
  2. Если видим DiskPressure, нужно понять, кто съел диск. Идем на ноду:
journalctl -u kubelet | grep -i "evicting pod"

В логах kubelet будет четко указано, какой Pod превысил использование диска и спровоцировал чистку. Чтобы предотвратить это в будущем, на Pod'ы нужно вешать лимиты не только на CPU/RAM, но и на диск:

resources:
  requests:
    ephemeral-storage: "1Gi"
  limits:
    ephemeral-storage: "2Gi"

При превышении этого лимита kubelet вытеснит только этот конкретный Pod (сработает Pod-level eviction), не доводя всю ноду до состояния DiskPressure.

Четырехслойный фреймворк отладки

Подводя итог нашему погружению в анатомию Pod и Node, зафиксируем алгоритм действий при любой непонятной аварии. Двигайтесь "снаружи внутрь":

  1. Слой API (Kubernetes Control Plane): kubectl describe pod, kubectl get events. Ищем ошибки планировщика (FailedScheduling) или отказы kubelet.
  2. Слой Node (Системные сервисы): systemctl status kubelet, journalctl -u kubelet. Ищем проблемы со связью с apiserver, состояния Pressure или ошибки инициализации сети (CNI).
  3. Слой CRI (Container Runtime): crictl ps, crictl logs, crictl inspectp. Ищем зависшие песочницы, проблемы с пуллингом образов или мертвые pause-контейнеры.
  4. Слой OS (Linux Kernel): dmesg, nsenter, top, df -h. Ищем срабатывания OOM Killer, исчерпание inodes или зависания сетевых интерфейсов.

Понимание того, что Pod — это не магическая коробка, а всего лишь набор Linux Namespaces, объединенных pause-контейнером и ограниченных cgroups, дает вам полный контроль над кластером. Вы больше не зависите от того, сможет ли kubelet ответить на ваш запрос — вы можете спуститься на уровень ядра и найти ответ самостоятельно.