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 занимает ядра одного узла, сколько воркеров ни добавь. Разгрузка получится только если:

  1. Проставить тяжёлым подам реальные requests/limits, чтобы планировщик мог их разместить и ограничить.
  2. Держать доминирующую нагрузку на отдельном узле, чтобы она не делила ядра с остальным.

Оба шага — ниже, после установки. Пропустишь — просто перенесёшь насыщение на один воркер.

Управляющая машина

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], правка, а не пересборка.