А что, если бы виртуалки вели себя как контейнеры — с миграциями, мониторингом, провижингом томов и GitOps? Мы во «Фланте» так и сделали: совместили Kubernetes с KubeVirt, там-сям допилили и получили решение, которое позволяет запускать виртуальные машины рядом с контейнерами и управляется как обычный кластер Kubernetes.

Привет, Хабр! Я — Олег Сапрыкин, технический директор по инфраструктуре компании «Флант». Сегодня я расскажу, как мы создавали виртуализацию в экосистеме Deckhouse от выбора инструмента для управления ВМ в 2023 году до полноценного продукта, готового к использованию в production весной 2025-го. Подробно опишу, с какими подводными камнями мы столкнулись в процессе эксплуатации и какие доработки потребовались.

Статья написана по мотивам моего второго доклада на тему Stateful в Kubernetes — первый был год назад на DevOpsConf 2024. В нём рассказывалось о том, как оценивать Stateful-компонент, про нюансы работы с такими приложениями, об особенностях конфигурирования и немного об опыте использования некоторых Stateful-операторов.

Сегодня продолжим углубляться в тему: я расскажу о проблематике, выборе инструментов, опыте их эксплуатации и доработке, о которых говорил на DevOpsConf 2025.

Зачем нужна виртуализация

Во «Фланте» мы поддерживаем сотни Kubernetes-кластеров клиентов в разном исполнении — облака, железо и гибридные инсталляции, где всё вперемешку. А когда мы говорим про железо, в какой-то момент возникает классическая виртуализация (KVM, QEMU и какие-то аналоги). Но зачем она вообще нужна?

  • Экономия

В первую очередь мы или клиент хотим сэкономить. Используя виртуализацию, можно запускать разные типы нагрузки в рамках одного сервера и тем самым полностью утилизировать железо.

Представим типичный кластер Kubernetes. Для обеспечения отказоустойчивости потребуется три мастер-узла (control plane). Но поскольку кластер выполняет не только управляющие задачи, необходимо добавить и рабочие узлы (worker nodes): для запуска полезной нагрузки (для резервирования) их понадобится минимум две штуки. При добавлении баз данных, CI/CD-систем и других сервисов общее количество физических серверов может достигать 10 отдельно стоящих железок. Но это звучит дорого, поэтому они превращаются в гипервизоры, и мы нарезаем виртуалки. На них кладём весь compute и утилизируем железо оптимальнее. 

При этом выбор чаще всего падает не на облака, потому что в большинстве случаев они дороже.

  • Изоляция

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

Когда мы говорим о контейнеризации, изоляция реализуется на уровне приложения. При этом процессы работают внутри одного и того же ядра операционной системы, но изолированы друг от друга.

В случае виртуализации изоляция осуществляется на уровне ядра операционной системы. Каждая виртуальная машина обладает полностью независимой операционной системой и собственным стеком, что обеспечивает максимальную степень изоляции.

  • Масштабируемость

Создавать виртуалки и управлять ими гораздо проще, чем гипервизорами и железом. Аналогично с перемещениями и другими манипуляциями — это удобнее.

  • Требования информационной безопасности

Это острый вопрос. Сюда относятся хранение персональных данных, изоляция сред разработки от продуктивной нагрузки и т. д. 

  • Старое ПО

Мы управляем всем «зоопарком» целиком — старое ПО тоже нужно где-то запускать и хранить рядом со всей остальной нагрузкой. Виртуальные машины для этого хорошо подходят.

  • Упрощение управления

Управлять хочется из одного места в хорошем смысле этих слов. В случае с Kubernetes есть дашборд, а с обычной виртуализацией это протокол передачи данных SSH (Secure Shell) на хосты. Туда же можно отнести и что-то более сложное вроде Virsh.

Выбор инструмента для управления ВМ

На момент поиска инструмента для управления виртуальными машинами у нас уже был хороший оркестратор в виде Kubernetes с продвинутым планировщиком и другим необходимым функционалом. Мы могли разделять на уровне экосистемы разные среды, разрезать по пространствам имён, давать квоты, распределять права доступа через RBAC — то есть была возможность управлять пользователями и их допуском. Кроме того, имелись механизм управления дисками (разные CSI), программно-управляемые хранилища и аппаратные системы хранения данных, а также вся сетевая инфраструктура.

Не хватало инструмента управления виртуальными машинами для получения максимального функционала при минимальных ресурсах. 

О том, как мы выбирали решение для построения облачной инфраструктуры, в 2023 году рассказывал Андрей Квапил, который тогда был архитектором решений компании. Если вкратце, основными претендентами были KubeVirt, OpenStack, CloudStack, OpenNebula и Ganeti. Последние три мы сразу отмели — они плохо интегрировались в Kubernetes. OpenStack нам не понравился сложностью реализации и поддержки, так что выбор пал на KubeVirt.

Что в нём особенного?

1. Быстрорастущее сообщество

По сравнению с остальными претендентами у KubeVirt действительно быстрорастущее комьюнити. Он вышел последним, но у него больше всех звёзд на GitHub. 

2. Большие игроки

KubeVirt используют крупные игроки, например RedHat и NVIDIA.

3. Разработка, управляемая сообществом

Здесь подразумевается отсутствие вендорлока — то есть пользователь не зависит от продуктов или услуг одного поставщика. Если про поисковую систему Elasticsearch все уже более-менее забыли, то скандал HashiCorp с ПО на открытом исходном коде Terraform для управления инфраструктурой ещё на слуху.

4. Относительно простая архитектура

Как и Kubernetes, KubeVirt состоит из нескольких бинарей. Кроме того, он основан на Kubernetes, который мы умеем и любим готовить.

Если вы хотите получше разобраться с тем, как устроен KubeVirt, почитайте наш перевод с глубоким погружением в технологию для администраторов VMware vSphere. А мы перейдём к практике. 

Первый опыт

В 2023 году в качестве подопытного под боевую нагрузку мы выбрали кластер на девяти железках, собранный в Selectel в трёх зонах доступности. Железки взяли довольно «жирные» — AMD EPYC 16 ядер, 128 ГБ ОЗУ. 

Мы брали готовые сетапы с гигабитной приватной сетью. Согласно расчётам, 50-гигабайтный том виртуалки будет перемещаться между узлами около 6 минут — это стоит учесть при проектировании.

Отдельно хочется упомянуть storage: в каждом сервере было по два терабайтных диска. Мы объединили их в большой мультизональный пул на базе LINSTOR.

Почему LINSTOR?

Об этом также рассказывал Андрей Квапил на питерском HighLoad++ в 2021 году в контексте выбора программно-управляемого хранилища. Тогда мы выбрали LINSTOR, который управляет системой DRBD. Она, в свою очередь, работает в пространстве ядра с минимальными затратами на compute. В случае с Ceph выходит сильно дороже. Кроме того, DRBD оперирует блочными устройствами и локальными дисками — задержки минимальные.

Подводные камни: с чем мы столкнулись при эксплуатации

При эксплуатации мы столкнулись с рядом вызовов. Расскажу о них и о том, что мы доработали за 2023–2024 годы для виртуализации в Deckhouse.  

Миграция виртуальных машин

В Kubernetes есть несколько видов подсетей, включая podCIDR. По сути, это большая сетка — обычно с префиксом /16. Каждый узел получает свою отдельную подсеть из этого диапазона, обычно с префиксом /24, то есть 256 адресов на узел. Когда мы запускаем поды на узлах, они получают адреса из этой подсети.

Виртуальная машина в контексте KubeVirt запускается внутри пода — соответственно, наследует его IP-адрес. 

Если нужно мигрировать ВМ на другой узел, запускается ещё один под с другим IP-адресом из подсети того узла. А когда виртуальная машина «переезжает», его нужно сменить.

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

В связи с этим мы добавили ещё одну подсеть — virtualMachineCIDRs. Она позволяет закрепить за подом с ВМ статичный IP-адрес на всё время жизни виртуалки. То есть, адрес сохраняется, даже когда ВМ мигрирует.

Но одного дополнительного пула адресов оказалось недостаточно: между логическим интерфейсом виртуальной машины и физическим интерфейсом узла нужно построить таблицу маршрутизации. Кроме того, её нужно поддерживать в актуальном состоянии.

Решением стал vm-route-forge. Это реконсилер (reconciler) актуального состояния таблицы маршрутизации, который при изменении или миграции моментально восстанавливает актуальное состояние. 

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

Отказ узла кластера

Предположим, в Kubernetes «из коробки» узел перестал отвечать по API. Его помечают NotReady, и все поды перепланируются на другие узлы, если это не запрещено явно. Там может быть запущена какая-то нагрузка, которая продолжит работать. Если говорить о Stateless, нас это не беспокоит. А вот в случае со Stateful-нагрузкой всё не так хорошо.

Например, есть кластер базы данных. Поды перепланируются, виртуальная машина с мастером запускается в другом месте, читает и пишет на диск. На отказавшем узле тоже есть мастер базы данных, но он не знает, что перестал им быть, и пытается записать что-то в хранилище. Мы получаем неконсистентные данные, Split Brain и другие проблемы. 

Чтобы этого не случалось, мы реализовали механизм Fencing: на узле запускается пингующий API агент. Если пинг пропадает, запускается таймер — по прошествии 60 секунд узел помечается удалённым из кластера. На самом узле запущен Watchdog, который принудительно останавливает всю нагрузку. Так решается проблема Split Brain.

Инструкции процессоров

Мы взяли девять одинаковых железок, но инструкции их процессоров отличались.

Инструкции или флаги — это фичи, которые позволяют ускорять разного рода вычисления. Они отличаются от поколения к поколению процессора — как правило, у тех, что посвежее, флагов и, соответственно, возможностей по ускорению расчётов просто больше. Как мы выяснили, флаги могут отличаться и у практически одинакового железа.

Унификация инструкций оказалась для нас критичной, потому что «под капотом» у KubeVirt используется нативная QEMU-KVM-миграция, которая не работает при миграции с узлов с более старыми моделями процессоров на узлы с новыми.

Вернёмся к теории. В технологии виртуализации QEMU есть два типа конфигурирования CPU-модели: 

  1. Host-passthrough — это, по сути, проброс всех флагов от хостовой системы внутрь виртуалки. Мы получаем максимальную производительность, но сужаем возможность перемещения между узлами, если они отличаются. 

  2. Named — это таблица совместимости поколений процессоров. То есть выбирается максимально похожий на имеющийся CPU, что позволяет обеспечить совместимость между разными узлами.

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

Пример группировки узлов

Два из них содержат более старый процессор (cpuX), два — поновее (cpuY). В старых процессорах 3 флага, в новых — 4. Мы можем объединить эти узлы в две группы и перемещать виртуалки между ними — будет работать практически идеально. 

На практике мы объявляем расширение функционала API Kubernetes — CustomResource. В данном случае CustomResource virtualMachineClass позволяет транслировать похожий на QEMU подход в платформе виртуализации. 

Иногда нужно обеспечить совместимость между всеми узлами кластера. Для этого можно объявить универсальный класс и пометить type: Discovery. В итоге мы получим полную совместимость между всеми узлами, и виртуалки смогут перемещаться по всему кластеру.

Хранилище

Storage — неотъемлемая часть платформы виртуализации. Как я уже говорил, мы выбрали LINSTOR, но столкнулись с рядом проблем при эксплуатации.

Начнём с того, что LINSTOR хранит свою конфигурацию в базе данных. Это может быть нативная Java SQL DB, реляционная БД (MySQL/PostgreSQL) или Kubernetes (CR прямо в кластере с состоянием ресурсов). 

Мы взяли экспериментальный драйвер на базе CR в кластере K8s и обнаружили, что он плохо работает. LINSTOR изначально написан не для Kubernetes — он пользуется push-моделью. Когда контроллер получает какие-то задания, он последовательно отправляет их на узлы, где запущены агенты linstor-satellite. 

Из-за отсутствия параллельности постановки заданий мы долгое время жили с рядом проблем. 

Расширение VG на узле. Когда заканчивалось место в хранилище, мы добавляли новый диск и делали vgextend на узле. Затем была проверка в StoragePool, но объём не менялся. Тогда мы шли на узел, давали пинка поду с агентом LINSTOR — и появлялся новый объём в StoragePool.

Потеря связи внутри LINSTOR. Аналогичная ситуация складывалась, если мы делали drain какой-то нагрузки с узла:

В итоге вопрос решился добавлением проб на всех агентах и на самом контроллере. Они друг друга постоянно пингуют: если теряется связь — происходит перезапуск.

Evacuate ресурсов с узлов. Так как мы взяли «жирное железо», периодически нужно было производить массовый evacuate всех виртуалок с узла. Но если одновременно попытаться перенести около 10 виртуалок с одного узла, linstor-controller попробует распараллелить этот процесс и уйдёт в каком-то из ресурсов в бесконечный цикл. 

Чтобы не тратить время на поиск багов, мы написали обвязку, которая заменяет работу контроллера — она делает это более прозрачно, со статусами. 

Остатки конфигураций DRBD. Иногда на узле даже оставались артефакты DRBD-устройств, и когда LINSTOR пытался завести устройство на этот узел ещё раз, то ресурс падал с ошибкой. Нужно было находить такие артефакты и удалять их. Тогда для автоматизации процесса было предложено сделать sidecar-контейнер.

Обновления LINSTOR. Немало проблем доставляли нам и обновления LINSTOR. Периодически разработчики меняют схему базы данных, и миграции проходят не так, как хотелось бы, — виртуалки падают и хранилище приходит в негодное состояние, его приходится восстанавливать вручную.

Так было в 2023 году:

В 2024 году (v.1.30.2) и в актуальной на момент доклада версии (v.1.30.4) также ещё были проблемы с миграциями. Мы тратим много времени на тестирование новых фич перед тем, как выкатить их в прод. Созданные фиксы мы отправляли в upstream, но разработчики-перфекционисты считали их костылями и ещё год писали нормальные решения.

Вишенка на торте: linstor-controller написан на Java, а сам Kubernetes и всё вокруг него — на Go. Кроме того, Deckhouse мы строим distroless, а тащить в него Java Virtual Machine больно. Поэтому мы решили переписать контроллер и всю его обвязку на Go.

Создание дисков

При создании дисков в KubeVirt появляется DataVolume, который инициирует создание соответствующего Persistent Volume в Kubernetes. Ему нужно указывать параметры размера хранилища и storageClassName по аналогии с тем, как это происходит в нативном K8s. Также нужно прописать несколько дополнительных параметров — это блоковая или файловая система: 

…и каков режим доступа к диску:

В данном случае может быть либо ReadWriteOnce (один потребитель пишет и читает в хранилище), либо ReadWriteMany (несколько потребителей могут читать и писать одновременно).

Миграция работает только во втором случае, но поначалу для нас это было неочевидно, и под базу данных мы частенько заводили ReadWriteOnce-устройство. В результате приходилось переливать данные уже в процессе эксплуатации, после того, как мы дали нагрузку. Чтобы избежать таких сложностей в дальнейшем, было решено переопределить CustomResource VirtualDisk.

VirtualDisk понимает, какой storage у него под капотом (здесь sds-replicated-r2), — достаточно указать только StorageClass. В LINSTOR можно использовать решение ReadWriteMany — тогда и режим доступа к диску получился ReadWriteMany, и том может быть смонтирован к множеству подов в режиме чтения и записи.

Последний обязательный пункт, который нужно указать в параметрах при создании диска, — source, откуда будет идти образ. С помощью Containerized Data Importer в KubeVirt можно либо нативно заливать данные с веб-ресурса, либо копировать уже имеющийся Persistent Volume.

Мы пошли дальше — объявили CustomResource VirtualImage, где можно указать загружаемые образы немного иначе.

Там используется репозиторий Deckhouse Virtualization Container Registry (DVCR) на базе docker-registry. С его помощью можно загрузить не только установочные образы в .iso, но и снимки готовых систем с других сред виртуализации, например .vmdk или .vdi. Более того, DVCR хранит образы внутри кластера в формате docker-registry, сжимает их по слоям и оптимально хранит на диске. 

DVCR встроен в экосистему Deckhouse, а значит, мы можем указать область видимости для разных типов дисков. Например, если нам нужно сделать cluster-wide, чтобы разрешить использовать дистрибутив Ubuntu всем пользователям кластера, или поместить какой-то диск в определённое пространство имён и давать доступ только одной команде. 

Бонусом при загрузке образа проверяется его заголовок, и мы знаем размер диска. Если указать диск меньше — пользователю придёт уведомление об ошибке.

Горячее подключение томов 

Горячее подключение томов в KubeVirt поддерживается «из коробки». Для этого нужно указать DataVolume и прописать команду, которая подключает том к конкретной виртуальной машине:

Но мы пошли дальше — мы же про GitOps. Вот и реализовали автоматизацию с помощью ресурса VirtualMachineBlockDeviceAttachment.

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

Обновление компонентов кластера

Отдельно хотелось бы сказать об обновлении компонентов кластера. Порой простое обновление агента kubelet может остановить весь compute: управляя жизненным циклом, kubelet выделяет на узле ресурсы для подов. Обычно это CPU, память и ephemeral-storage. 

Когда используется виртуализация, с помощью плагина добавляются KVM и сетевая подсистема. В одном из обновлений kubelet этого не учли, и произошло дополнительное выделение ресурсов для уже запущенных подов. Итог — все виртуалки остановились с ошибкой UnexpectedAdmissionError. Пришлось долго дебажить.

В новых версиях KubeVirt это исправили, но такое лучше тестировать, чтобы не нарваться на проблемы.

Работа с KubeVirt

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

Посмотрим, как это выглядит при наличии в Kubernetes описанных ресурсов, которые транслируются в API и хранилище etcd. В случае с виртуализацией было предложено поставить посередине kube-api-rewriter — он перехватывает все обращения от KubeVirt в API K8s и транслирует их для восстановления обратной совместимости со всем остальным функционалом платформы. 

Так мы будто убираем KubeVirt «под капот» и не можем его менять, но получаем обратную совместимость и избавляем себя от перспективы сломанного функционала.

За время нашей эксплуатации KubeVirt, а это чуть больше двух лет, мы написали около 40 патчей, которые расширяют функционал решения или улучшают пользовательский опыт. 

Виртуальная инфраструктура: версия 2025

Весной 2025 года мы собрали новую версию кластера, чтобы попробовать все лучшие практики и накопленный опыт за годы эксплуатации.

Продуманная архитектура

Мы не стали класть в реплицируемый storage компоненты, которые умеют, например, программно реплицироваться. Тот же Elasticsearch умеет в реплики внутри себя, и нет смысла гонять лишний трафик между узлами. 

  • Зонирование

Мы иначе подошли к размещению реплик томов между зонами и узлами. Теперь при создании StorageClass можно указать топологию размещения. Есть три вида:

1. Zonal — все реплики в рамках одной зоны. В этом случае мы получаем минимальные задержки, но риски при падении зоны. Если вдруг что-то произошло — теряем все данные. 

2. TransZonal — данные гарантированно размазаны по всем регионам присутствия. Storage автоматически понимает это.

3. Ignored — тот случай, когда мы не учитываем размещение. Данные могут лежать где угодно, лишь бы не в рамках одного узла. 

  • «Жирный» аплинк

У нас была гигабитная приватная сеть. Сейчас мы взяли более «жирное» железо на 10-гигабитной сети, и копирование 50-гигабайтного тома виртуалки занимает около 40 секунд. 

Полноценный GitOps и работа в UI

Мы описали в Git всё состояние кластера (виртуальные машины, диски и пр.) — катаем по кнопке из CI. Декларативный подход позволяет нам в одно нажатие запустить весь кластер — каждый следующий компонент знает о своих зависимостях. Например, виртуалка не упадёт с ошибкой, что под ней нет диска, и заработает, когда диск появится.

А для любителей кнопочек появилась полноценная работа из UI — Deckhouse Console. В интерфейсе можно следить за всеми виртуальными машинами, видеть их статусы и даже отметить, какие параметры не применились и почему. Например, ниже на скриншоте была включена поддержка всех флагов — то есть увеличено быстродействие для виртуальной машины. Для применения настройки необходим перезапуск. 

Также появились аудит событий и мониторинг «из коробки» на Grafana-стеке, благодаря которым можно отследить всё происходящее с виртуалкой или любым компонентом на протяжении всего жизненного цикла.

Конфигурацию параметров можно вручную накликать в UI, скопировать YAML, положить в Git и настроить тот самый GitOps.

Встраивание в экосистему

Как и замышлялось — виртуализация теперь встроена в экосистему Deckhouse. Мы не только сделали решение под свои задачи, но и выпустили его как отдельный продукт Deckhouse Virtualization Platform, доступный пользователям.

Мы получили «из коробки» хороший оркестратор и мультитенантность. Можно нарезать каждой группе пользователей как доступ к виртуальным машинам, так и к контейнерам. 

Заключение

Для построения виртуализации есть много неплохих Open Source-инструментов — их можно брать за основу и использовать, но они требуют доработки. «Из коробки» они чаще всего не работают или будут плохо интегрироваться в вашу инфраструктуру.

Все наработки, о которых я рассказывал, выложены в открытом доступе на GitHub — с ними можно ознакомиться по вот этой и вот этой ссылке. Кроме того, есть тестовое приложение — лаба, которую можно запустить на любом железе и покрутить. 

А для того, чтобы расширять свои познания и делиться опытом, лучше всего посещать профессиональные конференции. Если есть возможность — офлайн. Всё-таки у погружения в среду профессионалов много плюсов: нетворкинг, общение со спикерами, митапы и воркшопы. Однако онлайн-формат посещения хорош тем, что позволяет попасть на мероприятие из любой точки мира. С актуальной информацией о профессиональной конференции для разработчиков высоконагруженных систем HighLoad++ 2025 можно ознакомиться на официальном сайте.

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