Возможно, у вас была мысль запустить собственную услугу 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 12 в виде типового облачного образа. Образ оптимизирован для виртуальных машин — из него исключены драйверы для реального аппаратного обеспечения. Это не обязательно, но в дальнейшем может пригодиться, если развертывать собственные ВМ на bare-metal-серверах. Загружаем последнюю версию типового облачного образа Debian 12: Минус этого образа — в нем нет учетной записи пользователя по умолчанию или пароля root. Чтобы войти в систему, настроим ее с помощью сценария первоначальной настройки cloud-init. Для этого используем seed ISO. По сути, именно это облачный провайдер делает каждый раз при запуске нового VPS. Получается, чтобы создать новую ВМ, мы клонируем базовый образ диска. Затем загружаем ВМ с помощью нового образа диска и seed ISO. Если настройки ВМ отличаются, мы сразу генерируем новый seed ISO. В нашем случае во все ВМ сразу добавим открытый ключ SSH, чтобы на них можно было удаленно заходить с терминала по протоколу SSH. Для создания seed ISO-файла воспользуемся инструментом cloud-localds. Пользователям Ubuntu необходимо установить пакет cloud-image-utils: Теперь создадим cloud-конфиг cloud_init.yaml: Подставим в него свой публичный ключ и запустим cloud-localds: Должен появиться ISO-файл с именем seed.iso: Далее скопируем загруженный базовый образ диска: Теперь можно загрузить ВМ с помощью образа диска и seed ISO: У 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: В итоге мы успешно создали пробную виртуальную машину. Теперь пора настроить виртуальную сеть и создать ВМ для каждого из четырех узлов. Напомним, что пробная ВМ, которую мы только что создали, использует тип бэкенда user networking. Этот бэкенд отлично подходит для тестирования, но отличается низкой производительностью и изолирует каждую ВМ в отдельной сети. Мы же хотим создать кластер виртуальных машин, которые работают в общей виртуальной сети, поэтому этот сетевой бэкенд нам не подходит. Еще раз посмотрим на схему: В ней четыре основных сетевых компонента: Мост br0 выступает в роли виртуального коммутатора, к которому подключаются все наши виртуальные машины, образуя виртуальную локальную сеть. К каждой ВМ подключено свое TAP-устройство. Затем TAP-устройство «включается» в мост и пересылает пакеты через виртуальную карту сетевого интерфейса виртуальной машины. Демон dnsmasq, который выполняет функции сервера DHCP, назначает узлам IP-адреса. Правило iptables, которое превращает br0 в NAT-маршрутизатор, позволяя узлам выходить в интернет. Создадим устройство виртуального моста с помощью команды ip, присвоим ему IP-адрес 10.0.0.0/24 и запустим: Убедимся, что устройство создано: Должно получиться нечто подобное: Далее нужно запустить TAP-устройство для каждого из четырех узлов, которые мы будем создавать: Имена интерфейсов, которые выводятся через echo, понадобятся нам позже, когда мы будем настраивать сетевые бэкенды ВМ. Снова убедимся, что устройства TAP созданы: Вывод должен выглядеть примерно так: Далее настроим dnsmasq на запуск демоном на хосте. Он будет выступать DHCP-сервером и сетевым шлюзом для наших узлов. Вот файл dnsmasq.conf: Здесь наиболее важны следующие три части: Привязываем dnsmasq к мосту. Перенаправляем DNS-запросы на 8.8.8.8 и 8.8.4.4. Присваиваем IP-адреса нашим ВМ на основе их MAC-адресов. Теперь запускаем демон dnsmasq: В результате dnsmasq запустится в фоне, весь его вывод будет перенаправляться в dnsmasq.log, а PID запишется в dnsmasq.pid. Потом по этому PID’у мы сможем «убить» демона. Последнее, что нужно сделать, — применить правило iptables, чтобы узлы могли выходить в интернет: Без него мы сможем связаться с узлами с хоста, например, зайти на них по SSH, но сами узлы не смогут выйти в локальную сеть или интернет. После завершения работы правило можно удалить с помощью флага -D: Теперь мы готовы создать виртуальные машины и запустить кластер Kubernetes. Виртуальная сеть у нас есть. Пришло время создать сами узлы. Сначала клонируем базовый образ для каждого узла и увеличим его размер до 10 ГБ: Образы дисков на самом деле не занимают 10 ГБ — файлы в формате qcow2 увеличивают свой размер по необходимости. Далее с помощью уже знакомой команды qemu-system-x86_64 создаем первый узел node0: Я закинул некоторые значения в переменные окружения, чтобы было проще понять, что происходит. Это похоже на то, что мы раньше делали для пробной ВМ: -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-адреса на узел: Как только узел получит IP-адрес, на него можно будет зайти по SSH: Создадим еще три узла, подставив соответствующее имя TAP-интерфейса и MAC-адрес. Когда все четыре узла будут запущены, можно переходить к установке Kubernetes. Как и с Linux, есть множество дистрибутивов Kubernetes, которые отличаются способом установки. В этой статье мы воспользуемся k0s. Это одиночный статически связанный исполняемый двоичный файл, который работает как сервис systemd. Существуют и другие однофайловые дистрибутивы Kubernetes, например K3s. Он ориентирован на простоту использования и работу в окружениях с малыми ресурсами. При этом k0s ставит во главу угла минимализм и более чистый upstream Kubernetes. В состав K3s входят Ingress-контроллер и балансировщик нагрузки, а k0s не включает дополнительных инструментов. Мы остановимся на k0s, поскольку это прочный фундамент, на котором можно построить все что угодно. Тем же, кто работает в условиях ограниченных ресурсов, например на Raspberry PI, стоит обратить внимание на K3s. Проект k0s включает CLI-инструмент k0sctl — рекомендуемый метод удаленной установки. Установим последнюю версию k0sctl и создадим следующую конфигурацию кластера: Здесь мы ссылаемся на каждый из узлов 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 как часть образа базового диска. После выполнения этой команды в текущей рабочей директории появится файл kubeconfig, который можно использовать для подключения к кластеру с помощью kubectl. Если что-то пойдет не так, k0sctl выдаст путь к файлу лога ошибок в системе. В моем случае это был ~/.cache/k0sctl/k0sctl.log. Убедимся, что кластер запущен: Должно получиться нечто подобное: Теперь у нас есть работающий кластер Kubernetes. Пора настроить Ingress-контроллер и внешний балансировщик нагрузки, чтобы трафик мог доходить до подов. В Kubernetes Ingress-контроллер управляет маршрутизацией HTTP-трафика к сервисам приложения. Но он не определяет, как именно внешний трафик попадает на Ingress-контроллер. Те, кто пользуется управляемыми сервисами Kubernetes вроде GKE или EKS, могли заметить, что у сервиса Ingress-контроллера тип LoadBalancer. По нему облачная среда понимает, что необходимо выделить внешний балансировщик нагрузки для данного кластера. При этом Ingress-контроллер получит EXTERNAL IP — доступный снаружи IP-адрес, который привязан к балансировщику нагрузки. Балансировщик нагрузки будет проксировать весь трафик, который поступает на этот адрес, на Ingress-контроллер кластера. Так как облачное окружение у нас отсутствует, настраивать балансировщик нагрузки придется самим. Здесь как раз и пригодится четвертый узел. В Kubernetes у сервисов может быть тип ClusterIP, NodePort, LoadBalancer или ExternalName. Из документации Kubernetes следует, что LoadBalancer фактически ничего не делает. Часто это вызывает путаницу при попытке освоить Kubernetes в облачном окружении. Будем работать следующим образом: Установим Ingress-контроллер: воспользуемся ingress-nginx.  Установим HAProxy на node3 и настроим его на обратное проксирование всего HTTP- и HTTPS-трафика на Ingress-контроллер. Точнее на Ingress-контроллере будет настроен сервис NodePort, на который HAProxy сможет ссылаться как на бэкенд обратного прокси. Сетевой поток будет выглядеть примерно так: Сервис NodePort Ingress-контроллера открывает порты на каждом рабочем узле. Затем HAProxy проксирует полученный HTTP(S)-трафик на эти порты. Теперь нужно установить ingress-nginx. Применим манифест baremetal, как указано в документации по настройке сервиса NodePort: В production-кластере следует воспользоваться установкой с помощью Helm — это поможет автоматизировать процесс обновления Ingress-контроллера. После завершения установки Ingress-контроллера мы должны увидеть нечто подобное: Посмотрим на сервис Ingress-контроллера: У сервиса ingress-nginx-controller тип NodePort, на порт 30299 принимается HTTP-трафик, на 30448 — HTTPS. Запомним их, так как они понадобятся нам для настройки HAProxy. Стоит обратить внимание, что у каждого из нас порты могут быть разными. Kubernetes выделяет порты из предустановленного диапазона в зависимости от их доступности. Потом порты не меняются, если только не освободить их вручную (например, при удалении сервиса). Теперь, когда Ingress-контроллер установлен и готов к работе, можно установить HAProxy на узле балансировщика нагрузки: Убедимся, что он работает: Попытка зайти на http://10.0.0.13 в браузере приведет к ошибке подключения, поскольку мы еще не настроили бэкенды в HAProxy. Для этого воспользуемся следующим конфигом: В нем настраиваются два фронтенда и два бэкенда: один — для HTTP, другой — для HTTPS. Фронтенд — это сокет, к которому HAProxy привязывается и через который принимает пакеты. Бэкенд проксирует пакеты от фронтенда к серверу. В данном случае мы проксируем HTTP(S)-трафик на открытые порты worker-узлов. Для HTTPS-фронтенда и бэкенда установлен режим mode tcp. Использование фронтенда HTTPS с бэкендом в mode tcp называется TLS passthrough. В нем HAProxy просто передает зашифрованные TCP-пакеты на бэкенд-серверы, не пытаясь их расшифровать. Плюс этого режима в том, что все управление сертификатами ведется через Kubernetes. Иначе пришлось бы устанавливать HTTPS-сертификаты и на Ingress-контроллер, и на балансировщик нагрузки. Проверим, работает ли конфигурация: Теперь мы получаем 404 вместо ошибки подключения. Это ошибка nginx, то есть мы проходим балансировщик нагрузки и добираемся до Ingress-контроллера. Наконец-то можно развертывать приложение в кластере. Воспользуемся этим контейнером whoami и настроим deployment, сервис и Ingress-маршрут: Обычный шаблон Kubernetes. В спецификации Ingress мы задаем ingressClassName: nginx и ссылаемся на Ingress-контроллер, а в правилах в качестве хоста используем example.com. Проверим эндпоинт: Чтобы проверить с помощью браузера, сначала добавляем в файл /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-репозитории. Читайте также в нашем блоге: Разворачиваем приложение в кластере Kubernetes под управлением Deckhouse c помощью werf LVM+QCOW2, или Попытка создать идеальный CSI-драйвер для shared SAN в Kubernetes Эволюция технологий виртуализации сети в Linux