Kubernetes для самых маленьких. Часть 38: Собираем гибридный кластер на kubeadm

Все части серии «Kubernetes для самых маленьких»

В части 36 мы разобрали, чем Windows-узел отличается от Linux-узла, а в прошлой части — как устроена его сеть и почему для Windows-воркеров удобнее отдельный кластер, сразу собранный под требования Calico для Windows, чем перевод работающего. Сегодня этот кластер соберём и посмотрим вживую на всё, о чём шла речь в двух прошлых частях. Весь блок о гибридном кластере работает на нём, а основной учебный кластер серии остаётся как был — с VXLANCrossSubnet и BGP.

Часть состоит из трёх разделов. Первый — Linux-основа тестового кластера: мастер и два Linux-воркера ставятся точно так же, как в части 4, поэтому здесь только отличия, а Calico с первого дня ставится в режиме, который понимает Windows. Второй — Windows-воркер k8s-lab-win-1.example.com на Windows Server 2025 в установке Server Core. Путь тот же, что для Linux-узлов: подготовить ОС, поставить containerd, kubelet и kubeadm, выполнить kubeadm join, дать узлу сеть. Отличия — в деталях: службы Windows вместо systemd, HostProcess-поды вместо привилегированных контейнеров, HNS вместо netfilter, а Linux-подам нужно явно запретить ехать на новый узел. Третий — проверка и разбор: Windows-под, связность между ОС и то, как всё это выглядит изнутри узла.


Стенд

Узел Адрес Роль Ресурсы
k8s-lab-master-1.example.com 192.168.10.23 control plane, Ubuntu Server 26.04 2 vCPU, 4 ГБ, 32 ГБ
k8s-lab-node-1.example.com 192.168.10.24 Linux-воркер, Ubuntu Server 26.04 2 vCPU, 8 ГБ, 32 ГБ
k8s-lab-node-2.example.com 192.168.10.26 Linux-воркер, Ubuntu Server 26.04 2 vCPU, 8 ГБ, 32 ГБ
k8s-lab-win-1.example.com 192.168.10.25 Windows-воркер, Windows Server 2025 Core 4 vCPU, 8 ГБ, 64 ГБ
Сеть Тестовый кластер Основной кластер (для сравнения)
сеть узлов 192.168.10.0/24, шлюз 192.168.10.1 (router.example.com) та же
сеть подов 172.21.0.0/16, блоки /26 172.20.0.0/16
сеть сервисов 10.96.0.0/12, API 10.96.0.1, DNS 10.96.0.10 та же
адрес API k8s-lab-api.example.com:6443 → 192.168.10.23 k8s-api.example.com:6443
доставка между узлами VXLAN всегда, BGP выключен VXLANCrossSubnet и BIRD
Компонент Linux-узлы Windows-узел
ОС Ubuntu Server 26.04 Windows Server 2025 Datacenter, Server Core, сборка 10.0.26100
Kubernetes 1.37.2 1.37.2
containerd 2.2.2 из пакетов Ubuntu 2.2.2, служба Windows
Calico 3.33.0, Tigera operator, датаплейн nftables 3.33.0 (Felix и CNI для Windows), HostProcess-под
kube-proxy режим nftables режим kernelspace (winkernel), HostProcess-под из DaemonSet

Сеть подов у тестового кластера своя, чтобы его адреса в выводе не путались с адресами основного; сеть сервисов совпадает — кластеры между собой не маршрутизируют, и пересечение диапазонов сервисов им не мешает. Kubernetes сразу 1.37.2, на которую основной кластер перешёл в части 35: нет смысла ставить 1.36, чтобы тут же обновлять.

Почему в тестовом кластере два Linux-воркера. Windows-узел умеет запускать только Windows-контейнеры, а почти всё служебное в кластере существует только в виде Linux-образов: CoreDNS, Tigera operator, Typha и calico-kube-controllers, вебхуки (cert-manager, Calico, позже — вебхук gMSA), metrics-server, контроллеры CSI. Документация Calico для Windows прямо требует хотя бы один Linux-воркер, на котором работают общекластерные компоненты Calico. Держать всё это на мастере, сняв с него taint, можно, но тогда тестовый кластер разошёлся бы с основным в главном — в разделении ролей узлов. Одного воркера хватило бы, чтобы кластер заработал, но тогда его не обслужить: kubectl drain единственного Linux-воркера (часть 35) оставил бы CoreDNS, вебхуки и контроллеры без места. Поэтому рядом с мастером два Linux-воркера, k8s-lab-node-1 и k8s-lab-node-2, как в основном кластере; для отказоустойчивости Typha Calico советует три Linux-воркера, но тестовому кластеру хватит двух.

                  рабочая станция: kubectl, контекст lab
                                     │ 6443
                                     ▼
        ┌─ k8s-lab-master-1 192.168.10.23 ──────────────────────────┐
        │ etcd, kube-apiserver, scheduler, controller-manager       │
        │ имя API: k8s-lab-api.example.com                          │
        └────────────────────────────┬──────────────────────────────┘
            ┌────────────────────────┼────────────────────────┐
            ▼                        ▼                        ▼
 ┌─ k8s-lab-node-1 .24 ─┐ ┌─ k8s-lab-node-2 .26 ─┐ ┌─ k8s-lab-win-1 .25 ──┐
 │ Ubuntu Server 26.04  │ │ Ubuntu Server 26.04  │ │ Windows Server 2025  │
 │ служебные поды       │ │ служебные поды       │ │ Core, aspnetapp      │
 │ и поды hello         │ │ и поды hello         │ │ taint os=windows     │
 │ 172.21.211.0/26      │ │ 172.21.172.0/26      │ │ 172.21.41.0/26       │
 └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
   между всеми узлами — VXLAN: UDP 4789, VNI 4096

Адреса узлов на схеме — последний октет в сети 192.168.10.0/24, под ними — блоки адресов подов, которые Calico выдаст узлам. Как поставить Server Core и работать с ним из PowerShell, разобрано в первой части серии про Windows Server. Процессоров у Windows-узла вдвое больше, чем у Linux-воркеров: документация Kubernetes называет разумной отправной точкой 4 ядра, 8 ГБ памяти и 50 ГБ диска — сама ОС занимает больше, а образы Windows весят сотни мегабайт и гигабайты. Памяти Linux-воркерам дано столько же с запасом: в части 39 на них встанут Prometheus и Loki.

В DNS нужны записи четырёх узлов и k8s-lab-api.example.com → 192.168.10.23 — то же отдельное имя API, что в части 4: если тестовый кластер когда-нибудь получит второй мастер, имя перенаправят на виртуальный адрес, не пересобирая кластер.


Linux-узлы: как в части 4

Подготовка, containerd и пакеты

На k8s-lab-master-1, k8s-lab-node-1 и k8s-lab-node-2 всё делается по части 4, разделы «Подготовка узлов», «containerd» и «kubeadm, kubelet и kubectl», с такими отличиями:

  • имена и адреса — hostnamectl set-hostname k8s-lab-master-1, k8s-lab-node-1 и k8s-lab-node-2, адреса 192.168.10.23, .24 и .26 в netplan; проверки уникальности product_uuid и /etc/machine-id особенно важны, если машины склонированы из того же шаблона, что и узлы основного кластера;
  • swap, модули overlay и br_netfilter, sysctl, chrony с router.example.com — без изменений;
  • containerd — тот же пакет containerd из репозитория Ubuntu, SystemdCgroup = true; образ sandbox выставляем тем, что назовёт kubeadm config images list --kubernetes-version v1.37.2, — по той же причине, что в части 4: kubeadm предупреждает о расхождении образа pause;
  • доверие реестру — настраивать не нужно: образы hello тестовый кластер берёт из того же частного пакета в ghcr.io, что и основной, а сертификат у ghcr.io публичный (часть 5);
  • репозиторий пакетов — сразу v1.37:
user@k8s-lab-node-1:~$ curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.37/deb/Release.key \
    | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
user@k8s-lab-node-1:~$ echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /' \
    | sudo tee /etc/apt/sources.list.d/kubernetes.list
user@k8s-lab-node-1:~$ sudo apt-get update && sudo apt-get install -y kubelet kubeadm kubectl
user@k8s-lab-node-1:~$ sudo apt-mark hold kubelet kubeadm kubectl
user@k8s-lab-node-1:~$ kubeadm version -o short
v1.37.2

Файрвол узлов, как и в основном кластере, выключен; из таблицы портов части 4 тестовому кластеру не нужен 179/TCP — BGP здесь не будет, — а 4789/UDP теперь нужен между всеми узлами, а не только между подсетями.

Конфигурация и kubeadm init

kubeadm-config.yaml на мастере отличается от файла части 4 адресом, именами, версией и сетью подов:

apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 192.168.10.23
  bindPort: 6443
nodeRegistration:
  name: k8s-lab-master-1
  criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.37.2
controlPlaneEndpoint: k8s-lab-api.example.com:6443
networking:
  podSubnet: 172.21.0.0/16
  serviceSubnet: 10.96.0.0/12
  dnsDomain: cluster.local
apiServer:
  certSANs:
    - k8s-lab-api.example.com
    - k8s-lab-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

Формат конфигурации kubeadm в 1.37 по-прежнему v1beta4, смысл всех четырёх документов разобран в части 4, раздел «Конфигурация кластера». Два поля здесь важны для Windows-узла. serverTLSBootstrap: true попадёт в общий KubeletConfiguration и на Windows-узел тоже: его kubelet запросит серверный сертификат у CA кластера, и metrics-server сможет снимать с него метрики, проверяя сертификат. controlPlaneEndpoint — имя, к которому будут обращаться не только узлы, но и компоненты Calico на Windows, об этом ниже.

user@k8s-lab-master-1:~$ sudo kubeadm config validate --config kubeadm-config.yaml
ok
user@k8s-lab-master-1:~$ sudo kubeadm init --config kubeadm-config.yaml
[init] Using Kubernetes version: v1.37.2
[preflight] Running pre-flight checks
...
[certs] apiserver serving cert is signed for DNS names [k8s-lab-api.example.com k8s-lab-master-1 k8s-lab-master-1.example.com kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local] and IPs [10.96.0.1 192.168.10.23]
...
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

Your Kubernetes control-plane has initialized successfully!
...
kubeadm join k8s-lab-api.example.com:6443 --token 3h8q2w.k5n9v1c7x4m6z0p8 \
        --discovery-token-ca-cert-hash sha256:7b2e9d4a1c6f8b3e0d5a2c7f9e4b1d6a8c3f0e5b2d7a9c4e1f6b8d3a0c5e2f7b

Фазы те же, что в части 4, раздел «kubeadm init и его фазы». У тестового кластера свой CA — новый корень доверия, — поэтому и хеш sha256:… другой: сертификаты основного и тестового кластеров друг другу не доверяют. super-admin.conf так же уносим с мастера в надёжное место.


Второй контекст в kubeconfig

С рабочей станции теперь нужно ходить в два кластера. kubeconfig для этого и устроен: в одном файле несколько кластеров, пользователей и контекстов, kubectl работает с текущим контекстом, а ключ --context выбирает другой на одну команду.

Первое, что приходит в голову, — положить admin.conf тестового кластера рядом и перечислить оба файла в переменной KUBECONFIG. Так делать не стоит. kubeadm в обоих кластерах называет записи одинаково: кластер kubernetes, пользователь kubernetes-admin, контекст kubernetes-admin@kubernetes. При слиянии файлов из одинаковых имён побеждает первое, и «второй» контекст молча получит адрес и учётные данные первого кластера. Поэтому переносим содержимое admin.conf под своими именами — командами kubectl config.

Копируем admin.conf с мастера, как в части 4, раздел «Доступ с рабочей станции»:

user@k8s-lab-master-1:~$ sudo install -m 600 -o user -g user /etc/kubernetes/admin.conf ~/admin.conf
$ LAB=~/.kube/lab-admin.conf
$ scp user@k8s-lab-master-1.example.com:admin.conf "$LAB" && chmod 600 "$LAB"
$ ssh user@k8s-lab-master-1.example.com rm admin.conf
$ field() { kubectl --kubeconfig "$LAB" config view --raw -o jsonpath="$1"; }

$ kubectl config set-cluster lab --server=https://k8s-lab-api.example.com:6443
Cluster "lab" set.
$ kubectl config set clusters.lab.certificate-authority-data "$(field '{.clusters[0].cluster.certificate-authority-data}')"
Property "clusters.lab.certificate-authority-data" set.
$ kubectl config set users.lab-admin.client-certificate-data "$(field '{.users[0].user.client-certificate-data}')"
Property "users.lab-admin.client-certificate-data" set.
$ kubectl config set users.lab-admin.client-key-data "$(field '{.users[0].user.client-key-data}')"
Property "users.lab-admin.client-key-data" set.
$ kubectl config set-context lab --cluster=lab --user=lab-admin
Context "lab" created.
$ shred -u "$LAB"
  • set-cluster lab — запись в списке clusters с адресом API тестового кластера; certificate-authority-data — его CA, которым kubectl проверяет сертификат kube-apiserver.
  • users.lab-admin — клиентский сертификат и ключ администратора тестового кластера. kubectl config set для полей *-data ожидает base64 и сам его декодирует перед записью, а в admin.conf значения уже лежат в base64 — их и передаём как есть.
  • set-context lab — пара «кластер — пользователь» под коротким именем.
  • shred -u — копия admin.conf с закрытым ключом больше не нужна, ключ уже в ~/.kube/config.
$ kubectl config get-contexts
CURRENT   NAME                          CLUSTER      AUTHINFO           NAMESPACE
*         kubernetes-admin@kubernetes   kubernetes   kubernetes-admin
          lab                           lab          lab-admin

$ kubectl --context lab cluster-info
Kubernetes control plane is running at https://k8s-lab-api.example.com:6443
CoreDNS is running at https://k8s-lab-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'.

$ kubectl config use-context lab
Switched to context "lab".

Дальше в этой и следующих частях блока команды kubectl без --context выполняются в тестовом кластере: текущий контекст — lab. Помнить о текущем контексте приходится постоянно: он записан в файле и общий для всех окон терминала, и kubectl delete в «не том» кластере — классическая ошибка. Поэтому в скриптах контекст указывают явно (kubectl --context lab …, у Helm — --kube-context lab), а в приглашение командной строки многие выводят имя текущего контекста.


Calico сразу для Windows

Узел пока NotReady — сети подов нет, как и в части 4 до установки Calico. Устанавливаем Calico тем же оператором, но сразу со всем, что понадобится Windows-узлу: никакого VXLANCrossSubnet, никакого BGP и никакой миграции потом.

CRD и оператор

$ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/v3_projectcalico_org.yaml
customresourcedefinition.apiextensions.k8s.io/apiservers.operator.tigera.io created
...
customresourcedefinition.apiextensions.k8s.io/ipamconfigurations.projectcalico.org created
customresourcedefinition.apiextensions.k8s.io/ippools.projectcalico.org created
...

$ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/tigera-operator.yaml
namespace/tigera-operator created
...
deployment.apps/tigera-operator created

Отличие от части 4 — в файле CRD. Там ресурсы Calico (IPPool, NetworkPolicy Calico и другие) хранились в служебных CRD группы crd.projectcalico.org, а «настоящий» API projectcalico.org/v3 с проверкой и значениями по умолчанию отдавал отдельный агрегированный API-сервер Calico. Начиная с Calico 3.33 новые установки на Kubernetes 1.36 и новее по умолчанию используют нативные CRD projectcalico.org/v3: ресурсы Calico — обычные CRD, kubectl работает с ними напрямую, а проверку и значения по умолчанию делают политики допуска (MutatingAdmissionPolicy и ValidatingAdmissionPolicy — правила на языке CEL, которые kube-apiserver исполняет сам, без вызова внешнего вебхука) и небольшой под вебхуков. Для Kubernetes 1.34–1.35 у Calico есть отдельный файл CRD, а kubectl create вместо apply нужен по той же причине, что в части 4: CRD слишком велики для аннотации клиентского apply.

Адрес API для Calico

До установки Calico создаём ConfigMap, которым оператор передаёт компонентам Calico адрес API:

kind: ConfigMap
apiVersion: v1
metadata:
  name: kubernetes-services-endpoint
  namespace: tigera-operator
data:
  KUBERNETES_SERVICE_HOST: k8s-lab-api.example.com
  KUBERNETES_SERVICE_PORT: "6443"
$ kubectl apply -f kubernetes-services-endpoint.yaml
configmap/kubernetes-services-endpoint created

Обычно поды ходят в API по адресу сервиса kubernetes — 10.96.0.1. На Windows-узле это не работает: Calico запускается в HostProcess-поде в сети хоста, а Windows-хост к адресам сервисов обращаться не может — их балансировку VFP делает на портах подов, а не на самом хосте. К тому же calico-node-windows запускается раньше, чем kube-proxy настроит балансировку сервисов: kube-proxy для Windows ждёт сеть, которую создаёт Calico, — без прямого адреса API Calico не запустился бы никогда. Поэтому оператор передаёт компонентам Calico переменные KUBERNETES_SERVICE_HOST и KUBERNETES_SERVICE_PORT из этого ConfigMap, и они идут к API напрямую — по общему имени. ConfigMap действует и на Linux-компоненты: на Linux-узлах calico-node тоже ходит к k8s-lab-api.example.com, а не через kube-proxy, и не зависит от него при старте. В основном кластере ConfigMap пришлось бы добавлять задним числом, с перезапуском всех calico-node; здесь он есть с первой минуты.

Installation

calico-installation.yaml:

apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  serviceCIDRs:
    - 10.96.0.0/12
  calicoNetwork:
    bgp: Disabled
    linuxDataplane: Nftables
    windowsDataplane: HNS
    ipPools:
      - name: default-ipv4-ippool
        cidr: 172.21.0.0/16
        blockSize: 26
        encapsulation: VXLAN
        natOutgoing: Enabled
        nodeSelector: all()
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
  name: default
spec: {}

Каждое поле здесь выбрано в прошлой части, коротко напомним:

  • bgp: Disabled — calico-node не запускает BIRD, маршруты к блокам других узлов и на Linux прописывает Felix по ресурсам Calico. Calico для Windows не умеет VXLAN вместе с BGP.
  • encapsulation: VXLAN вместо VXLANCrossSubnet — пакеты между подами разных узлов всегда идут в туннеле VXLAN, даже в пределах одной подсети: L2-кадр пода заворачивается в UDP-датаграмму на порт 4789 между адресами узлов (часть 18 сетевой серии). Цена — 50 байт заголовков на пакет и MTU подов 1450 вместо 1500. VNI 4096 и порт 4789 — значения по умолчанию, и других Windows не примет.
  • windowsDataplane: HNS — единственный датаплейн Calico для Windows. Пока поле не задано, оператор Windows-узлы не обслуживает, а с ним создаёт DaemonSet calico-node-windows — сейчас пустой, Windows-узлов нет.
  • serviceCIDRs — сеть сервисов, совпадающая с serviceSubnet из конфигурации kubeadm: CNI-плагин на Windows вешает на конечную точку HNS пода политику исходящего NAT (OutBoundNAT) с исключениями — сеть подов и сеть сервисов, — чтобы трафик к подам и сервисам уходил с адресом пода.
  • cidr: 172.21.0.0/16, blockSize: 26, natOutgoing: Enabled — как в части 4, только своя сеть; блок /26 на Windows-узле — это 60 адресов для подов, об этом в разделе про kubeadm join.
  • linuxDataplane: Nftables и APIServer — как в части 4. С нативными CRD APIServer разворачивает не агрегированный API-сервер, а под вебхуков проверки.
$ 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      3m45s   All objects available
calico           True        False         False      3m10s   All objects available
calico-windows                             True               Invalid StrictAffinity, it must be set to 'true' when using Calico IPAM on Windows: StrictAffinity is false, it must be set to 'true' in the default IPAMConfiguration when using Calico IPAM on Windows
ippools          True        False         False      4m50s   All objects available
tiers            True        False         False      4m50s   All objects available

Строк здесь больше, чем в части 4: tiers — уровни политик Calico из части 20, а calico-windows — состояние Windows-части, которое оператор показывает, раз в Installation есть windowsDataplane. Она в состоянии DEGRADED и прямо пишет, чего не хватает.

strictAffinity

Последняя настройка IPAM — модуля Calico, который раздаёт адреса подам:

$ kubectl patch ipamconfigurations default --type merge --patch='{"spec": {"strictAffinity": true}}'
ipamconfiguration.projectcalico.org/default patched
$ kubectl get ipamconfigurations default -o jsonpath='{.spec.strictAffinity}{"\n"}'
true
$ kubectl get tigerastatus calico-windows
NAME             AVAILABLE   PROGRESSING   DEGRADED   SINCE
calico-windows   True        False         False      25s

strictAffinity: true запрещает всем узлам занимать адреса из чужих блоков: Windows-узел держит ровно один блок, и адрес из него на другом узле сломал бы маршрутизацию (подробно — в прошлой части). Ставим сейчас, пока Windows-узла нет и ни один Linux-узел не успел занять чужой адрес. Ресурс IPAMConfiguration с именем default Calico создаёт сам при установке, нативный CRD позволяет менять его прямо kubectl.

Поды Calico на мастере:

$ kubectl -n calico-system get pods -o wide
NAME                                       READY   STATUS    RESTARTS   AGE     IP              NODE
calico-kube-controllers-7cbbcdd58d-2h4x8   1/1     Running   0          4m50s   172.21.70.196   k8s-lab-master-1
calico-node-k74mn                          1/1     Running   0          4m53s   192.168.10.23   k8s-lab-master-1
calico-typha-7c68577f9-hjzzm               1/1     Running   0          4m54s   192.168.10.23   k8s-lab-master-1
calico-webhooks-648955b74-2m5p7            1/1     Running   0          4m57s   172.21.70.195   k8s-lab-master-1
calico-webhooks-648955b74-zqrkl            1/1     Running   0          4m57s   172.21.70.194   k8s-lab-master-1
csi-node-driver-gjfgd                      2/2     Running   0          4m52s   172.21.70.197   k8s-lab-master-1

calico-webhooks — тот самый под вебхуков нативных CRD вместо calico-apiserver. Пока в кластере один узел, всё работает на мастере: его taint Calico допускает.

Linux-воркеры

k8s-lab-node-1 и k8s-lab-node-2 подключаем командой из вывода init, как в части 4, раздел «Рабочие узлы: kubeadm join», и одобряем серверные CSR kubelet мастера и воркеров, сверив имена и адреса, как в разделе «Сертификат для HTTPS kubelet». Команда на обоих воркерах одна и та же, ниже — первый:

user@k8s-lab-node-1:~$ sudo kubeadm join k8s-lab-api.example.com:6443 --token 3h8q2w.k5n9v1c7x4m6z0p8 \
    --discovery-token-ca-cert-hash sha256:7b2e9d4a1c6f8b3e0d5a2c7f9e4b1d6a8c3f0e5b2d7a9c4e1f6b8d3a0c5e2f7b
...
This node has joined the cluster:
...

$ kubectl get csr --field-selector spec.signerName=kubernetes.io/kubelet-serving
NAME        AGE     SIGNERNAME                      REQUESTOR                      REQUESTEDDURATION   CONDITION
csr-4wm2t   7m2s    kubernetes.io/kubelet-serving   system:node:k8s-lab-master-1   <none>              Pending
csr-7672m   18s     kubernetes.io/kubelet-serving   system:node:k8s-lab-node-1     <none>              Pending
csr-gsll8   9s      kubernetes.io/kubelet-serving   system:node:k8s-lab-node-2     <none>              Pending
csr-lp4tq   7m21s   kubernetes.io/kubelet-serving   system:node:k8s-lab-master-1   <none>              Pending
$ kubectl certificate approve csr-4wm2t csr-7672m csr-gsll8 csr-lp4tq
certificatesigningrequest.certificates.k8s.io/csr-4wm2t approved
certificatesigningrequest.certificates.k8s.io/csr-7672m approved
certificatesigningrequest.certificates.k8s.io/csr-gsll8 approved
certificatesigningrequest.certificates.k8s.io/csr-lp4tq approved

От мастера запросов два: kubelet повторяет CSR, пока на первый нет ответа, — одобрить можно оба, после выдачи сертификата kubelet возьмёт один. Пока CSR не одобрены, серверного сертификата у kubelet нет вовсе: kubectl logs к подам узла падает с remote error: tls: internal error, а metrics-server не проходит проверку готовности.

Обе реплики CoreDNS запустились на мастере ещё до появления воркеров: других узлов не было, а taint мастера CoreDNS допускает. Сами они не переедут — планировщик не трогает работающие поды. Перезапустим Deployment:

$ kubectl -n kube-system rollout restart deployment coredns
deployment.apps/coredns restarted
$ kubectl -n kube-system get pods -l k8s-app=kube-dns -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP              NODE
coredns-8f97fdbd9-9gc9h   1/1     Running   0          27s   172.21.172.3    k8s-lab-node-2
coredns-8f97fdbd9-vr6vv   1/1     Running   0          26s   172.21.70.199   k8s-lab-master-1

Реплики разошлись по разным узлам — это правило антиаффинности в манифесте kubeadm: «реплики kube-dns предпочтительно на разных узлах». Но одна из них снова на мастере. Правило только предпочтительное, taint мастера CoreDNS допускает, и планировщик сложил антиаффинность с остальными оценками узлов. Для DNS важно, что реплики на разных узлах: выключение любого из них оставит кластер с работающим DNS.

Проверка Linux-части

$ kubectl get nodes -o wide
NAME               STATUS   ROLES           AGE    VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION             CONTAINER-RUNTIME
k8s-lab-master-1   Ready    control-plane   8m2s   v1.37.2   192.168.10.23   <none>        Ubuntu 26.04.1 LTS   7.0.0-42-generic (amd64)   containerd://2.2.2
k8s-lab-node-1     Ready    <none>          60s    v1.37.2   192.168.10.24   <none>        Ubuntu 26.04.1 LTS   7.0.0-42-generic (amd64)   containerd://2.2.2
k8s-lab-node-2     Ready    <none>          51s    v1.37.2   192.168.10.26   <none>        Ubuntu 26.04.1 LTS   7.0.0-42-generic (amd64)   containerd://2.2.2

$ kubectl -n calico-system get ds
NAME                  DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR              AGE
calico-node           3         3         3       3            3           kubernetes.io/os=linux     6m49s
calico-node-windows   0         0         0       0            0           kubernetes.io/os=windows   107s
csi-node-driver       3         3         3       3            3           kubernetes.io/os=linux     6m48s

$ kubectl -n calico-system get pods -l k8s-app=calico-typha -o wide
NAME                           READY   STATUS    RESTARTS   AGE     IP              NODE
calico-typha-7c68577f9-8gjrr   1/1     Running   0          50s     192.168.10.24   k8s-lab-node-1
calico-typha-7c68577f9-hjzzm   1/1     Running   0          6m51s   192.168.10.23   k8s-lab-master-1

В колонке KERNEL-VERSION kubectl 1.37 дописывает к версии ядра архитектуру узла. calico-node-windows уже есть, с нулём желаемых подов — его создал windowsDataplane: HNS. Typha — посредник между API и агентами Calico на узлах: он один держит watch к kube-apiserver и раздаёт изменения всем Felix, чтобы сотни узлов не нагружали API каждый своим watch. Число реплик оператор выбирает по числу узлов: пока узел был один, работала одна реплика, с тремя узлами — две, на разных узлах, в сети узла (адрес пода — адрес узла). Felix на Windows-узле тоже будет подключаться к одной из них.

Главное отличие от основного кластера видно в таблице маршрутов Linux-воркера:

user@k8s-lab-node-2:~$ ip route
default via 192.168.10.1 dev ens18 proto static
172.21.70.192/26 via 172.21.70.192 dev vxlan.calico onlink mtu 1450
blackhole 172.21.172.0/26 proto 80
172.21.172.2 dev calid60012519f0 scope link metric 1024
172.21.172.3 dev caliad8a24266f7 scope link metric 1024
172.21.211.0/26 via 172.21.211.0 dev vxlan.calico onlink mtu 1450
192.168.10.0/24 dev ens18 proto kernel scope link src 192.168.10.26

user@k8s-lab-node-2:~$ ip -d link show vxlan.calico | grep -o 'vxlan id [0-9]* .*dstport [0-9]*'
vxlan id 4096 local 192.168.10.26 dev ens18 srcport 0 0 dstport 4789

user@k8s-lab-node-2:~$ pgrep -a bird || echo 'BIRD не запущен'
BIRD не запущен
user@k8s-lab-node-2:~$ sudo ss -ltn 'sport = :179'
State   Recv-Q   Send-Q   Local Address:Port   Peer Address:Port   Process

В части 4 маршрут к блоку соседа шёл via 192.168.10.17 dev ens18 proto 80 — напрямую через адрес узла. Здесь блоки мастера 172.21.70.192/26 и первого воркера 172.21.211.0/26 доступны через интерфейс vxlan.calico с туннельными адресами этих узлов (первый адрес блока — 172.21.70.192, 172.21.211.0 — Calico отдаёт VXLAN-интерфейсу узла), а у маршрутов свой MTU 1450 — место под заголовки VXLAN. Метки proto 80 у таких маршрутов нет: Felix ставит её только на blackhole собственного блока. onlink говорит ядру считать шлюз доступным прямо через этот интерфейс, хотя он и не в подсети интерфейса. VNI 4096, порт 4789, BGP-порт 179 никто не слушает. Блок самого воркера — 172.21.172.0/26, его адрес .0 занят туннельным адресом, .2 и .3 — поды Calico (csi-node-driver) и CoreDNS.


metrics-server и cert-manager

Два дополнения нужны тестовому кластеру сразу.

metrics-server — как в части 32, раздел «metrics-server», тем же манифестом и без --kubelet-insecure-tls: серверные сертификаты kubelet подписаны CA тестового кластера, CSR мы только что одобрили. Если забыть их одобрить, под metrics-server будет Running, но не Ready: проверка готовности отвечает 500, пока он не сможет снять метрики ни с одного kubelet.

$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.9.0/components.yaml
...
$ kubectl get apiservice v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        2m35s

cert-manager — как в части 19, раздел «Установка», тем же манифестом, но без флага --enable-gateway-api: Gateway API и Traefik в тестовом кластере не нужны. Издатели Let's Encrypt тоже не нужны — cert-manager здесь ради вебхука gMSA из части 40, которому нужен сертификат для HTTPS, выпущенный внутри кластера.

$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.2/cert-manager.yaml
...
$ kubectl -n cert-manager rollout status deploy cert-manager-webhook
deployment "cert-manager-webhook" successfully rolled out

Оба манифеста задают своим Deployment nodeSelector: kubernetes.io/os: linux — Windows-узел их не заинтересует, даже если когда-нибудь лишится taint. Prometheus, Grafana и csi-driver-smb поставим в тестовый кластер в части 39, а Traefik, MetalLB и остальное из основного кластера ему не понадобятся.

hello для проверки

Для проверки связности между ОС нужен Linux-сервис. Возьмём hello из части 5 — из того же частного пакета в ghcr.io, с секретом ghcr-pull для скачивания, привязанным к ServiceAccount default неймспейса, как в той части:

$ kubectl create namespace demo
$ kubectl label namespace demo pod-security.kubernetes.io/enforce=restricted
$ read -rsp 'GitHub token (read:packages): ' GHCR_TOKEN; echo
$ kubectl -n demo create secret docker-registry ghcr-pull \
    --docker-server=ghcr.io --docker-username=example --docker-password="$GHCR_TOKEN"
$ unset GHCR_TOKEN
$ kubectl -n demo patch serviceaccount default -p '{"imagePullSecrets":[{"name":"ghcr-pull"}]}'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
  namespace: demo
  labels:
    app.kubernetes.io/name: hello
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: hello
  template:
    metadata:
      labels:
        app.kubernetes.io/name: hello
    spec:
      os:
        name: linux
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: hello
          image: ghcr.io/example/hello:1.2
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet: {path: /readyz, port: http}
          livenessProbe:
            httpGet: {path: /healthz, port: http}
          resources:
            requests: {cpu: 100m, memory: 32Mi}
            limits: {memory: 64Mi}
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
  name: hello
  namespace: demo
spec:
  selector:
    app.kubernetes.io/name: hello
  ports:
    - name: http
      port: 80
      targetPort: http
$ kubectl apply -f hello.yaml
deployment.apps/hello created
service/hello created
$ kubectl -n demo get pods,svc -o wide
NAME                         READY   STATUS    RESTARTS   AGE   IP              NODE             NOMINATED NODE   READINESS GATES
pod/hello-55fb8f8684-69mvv   1/1     Running   0          45s   172.21.211.25   k8s-lab-node-1   <none>           <none>
pod/hello-55fb8f8684-nztv2   1/1     Running   0          45s   172.21.172.4    k8s-lab-node-2   <none>           <none>

NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE   SELECTOR
service/hello   ClusterIP   10.97.141.163   <none>        80/TCP    45s   app.kubernetes.io/name=hello

Реплики разошлись по двум Linux-воркерам: мастер закрыт taint control-plane, а планировщик по умолчанию разносит поды одного Deployment по разным узлам. Linux-основа готова — дальше Windows.


Подготовка Windows Server 2025 Core

Действующие лица из двух прошлых частей, коротко:

  • SCM (Service Control Manager) — диспетчер служб Windows, в этой роли аналог systemd: запускает службы при загрузке, перезапускает, учитывает зависимости.
  • HCS (Host Compute Service, служба vmcompute) — API Windows для создания контейнеров. Контейнер с изоляцией процессов — это job object (группа процессов с общими ограничениями памяти и процессора — роль cgroups), server silo (job object с собственными пространствами имён объектов, реестра и файлов — роль namespaces) и файловая система из слоёв образа.
  • HNS (Host Networking Service, служба hns) — создаёт виртуальные сети и конечные точки (endpoints, сетевые карты контейнеров) на виртуальном коммутаторе Hyper-V (vSwitch) и вешает на них политики; VFP (Virtual Filtering Platform) — расширение vSwitch, которое исполняет политики на каждом порту: NAT, ACL, балансировку, инкапсуляцию. Это место netfilter.
  • HostProcess-контейнер — обычный процесс хоста внутри job object, без изоляции файловой системы и сети. Так на Windows запускают сетевой плагин и kube-proxy, для которых на Linux берут привилегированные поды.

Вот что окажется на узле к концу статьи:

 k8s-lab-win-1.example.com — 192.168.10.25, Windows Server 2025 Core
 ┌───────────────────────────────────────────────────────────────┐
 │ Службы Windows (SCM)                                          │
 │   containerd ◄──── CRI, именованный канал ──── kubelet (nssm) │
 │       │                                                       │
 │       ▼ containerd-shim-runhcs-v1                             │
 │   HCS: job object, слои образа; сеть контейнера — через HNS   │
 │                                                               │
 │ HostProcess-поды (процессы хоста в сети хоста)                │
 │   calico-node-windows: node, felix ──► HNS: сеть, ACL         │
 │   kube-proxy-windows: winkernel    ──► HNS: балансировщики    │
 │                                                               │
 │ HNS + vSwitch с расширением VFP                               │
 │   сеть «Calico»: Overlay, 172.21.41.0/26, VNI 4096            │
 │     шлюз       172.21.41.1    — он же туннельный адрес узла   │
 │     Calico_ep  172.21.41.2    — адрес хоста в сети подов      │
 │     поды       172.21.41.5, .6 — aspnetapp                    │
 │   vEthernet (Ethernet) 192.168.10.25 — адрес самого хоста     │
 └───────────────────────────────┬───────────────────────────────┘
                                 │ Ethernet; VXLAN — UDP 4789
                                 ▼
                     сеть узлов 192.168.10.0/24

Установку containerd, kubelet и kubeadm берут на себя два PowerShell-скрипта из репозитория sig-windows-tools группы SIG Windows; на них опирается и официальное руководство Adding Windows worker nodes. Скрипты делают немного, и ниже разобрано всё, что они меняют.

Всё в этом разделе выполняется на k8s-lab-win-1 в PowerShell от имени администратора. Server Core после входа открывает sconfig — текстовое меню основных настроек; всё, что в нём есть, доступно и командами, а команды можно повторить на следующем узле.

Имя и адрес

PS C:\> New-NetIPAddress -InterfaceAlias Ethernet -IPAddress 192.168.10.25 -PrefixLength 24 -DefaultGateway 192.168.10.1
PS C:\> Set-DnsClientServerAddress -InterfaceAlias Ethernet -ServerAddresses 192.168.10.1
PS C:\> Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -Name 'NV Domain' -Value 'example.com'
PS C:\> Rename-Computer -NewName k8s-lab-win-1 -Restart
...
PS C:\> [System.Net.Dns]::GetHostEntry('').HostName
k8s-lab-win-1.example.com
PS C:\> (Resolve-DnsName k8s-lab-api.example.com -Type A).IPAddress
192.168.10.23

Имя узла в кластере берётся из имени компьютера, поэтому оно должно быть уникальным и стабильным; NetBIOS-имя компьютера ограничено 15 символами, и k8s-lab-win-1 в него укладывается. NV Domain — основной DNS-суффикс: узел не входит в домен Active Directory (нужен ли он Windows-узлу — вопрос части 40), и без суффикса его полное имя было бы просто k8s-lab-win-1. Адреса и DNS из PowerShell подробно разобраны во второй части серии про Windows Server. Шаблон виртуальной машины для клонирования готовят командой sysprep /generalize, которая сбрасывает идентификаторы установки, — аналог очистки /etc/machine-id.

Обновления

На Windows обновления ОС влияют на то, какие образы можно запускать: контейнер с изоляцией процессов работает на ядре хоста, и сборка ОС в образе должна совпадать со сборкой хоста — 10.0.26100 для Windows Server 2025 и тегов ltsc2025. Номер ежемесячного обновления (UBR) совпадать не обязан, но Microsoft тестирует новые ревизии образов на обновлённых хостах, поэтому хост обновляют до установки компонентов (пункт «Install updates» в sconfig) и затем каждый месяц:

PS C:\> Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
    Select-Object ProductName, InstallationType, CurrentBuild, UBR

ProductName                     InstallationType CurrentBuild UBR
-----------                     ---------------- ------------ ---
Windows Server 2025 Datacenter  Server Core      26100        33438

На свежеустановленном с ISO сервере обновления могут встать не с первого раза. Накопительное обновление требует свежего стека обслуживания (servicing stack — компонента, который сам ставит обновления), и если он устанавливается в том же проходе, накопительное завершается ошибкой 0x800F0823; после перезагрузки установку запускают ещё раз, и тогда оно встаёт. Само накопительное обновление ставится десятки минут и перезагружает сервер дважды.

Режим обновлений в sconfig переключают в Manual: узел кластера перезагружают только через drain и uncordon, как в части 35. Обновление работающего Windows-узла — тема части 39.

Время

Время на Windows синхронизирует служба W32Time — клиент и сервер NTP, протокола сверки часов с источником точного времени. В рабочей группе она по умолчанию ходит к time.windows.com; направим её к router.example.com, как Linux-узлы:

PS C:\> w32tm /config /manualpeerlist:"router.example.com,0x8" /syncfromflags:manual /update
The command completed successfully.
PS C:\> Restart-Service w32time; w32tm /resync
The command completed successfully.
PS C:\> w32tm /query /status | Select-String 'Stratum|Source'
Stratum: 3 (secondary reference - syncd by (S)NTP)
Source: router.example.com,0x8

Флаг 0x8 — режим клиента. Узел в домене получал бы время по доменной иерархии (часть 12 серии про Windows Server). Последствия ушедших часов те же, что на Linux: сертификаты kubelet «ещё не действительны» или «уже истекли».

Компонент Containers

PS C:\> Install-WindowsFeature -Name Containers

Success Restart Needed Exit Code      Feature Result
------- -------------- ---------      --------------
True    Yes            SuccessRest... {Containers}
WARNING: You must restart this server to finish the installation process.

PS C:\> Restart-Computer

Компонент Containers — место модулей overlay и br_netfilter из части 4: он приносит службы HCS (vmcompute) и HNS (hns), фильтр-драйверы файловой системы контейнеров и виртуальный коммутатор; ip_forward не нужен — пересылку делает vSwitch.

Файрвол

Windows Defender Firewall, в отличие от ufw в Ubuntu Server, включён по умолчанию. Узлу нужны 10250/TCP для API kubelet (правило создаст PrepareNode.ps1) и 4789/UDP для VXLAN. Правило для VXLAN создавать руками не нужно: когда Calico создаст на узле overlay-сеть, HNS добавит входящее правило OverlayTraffic4789UDP («Overlay Traffic 4789 UDP») сам — мы увидим его ниже. Остаётся по желанию 10256/TCP для проверки здоровья kube-proxy, которой пользуются внешние балансировщики:

PS C:\> New-NetFirewallRule -Name kube-proxy-health -DisplayName 'kube-proxy health' -Direction Inbound `
    -Protocol TCP -LocalPort 10256 -RemoteAddress 192.168.10.0/24 -Action Allow

Правило kubelet скрипт создаст открытым для всех адресов, его стоит сузить так же: Set-NetFirewallRule -Name kubelet -RemoteAddress 192.168.10.0/24.


containerd

PS C:\> curl.exe -LO https://raw.githubusercontent.com/kubernetes-sigs/sig-windows-tools/master/hostprocess/Install-Containerd.ps1
PS C:\> .\Install-Containerd.ps1 -ContainerDVersion 2.2.2 -crictlVersion 1.37.0 -skipHypervisorSupportCheck
Windows feature 'Containers' is already installed.
WARNING: Windows optional feature 'Microsoft-Hyper-V' is not enabled. Enabling it now...

Deployment Image Servicing and Management tool
...
The operation completed successfully.
WARNING: Windows optional feature 'Microsoft-Hyper-V-Online' is not disabled. Disabling it now...
...
The operation completed successfully.
WARNING: The Hyper-V features was installed without checking the CPU for Hypervisor support. You may not be able to host Hyper-V isolated containers if CPU doesn't support hypervisors.
Please reboot and re-run this script.
PS C:\> Restart-Computer

Скрипт лежит в ветке master, которая меняется; правильнее скачать его по ссылке на конкретный коммит, прочитать и хранить рядом с остальными файлами установки. Версию containerd он принимает без буквы v, версию crictl стоит задать по версии кластера.

Первый запуск до containerd не доходит. Без ключа -skipHypervisorSupportCheck скрипт поставил бы роли Hyper-V целиком, вместе с проверкой процессора; с ключом он включает через DISM (средство обслуживания образа Windows) компонент Microsoft-Hyper-V, выключает Microsoft-Hyper-V-Online и просит перезагрузиться и запустить его снова. Нашей виртуальной машине вложенная виртуализация не нужна: изоляция Hyper-V Kubernetes не поддерживается, и предупреждение о процессоре можно пропустить. Второй запуск:

PS C:\> .\Install-Containerd.ps1 -ContainerDVersion 2.2.2 -crictlVersion 1.37.0 -skipHypervisorSupportCheck
Windows feature 'Containers' is already installed.
Windows optional feature 'Microsoft-Hyper-V' is already enabled.
Windows optional feature 'Microsoft-Hyper-V-Online' is already disabled.
WARNING: The Hyper-V features was installed without checking the CPU for Hypervisor support. You may not be able to host Hyper-V isolated containers if CPU doesn't support hypervisors.
Getting ContainerD binaries
Downloading https://github.com/containerd/containerd/releases/download/v2.2.2/containerd-2.2.2-windows-amd64.tar.gz to C:\Program Files\containerd\containerd.tar.gz
Configuring ContainerD CNI section: [plugins.'io.containerd.cri.v1.runtime'.cni]
Registering ContainerD as a service
Starting ContainerD service
Downloading https://github.com/kubernetes-sigs/cri-tools/releases/download/v1.37.0/crictl-v1.37.0-windows-amd64.tar.gz to C:\Program Files\containerd\crictl.tar.gz
...
Done - please remember to add '--cri-socket "npipe:////./pipe/containerd-containerd"' to your kubeadm join command if your kubernetes version is below 1.25!

Что скрипт сделал:

  1. проверил компонент Containers и компоненты Hyper-V — теперь они на месте;
  2. распаковал containerd для Windows из релиза на GitHub в C:\Program Files\containerd и добавил каталог в системную переменную PATH;
  3. сгенерировал config.toml командой containerd.exe config default и прописал в секции CNI каталоги c:/opt/cni/bin и c:/etc/cni/net.d — пути как на Linux, с буквой диска; их и ждёт Calico;
  4. зарегистрировал containerd службой (containerd.exe --register-service): containerd сам умеет отвечать SCM и работает от имени LocalSystem;
  5. поставил crictl с конфигурацией $HOME\.crictl\crictl.yaml того пользователя, который запускал скрипт, где адрес среды — npipe://./pipe/containerd-containerd.

Перед запуском скрипт стоит открыть в отдельном окне PowerShell, а не перенаправлять его вывод в файл: tar.exe пишет список распакованных файлов в поток ошибок, и при перенаправлении *>&1 Windows PowerShell 5.1 превращает эти строки в ошибки и останавливает скрипт.

CRI на Windows работает не через unix-сокет, а через именованный канал (named pipe) \\.\pipe\containerd-containerd — объект ядра для обмена данными между процессами, адресуемый по имени. Действующая конфигурация:

PS C:\> containerd.exe config dump | Select-String "^version|default_runtime_name|runhcs-wcow-process\]|runtime_type|bin_dirs|conf_dir|sandbox ="

version = 3
      sandbox = 'registry.k8s.io/pause:3.10.1'
      default_runtime_name = 'runhcs-wcow-process'
          runtime_type = 'io.containerd.runhcs.v1'
          cni_conf_dir = ''
        [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runhcs-wcow-process]
          runtime_type = 'io.containerd.runhcs.v1'
          cni_conf_dir = ''
      bin_dirs = ['c:/opt/cni/bin']
      conf_dir = 'c:/etc/cni/net.d'
  • runhcs-wcow-process — обработчик (runtime handler) по умолчанию: WCOW — Windows Containers on Windows, process — изоляция процессов. Он запускает shim containerd-shim-runhcs-v1.exe из проекта hcsshim — посредника между containerd и HCS, который делает на Windows работу runc. Это имя понадобится в RuntimeClass. Рядом в конфигурации есть и обработчик runhcs-wcow-hypervisor для изоляции Hyper-V, которой мы не пользуемся.
  • Никакого SystemdCgroup: cgroups нет, ресурсы контейнера ограничивает job object.
  • sandbox — образ pause-контейнера. registry.k8s.io/pause мультиплатформенный, в его списке манифестов есть варианты для Windows, и containerd выбирает совпадающий со сборкой хоста.
PS C:\> crictl version | Select-String Runtime
RuntimeName:  containerd
RuntimeVersion:  v2.2.2
RuntimeApiVersion:  v1

kubelet и kubeadm службами Windows

PS C:\> curl.exe -LO https://raw.githubusercontent.com/kubernetes-sigs/sig-windows-tools/master/hostprocess/PrepareNode.ps1
PS C:\> .\PrepareNode.ps1 -KubernetesVersion v1.37.2
Using Kubernetes version: v1.37.2
Downloading https://dl.k8s.io/v1.37.2/bin/windows/amd64/kubelet.exe to C:\k\kubelet.exe
Downloading https://dl.k8s.io/v1.37.2/bin/windows/amd64/kubeadm.exe to C:\k\kubeadm.exe
...
Installing nssm
Registering kubelet service
...

Здесь версию принято писать с буквой v, хотя скрипт допишет её и сам. Пакетов и pkgs.k8s.io для Windows нет; вместо apt-get install скрипт:

  1. проверяет, что канал containerd на месте;
  2. скачивает kubelet.exe и kubeadm.exe с dl.k8s.io/v1.37.2/bin/windows/amd64/ в C:\k и добавляет C:\k в PATH;
  3. создаёт C:\var\log\kubelet, C:\etc\kubernetes\pki, C:\etc\kubernetes\manifests и C:\var\lib\kubelet\etc\kubernetes с символической ссылкой pki на C:\etc\kubernetes\pki: пути вида /var/lib/kubelet в конфигурации kubelet и kubeadm на Windows означают эти каталоги;
  4. пишет обёртку C:\k\StartKubelet.ps1, которая читает C:\var\lib\kubelet\kubeadm-flags.env и запускает kubelet.exe с этими флагами плюс постоянными;
  5. ставит nssm и регистрирует через него службу kubelet, зависящую от containerd, с выводом в C:\var\log\kubelet\kubelet.out.log и kubelet.err.log и ротацией этих файлов раз в сутки или по 10 МБ;
  6. создаёт правило файрвола kubelet для 10250/TCP.

nssm (Non-Sucking Service Manager) превращает в службу Windows любую консольную программу: отвечает SCM вместо неё, перезапускает её при падении и пишет её вывод в файлы. Служба nssm и StartKubelet.ps1 играют роль юнита kubelet.service с drop-in 10-kubeadm.conf из части 4: обёртка так же подмешивает флаги из kubeadm-flags.env. Из постоянных флагов интересны --cgroups-per-qos=false и --enforce-node-allocatable="" — иерархии cgroups нет, и kubelet её не строит (поэтому systemReserved на Windows лишь уменьшает объём, доступный планировщику), --resolv-conf="" — DNS-настройки подам kubelet передаёт прямо в конечную точку HNS, и --hostname-override с именем компьютера.

Обёртка — не единственный способ: с флагом --windows-service kubelet.exe сам отвечает SCM, и его можно зарегистрировать командой New-Service. Но тогда флаги из kubeadm-flags.env придётся переносить в параметры службы руками после каждого join и upgrade, а обёртка перечитывает их при каждом запуске. Мы следуем официальному руководству — nssm. apt-mark hold не нужен: бинарники в C:\k никто, кроме нас, не обновит.

В отличие от Linux, где kubelet до join перезапускается по кругу, здесь служба зарегистрирована, но не запущена — её запустит сам kubeadm join:

PS C:\> Get-Service kubelet | Format-Table Status, Name, StartType

 Status Name    StartType
 ------ ----    ---------
Stopped kubelet Automatic

kubeadm join

Для Windows-узла команды из вывода init мало: узел должен появиться в кластере сразу с taint, иначе между регистрацией и kubectl taint планировщик успеет отправить на него Linux-поды, которые не запустятся (у Linux-образа нет варианта для Windows). Поэтому — файл конфигурации, как kubeadm-config.yaml в части 4. Токен создаём на мастере с коротким сроком жизни:

user@k8s-lab-master-1:~$ sudo kubeadm token create --ttl 1h --description 'join k8s-lab-win-1'
6w1r8t.p3k9m2x7c5v0b4n1

На узле — C:\k\join.yaml:

apiVersion: kubeadm.k8s.io/v1beta4
kind: JoinConfiguration
discovery:
  bootstrapToken:
    apiServerEndpoint: k8s-lab-api.example.com:6443
    token: 6w1r8t.p3k9m2x7c5v0b4n1
    caCertHashes:
      - sha256:7b2e9d4a1c6f8b3e0d5a2c7f9e4b1d6a8c3f0e5b2d7a9c4e1f6b8d3a0c5e2f7b
nodeRegistration:
  name: k8s-lab-win-1
  criSocket: npipe:////./pipe/containerd-containerd
  taints:
    - key: os
      value: windows
      effect: NoSchedule
patches:
  directory: C:\k\patches

и патч C:\k\patches\kubeletconfiguration.yaml:

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
maxPods: 60
  • discovery.bootstrapToken — то же, что --token и --discovery-token-ca-cert-hash в части 4: токен для TLS bootstrapping и хеш открытого ключа CA, по которому узел проверяет подлинность кластера. Хеш — тот же, что в выводе init тестового кластера.
  • criSocket — адрес CRI: схема npipe:// и путь к каналу. kubeadm на Windows подставляет его и сам, но явное значение читается лучше.
  • taints — kubelet зарегистрирует Node сразу с запретом os=windows:NoSchedule. Ключ и значение — договорённость из документации Kubernetes, а не встроенное имя.
  • patches — каталог с патчами к конфигурации из кластера; имя файла задаёт цель, kubeletconfiguration — общий KubeletConfiguration из ConfigMap kubelet-config. Патч действует только на этом узле.

Зачем maxPods: 60: Calico на Windows держит на узле один блок адресов и резервирует в нём четыре адреса (адрес сети, шлюз, Calico_ep и последний адрес), как разобрано в прошлой части. Из 64 адресов блока /26 подам остаётся 60, а kubelet по умолчанию рассчитывает на 110 подов — шестьдесят первый получил бы узел, но не адрес. Патчи kubeadm в кластере не хранит, поэтому при обновлении узла каталог передают снова: kubeadm upgrade node --patches C:\k\patches.

PS C:\k> kubeadm join --config C:\k\join.yaml
[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.
W1106 23:16:41.134109    5016 utils.go:69] The recommended value for "authentication.x509.clientCAFile" in "KubeletConfiguration" is: \etc\kubernetes\pki\ca.crt; the provided value is: /etc/kubernetes/pki/ca.crt
[kubelet-start] Writing kubelet configuration to file "\\var\\lib\\kubelet\\instance-config.yaml"
[patches] Reading patches from path "C:\\k\\patches"
[patches] Found the following patch files: [kubeletconfiguration.yaml]
[patches] Applied patch of type "application/strategic-merge-patch+json" to target "kubeletconfiguration"
[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
[patches] Applied patch of type "application/strategic-merge-patch+json" to target "kubeletconfiguration"
[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 3.0008224s
[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.

PS C:\k> Remove-Item C:\k\join.yaml

Механизм тот же, что в части 4: анонимное чтение cluster-info с проверкой подписи JWS и хеша CA, временный bootstrap-kubelet.conf с токеном, CSR на клиентский сертификат system:node:k8s-lab-win-1, постоянный C:\etc\kubernetes\kubelet.conf. Только kubelet kubeadm перезапускает через SCM, а не через systemd. Предупреждение про clientCAFile безобидно: kubeadm сравнивает строки путей, а /etc/kubernetes/pki/ca.crt на Windows и так означает C:\etc\kubernetes\pki\ca.crt. Патч применён трижды — к общей конфигурации, к config.yaml узла и к его instance-config.yaml, куда kubeadm с недавних пор кладёт параметры конкретного узла. join.yaml с токеном удаляем сразу.

Серверный сертификат kubelet (serverTLSBootstrap: true из общей конфигурации) ждёт одобрения, как у Linux-узлов. Сверяем имена и одобряем:

$ kubectl get csr --field-selector spec.signerName=kubernetes.io/kubelet-serving | grep Pending
csr-ljq25   9s    kubernetes.io/kubelet-serving   system:node:k8s-lab-win-1      <none>              Pending

$ kubectl get csr csr-ljq25 -o jsonpath='{.spec.request}' | base64 -d | openssl req -noout -text | grep -A1 'Subject Alternative'
            X509v3 Subject Alternative Name:
                DNS:k8s-lab-win-1, IP Address:192.168.10.25

$ kubectl certificate approve csr-ljq25
certificatesigningrequest.certificates.k8s.io/csr-ljq25 approved

Узел в кластере, но, как мастер до установки Calico, — NotReady:

$ kubectl get node k8s-lab-win-1 -o wide
NAME            STATUS     ROLES    AGE   VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION             CONTAINER-RUNTIME
k8s-lab-win-1   NotReady   <none>   10s   v1.37.2   192.168.10.25   <none>        Windows Server 2025 Datacenter   10.0.26100.33438 (amd64)   containerd://2.2.2

$ kubectl describe node k8s-lab-win-1 | grep -E 'NetworkReady'
  Ready            False   Fri, 06 Nov 2026 23:16:46 +0300   Fri, 06 Nov 2026 23:16:46 +0300   KubeletNotReady              [container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized, CSINode is not yet initialized]

$ kubectl get node k8s-lab-win-1 -o jsonpath='{.spec.taints}{"\n"}{.status.allocatable.pods}{"\n"}'
[{"effect":"NoSchedule","key":"os","value":"windows"},{"effect":"NoSchedule","key":"node.kubernetes.io/not-ready"},{"effect":"NoExecute","key":"node.kubernetes.io/not-ready","timeAdded":"2026-11-06T20:16:50Z"}]
60

Taint os=windows:NoSchedule стоит с первой секунды, рядом — служебные not-ready, которые снимутся сами. Ёмкость узла — 60 подов, патч сработал.


Calico для Windows

Что уже готово в кластере

В основном кластере здесь пришлось бы менять Installation и создавать ConfigMap. Тестовый кластер мы собрали сразу под Windows, поэтому только проверяем:

$ kubectl get installation default -o jsonpath='{.spec.calicoNetwork.bgp} {.spec.calicoNetwork.ipPools[0].encapsulation} {.spec.calicoNetwork.windowsDataplane} {.spec.serviceCIDRs}{"\n"}'
Disabled VXLAN HNS ["10.96.0.0/12"]
$ kubectl get ipamconfigurations default -o jsonpath='{.spec.strictAffinity}{"\n"}'
true
$ kubectl -n tigera-operator get configmap kubernetes-services-endpoint -o jsonpath='{.data}{"\n"}'
{"KUBERNETES_SERVICE_HOST":"k8s-lab-api.example.com","KUBERNETES_SERVICE_PORT":"6443"}

Все условия Calico для Windows на месте: VXLAN без CrossSubnet, BGP выключен, датаплейн HNS включён, сеть сервисов известна, strictAffinity и прямой адрес API. Ничего больше делать не нужно: как только в кластере появился узел с меткой kubernetes.io/os=windows, DaemonSet calico-node-windows получил желаемый под.

Про версию ОС. Страница требований Calico для Windows на момент написания перечисляет Windows Server 1809 и 2022, а Windows Server 2025 в ней нет; там же сказано, что поддерживаемые версии зависят от версии Kubernetes, со ссылкой на таблицу Kubernetes, где 2025 есть. У нас связка Calico 3.33.0 и Windows Server 2025 (26100.33438) работает, но формально Calico её явно не подтверждает. Для рабочего кластера это повод сверить release notes своей версии Calico или держать Windows-узлы на Windows Server 2022, пока 2025 не появится в списке.

Что появляется на узле

$ kubectl -n calico-system get ds calico-node-windows
NAME                  DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR              AGE
calico-node-windows   1         1         0       1            0           kubernetes.io/os=windows   83m

$ kubectl -n calico-system get pod -l k8s-app=calico-node-windows -o json \
    | jq -c '.items[0].spec | {init: [.initContainers[].name], containers: [.containers[] | {name, windowsOptions: .securityContext.windowsOptions}]}'
{"init":["uninstall-calico","install-cni"],"containers":[{"name":"felix","windowsOptions":{"hostProcess":true,"runAsUserName":"NT AUTHORITY\\system"}},{"name":"node","windowsOptions":{"hostProcess":true,"runAsUserName":"NT AUTHORITY\\system"}}]}

Разберём под:

  • hostProcess: true и runAsUserName: NT AUTHORITY\system заданы у каждого контейнера, а не у пода, — так тоже можно: контейнеры работают как процессы хоста от имени SYSTEM, самой привилегированной локальной учётной записи, в сети хоста (hostNetwork: true, у пода адрес узла). HCS всё равно создаёт для них job object (так kubelet считает ресурсы и останавливает под целиком), а файловую систему образа подключает в каталог, путь к которому лежит в переменной CONTAINER_SANDBOX_MOUNT_POINT. Процесс видит диски, реестр, службы и сеть хоста, поэтому такие поды запрещены уровнями baseline и restricted Pod Security Admission (часть 30), и неймспейс calico-system оператор помечает уровнем privileged.
  • uninstall-calico удаляет Calico, поставленный прежним способом (скриптами и службами Windows); install-cni кладёт плагины в C:\opt\cni\bin, а конфигурацию CNI и kubeconfig плагина — в C:\etc\cni\net.d.
  • node регистрирует узел в данных Calico, получает блок адресов и создаёт HNS-сеть. felix подключается к Typha на Linux-узлах, превращает NetworkPolicy (часть 20) в ACL-политики на конечных точках HNS, которые исполняет VFP, и прописывает в HNS-сеть маршруты к блокам подов других узлов через VXLAN. Политик для самих узлов (host endpoints) на Windows нет.

В режиме BGP был бы ещё контейнер confd и роль RemoteAccess на узле. Под готов через три-четыре минуты:

$ kubectl -n calico-system describe pod -l k8s-app=calico-node-windows | grep -E 'Pulled|Unhealthy'
  Normal   Pulled     116s               kubelet  spec.initContainers{uninstall-calico}: Successfully pulled image "quay.io/calico/node-windows:v3.33.0" in 1m0.951s (1m0.951s including waiting). Image size: 146560272 bytes.
  Normal   Pulled     92s                kubelet  spec.initContainers{install-cni}: Successfully pulled image "quay.io/calico/cni-windows:v3.33.0" in 17.528s (17.528s including waiting). Image size: 149137842 bytes.
  Warning  Unhealthy  20s (x5 over 58s)  kubelet  spec.containers{felix}: Liveness probe failed: calico/node is not ready: Felix is not live: Get "http://localhost:9099/liveness": dial tcp [::1]:9099: connectex: No connection could be made because the target machine actively refused it.

$ kubectl -n calico-system get pods -l k8s-app=calico-node-windows
NAME                        READY   STATUS    RESTARTS   AGE
calico-node-windows-q54h2   2/2     Running   0          4m6s

$ kubectl get node k8s-lab-win-1
NAME            STATUS   ROLES    AGE    VERSION
k8s-lab-win-1   Ready    <none>   4m6s   v1.37.2

Образы calico/node-windows и calico/cni-windows весят по 140 МБ с лишним. Первую минуту после старта Felix не отвечает на пробы — это нормально, пока он строит сеть, и под не перезапускается: kubelet ждёт, пока проба живучести не провалится нужное число раз подряд.

На узле появились HNS-сети и новый сетевой адаптер:

PS C:\> Get-HnsNetwork | Select-Object Name, Type,
    @{n='Subnet';e={$_.Subnets.AddressPrefix}}, @{n='Gateway';e={$_.Subnets.GatewayAddress}}, ManagementIP |
    Format-Table

Name     Type    Subnet           Gateway       ManagementIP
----     ----    ------           -------       ------------
External Overlay 192.168.255.0/30 192.168.255.1 192.168.10.25
Calico   Overlay 172.21.41.0/26   172.21.41.1   192.168.10.25

PS C:\> Get-NetIPAddress -AddressFamily IPv4 | Where-Object PrefixOrigin -ne WellKnown |
    Format-Table InterfaceAlias, IPAddress, PrefixLength

InterfaceAlias        IPAddress     PrefixLength
--------------        ---------     ------------
vEthernet (Ethernet)  192.168.10.25           24

PS C:\> Get-HnsEndpoint | Where-Object Name -eq Calico_ep | Format-List Name, IPAddress, VirtualNetworkName

Name               : Calico_ep
IPAddress          : 172.21.41.2
VirtualNetworkName : Calico

PS C:\> Get-NetFirewallRule -Name OverlayTraffic4789UDP | Format-Table Name, DisplayName, Enabled, Direction, Action

Name                  DisplayName              Enabled Direction Action
----                  -----------              ------- --------- ------
OverlayTraffic4789UDP Overlay Traffic 4789 UDP    True   Inbound  Allow
  • Сеть External — служебная сеть типа Overlay с крошечной подсетью 192.168.255.0/30: Calico создаёт её первой, и вместе с ней HNS поднимает внешний vSwitch поверх адаптера Ethernet.
  • Сеть Calico типа Overlay — с подсетью-блоком 172.21.41.0/26, который Calico выделил узлу. Шлюз сети 172.21.41.1 — порт распределённого маршрутизатора vSwitch; его же Calico записывает узлу как туннельный адрес VXLAN. В режиме Overlay трафик к подам других узлов VFP заворачивает в VXLAN.
  • vEthernet (Ethernet) — создавая первую сеть, HNS перенёс адрес хоста на виртуальный адаптер в коммутаторе. В момент переноса сеть хоста на несколько секунд пропадает, и kubelet иногда не восстанавливает соединение с API сам (см. раздел о диагностике).
  • Calico_ep — конечная точка самого узла в сети подов: через него хост достаёт до подов, а kube-proxy использует его адрес как source VIP.
  • OverlayTraffic4789UDP — то самое правило файрвола для VXLAN, которое HNS добавил вместе с overlay-сетью.

kube-proxy для Windows

DaemonSet kube-proxy от kubeadm на Windows-узел не попадёт: у него nodeSelector: kubernetes.io/os: linux, и образ у него только для Linux. Для Windows нужен отдельный DaemonSet в HostProcess-подах; манифест для Calico лежит в sig-windows-tools, заглушку KUBE_PROXY_VERSION заменяем на версию кластера:

$ curl -sL https://raw.githubusercontent.com/kubernetes-sigs/sig-windows-tools/master/hostprocess/calico/kube-proxy/kube-proxy.yml \
    | sed 's/KUBE_PROXY_VERSION/v1.37.2/g' > kube-proxy-windows.yaml
$ grep -E 'image:|hostProcess:|kubernetes.io/os' kube-proxy-windows.yaml
          hostProcess: true
      - image: sigwindowstools/kube-proxy:v1.37.2-calico-hostprocess
        kubernetes.io/os: windows
$ kubectl apply -f kube-proxy-windows.yaml
daemonset.apps/kube-proxy-windows created

Манифест создаёт только DaemonSet kube-proxy-windows в kube-system; сервисную учётную запись kube-proxy с правами RBAC и ConfigMap kube-proxy с kubeconfig он берёт у Linux-собрата. config.conf из того же ConfigMap — наш KubeProxyConfiguration с mode: nftables — на Windows не используется: параметры задаются флагами. Толерантность operator: Exists пускает под на узел с любым taint.

Образ собирает SIG Windows: внутри официальный kube-proxy.exe и скрипт start.ps1, который ждёт появления HNS-сети Calico и передаёт её имя в переменной KUBE_NETWORK; для сети типа Overlay ждёт конечную точку Calico_ep и передаёт его адрес флагом --source-vip; удаляет старые политики балансировки в HNS (kube-proxy не умеет согласовывать с ними состояние) и запускает kube-proxy с --proxy-mode=kernelspace и --enable-dsr=true. Что такое режим kernelspace, source VIP и DSR, разобрано в прошлой части.

$ kubectl -n kube-system get pods -o wide --field-selector spec.nodeName=k8s-lab-win-1
NAME                       READY   STATUS    RESTARTS   AGE   IP              NODE            NOMINATED NODE   READINESS GATES
kube-proxy-windows-z9qdq   1/1     Running   0          30s   192.168.10.25   k8s-lab-win-1   <none>           <none>
$ kubectl -n kube-system logs -l k8s-app=kube-proxy-windows | head -6
WARNING: The names of some imported commands from the module 'hns' include unapproved verbs that might make them less
discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose
parameter. For a list of approved verbs, type Get-Verb.
Running kub-proxy service.
Waiting for HNS network Calico to be created...
HNS network Calico found.

Предупреждение о «неодобренных глаголах» — от вспомогательного модуля hns со старыми именами команд, опечатка kub-proxy — из самого скрипта; дальше видно, как он ждёт сеть Calico и запускается.


Taint, RuntimeClass и Linux-DaemonSet'ы

Kubernetes сам ставит на узлы метки kubernetes.io/os и kubernetes.io/arch, а на Windows-узел — ещё node.kubernetes.io/windows-build:

$ kubectl get node k8s-lab-win-1 -L kubernetes.io/os,node.kubernetes.io/windows-build
NAME            STATUS   ROLES    AGE    VERSION   OS        WINDOWS-BUILD
k8s-lab-win-1   Ready    <none>   6m6s   v1.37.2   windows   10.0.26100

Поды по узлам разводят три механизма из части 13, каждый со своей задачей.

Taint os=windows:NoSchedule защищает Windows-узел от всего, что про Windows не знает: Deployment без nodeSelector или чарт, написанный в расчёте на Linux, на него не попадут — у их подов нет толерантности.

RuntimeClass избавляет Windows-поды от необходимости повторять nodeSelector и толерантность в каждом манифесте. Это объект кластерного уровня, связывающий имя с обработчиком containerd и условиями размещения:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: windows-2025
handler: runhcs-wcow-process
scheduling:
  nodeSelector:
    kubernetes.io/os: windows
    kubernetes.io/arch: amd64
    node.kubernetes.io/windows-build: "10.0.26100"
  tolerations:
    - key: os
      operator: Equal
      value: windows
      effect: NoSchedule
$ kubectl apply -f runtimeclass-windows-2025.yaml
runtimeclass.node.k8s.io/windows-2025 created

Admission-плагин RuntimeClass дописывает в под с runtimeClassName: windows-2025 селектор и толерантность из scheduling, а kubelet передаёт containerd имя обработчика. Сборка в селекторе делает имя класса честным: образ ltsc2025 на Windows Server 2022 не запустится, и у узлов каждой версии будет свой RuntimeClass. Поле spec.os.name: windows RuntimeClass не ставит, его пишут явно: по нему API-сервер отклоняет Linux-поля securityContext, а Pod Security Admission смягчает неприменимые к Windows проверки.

nodeSelector: kubernetes.io/os: linux у Linux-DaemonSet'ов — третья часть, о которой легко забыть. Системные DaemonSet'ы часто толерантны ко всему (operator: Exists), чтобы работать на любых узлах; taint их не остановит, на Linux их держит только селектор. Проверим, что стоит в тестовом кластере:

$ kubectl get ds -A -o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,NODE-SELECTOR:.spec.template.spec.nodeSelector,TOLERATIONS:.spec.template.spec.tolerations[*].operator'
NAMESPACE       NAME                  NODE-SELECTOR                   TOLERATIONS
calico-system   calico-node           map[kubernetes.io/os:linux]     Exists,Exists,Exists
calico-system   calico-node-windows   map[kubernetes.io/os:windows]   Exists,Exists,Exists
calico-system   csi-node-driver       map[kubernetes.io/os:linux]     Exists,Exists,Exists
kube-system     kube-proxy            map[kubernetes.io/os:linux]     Exists
kube-system     kube-proxy-windows    map[kubernetes.io/os:windows]   Exists,Exists

Селектор у kube-proxy записан в манифесте kubeadm, у calico-node и csi-node-driver его ставит оператор Tigera — каждому компоненту по ОС, которую тот поддерживает. Тестовый кластер молодой, и других DaemonSet'ов в нём пока нет; они появятся в части 39 вместе с мониторингом и хранилищем. Хороший пример того, почему правило важно, — node-exporter из kube-prometheus-stack (часть 32). Его чарт по умолчанию толерантен к любому taint с эффектом NoSchedule — чтобы собирать метрики и с мастеров, — а значит, и к os=windows:NoSchedule:

$ helm show values oci://ghcr.io/prometheus-community/charts/prometheus-node-exporter | grep -A3 -E '^(nodeSelector|tolerations):'
nodeSelector:
  kubernetes.io/os: linux
  #  kubernetes.io/arch: amd64

tolerations:
  - effect: NoSchedule
    operator: Exists

На Linux его удерживает только nodeSelector: kubernetes.io/os: linux, который чарт, к счастью, ставит сам. Кто переопределит nodeSelector в своих values, например чтобы добавить метку архитектуры, и забудет про ОС, получит под node-exporter с Linux-образом на Windows-узле — вечный ErrImagePull.

Правило: у любого DaemonSet'а с Linux-образом должен быть селектор kubernetes.io/os: linux, даже если сейчас его спасает taint; у каждого нового чарта это проверяют в значениях по умолчанию (helm show values) и после установки той же командой kubectl get ds -A. Агенты логов и метрик для самих Windows-узлов — в части 39.


Проверка: Windows-под, Service, DNS и связность

Повторим проверку из части 4 поперёк ОС: под на Linux-узле обращается к Windows-поду и наоборот — по адресам подов, через Service и по именам DNS. Windows-приложение возьмём готовое — пример веб-приложения на .NET, который Microsoft публикует в образе на основе Nano Server, mcr.microsoft.com/dotnet/samples:aspnetapp-nanoserver-ltsc2025. Тег плавающий и указывает на самую новую версию .NET — на момент написания это .NET 11 RC; для рабочих приложений образ собирают сами, об этом часть 39. Образ весит около 260 МБ — против 2,4 ГБ у IIS на Server Core, — но скачаем его на узел заранее:

PS C:\> crictl pull mcr.microsoft.com/dotnet/samples:aspnetapp-nanoserver-ltsc2025
Image is up to date for sha256:35169c38821e233b05473c07337b96034a71bbab3cda564a2817d5e9bdb0fd00
PS C:\> crictl images | Select-String samples
mcr.microsoft.com/dotnet/samples           aspnetapp-nanoserver-ltsc2025   35169c38821e2       258MB

Образы на Nano Server работают от непривилегированной учётной записи ContainerUser, поэтому неймспейсу win-demo можно дать тот же уровень restricted Pod Security Admission, что и demo, — как разобрано в части 36, для Windows-пода он требует только runAsNonRoot:

apiVersion: v1
kind: Namespace
metadata:
  name: win-demo
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.37
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: aspnetapp
  namespace: win-demo
  labels:
    app.kubernetes.io/name: aspnetapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: aspnetapp
  template:
    metadata:
      labels:
        app.kubernetes.io/name: aspnetapp
    spec:
      os:
        name: windows
      runtimeClassName: windows-2025
      securityContext:
        runAsNonRoot: true
        windowsOptions:
          runAsUserName: ContainerUser
      containers:
        - name: aspnetapp
          image: mcr.microsoft.com/dotnet/samples:aspnetapp-nanoserver-ltsc2025
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /
              port: http
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: "1"
              memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
  name: aspnetapp
  namespace: win-demo
spec:
  selector:
    app.kubernetes.io/name: aspnetapp
  ports:
    - name: http
      port: 80
      targetPort: http
$ kubectl apply -f aspnetapp.yaml
namespace/win-demo created
deployment.apps/aspnetapp created
service/aspnetapp created

$ kubectl -n win-demo get pods,svc -o wide
NAME                             READY   STATUS    RESTARTS   AGE   IP            NODE            NOMINATED NODE   READINESS GATES
pod/aspnetapp-769559d8ff-dkp9z   1/1     Running   0          56s   172.21.41.6   k8s-lab-win-1   <none>           <none>
pod/aspnetapp-769559d8ff-vk445   1/1     Running   0          57s   172.21.41.5   k8s-lab-win-1   <none>           <none>

NAME                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE   SELECTOR
service/aspnetapp   ClusterIP   10.102.98.171   <none>        80/TCP    57s   app.kubernetes.io/name=aspnetapp

$ kubectl -n win-demo get pod aspnetapp-769559d8ff-dkp9z -o json | jq '{nodeSelector: .spec.nodeSelector, tolerations: [.spec.tolerations[] | select(.key == "os")]}'
{
    "nodeSelector": {
        "kubernetes.io/arch": "amd64",
        "kubernetes.io/os": "windows",
        "node.kubernetes.io/windows-build": "10.0.26100"
    },
    "tolerations": [
        {
            "effect": "NoSchedule",
            "key": "os",
            "operator": "Equal",
            "value": "windows"
        }
    ]
}

$ kubectl -n win-demo exec aspnetapp-769559d8ff-dkp9z -- cmd /c "echo %USERNAME% & ver"
ContainerUser

Microsoft Windows [Version 10.0.26100.33674]

Под прошёл restricted без единого предупреждения. В манифесте не было ни nodeSelector, ни толерантности os=windows — их дописал admission-плагин RuntimeClass; остальные толерантности пода (not-ready, unreachable) Kubernetes добавляет любому поду, поэтому выбираем нужную фильтром. Внутри — ContainerUser, а ревизия Windows в образе (26100.33674) новее, чем на хосте (26100.33438): совпадать должна только сборка. whoami и PowerShell в Nano Server нет, поэтому — cmd /c. Служебные адреса блока 172.21.41.0/26 подам не достаются: .0 — адрес сети, .1 — шлюз HNS-сети, .2 — Calico_ep, .63 — последний адрес блока; поды получили .5 и .6, а .3 и .4 до них успели побывать у других подов — адреса не переиспользуются сразу.

С Linux на Windows

Под check создаём в неймспейсе default: на нём нет ограничений Pod Security, а ping в busybox требует прав root, которых restricted не даст. Без RuntimeClass taint не пустит под на Windows-узел — он окажется на одном из Linux-воркеров, у нас на k8s-lab-node-2:

$ kubectl run -i --rm check --image=busybox:1.37 --restart=Never -- sh -c '
    nslookup aspnetapp.win-demo.svc.cluster.local | tail -3
    wget -qO- http://aspnetapp.win-demo.svc.cluster.local | grep -o "<title>[^<]*</title>"
    ping -c 2 172.21.41.6'
Name:	aspnetapp.win-demo.svc.cluster.local
Address: 10.102.98.171

<title>Welcome to .NET - AspNetCoreRazorApp</title>
PING 172.21.41.6 (172.21.41.6): 56 data bytes
64 bytes from 172.21.41.6: seq=0 ttl=126 time=0.405 ms
64 bytes from 172.21.41.6: seq=1 ttl=126 time=0.489 ms
...
pod "check" deleted from default namespace
 k8s-lab-node-2 (Linux)                       k8s-lab-win-1 (Windows)
 ┌────────────────────────────┐               ┌────────────────────────────┐
 │ под check на этом узле     │               │ под aspnetapp 172.21.41.6  │
 │   │ aspnetapp.win-demo...  │               │   ▲                        │
 │   │ CoreDNS → 10.102.98.171│               │   │ VFP: ACL от Felix      │
 │   ▼                        │               │   │                        │
 │ nftables kube-proxy: DNAT  │               │ vSwitch, сеть «Calico»:    │
 │ 10.102.98.171:80 →         │               │ снять заголовок VXLAN      │
 │ 172.21.41.6:8080           │               │   ▲                        │
 │   ▼                        │   UDP 4789    │   │                        │
 │ vxlan.calico, VNI 4096 ────┼──────────────►┼───┘                        │
 └────────────────────────────┘  .26 → .25    └────────────────────────────┘

Имя сервиса разрешил CoreDNS (часть 16), ClusterIP превратил в адрес Windows-пода kube-proxy на Linux-узле — правилами nftables, ему всё равно, на какой ОС работает под. Пакет к блоку 172.21.41.0/26 ушёл в vxlan.calico — маршрут к нему Felix прописал, как только Windows-узел получил блок, — на Windows-узле VFP снял заголовок VXLAN и доставил пакет на порт пода. TTL 126: Windows ставит 128, а на обратном пути ответ проходит две маршрутизации — шлюз HNS-сети на Windows-узле и ядро Linux-узла. С самого Linux-узла ping того же пода показал бы ttl=127.

С Windows на Linux

Теперь из Windows-пода к hello в неймспейсе demo, который мы развернули на Linux-воркерах. В Nano Server нет nslookup, зато есть curl.exe и ping; какой адрес получил клиент из DNS, покажет сам curl.exe:

$ kubectl -n win-demo exec deploy/aspnetapp -- curl.exe -s -o NUL -w "%{remote_ip} %{http_code}\n" http://hello.demo.svc.cluster.local/healthz
10.97.141.163 200

$ kubectl -n win-demo exec deploy/aspnetapp -- ping -n 2 172.21.172.4

Pinging 172.21.172.4 with 32 bytes of data:
Reply from 172.21.172.4: bytes=32 time=13ms TTL=63
Reply from 172.21.172.4: bytes=32 time<1ms TTL=63
...

Здесь ClusterIP сервиса hello в адрес Linux-пода превратил уже kube-proxy для Windows — политикой балансировки HNS на порту пода. Особенности Windows-подов, о которых шла речь в прошлой части, — в действии:

$ kubectl -n win-demo exec deploy/aspnetapp -- curl.exe -s -o NUL -w "%{remote_ip} %{http_code}\n" --max-time 5 http://hello.demo/healthz
 000
command terminated with exit code 6
$ kubectl -n win-demo exec deploy/aspnetapp -- curl.exe -s -o NUL -w "%{remote_ip} %{http_code}\n" http://aspnetapp/
10.102.98.171 200
$ kubectl -n win-demo exec deploy/aspnetapp -- ping -n 2 1.1.1.1 | tail -3
    Packets: Sent = 2, Received = 2, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
    Minimum = 9ms, Maximum = 9ms, Average = 9ms
  • Имя с точкой не дополняется суффиксами поиска. Код выхода 6 у curl.exe — «не удалось разрешить имя»: hello.demo клиент DNS в Windows не дополняет, а aspnetapp без точки — дополняет. К сервисам других неймспейсов из Windows-подов обращаются по полному имени.
  • ping наружу у нас работает. Документация Kubernetes перечисляет исходящий ICMP через NAT среди ограничений, но на Windows Server 2025 с Calico в режиме VXLAN ответ вернулся. Проверять связность наружу всё равно надёжнее по TCP — curl.exe.

И ограничение самого узла: Windows-хост к адресам сервисов не ходит — curl.exe http://10.102.98.171 в PowerShell на k8s-lab-win-1 ответа не дождётся, а по адресу пода дождётся.


Смотрим руками

Теперь, когда кластер собран, посмотрим вживую на то, что в частях 36 и 37 разбиралось на схемах.

Кластер целиком

$ kubectl get nodes -o wide
NAME               STATUS   ROLES           AGE    VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION             CONTAINER-RUNTIME
k8s-lab-master-1   Ready    control-plane   153m   v1.37.2   192.168.10.23   <none>        Ubuntu 26.04.1 LTS               7.0.0-42-generic (amd64)   containerd://2.2.2
k8s-lab-node-1     Ready    <none>          146m   v1.37.2   192.168.10.24   <none>        Ubuntu 26.04.1 LTS               7.0.0-42-generic (amd64)   containerd://2.2.2
k8s-lab-node-2     Ready    <none>          146m   v1.37.2   192.168.10.26   <none>        Ubuntu 26.04.1 LTS               7.0.0-42-generic (amd64)   containerd://2.2.2
k8s-lab-win-1      Ready    <none>          64m    v1.37.2   192.168.10.25   <none>        Windows Server 2025 Datacenter   10.0.26100.33438 (amd64)   containerd://2.2.2

$ kubectl top nodes
NAME               CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
k8s-lab-master-1   121m         6%       1588Mi          48%
k8s-lab-node-1     34m          1%       1960Mi          26%
k8s-lab-node-2     55m          2%       1514Mi          20%
k8s-lab-win-1      8m           0%       1292Mi          16%

Колонки OS-IMAGE и KERNEL-VERSION у Windows-узла — название редакции и полная версия ОС с ревизией. Имя узла — короткое k8s-lab-win-1, как у Linux-узлов: kubelet регистрирует узел по имени компьютера. kubectl top видит и Windows-узел: kubelet на Windows отдаёт /metrics/resource так же, как на Linux, а metrics-server проверяет его серверный сертификат, выпущенный по одобренному CSR.

Службы и поды на узле

PS C:\> Get-Service kubelet, containerd, vmcompute, hns | Format-Table -AutoSize Status, Name, StartType

 Status Name       StartType
 ------ ----       ---------
Running containerd Automatic
Running hns           Manual
Running kubelet    Automatic
Running vmcompute     Manual

PS C:\> crictl pods
POD ID              CREATED             STATE               NAME                         NAMESPACE           ATTEMPT             RUNTIME
df1924ad959c6       4 minutes ago       Ready               aspnetapp-769559d8ff-dkp9z   win-demo            0                   runhcs-wcow-process
690ccfd48f5c4       4 minutes ago       Ready               aspnetapp-769559d8ff-vk445   win-demo            0                   runhcs-wcow-process
7fdf200e6eea1       59 minutes ago      Ready               kube-proxy-windows-z9qdq     kube-system         0                   (default)
b1da3ea54ae81       About an hour ago   Ready               calico-node-windows-q54h2    calico-system       0                   (default)

vmcompute (HCS) и hns запускаются по требованию, когда к ним обращается containerd или CNI-плагин. В колонке RUNTIME у подов aspnetapp — обработчик runhcs-wcow-process, который kubelet взял из RuntimeClass, а (default) у HostProcess-подов — обработчик по умолчанию, в нашей конфигурации тот же runhcs-wcow-process.

Процессы контейнера на хосте

При изоляции процессов процессы контейнера — процессы хоста. У каждого контейнера свой сеанс, и по номеру сеанса (SI) их легко отделить. Найдём сеанс приложения и посмотрим, что ещё в нём работает:

PS C:\> Get-Process -Name AspNetCoreRazorApp | Format-Table -AutoSize Id, SI, ProcessName

  Id SI ProcessName
  -- -- -----------
 144  5 AspNetCoreRazorApp
8228  4 AspNetCoreRazorApp

PS C:\> Get-Process | Where-Object SessionId -eq 5 | Sort-Object ProcessName | Format-Table -AutoSize Id, SI, ProcessName

  Id SI ProcessName
  -- -- -----------
 144  5 AspNetCoreRazorApp
8872  5 CExecSvc
8736  5 csrss
 912  5 lsass
6168  5 services
 192  5 svchost
...
3528  5 wininit

Два процесса приложения — два пода, каждый в своём сеансе. В сеансе контейнера — собственные csrss, wininit, services, lsass и десяток svchost, служба исполнения CExecSvc и само приложение — та самая картина со схемы из части 36. Диспетчера сеансов smss в этой выборке нет, хотя у контейнера он свой и живёт всё время: он работает в сеансе 0, а мы отбирали процессы по сеансу контейнера. А HostProcess-контейнеры — обычные процессы хоста в сеансе 0, рядом с системными службами самого узла, и запущены они из каталога, куда HCS подключил содержимое образа:

PS C:\> Get-Process calico, kube-proxy | Format-Table -AutoSize Id, SI, ProcessName, Path

  Id SI ProcessName Path
  -- -- ----------- ----
 852  0 calico      C:\hpc\CalicoWindows\calico.exe
3856  0 calico      C:\hpc\CalicoWindows\calico.exe
4308  0 kube-proxy  C:\hpc\kube-proxy\kube-proxy.exe

Два процесса calico — два контейнера пода calico-node-windows, felix и node: Calico для Windows собран в один исполняемый файл с подкомандами. C:\hpc — значение CONTAINER_SANDBOX_MOUNT_POINT для этих подов.

Сеть пода в HNS

Сетевые адаптеры узла:

PS C:\> Get-NetAdapter | Format-Table Name, InterfaceDescription, MacAddress

Name                                                                                InterfaceDescription                          MacAddress
----                                                                                --------------------                          ----------
Ethernet                                                                            Microsoft Hyper-V Network Adapter             00-15-5D-0A-00-05
vEthernet (Ethernet)                                                                Hyper-V Virtual Ethernet Adapter              00-15-5D-0A-00-05
vEthernet (DrPort 1803C339-EAB3-4933-8F1F-62E7D36C6E21)                             Hyper-V Virtual Ethernet Container Adapter    00-15-5D-EB-90-AE
vEthernet (df1924ad959c693c6dc07f1f797f63458fa53dcc81f8126755c3a4509e87a15e_Calico) Hyper-V Virtual Ethernet Container Adapter #3 0E-2A-AC-15-29-06
...

vEthernet (DrPort …) — порт распределённого маршрутизатора (distributed router) в vSwitch: это шлюз 172.21.41.1 сети Calico. Адаптеры подов названы по идентификатору песочницы пода, и последние четыре байта их MAC-адреса — адрес пода в шестнадцатеричном виде: AC-15-29-06 — это 172.21.41.6.

HNS-сеть Calico и её маршруты к блокам других узлов:

PS C:\> $net = Get-HnsNetwork | Where-Object Name -eq Calico
PS C:\> $net.Subnets.Policies | ConvertTo-Json -Compress
{"Type":"VSID","VSID":4096}
PS C:\> $net.Policies | Where-Object Type -eq RemoteSubnetRoute |
    Format-Table DestinationPrefix, ProviderAddress, DistributedRouterMacAddress, IsolationId

DestinationPrefix ProviderAddress DistributedRouterMacAddress IsolationId
----------------- --------------- --------------------------- -----------
172.21.211.0/26   192.168.10.24   66-ee-f4-61-98-50                  4096
172.21.70.192/26  192.168.10.23   66-79-81-e0-1a-7d                  4096
172.21.172.0/26   192.168.10.26   66-05-38-46-ab-ea                  4096

Это политики RemoteSubnetRoute из прошлой части: блок соседа, адрес его узла и MAC его VTEP — интерфейса vxlan.calico на Linux-узле. Сверим с Linux-стороной:

user@k8s-lab-node-1:~$ ip -d link show vxlan.calico | head -2
5: vxlan.calico: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether 66:ee:f4:61:98:50 brd ff:ff:ff:ff:ff:ff promiscuity 0 allmulti 0 minmtu 68 maxmtu 65535
user@k8s-lab-node-1:~$ ip route | grep 172.21.41
172.21.41.0/26 via 172.21.41.1 dev vxlan.calico onlink mtu 1450
user@k8s-lab-node-1:~$ ip neigh show dev vxlan.calico
172.21.70.192 lladdr 66:79:81:e0:1a:7d PERMANENT
172.21.172.0 lladdr 66:05:38:46:ab:ea PERMANENT
172.21.41.1 lladdr 00:15:5d:eb:90:ae PERMANENT
user@k8s-lab-node-1:~$ bridge fdb show dev vxlan.calico
66:05:38:46:ab:ea dst 192.168.10.26 self permanent
66:79:81:e0:1a:7d dst 192.168.10.23 self permanent
00:15:5d:eb:90:ae dst 192.168.10.25 self permanent

MAC 66:ee:f4:61:98:50 из ip -d link — тот, что записан на Windows-узле для блока k8s-lab-node-1. В обратную сторону: блок Windows-узла 172.21.41.0/26 идёт через vxlan.calico на шлюз 172.21.41.1, ARP-запись ведёт его к MAC порта DrPort, а FDB — к адресу Windows-узла. Откуда Felix на Linux берёт эти значения, видно в аннотациях объекта Node:

$ kubectl get node k8s-lab-win-1 -o json | jq '.metadata.annotations | with_entries(select(.key | test("VXLAN")))'
{
    "projectcalico.org/IPv4VXLANTunnelAddr": "172.21.41.1",
    "projectcalico.org/VXLANTunnelMACAddr": "00:15:5d:eb:90:ae"
}

У Linux-узлов туннельный адрес — первый адрес блока (172.21.70.192, 172.21.211.0, 172.21.172.0), а у Windows-узла — шлюз HNS-сети .1, и MAC его VTEP Calico записывает явно: на Windows это не интерфейс ядра, а порт vSwitch.

Конечные точки HNS — локальные (поды) и удалённые, которые kube-proxy создал для бэкендов и адресов сервисов:

PS C:\> Get-HnsEndpoint | Sort-Object IsRemoteEndpoint, IPAddress | Format-Table IPAddress, MacAddress, IsRemoteEndpoint

IPAddress     MacAddress        IsRemoteEndpoint
---------     ----------        ----------------
10.102.98.171 00:15:5d:0a:00:05             True
10.96.0.1     00:15:5d:0a:00:05             True
10.96.0.10    00:15:5d:0a:00:05             True
10.97.141.163 00:15:5d:0a:00:05             True
...
172.21.172.3  02-11-ac-15-ac-03             True
172.21.172.4  02-11-ac-15-ac-04             True
172.21.211.25 02-11-ac-15-d3-19             True
172.21.41.2   00:15:5d:0a:00:05             True
172.21.70.199 02-11-ac-15-46-c7             True
...
172.21.41.5   0E-2A-ac-15-29-05
172.21.41.6   0E-2A-ac-15-29-06

172.21.211.25 и 172.21.172.4 — поды hello, 172.21.172.3 и 172.21.70.199 — CoreDNS; удалённые конечные точки есть и для самих ClusterIP, и для Calico_ep. MAC у удалённых конечных точек подов не настоящий: kube-proxy составляет его из префикса 02-11 и четырёх байт адреса (ac-15-d3-19 — 172.21.211.25), а у ClusterIP и Calico_ep записан MAC самого узла. Доставляет к ним не порт коммутатора, а политики маршрутизации сети. Политики конечной точки пода:

PS C:\> $ep = Get-HnsEndpoint | Where-Object IPAddress -eq 172.21.41.6
PS C:\> $ep.Policies | Group-Object Type | Format-Table Count, Name

Count Name
----- ----
    2 OutBoundNAT
    1 ROUTE
    1 PA
    7 ACL

PS C:\> ($ep.Policies | Where-Object { $_.Type -eq 'OutBoundNAT' -and $_.ExceptionList }).ExceptionList
10.96.0.0/12
172.21.0.0/16

OutBoundNAT с исключениями — это natOutgoing: всё, что идёт не в сеть подов и не в сеть сервисов, получает адрес узла; сеть сервисов в списке — та самая serviceCIDRs из Installation. PA (provider address) и ROUTE связывают конечную точку с адресом узла в VXLAN-сети, ACL-записи — политики, которые Felix разложил на эту конечную точку; без NetworkPolicy в неймспейсе там правила «разрешить» и служебные правила для узла.

Балансировщики kube-proxy показывает hnsdiag — утилита диагностики HNS из состава Windows Server:

PS C:\> hnsdiag list loadbalancers
Load Balancer    : 996c2141-38f4-4d34-b971-781a68c61678
    Virtual IPs      : 10.96.0.10
    Direct IP IDs    : 2b87a046-19b9-458c-a681-5ff88360106d
                       0f8dc4db-f502-48d3-8845-29772ed1ea03
...
Load Balancer    : 6d740400-5fdb-4792-9d80-f05533d763e1
    Virtual IPs      : 10.96.0.10
    Direct IP IDs    : 2b87a046-19b9-458c-a681-5ff88360106d
                       0f8dc4db-f502-48d3-8845-29772ed1ea03
...

Каждый порт каждого сервиса — отдельный балансировщик со списком конечных точек-бэкендов: то, что на Linux-узле лежит в таблице ip kube-proxy nftables. У DNS (10.96.0.10) их два — на UDP и TCP, оба ссылаются на одни и те же конечные точки подов CoreDNS.

Linux-поля в Windows-поде

И последнее из части 36 — проверка os.name в API. Под, в который по привычке скопирован securityContext с Linux-узла:

$ kubectl -n win-demo apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: bad
spec:
  os:
    name: windows
  runtimeClassName: windows-2025
  securityContext:
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: shell
    image: mcr.microsoft.com/windows/nanoserver:ltsc2025
EOF
The Pod "bad" is invalid:
* spec.securityContext.seccompProfile: Forbidden: cannot be set for a windows pod
* spec.securityContext.runAsUser: Forbidden: cannot be set for a windows pod

API отказал до записи в etcd. Без os.name тот же под был бы принят — Pod Security проверила бы его как Linux-под и выдала бы предупреждения про allowPrivilegeEscalation, capabilities и runAsNonRoot, — а Linux-поля были бы молча проигнорированы на узле. Поэтому os.name стоит указывать всегда, и для Linux-подов тоже.


Если узел NotReady

Начинают, как и на Linux, с условий узла: kubectl describe node k8s-lab-win-1, раздел Conditions.

NetworkPluginNotReady ... cni plugin not initialized — в C:\etc\cni\net.d нет конфигурации, значит, не отработал calico-node-windows:

  • пода нет совсем — проверить windowsDataplane: HNS в Installation и kubectl get tigerastatus: оператор пишет туда причину, как в случае со strictAffinity выше;
  • под висит в Init или ContainerCreating — kubectl -n calico-system describe pod <под>: чаще всего это долгая загрузка образов или ошибка загрузки;
  • под перезапускается — логи по контейнерам: -c install-cni, затем -c node и -c felix. Ошибка dial tcp 10.96.0.1:443: i/o timeout означает, что нет ConfigMap kubernetes-services-endpoint. Провалы проб Felix в первую минуту после старта — нормальны.

Kubelet stopped posting node status или статус Unknown — kubelet не достучался до API. На узле — Get-Service kubelet, containerd, Get-Content C:\var\log\kubelet\kubelet.err.log -Tail 30 и Test-NetConnection k8s-lab-api.example.com -Port 6443. Частые причины:

  • соединение потерялось при создании vSwitch — адрес хоста переехал на vEthernet (Ethernet), и kubelet застрял на старом соединении; документация Calico прямо советует после установки Restart-Service kubelet;
  • часы — x509: certificate has expired or is not yet valid в логе, смотреть w32tm /query /status;
  • containerd не работает — kubelet от него зависит и без него не стартует; crictl info покажет, отвечает ли containerd, а журнал System с источником Service Control Manager (Get-WinEvent) — почему служба упала.

Узел Ready, но поды не работают — разбирают тут же:

  • The container operating system does not match the host operating system в событиях пода — сборка образа не совпадает со сборкой узла; лечится правильным тегом образа или RuntimeClass;
  • kube-proxy-windows в CrashLoopBackOff с ожиданием сети Calico в логе — сначала чинить Calico;
  • kubectl logs и exec падают с tls: internal error — не одобрен CSR kubelet-serving; с тайм-аутом — закрыт порт 10250;
  • Windows-поды не видят поды Linux-узлов, а внутри узла всё работает — закрыт UDP 4789 на одной из сторон: в файрволе Linux-узла или в файрволе гипервизора между виртуальными машинами.

Пока на узле нет ценного, поставить его заново дешевле, чем чинить:

$ kubectl drain k8s-lab-win-1 --ignore-daemonsets --delete-emptydir-data
$ kubectl delete node k8s-lab-win-1

PS C:\> kubeadm reset -f
PS C:\> Get-HnsNetwork | Remove-HnsNetwork
PS C:\> Remove-Item C:\etc\cni\net.d\* -Force
PS C:\> Restart-Computer

kubeadm reset на Windows, как и на Linux, не трогает чужое: HNS-сеть и конфигурацию CNI удаляют руками, а перезагрузка убирает виртуальный коммутатор и возвращает адрес на физический адаптер. Если же неудачной оказалась Linux-основа, а в тестовом кластере ещё ничего нет, проще всего пройти раздел «Если что-то пошло не так» части 4 на всех трёх Linux-узлах и начать с kubeadm init заново — именно для этого кластер и тестовый. Удаляя /etc/cni/net.d/* на Linux-узле, учтите, что каталог закрыт для обычного пользователя: шаблон * в sudo rm /etc/cni/net.d/* раскрывает ещё не root, и файлы остаются — нужен sudo sh -c 'rm -f /etc/cni/net.d/*'.


Источники


Дальше — рабочие нагрузки, хранилище и наблюдаемость на Windows: образы servercore и nanoserver и мультиплатформенные манифесты с os.version, сборка hello под windows/amd64, чем requests и limits на Windows отличаются от Linux, пробы и корректное завершение, CSI на Windows-узлах через csi-proxy и HostProcess-плагины, csi-driver-smb в тестовом кластере и на Windows-узле и почему NFS остаётся за Linux-узлами, kube-prometheus-stack с windows-exporter в тестовом кластере, Loki в тестовом кластере и логи Windows-узла в нём, ежемесячное обновление узлов.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.