Docker: зачем он нужен и почему это не «магия контейнеров»
Docker: зачем он нужен и почему это не «магия контейнеров»
Docker нужен, чтобы перестать играть в игру «у меня работает». Контейнер - это упакованное окружение:
- твоя версия Node/Go
- зависимости
- настройки
- команда запуска
И всё это воспроизводимо на любом компьютере и сервере.
Проблема: «у меня работает»
Знакомая история: ты написал приложение, запустил локально - всё отлично. Коллега клонирует репу - не работает. На сервере - другая ошибка. Причины всегда одни и те же:
- Разные версии - у тебя Go 1.22, на сервере 1.19
- Разные зависимости ОС - libpq не установлена, openssl другой версии
- Разные переменные окружения -
.envзабыли, порт занят - Разные ОС - macOS vs Ubuntu vs Windows, пути к файлам, line endings
Docker решает это: образ содержит всё нужное, и запускается одинаково везде, где есть Docker.
Контейнер vs виртуальная машина
<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-хосте не поедет, и наоборот. Требование «нужно ядро с определённым модулем» тоже решается на хосте, а не в образе.
Архитектура Docker
Docker состоит из трёх частей:
- Docker CLI - команды, которые ты вводишь (
docker run,docker build) - Docker Daemon (
dockerd) - фоновый процесс, управляет контейнерами - Container Runtime (
containerd) - собственно запускает и изолирует процессы
Образ 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}}'
Что образ фиксирует, а что нет
«Работает у меня» побеждается не полностью: образ замораживает не всё окружение. Полезно точно знать границу.
Фиксируется в образе НЕ фиксируется, приходит извне
────────────────────────────────── ────────────────────────────────────────
файлы, бинарники, библиотеки архитектура процессора (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}}' # проверка перед деплоем
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, настроить GOPATH | docker build -t backend . |
| Показать проект коллеге | «Сначала установи Go 1.22, потом...» | docker compose up |
Когда Docker не нужен
Честный разговор: у контейнеризации есть цена, и иногда она выше выгоды.
Ситуация Что дешевле Почему
─────────────────────────────────── ──────────────────────── ─────────────────────
Один статический бинарник Go systemd-юнит на VPS зависимостей нет,
на одном сервере воспроизводить нечего
Учебный скрипт, библиотека запуск локально нет внешних сервисов
без внешних сервисов
CLI-утилита для команды бинарник в релизах пользователю не нужен
демон и registry
Тяжёлая правка кода на macOS нативный запуск, bind mount через VM
с тысячами файлов БД в контейнере заметно медленнее
Прод, где команда не умеет managed-сервисы непонятная инфра
эксплуатировать контейнеры опаснее понятной
Цена Docker складывается из вполне конкретных пунктов: время сборки образов, место под кэш слоёв, registry и права доступа к нему, отдельная схема сбора логов (stdout вместо файлов), новый класс проблем «а в какой сети этот контейнер». Всё это оправдано, когда сервисов несколько и окружений тоже несколько. Для одного скрипта - нет.
Отдельно про базы данных: в разработке контейнер с PostgreSQL - лучшее, что могло случиться. В проде БД в контейнере тоже работает, но требует дисциплины с volume, бэкапами и обновлениями мажорных версий, и здесь вопрос «а не взять ли managed-БД» абсолютно уместен.
Мини-задание
- Установи 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 МБ.