Пересобираю свой Kubernetes-кластер на Proxmox из тех же компонентов, что используются в промышленной среде: Gateway API, реплицируемое хранилище, настоящие IP-адреса для LoadBalancer. И всё это на одном мини-ПК, который потребляет около 10 Вт. Система получилась достаточно экономичной, чтобы её можно было спокойно держать включённой постоянно.

У меня уже давно есть репозиторий с Terraform и Ansible для развёртывания Kubernetes на Proxmox. Всё работало, но последний раз я обновлял его ещё во времена Kubernetes 1.33. Причём под «работало» подразумевался голый кластер на kubeadm с Calico для организации сети Pod и практически ничем больше. Ни хранилища, ни возможности принимать входящий трафик. Всё это потом приходилось настраивать вручную.

В этот раз я решил собрать более полноценную систему. В первую очередь хотелось получить кластер, приближенный к промышленному, на котором можно тестировать разные решения. Ведь кластер мало похож на те, с которыми я обычно работаю, если в нём нет постоянного хранилища, способного пережить отказ узла, нормального уровня входящего трафика и сервисов типа LoadBalancer с настоящими маршрутизируемыми IP-адресами.

В прошлый раз я ещё и ошибся с выбором железа. Старый кластер работал на трёх физических узлах, каждый с 16 ГБ оперативной памяти и медленными SSD. Я исходил из того, что Kubernetes будет требователен к процессору. Но, по крайней мере в масштабах домашней лаборатории, это не так. Гораздо активнее он расходует оперативную память и нагружает дисковую подсистему.

Причём виноват был не только Kubernetes. На каждом из старых физических серверов стоял Proxmox с зеркальным ZFS-массивом, а ARC (кеш ZFS в оперативной памяти) отъедал часть этих 16 ГБ ещё до запуска первой виртуальной машины.

Поэтому теперь я решил пойти от обратного: один компьютер Dell Optiplex 3070 с процессором i5-9500T, который без дела пылился на полке, зато с 64 ГБ оперативной памяти, быстрым NVMe-накопителем Crucial P310 и Proxmox на однодисковом ZFS RAID0.

The Optiplex 3070 mini PC

Тут должен оговориться: всё это работает на одном физическом компьютере. Репликация хранилища между двумя виртуальными машинами защищает от сбоев самих ВМ или их операционных систем, но не от выхода из строя мини-ПК. По архитектуре решение похоже на корпоративное, но масштаб последствий сбоя здесь совсем другой.

Меня это устраивает: всё-таки речь о тестовой лаборатории. К тому же каждую ночь диски виртуальных машин Proxmox резервируются на мой NAS. Так что, если мини-ПК выйдет из строя, я восстановлю ВМ из резервных копий, и через несколько минут всё снова заработает.

А насколько уверенно вы сами ориентируетесь в Kubernetes и его инфраструктурной экосистеме? Проверить свои знания можно во вступительном тесте курса «Инфраструктурная платформа на основе Kubernetes».

Стек технологий

Вот какие компоненты я планировал использовать на каждом уровне:

Уровень

Что планировал использовать

Гипервизор

Proxmox VE 9 на Dell Optiplex 3070 (9500T, 64 ГБ DDR4-3200, NVMe Crucial P310 на 1 ТБ)

Виртуальные машины

Terraform (telmate/proxmox), клонирование из шаблона Ubuntu 26.04 с cloud-init

Kubernetes

kubeadm, v1.36.5 (версия задаётся через переменную Ansible)

Среда выполнения контейнеров

containerd 2.3

Сеть Pod

Calico 3.32

Хранилище

Longhorn 1.12 (по умолчанию, с репликацией) + local-path (быстрое локальное хранилище узла)

LoadBalancer

MetalLB 0.16 в режиме L2

Входящий трафик

Envoy Gateway 1.9 (Gateway API)

TLS

Терминация TLS на Nginx Proxy Manager перед кластером

Метрики

metrics-server

Vault для секретов?

Менеджера секретов пока нет. Vault есть в планах, но на данном этапе можно обойтись без него.

С TLS и DNS я немного схитрил: использовал NPM (Nginx Proxy Manager), который уже работает в моей домашней лаборатории и находится полностью за пределами кластера. Если TLS вам не нужен, всё прекрасно работает и без него.

Одна виртуальная машина для плоскости управления и два рабочих узла:

Виртуальная машина

vCPU

Оперативная память

Диск

kube-server-01

2

8 ГБ

64 ГБ

kube-agent-01

2

12 ГБ

128 ГБ

kube-agent-02

2

12 ГБ

128 ГБ

Виртуальные машины – Terraform

Я переработал конфигурации виртуальных машин, чтобы они больше походили на современные серверы, а не на стандартные ВМ, которые Proxmox создаёт по принципу «лишь бы запустилось» (SeaBIOS, i440fx и всё в таком духе):

  • Машина: q35

  • BIOS: OVMF (UEFI) с EFI-диском и диском для хранения состояния TPM 2.0

  • CPU: host

  • Диск: virtio-scsi-single с включёнными iothread, discard и эмуляцией SSD, чтобы команды TRIM действительно доходили до ZFS

  • Динамическое перераспределение памяти (ballooning): отключено. Kubernetes планирует размещение Pod исходя из доступного, по его мнению, объёма памяти. Если balloon-драйвер незаметно забирает часть этой памяти обратно, можно неожиданно получить принудительное завершение процессов из-за нехватки памяти (OOM kill)

  • Автозапуск: включён, чтобы кластер самостоятельно поднимался после перезагрузки физического хоста

Кроме того, виртуальной машине плоскости управления я назначил вдвое больший вес CPU по сравнению со значением по умолчанию. Если на физическом хосте возникнет конкуренция за процессорные ресурсы, etcd и API-сервер получат приоритет:

cpu {
  cores = var.server_cores
  type  = "host"
  # Удваиваем стандартный вес CPU, чтобы etcd и API-сервер получали приоритет при нехватке процессорных ресурсов хоста
  units = 200
}

Количество рабочих узлов, число ядер, объём оперативной памяти и размеры дисков задаются переменными. IP-адреса рабочих узлов назначаются последовательно, начиная с указанного начального адреса в заданной подсети.

Kubernetes – Ansible

Плейбуки я тоже основательно обновил:

  • Выбор версии Kubernetes. По умолчанию используется v1.36, но можно указать другую версию через --extra-vars "k8s_version=v1.37". Команда kubeadm init просто определяет установленную версию kubeadm, поэтому менять её нужно только в одном месте.

  • Больше никакого apt-key. В Ubuntu 26.04 этой утилиты уже нет, поэтому старый плейбук сразу падал. Теперь репозитории Docker и Kubernetes используют отдельные файлы ключей (keyring) с параметром signed-by.

  • Идемпотентная настройка репозиториев. Конфигурация источника пакетов записывается целиком в отдельный файл. Поэтому при смене версии старый репозиторий заменяется новым, а не остаётся рядом с ним.

ansible-playbook -i ./ansible/inventory/hosts --key-file ~/.ssh/my_kube_keys \
  ./ansible/roles/common/tasks/main.yaml

После установки Calico и подключения рабочих узлов я провёл быструю проверку из Pod на одном из них: сначала разрешил DNS-имя сервиса через CoreDNS, работающий в плоскости управления, а затем проверил доступ в интернет.

kubectl run nettest --image=busybox:1.37 --restart=Never --rm -i -- \
  sh -c "nslookup kubernetes.default.svc.cluster.local && wget -qO- -T5 http://example.com"

Все три узла в состоянии Ready. Примерно на этом возможности старого репозитория и заканчивались.

Хранилище – Longhorn

Для хранения данных я выбрал Longhorn. Он поддерживает репликацию томов между узлами, снапшоты, резервное копирование в S3 или NFS, расширение томов и веб-интерфейс. При этом ему не требуется ничего особенного от гипервизора.

Перед установкой нужно было немного подготовить узлы:

  • Установить open-iscsi с включённым iscsid, а также nfs-common для томов с режимом доступа ReadWriteMany.

  • Настроить загрузку модулей ядра iscsi_tcp и dm_crypt при старте системы.

  • Запретить multipathd вмешиваться в работу Longhorn. В облачных образах Ubuntu multipathd запускается по умолчанию и может перехватывать блочные устройства, подключённые Longhorn, из-за чего возникают проблемы с монтированием. Лично я с этим не столкнулся: заранее изучил документацию Ubuntu по multipath и решил, что лучше перестраховаться, чем потом разбираться с последствиями.

blacklist {
    devnode "^sd[a-z0-9]+"
}

На узле плоскости управления установлен taint, поэтому Longhorn хранит данные только на двух рабочих узлах. Значит, стандартное требование трёх реплик здесь выполнить невозможно. Я уменьшил их количество до двух.

Кроме того, я добавил local-path в качестве второго класса хранения. По сути, это обычный каталог на том узле, где запущен Pod. Репликации нет, зато работает быстро. Отличный вариант для кешей и временных данных.

Класс хранения

Для чего использовать

longhorn (по умолчанию)

Всё, что действительно нужно сохранить

local-path

Кеши и временные данные, когда скорость важнее сохранности

Для проверки я запустил тестовый Pod с томами обоих классов, записал в каждый файл и прочитал его обратно. Том Longhorn успешно создался и перешёл в исправное состояние: по одной реплике на каждом рабочем узле.

Как уменьшить потребление ресурсов Longhorn

С настройками по умолчанию Longhorn развернул в моём небольшом кластере 22 Pod. Для двух рабочих узлов это показалось многовато, поэтому я решил разобраться, чем занимается каждый из них.

Главными виновниками оказались сайдкары CSI: attacher, provisioner, resizer и snapshotter. На них приходилось 12 Pod, по три реплики каждого компонента. Все они используют механизм выбора лидера, а значит, в каждый момент времени реально работает только один Pod каждого типа. Остальные два просто ждут на случай, если первый выйдет из строя. В кластере с двумя рабочими узлами особой пользы от такой избыточности нет.

Веб-интерфейс Longhorn по умолчанию тоже запускается в двух репликах. Но это всего лишь веб-интерфейс без собственного состояния, который я открываю от силы раз в неделю.

longhornUI:
  replicas: 1
csi:
  attacherReplicaCount: 1
  provisionerReplicaCount: 1
  resizerReplicaCount: 1
  snapshotterReplicaCount: 1

В результате количество Pod сократилось с 22 до 14. Остались демоны, которые действительно выполняют основную работу и запускаются по одному экземпляру на узел: manager, плагин CSI, instance manager и engine image. Пожалуй, сильнее облегчить Longhorn уже не получится.

За это пришлось пойти на компромисс: если узел, на котором работает сайдкар CSI, выйдет из строя, создание и подключение новых томов задержится примерно на минуту, пока Pod не будет повторно назначен на другой узел. На уже смонтированные тома это не повлияет.

Как организовать входящий трафик – MetalLB + Envoy Gateway

Над этой частью я думал дольше всего.

Сначала хотелось пойти самым простым путём: открыть NodePort и направлять на него трафик через Nginx Proxy Manager (NPM), который уже обслуживает остальные сервисы моей домашней лаборатории.

Но у NodePort есть свои неудобства. Приходится обращаться к IP-адресу конкретного узла через порт из диапазона 30000. NPM не умеет работать с пулами вышестоящих серверов с проверкой их доступности. А если бы мне понадобилась маршрутизация по SNI, пришлось бы вручную прописывать конфигурацию nginx с ssl_preread в обход интерфейса NPM. Нет уж, спасибо.

Раньше очевидным решением внутри кластера был ingress-nginx, но теперь этот проект закрыт: команда Kubernetes прекратила выпуск новых версий и исправлений безопасности. Сам Ingress API больше не развивается, а дальнейшее развитие экосистемы связано с Gateway API. Поэтому я выбрал следующую связку:

  • MetalLB в режиме L2 – выдаёт сервисам типа LoadBalancer настоящие IP-адреса локальной сети из небольшого диапазона, который я выделил за пределами пула DHCP. Если один из узлов выходит из строя, IP-адрес переходит на другой. При единственном физическом хосте пользы от этого немного, но если я когда-нибудь добавлю новые серверы, ничего переделывать не придётся. К тому же именно так подобные задачи решают в промышленных кластерах на собственной инфраструктуре.

  • Envoy Gateway – реализация Gateway API, которая позволяет обрабатывать трафик L7 (HTTPRoute, GRPCRoute) и L4 (TCPRoute, UDPRoute, TLSRoute) через единую точку входа. Всё настраивается через YAML-манифесты, которые хранятся рядом с конфигурациями соответствующих приложений.

Internet ─► NPM (TLS, *.k8s.mydomain.com) ─► MetalLB IP ─► Envoy Gateway ─► HTTPRoute ─► app

Шлюз получает фиксированный IP-адрес от MetalLB через ресурс EnvoyProxy и принимает обычный HTTP-трафик для *.k8s.mydomain.com. NPM отвечает за TLS с помощью уже настроенного Let’s Encrypt и перенаправляет весь трафик на этот IP-адрес. Теперь для публикации нового приложения достаточно создать HTTPRoute. К настройкам NPM больше возвращаться не нужно:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: myapp
spec:
  parentRefs: [{name: eg, namespace: envoy-gateway-system}]
  hostnames: [myapp.k8s.mydomain.com]
  rules: [{backendRefs: [{name: myapp, port: 8080}]}]

Примечание: Helm-чарт MetalLB по умолчанию также устанавливает frr-k8s – Pod с пятью контейнерами на каждом узле. Он нужен только для BGP, а в режиме L2 ничего не делает. Поэтому достаточно добавить --set frrk8s.enabled=false, и лишние Pod не появятся.

Ловушка с X-Forwarded-Proto

При первом же запросе через curl всё заработало, но в заголовках ответа тестового приложения обнаружилось следующее:

X-Forwarded-Proto: http

Хотя я подключался по HTTPS. NPM правильно установил заголовок, но Envoy услужливо перезаписал его, указав протокол своего соединения с NPM, то есть обычный HTTP.

В результате приложения за Envoy считали бы, что к ним обращаются по HTTP. Отсюда и циклические перенаправления, ссылки с http:// на страницах и защищённые cookie, которые незаметно перестают устанавливаться.

Решение – указать Envoy, что перед ним находится один доверенный промежуточный прокси:

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: trust-reverse-proxy
  namespace: envoy-gateway-system
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: eg
  clientIPDetection:
    xForwardedFor:
      numTrustedHops: 1

После этого X-Forwarded-Proto стал содержать https, а в X-Real-Ip появился настоящий IP-адрес клиента.

Первым через эту схему я опубликовал веб-интерфейс Longhorn. Теперь все ссылки на API в нём корректно формировались с https://.

Примечание: у веб-интерфейса Longhorn нет авторизации. Любой, кто получит к нему доступ, сможет удалять тома. У меня интерфейс доступен только из локальной сети, но я всё равно собираюсь защитить его с помощью Envoy SecurityPolicy.

«Мои узлы используют 70% оперативной памяти!»

Через несколько дней Proxmox начал показывать, что каждый узел использует около 70% оперативной памяти. Первой мыслью было увеличить объём RAM. Но прежде я решил разобраться, что происходит на самом деле:

Узел

RAM

Реально используется

Страничный кеш

Доступно

kube-server-01

8 ГБ

24%

3,5 ГБ

6,0 ГБ

kube-agent-01

12 ГБ

9%

7,5 ГБ

10,8 ГБ

kube-agent-02

12 ГБ

9%

7,4 ГБ

10,8 ГБ

Давление на память (PSI) на всех узлах оказалось нулевым. Почти все эти 70% приходились на страничный кеш, который Linux заполнил при загрузке образов и дисковых операциях Longhorn. Если приложениям понадобится память, система сразу освободит её за счёт кеша.

Proxmox считает эту память занятой, потому что механизм ballooning отключён. Без balloon-драйвера гостевая система не может сообщить гипервизору, сколько памяти она реально использует. Поэтому Proxmox учитывает все страницы памяти, к которым виртуальная машина когда-либо обращалась на физическом хосте.

На этот показатель стоит обращать внимание, только если оперативная память заканчивается у самого физического хоста. А для оценки состояния виртуальных машин лучше следить за доступной памятью (available) и PSI.

После установки metrics-server команда kubectl top показала ту же картину:

NAME             CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
kube-agent-01    105m         5%       912Mi           7%
kube-agent-02    123m         6%       930Mi           7%
kube-server-01   298m         14%      1897Mi          24%

Вся платформа целиком – Kubernetes, Calico, Longhorn, MetalLB, Envoy Gateway и metrics-server – занимает меньше 4 ГБ оперативной памяти. Самый прожорливый отдельный процесс – kube-apiserver, которому требуется около 830 МБ.

И кто там говорил, что Kubernetes слишком тяжёлый для мини-ПК?

Теперь, когда вопросы с хранилищем и входящим трафиком решены, у меня наконец-то есть полноценный кластер, на котором можно нормально разворачивать и тестировать разные решения.

Первым делом я хотел попробовать настроить SPIRE для идентификации рабочих нагрузок:

  • SPIRE – выдача идентификаторов SPIFFE рабочим нагрузкам внутри кластера.

  • Entra ID Federated Identity Credentials – обмен JWT, выданного SPIRE, на токен Azure без необходимости где-либо хранить секреты.

  • Федерация SPIRE-to-SPIRE – объединение кластера с отдельным доменом доверия (возможно, на самостоятельной ВМ) с поддержкой mTLS между доменами.

Но это уже тема для отдельной статьи.

Весь код Terraform и Ansible доступен в репозитории, если захотите попробовать собрать такой кластер самостоятельно.

Стенд развёрнут на Proxmox VE 9.2.2, Kubernetes v1.36.5 и Ubuntu 26.04.

Собрать Kubernetes-кластер — одна задача, наладить работу приложений в нём — уже другая. Здесь важно разобраться, как K8s взаимодействует с контейнерными runtime и как автоматизировать сборку и доставку приложений. Обе темы будут разбираться на ближайших бесплатных уроках Otus:

  • 10 ноября, 20:00. «CRI — обзор контейнерных runtime». Записаться

  • 16 ноября, 20:00. «GitlabCI + ArgoCD — сборка и доставка приложений, не покидая кластер». Записаться

Больше открытых уроков можно посмотреть в календаре мероприятий.

Комментарии (0)