k3s на двух ВМ: Cilium, APISIX и Longhorn для тестового стенда

Published: 2026-06-21

Single-node k3s — удобный старт: один сервер, весь кластер, никаких компромиссов с сетью. Но как только нужно проверить поведение workload при отказе узла, вопросы по репликации хранилища или поведение ingress при дрейфе pod — single-node перестаёт быть честным тестом. Переход на два узла открывает именно эти сценарии при минимальных затратах.

Эта статья — разбор ключевых решений при таком переходе, с тем же стеком, что уже работает на production: Cilium как CNI и LB (без MetalLB), APISIX как ingress, Longhorn как distributed storage.


Почему single-node перестаёт хватать

Single-node k3s хорош для:

  • Разработки и локальных проверок конфигурации
  • CI/CD пайплайнов, где состояние не важно
  • Деплоя stateless приложений

Он ломает тест в момент, когда нужно:

  • Проверить поведение Deployment при убийстве узла
  • Протестировать ReadWriteMany storage (PVC одновременно на двух pod)
  • Убедиться, что Cilium L2 правильно переключает ARP при миграции pod
  • Проверить PodDisruptionBudget в реальных условиях

Два узла закрывают все эти сценарии и сохраняют тот же стек, что на production.


Топология кластера

Для тестового стенда — схема 1 server + 1 agent:

┌─────────────────────────────────────────────────────────┐
│  VM1 (server)                     VM2 (agent)           │
│  192.168.1.10                     192.168.1.11          │
│                                                         │
│  ┌─────────────────┐              ┌─────────────────┐   │
│  │ kube-apiserver  │              │                 │   │
│  │ kube-scheduler  │◄────────────►│  kubelet        │   │
│  │ kube-controller │    6443/TCP  │  Cilium agent   │   │
│  │ etcd            │              │  (kube-proxy    │   │
│  │ kubelet         │              │   replacement)  │   │
│  │ Cilium agent    │              │                 │   │
│  └─────────────────┘              └─────────────────┘   │
│                                                         │
│  ← — — — — — — — L2 сеть / одна подсеть — — — — — — →  │
└─────────────────────────────────────────────────────────┘

Server-узел — и control plane, и worker. Agent — только worker. Для тестового стенда это нормально: на production разделяют через taints, здесь экономим ресурсы.

Требования к сети:

  • Оба узла в одной L2-сети (обязательно для Cilium L2 Announcements)
  • Порт 6443/TCP открыт от agent к server
  • Порт 4240/TCP (health check Cilium) и 8472/UDP (если используется VXLAN) открыты между узлами
  • Для Longhorn — порт 9500/TCP между узлами

Установка k3s

Подготовка узлов

На обоих узлах перед установкой:

bash# Отключаем swap — обязательно для kubelet
swapoff -a
sed -i '/ swap / s/^/#/' /etc/fstab

# Загружаем необходимые модули ядра
modprobe overlay
modprobe br_netfilter
cat <<EOF > /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

# sysctl для сети Kubernetes
cat <<EOF > /etc/sysctl.d/99-kubernetes.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
net.ipv4.conf.all.rp_filter         = 0
net.ipv4.conf.default.rp_filter     = 0
fs.inotify.max_user_watches         = 524288
fs.inotify.max_user_instances       = 512
EOF
sysctl --system

rp_filter=0 — обязательно для Cilium L2 Announcements. В строгом режиме (1) ядро отбрасывает ответные пакеты балансировщика, потому что они уходят не через тот интерфейс.

Установка server-узла

k3s запускается без Flannel и без kube-proxy — оба заменяет Cilium:

bashcurl -sfL https://get.k3s.io | sh -s - server \
  --flannel-backend=none \
  --disable-kube-proxy \
  --disable=traefik \
  --disable=servicelb \
  --node-ip=192.168.1.10 \
  --advertise-address=192.168.1.10

Флаги:

  • --flannel-backend=none — не устанавливать Flannel CNI, эту роль берёт Cilium
  • --disable-kube-proxy — Cilium заменяет kube-proxy через eBPF
  • --disable=traefik — убираем дефолтный ingress, ставим APISIX
  • --disable=servicelb — убираем встроенный Klipper LB, Cilium сам анонсирует LoadBalancer IP
  • --node-ip и --advertise-address — явно задаём IP, иначе k3s возьмёт не тот интерфейс

Лучше использовать config.yaml, чем флаги CLI — проще обновлять:

yaml# /etc/rancher/k3s/config.yaml (на server-узле)
flannel-backend: "none"
disable-kube-proxy: true
disable:
  - traefik
  - servicelb
node-ip: "192.168.1.10"
advertise-address: "192.168.1.10"

После запуска узел будет в NotReady — это ожидаемо, пока Cilium не установлен:

bashk3s kubectl get nodes
# NAME   STATUS     ROLES                  AGE   VERSION
# vm1    NotReady   control-plane,master   30s   v1.32.x+k3s1

Не ждите Ready перед установкой Cilium — он сам переведёт узел в Ready после запуска.

Получение токена и kubeconfig

bash# Токен для подключения agent-узла
cat /var/lib/rancher/k3s/server/node-token

# kubeconfig для управления с локальной машины
cat /etc/rancher/k3s/k3s.yaml | sed 's/127.0.0.1/192.168.1.10/' > ~/k3s-test.yaml
export KUBECONFIG=~/k3s-test.yaml

Установка Cilium (до подключения agent)

Cilium нужно установить до подключения agent-узла — иначе agent зависнет в NotReady:

bashhelm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=192.168.1.10 \
  --set k8sServicePort=6443 \
  --set ipam.mode=kubernetes \
  --set operator.replicas=1 \
  --set socketLB.enabled=true \
  --set socketLB.hostNamespaceOnly=true \
  --set nodePort.enabled=true \
  --set hostPort.enabled=true \
  --set l2announcements.enabled=true \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true

k8sServiceHost — реальный IP server-узла, не 127.0.0.1. Cilium без kube-proxy должен сам найти API-сервер; localhost здесь создаст петлю.

operator.replicas=1 — для кластера из двух узлов, чтобы оператор Cilium не ждал второго узла для HA.

После установки server-узел переходит в Ready:

bashkubectl get nodes
# NAME   STATUS   ROLES                  AGE   VERSION
# vm1    Ready    control-plane,master   3m    v1.32.x+k3s1

cilium status
# KubeProxyReplacement: True
# Cilium: 2/2 agents running

Подключение agent-узла

bash# На VM2
curl -sfL https://get.k3s.io | K3S_URL=https://192.168.1.10:6443 \
  K3S_TOKEN="$(cat /var/lib/rancher/k3s/server/node-token)" \
  sh -s - agent \
  --node-ip=192.168.1.11

Или через config.yaml:

yaml# /etc/rancher/k3s/config.yaml (на agent-узле)
server: "https://192.168.1.10:6443"
token: "K107abc...::server:abc123token..."
node-ip: "192.168.1.11"

После подключения Cilium автоматически развернёт агента на VM2:

bashkubectl get nodes
# NAME   STATUS   ROLES                  AGE    VERSION
# vm1    Ready    control-plane,master   5m     v1.32.x+k3s1
# vm2    Ready    <none>                 30s    v1.32.x+k3s1

kubectl -n kube-system get pods -l app.kubernetes.io/name=cilium
# NAME            READY   STATUS    RESTARTS
# cilium-xxxxx    1/1     Running   0         vm1
# cilium-yyyyy    1/1     Running   0         vm2

Load Balancer: Cilium L2 Announcements

Поскольку CNI — Cilium, отдельный MetalLB не нужен. Cilium сам анонсирует LoadBalancer IP через ARP в L2-сети.

Пул IP-адресов

yaml# cilium-lb.yaml
apiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
  name: default-pool
spec:
  blocks:
    - cidr: "192.168.1.100/28"   # 14 адресов: .101 — .114
---
apiVersion: "cilium.io/v2alpha1"
kind: CiliumL2AnnouncementPolicy
metadata:
  name: default-policy
spec:
  interfaces:
    - ^eth[0-9]+    # все eth-интерфейсы
  loadBalancerIPs: true
  externalIPs: true
bashkubectl apply -f cilium-lb.yaml

CIDR пула должен быть в той же подсети, что и интерфейсы узлов — иначе ARP-ответы не примет коммутатор. Диапазон выбирайте вне DHCP-пула роутера.

Закрепление конкретного IP за Service

yamlmetadata:
  annotations:
    "lbipam.cilium.io/ips": "192.168.1.101"

Проверка

bash# Тестовый Service
kubectl create deployment nginx --image=nginx
kubectl expose deployment nginx --port=80 --type=LoadBalancer

kubectl get svc nginx
# NAME    TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)
# nginx   LoadBalancer   10.43.12.5    192.168.1.101   80:30080/TCP

# Cilium анонсирует IP через ARP
cilium l2announce list

# Доступность с любой машины в LAN
curl http://192.168.1.101/

Cilium выбирает один узел-лидер для ARP на каждый IP. При падении узла другой перехватывает роль через несколько секунд (определяется leaseDuration в политике).

Почему не MetalLB

MetalLB работает с любым CNI и хорош как самостоятельный компонент. Но если CNI уже Cilium — MetalLB дублирует функциональность, добавляет ещё один Helm chart и ещё один набор CRD. Cilium L2 Announcements решает ту же задачу в рамках уже установленного компонента, плюс трафик наблюдается через Hubble.


Ingress: APISIX

k3s запущен с --disable=traefik. На его место — APISIX Ingress Controller, который уже используется в production-стеке.

bashhelm repo add apisix https://charts.apiseven.com
helm install apisix apisix/apisix \
  --namespace apisix \
  --create-namespace \
  --set service.type=LoadBalancer \
  --set ingress-controller.enabled=true \
  --set ingress-controller.config.apisix.serviceNamespace=apisix

service.type=LoadBalancer — APISIX сразу запросит внешний IP из пула Cilium.

После установки:

bashkubectl -n apisix get svc apisix-gateway
# NAME              TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)
# apisix-gateway    LoadBalancer   10.43.5.10    192.168.1.102   80:30080/TCP,443:30443/TCP

Пример ApisixRoute для деплоя:

yamlapiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: my-app
  namespace: default
spec:
  http:
    - name: main
      match:
        hosts:
          - myapp.example.com
        paths:
          - "/*"
      backends:
        - serviceName: my-app
          servicePort: 8080

StorageClass: Longhorn

Проблема с local-path в multi-node

k3s ставит local-path StorageClass по умолчанию. На single-node — работает. На двух узлах — создаёт невидимую ловушку.

local-path создаёт PV на том узле, где был запланирован pod. Если pod переезжает на другой узел — PV остаётся на первом, pod зависает:

Events:
  Warning  FailedScheduling  pod/my-app  0/2 nodes are available:
  1 node(s) had volume node affinity conflict.

На single-node это не проявляется. На двух узлах — при любом kubectl drain или автоматическом reschedule.

local-path оставляем только для временных данных, которые можно пересоздать.

Longhorn: распределённое хранилище

Longhorn хранит данные на локальных дисках обоих узлов и реплицирует между ними:

┌──────────────────────┐     ┌──────────────────────┐
│  VM1 (server)        │     │  VM2 (agent)          │
│                      │     │                       │
│  /var/lib/longhorn   │◄───►│  /var/lib/longhorn    │
│  Replica A           │     │  Replica B            │
│                      │     │                       │
└──────────────────────┘     └──────────────────────┘

Если VM2 упала — данные остаются на VM1, pod продолжает работу с одной репликой. После восстановления VM2 Longhorn автоматически перестраивает вторую реплику.

Подготовка узлов

bash# На обоих узлах
apt install open-iscsi util-linux nfs-common
modprobe iscsi_tcp
echo 'iscsi_tcp' >> /etc/modules-load.d/iscsi.conf
systemctl enable --now iscsid

Установка

bashhelm repo add longhorn https://charts.longhorn.io
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --create-namespace \
  --set defaultSettings.defaultReplicaCount=2 \
  --set defaultSettings.storageMinimalAvailablePercentage=10

Проверка:

bashkubectl -n longhorn-system get node.longhorn.io
# NAME   READY   ALLOWSCHEDULING   SCHEDULABLE   AGE
# vm1    True    True              True          5m
# vm2    True    True              True          4m

kubectl get storageclass
# NAME                 PROVISIONER          ...
# longhorn (default)   driver.longhorn.io   ...
# local-path           rancher.io/local-path ...

Использование

yamlapiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: longhorn
  resources:
    requests:
      storage: 10Gi

Longhorn UI доступен через port-forward или ApisixRoute:

bashkubectl -n longhorn-system port-forward svc/longhorn-frontend 8080:80
# http://localhost:8080 — volumes, реплики, состояние узлов

Если нужен ReadWriteMany

Longhorn поддерживает только ReadWriteOnce из коробки. Для ReadWriteMany — NFS Provisioner поверх NFS-сервера:

bash# NFS-сервер на VM1
apt install nfs-kernel-server
mkdir -p /srv/nfs/k8s
echo "/srv/nfs/k8s  192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)" >> /etc/exports
exportfs -ra
systemctl enable --now nfs-kernel-server

# NFS-клиент на VM2
apt install nfs-common

# Provisioner в кластер
helm repo add nfs-subdir-external-provisioner \
  https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm install nfs-provisioner \
  nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
  --namespace nfs-provisioner \
  --create-namespace \
  --set nfs.server=192.168.1.10 \
  --set nfs.path=/srv/nfs/k8s \
  --set storageClass.name=nfs \
  --set storageClass.defaultClass=false

NFS не реплицирует данные — это не замена Longhorn для stateful workload. Используйте NFS только там, где нужен RWX: shared uploads, общий конфиг между репликами.


Итоговый стек

Компонент Выбор
k3s server + agent, --flannel-backend=none --disable-kube-proxy
CNI Cilium (kube-proxy replacement)
Load Balancer Cilium L2 Announcements + CiliumLoadBalancerIPPool
Ingress APISIX Ingress Controller
StorageClass / блочное Longhorn (2 реплики)
StorageClass / shared NFS Provisioner (если нужен RWX)
Наблюдаемость сети Hubble (встроен в Cilium)

Последовательность деплоя:

bash# 1. Подготовить оба узла (sysctl, swap, модули ядра, open-iscsi)
# 2. Установить k3s server (с config.yaml)
# 3. Установить Cilium через Helm
# 4. Подключить k3s agent
# 5. Создать CiliumLoadBalancerIPPool и CiliumL2AnnouncementPolicy
# 6. Установить Longhorn
# 7. Установить APISIX
# 8. Проверить: kubectl get nodes, cilium status, kubectl get storageclass

Что может пойти не так

Узел зависает в NotReady после установки agent

Cilium не поднялся на agent-узле. Проверяем:

bashkubectl -n kube-system logs -l app.kubernetes.io/name=cilium --tail=50
kubectl describe node vm2 | grep -A10 Conditions

Частая причина: Cilium на server-узле ещё не Ready в момент подключения agent. Решение — подождать полного старта Cilium на server перед k3s-agent install.

Cilium L2: IP выдан, но недоступен с LAN

bash# Проверить, что политика L2 применена
kubectl get ciliuml2announcementpolicies

# Проверить анонсы
cilium l2announce list

# Проверить rp_filter — должен быть 0
sysctl net.ipv4.conf.eth0.rp_filter

Если rp_filter=1 — ответные пакеты отбрасываются. Исправить:

bashsysctl -w net.ipv4.conf.all.rp_filter=0
sysctl -w net.ipv4.conf.default.rp_filter=0

И зафиксировать в /etc/sysctl.d/99-kubernetes.conf (чтобы не сбросилось после reboot).

APISIX не получает IP от Cilium

bashkubectl -n apisix get svc apisix-gateway
# EXTERNAL-IP: <pending>

# Проверить события
kubectl -n apisix describe svc apisix-gateway

# Проверить, что IPPool не исчерпан
kubectl get ciliumloadbalancerippool default-pool -o yaml | grep -A5 status

Частая причина: пул исчерпан или CIDR пула не в той же подсети, что узлы.

Longhorn: реплика не создаётся на VM2

bashkubectl -n longhorn-system get volume
# STATE: degraded

# Статус узла в Longhorn
kubectl -n longhorn-system get node.longhorn.io vm2 -o yaml | grep -A5 conditions

# iscsid на VM2
systemctl status iscsid
df -h /var/lib/longhorn

Причины: iscsid не запущен, нет свободного места, или узел помечен allowScheduling: false в Longhorn.

Pod зависает при переезде на другой узел (local-path)

Это ожидаемое поведение local-path — PV привязан к узлу через node affinity. Переведите PVC на storageClassName: longhorn.

bash# Проверить affinity у существующего PV
kubectl get pv <pv-name> -o jsonpath='{.spec.nodeAffinity}'

Итого

  • k3s server запускается с --flannel-backend=none --disable-kube-proxy --disable=traefik,servicelb
  • Cilium ставится до подключения agent, иначе agent зависнет в NotReady
  • k8sServiceHost в Cilium — реальный IP узла, не 127.0.0.1
  • rp_filter=0 обязателен для L2 Announcements — без этого ответные пакеты LB теряются
  • Cilium L2 Announcements заменяет MetalLB при Cilium CNI — не нужен отдельный Helm chart
  • APISIX получает LoadBalancer IP из пула Cilium автоматически
  • local-path в multi-node ломает pod при переезде на другой узел — используйте Longhorn
  • Longhorn с 2 репликами переживает потерю одного узла без потери данных