Запустить гипервизор внутри виртуальной машины выглядит как попытка поднять себя за шнурки, но именно так работает вложенная виртуализация KVM. CI/CD-платформы тестируют гипервизорно-зависимый код в изолированных гостях, лабораторные стенды для Kubernetes имитируют целые кластеры на одном сервере, облачные провайдеры предоставляют инстансы с полноценным VMX-доступом. За всем этим стоит один механизм. И понять его стоит по-настоящему глубоко, а не только до уровня "нужная кнопка нажата".

Три уровня вместо двух и архитектура вложенного стека

В обычной виртуализации существуют два участника: физический хост (L0) и гостевая операционная система (L1). L0 управляет процессором через аппаратные расширения Intel VT-x или AMD-V, а L1 думает, что работает на реальном железе, и не подозревает об обмане.

При вложении появляется третий участник. L0 остаётся настоящим гипервизором на физическом хосте. L1 теперь сам является гипервизором внутри виртуальной машины: KVM, Hyper-V или любой другой. L2 представляет собой гостевую систему внутри L1, которая находится уже на два уровня выше реального железа, хотя совершенно об этом не знает.

Фундаментальная проблема здесь архитектурная. В x86 VMX-инструкции, которыми пользуется гипервизор, выполняются только в VMX root mode. Гостевая система работает в VMX non-root mode, и VMX-инструкции там недоступны. Значит, L1 по определению не может быть гипервизором, потому что он физически не умеет делать то, что гипервизор делает.

Nested VMX решает это через перехват. Когда L1 пытается выполнить VMX-инструкцию, L0 перехватывает её через VM exit, эмулирует поведение железа и возвращает управление L1. Для самого L1 это выглядит как настоящая аппаратная виртуализация. L0 при этом держит в памяти теневую структуру VMCS12, которая описывает состояние L2-гостя, и транслирует её в реальный VMCS, с которым работает физический процессор.

Начиная с ядра Linux версии 4.19 поддержка nested VMX для Intel и Nested SVM для AMD включена по умолчанию. На более старых ядрах функциональность активировалась вручную. Проверить, что модули загружены и вложенность действительно активна, нужно до любых дальнейших шагов:

# Убедиться, что модули KVM загружены
lsmod | grep -i kvm
# Ожидаемый вывод для Intel-системы:
# kvm_intel   ...
# kvm         ...

# Проверить флаги виртуализации в процессоре
grep -Ec '(vmx|svm)' /proc/cpuinfo
# Любое число больше 0 означает, что флаги есть

# Для Intel — проверить статус nested
cat /sys/module/kvm_intel/parameters/nested
# Y — включено, N — выключено

# Для AMD
cat /sys/module/kvm_amd/parameters/nested
# 1 — включено, 0 — выключено

EPT-on-EPT и почему управление памятью усложняется нелинейно

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

В обычной виртуализации Intel EPT (Extended Page Tables) строит аппаратную трансляцию из гостевых физических адресов (GPA) в реальные адреса хоста (HPA). Одна таблица, одна трансляция, всё работает быстро. При вложении появляется три уровня адресного пространства: виртуальные адреса L2, физические адреса которые видит L2, и реальные физические адреса железа. Наивный подход потребовал бы двух последовательных трансляций, но процессорный MMU аппаратно работает только с одной EPT-таблицей одновременно.

KVM решает это слиянием. L0 строит объединённую таблицу EPT02, которая отображает адреса L2 напрямую в физические адреса железа, минуя промежуточный слой. Цена этого решения: каждый раз, когда L1 или L2 меняют свои страничные таблицы, EPT02 нужно пересчитать. Именно поэтому нагрузки с интенсивной работой с памятью страдают от вложения значительно сильнее вычислительных задач.

Для AMD аналогичный механизм называется Nested Page Tables (NPT), и поведение там принципиально то же самое: три уровня адресного пространства, слияние таблиц трансляции, пересчёт при изменениях.

Включение вложенности и полная проверка конфигурации хоста

Если nested вернул N или модуль нужно перезагрузить с новыми параметрами, правильная последовательность действий такая:

# Способ 1 — через временную выгрузку и загрузку модуля (без перезагрузки)
# Intel
sudo rmmod kvm_intel
sudo modprobe kvm_intel nested=1
# Проверить, что применилось
cat /sys/module/kvm_intel/parameters/nested

# AMD
sudo rmmod kvm_amd
sudo modprobe kvm_amd nested=1
cat /sys/module/kvm_amd/parameters/nested

# Способ 2 — постоянное включение через конфигурацию (переживает перезагрузку)
# Intel
echo "options kvm-intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
# AMD
echo "options kvm-amd nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf

# Дополнительно для Intel — убедиться, что Shadow VMCS и APIC-v включены
# (на Haswell и новее они активны по умолчанию, но стоит проверить)
cat /sys/module/kvm_intel/parameters/enable_shadow_vmcs
cat /sys/module/kvm_intel/parameters/enable_apicv
cat /sys/module/kvm_intel/parameters/ept
# Все три должны вернуть Y

После включения вложенности на L0 нужно правильно настроить L1-гостя. Ключевой момент здесь: тип CPU, который видит L1, определяет, увидит ли он флаги vmx или svm вообще. Если CPU-тип ограничен именованной моделью без явной передачи нужного флага, L1 просто не узнает, что может быть гипервизором, и никакие настройки L0 ему не помогут.

Конфигурация L1-гостя через libvirt XML для Intel-системы:

<!-- Вариант 1 — host-passthrough: L1 видит все флаги физического CPU -->
<cpu mode='host-passthrough' check='none'>
  <topology sockets='1' cores='4' threads='2'/>
</cpu>

<!-- Вариант 2 — host-model с явной передачей vmx: лучше для живой миграции -->
<cpu mode='host-model' check='partial'>
  <feature policy='require' name='vmx'/>
</cpu>

<!-- Вариант 3 — именованная модель с явным флагом vmx -->
<cpu mode='custom' match='exact' check='partial'>
  <model fallback='allow'>Skylake-Server-noTSX-IBRS</model>
  <feature policy='require' name='vmx'/>
</cpu>

Для AMD конфигурация аналогична, но флаг называется svm:

<cpu mode='host-passthrough' check='none'>
  <topology sockets='1' cores='4' threads='2'/>
</cpu>
<!-- Либо явно для host-model -->
<cpu mode='host-model'>
  <feature policy='require' name='svm'/>
</cpu>

Применить изменённую XML-конфигурацию через virsh:

# Открыть XML-конфигурацию гостя для редактирования
virsh edit <имя_ВМ>

# Проверить, что L1 видит флаг vmx после запуска
virsh start <имя_ВМ>
virsh console <имя_ВМ>
# Внутри L1:
grep -c vmx /proc/cpuinfo
# Ненулевой результат подтверждает, что флаг передан

# Убедиться, что /dev/kvm доступен внутри L1
ls -la /dev/kvm

Shadow VMCS и Nested APIC-v как ключевые ускорители

Без дополнительных оптимизаций вложенная виртуализация генерирует огромное число VM exit. Каждое обращение L1 к структуре VMCS через инструкции VMREAD или VMWRITE вызывало бы переход к L0, обработку запроса, возврат. На тяжёлых нагрузках это сотни тысяч лишних переходов в секунду.

Shadow VMCS устраняет это изящным способом. L0 создаёт для L1 теневую копию VMCS в специально выделенной памяти, к которой L1 обращается напрямую без VM exit. Синхронизация теневой копии с реальной происходит только на VM exit, то есть тогда, когда переход всё равно неизбежен. Практический эффект измерим: сборка ядра Linux в тестах с Shadow VMCS занимала около 13 минут против более 15 минут без него при прочих равных условиях.

Nested APIC-v решает аналогичную проблему для аппаратных прерываний. Без неё любой доступ L2 к регистрам APIC требовал выхода через L1 к L0. С виртуализированным APIC гость читает данные из специальной Virtual APIC Page напрямую, а горячие пути записи обрабатываются без выхода из гостя. В тестах сетевых нагрузок включение Nested APIC-v поднимало пропускную способность с 2.1 Гбит/с до 3.5 Гбит/с на идентичном стеке.

Проверить статус всех ускорений и убедиться, что они активны:

# Полная проверка параметров модуля kvm_intel
for param in nested ept enable_shadow_vmcs enable_apicv unrestricted_guest; do
    val=$(cat /sys/module/kvm_intel/parameters/$param 2>/dev/null || echo "N/A")
    echo "$param = $val"
done
# Все критически важные параметры должны быть Y или 1

# Посмотреть статистику VM exit в реальном времени (нужен perf)
# Чем меньше выходов — тем лучше работают оптимизации
perf kvm stat live -p $(pgrep -f qemu-system)

Реальные накладные расходы и три сценария, где они оправданы

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

На правильно настроенном стеке с EPT, Shadow VMCS и APIC-v типичный overhead для вычислительных задач составляет от 5 до 30 процентов относительно нативной виртуализации L1. Для операций с памятью разрыв шире из-за дополнительного уровня трансляции EPT02. Сетевые задачи с паравиртуальными драйверами virtio ведут себя лучше, потому что обходят лишние слои эмуляции. Если EPT недоступен и система переходит в shadow paging, производительность падает в десять и более раз, делая L2-гостей непригодными для любой серьёзной работы.

Три сценария, где накладные расходы оправданы с практической точки зрения:

  • CI/CD-пайплайны для тестирования гипервизорно-зависимого кода, драйверов, конфигураций Kubernetes и контейнерных сред внутри полностью изолированных виртуальных окружений;
  • лабораторные стенды, где на одном физическом сервере нужно имитировать несколько независимых гипервизоров или целый кластер с вложенными машинами без выделения отдельного железа;
  • облачные платформы, которые предоставляют пользователям возможность запускать собственный гипервизор на арендованном инстансе.

Для производственных нагрузок, чувствительных к задержкам, вложение остаётся плохим выбором. Когда каждая микросекунда имеет значение, дополнительный VM exit неприемлем.

Диагностика проблем и типичные ошибки конфигурации

Большинство проблем с вложенной виртуализацией объясняются несколькими типовыми причинами: неверный CPU-тип гостя, отсутствие флага vmx/svm в конфигурации, или неактивный nested на уровне L0. Диагностический путь выглядит так:

# Шаг 1 — проверить, что на L1 видно /dev/kvm
ls -la /dev/kvm
# Если файла нет, KVM в L1 не работает

# Шаг 2 — проверить, что L1 загрузил модуль kvm_intel или kvm_amd
lsmod | grep kvm
# Если модуль не загружается — смотреть dmesg на ошибки

# Шаг 3 — посмотреть dmesg на ошибки VMX
dmesg | grep -iE '(vmx|nested|kvm)'

# Шаг 4 — убедиться, что virsh видит возможности nested на хосте
virsh capabilities | grep -A 5 '<cpu>'

# Шаг 5 — проверить конкретную ВМ на предмет CPU-флагов
virsh dumpxml <имя_ВМ> | grep -A 10 '<cpu'

# Шаг 6 — динамически посмотреть количество VM exit по типам
# (требует привилегий и наличия perf)
perf kvm stat record -a sleep 5
perf kvm stat report
# Большое число VMRESUME и VMLAUNCH при малой нагрузке
# указывает на неоптимальную конфигурацию Shadow VMCS

Отдельного внимания заслуживает живая миграция. На AMD-системах, если L1-гость в момент миграции имеет активный L2-гость, поведение не определено: возможен kernel BUG!, oops или паника. Официальная документация ядра прямо предупреждает, что мигрированный в таком состоянии L1-гость нельзя считать стабильным или безопасным и он требует перезапуска. На Intel живая миграция L1 со вложенным L2 поддерживается начиная с ядра 5.3 и QEMU 4.2.0.

Ограничения, которые нужно знать до внедрения

Несколько ограничений критически важно учитывать заранее, а не обнаруживать в момент, когда что-то пошло не так в production.

Третий уровень вложения (L3) технически никто не запрещает, но практика показывает: он работает нестабильно и с таким overhead, что практической ценности в нём нет. Официальная документация прямо предупреждает, что использование L2 в качестве гипервизора для L3 не тестировалось и работающим не ожидается.

На архитектуре IBM Z (s390x) вложенность несовместима с huge pages: оба параметра модуля kvm-s390 взаимно исключают друг друга на уровне ядра. На IBM POWER9 функциональность существует в статусе Technology Preview без каких-либо гарантий стабильности.

Внутренняя структура VMCS12 между версиями KVM может меняться. Если константа VMCS12_REVISION не синхронизирована между узлами кластера, миграция L1-гостей между серверами с разными ядрами завершится ошибкой. Это особенно актуально для гетерогенных кластеров, где часть узлов обновлена, а часть нет.

# Убедиться, что версия KVM одинакова на всех узлах кластера
# (перед живой миграцией L1-гостей)
virsh version
uname -r

# Посмотреть текущую версию VMCS на хосте (Intel)
dmesg | grep -i "vmcs12"

Вложенная виртуализация продолжает зреть с каждым поколением процессоров и каждой версией ядра. Накладные расходы снижаются, стабильность растёт, список поддерживаемых сценариев расширяется. То, что несколько лет назад работало непредсказуемо, сегодня вполне пригодно для CI/CD и лабораторных сред. Главное условие остаётся неизменным: понимать механизм, проверять каждый слой конфигурации и не полагаться на то, что "само заработает".