Docker: зачем он нужен и почему это не «магия контейнеров»

Docker: зачем он нужен и почему это не «магия контейнеров»

Docker нужен, чтобы перестать играть в игру «у меня работает». Контейнер - это упакованное окружение:

  • твоя версия Node/Go
  • зависимости
  • настройки
  • команда запуска

И всё это воспроизводимо на любом компьютере и сервере.

Проблема: «у меня работает»

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

  • Разные версии - у тебя Go 1.22, на сервере 1.19
  • Разные зависимости ОС - libpq не установлена, openssl другой версии
  • Разные переменные окружения - .env забыли, порт занят
  • Разные ОС - macOS vs Ubuntu vs Windows, пути к файлам, line endings

Docker решает это: образ содержит всё нужное, и запускается одинаково везде, где есть Docker.

Контейнер vs виртуальная машина

Container vs VM: VM везёт свой kernel на каждое приложение, контейнеры делят kernel хоста

<ComparisonTable title="Контейнер vs VM" headers={["", "Виртуальная машина", "Контейнер"]} rows={[ ["Ядро ОС", "Своё (гостевая ОС)", "Общее с хостом"], ["Размер", "Гигабайты (Ubuntu ~2 ГБ)", "Мегабайты (Alpine ~5 МБ)"], ["Запуск", "Минуты", "Секунды"], ["Изоляция", "Полная (гипервизор)", "Процессная (namespaces, cgroups)"], ["Overhead", "Высокий (RAM под гостевую ОС)", "Минимальный"], ["Пример", "VirtualBox, VMware", "Docker, Podman"] ]} />

VM - это квартира с отдельным фундаментом. Контейнер - комната в общежитии: своя дверь, свои вещи, но стены и крыша общие. Контейнеры - это процессы Linux, изолированные через namespaces.

Общее ядро: откуда и скорость, и ограничения

Вся таблица выше выводится из одной строки: контейнер использует ядро хоста. Проверить это можно за одну команду:

uname -r                        # ядро хоста, например 6.8.0-45-generic
docker run --rm alpine uname -r # то же самое ядро внутри контейнера
docker run --rm ubuntu uname -r # и здесь оно же, хотя дистрибутив другой

Обрати внимание: три разных «операционных системы» показывают одну и ту же версию ядра. Контейнер приносит с собой только userland - библиотеки, бинарники, файлы дистрибутива. Ядро он берёт готовое. Отсюда:

  • Плюс: запуск за миллисекунды. Загружать ядро и инициализировать систему не нужно, это просто новый процесс с изолированным окружением.
  • Плюс: мегабайты вместо гигабайт. В образе нет ядра и системных служб.
  • Минус: чужое ядро запустить нельзя. Windows-приложение в Linux-контейнере на Linux-хосте не поедет, и наоборот. Требование «нужно ядро с определённым модулем» тоже решается на хосте, а не в образе.
На macOS и Windows нет Linux-ядра, поэтому Docker Desktop незаметно поднимает виртуальную машину с Linux, и все контейнеры живут внутри неё. Практические следствия: у VM выделен свой лимит памяти и CPU, `network_mode: host` работает не так, как на Linux, а bind mount исходников идёт через слой трансляции файловой системы и потому ощутимо медленнее (особенно больно проектам с десятками тысяч файлов). На Linux-сервере этого слоя нет - там контейнеры работают напрямую.

Архитектура Docker

Docker состоит из трёх частей:

Архитектура Docker: CLI → Daemon → containerd, OCI как общий стандарт

  • Docker CLI - команды, которые ты вводишь (docker run, docker build)
  • Docker Daemon (dockerd) - фоновый процесс, управляет контейнерами
  • Container Runtime (containerd) - собственно запускает и изолирует процессы
Open Container Initiative определяет стандарт на формат образов и runtime. Docker - не единственный: Podman, containerd, CRI-O тоже работают с OCI-образами. Ты учишь не «Docker», а индустриальный стандарт.

Образ vs контейнер

Образ (image) - это рецепт и ингредиенты. Контейнер - это запущенное блюдо.

# Скачиваем образ (рецепт)
docker pull nginx:alpine

# Запускаем контейнер (блюдо)
docker run -d --name web nginx:alpine

# Из одного образа можно запустить сколько угодно контейнеров
docker run -d --name web2 nginx:alpine
docker run -d --name web3 nginx:alpine

Образ - read-only, его можно версионировать и хранить в registry (Docker Hub, GitLab Registry). Контейнер - это образ + тонкий writable-слой для данных процесса.

Образ, слой, контейнер - три разные сущности

Их путают чаще всего, поэтому разложим по полкам:

Слой (layer)      неизменяемый набор изменений в файловой системе.
                  Один слой = результат одной инструкции сборки.
Образ (image)     стек слоёв плюс метаданные: команда запуска,
                  переменные, рабочий каталог, порты.
Контейнер         запущенный образ плюс writable-слой сверху.

Практический эффект от такого устройства виден на диске:

docker pull nginx:alpine
docker run -d --name w1 nginx:alpine
docker run -d --name w2 nginx:alpine
docker run -d --name w3 nginx:alpine

docker ps -s   # колонка SIZE: virtual ~50 МБ, собственный размер - килобайты

Три контейнера не занимают тройной объём: слои образа read-only и переиспользуются всеми контейнерами, каждый добавляет только свой writable-слой. По той же причине второй образ на той же базе alpine скачивается почти мгновенно - базовые слои уже локально.

docker history nginx:alpine   # из каких слоёв собран образ и сколько весит каждый
docker image inspect nginx:alpine --format '{{.Os}}/{{.Architecture}}'
Всё, что процесс записал внутрь контейнера (загруженные файлы, SQLite-база, логи в файл), лежит в writable-слое. `docker rm` - и данных нет. Это не баг, а дизайн: контейнер считается одноразовым. Данные, которые должны жить дольше, выносятся в volume - об этом урок про [тома и сети](./07-env-volumes-networks.md). Про то, как слои образуются при сборке и почему их порядок влияет на скорость, - в уроке про [Dockerfile](./03-dockerfile-basics.md).

Что образ фиксирует, а что нет

«Работает у меня» побеждается не полностью: образ замораживает не всё окружение. Полезно точно знать границу.

Фиксируется в образе                 НЕ фиксируется, приходит извне
──────────────────────────────────   ────────────────────────────────────────
файлы, бинарники, библиотеки         архитектура процессора (amd64 / arm64)
версия языка и пакетов               версия ядра хоста и его возможности
ENV, заданные в Dockerfile           переменные окружения при запуске
ENTRYPOINT / CMD, WORKDIR, USER      содержимое volume и bind mount
открытые порты (как декларация)      реальный маппинг портов и сети
                                     системное время, лимиты CPU и памяти
                                     секреты, конфиги, доступы к внешним API

Отсюда самая обидная ошибка эпохи ноутбуков на ARM:

# Собрали на ноутбуке с arm64-процессором:
docker build -t myapp:1.0 .
docker push registry.example.com/myapp:1.0

# На amd64-сервере:
docker run registry.example.com/myapp:1.0
# exec /app/server: exec format error

Образ собрался под ту архитектуру, на которой шла сборка. Лечится явным указанием платформы или сборкой мультиплатформенного образа:

docker buildx build --platform linux/amd64 -t myapp:1.0 --push .
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 --push .
docker image inspect myapp:1.0 --format '{{.Architecture}}'   # проверка перед деплоем
Если контейнер ведёт себя по-разному на двух машинах при одном и том же теге образа, ищи причину в том, что снаружи: переменные окружения, содержимое volume, часовой пояс, лимиты памяти (процесс убит OOM-killer), доступность внешнего сервиса. Сам образ побайтово одинаков - его версия проверяется по digest: `docker image inspect --format '{{.RepoDigests}}'`. Кстати, поэтому тег `latest` - плохая идея: под ним на двух серверах могут лежать разные сборки.

Docker Registry

Registry - хранилище образов. Как GitHub для кода, только для Docker-образов.

  • Docker Hub - публичный по умолчанию (nginx, postgres, golang)
  • GitLab Container Registry - приватный, встроен в GitLab
  • GitHub Container Registry (ghcr.io) - приватный, привязан к GitHub
# Скачать публичный образ
docker pull postgres:16-alpine

# Залить свой образ в registry
docker tag myapp:latest registry.gitlab.com/user/myapp:v1.0
docker push registry.gitlab.com/user/myapp:v1.0

Где Docker поможет тебе прямо сейчас

ЗадачаБез DockerС Docker
Поднять PostgreSQLУстановка, pg_hba.conf, перезапускdocker run -d postgres:16-alpine
Запустить RedisСкачать, скомпилировать, настроитьdocker run -d redis:7-alpine
Собрать Go backendУстановить Go, настроить GOPATHdocker build -t backend .
Показать проект коллеге«Сначала установи Go 1.22, потом...»docker compose up
Docker не ускоряет код, не исправляет баги и не заменяет тесты. Он решает одну проблему - воспроизводимость окружения. Если приложение падает в контейнере, оно упадёт и без него.

Когда Docker не нужен

Честный разговор: у контейнеризации есть цена, и иногда она выше выгоды.

Ситуация                              Что дешевле                Почему
───────────────────────────────────   ────────────────────────   ─────────────────────
Один статический бинарник Go          systemd-юнит на VPS        зависимостей нет,
на одном сервере                                                 воспроизводить нечего
Учебный скрипт, библиотека            запуск локально            нет внешних сервисов
без внешних сервисов
CLI-утилита для команды               бинарник в релизах         пользователю не нужен
                                                                 демон и registry
Тяжёлая правка кода на macOS          нативный запуск,           bind mount через VM
с тысячами файлов                     БД в контейнере            заметно медленнее
Прод, где команда не умеет            managed-сервисы            непонятная инфра
эксплуатировать контейнеры                                       опаснее понятной

Цена Docker складывается из вполне конкретных пунктов: время сборки образов, место под кэш слоёв, registry и права доступа к нему, отдельная схема сбора логов (stdout вместо файлов), новый класс проблем «а в какой сети этот контейнер». Всё это оправдано, когда сервисов несколько и окружений тоже несколько. Для одного скрипта - нет.

Отдельно про базы данных: в разработке контейнер с PostgreSQL - лучшее, что могло случиться. В проде БД в контейнере тоже работает, но требует дисциплины с volume, бэкапами и обновлениями мажорных версий, и здесь вопрос «а не взять ли managed-БД» абсолютно уместен.

Общее ядро значит, что изоляция слабее, чем у VM. Root внутри контейнера по умолчанию - это тот же UID 0 на хосте, `--privileged` фактически снимает границу, а проброс `/var/run/docker.sock` внутрь контейнера равен выдаче прав root на хост. Не запускай в контейнере недоверенный код и не считай, что «в контейнере - значит безопасно». Практический минимум: non-root пользователь в образе (об этом в уроке про [Dockerfile](./03-dockerfile-basics.md)) и никакого `--privileged` без ясной причины.

Мини-задание

  • Установи Docker Desktop (macOS/Windows) или Docker Engine (Linux)
  • Проверь: docker --version и docker compose version
  • Запусти docker run --rm hello-world - увидишь приветствие от Docker
  • Запусти docker run --rm -it alpine sh - окажешься внутри контейнера Alpine
  • Внутри контейнера: cat /etc/os-release, whoami, exit
  • Сравни uname -r на хосте и в контейнерах alpine и ubuntu - убедись, что ядро одно
  • Запусти три контейнера из одного образа и посмотри docker ps -s - найди разницу между собственным и virtual размером
  • Посмотри docker history nginx:alpine и найди самый тяжёлый слой
  • Узнай архитектуру образа: docker image inspect alpine --format '{{.Os}}/{{.Architecture}}'
  • Создай файл внутри контейнера (docker exec), удали контейнер, запусти новый из того же образа - файла нет

Итог

  • Docker упаковывает приложение + зависимости + ОС в один образ. «Работает у меня» становится «работает везде».
  • Контейнер - не VM: он разделяет ядро хоста, запускается за секунды, весит мегабайты.
  • Архитектура: CLI → Daemon → Container Runtime. Образы хранятся в Registry.
  • OCI-стандарт: навыки переносятся между Docker, Podman, containerd.

Типичная ошибка

Начинающие часто путают образ и контейнер. Собрал образ - он есть в docker images. Запустил контейнер - он в docker ps. Удалил контейнер (docker rm) - образ всё ещё на месте. Удалил образ (docker rmi) - а контейнеры от него могут ещё работать (пока не остановишь).

Вторая ошибка - думать, что Docker заменяет CI/CD, мониторинг и тесты. Docker - это инструмент упаковки, а не волшебная кнопка «в прод».

Мини-практика (10-15 минут)

Запусти три разных контейнера и исследуй их изнутри:

# 1. Alpine - минимальная Linux
docker run --rm -it alpine sh
# Внутри: ls /, cat /etc/os-release, exit

# 2. Ubuntu - знакомый Linux
docker run --rm -it ubuntu bash
# Внутри: apt list --installed | head, exit

# 3. Nginx - рабочий веб-сервер
docker run --rm -d -p 8080:80 --name test-nginx nginx:alpine
# Открой http://localhost:8080 в браузере
docker stop test-nginx

Сравни размеры: docker images | grep -E "alpine|ubuntu|nginx". Заметь разницу - Alpine ~5 МБ, Ubuntu ~70 МБ.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.