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.1rp_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 репликами переживает потерю одного узла без потери данных