Возможно, у вас была мысль запустить собственную услугу Infrastructure as a Service (IaaS) либо разместить свои проекты на Kubernetes, но не платить за managed-услуги, или ваш проект — это и есть self-managed Kubernetes. У full-stack-разработчика и предпринимателя Нейта Бакареффа (Nate Buckareff) эти желания сошлись воедино. Он специализируется на консультации по веб-разработке — помогает людям воплощать потребности бизнеса в технические решения, сохраняя при этом внимание к клиенту.

Нейт решил научиться развертывать собственный кластер на виртуальных выделенных серверах, о чем и рассказал в своем блоге. Эта статья является переводом его материала. Далее идет текст от автора.

Сначала мне казалось, что установка Kubernetes будет самым сложным этапом. Но благодаря проектам вроде k0s это оказалось довольно просто. Настоящая же проблема была в другом: для работы с cloud-init, QEMU, виртуальными сетевыми интерфейсами Linux и HAProxy мне пришлось изучить немало источников.

Пока мы не приступили к настройке кластера, хочу предупредить, что его не стоит использовать для production. Установить Kubernetes легко, но обслуживать кластер и поддерживать его в актуальном состоянии труднее. Мой совет всем, кто рассматривает Kubernetes в качестве production-системы: вам придется либо стать экспертом по Kubernetes, либо нанять эксперта, либо выбрать какой-то managed-сервис. Также рекомендую использовать Docker Compose до тех пор, пока рабочая нагрузка помещается на одном сервере.

В этой статье мы рассмотрим, как развернуть локальный ВМ-кластер с помощью QEMU, настроить виртуальную сеть с помощью команды ip, установить Kubernetes (k0s), настроить Ingress-контроллер, а также HAProxy в качестве балансировщика нагрузки. Предполагается, что вы используете Linux.

Локальное тестовое окружение

Мы будем создавать локальные виртуальные машины (ВМ) с помощью QEMU. Изначально я хотел использовать VPS (виртуальный частный сервер), но локальный вариант больше подходит для экспериментов. В зависимости от целей такая установка — хорошая отправная точка для развертывания CI/CD-системы или локальной среды разработки.

Так выглядит высокоуровневая архитектурная схема того, что мы будем строить:

Всего у нас будет четыре ВМ на базе QEMU в отдельной приватной сети /24. Три ВМ мы используем в качестве узлов Kubernetes, а на четвертой запустим балансировщик нагрузки HAProxy. На каждом узле будет виртуальное TAP-устройство, подключенное к виртуальному мосту br0. Демон dnsmasq, запущенный на хосте, будет назначать IP-адреса узлам на основе их MAC-адресов.

Я использую QEMU, но подойдет и другое ПО для виртуализации в Linux. Например, VirtualBox или VMWare. Еще есть проект libvirt, который абстрагируется от различных бэкендов виртуализации (включая QEMU). Можно использовать его.

Приватная сеть выступает в роли эмулированного VPC. Правила iptables позволяют узлам выходить во внешнюю сеть.

Каждый узел получает предопределенный MAC-адрес. Далее dnsmasq будет назначать соответствующий IP-адрес по MAC-адресу клиента.

Также можно использовать cloud-init для статического назначения IP-адресов, но тогда потребуется создавать свой seed ISO для каждого узла.

Узел HAProxy отвечает за обратное проксирование внешнего трафика на узлы Kubernetes.

Debian на QEMU с cloud-init

Для ВМ мы будем использовать Debian 12 в виде типового облачного образа. Образ оптимизирован для виртуальных машин — из него исключены драйверы для реального аппаратного обеспечения. Это не обязательно, но в дальнейшем может пригодиться, если развертывать собственные ВМ на bare-metal-серверах.

Загружаем последнюю версию типового облачного образа Debian 12:

wget -O debian12.qcow2 https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2

Минус этого образа — в нем нет учетной записи пользователя по умолчанию или пароля root. Чтобы войти в систему, настроим ее с помощью сценария первоначальной настройки cloud-init. Для этого используем seed ISO. По сути, именно это облачный провайдер делает каждый раз при запуске нового VPS.

Получается, чтобы создать новую ВМ, мы клонируем базовый образ диска. Затем загружаем ВМ с помощью нового образа диска и seed ISO. Если настройки ВМ отличаются, мы сразу генерируем новый seed ISO. В нашем случае во все ВМ сразу добавим открытый ключ SSH, чтобы на них можно было удаленно заходить с терминала по протоколу SSH.

Для создания seed ISO-файла воспользуемся инструментом cloud-localds. Пользователям Ubuntu необходимо установить пакет cloud-image-utils:

sudo apt install cloud-image-utils

Теперь создадим cloud-конфиг cloud_init.yaml:

#cloud-config

users:

  - name: root

    ssh-authorized-keys:

      - <ВАШ_ПУБЛИЧНЫЙ_КЛЮЧ_SSH>

Подставим в него свой публичный ключ и запустим cloud-localds:

cloud-localds -v seed.iso cloud_init.yaml

Должен появиться ISO-файл с именем seed.iso:

fileseed.iso

seed.iso: ISO 9660 CD-ROM filesystem data 'cidata'

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

cp ./debian12.qcow2 ./test.qcow2

Теперь можно загрузить ВМ с помощью образа диска и seed ISO:

sudo qemu-system-x86_64 \
    -m 2G \
    -smp 2 \
    -device virtio-net-pci,netdev=net0 \
    -netdev user,id=net0,hostfwd=tcp::2222-:22 \
    -drive file=./test.qcow2,if=virtio,cache=writeback,discard=ignore,format=qcow2 \
    -drive file=./seed.iso,media=cdrom \
    -boot d \
    -serial stdio \
    -machine type=pc,accel=kvm

У QEMU много флагов, поэтому изучим эту команду подробнее:

  • qemu-system-x86_64 — QEMU использует разные исполняемые файлы для архитектур гостевых систем. Например, здесь мы указываем тип гостевой системы x86_64.

  • -m 2G объем оперативной памяти.

  • -smp 2 — число ядер CPU (vCPU).

  • -device virtio-net-pci,netdev=net0 — создание виртуальной карты сетевого интерфейса с net0 в качестве сетевого бэкенда. В QEMU сетевые устройства и сетевые бэкенды — разные вещи. Устройство — это то, что видит гостевая система, а бэкенд — это то, что подключается к окружению хоста.

  • -netdev user,id=net0,hostfwd=tcp::2222-:22 — создание сетевого бэкенда с именем net0. Мы используем тип бэкенда user networking (или SLIRP) и перенаправляем трафик с порта 2222 на хосте на порт 22 на госте.

  • -drive file=./test.qcow2,... — создание виртуального диска на базе клонированного образа Debian.

  • -drive file=./seed.iso,... — создание виртуального CD-ROM со вставленным seed ISO.

  • -boot d — загрузка ВМ с CD-ROM. В нашем случае загружается seed ISO, который настраивает систему.

  • -serial stdio — перенаправление виртуальной консоли в stdout нашего терминала для облегчения отладки. Это делается, чтобы можно было прокручивать вывод назад.

  • -machine type=pc,accel=kvm — предписание QEMU использовать ускорение технологии KVM. Это позволяет задействовать встроенную поддержку виртуализации на хосте с помощью KVM-модуля ядра.

После выполнения этой команды откроется графический интерфейс QEMU и начнется загрузка виртуальной машины. Мы должны увидеть вывод виртуальной консоли в графическом интерфейсе и терминале. Здесь нужно обратить внимание на логи cloud-init и ci-info — в одном из сообщений будет упомянут заданный в конфигурации публичный ключ.

После завершения загрузки ВМ можно войти в систему по SSH на порту 2222:

ssh root@localhost -p 2222

В итоге мы успешно создали пробную виртуальную машину. Теперь пора настроить виртуальную сеть и создать ВМ для каждого из четырех узлов.

Виртуальная сеть

Напомним, что пробная ВМ, которую мы только что создали, использует тип бэкенда user networking. Этот бэкенд отлично подходит для тестирования, но отличается низкой производительностью и изолирует каждую ВМ в отдельной сети. Мы же хотим создать кластер виртуальных машин, которые работают в общей виртуальной сети, поэтому этот сетевой бэкенд нам не подходит.

Еще раз посмотрим на схему:

В ней четыре основных сетевых компонента:

  • Мост br0 выступает в роли виртуального коммутатора, к которому подключаются все наши виртуальные машины, образуя виртуальную локальную сеть.

  • К каждой ВМ подключено свое TAP-устройство. Затем TAP-устройство «включается» в мост и пересылает пакеты через виртуальную карту сетевого интерфейса виртуальной машины.

  • Демон dnsmasq, который выполняет функции сервера DHCP, назначает узлам IP-адреса.

  • Правило iptables, которое превращает br0 в NAT-маршрутизатор, позволяя узлам выходить в интернет.

Создадим устройство виртуального моста с помощью команды ip, присвоим ему IP-адрес 10.0.0.0/24 и запустим:

#  Создаем мост.
sudo ip link add br0 type bridge

# Назначаем адрес интерфейсу. Если этого не сделать, впоследствии dnsmasq будет жаловаться, что интерфейс, к которому он привязан, "не имеет адреса".
sudo ip addr add 10.0.0.0/24 dev br0

# Поднимаем мост.
sudo ip link set up dev br0

Убедимся, что устройство создано:

ip -d link show br0

Должно получиться нечто подобное:

159: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 6a:09:53:74:75:05 brd ff:ff:ff:ff:ff:ff promiscuity 0  allmulti 0 minmtu 68 maxmtu 65535 
    bridge [...]

Далее нужно запустить TAP-устройство для каждого из четырех узлов, которые мы будем создавать:

for NODE_NAME in node0 node1 node2 node3; do
    echo "${NODE_NAME}tap"

    # Создаем устройство TAP.
    sudo ip tuntap add "${NODE_NAME}tap" mode tap
    
    # Запускаем его.
    sudo ip link set "${NODE_NAME}tap" up
    
    # Подключаем его к мосту.
    sudo ip link set "${NODE_NAME}tap" master br0
done

Имена интерфейсов, которые выводятся через echo, понадобятся нам позже, когда мы будем настраивать сетевые бэкенды ВМ.

Снова убедимся, что устройства TAP созданы:

ip -d link show node0tap

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

160: node0tap: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast master br0 state UP mode DEFAULT group default qlen 1000
    link/ether e6:7d:17:30:0d:e5 brd ff:ff:ff:ff:ff:ff promiscuity 1  allmulti 1 minmtu 68 maxmtu 65521 
    tun type tap pi off vnet_hdr on persist on 
    bridge_slave [...]

Далее настроим dnsmasq на запуск демоном на хосте. Он будет выступать DHCP-сервером и сетевым шлюзом для наших узлов.

Вот файл dnsmasq.conf:

# Привязываем к мосту.
interface=br0
bind-interfaces

# Игнорируем /etc/resolv.conf и /etc/hosts хоста.
no-resolv
no-hosts

# Перенаправляем DNS-запросы на публичный DNS-резольвер.
domain-needed
bogus-priv
server=8.8.8.8
server=8.8.4.4

# Задаем диапазон адресов.
dhcp-range=10.0.0.0,10.0.0.255

# Предоставляем эти IP-адреса узлам с конкретными MAC-адресами.
dhcp-host=86:e2:e3:21:13:b4,10.0.0.10,node0
dhcp-host=7c:92:6e:84:1f:50,10.0.0.11,node1
dhcp-host=b0:aa:4c:4c:7b:1a,10.0.0.12,node2
dhcp-host=a6:fc:4a:66:8f:c9,10.0.0.13,node3

Здесь наиболее важны следующие три части:

  • Привязываем dnsmasq к мосту.

  • Перенаправляем DNS-запросы на 8.8.8.8 и 8.8.4.4.

  • Присваиваем IP-адреса нашим ВМ на основе их MAC-адресов.

Теперь запускаем демон dnsmasq:

sudo dnsmasq \
    --no-daemon --conf-file=./dnsmasq.conf \
    > dnsmasq.log 2>&1 &

echo $! > dnsmasq.pid

В результате dnsmasq запустится в фоне, весь его вывод будет перенаправляться в dnsmasq.log, а PID запишется в dnsmasq.pid. Потом по этому PID’у мы сможем «убить» демона.

Последнее, что нужно сделать, — применить правило iptables, чтобы узлы могли выходить в интернет:

sudo iptables \
    -t nat -A POSTROUTING ! -o br0 \
    --source 10.0.0.0/24 -j MASQUERADE

Без него мы сможем связаться с узлами с хоста, например, зайти на них по SSH, но сами узлы не смогут выйти в локальную сеть или интернет.

После завершения работы правило можно удалить с помощью флага -D:

sudo iptables \
    -t nat -D POSTROUTING ! -o br0 \
    --source 10.0.0.0/24 -j MASQUERADE

Теперь мы готовы создать виртуальные машины и запустить кластер Kubernetes.

Бутстрап узлов

Виртуальная сеть у нас есть. Пришло время создать сами узлы. Сначала клонируем базовый образ для каждого узла и увеличим его размер до 10 ГБ:

for NODE_NAME in node0 node1 node2 node3; do
    # Клонируем образ.
    cp ./debian12.qcow2 "./${NODE_NAME}.qcow2"

    # Меняем его размер.
    qemu-img resize "./${NODE_NAME}.qcow2" 10G
done

Образы дисков на самом деле не занимают 10 ГБ — файлы в формате qcow2 увеличивают свой размер по необходимости.

Далее с помощью уже знакомой команды qemu-system-x86_64 создаем первый узел node0:

NODE_NAME="node0"
NODE_MAC="86:e2:e3:21:13:b4"
NODE_DISK="./${NODE_NAME}.qcow2"

sudo qemu-system-x86_64 \
    -name "$NODE_NAME" \
    -m 2G \
    -smp 2 \
    -device "virtio-net-pci,netdev=${NODE_NAME}tap,mac=${NODE_MAC}" \
    -netdev "tap,id=${NODE_NAME}tap,ifname=${NODE_NAME}tap,script=no" \
    -drive "file=./${NODE_DISK},if=virtio,cache=writeback,discard=ignore,format=qcow2" \
    -drive "file=./seed.iso,media=cdrom" \
    -boot d \
    -machine type=pc,accel=kvm &

Я закинул некоторые значения в переменные окружения, чтобы было проще понять, что происходит. Это похоже на то, что мы раньше делали для пробной ВМ:

  • -device — явно задает MAC-адрес сетевого интерфейса. Ориентируясь на него, демон dnsmasq навесит на этот узел IP-адрес 10.0.0.10.

  • -netdev — задает тип сетевого бэкенда (tap) и ссылается на имя TAP-устройства node0tap, созданного ранее. 

  • script=no— запрещает QEMU автоматически настраивать дополнительные сетевые устройства на хосте. Опция script по умолчанию имеет значение script=yes, то есть QEMU будет пытаться автоматически настроить мост при использовании сетевого бэкенда tap. Поскольку мост уже создан, ее необходимо отключить.

  • -drive — создает два виртуальных диска, но на этот раз используется клонированный образ.

Процесс запускается в фоновом режиме (&) — можно продолжить создавать ВМ в том же окне терминала. Если что-то пойдет не так, можно добавить опцию -serial stdio и запустить каждую ВМ в своем терминале, чтобы изучить вывод виртуальной консоли.

Виртуальная машина начинает загружаться. В логе dnsmasq.log должна появиться запись о навешивании IP-адреса на узел:

$ watch -n0.1 cat ./dnsmasq.log
...
dnsmasq-dhcp: DHCPDISCOVER(br0) 86:e2:e3:21:13:b4 
dnsmasq-dhcp: DHCPOFFER(br0) 10.0.0.10 86:e2:e3:21:13:b4 
dnsmasq-dhcp: DHCPREQUEST(br0) 10.0.0.10 86:e2:e3:21:13:b4 
dnsmasq-dhcp: abandoning lease to 86:e2:e3:21:13:b4 of 10.0.0.10
dnsmasq-dhcp: DHCPACK(br0) 10.0.0.10 86:e2:e3:21:13:b4 node0

Как только узел получит IP-адрес, на него можно будет зайти по SSH:

ssh root@10.0.0.10

Создадим еще три узла, подставив соответствующее имя TAP-интерфейса и MAC-адрес. Когда все четыре узла будут запущены, можно переходить к установке Kubernetes.

Установка Kubernetes

Как и с Linux, есть множество дистрибутивов Kubernetes, которые отличаются способом установки. В этой статье мы воспользуемся k0s. Это одиночный статически связанный исполняемый двоичный файл, который работает как сервис systemd.

Существуют и другие однофайловые дистрибутивы Kubernetes, например K3s. Он ориентирован на простоту использования и работу в окружениях с малыми ресурсами. При этом k0s ставит во главу угла минимализм и более чистый upstream Kubernetes.

В состав K3s входят Ingress-контроллер и балансировщик нагрузки, а k0s не включает дополнительных инструментов. Мы остановимся на k0s, поскольку это прочный фундамент, на котором можно построить все что угодно. Тем же, кто работает в условиях ограниченных ресурсов, например на Raspberry PI, стоит обратить внимание на K3s.

Проект k0s включает CLI-инструмент k0sctl — рекомендуемый метод удаленной установки.

Установим последнюю версию k0sctl и создадим следующую конфигурацию кластера:

apiVersion: k0sctl.k0sproject.io/v1beta1
kind: Cluster
metadata:
  name: local-cluster
spec:
  hosts:
  - role: controller
    installFlags:
    - --debug
    ssh:
      address: 10.0.0.10
      user: root
      port: 22
      keyPath: ~/.ssh/id_ed25519
  - role: worker
    installFlags:
    - --debug
    ssh:
      address: 10.0.0.11
      user: root
      port: 22
      keyPath: ~/.ssh/id_ed25519
  - role: worker
    installFlags:
    - --debug
    ssh:
      address: 10.0.0.12
      user: root
      port: 22
      keyPath: ~/.ssh/id_ed25519
  k0s:
    version: 1.27.5+k0s.0
    dynamicConfig: true

Здесь мы ссылаемся на каждый из узлов Kubernetes по его IP-адресу и удаленно логиним k0sctl с помощью файла идентификации SSH.

У каждого узла есть роль:

  • Controller-узлы образуют управляющий слой (control plane). На них работают компоненты управляющего слоя Kubernetes. 

  • На worker-узлах запускаются рабочие нагрузки. Мы видим их в выводе kubectl get nodes.

В этом туториале используется один controller-узел. В production обычно запускают 2k + 1 controller-узлов, где k — число отказов узлов, которые должен выдержать кластер. В идеале каждый controller-узел должен работать в отдельной зоне отказа (или зоне доступности).

Теперь остается только попросить k0sctl применить кластерную конфигурацию к узлам.

Загрузка исполняемого файла k0s может занять много времени. В этом случае можно самостоятельно загрузить свежий исполняемый файл со страницы релизов проекта на GitHub и вручную установить его в /usr/local/bin/k0s как часть образа базового диска.

k0sctl --debug apply \
    --config ./k0sctl.yaml \
    --kubeconfig-out kubeconfig

После выполнения этой команды в текущей рабочей директории появится файл kubeconfig, который можно использовать для подключения к кластеру с помощью kubectl.

Если что-то пойдет не так, k0sctl выдаст путь к файлу лога ошибок в системе. В моем случае это был ~/.cache/k0sctl/k0sctl.log.

Убедимся, что кластер запущен:

KUBECONFIG=./kubeconfig kubectl get pods -A

Должно получиться нечто подобное:

NAMESPACE       NAME                                        READY   STATUS             RESTARTS        AGE
kube-system     coredns-878bb57ff-l7c8p                     1/1     Running            1 (3m52s ago)   59m
kube-system     coredns-878bb57ff-tl4nx                     1/1     Running            1 (3m53s ago)   59m
kube-system     konnectivity-agent-6bdm5                    1/1     Running            1 (3m53s ago)   59m
kube-system     konnectivity-agent-fcssv                    1/1     Running            1 (3m52s ago)   59m
kube-system     kube-proxy-5lpg2                            1/1     Running            1 (3m52s ago)   59m
kube-system     kube-proxy-c8tbq                            1/1     Running            1 (3m53s ago)   59m
kube-system     kube-router-n4q7s                           1/1     Running            1 (3m53s ago)   59m
kube-system     kube-router-xtzkk                           1/1     Running            1 (3m52s ago)   59m
kube-system     metrics-server-7cdb99bf49-zwgvm             1/1     Running            2 (3m13s ago)   59m

Теперь у нас есть работающий кластер Kubernetes. Пора настроить Ingress-контроллер и внешний балансировщик нагрузки, чтобы трафик мог доходить до подов.

Ingress и балансировка нагрузки

В Kubernetes Ingress-контроллер управляет маршрутизацией HTTP-трафика к сервисам приложения. Но он не определяет, как именно внешний трафик попадает на Ingress-контроллер.

Те, кто пользуется управляемыми сервисами Kubernetes вроде GKE или EKS, могли заметить, что у сервиса Ingress-контроллера тип LoadBalancer. По нему облачная среда понимает, что необходимо выделить внешний балансировщик нагрузки для данного кластера. При этом Ingress-контроллер получит EXTERNAL IP — доступный снаружи IP-адрес, который привязан к балансировщику нагрузки. Балансировщик нагрузки будет проксировать весь трафик, который поступает на этот адрес, на Ingress-контроллер кластера.

Так как облачное окружение у нас отсутствует, настраивать балансировщик нагрузки придется самим. Здесь как раз и пригодится четвертый узел.

В Kubernetes у сервисов может быть тип ClusterIP, NodePort, LoadBalancer или ExternalName. Из документации Kubernetes следует, что LoadBalancer фактически ничего не делает. Часто это вызывает путаницу при попытке освоить Kubernetes в облачном окружении.

Будем работать следующим образом:

  1. Установим Ingress-контроллер: воспользуемся ingress-nginx

  2. Установим HAProxy на node3 и настроим его на обратное проксирование всего HTTP- и HTTPS-трафика на Ingress-контроллер. Точнее на Ingress-контроллере будет настроен сервис NodePort, на который HAProxy сможет ссылаться как на бэкенд обратного прокси.

Сетевой поток будет выглядеть примерно так:

Сервис NodePort Ingress-контроллера открывает порты на каждом рабочем узле. Затем HAProxy проксирует полученный HTTP(S)-трафик на эти порты.

Теперь нужно установить ingress-nginx. Применим манифест baremetal, как указано в документации по настройке сервиса NodePort:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/baremetal/deploy.yaml

В production-кластере следует воспользоваться установкой с помощью Helm — это поможет автоматизировать процесс обновления Ingress-контроллера.

После завершения установки Ingress-контроллера мы должны увидеть нечто подобное:

$ kubectl get pods -n ingress-nginx
NAME                                        READY   STATUS      RESTARTS   AGE
ingress-nginx-admission-create-dv4p7        0/1     Completed   0          60s
ingress-nginx-admission-patch-7htxb         0/1     Completed   2          60s
ingress-nginx-controller-79bc9f5df8-vpmrw   0/1     Running     0          60s

Посмотрим на сервис Ingress-контроллера:

$ kubectl get -n ingress-nginx svc
NAME                                 TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx-controller             NodePort    10.106.85.180   <none>        80:30299/TCP,443:30448/TCP   85s
ingress-nginx-controller-admission   ClusterIP   10.105.197.7    <none>        443/TCP                      85s

У сервиса ingress-nginx-controller тип NodePort, на порт 30299 принимается HTTP-трафик, на 30448 — HTTPS. Запомним их, так как они понадобятся нам для настройки HAProxy.

Стоит обратить внимание, что у каждого из нас порты могут быть разными. Kubernetes выделяет порты из предустановленного диапазона в зависимости от их доступности. Потом порты не меняются, если только не освободить их вручную (например, при удалении сервиса).

Теперь, когда Ingress-контроллер установлен и готов к работе, можно установить HAProxy на узле балансировщика нагрузки:

ssh "root@$10.0.0.13" "apt-get update -y && apt-get install -y haproxy=2.6.\*"

Убедимся, что он работает:

$ ssh root@10.0.0.13 systemctl status haproxy
● haproxy.service - HAProxy Load Balancer
     Loaded: loaded (/lib/systemd/system/haproxy.service; enabled; preset: enabled)
     Active: active (running) since Wed 2023-09-27 18:03:55 UTC; 21s ago
       Docs: man:haproxy(1)
             file:/usr/share/doc/haproxy/configuration.txt.gz
   Main PID: 1186 (haproxy)
      Tasks: 3 (limit: 2353)
     Memory: 41.9M
        CPU: 108ms
     CGroup: /system.slice/haproxy.service
             ├─1186 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock
             └─1188 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock

Sep 27 18:03:55 node3 systemd[1]: Starting haproxy.service - HAProxy Load Balancer...
Sep 27 18:03:55 node3 haproxy[1186]: [NOTICE]   (1186) : New worker (1188) forked
Sep 27 18:03:55 node3 haproxy[1186]: [NOTICE]   (1186) : Loading success.
Sep 27 18:03:55 node3 systemd[1]: Started haproxy.service - HAProxy Load Balancer.

Попытка зайти на http://10.0.0.13 в браузере приведет к ошибке подключения, поскольку мы еще не настроили бэкенды в HAProxy. Для этого воспользуемся следующим конфигом:

global
    log /dev/log local0
    log /dev/log local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    user haproxy
    group haproxy
    daemon

defaults
    log	global
    mode http
    option httplog
    option dontlognull
    timeout connect 5000
    timeout client 50000
    timeout server 50000
    timeout http-request 10000
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 408 /etc/haproxy/errors/408.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

frontend http-frontend
    bind *:80
    default_backend http-backend

backend http-backend
    server node1 10.0.0.11:30299
    server node2 10.0.0.12:30299

frontend https-frontend
    mode tcp
    option tcplog
    bind *:443
    default_backend https-backend

backend https-backend
    mode tcp
    server node1 10.0.0.11:30448
    server node2 10.0.0.12:30448

В нем настраиваются два фронтенда и два бэкенда: один — для HTTP, другой — для HTTPS.

Фронтенд — это сокет, к которому HAProxy привязывается и через который принимает пакеты. Бэкенд проксирует пакеты от фронтенда к серверу. В данном случае мы проксируем HTTP(S)-трафик на открытые порты worker-узлов.

Для HTTPS-фронтенда и бэкенда установлен режим mode tcp. Использование фронтенда HTTPS с бэкендом в mode tcp называется TLS passthrough. В нем HAProxy просто передает зашифрованные TCP-пакеты на бэкенд-серверы, не пытаясь их расшифровать.

Плюс этого режима в том, что все управление сертификатами ведется через Kubernetes. Иначе пришлось бы устанавливать HTTPS-сертификаты и на Ingress-контроллер, и на балансировщик нагрузки.

Проверим, работает ли конфигурация:

$ curl -ik https://10.0.0.13
HTTP/2 404 
date: Wed, 27 Sep 2023 18:24:38 GMT
content-type: text/html
content-length: 146
strict-transport-security: max-age=15724800; includeSubDomains

<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

Теперь мы получаем 404 вместо ошибки подключения. Это ошибка nginx, то есть мы проходим балансировщик нагрузки и добираемся до Ingress-контроллера.

Наконец-то можно развертывать приложение в кластере. Воспользуемся этим контейнером whoami и настроим deployment, сервис и Ingress-маршрут:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-test
  labels:
    app.kubernetes.io/name: whoami
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: whoami
  template:
    metadata:
      labels:
        app.kubernetes.io/name: whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami:v1.10
          ports:
            - containerPort: 80

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-test
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-test
spec:
  ingressClassName: nginx
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-test
                port:
                  number: 80

Обычный шаблон Kubernetes. В спецификации Ingress мы задаем ingressClassName: nginx и ссылаемся на Ingress-контроллер, а в правилах в качестве хоста используем example.com.

Проверим эндпоинт:

$ curl -ik -H 'Host: example.com' http://10.0.0.13
HTTP/1.1 200 OK
date: Wed, 27 Sep 2023 18:45:20 GMT
content-type: text/plain; charset=utf-8
content-length: 429

Hostname: whoami-test-6747545df6-m9mcg
IP: 127.0.0.1
IP: ::1
IP: 10.244.0.20
IP: fe80::90f6:43ff:fe75:d8f6
RemoteAddr: 10.244.1.8:51950
GET / HTTP/1.1
Host: example.com
User-Agent: curl/7.88.1
Accept: */*
X-Forwarded-For: 10.0.0.12
X-Forwarded-Host: example.com
X-Forwarded-Port: 80
X-Forwarded-Proto: http
X-Forwarded-Scheme: http
X-Real-Ip: 10.0.0.12
X-Request-Id: f942bf1e3c5a9e4af9a14b7c4903ad16
X-Scheme: http

Чтобы проверить с помощью браузера, сначала добавляем в файл /etc/hosts строку 10.0.0.13 example.com. Вывод в браузере должен быть похожим.

Итоги

Мы прошлись по основным шагам по созданию собственного кластера Kubernetes. Перенести их на кластер VPS довольно просто, так как не нужно связываться с кастомными образами дисков или сетевыми настройками — за нас это сделает облачный провайдер.

При установке Kubernetes с помощью k0sctl нужно обязательно ознакомиться с документацией по правильной настройке bastion-узла, чтобы не давать всем узлам публичные IP-адреса.

Возможные дальнейшие шаги по расширению кластера:

  • Установить Helm.

  • Установить Local Persistence Volume Static Provisioner, чтобы создавать постоянные тома на подключенных к узлу дисках.

  • Установить Harbor для управления образами контейнеров.

  • Установить cert-manager для автоматического управления сертификатами TLS.

  • Настроить высокодоступный API-сервер Kubernetes с помощью keepalived с виртуальным IP-адресом.

Весь код и конфигурационные файлы для этого туториала доступны в Git-репозитории.

P. S.

Читайте также в нашем блоге:

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


  1. dsoastro
    06.12.2023 07:52

    Хороший текст. Хорошо описано создание виртуальных машин с qemu, заведение в них tap-интерфейсов и их объединения при помощи бриджа. Настройка сетевой части куба с haproxy тоже весьма интересна. Попробую на досуге


    1. TimurTukaev
      06.12.2023 07:52

      Будет, чем заняться на выходных:)


  1. denaspireone
    06.12.2023 07:52
    +2

    Я бы советовал обратить внимание на firecracker, вот тут хорошо описано https://iximiuz.com/en/posts/iximiuz-labs-story/


    1. TimurTukaev
      06.12.2023 07:52

      Интересная штука, спасибо!


  1. Igorgro
    06.12.2023 07:52
    +2

    Делал примерно то же самое на домашнем сервер но немного на других технологиях:

    • Вместо сырого QEMU - LXD, он позволяет управлять не только контейнерами, но и виртуальными машинами, поддерживает cloud-init. В принципе это отличная замена libvirt, с намного более user-friendly интерфейсом с несложными YAML вместо монструозных XML;

    • В качестве балансировщика нагрузки перед нодами - Nginx в режиме stream - в отличие от http это балансировка на уровне TCP, за счет этого должна работать быстрее;

    • Все это управляется Ansible, а внутри Kubernetes используется FluxCD - получается что вся система полностью работает за счет IaaC.


    1. TimurTukaev
      06.12.2023 07:52

      То есть прям гитерменизм и вот это всё? А для какой цели разворачивали?


      1. Igorgro
        06.12.2023 07:52
        +1

        Для двух целей: во-первых чтобы вынести сервисы, которые развернуты на нескольких машинах в докере в одно место, чтобы постоянно не вспоминать где что развернуто (ssh+docker compose). А во-вторых чтобы перенести те сервисы, которые развернуты не в докере и про которые достаточно быстро забывается, что и где ты настраивал. Вот эти бесконечные копания в /etc и т.п. После того как перешел на Ansible - небо и земля.

        В некоторых случаях правда получается что для того чтобы развернуть что-то надо написать манифестов в 4-5 раз больше чем docker compose для той же цели.


  1. Twissell
    06.12.2023 07:52

    А почему никого не смущает, что для коннект машин автор использует tap-device вместо ethernet?

    Может я чего-то не понял, но разве оно будет так работать?