InquirerTagum diocese: Refrain from sexualized comments about ‘handsome’ priestESPNTransfer rumors, news: Could Viktor Gyökeres leave Arsenal in January?The Jerusalem Post'You are the real target': Netanyahu warns Iran may attempt to develop missiles able to reach USZDF heuteEntdecken Sie das ZDF-Nachrichtenstudio20 Minuten«Von Beginn an böse Absichten»: Blitz-K.-o. im TitelkampfBBC عربيمقتل طيارين سعوديين بتحطم مروحية استطلاع قرب اليمن، وجماعة أنصار الله تعلن إسقاط مسيّرة سعوديةAnadolu AjansıAB, Batı Şeria'da Avrupalı diplomatlara ve Filistinlilere yönelik saldırıları kınadın-tvNeue Standorte geplant : Wie Deutschland seine Marinehäfen mit Milliarden aufrüsten willAntara NewsNo story Is worth a life: ANTARA steps up journalist safetySudinfo« Je souhaite remercier tous ceux qui ont prié pour moi » : le pape du silence sort pour la première fois après son opération !VanguardFrance counts cost of violent protests, 1,800 arrestedRMF24Za ciasno na dzieci. Zaskakujące wyniki raportu
The Daily Newsstand · Free, Always
Sunday, October 18, 2026

Cilium в качестве Load Balancer для локального Kind-кластера через Gateway API

Translate

В этой статье я делюсь опытом развертывания и предлагаю заглянуть в настройку этой технологии — сетевой стек на базе Gateway API и Cilium, выступающего в роли балансировщика. Это практический стенд, демонструет возможности Cilium и декларативный подход к маршрутизации в локальной среде.

⚠️ Параметры тестового стенда:

Все тесты проводятся на Linux-системе. Кластер из 9 нод (1 control-plane + 8 воркеров) успешно разворачивается и стабильно работает на ноутбуке с 4 ЦПУ и 16 Гб ОЗУ.

На первом этапе поднимается тестовый Kubernetes-кластер с помощью Kind. В Конфигурация мы отключаем стандартный CNI и kube-proxy, чтобы запустить Cilium. В качестве базового стандарта и правильной архитектурной привычки воркер-ноды на уровне конфигурации строго разделены на пулы по своим ролям (системные компоненты, базы данных инфраструктуры, MLflow, SeaweedFS и Web). Направленная изоляция рабочих нагрузок реализуется с помощью механизмов ограничений (taints) и меток (labels). Такая прозрачная топология позволяет наглядно увидеть, как именно трафик распределяется внутри кластера.

Команда для инициализации кластера

kind create cluster --name testlb --config infrastructure/kind/kind-config.yaml

Манифест для Kind - infrastructure/kind/kind-config.yaml

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4 
networking:
  disableDefaultCNI: true # Отключаем автоматическую установку Kindnet
  kubeProxyMode: "none" # Убираем kube-proxy
nodes:
- role: control-plane

# systempool
- role: worker

# DATABASES - jupyterhub & mlflow
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "databasesnode=yesnaff"
          register-with-taints: "databasetaint=yestaint:NoSchedule"

- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "databasesnode=yesnaff"
          register-with-taints: "databasetaint=yestaint:NoSchedule"

- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "databasesnode=yesnaff"
          register-with-taints: "databasetaint=yestaint:NoSchedule"

# mlflow
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "mlflownode=yesnaff"
          register-with-taints: "mlflowtaint=yestaint:NoSchedule"

# seaweedfs
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "seaweedfsnode=yesnaff"
          register-with-taints: "seaweedfstaint=yestaint:NoSchedule"

# WEB
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "webnode=yesnaff"
          register-with-taints: "webtaint=yestaint:NoSchedule"

- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "webnode=yesnaff"
          register-with-taints: "webtaint=yestaint:NoSchedule"

Бывает, что Kind не собирает кластер

Альтернативный запуск Kind с флагом --retain

kind create cluster --name testlb --config infrastructure/kind/kind-config.yaml --retain

Копируем /etc/kubernetes/admin.conf из контейнера testlb-control-plane

docker exec -it testlb-control-plane cat /etc/kubernetes/admin.conf > ~/.kube/config

Проверяем control-plane

kubectl get no
Output:
NAME                   STATUS     ROLES           AGE     VERSION
testlb-control-plane   NotReady   control-plane   2m59s   v1.37.0

Генерация команды подключения нод с флагом --print-join-command через docker exec

docker exec -it testlb-control-plane kubeadm token create --print-join-command

Output:

  kubeadm join testlb-control-plane:6443 --token uvukvj.40pf1jzlosjffgar --discovery-token-ca-cert-hash sha256:ee6183fc29377ab060e9e5ff01b2956611ea9b8a153cd2696dd5b3da4316d644

К полученному выводу для тестового окружения добавляем флаг в конце команды --ignore-preflight-errors=SystemVerification,Swap

docker ps --format '{{.Names}}' | grep testlb-worker | xargs -I {} docker exec -t {} kubeadm join testlb-control-plane:6443 --token uvukvj.40pf1jzlosjffgar --discovery-token-ca-cert-hash sha256:ee6183fc29377ab060e9e5ff01b2956611ea9b8a153cd2696dd5b3da4316d644  --ignore-preflight-errors=SystemVerification,Swap

  docker ps --format '{{.Names}}' | grep testlb-worker | xargs -I {} docker exec -t {} kubeadm join testlb-control-plane:6443 --token uvukvj.40pf1jzlosjffgar --discovery-token-ca-cert-hash sha256:ee6183fc29377ab060e9e5ff01b2956611ea9b8a153cd2696dd5b3da4316d644 --ignore-preflight-errors=SystemVerification,Swap

Проверяем ноды кластера

kubectl get no
# Output:
NAME                   STATUS     ROLES           AGE     VERSION
testlb-control-plane   NotReady   control-plane   17m     v1.37.0
testlb-worker          NotReady   <none>          2m31s   v1.37.0
testlb-worker2         NotReady   <none>          2m23s   v1.37.0
testlb-worker3         NotReady   <none>          2m27s   v1.37.0
testlb-worker4         NotReady   <none>          2m22s   v1.37.0
testlb-worker5         NotReady   <none>          2m30s   v1.37.0
testlb-worker6         NotReady   <none>          2m26s   v1.37.0
testlb-worker7         NotReady   <none>          2m29s   v1.37.0
testlb-worker8         NotReady   <none>          2m24s   v1.37.0

Если используется схема “K8s in Docker” через kubeadm join, то метки и тейнты нужно добавить вручную командами kubectl label и kubectl taint.

Если имя кластера — testlb, то это можно сделать одной строкой

w_idx=1; while read -r line; do if [[ "$line" =~ "role: worker" ]]; \
then [ $w_idx -eq 1 ] && name="testlb-worker" || name="testlb-worker$w_idx"; num=$(echo "$line" | cut -d: -f1); \
labels=$(sed -n "$num,/role:/p" infrastructure/kind/kind-config.yaml | grep "node-labels:" | \
sed -E 's/.*node-labels:[[:space:]]*"([^"]+)".*/\1/'); taints=$(sed -n "$num,/role:/p" infrastructure/kind/kind-config.yaml | grep "register-with-taints:" | \
sed -E 's/.*register-with-taints:[[:space:]]*"([^"]+)".*/\1/'); [ -n "$labels" ] && kubectl label node "$name" "$labels" --overwrite; [ -n "$taints" ] \
&& kubectl taint node "$name" "$taints" --overwrite; ((w_idx++)); fi; done < <(grep -n "role:" infrastructure/kind/kind-config.yaml)
# Output:
node/testlb-worker2 labeled
node/testlb-worker2 modified
node/testlb-worker3 labeled
node/testlb-worker3 modified
node/testlb-worker4 labeled
node/testlb-worker4 modified
node/testlb-worker5 labeled
node/testlb-worker5 modified
node/testlb-worker6 labeled
node/testlb-worker6 modified
node/testlb-worker7 labeled
node/testlb-worker7 modified
node/testlb-worker8 labeled
node/testlb-worker8 modified

Проверим taints тестовых нод в Kind

kubectl get no -o custom-columns="NAME:.metadata.name,TAINTS:.spec.taints[*].key"
# Output:
NAME                   TAINTS
testlb-control-plane   node-role.kubernetes.io/control-plane
testlb-worker          <none>
testlb-worker2         databasetaint
testlb-worker3         databasetaint
testlb-worker4         databasetaint
testlb-worker5         mlflowtaint
testlb-worker6         seaweedfstaint
testlb-worker7         webtaint
testlb-worker8         webtaint

Проверяем метки

kubectl get no --show-labels | awk 'NR==1 {printf "%-25s %s\n", "NAME", "YESNAFF_LABELS"} NR>1 {labels=""; \
for(i=1;i<=NF;i++) if($i ~ /=yesnaff/) {split($i, a, ","); for(j in a) if(a[j] ~ /=yesnaff/) labels=labels a[j] " "}; \
if(labels=="") labels="<none>"; printf "%-25s %s\n", $1, labels}'
# Output:
NAME                      YESNAFF_LABELS
testlb-control-plane      <none>
testlb-worker             <none>
testlb-worker2            databasesnode=yesnaff 
testlb-worker3            databasesnode=yesnaff 
testlb-worker4            databasesnode=yesnaff 
testlb-worker5            mlflownode=yesnaff 
testlb-worker6            seaweedfsnode=yesnaff 
testlb-worker7            webnode=yesnaff 
testlb-worker8            webnode=yesnaff 

Проверяем StorageClasses

kubectl get storageclasses.storage.k8s.io
# Output:
No resources found

Если ресурсы SC не найдены, то устанавливаем их следующим образом

kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.37/deploy/local-path-storage.yaml

Создаём дополнительный StorageClass “standard”. Kind создаёт SC с именем standard по умолчанию

kubectl apply -f - <<EOF
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: standard
provisioner: rancher.io/local-path
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
allowVolumeExpansion: false
EOF

Проверяем SC

kubectl get storageclasses.storage.k8s.io
# Output:
NAME         PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  2m14s
standard     rancher.io/local-path   Delete          WaitForFirstConsumer   false                  2m7s

Архитектурное планирование: проектируем топологию кластера

В реальном продакшене базы данных, тяжелые хранилища и веб-сервисы принято разносить по изолированным пулам нод. В нашем локальном окружении Kind мы частично сымитируем эту серьезную топологию. Это позволит наглядно протестировать механизмы планирования подов (Scheduling), изоляцию ресурсов и, главное, отказоустойчивость сети под управлением Cilium.

Для демонстрации мы спроектируем кластер со следующей логикой распределения нагрузок:

  • Системный пул (System нода) — одна нода без дополнительных меток и тейнтов. Она предназначена для служебных компонентов кластера и сервисов, которые потребляют меньше всего ресурсов.

  • Базы данных (JupyterHub DB + MLflow DB) — в идеальном мире под каждую БД выделяется своя нода. Однако для оптимизации ресурсов нашей локальной машины мы совместим инфраструктурные базы данных для JupyterHub и MLflow на общем пуле из трёх нод. Чтобы обычные поды туда случайно не попадали, мы закроем эти ноды специальным запрещающим тейнтом (Taint) и добавим опознавательный лейбл.

  • Сервисы приложений (JupyterHub + MLflow) — сами управляющие интерфейсы и воркеры JupyterHub и MLflow мы поселим вместе на одной выделенной ноде. Это для экономии ресурсов.

  • SeaweedFS — для работы с S3-совместимым хранилищем мы выделим одну независимую ноду.

  • Сетевой пул (Web-ноды) — две выделенные ноды, на которых мы развернем целевую веб-нагрузку. Именно на этом пуле мы будем тестировать отказоустойчивость, балансировку трафика и High Availability (HA) с помощью Cilium LoadBalancer.

Такое разделение позволит нам гибко управлять распределением подов через выражения nodeAffinity и tolerations, а также наглядно увидеть, как Cilium мгновенно перенаправляет трафик на выжившую веб-ноду при имитации аварии и отключении одного из узлов.

Установка Gateway API CRD

# Определяем базовый URL для CRD
GW_API="https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard"

kubectl apply --server-side -f ${GW_API}/gateway.networking.k8s.io_gatewayclasses.yaml \
  -f ${GW_API}/gateway.networking.k8s.io_gateways.yaml \
  -f ${GW_API}/gateway.networking.k8s.io_httproutes.yaml \
  -f ${GW_API}/gateway.networking.k8s.io_referencegrants.yaml \
  -f ${GW_API}/gateway.networking.k8s.io_grpcroutes.yaml \
  -f ${GW_API}/gateway.networking.k8s.io_backendtlspolicies.yaml \
  -f ${GW_API}/gateway.networking.k8s.io_tlsroutes.yaml

Установка и настройка Cilium

После подготовки Gateway API переходим к развертыванию самого Cilium.

Поскольку на этапе создания Kind-кластера мы полностью вырезали kube-proxy, Cilium разворачивается и заменяет собой старые механизмы маршрутизации и беря на себя управление сетевым трафиком и балансировкой.

Подключение репозитория и деплой

# Добавляем официальный репозиторий Cilium
helm repo add cilium https://helm.cilium.io/
helm repo update

# Запускаем установку заданной версии
helm upgrade --install cilium -f infrastructure/cilium/values.yaml -n kube-system cilium/cilium --create-namespace --version 1.20.2

*Содержимое values-файла — infrastructure/cilium/values.yaml*

k8sServiceHost: "testlb-control-plane"
k8sServicePort: "6443"
l2announcements:
  enabled: true
gatewayAPI:
  enabled: true
kubeProxyReplacement: "true"
hubble:
  enabled: true
  relay:
    enabled: true
  ui:
    enabled: true

Назначение параметров конфигурации:

  • k8sServiceHost и k8sServicePort — указывают Cilium имя Docker-контейнера управляющей ноды (testlb-control-plane) и порт для подключения Cilium к API-серверу.

  • kubeProxyReplacement: “true” — включает полную замену механизмов kube-proxy на eBPF для маршрутизации трафика внутри кластера.

  • l2announcements.enabled: true — активирует поддержку Layer 2 анонсов (ARP), чтобы Cilium мог объявлять IP-адреса сервисов типа LoadBalancer в подсети Docker.

  • gatewayAPI.enabled: true — включает встроенную поддержку Kubernetes Gateway API для управления входящим трафиком.

  • hubble.enabled, relay.enabled и ui.enabled — активируют систему Hubble для мониторинга и визуализации сетевого трафика в реальном времени с включением графического веб-интерфейса (Hubble UI).

Цель стенда и тестируемые сервисы

В качестве комплексного теста веб-маршрутизации и балансировки в кластере разворачивается тестовый MLOps-стек. Мы будем тестировать доступ к следующим веб-сервисам:

  • JupyterHub — для организации многопользовательской среды работы с ноутбуками.

  • MLflow — для трекинга и логирования экспериментов машинного обучения.

Для обеспечения их стабильной работы, хранения данных и артефактов в кластер также устанавливаются:

  • CloudNativePG (CNPG) — оператор PostgreSQL для баз данных.

  • SeaweedFS — в качестве S3-совместимого хранилища артефактов и моделей.

Добавляем репозитории компонентов инфраструктуры и приложений

# Добавляем репозитории компонентов инфраструктуры и приложений
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo add jupyterhub https://hub.jupyter.org/helm-chart/
helm repo add seaweedfs https://seaweedfs.github.io/seaweedfs/helm

# Синхронизируем локальный кэш чартов
helm repo update

Установка CloudNativePG (Оператор PostgreSQL)

Для развертывания и управления базами данных в нашем кластере используется оператор CloudNativePG (CNPG). Он идеально подходит для исследования облачных СУБД в локальной среде, так как умеет автоматически управлять отказоустойчивостью и репликацией.

Мы устанавливаем его через Helm в изолированное пространство имен cnpg-system.

Команда установки Для деплоя версии v0.29.1 выполним следующую команду

helm upgrade --install cnpg -f infrastructure/operators/cnpg/values.yaml -n cnpg-system    --create-namespace cnpg/cloudnative-pg --version 0.29.1 

values - infrastructure/operators/cnpg/values.yaml

Ждём запуск оператора

kubectl -n cnpg-system rollout status deployment/cnpg-cloudnative-pg --timeout=5m
# Output:
deployment "cnpg-cloudnative-pg" successfully rolled out

НАСТРОЙКА двух PG кластеров для JupyterHub и mlflow

Манифест для создания namespace и CR для БД JupyterHub расположен по пути - platform/database/jupyter/postgres-cluster.yaml

kubectl apply -f platform/database/jupyter/postgres-cluster.yaml
# Output:
namespace/jnhdb configured
cluster.postgresql.cnpg.io/cluster-jnhdb created

Содержимое манифеста platform/database/jupyter/postgres-cluster.yaml

apiVersion: v1
kind: Namespace
metadata:
  labels:
  name: jnhdb
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: cluster-jnhdb
  namespace: jnhdb
spec:
  instances: 3
  # Ограничение планирования подов на целевые ноды
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: databasesnode
            operator: In
            values:
            - "yesnaff"

    # Допуск на ноды с запрещающим тейнтом
    tolerations:
    - key: "databasetaint"
      operator: "Equal"
      value: "yestaint"
      effect: "NoSchedule"

  # Переопределение имени базы данных при инициализации кластера
  bootstrap:
    initdb:
      database: jupyterhub


  storage:
    size: 5Gi
    storageClass: standard

Манифест для создания namespace и CR для БД MLflow расположен по пути — platform/database/mlflow/postgres-cluster.yaml

kubectl apply -f platform/database/mlflow/postgres-cluster.yaml
# Output:
namespace/mlflowdb created
cluster.postgresql.cnpg.io/cluster-mlflowdb created

Содержимое манифеста platform/database/mlflow/postgres-cluster.yaml

apiVersion: v1
kind: Namespace
metadata:
  labels:
  name: mlflowdb
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: cluster-mlflowdb
  namespace: mlflowdb
spec:
  instances: 3
  # Ограничение планирования подов на целевые ноды
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: databasesnode
            operator: In
            values:
            - "yesnaff"

    # Допуск на ноды с запрещающим тейнтом
    tolerations:
    - key: "databasetaint"
      operator: "Equal"
      value: "yestaint"
      effect: "NoSchedule"

  # Переопределение имени базы данных при инициализации кластера
  bootstrap:
    initdb:
      database: mlflow  # Вместо "app" будет создана БД "mlflow"

  storage:
    size: 5Gi
    storageClass: standard

Установка SeaweedFS (S3-совместимое хранилище)

Для хранения артефактов, логов и обученных моделей из MLflow нам необходимо объектное хранилище. В рамках этого стенда используется SeaweedFS, который разворачивается через Helm в изолированном пространстве имен seaweedfs и предоставляет полноценный S3-совместимый API.

Команда установки

helm upgrade --install seaweedfs -f platform/seaweedfs/values.yaml -n seaweedfs --create-namespace seaweedfs/seaweedfs

в platform/seaweedfs/values.yaml не меняем весь, только добавляем nodeAffinity и tolerations ниже выделил изменения этого values

master:
  enabled: true
  affinity: |
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: seaweedfsnode
            operator: In
            values:
            - "yesnaff"
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
              app.kubernetes.io/instance: {{ .Release.Name }}
              app.kubernetes.io/component: master
          topologyKey: kubernetes.io/hostname

  tolerations: |
    - key: "seaweedfstaint"
      operator: "Equal"
      value: "yestaint"
      effect: "NoSchedule"

volume:
  enabled: true
  affinity: |
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: seaweedfsnode
            operator: In
            values:
            - "yesnaff"
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
              app.kubernetes.io/instance: {{ .Release.Name }}
              app.kubernetes.io/component: {{ $volumeName }}
          topologyKey: kubernetes.io/hostname

  tolerations: |
    - key: "seaweedfstaint"
      operator: "Equal"
      value: "yestaint"
      effect: "NoSchedule"

filer:
  enabled: true
  affinity: |
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: seaweedfsnode
            operator: In
            values:
            - "yesnaff"
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
              app.kubernetes.io/instance: {{ .Release.Name }}
              app.kubernetes.io/component: filer
          topologyKey: kubernetes.io/hostname

  tolerations: |
    - key: "seaweedfstaint"
      operator: "Equal"
      value: "yestaint"
      effect: "NoSchedule"

Создание пространств имён и копирование секретов для JupyterHub и MLflow

Создание пространств имён:

kubectl create ns jupyterhub; kubectl create ns  mlflow 

Копирование пароля БД для JupyterHub:

kubectl -n jnhdb get secret cluster-jnhdb-app -o json | jq '.type = "Opaque" | {apiVersion: .apiVersion, kind: .kind, type: .type, metadata: {name: "jupyterhub-db-secret"}, data: {"hub.db.password": .data.password}}' | kubectl apply -f - -n jupyterhub

Пример с переносом строк

kubectl -n jnhdb get secret cluster-jnhdb-app -o json | jq '.type = "Opaque" | {apiVersion: .apiVersion, kind: .kind, type: .type, metadata: {name: "jupyterhub-db-secret"}, data: {"hub.db.password": .data.password}}' | kubectl apply -f - -n jupyterhub

Копирование строки подключения (URI) БД для MLflow:

kubectl -n mlflowdb get secret cluster-mlflowdb-app -o json | jq '.type = "Opaque" | {apiVersion: .apiVersion, kind: .kind, type: .type, metadata: {name: "mlflow-db-secret"}, data: {"uri": .data.uri}}' | kubectl apply -f - -n mlflow

Пример с переносом строк

kubectl -n mlflowdb get secret cluster-mlflowdb-app -o json | jq '.type = "Opaque" | {apiVersion: .apiVersion, kind: .kind, type: .type, metadata: {name: "mlflow-db-secret"}, data: {"uri": .data.uri}}' | kubectl apply -f - -n mlflow

Копирование секретов S3 SeaweedFS для MLflow:

kubectl -n seaweedfs get secret seaweedfs-s3-secret -o json | jq '.type = "Opaque" | {apiVersion: .apiVersion, kind: .kind, type: .type, metadata: {name: "mlflow-s3-env-secret"}, data: {"AWS_ACCESS_KEY_ID": .data.admin_access_key_id, "AWS_SECRET_ACCESS_KEY": .data.admin_secret_access_key}}' | kubectl apply -f - -n mlflow

Пример с переносом строк

kubectl -n seaweedfs get secret seaweedfs-s3-secret -o json | jq '.type = "Opaque" | {apiVersion: .apiVersion, kind: .kind, type: .type, metadata: {name: "mlflow-s3-env-secret"}, data: {"AWS_ACCESS_KEY_ID": .data.admin_access_key_id, "AWS_SECRET_ACCESS_KEY": .data.admin_secret_access_key}}' | kubectl apply -f - -n mlflow

Установка JupyterHub

Для организации многопользовательской среды разворачивается JupyterHub. Мы устанавливаем его через Helm в выделенное пространство имён jupyterhub, используя версию 4.4.2.

Команда установки:

helm upgrade --cleanup-on-fail --install jupyterhub jupyterhub/jupyterhub --namespace jupyterhub --version 4.4.2  --values platform/jupyter/values.yaml

Конфигурация values-файла JupyterHub (параметры tolerations и nodeAffinity) — platform/jupyter/values.yaml

При настройке планировщика мы ориентируемся на официальную документацию Zero to JupyterHub K8s, чтобы понять, какие метки и параметры планирования необходимы.

Для примера найдём ноду с тейнтом mlflowtaint

kubectl get no -o custom-columns="NAME:.metadata.name,TAINTS:.spec.taints[*].key"
# Output:
NAME                   TAINTS
testlb-control-plane   node-role.kubernetes.io/control-plane
...
testlb-worker5         mlflowtaint
...

Устанавливаем метку hub.jupyter.org/node-purpose=core для ноды testlb-worker5

kubectl label nodes testlb-worker5 hub.jupyter.org/node-purpose=core

Используем стандартный values-файл (platform/jupyter/values.yaml), добавив или переопределив в нём следующие параметры

hub:
  # tolerations: []
  tolerations:
  - key: "mlflowtaint"
    operator: "Equal"
    value: "yestaint"
    effect: "NoSchedule"
  db:
    # type: sqlite-pvc
    # upgrade:
    # pvc:
    #   annotations: {}
    #   selector: {}
    #   accessModes:
    #     - ReadWriteOnce
    #   storage: 1Gi
    #   subPath:
    #   storageClassName:
    # url:
    # password:
    type: postgres
    url: postgresql+psycopg2://jupyterhub@cluster-jnhdb-rw.jnhdb:5432/jupyterhub

  # existingSecret:
  existingSecret: "jupyterhub-db-secret"

singleuser:
  extraNodeAffinity:
    required:
      - matchExpressions:
        - key: mlflownode
          operator: In
          values:
          - "yesnaff"
    # required: []
    # preferred: []
scheduling:
  corePods:
    tolerations:
      - key: hub.jupyter.org/dedicated
        operator: Equal
        value: core
        effect: NoSchedule
      - key: hub.jupyter.org_dedicated
        operator: Equal
        value: core
        effect: NoSchedule
      - key: "mlflowtaint"
        operator: "Equal"
        value: "yestaint"
        effect: "NoSchedule"
    nodeAffinity:
      matchNodePurpose: prefer
  userPods:
    tolerations:
      - key: hub.jupyter.org/dedicated
        operator: Equal
        value: user
        effect: NoSchedule
      - key: hub.jupyter.org_dedicated
        operator: Equal
        value: user
        effect: NoSchedule
      - key: "mlflowtaint"
        operator: "Equal"
        value: "yestaint"
        effect: "NoSchedule"
    nodeAffinity:
      matchNodePurpose: ignore # prefer

Установка и настройка MLflow

Команда установки через Helm:

helm upgrade --install mlflow oci://ghcr.io/mlflow/charts/mlflow --version 3.17.0 --namespace mlflow  --values platform/mlflow/values.yaml

Содержимое конфигурационного файла platform/mlflow/values.yaml

mlflow:
  backendStoreUriFrom:
    secretKeyRef:
      name: mlflow-db-secret
      key: uri

  defaultArtifactRoot: "mlflow-artifacts:/" 
  artifactsDestination: "s3://mlflow"

env:
  - name: MLFLOW_S3_ENDPOINT_URL
    value: "http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333"
  - name: AWS_DEFAULT_REGION
    value: "us-east-1"
  - name: MLFLOW_SERVER_ALLOWED_HOSTS
    value: "172.18.255.240,mlflow-mlflow.mlflow.svc.cluster.local,mlflow-mlflow.mlflow.svc.cluster.local:5000,localhost,127.0.0.1"
  - name: MLFLOW_SERVER_CORS_ALLOWED_ORIGINS
    value: "http://172.18.255.240"

envFrom:
  - secretRef:
      name: mlflow-s3-env-secret

server:
  staticPrefix: "/mlflow"

tolerations:
- key: "mlflowtaint"
  operator: "Equal"
  value: "yestaint"
  effect: "NoSchedule"

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
      - matchExpressions:
        - key: mlflownode
          operator: In
          values:
          - "yesnaff"

Настройка CiliumLoadBalancerIPPool

docker network inspect kind -f '{{range .IPAM.Config}}{{.Subnet}} {{end}}'
# Output:
fc00:f853:ccd:e793::/64 172.18.0.0/16

Выбираем диапазон подсети через маску /28 Чтобы адреса наших виртуальных балансировщиков никогда не пересеклись с реальными IP-адресами нод (в том числе если мы решим подключить ещё десяток воркер-нод через kubeadm join), мы уходим в самый конец сети — на диапазон 172.18.255.X.

Почему именно маска /28 и адрес 172.18.255.240? Для тестового стенда с JupyterHub и MLflow нам не нужны сотни IP-адресов.

Разбиения сети выглядит следующим образом:

  • Подсеть: 172.18.255.240/28

  • Диапазон выдаваемых адресов: от 172.18.255.241 до 172.18.255.254

  • Broadcast-адрес: 172.18.255.255

ipcalc 172.18.255.240/28
# Output:
Address:   172.18.255.240       10101100.00010010.11111111.1111 0000
Netmask:   255.255.255.240 = 28 11111111.11111111.11111111.1111 0000
Wildcard:  0.0.0.15             00000000.00000000.00000000.0000 1111
=>
Network:   172.18.255.240/28    10101100.00010010.11111111.1111 0000
HostMin:   172.18.255.241       10101100.00010010.11111111.1111 0001
HostMax:   172.18.255.254       10101100.00010010.11111111.1111 1110
Broadcast: 172.18.255.255       10101100.00010010.11111111.1111 1111
Hosts/Net: 14                    Class B, Private Internet

Применяем манифест platform/gateway/cilium/clb.yaml

apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
  name: kind-pool
  namespace: kube-system
  labels:
    pool: kind
spec:
  blocks:
  - cidr: 172.18.255.240/28
---
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
  name: kind-l2-policy
  namespace: kube-system
spec:
  interfaces:
  - ^eth[0-9]+
  loadBalancerIPs: true
  nodeSelector:
    matchLabels:
      webnode: "yesnaff"

Анонсы завязывем по matchLabels строго на ноды с меткой webnode, это изолирует сетевой внешний трафик от остальных нод.

Проверяем типы сервисов

kubectl get svc -A
# Output:
NAMESPACE     NAME                     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                                AGE
cnpg-system   cnpg-webhook-service     ClusterIP   10.96.81.40     <none>        443/TCP                                4h26m
default       kubernetes               ClusterIP   10.96.0.1       <none>        443/TCP                                8h
jnhdb         cluster-jnhdb-r          ClusterIP   10.96.214.15    <none>        5432/TCP                               4h18m
jnhdb         cluster-jnhdb-ro         ClusterIP   10.96.97.73     <none>        5432/TCP                               4h18m
jnhdb         cluster-jnhdb-rw         ClusterIP   10.96.192.167   <none>        5432/TCP                               4h18m
jupyterhub    hub                      ClusterIP   10.96.53.24     <none>        8081/TCP                               3h14m
jupyterhub    proxy-api                ClusterIP   10.96.231.137   <none>        8001/TCP                               3h14m
jupyterhub    proxy-public             ClusterIP   10.96.61.233    <none>        80/TCP                                 3h14m
kube-system   cilium-envoy             ClusterIP   None            <none>        9964/TCP                               4h36m
kube-system   hubble-peer              ClusterIP   10.96.115.63    <none>        443/TCP                                4h36m
kube-system   kube-dns                 ClusterIP   10.96.0.10      <none>        53/UDP,53/TCP,9153/TCP                 8h
mlflow        mlflow-mlflow            ClusterIP   10.96.186.249   <none>        5000/TCP                               36m
mlflowdb      cluster-mlflowdb-r       ClusterIP   10.96.241.117   <none>        5432/TCP                               3h31m
mlflowdb      cluster-mlflowdb-ro      ClusterIP   10.96.82.54     <none>        5432/TCP                               3h31m
mlflowdb      cluster-mlflowdb-rw      ClusterIP   10.96.111.65    <none>        5432/TCP                               3h31m
seaweedfs     seaweedfs-filer          ClusterIP   None            <none>        8888/TCP,18888/TCP,8333/TCP,9327/TCP   3h18m
seaweedfs     seaweedfs-filer-client   ClusterIP   None            <none>        8888/TCP,18888/TCP,9327/TCP            3h18m
seaweedfs     seaweedfs-master         ClusterIP   None            <none>        9333/TCP,19333/TCP,9327/TCP            3h18m
seaweedfs     seaweedfs-s3             ClusterIP   10.96.105.179   <none>        8333/TCP                               3h18m
seaweedfs     seaweedfs-volume         ClusterIP   None            <none>        8080/TCP,18080/TCP,9327/TCP            3h18m

Внешний IP-адрес балансировщика (LoadBalancer) появится только после создания объекта Gateway.

Создадим namespace с именем “infra-gw-jml” в нём будут объекты “Gateway”

kubectl create ns infra-gw-jml

Применяем манифест шлюза platform/gateway/mlflow/gateway.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: jml-cilium-gateway
  namespace: infra-gw-jml
  annotations:
    io.cilium/lb-ipam-pool: kind-pool
spec:
  gatewayClassName: cilium
  listeners:
  - name: http
    protocol: HTTP
    port: 80
    allowedRoutes:
      namespaces:
        from: All

Проверяем появление сервиса типа LoadBalancer

kubectl get svc -A|grep -E LoadBalancer\|TYPE
# Output:
NAMESPACE      NAME                                TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)                                AGE
infra-gw-jml   cilium-gateway-jml-cilium-gateway   LoadBalancer   10.96.107.1     172.18.255.240   80:31109/TCP                           5m11s

Внешний адрес EXTERNAL-IP успешно назначился и получил IP 172.18.255.240.

Проверяем доступность адреса

curl -I http://172.18.255.240/
# Output:
HTTP/1.1 404 Not Found
date: Mon, 15 Oct 2026 17:23:46 GMT
server: envoy
transfer-encoding: chunked

Теперь добавим HTTPRoute — platform/gateway/mlflow/HTTPRoute-jupyterhub.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web-route
  namespace: jupyterhub # ПЕРЕНЕСЛИ маршрут в неймспейс самого приложения
spec:
  parentRefs:
  - name: jml-cilium-gateway
    namespace: infra-gw-jml # Явно указываем, что шлюз лежит в другом ns
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: proxy-public # Чистое имя сервиса без точек
      namespace: jupyterhub # ЯВНО указываем целевой неймспейс сервиса
      port: 80

Проверяем доступность адреса и ищем в ответе статус “HTTP/1.1 200 OK”

curl -o /dev/null -s -v http://172.18.255.240/hub/login
# Output:
*   Trying 172.18.255.240:80...
* Established connection to 172.18.255.240 (172.18.255.240 port 80) from 172.18.0.2 port 52214 
* using HTTP/1.x
> GET /hub/login HTTP/1.1
> Host: 172.18.255.240
> User-Agent: curl/8.18.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< server: envoy
< content-type: text/html
< date: Mon, 17 Oct 2026 17:45:59 GMT
< x-jupyterhub-version: 5.3.0
< access-control-allow-headers: accept, content-type, authorization
< content-security-policy: frame-ancestors 'none'; report-uri /hub/security/csp-report
< etag: "df589664a25d5c3eb4c091fcadd7ea94f9add5f3"
< content-length: 7870
< set-cookie: _xsrf=MnwxOjB8MTA6MTc5MTgyNzE1OXw1Ol94c3JmfDY4OlRtOXVaVHBZTlVwSVMySkRlbVZmUWtVek9GTnNlRFpsWlY5d2NrZHhMVkJLYW5WZlkyOVlNek5ZZFZwaU1HZzRQUT09fGU4YWJlMjc3ZDA2M2MzZTU3NjllYWZjZTMxNGIzNjNiMmNkZTljZWUxOGI1ZmUzMGQzZjNkMjQ1MzZiMjdkNTk; Max-Age=3600; Path=/hub/
< x-envoy-upstream-service-time: 7
< 
{ [7870 bytes data]
* Connection #0 to host 172.18.255.240:80 left intact

Добавим HTTPRoute для mlflow — platform/gateway/mlflow/HTTPRoute-mlflow.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web-route-mlflow
  namespace: mlflow # ПЕРЕНЕСЛИ маршрут в неймспейс самого приложения
spec:
  parentRefs:
  - name: jml-cilium-gateway
    namespace: infra-gw-jml # Явно указываем, что шлюз лежит в другом ns
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /mlflow
    backendRefs:
    - name: mlflow-mlflow # Имя сервиса без точек
      namespace: mlflow # Указываем целевой неймспейс сервиса
      port: 5000

Добавим gateway с именем “hubble-cilium-gateway” для создания ещё одного LB для hubble-ui Манифест для hubble-cilium-gateway

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: hubble-cilium-gateway
  namespace: infra-gw-jml
  annotations:
    io.cilium/lb-ipam-pool: kind-pool
spec:
  gatewayClassName: cilium
  listeners:
  - name: http
    protocol: HTTP
    port: 80
    allowedRoutes:
      namespaces:
        from: All

Проверяем новый LB

kubectl get svc -A|grep -E LoadBalancer\|TYPE
# Output:
NAMESPACE      NAME                                   TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)                                AGE
infra-gw-jml   cilium-gateway-hubble-cilium-gateway   LoadBalancer   10.96.67.14     172.18.255.241   80:30556/TCP                           15s
infra-gw-jml   cilium-gateway-jml-cilium-gateway      LoadBalancer   10.96.107.1     172.18.255.240   80:31109/TCP                           4d18h

Видим новый внешний IP 172.18.255.241

Добавим HTTPRoute для Hubble UI — platform/gateway/hubble/HTTPRoute-hubble.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web-route-hubble-ui
  namespace: kube-system # ПЕРЕНЕСЛИ маршрут в неймспейс самого приложения
spec:
  parentRefs:
  - name: hubble-cilium-gateway # Здесь указываем имя gateway которое указали для hubble-ui
    namespace: infra-gw-jml # Явно указываем, что шлюз лежит в другом ns
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: hubble-ui # Имя сервиса без точек
      namespace: kube-system # Указываем целевой неймспейс сервиса
      port: 80

Проверяем доступность адреса и ищем в ответе статус “HTTP/1.1 200 OK”

curl -o /dev/null -sv "http://172.18.255.241"
# Output:
*   Trying 172.18.255.241:80...
* Established connection to 172.18.255.241 (172.18.255.241 port 80) from 172.18.0.2 port 55256 
* using HTTP/1.x
> GET / HTTP/1.1
> Host: 172.18.255.241
> User-Agent: curl/8.18.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< server: envoy
< date: Sat, 17 Oct 2026 11:43:10 GMT
< content-type: text/html
< content-length: 642
< last-modified: Tue, 08 Sep 2026 12:41:20 GMT
< etag: "6aa00270-282"
< accept-ranges: bytes
< x-envoy-upstream-service-time: 0
< 
{ [642 bytes data]
* Connection #0 to host 172.18.255.241:80 left intact

Наблюдаемость и сетевая безопасность: работа с Hubble UI

Для тестирования сетевой связности и проверки работы политик изоляции мы запускаем проверочный скрипт py/test.py внутри JupyterHub, создав новый ноутбук, и отслеживаем поведение трафика в реальном времени через Hubble UI.

1. Мониторинг пространства имен jupyterhub

На первом этапе мы заглядываем в неймспейс jupyterhub. Здесь отчетливо видна вся цепочка взаимодействия: трафик успешно заходит через Ingress, обрабатывается компонентами JupyterHub, отправляется во внешний мир (world), а также уходит в сторону сервера трекинга mlflow:

Hubble UI: namespace jupyterhub">

Hubble UI: namespace jupyterhub">

2. Мониторинг пространства имен mlflow

Затем мы переключаемся на пространство имен mlflow, чтобы детально рассмотреть инфраструктуру самого приложения. На карте зависимостей видно, как сервис mlflow принимает входящие запросы от jupyterhub и успешно коммуницирует со своими внутренними ресурсами: базой данных postgresql (порт 5432) и объектным хранилищем seaweedfs (порт 8333):

Hubble UI: namespace mlflow

Hubble UI: namespace mlflow

3. Обнаружение нежелательного внешнего трафика (Телеметрия)

При детальном анализе логов в Hubble UI мы замечаем, что из нашего кластера наружу уходят запросы. В логах четко фиксируются пакеты, отправляемые на внешний адрес api.mlflow-telemetry.io на порт 443 со статусом forwarded (перенаправлено). Наша задача — полностью заблокировать этот поток в интернет, сохранив внутреннюю связность кластера.

Исходящий трафик телеметрии разрешен

Исходящий трафик телеметрии разрешен

Применение политики безопасности (CiliumNetworkPolicy)

Для изоляции подов JupyterHub от внешней сети применяем манифест platform/gateway/cilium/cnp.yaml

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: jupyterhub-isolation
  namespace: jupyterhub
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/component: singleuser-server 
  egress:
    # 1. Разрешаем DNS-запросы ко всему кластеру (чтобы поды могли резолвить имена)
    - toEndpoints:
        - matchLabels:
            "k8s:io.kubernetes.pod.namespace": kube-system
            k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: UDP
          rules:
            dns:
              - matchPattern: "*"

    # 2. Разрешаем трафик ко ВСЕМ внутренним подам кластера (Kube-DNS, MLflow, SeaweedFS)
    - toEndpoints:
        - matchExpressions:
            - key: "k8s:io.kubernetes.pod.namespace"
              operator: Exists

    # Обратите внимание: секции для "reserved:world" здесь нет, 
    # поэтому весь трафик в интернет будет отброшен.

Результат фильтрации трафика в Hubble UI

4. Фиксация заблокированных запросов (Dropped Flows)

Как только политика вступает в силу, eBPF-программы Cilium начинают блокировать внешние запросы. В панели Hubble мы сразу видим маркеры dropped (подсвечены красным) при новой попытке отправить пакеты на api.mlflow-telemetry.io.

Кликнув по строке заблокированного лога, можно провести глубокий анализ пакета: увидеть вердикт, направление трафика (egress), TCP-флаги (RST) и конкретный исходный под (jupyter-admin), на котором сработала наша политика изоляции

Hubble UI: Блокировка внешних запросов после применения CNP

Hubble UI: Блокировка внешних запросов после применения CNP

Проверка артефактов в SeaweedFS S3

Политика безопасности успешно заблокировала внешнюю сеть, но сохранила внутреннюю связность кластера. Чтобы окончательно убедиться, что JupyterHub и MLflow беспрепятственно сохраняют данные во внутреннем объектном хранилище, проверим содержимое бакета mlflow.

Для этого отправим прямой запрос к API SeaweedFS S3 внутри кластера, используя встроенную в curl подпись AWS SigV4

Политика безопасности успешно заблокировала внешнюю сеть, но сохранила внутреннюю связность кластера. Чтобы окончательно убедиться, что стек JupyterHub ➔ MLflow беспрепятственно сохраняет данные во внутреннем объектном хранилище, проверим содержимое бакета mlflow.

Для этого сначала извлечем credentials для чтения из секрета Kubernetes и расшифруем их с помощью jq

kubectl -n seaweedfs get secret seaweedfs-s3-secret -o json | jq -r '"\(.data.read_access_key_id | @base64d):\(.data.read_secret_access_key | @base64d)"'
# Output
Tzk74FVIQAOnPbqfOc6z:uVSXdFcpTOwxjoj8PeRIUPvDN5tCzakFhnfNmTBY

Используя полученные данные формата username:password, отправляем запрос к SeaweedFS S3 внутри кластера с авторизацией AWS SigV4, чтобы увидеть список файлов

curl --user "Tzk74FVIQAOnPbqfOc6z:uVSXdFcpTOwxjoj8PeRIUPvDN5tCzakFhnfNmTBY" --aws-sigv4 "aws:amz:us-east-1:s3" -s "http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333/mlflow"| grep -o '<Key>[^<]*</Key>' | sed 's|<Key>||g; s|</Key>||g'
# Output:
1/13229738f0f644de80a412e89cd284f4/artifacts/check-status.txt
1/2d38b8d475d545d199a71a502bcedd4c/artifacts/check-status.txt
1/757d5f747aad4059b1396933ecab5654/artifacts/check-status.txt
1/b460b792eaeb489fbc1b7a2bc2b8668d/artifacts/check-status.txt
1/c04b8b16cee74e52a4918adc712db8ea/artifacts/check-status.txt
1/d802426e487c40898a0e62c9d7605bfc/artifacts/check-status.txt

Итог эксперимента: Все артефакты (файлы check-status.txt) от запусков экспериментов MLflow успешно создаются и логируются в S3-бакете. Сетевая изоляция работает: внутренний трафик ходит без ограничений, а несанкционированная внешняя телеметрия полностью заблокирована.

Лог хаос-тестирования (Файловер в действии)

Вот как выглядит живой лог из терминала, когда мы поочередно отключаем и включаем наши ноды testlb-worker7 и testlb-worker8 (фоновый скрипт мониторинга перемешался с командами управления Docker):

while true; do echo -n "$(date +%T) -> HTTP Code: "; curl -o /dev/null -s -w "%{http_code}\n" http://172.18.255.240/hub/login; sleep 10; done &
[1] 60152

14:36:37 -> HTTP Code: 200

# Шаг 1: Гасим ОБЕ ноды, имитируя полный отказ
$ docker stop testlb-worker7
$ docker stop testlb-worker8

14:56:13 -> HTTP Code: 000
14:57:02 -> HTTP Code: 000  # Кластер лежит, шлюз недоступен

# Шаг 2: Возвращаем к жизни первую ноду
$ docker start testlb-worker7

14:57:15 -> HTTP Code: 000  # Идет инициализация Cilium агента...
14:57:28 -> HTTP Code: 000
14:57:41 -> HTTP Code: 200  # L2-анонсы отработали, шлюз ОЖИЛ!
14:58:12 -> HTTP Code: 200

# Шаг 3: Поднимаем вторую ноду для обеспечения HA
$ docker start testlb-worker8

14:58:42 -> HTTP Code: 200  #  две ноды полностью в строю

# Шаг 4: Тест файловера. Выбиваем активную ноду worker7
$ docker stop testlb-worker7

14:59:23 -> HTTP Code: 200  #  трафик ушел на worker8!
14:59:34 -> HTTP Code: 200
14:59:44 -> HTTP Code: 200

# Шаг 5: Добиваем последнюю ноду
$ docker stop testlb-worker8

14:59:53 -> HTTP Code: 000  # Все ноды с метками webnode отключены

И способ посмотреть через объекты Kubernetes (нужно запустить ноды worker7 и worker8 перед следующей проверкой):

# Проверяем какая нода принимает трафик
kubectl -n kube-system get lease | grep -E jml-cilium-gateway\|HOLDER
NAME                                                                  HOLDER                                                                      AGE
cilium-l2announce-infra-gw-jml-cilium-gateway-hubble-cilium-gateway   testlb-worker8                                                              25m
cilium-l2announce-infra-gw-jml-cilium-gateway-jml-cilium-gateway      testlb-worker8                                                              25m

# Останавливаем ноду из столбца HOLDER - testlb-worker8
docker stop testlb-worker8
testlb-worker8

# Проверяем какая нода принимает трафик
kubectl -n kube-system get lease | grep -E jml-cilium-gateway\|HOLDER
NAME                                                                  HOLDER                                                                      AGE
cilium-l2announce-infra-gw-jml-cilium-gateway-hubble-cilium-gateway   testlb-worker7                                                              26m
cilium-l2announce-infra-gw-jml-cilium-gateway-jml-cilium-gateway      testlb-worker7

# Останавливаем оставшуюся ноду из столбца HOLDER - testlb-worker7
docker stop testlb-worker7
testlb-worker7

# Убеждаемся, что аренды сброшены и лидеры отсутствуют (в выводе осталась только строка заголовков)
kubectl -n kube-system get lease | grep -E jml-cilium-gateway\|HOLDER
NAME                                   HOLDER                                                                      AGE
kubectl -n kube-system get lease | grep -E jml-cilium-gateway\|HOLDER|wc -l
1

Вывод: Тест наглядно показал:

  1. При падении одной из нод (worker7) Cilium выполняет переключение трафика на worker8. Клиент не замечает аварии.

  2. При падении всех нод и последующем старте хотя бы одной, балансировщик самостоятельно восстанавливает работоспособность за ~20-30 секунд (время на инициализацию пода cilium-agent).

Оптимизация для KinD кластера

Локальный запуск тяжелого стека приложений (JupyterHub, MLflow, SeaweedFS, PostgreSQL) накладывает свои ограничения на ресурсы хоста. Ниже собраны проверенные рецепты для оптимизации работы и решения частых проблем с KinD.

1. Отключаем автостарт кластера testlb

По умолчанию KinD настраивает контейнеры нод на автоматический запуск вместе с Docker-демоном. Если вы хотите контролировать ресурсы вашей машины и запускать кластер только для работы, отключите автостарт одной командой:

docker update --restart=no $(docker ps -a -q --filter name=testlb)

2. Запуск кластера testlb

Теперь кластер будет «спать» до тех пор, пока вы не поднимете его вручную, когда это необходимо:

docker start $(docker ps -a -q --filter name=testlb)

3. Регулировка лимитов ядра (Inotify)

Если вы разворачиваете в KinD большое количество микросервисов, вам может потребоваться подобрать оптимальные параметры ядра хост-системы (Linux/WSL2): fs.inotify.max_user_watches, fs.inotify.max_user_instances и fs.inotify.max_queued_events. Без их увеличения можно столкнуться с нехваткой лимитов подсистемы inotify.

4. Лечение подов в статусе “Unknown”

После выключения кластера, падения Docker или ухода компьютера в сон, часть подов зависает в статусе Unknown. Лечим эту проблему командой принудительного удаления

kubectl get pods --all-namespaces | grep -i "Unknown" | awk '{print \$1, \$2}' | xargs -n2 kubectl delete pod --grace-period 0 --force -n

🏁 Вместо заключения

Конечно, в рамках одной статьи получилось затронуть всего понемногу. Мы развернули базовый локальный стенд, настроили простейшую маршрутизацию и посмотрели на базовую блокировку трафика через интерфейс Hubble.

Этот проект — отличная отправная точка для дальнейших экспериментов. Куда можно копать дальше:

  • CiliumNetworkPolicy: углубиться в eBPF-фильтрацию, настроить L7-политики (например, фильтровать трафик по конкретным HTTP-путям или методам) и протестировать защиту от спуфинга.

  • Gateway API: подробно изучить возможности продвинутого управления трафиком.

Технологии Cilium и Gateway API развиваются очень быстро, и их локальное тестирование в связке с KinD — лучший способ разобраться в современных сетевых стандартах Kubernetes.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.