Главная/Основы Containerization/Урок

2.2. Containers против virtual machines

Цели

После этого материала вы сможете:

  • объяснить механизм виртуальной машины и механизм container, не прибегая к аналогиям;
  • назвать, что именно виртуализируется в каждом случае;
  • сравнить подходы по стоимости запуска, плотности размещения, накладным расходам и границе изоляции;
  • объяснить, почему «container легче VM» — следствие устройства, а не оптимизации;
  • назвать задачи, где VM остаётся правильным выбором;
  • объяснить, что даёт комбинация VM и containers, применяемая в облаках.

Предварительные знания

Ключевые термины

ТерминОбъяснение
hypervisorПрограмма, создающая и управляющая виртуальными машинами. Виртуализирует оборудование
type 1 hypervisorРаботает непосредственно на оборудовании (KVM, Xen, ESXi)
type 2 hypervisorРаботает поверх обычной ОС (VirtualBox, QEMU в пользовательском режиме)
guest OSОперационная система внутри виртуальной машины, включая собственное ядро
hardware virtualizationАппаратная поддержка виртуализации в процессоре: Intel VT-x, AMD-V
attack surfaceСовокупность точек, через которые система может быть атакована
overheadНакладные расходы: ресурсы, потраченные не на полезную работу

Теория

Что виртуализирует каждый подход

Разница между VM и container — в том, на каком уровне проводится граница.

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

Container виртуализирует интерфейс ядра. Никакого оборудования не создаётся. Процесс обращается к тому же ядру, что и все остальные процессы host, — но ядро отвечает ему иначе, руководствуясь его namespaces.

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

  ┌──────────┐  ┌──────────┐            ┌──────────┐  ┌──────────┐
  │  App A   │  │  App B   │            │  App A   │  │  App B   │
  ├──────────┤  ├──────────┤            ├──────────┤  ├──────────┤
  │ Bins/Libs│  │ Bins/Libs│            │ Bins/Libs│  │ Bins/Libs│
  ├──────────┤  ├──────────┤            └────┬─────┘  └────┬─────┘
  │ Guest OS │  │ Guest OS │                 │             │
  │ + kernel │  │ + kernel │            ┌────┴─────────────┴────┐
  ├──────────┤  ├──────────┤            │   Container runtime   │
  │ vHW      │  │ vHW      │            ├───────────────────────┤
  └────┬─────┘  └────┬─────┘            │      Host kernel      │
       │             │                  │  (один на всех)       │
  ┌────┴─────────────┴────┐             ├───────────────────────┤
  │      Hypervisor       │             │      Оборудование     │
  ├───────────────────────┤             └───────────────────────┘
  │    Host OS / kernel   │
  ├───────────────────────┤
  │     Оборудование      │
  └───────────────────────┘

Ключевое различие видно на схеме: в левой части каждая VM несёт собственное ядро, в правой ядро одно.

Почему container запускается быстро

Не потому, что «оптимизирован». Потому, что делает меньше работы.

Запуск виртуальной машины:

  1. Гипервизор выделяет память и создаёт виртуальные устройства.
  2. Загружается firmware (BIOS/UEFI гостя).
  3. Загрузчик читает ядро с виртуального диска.
  4. Ядро инициализируется: определяет устройства, загружает драйверы, монтирует корневую файловую систему.
  5. Стартует init-система, запускаются службы.
  6. Наконец, запускается приложение.

Секунды, иногда десятки секунд.

Запуск container:

  1. Собирается корневая файловая система из слоёв.
  2. Создаются namespaces и cgroup.
  3. Порождается процесс, выполняется execve().

Миллисекунды. Шагов 2–5 из списка VM просто не существует: ядро уже загружено, устройства уже определены, драйверы уже работают.

Накладные расходы

РесурсВиртуальная машинаContainer
ПамятьГостевое ядро, кэши гостя, службы гостя — обычно от 200–500 MB на VM только на «системную» частьТолько память приложения. Накладных расходов почти нет
ДискОбраз с полной ОС — от 1 GBТолько библиотеки и приложение; слои переиспользуются между образами
CPUАппаратная виртуализация делает накладные расходы небольшими, но они есть: выходы в гипервизор, эмуляция устройствОтсутствуют — это обычные системные вызовы
Ввод-выводЧерез виртуальные устройства; заметно при интенсивной работе с диском и сетьюПрямой доступ к ядру; overlay-файловая система даёт небольшие расходы при записи
Время запускаСекунды–десятки секундМиллисекунды
Плотность на узлеДесяткиСотни–тысячи

Граница изоляции — главное различие

Здесь заканчиваются преимущества containers и начинаются их ограничения.

Виртуальная машина. Гость обращается к виртуальному оборудованию. Между гостем и host — гипервизор, у которого сравнительно небольшая и хорошо изученная поверхность атаки. Побег из VM (VM escape) — редкое и дорогое событие, требующее уязвимости в гипервизоре.

Container. Процесс обращается напрямую к ядру host. Поверхность атаки — весь интерфейс системных вызовов Linux: сотни вызовов, десятки миллионов строк кода. Уязвимость в любом из них потенциально даёт побег из container.

Это не означает, что containers небезопасны. Это означает, что граница слабее, и её нужно учитывать при проектировании. Docker применяет дополнительные механизмы (seccomp, capabilities, AppArmor), сужающие поверхность атаки, — они разбираются в разделе 12.

Практическое правило:

Containers достаточны для изоляции своего кода друг от друга. Для изоляции недоверенного кода нужна виртуальная машина или специализированный runtime.

Другие ограничения containers

ОграничениеПричинаСледствие
Только Linux-приложения на Linux-hostЯдро общееНельзя запустить Windows-приложение в container на Linux
Нельзя использовать другую версию ядраЯдро общееПриложение, требующее ядра 6.5, не заработает на host с ядром 5.15
Нельзя загрузить модуль ядраМодули загружаются на уровне hostЗагружайте на host; container получит доступ
Ограниченный доступ к оборудованиюТребует явного пробрасыванияGPU, USB-устройства требуют настройки
Нельзя изменить системные параметры глобальноsysctl частично изолирован, частично общийЧасть настроек применима только на host

Когда VM остаётся правильным выбором

  1. Другая операционная система. Windows-сервис на Linux-сервере, macOS-сборка. Container этого не даст.
  2. Другая версия ядра. Приложение требует ядра, отличного от host.
  3. Недоверенный код. Выполнение кода, которому вы не доверяете: пользовательские скрипты, вредоносное ПО для анализа, многопользовательские платформы.
  4. Жёсткие требования сертификации. Регуляторные требования иногда прямо предписывают уровень изоляции VM.
  5. Полная эмуляция устройств. Тестирование драйверов, работа со специфическим оборудованием.
  6. Гарантированная изоляция ресурсов. У VM ресурсы выделены жёстко; cgroups дают ограничение, но соседство по кэшу процессора и шине памяти остаётся.

Комбинация, а не выбор

На практике эти технологии редко конкурируют. Стандартная схема в облаках:

text
  Физический сервер
  └── Hypervisor
      ├── VM клиента A          ◄── граница между клиентами: VM
      │   └── containers A1..An ◄── граница внутри клиента: container
      └── VM клиента B
          └── containers B1..Bm

VM отделяет клиентов друг от друга — там нужна сильная граница. Containers отделяют сервисы одного клиента — там достаточно лёгкой границы, зато важны плотность и скорость.

Такая же схема применима и внутри компании: VM на команду или на среду (prod/staging), containers на сервис.


Внутренний механизм

Что делает гипервизор

Аппаратная виртуализация (Intel VT-x, AMD-V) вводит дополнительный режим работы процессора. Гостевое ядро выполняется почти на полной скорости, но привилегированные операции вызывают VM exit — передачу управления гипервизору. Гипервизор обрабатывает операцию и возвращает управление.

Количество VM exit определяет накладные расходы. Для вычислительных задач их мало — производительность близка к нативной. Для интенсивного ввода-вывода их много — отсюда исторически заметная разница; современные механизмы (virtio, SR-IOV) её сокращают.

Что делает container runtime

Никакого нового режима процессора. Процесс выполняет обычные системные вызовы. Ядро при обработке вызова смотрит на namespaces процесса и отвечает соответственно.

Например, при вызове getpid() ядро возвращает PID процесса в его PID namespace, а не глобальный. При чтении /proc ядро формирует содержимое, отфильтрованное по namespace. Дополнительной работы почти нет — отсюда отсутствие накладных расходов на CPU.


Команды и примеры

Измерение времени запуска

bash
time docker run --rm alpine true
text
real    0m0.412s
user    0m0.021s
sys     0m0.017s

Основная часть этого времени — работа CLI и daemon, а не сам запуск процесса. Повторный запуск обычно ещё быстрее.

Для сравнения: запуск виртуальной машины с Ubuntu до состояния готовности занимает 10–40 секунд.

Проверка общего ядра

bash
uname -r
docker run --rm ubuntu:24.04 uname -r
docker run --rm alpine uname -r
docker run --rm debian:trixie-slim uname -r

Все четыре команды выведут одно и то же — ядро вашего host. Четыре разных дистрибутива, одно ядро.

Для сравнения, в виртуальной машине с Ubuntu вы получили бы ядро, установленное внутри неё, независимое от host.

Сравнение размеров

bash
docker pull alpine:latest
docker pull ubuntu:24.04
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'
text
REPOSITORY   TAG       SIZE
ubuntu       24.04     78.1MB
alpine       latest    8.31MB

Оба образа содержат пользовательское окружение дистрибутива без ядра. Образ виртуальной машины с той же Ubuntu занял бы 1.5–2.5 GB — разницу составляют ядро, загрузчик, драйверы, полный набор служб.

Проверка накладных расходов на память

bash
docker run -d --name mem-demo alpine sleep 300
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}' mem-demo
text
NAME       MEM USAGE / LIMIT
mem-demo   404KiB / 15.32GiB

404 килобайта — это память самого процесса sleep. Никакого «системного» расхода нет: нет гостевого ядра, нет init-системы, нет служб.

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

Уборка:

bash
docker rm -f mem-demo

Проверка типа виртуализации host

bash
systemd-detect-virt

Если вывод — kvm, vmware, microsoft, вы уже работаете внутри VM, и containers запускаются внутри неё. Это ровно та комбинированная схема, о которой говорилось выше.

Демонстрация общей поверхности ядра

bash
docker run --rm alpine sh -c 'cat /proc/version'
cat /proc/version

Идентичный вывод. Файл /proc/version формируется ядром — тем же самым ядром.


Практическое упражнение

Задание. Экспериментально подтвердите три различия между containers и VM. Оформите результат таблицей.

Измерьте:

  1. Время запуска. Среднее время запуска container с тривиальной командой по десяти повторам.
  2. Накладные расходы на память. Потребление памяти container с процессом sleep.
  3. Разделение ядра. Версия ядра для трёх образов разных дистрибутивов и для host.

Дополнительно: объясните письменно, почему первые два результата — следствие устройства, а не оптимизации.

Подсказки

Подсказка 1

Для среднего времени по десяти повторам используйте цикл и date +%s%N (наносекунды) до и после, либо оберните весь цикл в time и разделите результат на 10.

Подсказка 2

docker stats --no-stream делает один снимок и завершается — в отличие от интерактивного режима без флага.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/usr/bin/env bash
# vm-vs-container.sh — измеримое сравнение containers и VM.
set -euo pipefail

echo "=== 1. Время запуска container (10 повторов) ==="
start="$(date +%s%N)"
for _ in $(seq 10); do
    docker run --rm alpine true
done
end="$(date +%s%N)"
avg_ms=$(( (end - start) / 10 / 1000000 ))
printf '   Среднее: %s мс\n' "$avg_ms"
echo "   Для сравнения: загрузка VM с Ubuntu — 10 000–40 000 мс"

echo
echo "=== 2. Накладные расходы на память ==="
docker run -d --name mem-probe alpine sleep 60 > /dev/null
sleep 1
usage="$(docker stats --no-stream --format '{{.MemUsage}}' mem-probe)"
printf '   Потребление container с sleep: %s\n' "$usage"
docker rm -f mem-probe > /dev/null
echo "   Для сравнения: VM с Ubuntu в простое — 200–500 MB только на систему"

echo
echo "=== 3. Разделение ядра ==="
printf '   %-28s %s\n' "host:" "$(uname -r)"
for img in alpine ubuntu:24.04 debian:trixie-slim; do
    printf '   %-28s %s\n' "$img:" "$(docker run --rm "$img" uname -r)"
done
echo
echo "   Все значения совпадают: ядро одно на всех."

Ожидаемый вывод:

text
=== 1. Время запуска container (10 повторов) ===
   Среднее: 389 мс
   Для сравнения: загрузка VM с Ubuntu — 10 000–40 000 мс

=== 2. Накладные расходы на память ===
   Потребление container с sleep: 404KiB / 15.32GiB
   Для сравнения: VM с Ubuntu в простое — 200–500 MB только на систему

=== 3. Разделение ядра ===
   host:                        6.8.0-88-generic
   alpine:                      6.8.0-88-generic
   ubuntu:24.04:                6.8.0-88-generic
   debian:trixie-slim:          6.8.0-88-generic

   Все значения совпадают: ядро одно на всех.

Объяснение результатов.

Быстрый запуск — не оптимизация, а отсутствие работы. VM должна инициализировать ядро, определить устройства, загрузить драйверы и запустить init-систему. Container не делает ничего из этого: ядро уже работает, устройства уже определены. Остаётся только создать namespaces и выполнить execve().

Малое потребление памяти — по той же причине. Расход VM складывается из гостевого ядра, его кэшей и служб гостевой ОС. У container нет ни того, ни другого, ни третьего: он потребляет ровно столько, сколько его приложение.

Цена этих преимуществ — общее ядро. Отсюда и невозможность другой ОС, и более слабая граница изоляции.

Проверка результата

Ответьте на контрольный вопрос по своим измерениям: если container запускается в 50 раз быстрее VM, что именно из работы VM он не выполняет? Перечислите минимум три шага.

Типичные ошибки

ОшибкаПричинаИсправление
«Container — это лёгкая VM»Внешнее сходство: изолированное окружение со своей файловой системойРазные механизмы. Аналогия ведёт к неверным ожиданиям об изоляции
Ожидание, что container защитит от недоверенного кода так же, как VMСлово «изоляция» одинаково, механизм — нетДля недоверенного кода использовать VM или специализированный runtime
Попытка запустить Windows-приложение в Linux-containerКажется, что container «содержит ОС»Container использует ядро host; нужна VM
«Alpine маленький, потому что урезанный Linux»Верно лишь отчастиВсе образы малы, потому что не содержат ядра. Alpine мал ещё и за счёт musl и BusyBox
Выбор VM там, где достаточно container, ради «безопасности»Не проанализирована модель угрозОпределить, от чего защищаемся: свой код или чужой
Выбор container там, где нужна VM, ради «современности»Технологический выбор по модеОценить требования: ОС, ядро, доверие к коду

Контрольные вопросы

На понимание:

  1. Что виртуализирует гипервизор и что «виртуализирует» container runtime?
  2. Почему образ container меньше образа VM в 20–100 раз?
  3. Почему граница изоляции container слабее, чем у VM?
  4. Почему нельзя запустить приложение, требующее ядра 6.5, в container на host с ядром 5.15?
  5. Зачем в облаках используют VM и containers одновременно?

На применение:

  1. Как экспериментально подтвердить, что все containers на машине используют одно ядро?
  2. Как измерить накладные расходы container на память?
  3. Как определить, работает ли ваш host сам внутри виртуальной машины?

На диагностику:

  1. Команда установки требует загрузки модуля ядра и не работает внутри container. Почему и как решить?
  2. Многопользовательская платформа запускает пользовательский код в containers. Какие риски это создаёт и чем их закрыть?

Краткое резюме

  1. VM виртуализирует оборудование; container изолирует представление процесса о системе.
  2. Каждая VM несёт собственное ядро; все containers на машине используют одно ядро host.
  3. Быстрый запуск container — следствие отсутствия работы, а не оптимизации.
  4. Накладные расходы container на память и CPU практически отсутствуют.
  5. Плотность: десятки VM против сотен и тысяч containers на узле.
  6. Поверхность атаки container — весь интерфейс системных вызовов; у VM — интерфейс гипервизора.
  7. Containers достаточны для изоляции своего кода; для недоверенного нужна VM.
  8. Container не даёт другую ОС, другое ядро и загрузку модулей.
  9. VM и containers дополняют друг друга — комбинированная схема стандартна в облаках.
  10. Выбор делается по требованиям к изоляции, ОС и ядру, а не по «весу» технологии.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker overviewhttps://docs.docker.com/get-started/docker-overview/Определение container и сравнение с виртуальными машинами
Docker Engine securityhttps://docs.docker.com/engine/security/Модель безопасности containers, поверхность атаки, роль namespaces и cgroups
namespaces(7) man pagehttps://man7.org/linux/man-pages/man7/namespaces.7.htmlМеханизм изоляции без виртуализации оборудования
KVM documentationhttps://www.linux-kvm.org/page/Main_PageМеханизм аппаратной виртуализации и роль гипервизора
Linux kernel: virtualizationhttps://docs.kernel.org/virt/index.htmlПоддержка виртуализации в ядре, VM exit
systemd-detect-virt(1)https://man7.org/linux/man-pages/man1/systemd-detect-virt.1.htmlОпределение типа виртуализации host

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Linux namespaces
Главное оглавление

Markdown на GitHub ↗