2.2. Containers против virtual machines
Цели
После этого материала вы сможете:
- объяснить механизм виртуальной машины и механизм container, не прибегая к аналогиям;
- назвать, что именно виртуализируется в каждом случае;
- сравнить подходы по стоимости запуска, плотности размещения, накладным расходам и границе изоляции;
- объяснить, почему «container легче VM» — следствие устройства, а не оптимизации;
- назвать задачи, где VM остаётся правильным выбором;
- объяснить, что даёт комбинация VM и containers, применяемая в облаках.
Предварительные знания
- 2.1. Процессы и 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.
Виртуальные машины 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 запускается быстро
Не потому, что «оптимизирован». Потому, что делает меньше работы.
Запуск виртуальной машины:
- Гипервизор выделяет память и создаёт виртуальные устройства.
- Загружается firmware (BIOS/UEFI гостя).
- Загрузчик читает ядро с виртуального диска.
- Ядро инициализируется: определяет устройства, загружает драйверы, монтирует корневую файловую систему.
- Стартует init-система, запускаются службы.
- Наконец, запускается приложение.
Секунды, иногда десятки секунд.
Запуск container:
- Собирается корневая файловая система из слоёв.
- Создаются namespaces и cgroup.
- Порождается процесс, выполняется
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 остаётся правильным выбором
- Другая операционная система. Windows-сервис на Linux-сервере, macOS-сборка. Container этого не даст.
- Другая версия ядра. Приложение требует ядра, отличного от host.
- Недоверенный код. Выполнение кода, которому вы не доверяете: пользовательские скрипты, вредоносное ПО для анализа, многопользовательские платформы.
- Жёсткие требования сертификации. Регуляторные требования иногда прямо предписывают уровень изоляции VM.
- Полная эмуляция устройств. Тестирование драйверов, работа со специфическим оборудованием.
- Гарантированная изоляция ресурсов. У VM ресурсы выделены жёстко; cgroups дают ограничение, но соседство по кэшу процессора и шине памяти остаётся.
Комбинация, а не выбор
На практике эти технологии редко конкурируют. Стандартная схема в облаках:
Физический сервер
└── 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.
Команды и примеры
Измерение времени запуска
time docker run --rm alpine true
real 0m0.412s
user 0m0.021s
sys 0m0.017s
Основная часть этого времени — работа CLI и daemon, а не сам запуск процесса. Повторный запуск обычно ещё быстрее.
Для сравнения: запуск виртуальной машины с Ubuntu до состояния готовности занимает 10–40 секунд.
Проверка общего ядра
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.
Сравнение размеров
docker pull alpine:latest
docker pull ubuntu:24.04
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'
REPOSITORY TAG SIZE
ubuntu 24.04 78.1MB
alpine latest 8.31MB
Оба образа содержат пользовательское окружение дистрибутива без ядра. Образ виртуальной машины с той же Ubuntu занял бы 1.5–2.5 GB — разницу составляют ядро, загрузчик, драйверы, полный набор служб.
Проверка накладных расходов на память
docker run -d --name mem-demo alpine sleep 300
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}' mem-demo
NAME MEM USAGE / LIMIT
mem-demo 404KiB / 15.32GiB
404 килобайта — это память самого процесса sleep. Никакого «системного» расхода нет: нет гостевого ядра, нет init-системы, нет служб.
Виртуальная машина с той же задачей потребляла бы сотни мегабайт только на систему.
Уборка:
docker rm -f mem-demo
Проверка типа виртуализации host
systemd-detect-virt
Если вывод — kvm, vmware, microsoft, вы уже работаете внутри VM, и containers запускаются внутри неё. Это ровно та комбинированная схема, о которой говорилось выше.
Демонстрация общей поверхности ядра
docker run --rm alpine sh -c 'cat /proc/version'
cat /proc/version
Идентичный вывод. Файл /proc/version формируется ядром — тем же самым ядром.
Практическое упражнение
Задание. Экспериментально подтвердите три различия между containers и VM. Оформите результат таблицей.
Измерьте:
- Время запуска. Среднее время запуска container с тривиальной командой по десяти повторам.
- Накладные расходы на память. Потребление памяти container с процессом
sleep. - Разделение ядра. Версия ядра для трёх образов разных дистрибутивов и для host.
Дополнительно: объясните письменно, почему первые два результата — следствие устройства, а не оптимизации.
Подсказки
Подсказка 1
Для среднего времени по десяти повторам используйте цикл и date +%s%N (наносекунды) до и после, либо оберните весь цикл в time и разделите результат на 10.
Подсказка 2
docker stats --no-stream делает один снимок и завершается — в отличие от интерактивного режима без флага.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/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 " Все значения совпадают: ядро одно на всех."
Ожидаемый вывод:
=== 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, ради «современности» | Технологический выбор по моде | Оценить требования: ОС, ядро, доверие к коду |
Контрольные вопросы
На понимание:
- Что виртуализирует гипервизор и что «виртуализирует» container runtime?
- Почему образ container меньше образа VM в 20–100 раз?
- Почему граница изоляции container слабее, чем у VM?
- Почему нельзя запустить приложение, требующее ядра 6.5, в container на host с ядром 5.15?
- Зачем в облаках используют VM и containers одновременно?
На применение:
- Как экспериментально подтвердить, что все containers на машине используют одно ядро?
- Как измерить накладные расходы container на память?
- Как определить, работает ли ваш host сам внутри виртуальной машины?
На диагностику:
- Команда установки требует загрузки модуля ядра и не работает внутри container. Почему и как решить?
- Многопользовательская платформа запускает пользовательский код в containers. Какие риски это создаёт и чем их закрыть?
Краткое резюме
- VM виртуализирует оборудование; container изолирует представление процесса о системе.
- Каждая VM несёт собственное ядро; все containers на машине используют одно ядро host.
- Быстрый запуск container — следствие отсутствия работы, а не оптимизации.
- Накладные расходы container на память и CPU практически отсутствуют.
- Плотность: десятки VM против сотен и тысяч containers на узле.
- Поверхность атаки container — весь интерфейс системных вызовов; у VM — интерфейс гипервизора.
- Containers достаточны для изоляции своего кода; для недоверенного нужна VM.
- Container не даёт другую ОС, другое ядро и загрузку модулей.
- VM и containers дополняют друг друга — комбинированная схема стандартна в облаках.
- Выбор делается по требованиям к изоляции, ОС и ядру, а не по «весу» технологии.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker overview | https://docs.docker.com/get-started/docker-overview/ | Определение container и сравнение с виртуальными машинами |
| Docker Engine security | https://docs.docker.com/engine/security/ | Модель безопасности containers, поверхность атаки, роль namespaces и cgroups |
namespaces(7) man page | https://man7.org/linux/man-pages/man7/namespaces.7.html | Механизм изоляции без виртуализации оборудования |
| KVM documentation | https://www.linux-kvm.org/page/Main_Page | Механизм аппаратной виртуализации и роль гипервизора |
| Linux kernel: virtualization | https://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
Главное оглавление