Kubespray: выделенный control plane и два воркера для разгрузки перегруженного узла
Published: 2026-06-23
Одноузловой тестовый кластер устраивает ровно до момента, когда одна машина тянет всё сразу — control plane, etcd и тяжёлую базу вроде ClickHouse — и упирается в CPU. Симптом выглядит как медленные диски; причина обычно в конкуренции за процессор. Лечится это не быстрым диском, а разнесением ролей по VM: выделенный control plane, с которым никто не конкурирует, плюс воркеры под нагрузку. Ниже — как собрать такую связку через Kubespray на Ubuntu 24.04: один узел control-plane+etcd и два воркера, Cilium с заменой kube-proxy — и, что не менее важно, как реально разнести нагрузку после того, как узлы появились.
Сначала диагностика: точно ли диск?
Прежде чем докидывать железо, выясни, что насыщено. На узле, где «тормозят диски», проверь, на чём встают задачи — на I/O или на CPU — ничего не устанавливая:
bashnproc # сколько ядер реально есть
cat /proc/loadavg # load относительно ядер
cat /proc/pressure/cpu /proc/pressure/io # PSI: % времени в простое-ожидании, за 10/60/300с
vmstat 1 3 # 'wa' = iowait%, 'b' = процессы, заблокированные на I/O
Если wa низкий (единицы процентов), b ≈ 0, PSI io низкий, а PSI cpu высокий и load больше числа ядер — диск ни при чём, узкое место в CPU. То, что ощущается как лаги диска, — это очередь за занятыми ядрами: fsync etcd, kubelet, API-сервер, все ждут процессор. Быстрый диск тут не меняет ничего. (Если же wa высокий и процессы висят в D-состоянии — тогда да, это хранилище, но это другая история.)
Лекарство от насыщения CPU — больше ядер и меньше тех, кто за них дерётся. Ровно это и даёт выделенный control plane плюс воркеры.
Связка: один control plane, два воркера
cp1 10.0.0.10 control-plane + etcd (выделенный, под taint)
worker1 10.0.0.11 тяжёлая нагрузка (БД)
worker2 10.0.0.12 всё остальное
Три роли, которые дрались на одном узле, получают место:
- cp1 держит только control plane и единственный член etcd. Остаётся под taint — fsync etcd и API-сервер больше не конкурируют с CPU приложений. Этот узел можно сделать маленьким (control plane для скромного кластера хочет ~2–4 ядра); дай ему быстрый, желательно отдельный диск под
/var/lib/etcd. - worker1 / worker2 несут нагрузку. Их двое — это вдвое больше планируемого CPU и место, чтобы держать главного пожирателя в стороне от остального.
Это намеренно не три control-plane узла. Три дали бы etcd-HA (кворум из 3 переживает один отказ), но вернули бы оверхед control plane на каждый узел — обратное изоляции. На тесте цель — разгрузка, а не HA. Один член etcd нормально; вырасти в настоящий 3-узловой HA-control plane позже — это правка inventory (последний раздел).
Ловушка: ноды сами по себе нагрузку не разносят
Добавить воркеры — само по себе не делает ничего. Планировщик ставит поды по их resource requests; под без requests может сесть куда угодно, и несколько штук свалятся на один воркер. Хуже того — один большой под не делится: один под ClickHouse занимает ядра одного узла, сколько воркеров ни добавь. Разгрузка получится только если:
- Проставить тяжёлым подам реальные
requests/limits, чтобы планировщик мог их разместить и ограничить. - Держать доминирующую нагрузку на отдельном узле, чтобы она не делила ядра с остальным.
Оба шага — ниже, после установки. Пропустишь — просто перенесёшь насыщение на один воркер.
Управляющая машина
Kubespray запускается с управляющей машины (ноутбук или маленькая admin-VM) и пушит по SSH. Ей не обязательно быть одним из узлов. Главная грабля — версия Ansible: Kubespray прибивает конкретный ansible-core, и более новый из дистрибутива падает с непонятными ошибками. Используй зафиксированный стек в venv.
bash# Прибиваемся к тегу релиза — стенд с main не собираем
git clone -b v2.31.0 https://github.com/kubernetes-sigs/kubespray.git
cd kubespray
python3 -m venv .venv && source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt # прибивает ansible==11.13.0 → ansible-core 2.18.x
v2.31.0 (апрель 2026) везёт Kubernetes вплоть до 1.35.4 и Cilium 1.19.3 и поддерживает Ubuntu 24.04 (cgroup v2 — то, что нужно текущему Kubespray). requirements.txt прибивает ansible==11.13.0, то есть ansible-core 2.18.x; метаданные роли объявляют requires_ansible: ">=2.18.0,<2.19.0". Не делай apt install ansible — там 2.19+, и проверка синтаксиса упадёт раньше, чем что-либо развернётся.
Ещё нужен SSH-доступ по ключу ко всем трём узлам и логин с sudo. Если логин в группе sudo, но без NOPASSWD — передавай -K и один раз вводи sudo-пароль (ниже).
Inventory
Копируем sample и правим его — в sample сотни разумных дефолтов, которые стоит сохранить.
bashcp -rfp inventory/sample inventory/mycluster
inventory/mycluster/inventory.ini под раскладку «один control plane, два воркера»:
ini[kube_control_plane]
cp1 ansible_host=10.0.0.10 ip=10.0.0.10 ansible_user=ubuntu etcd_member_name=etcd1
[etcd:children]
kube_control_plane
[kube_node]
worker1 ansible_host=10.0.0.11 ip=10.0.0.11 ansible_user=ubuntu
worker2 ansible_host=10.0.0.12 ip=10.0.0.12 ansible_user=ubuntu
Что означают поля:
ansible_host=— адрес, на который Ansible ходит по SSH.ip=— адрес, на который биндятся kubelet и etcd. Задавай явно; на VM с несколькими NIC автоопределённый IP часто оказывается не тем (интерфейс CNI или моста).ansible_user=— SSH-логин. Можно задать и глобально через--user.[etcd:children] kube_control_planeкладёт etcd на control-plane-узлы. Поскольку в[kube_control_plane]ровно один хост, etcd получает ровно один член — нечётный счёт, как и требует etcd.
Ключевое отличие от раскладки «используем все узлы»: cp1 не указан в [kube_node]. Именно это делает его выделенным control plane — он подключается как control-plane узел (kubelet работает, но висит taint NoSchedule) и никогда не становится целью планирования для нагрузки.
Конфигурация кластера
Решения держат три файла в inventory/mycluster/group_vars/.
k8s_cluster/k8s-cluster.yml
yaml# Прибиваем версию явно. Kubespray вычисляет дефолт динамически,
# поэтому пустое значение означает, что minor может уехать на следующем прогоне.
kube_version: v1.35.4
# Дефолт в sample — calico, переключаем на cilium под остальной парк
kube_network_plugin: cilium
container_manager: containerd
# Забираем admin-kubeconfig (и подходящий kubectl) обратно на управляющую машину
kubeconfig_localhost: true
kubectl_localhost: true
# Добавляем в SAN сертификата apiserver любой адрес, через который будем ходить в API
supplementary_addresses_in_ssl_keys:
- 10.0.0.10
kube_proxy_mode лежит в этом же файле, но становится неважен, как только Cilium заменяет kube-proxy (следующий раздел) — Kubespray вообще не ставит kube-proxy.
k8s_cluster/addons.yml
yamlhelm_enabled: true # дефолт false; удобно на стенде, где будешь helm install
local_path_provisioner_enabled: true # Kubespray по умолчанию НЕ ставит ни одного StorageClass — см. раздел Storage
k8s_cluster/k8s-net-cilium.yml
yamlcilium_version: "1.19.3"
# Cilium балансирует сервисы в eBPF; kube-proxy не ставится вообще
cilium_kube_proxy_replacement: true
# On-prem L2 LoadBalancer (опционально — оставь false, пока не нужны LB-IP)
cilium_l2announcements: false
cilium_loadbalancer_ip_pools: []
Как kube-proxy на самом деле убирается
Когда kube_network_plugin: cilium и cilium_kube_proxy_replacement истинно, Kubespray добавляет addon/kube-proxy в пропускаемые фазы init у kubeadm — kube-proxy не создаётся вовсе. Нужны оба условия: задал только CNI — kube-proxy всё равно появится; задал только флаг Cilium без выбора Cilium — он ничего не делает. В v2.31.0 принимается булево true (старые строки Cilium вроде "strict" в 1.19 в апстриме устарели). kube_proxy_remove: true дополнительно не нужен — это общий запасной вариант для других CNI, здесь он избыточен.
Запуск установки
bashansible-playbook -i inventory/mycluster/inventory.ini \
--become --become-user=root \
--ask-become-pass \
cluster.yml
--become поднимает права до root на хостах; --ask-become-pass (-K) один раз спрашивает sudo-пароль — нужно, когда у логина есть sudo, но без NOPASSWD. Если по SSH тоже пароль, а не ключ, добавь ещё -k.
cluster.yml — это полная сборка: готовит ОС (отключает swap, грузит br_netfilter/overlay, выставляет sysctl), ставит containerd, выполняет kubeadm init на cp1, подключает оба воркера, разворачивает etcd и ставит Cilium. На трёх VM это примерно 20–30 минут. Прогон идемпотентен — повтор сходится, а не пересобирает, поэтому упавший прогон обычно безопасно перезапустить после устранения причины.
Kubespray берёт на себя swap, sysctl, модули ядра, containerd, kubeadm, etcd, PKI и Cilium. На тебе: python3 на каждом узле, SSH-ключ + доступ к sudo, фаервол (Kubespray им не управляет — sudo ufw disable на стенде или открой порты k8s/etcd/Cilium), синхронизация времени (chrony — etcd чувствителен к расхождению), доступ к реестрам образов и уникальные hostname.
Держим control plane выделенным и изолируем тяжёлую нагрузку
Это шаг, который превращает три VM в реальную разгрузку.
Не снимай taint с cp1. Kubespray по умолчанию ставит на control-plane узлы taint node-role.kubernetes.io/control-plane:NoSchedule — здесь это ровно то, что нужно. (На стенде из 2 VM ты бы его снял, чтобы вернуть узел в работу; тут весь смысл — держать нагрузку прочь от control plane.) Проверь, что taint на месте:
bashkubectl get node cp1 -o jsonpath='{.spec.taints}'
# [{"effect":"NoSchedule","key":"node-role.kubernetes.io/control-plane"}]
Прибей тяжёлую нагрузку к одному воркеру. Повесь taint на worker1, чтобы его терпела только БД, и направь её туда:
bashkubectl taint nodes worker1 dedicated=db:NoSchedule
yaml# в pod spec базы
spec:
nodeSelector:
kubernetes.io/hostname: worker1
tolerations:
- key: dedicated
value: db
effect: NoSchedule
containers:
- name: clickhouse
resources:
requests: { cpu: "6", memory: 24Gi } # без requests планировщик не понимает ёмкость
limits: { cpu: "10", memory: 32Gi } # без limits это noisy neighbor
Всё остальное теперь планируется на worker2 (и никогда — на control plane). Для нагрузок с несколькими репликами разноси их anti-affinity вместо прибивания:
yamlaffinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels: { app: my-api }
topologyKey: kubernetes.io/hostname
И проставь остальным шумным подам (сборщики логов, сайдкары) явные CPU requests — поды без них и есть обычная причина, по которой один узел тихо забивается.
Storage в multi-node кластере
Переход на несколько узлов меняет поведение хранилища. Kubespray по умолчанию не ставит ни одного StorageClass (в отличие от k3s) — kubectl get sc пуст, пока не выберешь. Варианты и поведение при переезде пода между узлами:
| Provisioner | Под переехал на другой узел | Цена по CPU | Когда |
|---|---|---|---|
local-path (встроенный addon) |
PV прибит к ноде → под остаётся на своей ноде | ~ноль | тестовые данные, которые не жалко; привязка к ноде ок |
| NFS subdir | данные едут (сетевой том) | низкая | общие файлы / RWX; экспортирующая нода — SPOF |
| Longhorn (Helm, не addon Kubespray) | реплика уже на целевой ноде → едут | высокая (engine + реплика на том, iSCSI) | данные должны пережить падение ноды |
На стенде с дефицитом CPU выбор очевиден: local-path (дешевле всего) или NFS (лёгкий, RWX) против Longhorn — синхронная репликация и per-volume engine у Longhorn добавляют ровно тот CPU, который ты пытаешься освободить. local_path_provisioner_enabled: true (задан выше) создаёт дефолтный StorageClass local-path; его PV прибиты к ноде, так что прибитый под БД и его данные и так остаются вместе на worker1.
Получение kubeconfig
С kubeconfig_localhost: true admin-конфиг оказывается на управляющей машине:
bashexport KUBECONFIG=$PWD/inventory/mycluster/artifacts/admin.conf
kubectl get nodes -o wide
# NAME STATUS ROLES AGE VERSION INTERNAL-IP
# cp1 Ready control-plane 9m v1.35.4 10.0.0.10
# worker1 Ready <none> 7m v1.35.4 10.0.0.11
# worker2 Ready <none> 7m v1.35.4 10.0.0.12
Строка server: в admin.conf указывает на IP cp1. Если ходишь в кластер по другому адресу — NAT, hostname, jump-хост — поправь server: под него и убедись, что этот адрес был в supplementary_addresses_in_ssl_keys, иначе в сертификате apiserver нет под него SAN и TLS отклонит соединение.
Day-2: масштабируем воркеры, растём в HA
Тот же inventory ведёт lifecycle-плейбуки:
bash# Добавить worker3: дописать его в [kube_node], затем
ansible-playbook -i inventory/mycluster/inventory.ini --become -K scale.yml
# Аккуратно drain и удалить узел
ansible-playbook -i inventory/mycluster/inventory.ini --become -K \
-e node=worker2 remove-node.yml
# Плавный апгрейд версии по одному узлу (сперва поднять kube_version)
ansible-playbook -i inventory/mycluster/inventory.ini --become -K upgrade-cluster.yml
# Снести k8s/etcd/CNI со всех хостов до голой ОС — деструктивно
ansible-playbook -i inventory/mycluster/inventory.ini --become -K reset.yml
Когда стенд перерастёт один control plane, расти в HA: добавь ещё два хоста в [kube_control_plane], чтобы у etcd стало три члена (первый счёт, переживающий отказ), и запусти upgrade-cluster.yml, чтобы расширить etcd и control plane. Добавить воркер-ёмкость — это просто scale.yml после дописывания хоста. Inventory и есть кластер — никакой пересборки.
Что может пойти не так
Добавил воркер, но на него ничего не переехало
Поды без resource requests не перебалансируются, а существующие поды сами не мигрируют. Проставь requests тяжёлым подам, размести их anti-affinity или taint'ом ноды и пере-раскатай, чтобы планировщик их переставил. Один большой под всё равно займёт один узел — его не разделить.
Случайно снял taint с control plane
Старая мышечная память от одно-/двухузловых кластеров. Если выполнил kubectl taint nodes cp1 ...-, верни taint, чтобы нагрузка ушла с control plane:
bashkubectl taint nodes cp1 node-role.kubernetes.io/control-plane=:NoSchedule
«ansible-core version is not supported»
Запустил Ansible вне venv (или apt install ansible, а там 2.19+). Пересоздай venv и pip install -r requirements.txt — прибитый ansible==11.13.0 это ansible-core 2.18.x, единственная поддерживаемая линия для v2.31.0.
Сбор фактов падает: /usr/bin/python3 not found
Минимальный или cloud-образ Ubuntu без Python. Kubespray нужен python3 на каждом узле: ssh ubuntu@10.0.0.12 sudo apt-get install -y python3.
Missing sudo password
Логин с sudo, но без NOPASSWD. Добавь -K (--ask-become-pass). Если и SSH по паролю — добавь -k.
etcd не становится здоровым / установка виснет на join etcd
Почти всегда расхождение часов или чётный счёт etcd. Держи ровно один хост в [etcd] и убедись, что chrony работает на всех узлах — etcd отвергает пиров с разошедшимися часами.
Поды висят в Pending, либо kube-proxy всё ещё на месте
bashsudo ufw status # на тестовом стенде должно быть inactive — иначе режет CNI/etcd
kubectl -n kube-system get pods | grep kube-proxy # с заменой Cilium должно быть пусто
grep -r cilium_kube_proxy_replacement inventory/mycluster/group_vars/ # должно быть true
grep -r kube_network_plugin inventory/mycluster/group_vars/ # должно быть cilium
kubectl -n kube-system get pods -l k8s-app=cilium -o wide
Итого
- Симптом был «медленные диски», причина — конкуренция за CPU. Проверяй
wa/PSI до закупки хранилища — насыщенный одиночный узел заставляет всё, включая etcd, ощущаться как лаги диска. - Разнеси роли по VM: выделенный control plane под taint + два воркера. etcd и API-сервер перестают конкурировать с CPU приложений; два воркера удваивают планируемую ёмкость.
- Ноды сами по себе нагрузку не разносят. Проставь
requests/limits, прибей доминирующую нагрузку к своему воркеру и используй anti-affinity для реплицируемых приложений — один большой под не делится. - Cilium с
cilium_kube_proxy_replacement: trueубирает kube-proxy целиком — но только если задан иkube_network_plugin: cilium. - Хранилище выбирай по бюджету CPU:
local-pathили NFS против Longhorn на нагруженном стенде. Kubespray по умолчанию StorageClass не ставит. - Inventory и есть кластер. Добавляй воркеры через
scale.yml; вырасти в 3-узловой HA-control plane позже — это перенос хостов в[kube_control_plane], правка, а не пересборка.