Kubernetes для самых маленьких. Часть 4: Ставим кластер на kubeadm
Все части серии «Kubernetes для самых маленьких»В первой части мы разобрали, зачем нужен оркестратор, во второй — из каких компонентов состоит кластер, в третьей — как устроен его API. Теперь соберём учебный кластер, на котором будет работать вся серия: три виртуальные машины Ubuntu Server 26.04, Kubernetes 1.36, containerd, kube-proxy в режиме nftables и Calico. Ставить будем kubeadm — официальным инструментом проекта, который делает ровно одно: превращает подготовленные машины в кластер по лучшим практикам, не пряча при этом ни одного компонента. Каждую команду разберём: что она меняет на узле и в API.
Кластер будет с одним мастером. Отказоустойчивый control plane из трёх мастеров с общим адресом API разобран в серии «Отказоустойчивый Kubernetes на BGP»; здесь мы только заложим возможность перейти к нему позже.
Стенд
| Узел | Адрес | Роль | Ресурсы |
|---|---|---|---|
k8s-master-1.example.com |
192.168.10.17 | control plane | 2 vCPU, 4 ГБ, 32 ГБ |
k8s-node-1.example.com |
192.168.10.20 | рабочий узел | 2 vCPU, 4 ГБ, 32 ГБ |
k8s-node-2.example.com |
192.168.10.21 | рабочий узел | 2 vCPU, 4 ГБ, 32 ГБ |
| Сеть | Значение |
|---|---|
| сеть узлов | 192.168.10.0/24, шлюз 192.168.10.1 (router.example.com) |
| сеть подов | 172.20.0.0/16 (раздаёт Calico блоками по /26 на узел) |
| сеть сервисов | 10.96.0.0/12, адрес API внутри кластера 10.96.0.1, DNS 10.96.0.10 |
| адрес API | k8s-api.example.com:6443 → 192.168.10.17 |
Минимум kubeadm — 2 ГБ памяти на машину и 2 процессора на мастере; с 4 ГБ на узлах хватит места и для учебных приложений, и для мониторинга из части 32. Сети подов и сервисов не должны пересекаться ни между собой, ни с сетью узлов, ни с сетями, куда поды будут ходить: иначе пакет к внешнему адресу уйдёт в под.
Отдельное имя k8s-api.example.com для API — самое важное решение этой части. Сейчас это A-запись в DNS, указывающая на единственный мастер. Все сертификаты, kubeconfig и узлы будут знать API под этим именем, и когда понадобится второй и третий мастер, достаточно перенаправить имя на виртуальный адрес балансировщика — например, kube-vip, как в серии про BGP. Если же поставить кластер на адрес мастера, kubeadm превратить его в отказоустойчивый не сможет: адрес вшит в сертификаты и конфигурации всех узлов, и кластер придётся собирать заново.
В DNS нужны записи всех трёх узлов и k8s-api.example.com. Без DNS сойдёт /etc/hosts на каждом узле и на рабочей станции, но это ещё одно место, которое придётся поддерживать.
Подготовка узлов
Всё в этом разделе выполняется на всех трёх машинах. Ubuntu установлена в минимальном серверном варианте с OpenSSH, адрес узла не меняется: он задан статически в netplan (системе описания сетевых настроек Ubuntu в YAML) или закреплён за машиной в DHCP.
Имена и уникальность
user@k8s-node-1:~$ hostnamectl --static
k8s-node-1
user@k8s-node-1:~$ ip -4 -br addr show ens18; sudo cat /sys/class/dmi/id/product_uuid; cat /etc/machine-id
ens18 UP 192.168.10.20/24 metric 100
aea5dde7-527d-4a66-bea4-feb9594806aa
07300e61e2dd497d9a97580fba176afb
Имя узла в кластере по умолчанию берётся из имени хоста, поэтому оно должно быть уникальным и стабильным. Установщик Ubuntu спрашивает имя при установке; изменить его позже можно командой sudo hostnamectl set-hostname k8s-node-1. kubeadm проверяет и уникальность product_uuid (идентификатора, который гипервизор отдаёт виртуальной машине): у клонов виртуальной машины он иногда совпадает. У клонов также стоит проверить /etc/machine-id — идентификатор, из которого systemd строит, среди прочего, DUID для DHCP; одинаковый machine-id на двух узлах — источник трудноуловимых проблем.
Имя API
Все узлы должны находить k8s-api.example.com. Если записи в DNS ещё нет, сойдёт строка в /etc/hosts на каждом узле:
user@k8s-node-1:~$ echo '192.168.10.17 k8s-api.example.com' | sudo tee -a /etc/hosts
192.168.10.17 k8s-api.example.com
user@k8s-node-1:~$ getent hosts k8s-api.example.com
192.168.10.17 k8s-api.example.com
getent hosts проверяет разрешение имени тем путём, каким его ищут программы (через /etc/hosts и DNS), поэтому показывает именно то, что увидит kubeadm.
swap
user@k8s-node-1:~$ sudo swapoff -a
user@k8s-node-1:~$ sudo sed -i '/\sswap\s/ s/^/#/' /etc/fstab
user@k8s-node-1:~$ sudo rm -f /swap.img
user@k8s-node-1:~$ swapon --show; free -h | grep -i swap
Swap: 0B 0B 0B
Установщик Ubuntu Server создаёт файл подкачки /swap.img и строку для него в /etc/fstab. kubelet по умолчанию отказывается запускаться, если видит swap (failSwapOn: true): в его журнале появляется running with swap on is not supported, please disable swap or set --fail-swap-on flag to false, и kubeadm init или join ждёт здоровый kubelet до четырёх минут и завершается ошибкой. Предупреждение при проверках kubeadm 1.36 только мягкое — [WARNING Swap]: swap is supported for cgroup v2 only. The kubelet must be properly configured to use swap… — и остановить установку заранее оно не заставит. Поддержка swap в Kubernetes есть: kubelet можно разрешить работать с ним, а подам — пользоваться им в режиме LimitedSwap. Но для учебного и большинства рабочих кластеров swap выключают: планировщик и лимиты памяти (часть 10) считают только физическую память, а под, ушедший в swap, вместо быстрого и понятного OOMKilled (принудительного завершения процесса при превышении лимита памяти) медленно деградирует вместе со всем узлом.
Модули ядра и sysctl
user@k8s-node-1:~$ cat <<'EOF' | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
overlay
br_netfilter
user@k8s-node-1:~$ sudo modprobe overlay
user@k8s-node-1:~$ sudo modprobe br_netfilter
user@k8s-node-1:~$ cat <<'EOF' | sudo tee /etc/sysctl.d/99-kubernetes.conf
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
user@k8s-node-1:~$ sudo sysctl --system | grep -E 'ip_forward|bridge-nf'
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
(Команда tee печатает записанное в файл, поэтому каждый блок повторяется: сначала содержимое файла, потом применённые значения.)
overlay— модуль файловой системы overlay, на которой containerd собирает корень контейнера из слоёв образа (снапшоттерoverlayfs).br_netfilter— пропускает кадры, которые идут через Linux-мост (bridge), через хуки netfilter; переменныеnet.bridge.bridge-nf-call-*появляются только после его загрузки. Это нужно сетевым плагинам, которые соединяют поды мостом (простейший плагинbridge, kind — кластер в контейнерах Docker, ему посвящена бонусная часть в конце серии): без модуля трафик между подами одного узла обходит правила kube-proxy. Calico строит сеть без моста — маршрутами через veth, — так что ему модуль не обязателен, но включённым он ничего не ломает, а смена плагина или эксперименты не превратятся в поиск исчезающих пакетов.net.ipv4.ip_forward = 1— маршрутизация между интерфейсами. Узел пересылает пакеты между подами, между подом и сетью узлов; без этого параметра сеть подов не работает вовсе. Это единственная настройка, которую официальная документация называет обязательной.
Время
Сертификаты, токены сервисных учётных записей, аренды узлов и лидеров — всё в кластере опирается на время. Узел с часами, ушедшими на пару минут, отклоняет «ещё не действующие» сертификаты и выглядит для control plane мёртвым. В Ubuntu 26.04 время синхронизирует chrony, и уже установленная система настроена правильно:
user@k8s-node-1:~$ sudo timedatectl set-timezone Europe/Minsk
user@k8s-node-1:~$ timedatectl | grep -E 'Time zone|synchronized'
Time zone: Europe/Minsk (+03, +0300)
System clock synchronized: yes
user@k8s-node-1:~$ chronyc tracking | grep -E 'Reference|System time'
Reference ID : B97DBE7B (ntp-nts-3.ps5.canonical.com)
System time : 0.000031123 seconds slow of NTP time
user@k8s-node-1:~$ chronyc sources
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^+ ntp-nts-2.ps5.canonical.> 2 6 377 98 +47us[+2951ns] +/- 18ms
^* ntp-nts-3.ps5.canonical.> 2 6 377 33 +49us[ +69us] +/- 17ms
^- ntp-nts-2.ps6.canonical.> 2 6 377 33 +8835us[+8835us] +/- 85ms
^- ntp-nts-3.ps6.canonical.> 2 6 377 31 -2591us[-2591us] +/- 86ms
Часовой пояс влияет только на то, как время выглядит в date и в журналах: сертификаты и объекты Kubernetes хранят время в UTC. Источники времени chrony берёт из каталога /etc/chrony/sources.d: по умолчанию там лежит файл ubuntu-ntp-pools.sources с серверами Canonical, и они подключены с NTS (Network Time Security, RFC 8915) — расширением NTP, которое по TLS обменивается с сервером ключами и затем подтверждает подлинность каждого ответа, так что подставной «сервер времени» в сети не сдвинет часы узла. В выводе chronyc sources звёздочка ^* — источник, с которым узел сейчас синхронизирован, ^+ — годные запасные, ^- — исправные, но не выбранные.
В закрытой сети узлам удобнее синхронизироваться с одним локальным сервером, чем каждому ходить в интернет: добавьте файл /etc/chrony/sources.d/local.sources со строкой server router.example.com iburst и выполните sudo chronyc reload sources.
Файрвол
На учебном стенде ufw выключен (так он поставляется в Ubuntu Server: sudo ufw status отвечает Status: inactive), а сеть узлов закрыта от внешнего мира роутером. В рабочем кластере фильтрацию на узлах включают, и порты нужны такие:
| Узлы | Порт | Откуда | Зачем |
|---|---|---|---|
| мастер | 6443/TCP | все узлы, администраторы, клиенты API | kube-apiserver |
| мастер | 2379–2380/TCP | другие мастера | etcd |
| все | 10250/TCP | мастера, сборщики метрик | API kubelet |
| все | 10256/TCP | внешние балансировщики | проверка здоровья kube-proxy |
| рабочие | 30000–32767/TCP и UDP | клиенты | сервисы NodePort |
| все | 179/TCP | другие узлы | BGP между узлами в Calico |
| все | 4789/UDP | другие узлы | VXLAN Calico (между подсетями) |
| узлы с Typha | 5473/TCP | агенты Calico на других узлах | Calico Typha — посредник между API и агентами Calico на узлах |
Порты 10257 и 10259 открывать не нужно: kubeadm привязывает менеджер контроллеров и планировщик к 127.0.0.1. Правила файрвола на узлах кластера стоит согласовывать с сетевыми политиками Calico, которые умеют защищать и сами узлы (часть 20).
containerd
user@k8s-node-1:~$ sudo apt-get update
user@k8s-node-1:~$ sudo apt-get install -y containerd runc
user@k8s-node-1:~$ containerd --version
containerd github.com/containerd/containerd/v2 2.2.2
Пакет containerd из репозитория Ubuntu — это containerd 2.2 с обновлениями безопасности от Canonical. Альтернатива — пакет containerd.io из репозитория Docker; он новее, но обновляется отдельно от системы. Для кластера важнее предсказуемость, поэтому берём пакет дистрибутива.
Сгенерируем полную конфигурацию по умолчанию и поправим в ней два параметра:
user@k8s-node-1:~$ sudo mkdir -p /etc/containerd
user@k8s-node-1:~$ containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
user@k8s-node-1:~$ sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
user@k8s-node-1:~$ sudo sed -i "s|sandbox = 'registry.k8s.io/pause:.*'|sandbox = 'registry.k8s.io/pause:3.10.2'|" /etc/containerd/config.toml
user@k8s-node-1:~$ sudo systemctl restart containerd
user@k8s-node-1:~$ sudo containerd config dump | grep -E "^version|SystemdCgroup|sandbox = "
version = 3
sandbox = 'registry.k8s.io/pause:3.10.2'
SystemdCgroup = true
version = 3 — формат конфигурации containerd 2.x. Пути к настройкам CRI в нём другие, чем в 1.x: plugins.'io.containerd.cri.v1.runtime' вместо plugins."io.containerd.grpc.v1.cri", поэтому рецепты для старых версий молча не работают — параметр с неверным путём просто игнорируется. Проверять итог нужно командой containerd config dump, которая показывает действующую конфигурацию. Если строки SystemdCgroup в сгенерированном файле не оказалось (шаблон менялся между версиями), её добавляют в таблицу [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options].
SystemdCgroup = true — runc создаёт cgroup контейнеров через systemd, а не напрямую в /sys/fs/cgroup (драйвер cgroupfs). На машине с systemd и cgroup v2 (а в Ubuntu 26.04 есть только v2) должен быть один управляющий cgroup — systemd; два менеджера с разными представлениями об иерархии приводят к нестабильности узла под нагрузкой. kubelet должен использовать тот же драйвер; современные версии kubelet узнают его у containerd сами через CRI (вызов RuntimeConfig), а kubeadm и так по умолчанию пишет cgroupDriver: systemd.
sandbox — образ pause-контейнера. kubeadm 1.36 скачивает registry.k8s.io/pause:3.10.2, а containerd по умолчанию создаёт поды из 3.10.1. Без правки на узле оказались бы два образа pause, а первому поду пришлось бы ждать, пока containerd скачает тот, что указан в его настройках: kubeadm 1.36 на расхождение не жалуется, поэтому о нём легко не узнать. «Закреплённый» (pinned) образ containerd не удаляет при очистке неиспользуемых образов.
kubeadm, kubelet и kubectl
Пакеты Kubernetes лежат в репозитории проекта pkgs.k8s.io, у каждой минорной версии — свой репозиторий. Старые apt.kubernetes.io заморожены с 2023 года.
user@k8s-node-1:~$ sudo apt-get install -y ca-certificates curl gpg
user@k8s-node-1:~$ curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
user@k8s-node-1:~$ echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /' \
| sudo tee /etc/apt/sources.list.d/kubernetes.list
deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /
user@k8s-node-1:~$ sudo apt-get update
user@k8s-node-1:~$ sudo apt-get install -y kubelet kubeadm kubectl cri-tools
user@k8s-node-1:~$ sudo apt-mark hold kubelet kubeadm kubectl cri-tools
kubelet set on hold.
kubeadm set on hold.
kubectl set on hold.
cri-tools set on hold.
user@k8s-node-1:~$ sudo systemctl enable --now kubelet
user@k8s-node-1:~$ kubeadm version -o short
v1.36.5
signed-by привязывает ключ к одному репозиторию: этим ключом нельзя подписать пакеты, которые придут из других источников. apt-mark hold запрещает обновлять пакеты при обычном apt upgrade: версии компонентов кластера связаны допустимым перекосом (kubelet не может быть новее kube-apiserver), и обновление идёт в строгом порядке через kubeadm upgrade — об этом часть 35. Пакет cri-tools с утилитой crictl — отдельный: kubeadm 1.36 его в зависимости не берёт (там только iptables, kubernetes-cni, mount и util-linux), а crictl понадобится для диагностики прямо на узле, поэтому ставим его в той же команде. Чтобы crictl знал, куда подключаться, и не перебирал устаревшие сокеты, укажем сокет containerd:
user@k8s-node-1:~$ cat <<'EOF' | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
EOF
user@k8s-node-1:~$ sudo crictl version | grep -E 'RuntimeName|RuntimeVersion'
RuntimeName: containerd
RuntimeVersion: 2.2.2
kubelet после установки перезапускается каждые несколько секунд — это нормально:
user@k8s-node-1:~$ systemctl status kubelet --no-pager | grep Active
Active: activating (auto-restart) (Result: exit-code) since Sat 2026-10-24 13:29:38 +03; 3s ago
user@k8s-node-1:~$ journalctl -u kubelet -n 3 --no-pager | grep -o 'failed to load kubelet config file.*'
failed to load kubelet config file, path: /var/lib/kubelet/config.yaml, error: failed to load Kubelet config file /var/lib/kubelet/config.yaml, error failed to read kubelet config file "/var/lib/kubelet/config.yaml", error: open /var/lib/kubelet/config.yaml: no such file or directory"
Его конфигурацию напишет kubeadm при init или join.
Конфигурация кластера
Параметры кластера можно передать kubeadm init ключами командной строки, но правильнее — файлом: он лежит в Git, его можно проверить и повторить, а ключами не задать параметры kubelet и kube-proxy. На мастере создаём kubeadm-config.yaml:
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.10.17
bindPort: 6443
nodeRegistration:
name: k8s-master-1
criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.5
controlPlaneEndpoint: k8s-api.example.com:6443
networking:
podSubnet: 172.20.0.0/16
serviceSubnet: 10.96.0.0/12
dnsDomain: cluster.local
apiServer:
certSANs:
- k8s-api.example.com
- k8s-master-1.example.com
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
serverTLSBootstrap: true
---
apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: KubeProxyConfiguration
mode: nftables
Четыре документа — четыре объекта конфигурации:
InitConfiguration— то, что относится только к этому запуску и этому узлу: на каком адресе слушает kube-apiserver мастера, как называется узел, где сокет CRI. Сюда же можно положить bootstrap-токен; если не положить, kubeadm сгенерирует его сам.ClusterConfiguration— параметры всего кластера; kubeadm сохранит их в ConfigMapkubeadm-configи будет читать приjoinиupgrade.controlPlaneEndpoint— то самое общее имя API.podSubnet— сеть подов: Calico раздаёт адреса своим IPAM (модулем распределения адресов) и выделенными узлам диапазонамиpodCIDRне пользуется, но значение всё равно нужно — kube-proxy по нему отличает трафик подов, а оператор Calico сверяет с ним свой пул.serviceSubnet— диапазон виртуальных адресов сервисов; первый адрес,10.96.0.1, получит сервисkubernetes— адрес API изнутри кластера.certSANs— дополнительные имена в сертификате kube-apiserver (имяcontrolPlaneEndpointkubeadm добавит и сам, но явное перечисление удобно читать).KubeletConfiguration— общая конфигурация kubelet для всех узлов; kubeadm положит её в ConfigMapkubelet-config, и каждый узел при подключении получит её оттуда.serverTLSBootstrap: true— kubelet запросит сертификат для своего HTTPS-порта 10250 у CA кластера, а не сгенерирует самоподписанный. Зачем — разберём ниже.KubeProxyConfiguration—mode: nftables: kube-proxy пишет правила сервисов в nftables. Этот режим стабилен с версии 1.33 и масштабируется лучше iptables; режим по умолчанию в 1.36 остаётсяiptables, поэтому его задают явно. Подробно — в части 15.
apiVersion у них разные, потому что это разные API: формат конфигурации kubeadm сейчас v1beta4, kubelet — v1beta1, kube-proxy — v1alpha1 (несмотря на «alpha», это давно рабочий формат конфигурации, а не экспериментальная функция).
Проверим файл и заранее скачаем образы — так init не упрётся в медленную сеть:
user@k8s-master-1:~$ sudo kubeadm config validate --config kubeadm-config.yaml
ok
user@k8s-master-1:~$ sudo kubeadm config images pull --config kubeadm-config.yaml
[config/images] Pulled registry.k8s.io/kube-apiserver:v1.36.5
[config/images] Pulled registry.k8s.io/kube-controller-manager:v1.36.5
[config/images] Pulled registry.k8s.io/kube-scheduler:v1.36.5
[config/images] Pulled registry.k8s.io/kube-proxy:v1.36.5
[config/images] Pulled registry.k8s.io/coredns/coredns:v1.14.2
[config/images] Pulled registry.k8s.io/pause:3.10.2
[config/images] Pulled registry.k8s.io/etcd:3.6.8-0
kubeadm init и его фазы
user@k8s-master-1:~$ sudo kubeadm init --config kubeadm-config.yaml
[init] Using Kubernetes version: v1.36.5
[preflight] Running pre-flight checks
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [k8s-api.example.com k8s-master-1 k8s-master-1.example.com kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local] and IPs [10.96.0.1 192.168.10.17]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [k8s-master-1 localhost] and IPs [192.168.10.17 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [k8s-master-1 localhost] and IPs [192.168.10.17 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "super-admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/instance-config.yaml"
[patches] Applied patch of type "application/strategic-merge-patch+json" to target "kubeletconfiguration"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests"
[kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s
[kubelet-check] The kubelet is healthy after 746.117µs
[control-plane-check] Waiting for healthy control plane components. This can take up to 4m0s
[control-plane-check] Checking kube-apiserver at https://192.168.10.17:6443/livez
[control-plane-check] Checking kube-controller-manager at https://127.0.0.1:10257/healthz
[control-plane-check] Checking kube-scheduler at https://127.0.0.1:10259/livez
[control-plane-check] kube-controller-manager is healthy after 4.779062ms
[control-plane-check] kube-scheduler is healthy after 5.14416ms
[control-plane-check] kube-apiserver is healthy after 16.501302316s
[upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace
[kubelet] Creating a ConfigMap "kubelet-config" in namespace kube-system with the configuration for the kubelets in the cluster
[upload-certs] Skipping phase. Please see --upload-certs
[mark-control-plane] Marking the node k8s-master-1 as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]
[mark-control-plane] Marking the node k8s-master-1 as control-plane by adding the taints [node-role.kubernetes.io/control-plane:NoSchedule]
[bootstrap-token] Using token: 7k2m9x.q4w8e1r5t6y3u0i2
[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[bootstrap-token] Configured RBAC rules to allow the API server kubelet client certificate to access the kubelet API
[bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace
[kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy
Your Kubernetes control-plane has initialized successfully!
To start using your cluster, you need to run the following as a regular user:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
...
Then you can join any number of worker nodes by running the following on each as root:
kubeadm join k8s-api.example.com:6443 --token 7k2m9x.q4w8e1r5t6y3u0i2 \
--discovery-token-ca-cert-hash sha256:3f9a5c2e8b1d4f7a0c6e9b2d5f8a1c4e7b0d3f6a9c2e5b8d1f4a7c0e3b6d9f2a
Меньше минуты — и на мастере работает control plane (kube-apiserver отвечает через шестнадцать секунд после запуска kubelet). Каждая строка вывода принадлежит одной из фаз; любую фазу можно выполнить отдельно (kubeadm init phase <имя>) или пропустить (--skip-phases), и знание фаз — это знание того, где у кластера что лежит.
preflight — проверки: версия ядра, свободные порты 6443, 2379, 2380, 10250, 10257, 10259, доступность сокета CRI, пустой каталог /etc/kubernetes/manifests, включённая маршрутизация (net.ipv4.ip_forward). Включённый swap kubeadm 1.36 только отмечает предупреждением ([WARNING Swap]), а отсутствие маршрутизации — ошибка. Ошибки останавливают установку, предупреждения — нет; пропускать проверки ключом --ignore-preflight-errors стоит только понимая, что именно пропускаешь.
certs — инфраструктура открытых ключей кластера в /etc/kubernetes/pki. Три независимых центра сертификации, каждый со своей зоной ответственности:
| CA | Что подписывает |
|---|---|
ca |
сертификат kube-apiserver, клиентские сертификаты компонентов, администраторов и узлов; его открытый ключ — корень доверия всего кластера |
etcd/ca |
сертификаты etcd (сервер, пиры) и клиентов etcd — kube-apiserver и проверки здоровья. Отдельный CA означает, что сертификат, выданный общим CA, к etcd не пустят |
front-proxy-ca |
сертификат, которым kube-apiserver представляется агрегированным API-серверам (вроде metrics-server), передавая им имя пользователя в заголовках |
Плюс пара ключей sa.key/sa.pub — не сертификат, а ключ подписи токенов сервисных учётных записей. Сертификат kube-apiserver содержит все имена, по которым к нему обращаются: k8s-api.example.com, имя мастера, имена сервиса kubernetes внутри кластера и адрес 10.96.0.1. Сертификаты компонентов действуют год, CA — десять лет.
kubeconfig — файлы подключения к API в /etc/kubernetes, у каждого компонента своя личность:
| Файл | Кто | Личность в сертификате |
|---|---|---|
admin.conf |
администратор, kubeadm | CN=kubernetes-admin, O=kubeadm:cluster-admins — группа, привязанная к роли cluster-admin через RBAC |
super-admin.conf |
аварийный доступ | CN=kubernetes-super-admin, O=system:masters — группа, которая обходит RBAC целиком |
controller-manager.conf |
kube-controller-manager | CN=system:kube-controller-manager |
scheduler.conf |
kube-scheduler | CN=system:kube-scheduler |
kubelet.conf |
kubelet мастера | CN=system:node:k8s-master-1, O=system:nodes |
Разница между двумя администраторскими файлами принципиальна. Права admin.conf даны через RBAC, их можно отозвать, удалив привязку роли. Права super-admin.conf отозвать нельзя ничем, кроме смены CA: группа system:masters проверяется до RBAC. Поэтому super-admin.conf после установки уносят с мастера в надёжное место (сейф паролей, зашифрованный архив) и пользуются им, только если RBAC сломан.
etcd и control-plane — манифесты статических подов в /etc/kubernetes/manifests: etcd.yaml, kube-apiserver.yaml, kube-controller-manager.yaml, kube-scheduler.yaml. В них — все флаги компонентов, собранные из ClusterConfiguration: например, kube-apiserver получает --authorization-mode=Node,RBAC, --enable-admission-plugins=NodeRestriction, --service-cluster-ip-range=10.96.0.0/12, а kube-controller-manager — --cluster-cidr=172.20.0.0/16 и --use-service-account-credentials=true.
kubelet-start — kubelet получает конфигурацию и перезапускается. Параметры лежат в /var/lib/kubelet/config.yaml (наш KubeletConfiguration плюс то, что kubeadm добавил сам), сокет CRI — ещё и в /var/lib/kubelet/instance-config.yaml, а файл флагов /var/lib/kubelet/kubeadm-flags.env в 1.36 пуст (KUBELET_KUBEADM_ARGS=""): все настройки kubeadm перенёс из флагов командной строки в файл конфигурации. Строка [patches] — kubeadm применил к конфигурации kubelet «патчи» (каталог с исправлениями, который можно указать ключом --patches; у нас его нет, поэтому патч пустой). Теперь kubelet видит каталог манифестов и запускает control plane.
wait-control-plane — kubeadm ждёт, пока kubelet ответит на /healthz, а затем все три компонента control plane — на свои /livez и /healthz. Если что-то не поднялось за четыре минуты, init останавливается; причину ищут в journalctl -u kubelet и crictl logs упавшего контейнера.
upload-config — ClusterConfiguration уходит в ConfigMap kubeadm-config, KubeletConfiguration — в kubelet-config (оба в kube-system). Отсюда их возьмут join и upgrade; правка конфигурации кластера потом — это правка этих ConfigMap.
upload-certs — пропущена. С ключом --upload-certs kubeadm положил бы сертификаты CA в Secret, зашифрованный отдельным ключом, чтобы следующие мастера скачали их при join --control-plane; Secret живёт два часа.
mark-control-plane — метка node-role.kubernetes.io/control-plane (отсюда ROLES в kubectl get nodes) и запрет node-role.kubernetes.io/control-plane:NoSchedule: обычные поды на мастер не попадут. Убирать запрет на рабочем кластере не стоит — приложение, съевшее память мастера, уронит control plane.
bootstrap-token — токен для подключения узлов (по умолчанию живёт 24 часа) и всё, что нужно для подключения: правила RBAC, автоодобрение запросов сертификатов и публичный ConfigMap cluster-info. Как это работает, разберём на join.
kubelet-finalize — kubelet мастера переключается с сертификата, вписанного в kubelet.conf, на файл /var/lib/kubelet/pki/kubelet-client-current.pem, который он сам продлевает до истечения срока.
addon — CoreDNS, DNS-сервер кластера (Deployment и сервис kube-dns на 10.96.0.10) и kube-proxy (DaemonSet с нашей конфигурацией из ConfigMap kube-proxy) создаются обычными объектами через API.
show-join-command — подсказка с командой подключения.
Доступ с рабочей станции
На мастере kubectl уже можно настроить командами из подсказки, но работать с кластером удобнее с рабочей станции. Файл admin.conf читается только root, поэтому копируем его через временный файл:
user@k8s-master-1:~$ sudo install -m 600 -o user -g user /etc/kubernetes/admin.conf ~/admin.conf
$ mkdir -p ~/.kube
$ scp user@k8s-master-1.example.com:admin.conf ~/.kube/config
$ chmod 600 ~/.kube/config
$ ssh user@k8s-master-1.example.com rm admin.conf
$ kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* kubernetes-admin@kubernetes kubernetes kubernetes-admin
$ kubectl cluster-info
Kubernetes control plane is running at https://k8s-api.example.com:6443
CoreDNS is running at https://k8s-api.example.com:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
Имя k8s-api.example.com должно разрешаться и на рабочей станции — в DNS или в её файле hosts: адрес берётся из kubeconfig, а в сертификате kube-apiserver он есть только под этим именем и под адресом мастера.
kubeconfig — YAML с тремя списками: clusters (адрес API и CA, которым проверять его сертификат), users (учётные данные: здесь клиентский сертификат и ключ), contexts (пары «кластер — пользователь» с неймспейсом по умолчанию). current-context выбирает активную пару, kubectl config use-context переключает.
В файле лежит закрытый ключ с правами администратора кластера на год. Он не должен попадать в Git, в общие каталоги, в переписку; права 600 обязательны. Личные учётные записи администраторов вместо общего admin.conf — через отдельные сертификаты или вход через OIDC (единый вход через внешний сервер идентификации) — тема части 29.
Сеть подов: Calico
Узел пока NotReady, а CoreDNS — Pending:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-master-1 NotReady control-plane 2m v1.36.5
$ kubectl describe node k8s-master-1 | grep -m1 'NetworkReady'
Ready False Fri, 09 Oct 2026 11:04:52 +0300 Fri, 09 Oct 2026 11:02:39 +0300 KubeletNotReady container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized
kubelet честно сообщает: среда выполнения не может подключать поды к сети, потому что в /etc/cni/net.d нет конфигурации CNI. Ставим Calico. Сначала определения типов (CRD) и оператор Tigera — контроллер, который по одному объекту Installation разворачивает и обновляет все компоненты Calico:
$ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/v1_crd_projectcalico_org.yaml
customresourcedefinition.apiextensions.k8s.io/apiservers.operator.tigera.io created
customresourcedefinition.apiextensions.k8s.io/gatewayapis.operator.tigera.io created
customresourcedefinition.apiextensions.k8s.io/goldmanes.operator.tigera.io created
...
customresourcedefinition.apiextensions.k8s.io/tiers.crd.projectcalico.org created
customresourcedefinition.apiextensions.k8s.io/clusternetworkpolicies.policy.networking.k8s.io created
$ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/tigera-operator.yaml
namespace/tigera-operator created
serviceaccount/tigera-operator created
clusterrole.rbac.authorization.k8s.io/tigera-operator-secrets created
clusterrole.rbac.authorization.k8s.io/tigera-operator created
clusterrolebinding.rbac.authorization.k8s.io/tigera-operator created
rolebinding.rbac.authorization.k8s.io/tigera-operator-secrets created
deployment.apps/tigera-operator created
Первый файл создаёт 32 определения: типы самого оператора (*.operator.tigera.io), типы Calico (*.crd.projectcalico.org: пулы адресов, BGP, политики) и один тип из API Kubernetes — clusternetworkpolicies.policy.networking.k8s.io. Он создаётся долго, потому что большой, — не прерывайте kubectl create на середине (например, конвейером в head): часть типов останется несозданной.
Здесь kubectl create, а не apply, — и это не небрежность: определения CRD Calico настолько велики, что клиентский apply не уместит их в аннотацию last-applied-configuration (предел 256 КиБ, мы говорили о нём в части 3): kubectl apply на этом файле отвечает The CustomResourceDefinition "installations.operator.tigera.io" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes. Обновлять их потом будем kubectl replace или серверным apply.
Теперь сам объект конфигурации. Стандартный файл custom-resources.yaml из репозитория Calico рассчитан на пул 192.168.0.0/16, у нас своя сеть, поэтому пишем calico-installation.yaml:
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
linuxDataplane: Nftables
ipPools:
- name: default-ipv4-ippool
cidr: 172.20.0.0/16
blockSize: 26
encapsulation: VXLANCrossSubnet
natOutgoing: Enabled
nodeSelector: all()
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
name: default
spec: {}
linuxDataplane: Nftables— Calico пишет свои правила (сетевые политики, NAT, маркировку) в nftables, как и kube-proxy. Документация Calico требует, чтобы при этом режиме и kube-proxy работал в nftables, — у нас так и есть. Один механизм фильтрации на узле проще диагностировать, чем смесь iptables и nftables. Режим nftables в Calico 3.32 стабилен.cidr: 172.20.0.0/16— совпадает сpodSubnetиз конфигурации kubeadm.blockSize: 26— пул делится на блоки по 64 адреса; узел берёт блок целиком и раздаёт адреса своим подам. Когда блок кончается, узел берёт ещё один. Маршрут к соседнему узлу — один на блок, а не на под.encapsulation: VXLANCrossSubnet— пакеты между подами разных узлов одной подсети идут как есть, обычной маршрутизацией: узел отправляет пакет для172.20.140.0/26прямо на192.168.10.21. Между узлами разных подсетей, где промежуточный роутер о сети подов ничего не знает, пакет заворачивается в VXLAN — L2-кадр внутри UDP-датаграммы на порт 4789 (подробно протокол — в части 18 сетевой серии). На нашем стенде все узлы в192.168.10.0/24, так что инкапсуляции не будет вовсе и накладных расходов на неё тоже.natOutgoing: Enabled— пакеты из подов к адресам вне пулов Calico (в интернет, к серверам в сети узлов) уходят с адресом узла: роутер192.168.10.1не знает маршрута в172.20.0.0/16и не смог бы ответить. В объектеIPPoolэто полеnatOutgoing: true. Следствие, о котором важно помнить: внешний сервер видит не под, а узел, и ограничение доступа «по адресам узлов» пускает любой под кластера. В части 22 это станет главной темой разговора о NFS-сервере.nodeSelector: all()— пул доступен всем узлам.APIServer— API-сервер Calico, через которыйkubectlработает с ресурсамиprojectcalico.org/v3(политиками, пулами) с проверкой и значениями по умолчанию.
Стандартный файл создаёт ещё два объекта — Goldmane (агрегатор журналов сетевых потоков) и Whisker (веб-интерфейс к ним). Они в статусе технологического превью, и для учебного кластера мы их не ставим.
$ kubectl create -f calico-installation.yaml
installation.operator.tigera.io/default created
apiserver.operator.tigera.io/default created
$ kubectl get tigerastatus
NAME AVAILABLE PROGRESSING DEGRADED SINCE MESSAGE
apiserver True False False 5s All objects available
calico True False False 55s All objects available
ippools True False False 3m5s All objects available
tiers True Waiting for Tigera API server to be ready
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-master-1 Ready control-plane 5m11s v1.36.5
Строка tiers в первые секунды показывает ожидание: оператор создаёт встроенные «уровни» политик (tiers, о них — в части 20), когда запустится API-сервер Calico; через минуту она тоже станет True.
Оператор создал неймспейс calico-system с calico-node, calico-typha, calico-kube-controllers, calico-apiserver (API-сервер из объекта APIServer) и csi-node-driver, calico-node положил на узел CNI-плагин /opt/cni/bin/calico и конфигурацию /etc/cni/net.d/10-calico.conflist, kubelet увидел сеть — узел Ready, CoreDNS запустился.
Рабочие узлы: kubeadm join
На k8s-node-1 и k8s-node-2 уже выполнена подготовка, установлены containerd и пакеты Kubernetes. Команду подключения берём из вывода init или, если прошло больше суток и токен истёк, генерируем новую:
user@k8s-master-1:~$ sudo kubeadm token create --print-join-command
kubeadm join k8s-api.example.com:6443 --token 1p8v3d.h6n2k9w4x7q0z5m1 --discovery-token-ca-cert-hash sha256:3f9a5c2e8b1d4f7a0c6e9b2d5f8a1c4e7b0d3f6a9c2e5b8d1f4a7c0e3b6d9f2a
user@k8s-node-1:~$ sudo kubeadm join k8s-api.example.com:6443 --token 7k2m9x.q4w8e1r5t6y3u0i2 \
--discovery-token-ca-cert-hash sha256:3f9a5c2e8b1d4f7a0c6e9b2d5f8a1c4e7b0d3f6a9c2e5b8d1f4a7c0e3b6d9f2a
[preflight] Running pre-flight checks
[preflight] Reading configuration from the "kubeadm-config" ConfigMap in namespace "kube-system"...
[preflight] Use 'kubeadm init phase upload-config kubeadm --config your-config-file' to re-upload it.
W1024 13:37:17.660286 3729 utils.go:69] The recommended value for "bindAddress" in "KubeProxyConfiguration" is: ::; the provided value is: 0.0.0.0
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/instance-config.yaml"
[patches] Applied patch of type "application/strategic-merge-patch+json" to target "kubeletconfiguration"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Starting the kubelet
[kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s
[kubelet-check] The kubelet is healthy after 501.234746ms
[kubelet-start] Waiting for the kubelet to perform the TLS Bootstrap
This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.
Run 'kubectl get nodes' on the control-plane to see this node join the cluster.
Строка W… про bindAddress — предупреждение kubeadm о настройке kube-proxy: для кластеров с IPv6 он рекомендует адрес ::, а в нашей конфигурации (кластер на IPv4) стоит 0.0.0.0; предупреждение можно не принимать во внимание.
То же — на k8s-node-2. Токен в этой команде секретен: с ним и сетевым доступом к API кто угодно подключит к кластеру свою машину как узел. Поэтому срок его жизни сутки, а лишние токены удаляют: kubeadm token list, kubeadm token delete <id>.
Что происходит при join
kubeadm kubelet kube-apiserver controller-manager
│ │ │ │
├── 1. cluster-info ─┼───────────────────►│ │
│ анонимно; затем │ │ │
│ проверка JWS │ │ │
│ и хеша CA │ │ │
├── 2. запуск ──────►│ │ │
│ │ bootstrap- │ │
│ │ kubelet.conf │ │
│ ├── 3. POST CSR ────►│ │
│ │ ├── 4. watch CSR ────►│
│ │ │ │ одобрить,
│ │ │ │ подписать CA
│ │ │◄─ 5. сертификат ────┤
│ │◄─ 6. watch ────────┤ │
│ │ kubelet.conf, │ │
│ │ свой сертификат │ │
│ ├── 7. POST Node ───►│ │
│ │ │ │
У нового узла нет ничего: ни сертификата, ни доверия к серверу. Есть только токен и хеш. Задача — безопасно получить и то и другое.
Токен имеет вид 7k2m9x.q4w8e1r5t6y3u0i2: открытый идентификатор (6 символов) и секрет (16 символов). На стороне кластера это Secret bootstrap-token-7k2m9x в kube-system со сроком действия и назначением.
1. Обнаружение (discovery). kubeadm анонимно читает ConfigMap cluster-info в неймспейсе kube-public — kubeconfig с адресом API и сертификатом CA кластера. Анонимно, потому что доверять серверу узел ещё не может. Подлинность ответа проверяется двумя способами. Контроллер bootstrapsigner в менеджере контроллеров кладёт в тот же ConfigMap подпись JWS (JSON Web Signature) содержимого, сделанную секретом токена: подделать её может только тот, кто знает токен. А хеш sha256:… из команды — отпечаток открытого ключа CA: kubeadm сверяет его с CA из ConfigMap и тем самым защищается даже от того, кто украл токен и выдаёт себя за кластер. Хеш можно вычислить на мастере и сверить:
user@k8s-master-1:~$ openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt \
| openssl pkey -pubin -outform der | openssl dgst -sha256 -hex | sed 's/^.* //'
3f9a5c2e8b1d4f7a0c6e9b2d5f8a1c4e7b0d3f6a9c2e5b8d1f4a7c0e3b6d9f2a
Ключ --discovery-token-unsafe-skip-ca-verification отключает проверку хеша; в названии честно написано, почему им не пользуются.
2. Запуск kubelet. kubeadm читает kubeadm-config и kubelet-config из кластера, пишет /var/lib/kubelet/config.yaml и временный /etc/kubernetes/bootstrap-kubelet.conf, где вместо сертификата — токен, и запускает kubelet.
3. TLS bootstrapping. kubelet с токеном аутентифицируется как пользователь system:bootstrap:7k2m9x из группы system:bootstrappers:kubeadm:default-node-token. Привязка ролей kubeadm:kubelet-bootstrap разрешает этой группе ровно одно существенное действие — создать CertificateSigningRequest (CSR, объект «прошу подписать сертификат»): kubelet генерирует закрытый ключ и просит клиентский сертификат с CN=system:node:k8s-node-1, O=system:nodes у подписанта kubernetes.io/kube-apiserver-client-kubelet.
4–5. Одобрение и подпись. Контроллер csrapproving одобряет запрос автоматически — это разрешено привязкой kubeadm:node-autoapprove-bootstrap, — а контроллер csrsigning подписывает его ключом CA кластера (/etc/kubernetes/pki/ca.key на мастере) и кладёт сертификат в status CSR.
6. Постоянные учётные данные. kubelet забирает сертификат, сохраняет его в /var/lib/kubelet/pki/kubelet-client-<дата>.pem (символическая ссылка kubelet-client-current.pem), пишет /etc/kubernetes/kubelet.conf и удаляет bootstrap-kubelet.conf. Токен больше не нужен.
7. Регистрация. kubelet под своей новой личностью создаёт объект Node. Admission-плагин NodeRestriction следит, чтобы system:node:k8s-node-1 мог создать и менять только Node с именем k8s-node-1.
Дальше всё уже знакомо: контроллеры DaemonSet создают на новом узле поды kube-proxy и calico-node, calico-node раскладывает CNI-плагин, узел становится Ready. Продлевает клиентский сертификат kubelet сам, тем же механизмом CSR, — его запросы одобряются автоматически привязкой kubeadm:node-autoapprove-certificate-rotation.
Сертификат для HTTPS kubelet
Клиентский сертификат kubelet нужен ему, чтобы ходить в API. Но kubelet ещё и сервер: на порту 10250 к нему обращаются kube-apiserver (логи, exec) и metrics-server (метрики ресурсов). По умолчанию kubelet сам генерирует для этого порта самоподписанный сертификат, проверить который никто не может, — и metrics-server в таком кластере приходится запускать с отключённой проверкой (--kubelet-insecure-tls), что открывает дорогу подмене узла.
Мы включили serverTLSBootstrap: true, и kubelet просит серверный сертификат у CA кластера — тоже через CSR, но с подписантом kubernetes.io/kubelet-serving. Такие запросы kubeadm не одобряет автоматически, и это правильно: в запросе kubelet сам перечисляет имена и адреса, на которые хочет сертификат, и автоматическое одобрение позволило бы захваченному узлу получить сертификат на чужой адрес. Смотрим запросы:
$ kubectl get csr
NAME AGE SIGNERNAME REQUESTOR REQUESTEDDURATION CONDITION
csr-2whjl 5m53s kubernetes.io/kube-apiserver-client-kubelet system:node:k8s-master-1 <none> Approved,Issued
csr-6x6hd 7s kubernetes.io/kubelet-serving system:node:k8s-node-2 <none> Pending
csr-mdcst 5m53s kubernetes.io/kubelet-serving system:node:k8s-master-1 <none> Pending
csr-nj5kg 17s kubernetes.io/kubelet-serving system:node:k8s-node-1 <none> Pending
csr-q4fzv 11s kubernetes.io/kube-apiserver-client-kubelet system:bootstrap:7k2m9x <none> Approved,Issued
csr-w7sgm 19s kubernetes.io/kube-apiserver-client-kubelet system:bootstrap:7k2m9x <none> Approved,Issued
csr-xj59f 5m33s kubernetes.io/kubelet-serving system:node:k8s-master-1 <none> Pending
$ kubectl get csr csr-nj5kg -o jsonpath='{.spec.request}' | base64 -d | openssl req -noout -subject -text | grep -E 'Subject:|DNS:'
Subject: O=system:nodes, CN=system:node:k8s-node-1
DNS:k8s-node-1, IP Address:192.168.10.20
$ kubectl certificate approve csr-mdcst csr-xj59f csr-nj5kg csr-6x6hd
certificatesigningrequest.certificates.k8s.io/csr-mdcst approved
certificatesigningrequest.certificates.k8s.io/csr-xj59f approved
certificatesigningrequest.certificates.k8s.io/csr-nj5kg approved
certificatesigningrequest.certificates.k8s.io/csr-6x6hd approved
Клиентские запросы рабочих узлов (csr-q4fzv, csr-w7sgm) пришли от токена и одобрены автоматически; csr-2whjl — запрос самого мастера на продление клиентского сертификата. Серверные запросы ждут нас, причём у мастера их два: kubelet отправил один и тот же запрос с интервалом в двадцать секунд, одобрить нужно оба.
Перед одобрением сверяем, что в запросе имя и адрес именно этого узла. Пока серверный запрос не одобрен, kubelet не может принять HTTPS-соединение, и kubectl logs и kubectl exec к подам на этом узле завершаются ошибкой TLS — это первое, что стоит проверить, если они не работают на свежем узле:
$ kubectl logs -n kube-system etcd-k8s-master-1 --tail=1
Error from server: Get "https://192.168.10.17:10250/containerLogs/kube-system/etcd-k8s-master-1/etcd?tailLines=1": remote error: tls: internal error
$ kubectl exec -n kube-system etcd-k8s-master-1 -- true
error: unable to upgrade connection: error dialing backend: remote error: tls: internal error
Серверный сертификат тоже действует год, и его продление снова создаёт CSR, который нужно одобрить; в больших кластерах для этого ставят отдельный контроллер-одобритель, который проверяет имена и адреса по объекту Node.
Проверка
$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
k8s-master-1 Ready control-plane 7m38s v1.36.5 192.168.10.17 <none> Ubuntu 26.04.1 LTS 7.0.0-38-generic (amd64) containerd://2.2.2
k8s-node-1 Ready <none> 2m3s v1.36.5 192.168.10.20 <none> Ubuntu 26.04.1 LTS 7.0.0-38-generic (amd64) containerd://2.2.2
k8s-node-2 Ready <none> 115s v1.36.5 192.168.10.21 <none> Ubuntu 26.04.1 LTS 7.0.0-38-generic (amd64) containerd://2.2.2
$ kubectl get pods -A | grep -v Running
NAMESPACE NAME READY STATUS RESTARTS AGE
$ kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
coredns-589f44dc88-bzxpn 1/1 Running 0 7m11s
coredns-589f44dc88-lv5bl 1/1 Running 0 7m11s
etcd-k8s-master-1 1/1 Running 0 7m29s
kube-apiserver-k8s-master-1 1/1 Running 0 7m34s
kube-controller-manager-k8s-master-1 1/1 Running 0 7m30s
kube-proxy-7rbtd 1/1 Running 0 7m12s
kube-proxy-sf5g2 1/1 Running 0 114s
kube-proxy-xmjz2 1/1 Running 0 2m3s
kube-scheduler-k8s-master-1 1/1 Running 0 7m32s
$ kubectl get pods -n calico-system
NAME READY STATUS RESTARTS AGE
calico-apiserver-7c56559b65-dm42w 1/1 Running 0 6m20s
calico-apiserver-7c56559b65-phtkx 1/1 Running 0 6m20s
calico-kube-controllers-6879897867-8jnzf 1/1 Running 0 6m15s
calico-node-6d2qb 1/1 Running 0 2m45s
calico-node-l9g44 1/1 Running 0 5m54s
calico-node-q62j9 1/1 Running 0 2m36s
calico-typha-55d594d885-7cb8k 1/1 Running 0 2m32s
calico-typha-55d594d885-dsh7d 1/1 Running 0 6m19s
csi-node-driver-7jlkp 2/2 Running 0 6m17s
csi-node-driver-q6t25 2/2 Running 0 2m44s
csi-node-driver-s546r 2/2 Running 0 2m36s
Пока воркеры подключались, часть подов Calico на них ещё запускалась (Init, ContainerCreating) — это занимает около минуты, после чего список проблемных подов пуст.
calico-apiserver — две реплики API-сервера Calico (из объекта APIServer), calico-typha — по реплике на два узла, calico-node — на каждом узле, а csi-node-driver — CSI-драйвер Calico, который монтирует в поды служебный сокет для политик прикладного уровня (L7); оператор ставит его всегда, а без таких политик он простаивает. kubectl get pods -A | grep -v Running — быстрый способ увидеть проблемные поды кластера: здесь остался только заголовок, значит, все поды запущены.
Первый под
$ kubectl run hello --image=nginx:1.29 --overrides='{"spec":{"nodeName":"k8s-node-2"}}'
pod/hello created
$ kubectl get pod hello -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hello 1/1 Running 0 6s 172.20.172.4 k8s-node-2 <none> <none>
$ kubectl run -it --rm check --image=busybox:1.37 --restart=Never \
--overrides='{"spec":{"nodeName":"k8s-node-1"}}' -- \
sh -c 'wget -qO- http://172.20.172.4 | grep title; nslookup kubernetes.default.svc.cluster.local'
<title>Welcome to nginx!</title>
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: kubernetes.default.svc.cluster.local
Address: 10.96.0.1
pod "check" deleted from default namespace
Ключ --overrides подменяет часть спецификации пода: мы явно указали узлы (nodeName), чтобы hello и check оказались на разных, — без этого планировщик мог посадить их вместе, и проверка не прошла бы по сети между узлами (как он выбирает узел сам и как этим управлять — в части 13). Под check на k8s-node-1 достучался до nginx на k8s-node-2 по адресу пода, а имя сервиса kubernetes из неймспейса default превратилось в адрес сервиса API 10.96.0.1 через CoreDNS на 10.96.0.10. Значит, работают сеть подов между узлами, kube-proxy и DNS — кластер готов. kubectl delete pod hello.
Смотрим руками
Что kubeadm оставил на мастере:
user@k8s-master-1:~$ sudo ls /etc/kubernetes /etc/kubernetes/pki /etc/kubernetes/pki/etcd
/etc/kubernetes:
admin.conf controller-manager.conf kubelet.conf manifests pki scheduler.conf super-admin.conf
/etc/kubernetes/pki:
apiserver.crt apiserver-etcd-client.key apiserver-kubelet-client.crt ca.crt ca.key front-proxy-ca.crt front-proxy-client.crt sa.key
apiserver-etcd-client.crt apiserver.key apiserver-kubelet-client.key etcd front-proxy-ca.key front-proxy-client.key sa.pub
/etc/kubernetes/pki/etcd:
ca.crt ca.key healthcheck-client.crt healthcheck-client.key peer.crt peer.key server.crt server.key
super-admin.conf ещё на месте — самое время унести его с мастера. Сроки сертификатов:
user@k8s-master-1:~$ sudo kubeadm certs check-expiration
CERTIFICATE EXPIRES RESIDUAL TIME CERTIFICATE AUTHORITY EXTERNALLY MANAGED
admin.conf Oct 24, 2027 10:31 UTC 364d ca no
apiserver Oct 24, 2027 10:31 UTC 364d ca no
apiserver-etcd-client Oct 24, 2027 10:31 UTC 364d etcd-ca no
apiserver-kubelet-client Oct 24, 2027 10:31 UTC 364d ca no
controller-manager.conf Oct 24, 2027 10:31 UTC 364d ca no
etcd-healthcheck-client Oct 24, 2027 10:31 UTC 364d etcd-ca no
etcd-peer Oct 24, 2027 10:31 UTC 364d etcd-ca no
etcd-server Oct 24, 2027 10:31 UTC 364d etcd-ca no
front-proxy-client Oct 24, 2027 10:31 UTC 364d front-proxy-ca no
scheduler.conf Oct 24, 2027 10:31 UTC 364d ca no
super-admin.conf Oct 24, 2027 10:31 UTC 364d ca no
CERTIFICATE AUTHORITY EXPIRES RESIDUAL TIME EXTERNALLY MANAGED
ca Oct 21, 2036 10:31 UTC 9y no
etcd-ca Oct 21, 2036 10:31 UTC 9y no
front-proxy-ca Oct 21, 2036 10:31 UTC 9y no
Через год сертификаты компонентов истекут, и кластер перестанет работать. kubeadm upgrade продлевает их при каждом обновлении версии, а обновляться раз в год — минимум, чтобы оставаться на поддерживаемой версии; подробно и о ручном продлении — в части 35.
Что kubeadm положил в кластер для подключения узлов:
$ kubectl get configmap cluster-info -n kube-public -o yaml | grep -E 'jws|server:'
server: https://k8s-api.example.com:6443
jws-kubeconfig-7k2m9x: eyJhbGciOiJIUzI1NiIsImtpZCI6IjdrMm05eCJ9..Wq3b7Tg2m9vJ0K4xRZp1cYfH8sLd6NuE5oAiQwMkV2s
user@k8s-master-1:~$ sudo kubeadm token list
TOKEN TTL EXPIRES USAGES DESCRIPTION EXTRA GROUPS
7k2m9x.q4w8e1r5t6y3u0i2 23h 2026-10-25T10:32:01Z authentication,signing <none> system:bootstrappers:kubeadm:default-node-token
Подпись JWS в «отсоединённом» виде — заголовок, пустое место вместо содержимого и подпись через точки: содержимое — это сам kubeconfig рядом. В заголовке (eyJhbGciOiJIUzI1NiIsImtpZCI6IjdrMm05eCJ9 — это {"alg":"HS256","kid":"7k2m9x"} в base64) указан идентификатор токена, которым подписано. Когда токен удаляют или он истекает, контроллер tokencleaner удаляет Secret, а bootstrapsigner — подпись.
На рабочем узле — сертификаты kubelet и маршруты Calico:
user@k8s-node-1:~$ sudo ls -l /var/lib/kubelet/pki/
total 8
-rw------- 1 root root 1118 Oct 24 13:37 kubelet-client-2026-10-24-13-37-18.pem
lrwxrwxrwx 1 root root 59 Oct 24 13:37 kubelet-client-current.pem -> /var/lib/kubelet/pki/kubelet-client-2026-10-24-13-37-18.pem
-rw------- 1 root root 1167 Oct 24 13:37 kubelet-server-2026-10-24-13-37-49.pem
lrwxrwxrwx 1 root root 59 Oct 24 13:37 kubelet-server-current.pem -> /var/lib/kubelet/pki/kubelet-server-2026-10-24-13-37-49.pem
user@k8s-node-1:~$ ip route | grep 172.20
172.20.70.192/26 via 192.168.10.17 dev ens18 proto 80 onlink
172.20.172.0/26 via 192.168.10.21 dev ens18 proto 80 onlink
blackhole 172.20.211.0/26 proto 80
172.20.211.1 dev cali01e594701c5 scope link metric 1024
172.20.211.3 dev calie9f7c1b68b7 scope link metric 1024
Узлу k8s-node-1 достался блок 172.20.211.0/26; маршрут blackhole на весь блок отбрасывает пакеты к несуществующим подам блока, а каждый реальный под — отдельный маршрут /32 в свой veth cali… (172.20.211.3 — наш check, пока он жил). Блоки мастера и k8s-node-2 доступны напрямую через их адреса в общей подсети — без VXLAN, как и задумано режимом VXLANCrossSubnet; флаг onlink сообщает ядру, что следующий шаг достижим по этому интерфейсу. proto 80 — номер протокола, которым Calico помечает маршруты в ядре: их прописывает BIRD — демон BGP внутри calico-node, через который узлы по умолчанию обмениваются маршрутами к блокам. Роутер с BGP для этого не нужен: узлы устанавливают сессии друг с другом («полная сетка», node-to-node mesh), и наружу кластера BGP не выходит. Сессии видны прямо в calico-node:
user@k8s-node-1:~$ sudo crictl exec $(sudo crictl ps --name calico-node -q) birdcl show protocols
BIRD v0.3.3+birdv1.6.8 ready.
name proto table state since info
static1 Static master up 10:39:27
kernel1 Kernel master up 10:39:27
device1 Device master up 10:39:27
direct1 Direct master up 10:39:27
Mesh_192_168_10_21 BGP master up 10:39:29 Established
Mesh_192_168_10_17 BGP master up 10:39:28 Established
Как устроен BGP и как Calico анонсирует сети подов и адреса сервисов наружу, на роутер, разобрано в серии про BGP.
И таблицы nftables:
user@k8s-node-1:~$ sudo nft list tables
table ip mangle
table ip filter
table ip6 mangle
table ip nat
table ip6 nat
table ip6 filter
table ip kube-proxy
table ip6 kube-proxy
table ip calico
table arp calico-arp
kube-proxy — правила сервисов, calico и calico-arp — правила Calico (политики, NAT, маркировка пакетов). Таблицы mangle, filter и nat создаёт слой совместимости iptables-nft: через него kubelet пишет в нужные цепочки привычные команды iptables (в них видны его служебные цепочки KUBE-KUBELET-CANARY и KUBE-FIREWALL). Что лежит в таблицах kube-proxy и как пакет на адрес сервиса превращается в пакет на адрес пода — тема части 15.
Если что-то пошло не так
Пока в кластере нет ценных данных, неудачную установку проще начать заново, чем чинить:
user@k8s-node-2:~$ sudo kubeadm reset -f
[preflight] Running pre-flight checks
W1024 13:43:30.795284 6560 removeetcdmember.go:105] [reset] No kubeadm config, using etcd pod spec to get data directory
[reset] Deleted contents of the etcd data directory: /var/lib/etcd
[reset] Stopping the kubelet service
[reset] Unmounting mounted directories in "/var/lib/kubelet"
[reset] Deleting contents of directories: [/etc/kubernetes/manifests /var/lib/kubelet /etc/kubernetes/pki]
[reset] Deleting files: [/etc/kubernetes/admin.conf /etc/kubernetes/super-admin.conf /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf]
The reset process does not perform cleanup of CNI plugin configuration,
network filtering rules and kubeconfig files.
For information on how to perform this cleanup manually, please see:
https://k8s.io/docs/reference/setup-tools/kubeadm/kubeadm-reset/
kubeadm reset останавливает kubelet, удаляет контейнеры, манифесты, сертификаты, /var/lib/etcd (даже на рабочем узле, где его нет) и конфигурацию kubelet, но не трогает то, что сделали другие программы, о чём честно предупреждает. После reset на узле остаются конфигурация CNI и данные Calico, интерфейс vxlan.calico, маршруты к блокам подов и таблицы nftables Calico и kube-proxy:
user@k8s-node-2:~$ sudo ls /etc/cni/net.d /var/lib/calico
/etc/cni/net.d:
10-calico.conflist calico-kubeconfig
/var/lib/calico:
mtu nodename
user@k8s-node-2:~$ ip -br link | grep -E 'cali|vxlan'; ip route | grep 172.20; sudo nft list tables | grep -E 'calico|kube-proxy'
vxlan.calico UNKNOWN 66:05:38:46:ab:ea <BROADCAST,MULTICAST,UP,LOWER_UP>
172.20.70.192/26 via 192.168.10.17 dev ens18 proto 80 onlink
blackhole 172.20.172.0/26 proto 80
172.20.211.0/26 via 192.168.10.20 dev ens18 proto 80 onlink
table ip kube-proxy
table ip6 kube-proxy
table ip calico
table arp calico-arp
Файлы CNI и данные Calico удаляют руками, а интерфейсы, маршруты и правила nftables проще всего убрать перезагрузкой — они не сохраняются между загрузками:
user@k8s-node-2:~$ sudo rm -rf /etc/cni/net.d /var/lib/cni /var/lib/calico
user@k8s-node-2:~$ sudo reboot
После перезагрузки на узле нет ни vxlan.calico, ни маршрутов 172.20, ни таблиц calico и kube-proxy, и его можно подключать заново. На мастере для рабочего узла после этого удаляют объект узла: kubectl delete node k8s-node-2, а у мастера заодно $HOME/.kube.
Самые частые причины неудачи и то, что видно в каждом случае:
- включённый swap — kubeadm только предупреждает (
[WARNING Swap]), а kubelet не запускается:running with swap on is not supported, please disable swap or set --fail-swap-on flag to false; net.ipv4.ip_forward = 0—kubeadm initостанавливается проверкой[ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1(приjoinkubeadm 1.36 этого не проверяет, так что настройку нужно сделать на всех узлах заранее);- не разрешающееся имя
k8s-api.example.comна узлах — не проходитjoin; - расхождение часов — ошибки вида
x509: certificate has expired or is not yet valid; - забытый
SystemdCgroup = true— поды control plane начинают перезапускаться каждые несколько минут.
Источники
- Установка kubeadm и создание кластера с помощью kubeadm — официальный порядок, на котором построена часть.
- Справочник kubeadm:
initи его фазы,join,reset,certsи конфигурацияv1beta4. - TLS bootstrapping kubelet, CertificateSigningRequest и ротация сертификатов kubelet.
- Порты и протоколы, среды выполнения контейнеров и swap на узле; kube-proxy.
- containerd: начало работы и настройка CRI; утилита crictl.
- Calico: установка на своих серверах, VXLAN и IP-in-IP и режим nftables.
- chrony и RFC 8915 — Network Time Security для NTP.
Дальше — часть 5: как собрать образ и где его хранить. Как собрать образ приложения hello, положить его в частный реестр и подключить реестр к кластеру.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.