Четверг, на часах 11:03. В мониторинге загорелся алерт K8SKubeletTooManyPods. Смотрим ближе: на нескольких узлах-гипервизорах нашего внутреннего облака заканчивается сеть подов. Каждый узел получил /24-подсеть — 254 адреса. Для обычного кластера этого хватит с запасом, но у нас на каждом гипервизоре запущены десятки виртуальных машин, и каждая из них — это под.

Меня зовут Никита Ермилов, я инженер в команде разработки Deckhouse в компании «Флант». В этой статье я расскажу, как мы расширяли подсеть подов в работающем кластере, зачем полезли руками в etcd и что из этого вышло. Материал будет полезен любому, кто столкнулся с нехваткой IP-адресов в Kubernetes — вне зависимости от дистрибутива и CNI.

Для контекста: облако на Kubernetes

Внутри компании у нас работает приватное облако. Архитектурно это обычный Kubernetes-кластер с подключённым хранилищем и модулем виртуализации на базе переработанного KubeVirt. Этот модуль позволяет запускать виртуальные машины и управлять ими так же, как и подами. Каждый сотрудник может поднять себе пару виртуалок или целый Kubernetes-кластер для разработки, тестирования, обучения. Наш кластер состоит из 10 узлов — это почти 2300 ядер, 9,5 ТБ ОЗУ и 50 ТБ пользовательских данных.

Приватное облако построено на базе Deckhouse Kubernetes Platform (DKP), но всё, о чём пойдёт речь ниже, относится к механике ванильного Kubernetes. Если вы используете kubeadm или любой другой дистрибутив, подход тоже применим.

Вот конфигурация сети в kube-controller-manager на момент инцидента:

Параметр

Значение

cluster-cidr

10.112.0.0/16

node-cidr-mask-size

24

CNI

Cilium (IPAM: kubernetes)

  • cluster-cidr— общий пул IP-адресов для всей сети подов кластера;

  • node-cidr-mask-size — маска подсети, которую каждый узел «откусывает» из пула cluster-cidr. Чем меньше это число, тем больше адресов получает узел, но при этом общая вместимость кластера по узлам снижается.

С такими параметрами кластер вмещает до 256 узлов. Каждый узел получает /24-подсеть — 254 адреса для подов. Для 10 узлов-гипервизоров с сотнями виртуальных машин этого оказалось мало — кластер позволял запустить больше ВМ по ресурсам, но мы упёрлись в сеть.

Стандартные решения и почему они не подошли

Сначала мы рассмотрели три классических подхода.

Добавить узел. Самый очевидный путь: больше узлов — больше адресов. Но серверы нашей конфигурации стоят дорого, а мониторинг показывал, что CPU и память утилизированы далеко не полностью. Мы упирались именно в сеть, а не в вычислительные ресурсы. Покупать сервер ради IP-адресов — расточительство.

Расширить пул на уровне CNI. Cilium в режиме ipam: cluster-pool позволяет добавить дополнительную подсеть в clusterPoolIPv4PodCIDRList — и она применится автоматически. Но в нашем кластере IPAM настроен как kubernetes: CNI не управляет подсетями, а получает их от kube-controller-manager. Такой подход даёт гибкость при смене CNI, но лишает возможности «просто добавить пул». Если ваш CNI работает в режиме cluster-pool — вам повезло, и эта статья вам, скорее всего, не нужна. С Calico, Flannel и всеми остальными CNI ситуация аналогичная.

Заново создать кластер. Обычно мы рекомендуем поднять новый кластер с нужной конфигурацией и перенести нагрузку. В нашем случае это было невозможно:

  • поднять второй аналогичный кластер — это удвоить стоимость железа;

  • миграция виртуальных машин и дисков займёт недели;

  • разделить текущий кластер пополам и собрать из половины узлов второй кластер тоже нельзя — нагрузка не поместится на половине узлов.

Итого: стандартные варианты нам не подходили — покупать сервер дорого и нерационально, расширить пул на уровне CNI или пересоздать кластер нельзя. Оставался один путь — изменить cluster-cidr и node-cidr-mask-size прямо в работающем кластере. Но, как выяснилось, просто поправить конфиг контроллера недостаточно.

Почему нельзя просто изменить параметры kube-controller-manager

На первый взгляд всё просто: меняем флаги --cluster-cidr и --node-cidr-mask-size в манифесте kube-controller-manager, перезапускаем — готово. Но есть нюанс: при добавлении узла в кластер kube-controller-manager выделяет ему подсеть из cluster-cidr и записывает её в поле spec.podCIDR объекта Node. Это поле immutable — изменить его через Kubernetes API нельзя. Даже после обновления параметров контроллера существующие узлы останутся со старыми подсетями.

Вот как это работает:

cluster-cidr = 10.112.0.0/16, node-cidr-mask-size = 24
Узел 0 → 10.112.0.0/24   (254 адреса)
Узел 1 → 10.112.1.0/24   (254 адреса)
...
Узел N → 10.112.N.0/24   (254 адреса)

Если мы хотим увеличить количество адресов на узел (например, до 1022 при /22), нужно одновременно изменить и cluster-cidr, и node-cidr-mask-size. Менять только маску опасно: старые /24-подсети могут пересечься с новыми /22-подсетями, и мы получим коллизии IP-адресов.

Вот значения параметров, которых нам хотелось добиться:

Параметр

Текущие значения

Желаемые значения

cluster-cidr

10.112.0.0/16

10.212.0.0/16

node-cidr-mask-size

24

22

При /22-подсети каждый узел получает 1022 адреса — четырёхкратный запас по сравнению с /24-подсетью. А смена самой подсети на 10.212.0.0/16 гарантирует отсутствие пересечений со старой.

Решение: etcdhelper и прямое редактирование etcd

Раз Kubernetes API не позволяет изменить spec.podCIDR, нужно обойти его. Можно сделать это через etcd. API-сервер лишь читает из него и пишет в него, поэтому, если записать/изменить значение напрямую в etcd, API-сервер увидит его как данность — безо всякой валидации.

В этом может помочь утилита etcdhelper — инструмент для чтения и редактирования объектов Kubernetes в etcd в обход API-сервера. В далёком 2020 году мы рассказывали про эту утилиту в отдельной статье на Хабре. Удивительно, но её даже не пришлось дорабатывать.

Важно понимать: прямое редактирование etcd — это крайняя мера. API-сервер не знает об изменениях, валидация не работает, откатить сложно: нет ни истории изменений, ни предыдущей версии объекта — придётся либо повторять операцию вручную в обратную сторону, либо восстанавливать весь etcd из снапшота. Мы использовали этот подход только потому, что штатные варианты в нашей ситуации не подошли.

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

  1. Подготовить кластер: остановить виртуальные машины, сжать до одного master-узла, отключить worker-узлы.

  2. Изменить конфигурацию cluster-cidr и node-cidr-mask-size.

  3. С помощью etcdhelper обновить spec.podCIDR у всех узлов в etcd.

  4. Обновить сетевое состояние на каждом узле.

  5. Вернуть кластер в рабочее состояние.

Разберём ключевые этапы.

Подготовка кластера

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

Остановка виртуальных машин. В нашем случае в кластере работают ВМ на базе модуля виртуализации. До начала работ их следует остановить. Если у вас stateful-нагрузка — аналогично: убедитесь, что данные в безопасности.

Сжатие до одного master-узла. Если кластер multi-master, оставляем один активный master-узел. Это упрощает работу с etcd: не нужно координировать изменения между несколькими членами кластера.

Drain всех worker-узлов. Эвакуируем поды со всех worker-узлов:

kubectl drain <node-name> --delete-emptydir-data --ignore-daemonsets

После drain’а worker-узлы можно остановить.

Изменение подсетей в etcd

Когда кластер подготовлен, применяем изменения. Сначала обновляем конфигурацию kube-controller-manager (в нашем случае — через конфигурацию Deckhouse Kubernetes Platform d8 platform edit cluster-configuration, а в vanilla-кластере это манифест /etc/kubernetes/manifests/kube-controller-manager.yaml):

# Было:
- --cluster-cidr=10.112.0.0/16
- --node-cidr-mask-size=24
# Стало:
- --cluster-cidr=10.212.0.0/16
- --node-cidr-mask-size=22

Затем — ключевой шаг. Запускаем etcdhelper на master-узле:

./etcdhelper \
  -cacert /etc/kubernetes/pki/etcd/ca.crt \
  -cert /etc/kubernetes/pki/etcd/server.crt \
  -key /etc/kubernetes/pki/etcd/server.key \
  -endpoint https://127.0.0.1:2379 \
  change-pod-cidr 10.212.0.0/16

Команда обходит все объекты Node в etcd и обновляет spec.podCIDR на подсети из нового диапазона. На выходе — список изменённых ключей.

Проверяем результат:

kubectl get no -o json | jq '[.items[] | {
  "name": .metadata.name,
  "podCIDR": .spec.podCIDR,
  "podCIDRs": .spec.podCIDRs
}]'

Если podCIDR у узлов обновился — etcd-часть готова. Но node-cidr-mask-size пока старый (/24) — он обновится при переподключении узлов.

Обновление сети на узлах

Это самый деликатный этап.

Важно! Прежде чем проводить работы, которые я описал ниже, нужно обязательно вернуть master-узлы в кластер, чтобы снова получился multi-master (процесс зависит от дистрибутива Kubernetes, вот инструкция для DKP). Необходимо сначала обновить сеть на worker-узлах, а затем уже на оставшемся master-узле — с ним будут нюансы (см. раздел «Грабли, на которые мы наступили»).

Мы протестировали два подхода к обновлению сети.

Быстрый способ: удаление узла из etcd с очисткой состояния CNI.

Суть: останавливаем kubelet, удаляем объект Node из API, чистим локальный кеш CNI, запускаем kubelet — узел регистрируется заново и получает подсеть с обновлённым префиксом.

Для каждого узла последовательно:

# 1. Эвакуация подов.
kubectl drain <node-name> --delete-emptydir-data --ignore-daemonsets
# 2. На узле: остановка kubelet.
systemctl stop kubelet
# 3. На master-узле: удаление объекта Node.
kubectl delete node <node-name>
# 4. На узле: очистка состояния CNI (пример для Cilium).
rm -rf /var/run/cilium/
# 5. Пауза 30–60 секунд.
# 6. На узле: запуск kubelet.
systemctl restart kubelet

После перезапуска kubelet регистрируется в API-сервере как новый узел, kube-controller-manager выделяет ему подсеть из обновлённого cluster-cidr с новой маской, CNI инициализируется с чистым состоянием.

Проверка:

kubectl get no <node-name> -o jsonpath='{.spec.podCIDR}'
# Ожидаем: 10.212.X.0/22

Этот способ быстрый (минуты на узел), но зависит от специфики CNI. В нашем случае с Cilium он сработал. Для других CNI потребуется выяснить, где хранится локальный кеш подсетей, и очистить его.

Надёжный способ: полное пересоздание узла. Узел удаляется из кластера полностью.

Для кластеров на базе Deckhouse Kubernetes Platform подход следующий:

# 1. Эвакуация подов.
kubectl drain <node-name> --delete-emptydir-data --ignore-daemonsets
# 2. Очистка узла.
bash /var/lib/bashible/cleanup_static_node.sh --yes-i-am-sane-and-i-understand-what-i-am-doing

Скрипт удалит всё связанное с DKP (поды, их данные, сам CRI, исполняемые файлы платформы и т. д.), перезапустится, и у нас будет полностью чистый узел.

Система управления узлами (Cluster API Provider Static, он же CAPS) обнаружит его отсутствие и вернёт узел в кластер. Если вы не используете CAPS, верните узел в кластер так же, как добавляли его изначально.

Процесс очистки узла может варьироваться в зависимости от дистрибутива Kubernetes, но суть, как правило, везде одна: нужно зачистить узел и добавить его заново либо, если позволяет инфраструктура (не bare metal), можно заменить серверы на новые.

Плюс такого подхода — полная надёжность: узел возвращается в гарантированно чистом состоянии. Минус — время: если на узлах есть реплицируемое хранилище (LINSTOR, Rook/Ceph), потребуется предварительная эвакуация данных, что может занять часы. Оставлю ссылку для пользователей модуля sds-replicated-storage.

Грабли, на которые мы наступили

Процедура кажется линейной, но на практике мы столкнулись с несколькими проблемами.

Drain занимает очень много времени. На узлах работали виртуальные машины. Живая миграция большого количества ВМ, особенно для машин с большим объёмом памяти, — процесс небыстрый. Вывод: нужно остановить некритичную stateful-нагрузку заранее, чтобы ускорить drain.

Master-узел — отдельная история. При удалении и повторном добавлении master-узла он вылетает из etcd-кластера. Чтобы вернуть его, нужно дополнительно настроить membership в etcd (я опишу эти настройки в бонусном разделе ниже под катом). Поэтому мы сжали кластер до одного master-узла перед началом работ.

Узел вернулся со старым IP. Если после перезапуска kubelet узел получил подсеть из старого диапазона, значит, кеш CNI не был полностью очищен. Нужно повторить очистку и увеличить паузу перед перезапуском kubelet. Для Cilium критично удалить /var/run/cilium/.

Бонус. Возврат master-узла в etcd-кластер

Бонус. Возврат master-узла в etcd-кластер

Если мастер-узел покинул etcd-кластер в ходе работ, простой рестарт kubelet не поможет: etcd зафиксировал, что этого участника больше нет, и не примет его обратно без явной регистрации. Нужно вернуть узел через member add.

Проверяем текущее состояние кластера — смотрим, кто жив и отвечает:

export ETCDCTL_API=3
etcdctl --endpoints="https://127.0.0.1:2379" \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/peer.crt \
  --key=/etc/kubernetes/pki/etcd/peer.key \
  endpoint health --write-out=table

В DKP процесс аналогичный с единственным отличием — к командам etcdctl добавляется префикс kubectl -n kube-system exec -ti $(kubectl -n kube-system get pod -l component=etcd,tier=control-plane -o name | head -n1) --.

Опираясь на вывод команды выше, обозначим переменные для удобства:

KUBE0=<IP лидера etcd>
KUBE1=<IP возвращаемого master-узла>
KUBE2=<IP третьего master-узла>

На возвращаемом узле останавливаем kubelet и очищаем данные старого члена etcd. Без этого etcd откажется стартовать с --initial-cluster-state=existing:

sudo systemctl stop kubelet
sudo rm -rf /var/lib/etcd/member
sudo mkdir -p /var/lib/etcd/member/{wal,snap}
sudo chown -R root:root /var/lib/etcd

На узле-лидере регистрируем возвращаемый master-узел как нового участника:

export ETCDCTL_API=3
etcdctl --endpoints="https://127.0.0.1:2379" \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/peer.crt \
  --key=/etc/kubernetes/pki/etcd/peer.key \
  member add kube-1 \
  --peer-urls="https://$KUBE1:2380"

Команда вернёт строку ETCD_INITIAL_CLUSTER="kube-0=...,kube-1=..." — сохраним её.

На возвращаемом узле обновляем /etc/kubernetes/manifests/etcd.yaml, в секции command прописываем актуальные значения:

--name=kube-1
--initial-advertise-peer-urls=https://$KUBE1:2380
--listen-peer-urls=https://$KUBE1:2380
--advertise-client-urls=https://127.0.0.1:2379,https://$KUBE1:2379
--listen-client-urls=https://127.0.0.1:2379,https://$KUBE1:2379
--initial-cluster=<значение ETCD_INITIAL_CLUSTER из предыдущего шага>
--initial-cluster-state=existing

Флаг --initial-cluster-state=existing сигнализирует etcd, что он присоединяется к работающему кластеру, а не поднимает новый.

Запускаем kubelet — он поднимет etcd через static pod из обновлённого манифеста:

sudo systemctl start kubelet

Проверяем, что все участники вернулись в статус healthy:

export ETCDCTL_API=3
etcdctl --endpoints="https://127.0.0.1:2379" \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/peer.crt \
  --key=/etc/kubernetes/pki/etcd/peer.key \
  endpoint health --write-out=table

Финальная проверка

После обработки всех узлов — чек-лист:

# Все узлы получили новый podCIDR с нужным префиксом:
kubectl get no -o json | jq -r '.items[] | "\(.metadata.name): \(.spec.podCIDR)"'
# Нет подов со старыми адресами:
kubectl get po -A -o wide | grep "10\.112\."
# kube-controller-manager работает с новыми параметрами:
grep -E "(node-cidr-mask-size|cluster-cidr)" /etc/kubernetes/manifests/kube-controller-manager.yaml
# CNI обновил конфигурацию (пример для Cilium):
kubectl -n d8-cni-cilium get cm cilium-config -o yaml | grep native-routing-cidr

Ожидаемый результат для нашего случая:

etcdhelper-node-0: 10.212.0.0/22
etcdhelper-node-1: 10.212.4.0/22
etcdhelper-node-2: 10.212.8.0/22
etcdhelper-node-3: 10.212.12.0/22

Каждый узел теперь имеет 1022 адреса вместо 254.

Выводы

Смена подсети подов в работающем Kubernetes-кластере — операция нештатная, но выполнимая, если помнить о главном:

  • Меняйте cluster-cidr и node-cidr-mask-size одновременно. Смена только маски при той же подсети приведёт к коллизиям.

  • spec.podCIDR — immutable. Единственный способ обновить его на существующих узлах — прямое редактирование etcd.

  • Тестируйте на стенде. Мы прогнали всю процедуру на тестовом кластере, прежде чем трогать рабочее окружение. Это обязательный шаг.

  • Готовьтесь к тому, что это займёт время. Даже с быстрым способом обновления узлов нужно закладывать техническое окно. В нашем случае потребовалось четыре часа.

  • Подход не зависит от дистрибутива Kubernetes. Etcd, kube-controller-manager и spec.podCIDR — это vanilla Kubernetes. Инструкция применима к любому кластеру, где IPAM работает на стороне Kubernetes, а не CNI.

  • Живая миграция ВМ требует хорошего сетевого канала. Это следует учитывать при проектировании архитектуры. Это особенно важно для тяжёлых нагрузок, активно работающих с памятью.

Если у вас ещё есть возможность выбрать IPAM-стратегию, подумайте о cluster-pool в Cilium или аналогичных режимах в вашем CNI. Это позволит расширять пул адресов без приключений с etcd.

P.S.

Читайте также в нашем блоге:

Комментарии (5)


  1. vanyas
    16.06.2026 08:44

    Но зачем ноды называть узлами? Это же устоявшаяся терминология куба

    Ну и раз уж ноды дрейнятся, то вместо редактирования etcd, можно было просто удалять ноду и заводить заново и ей бы автоматам уже выделилась новая правильная сеть


    1. anna_lesnykh
      16.06.2026 08:44

      Но зачем ноды называть узлами? Это же устоявшаяся терминология куба

      Привет! Отвечу тут за автора, потому что это скорее редакторская часть. Мы всегда пишем в своих статьях и новостях «узлы»: во-первых, хочется по возможности говорить по-русски, во-вторых, это первый перевод, рекомендованный глоссарием CNCF. И наш опрос людей показал, что все понимают, что имеется в виду.


  1. leshoi
    16.06.2026 08:44

    Сами себе создали трудности: привязали IPAM к kube-controller-manager вместо отдельного манифеста (cluster-pool), а затем решили проблему через прямую правку etcd.

    Тут возникает несколько вопросов:

    1. Вы часто меняете CNI в кластерах, раз вам требуется привязка к immutable-полю, которое невозможно изменить просто так?

    2. В чем сложность при переезде с одного CNI на другой просто создать новый манифест с cluster-pool, а не заниматься подобными сомнительными операциями?

    3. Данная практика считается устаревшей: большинство CNI уже давно по умолчанию не ориентируются на эти поля, а задают собственные манифесты IPAM для простоты управления.


  1. slonopotamus
    16.06.2026 08:44

    Вот бы кто-нибудь придумал замену для IPv4, чтобы каждому компьютеру было доступно настолько много IP-адресов, что их вообще нереально все потратить…


    1. CherryPah
      16.06.2026 08:44

      У них проблема не в количестве была, а в нарезке неправильной. Так можно и в v6 с маской накосячить