Четверг, на часах 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 из снапшота. Мы использовали этот подход только потому, что штатные варианты в нашей ситуации не подошли.
Общий план действий такой:
Подготовить кластер: остановить виртуальные машины, сжать до одного master-узла, отключить worker-узлы.
Изменить конфигурацию
cluster-cidrиnode-cidr-mask-size.С помощью etcdhelper обновить
spec.podCIDRу всех узлов в etcd.Обновить сетевое состояние на каждом узле.
Вернуть кластер в рабочее состояние.
Разберём ключевые этапы.
Подготовка кластера
Прежде чем начинать работу с 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)

leshoi
16.06.2026 08:44Сами себе создали трудности: привязали IPAM к
kube-controller-managerвместо отдельного манифеста (cluster-pool), а затем решили проблему через прямую правку etcd.Тут возникает несколько вопросов:
Вы часто меняете CNI в кластерах, раз вам требуется привязка к immutable-полю, которое невозможно изменить просто так?
В чем сложность при переезде с одного CNI на другой просто создать новый манифест с
cluster-pool, а не заниматься подобными сомнительными операциями?Данная практика считается устаревшей: большинство CNI уже давно по умолчанию не ориентируются на эти поля, а задают собственные манифесты IPAM для простоты управления.

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

CherryPah
16.06.2026 08:44У них проблема не в количестве была, а в нарезке неправильной. Так можно и в v6 с маской накосячить
vanyas
Но зачем ноды называть узлами? Это же устоявшаяся терминология куба
Ну и раз уж ноды дрейнятся, то вместо редактирования etcd, можно было просто удалять ноду и заводить заново и ей бы автоматам уже выделилась новая правильная сеть
anna_lesnykh
Привет! Отвечу тут за автора, потому что это скорее редакторская часть. Мы всегда пишем в своих статьях и новостях «узлы»: во-первых, хочется по возможности говорить по-русски, во-вторых, это первый перевод, рекомендованный глоссарием CNCF. И наш опрос людей показал, что все понимают, что имеется в виду.