Docker: контейнеризация без магии
Когда я впервые столкнулся с Docker лет пять назад, он казался мне какой-то чёрной магией. Контейнер — это виртуалка? Не совсем. Процесс? Тоже не точно. Образ — это снимок диска? Вроде бы, но не классический. Со временем пришло понимание, что Docker — это не магия, а набор вполне конкретных механизмов Linux: namespaces, cgroups, union filesystem. И когда разберёшься в основах, всё встаёт на свои места.
В этой статье разберём Docker с практической стороны — от написания Dockerfile до отладки падающих контейнеров.
Как устроен Docker
На верхнем уровне архитектура Docker выглядит так: клиент (docker CLI) общается с демоном (dockerd), который управляет образами, контейнерами, сетями и томами. Образ — это набор read-only слоёв файловой системы (union FS), а контейнер добавляет поверх них тонкий writable-слой.
Ключевая идея — слои кэшируются. Если вы не меняли команду в Dockerfile, Docker переиспользует уже собранный слой. Это критически важно для скорости сборки.
Dockerfile: пишем правильно
Начнём с типичной ошибки — Dockerfile для Python-приложения, который делает всё «в лоб»:
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "main.py"] Работает? Да. Проблемы? Полно. Любое изменение в коде инвалидирует кэш слоя COPY . ., и зависимости будут ставиться заново. Финальный образ тянет за собой весь build-инструментарий. Размер — полгигабайта на ровном месте.
Вот как это должно выглядеть:
# --- Build stage ---
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY . .
# --- Runtime stage ---
FROM python:3.12-slim
WORKDIR /app
COPY /install /usr/local
COPY /app .
RUN useradd -r -s /bin/false appuser
USER appuser
HEALTHCHECK
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["python", "main.py"] Что здесь важно:
- Multi-stage build — разделяем сборку и рантайм. В финальный образ попадает только то, что нужно для запуска.
- Порядок COPY — сначала
requirements.txt, потомpip install, и только потомCOPY . .. Зависимости кэшируются отдельно от кода. - slim-образ —
python:3.12-slimвместо полногоpython:3.12. Экономим 600+ МБ. - Non-root user — контейнер не работает из-под root. Базовая безопасность.
- HEALTHCHECK — Docker знает, жив ли контейнер на самом деле, а не просто «процесс запущен».
.dockerignore
Не забывайте про .dockerignore. Без него COPY . . затянет в контекст сборки .git, node_modules, __pycache__ и прочий мусор:
.git
.gitignore
__pycache__
*.pyc
.env
.venv
docker-compose*.yml
Dockerfile
README.md Docker Compose для локальной разработки
Для локальной среды docker-compose — незаменимая вещь. Вот реалистичный пример для бэкенда на FastAPI с PostgreSQL и Redis:
services:
api:
build:
context: .
dockerfile: Dockerfile
target: builder # используем build-stage для dev
volumes:
- .:/app # live reload
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://app:secret@db:5432/myapp
- REDIS_URL=redis://cache:6379/0
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--reload"]
db:
image: postgres:16-alpine
volumes:
- pg_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
pg_data:
redis_data: Обратите внимание на depends_on с condition: service_healthy. Без этого ваш API может стартовать раньше, чем PostgreSQL будет готов принимать соединения. Классическая проблема, которая вылезает раз в пять запусков и отнимает кучу времени на дебаг.
Сети: bridge, host, overlay
Docker создаёт несколько типов сетей:
- bridge (по умолчанию) — изолированная сеть для контейнеров на одном хосте. Каждый compose-проект получает свою bridge-сеть. Контейнеры внутри одной сети обращаются друг к другу по имени сервиса.
- host — контейнер использует сетевой стек хоста напрямую. Полезно для максимальной производительности сети, но теряется изоляция.
- overlay — сеть между несколькими Docker-хостами (Swarm). На практике, если вам нужен multi-host, скорее всего вы уже смотрите в сторону Kubernetes.
Частый вопрос: как связать контейнеры из разных compose-файлов? Самый чистый способ — создать внешнюю сеть:
docker network create shared-net И подключить к ней оба проекта:
services:
api:
networks:
- shared-net
networks:
shared-net:
external: true Тома: named volumes vs bind mounts
Два типа монтирования, два сценария:
- Bind mounts (
./src:/app/src) — для разработки. Код на хосте синхронизирован с контейнером, работает hot reload. Но производительность на macOS с Docker Desktop оставляет желать лучшего (используйте VirtioFS илиconsistency: cached). - Named volumes (
pg_data:/var/lib/postgresql/data) — для данных. Docker управляет хранением, данные переживают пересоздание контейнера, работает быстро на всех платформах.
Золотое правило: исходный код — через bind mount, данные БД и кэши — через named volumes.
Отладка контейнеров
Когда что-то идёт не так (а оно идёт), есть набор команд, который спасает:
# Логи контейнера (последние 100 строк, follow)
docker logs --tail 100 -f container_name
# Зайти внутрь работающего контейнера
docker exec -it container_name /bin/sh
# Ресурсы (CPU, RAM, I/O) в реальном времени
docker stats
# Детальная информация о контейнере (конфиги, маунты, сети)
docker inspect container_name
# Почему контейнер упал?
docker inspect --format='{{.State.ExitCode}}' container_name
docker inspect --format='{{.State.OOMKilled}}' container_name Отдельно про OOMKilled — если контейнер молча падает без ошибок в логах, почти наверняка он вылетел по памяти. Ставьте лимиты в compose через deploy.resources.limits.memory и мониторьте через docker stats.
services:
api:
deploy:
resources:
limits:
memory: 512M
cpus: "0.5" Чего лучше избегать
Несколько антипаттернов, которые я встречаю регулярно:
- Хранить данные в контейнере без volume. Контейнер пересоздался — данные потеряны. Это не баг, это by design.
- Запускать всё от root. Уязвимость в приложении = root-доступ в контейнере. Всегда
USER non-root. - Не ограничивать ресурсы. Один контейнер без лимитов может положить весь хост.
- Использовать
latestтег в продакшене. Сегодняlatest— это v2.1, завтра — v3.0 с breaking changes. Пиннинг версий обязателен. - Игнорировать
.dockerignore. Контекст сборки в 2 ГБ — это реальность, если в проекте естьnode_modulesили.git.
Итог
Docker — это не сложно, если понять несколько ключевых концепций: слои и кэширование, разделение build/runtime, сети и тома. Multi-stage builds, healthcheck-и и ограничение ресурсов — это не «продвинутые техники», а базовая гигиена. Начните с этого, а уже потом переходите к оркестрации — будь то Compose для одного сервера или Kubernetes для кластера.