Контейнеры vs виртуальные машины
Проблема: «У меня работает, у тебя — нет»
Классическая история в командах разработки: разработчик пишет код на MacBook, всё работает. Передаёт тестировщику — падает. Деплоит на сервер — снова падает. Причина почти всегда одна: разные окружения. Разные версии Python, Node.js, разные системные библиотеки, разные переменные окружения.
До Docker проблему решали по-разному. Виртуальные машины — тяжёлый, но надёжный способ. Скрипты настройки окружения типа bootstrap.sh — хрупко и ненадёжно. Инструменты вроде Vagrant — лучше, но всё ещё громоздко.
Docker предложил принципиально иной подход: упаковать приложение вместе с его окружением.
Важная идея: контейнер решает не только задачу запуска процесса, а задачу повторяемого окружения для этого процесса.
Когда приложение запускается из образа (image), вместе с ним приезжают нужные библиотеки, файлы и настройки запуска. Поэтому меньше зависит от того, что случайно установлено на сервере руками.
Виртуальные машины: как они работают
Виртуальная машина (VM) — это полная симуляция компьютера. Она содержит:
- Виртуальное железо (CPU, RAM, диск, сетевую карту)
- Гипервизор — программу, которая управляет этим виртуальным железом
- Полноценную гостевую ОС с ядром (Guest OS)
- Все системные службы этой ОС
- Ваше приложение поверх всего этого
┌─────────────────────────────────────────────┐
│ Host OS │
│ ┌─────────────────────────────────────────┐ │
│ │ Гипервизор (VMware/KVM) │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ VM 1 │ │ VM 2 │ │ │
│ │ │ Guest OS │ │ Guest OS │ │ │
│ │ │ App A │ │ App B │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Плюсы VM:
- Полная изоляция — разные ОС на одном железе
- Можно запустить Windows на Linux-хосте и наоборот
- Хорошая безопасность: атака из VM не затронет хост
Минусы VM:
- Большой размер: образ Ubuntu занимает несколько гигабайт
- Медленный старт: VM загружается минуты
- Большое потребление ресурсов: каждая VM резервирует RAM и CPU
- Накладные расходы (overhead): ядро гостевой ОС работает поверх виртуального железа
Контейнеры: другой подход к изоляции
Контейнеры используют совершенно другую идею. Они не создают виртуальное железо и не загружают отдельное ядро ОС. Вместо этого они используют механизмы самого Linux-ядра для изоляции процессов.
┌─────────────────────────────────────────────┐
│ Host OS │
│ ┌─────────────────────────────────────────┐ │
│ │ Docker Engine (daemon) │ │
│ └─────────────────────────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Контейнер 1 │ │ Контейнер 2 │ │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │
│ │ │ App A │ │ │ │ App B │ │ │
│ │ ├─────────────┤ │ │ ├─────────────┤ │ │
│ │ │ libs A │ │ │ │ libs B │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │ │
│ └─────────────────┘ └─────────────────┘ │
│ Одно ядро Linux для всех контейнеров │
└─────────────────────────────────────────────┘
Два ключевых механизма ядра Linux, которые делают контейнеры возможными:
Пространства имён (namespaces) — изоляция
Пространства имён создают изолированный взгляд на разные ресурсы:
- пространство процессов (pid namespace) — каждый контейнер видит свои процессы со своими PID. Процесс с PID 1 внутри контейнера — это не init системы, а ваше приложение.
- сетевое пространство (net namespace) — у каждого контейнера своя сетевая карта, свой IP, свои порты. Два контейнера могут оба слушать порт 80 без конфликта.
- пространство монтирования (mnt namespace) — у каждого контейнера своя файловая система. Контейнер не видит файлы хоста, если вы явно не подключили их внутрь.
- пространство имени хоста (uts namespace) — у каждого контейнера свой hostname.
- пространство пользователей (user namespace) — сопоставление пользователей и групп (uid/gid).
- пространство межпроцессного взаимодействия (ipc namespace) — изоляция обмена данными между процессами.
Контрольные группы (cgroups) — контроль ресурсов
Контрольные группы (Control Groups, cgroups) позволяют ограничивать ресурсы для группы процессов:
- Максимальное использование CPU (например, не более 0.5 ядра)
- Максимальное использование RAM (например, не более 256 MB)
- Дисковый I/O
- Пропускная способность сети
Если контейнер превысит лимит по памяти — ядро убьёт его процессы. Это защищает соседние контейнеры.
Если совсем коротко:
- пространства имён (namespaces) отвечают за то, что процесс видит вокруг себя: процессы, сеть, hostname, точки монтирования;
- контрольные группы (cgroups) отвечают за то, сколько ресурсов процесс может потреблять: CPU, RAM, I/O.
Контейнер получается из сочетания этих механизмов: обычный Linux-процесс запускается с отдельными пространствами имён, попадает в свою контрольную группу и получает свою файловую систему из образа. Docker не изобрёл изоляцию на уровне ядра, а собрал эти возможности в удобный инструмент: docker run, образы, реестры, сети и тома.
Важная идея: контейнер - это не маленькая виртуальная машина, а изолированный процесс на общем ядре хоста.
Из-за этого контейнеры быстро стартуют и занимают меньше ресурсов, но их изоляция отличается от VM. У контейнера нет своего отдельного ядра ОС; он использует ядро хоста через Linux namespaces и cgroups.
Не путай Linux namespace с namespace в имени образа. В registry.company.local/dev/app:v1 слово dev тоже могут называть namespace, но это уже часть имени образа в реестре, а не механизм изоляции Linux.
Сравнение в цифрах
| Характеристика | Виртуальная машина | Контейнер |
|---|---|---|
| Время запуска | 30 секунд — несколько минут | Секунды или миллисекунды |
| Размер образа | Гигабайты (полная ОС) | Мегабайты (только нужные слои) |
| Потребление RAM | Гигабайты (резервируется) | Только то, что реально нужно |
| Плотность на хосте | Единицы VM | Сотни контейнеров |
| Изоляция ядра | Полная (своё ядро) | Общее ядро хоста |
| Безопасность | Выше (полная изоляция) | Ниже (общее ядро, shared kernel) |
Когда что использовать
Виртуальные машины подходят, когда:
- Нужна разная ОС (Windows на Linux-хосте)
- Требуется максимальная безопасность для недоверенного кода (untrusted code)
- Нужно своё ядро с особыми настройками
- Требования комплаенса (compliance) требуют полной изоляции
Контейнеры подходят, когда:
- Все приложения работают на Linux
- Нужна быстрая разработка и деплой
- Важна плотность размещения (много сервисов на одном хосте)
- Используется микросервисная архитектура
На практике в DevOps используют и то, и другое: контейнеры запускаются внутри виртуальных машин. Это даёт и изоляцию VM, и эффективность контейнеров.
Docker и OCI
Docker — это не просто контейнеры. Это экосистема, включающая:
- Docker Engine — демон, управляющий контейнерами
- Docker Image — образ, неизменяемый шаблон для создания контейнеров
- Docker Registry — реестр, хранилище образов (Docker Hub, Nexus, Harbor)
- Docker Compose — инструмент для запуска нескольких контейнеров вместе
- Docker CLI — командная строка для управления
В 2015 году Docker передал спецификацию контейнеров в OCI (Open Container Initiative). Теперь существуют и другие container runtimes (среды запуска контейнеров): containerd, CRI-O, podman. Kubernetes использует containerd под капотом. Но образы, собранные Docker, работают везде, потому что все следуют одной спецификации.
Итого
Контейнеры — это изолированные процессы на общем ядре, а не отдельные ОС. Это даёт скорость и эффективность, которые сделали Docker стандартом индустрии. В следующих главах мы разберём Docker по порядку: сначала архитектуру, потом подробный запуск через docker run, затем образы, сети, тома и сборку собственных образов.
Практическая модель перед первыми командами
В первых лабораторных важно думать о контейнере не как о “маленькой VM”, а как о процессе с настройками изоляции.
Главная связка такая:
образ (image) -> контейнер (container) -> процесс (process)
Образ (image) — это шаблон. Контейнер (container) — экземпляр этого шаблона. Процесс (process) — программа, которая реально выполняется внутри контейнера. Если главный процесс завершился, контейнер тоже остановился. Подробные команды запуска, просмотра логов и входа внутрь контейнера будут в статье про жизненный цикл.
Что сохраняется, а что нет
Образ неизменяемый. Контейнер имеет свой записываемый слой (writable layer) поверх образа. Если записать файл внутрь контейнера и удалить контейнер, файл пропадёт вместе с этим слоем.
Для данных, которые должны жить дольше контейнера, нужны volumes (тома) или bind mounts (привязки директорий хоста). Это появится в сетевых и volume-лабах, но базовую идею лучше держать сразу:
| Где данные | Что будет при docker rm |
|---|---|
| внутри записываемого слоя контейнера | удалятся |
| named volume (именованный том) | сохранятся |
| bind mount (директория хоста) | останутся на хосте |
Контейнеры и безопасность
Изоляция контейнера полезна, но не абсолютна. Контейнеры используют ядро хоста, поэтому root внутри контейнера и небезопасные capabilities могут быть проблемой.
Capabilities — это отдельные привилегии ядра Linux, которые можно дать процессу вместо полного root-доступа. Например, одна capability может разрешать менять сетевые настройки, другая — выполнять операции администрирования системы. Чем больше таких привилегий у контейнера, тем шире возможный ущерб при ошибке или взломе.
В начальном курсе мы не уходим глубоко в настройку безопасности контейнеров, но хорошая привычка уже здесь: не запускать лишнее, не хранить секреты в образе, не использовать --privileged без причины, проверять опубликованные порты.
Почему контейнеры быстро стартуют
VM должна загрузить отдельную гостевую ОС: ядро, init, системные сервисы. Контейнер использует уже работающее ядро хоста и стартует только нужный процесс. Поэтому docker run nginx обычно занимает секунды, а создание VM - минуты.
Но скорость не означает, что контейнер “проще” операционно. У контейнера всё равно есть образ, сеть, тома, логи, лимиты ресурсов и жизненный цикл. Docker просто делает эти вещи более лёгкими и повторяемыми.
Что значит stateless
Stateless-сервис — это сервис без локального состояния. Он не хранит критичные данные внутри файловой системы контейнера, поэтому его можно удалить и создать заново без потери данных. Примеры: frontend, API, worker, который читает задачи из очереди.
Stateful-сервис хранит состояние: база данных, брокер сообщений, файловое хранилище. Для него обязательно нужно думать о volumes, backup и восстановлении.
В этом курсе это решается просто: nginx-контейнер можно удалить и запустить заново — контейнер будет точно такой же, а данные PostgreSQL или пользовательские файлы должны быть в volume, иначе при удалении контейнера все данные будут потеряны.
Образ против контейнера
Один образ может породить много контейнеров:
docker run -d --name web1 nginx:alpine
docker run -d --name web2 nginx:alpine
web1 и web2 используют один образ, но имеют разные container layers, имена, IP и lifecycle. Удаление одного контейнера не удаляет образ и не останавливает другой контейнер.
Почему “зайти в контейнер и поправить” плохо
Можно выполнить:
docker exec -it web sh
и вручную изменить файл внутри контейнера. Для диагностики это нормально. Для постоянного исправления - плохо: изменение живёт только в записываемом слое конкретного контейнера и пропадёт при пересоздании.
Правильный путь зависит от типа изменения:
| Изменение | Где фиксировать |
|---|---|
| код приложения | в Git и новом образе |
| системная зависимость | в Dockerfile |
| конфигурация запуска | env/volume/Compose/Kubernetes |
| данные приложения | volume или внешнее хранилище |
Так контейнер остаётся воспроизводимым.
