Docker и деплой
Зачем контейнеры
«У меня работает» — главная боль деплоя: другая версия Python, другие системные библиотеки, забытая переменная окружения. Контейнер упаковывает приложение вместе со всем окружением (Python, библиотеки, системные пакеты) в образ, который одинаково запускается на ноутбуке, в CI и на сервере.
Контейнер — это обычный процесс Linux, изолированный механизмами ядра: namespaces (свои процессы, сеть, файловая система) и cgroups (лимиты CPU и памяти). Своего ядра у него нет, поэтому он лёгкий.
- Образ (image) — неизменяемый шаблон из слоёв:
python:3.12-slim,postgres:16. - Контейнер — запущенный экземпляр образа со своим записываемым слоем.
- Реестр (registry) — хранилище образов: Docker Hub, GitHub Container Registry, приватный.
Dockerfile для Python-приложения
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# системные зависимости одним слоем, без кеша apt
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# сначала только зависимости — этот слой кешируется, пока requirements.txt не меняется
COPY requirements.txt .
RUN pip install -r requirements.txt
# потом код — меняется часто
COPY . .
# не запускаем приложение от root
RUN useradd --create-home app
USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
| Инструкция | Что делает |
|---|---|
FROM |
базовый образ; указывайте точный тег (3.12-slim), не latest |
RUN |
выполнить команду при сборке → новый слой |
COPY |
скопировать файлы из контекста сборки (ADD умеет распаковывать архивы и качать URL — обычно не нужен) |
WORKDIR |
текущая папка |
ENV |
переменные окружения |
USER |
от чьего имени запускать |
EXPOSE |
документирует порт (не публикует его) |
CMD / ENTRYPOINT |
команда запуска контейнера |
PYTHONUNBUFFERED=1 — чтобы print и логи сразу шли в stdout контейнера, а не копились в буфере.
Слои и кеш
Каждая инструкция создаёт слой. При пересборке Docker берёт слой из кеша, если инструкция и её входные файлы не менялись, — до первого изменения, после него всё пересобирается.
import hashlib
def build(dockerfile, files, cache):
parent = ""
rebuilt = []
for instr in dockerfile:
inputs = ""
if instr.startswith("COPY"):
src = instr.split()[1]
inputs = "".join(v for k, v in sorted(files.items()) if src == "." or k == src)
key = hashlib.sha256((parent + instr + inputs).encode()).hexdigest()
rebuilt.append(("кеш " if key in cache else "СБОРКА ") + instr)
cache.add(key)
parent = key
return rebuilt
good = ["FROM python:3.12-slim", "COPY requirements.txt .", "RUN pip install -r requirements.txt", "COPY . ."]
bad = ["FROM python:3.12-slim", "COPY . .", "RUN pip install -r requirements.txt"]
files = {"requirements.txt": "django==5.2", "app.py": "print('v1')"}
for name, df in (("правильный порядок", good), ("COPY . . в начале", bad)):
cache = set()
build(df, files, cache)
changed = {**files, "app.py": "print('v2')"} # поменяли одну строку кода
print(name)
print("\n".join(" " + line for line in build(df, changed, cache)))
Во втором случае правка одной строки кода заставляет заново ставить все зависимости — минуты на каждую сборку.
.dockerignore
Всё, что не нужно внутри образа, исключают — сборка быстрее, образ меньше, секреты не утекают:
.git
.venv
__pycache__/
*.pyc
.pytest_cache/
.env
node_modules/
media/
Многоэтапная сборка
Компиляторы и заголовочные файлы нужны для сборки пакетов, но не для работы. Собираем в одном образе, копируем результат в чистый:
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
WORKDIR /app
COPY . .
USER nobody
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Основные команды
docker build -t shop-api:1.4.0 . # собрать образ из Dockerfile в текущей папке
docker images # список образов
docker run -d --name api -p 8000:8000 --env-file .env shop-api:1.4.0 # запустить в фоне
docker ps # запущенные контейнеры (-a — все)
docker logs -f api # логи (stdout/stderr контейнера)
docker exec -it api bash # зайти внутрь работающего контейнера
docker stop api && docker rm api
docker run --rm -it python:3.12 python # одноразовый контейнер с REPL
docker system prune # почистить неиспользуемое
docker push ghcr.io/team/shop-api:1.4.0 # отправить в реестр
-p 8000:8000 — «порт хоста : порт контейнера». Без него сервис внутри недоступен снаружи.
Тома: данные вне контейнера
Контейнер одноразовый: при удалении его файлы пропадают. Данные, которые нужно сохранить, хранят в томах:
- volume — том, которым управляет Docker (
-v pgdata:/var/lib/postgresql/data) — для баз данных; - bind mount — папка хоста (
-v $(pwd):/app) — для разработки с горячей перезагрузкой.
docker compose
Описание нескольких сервисов в одном файле compose.yaml:
services:
web:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
ports: ["8000:8000"]
env_file: .env
environment:
DATABASE_URL: postgresql+psycopg://shop:shop@db:5432/shop # имя сервиса = имя хоста
REDIS_URL: redis://redis:6379/0
volumes: [".:/app"]
depends_on:
db: {condition: service_healthy}
redis: {condition: service_started}
db:
image: postgres:16
environment: {POSTGRES_USER: shop, POSTGRES_PASSWORD: shop, POSTGRES_DB: shop}
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
worker:
build: .
command: celery -A app.worker worker -l info
env_file: .env
depends_on: [db, redis]
volumes:
pgdata:
docker compose up -d # поднять всё в фоне
docker compose logs -f web
docker compose exec web alembic upgrade head # команда внутри сервиса
docker compose down # остановить (с -v — удалить и тома)
Сервисы в одном compose видят друг друга по имени сервиса: из web база доступна как db:5432, а не localhost.
Python в продакшене
- WSGI (Django, Flask) —
gunicornс несколькими воркерами-процессами; ASGI (FastAPI, async Django) —uvicornилиgunicorn -k uvicorn.workers.UvicornWorker.runserver/--reload— только для разработки. - Число воркеров: стартовая точка
2 × CPU + 1для синхронных, затем по замерам. - Перед приложением — обратный прокси (nginx, Traefik, облачный балансировщик): TLS, статика, сжатие, лимиты, раздача на несколько экземпляров.
- Логи — в stdout/stderr, их собирает платформа (Docker, Kubernetes, Loki, ELK). Не пишите в файлы внутри контейнера.
- Health check — эндпоинт
/health(жив ли процесс) и/ready(готов ли принимать трафик: есть ли связь с базой). - Миграции — отдельным шагом деплоя (
alembic upgrade head,manage.py migrate) до переключения трафика. Делайте их обратно совместимыми: старая версия кода должна работать с новой схемой, пока идёт выкатка. - Graceful shutdown: по
SIGTERMдообработать текущие запросы и выйти.
CI/CD
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: {POSTGRES_PASSWORD: test}
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.12", cache: pip}
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: ruff check . && ruff format --check .
- run: mypy app
- run: pytest --cov=app
env: {DATABASE_URL: "postgresql://postgres:test@localhost:5432/postgres"}
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
# push в реестр и выкатка: kubectl / helm / ssh + docker compose pull && up -d
CI (continuous integration) — на каждый push: линтеры, типы, тесты. CD (continuous delivery/deployment) — сборка образа с тегом коммита и автоматическая выкатка. Стратегии выкатки: rolling (по одному экземпляру), blue-green (две копии окружения, переключение трафика), canary (сначала на небольшой процент пользователей).
12-factor и Kubernetes в двух словах
12-factor app — принципы приложения, удобного для облака: конфигурация в переменных окружения, процессы без состояния (сессии и файлы — во внешних сервисах), логи как поток в stdout, одинаковые dev и prod, быстрый старт и корректное завершение.
Kubernetes управляет контейнерами на кластере машин: Pod — один или несколько контейнеров; Deployment держит нужное число реплик и делает rolling update; Service — стабильный адрес и балансировка; Ingress — вход HTTP снаружи; ConfigMap/Secret — конфигурация; liveness/readiness-пробы — те самые /health и /ready. Для бэкенд-разработчика важно понимать эти слова и уметь читать манифест; администрирование кластера — обычно задача DevOps.
Итог
- Образ = слои; порядок инструкций определяет кеш: зависимости раньше кода.
- Точные теги,
slim-образ,.dockerignore, многоэтапная сборка,USERне root,PYTHONUNBUFFERED=1. - compose описывает приложение с базой и кешем; сервисы видят друг друга по имени.
- В продакшене: gunicorn/uvicorn за обратным прокси, логи в stdout, health check, миграции отдельным шагом, CI/CD.
Практика решено 0 из 4
Линтер Dockerfile. Напишите lint_dockerfile(text), возвращающую отсортированный список кодов найденных проблем:
"latest"—FROMбез тега или с тегомlatest(FROM python,FROM python:latest);"add"— используетсяADD(для обычных файлов нуженCOPY);"apt-cache"— в строкеRUNестьapt-get install, но в ней же нетrm -rf /var/lib/apt/lists;"cache-order"—COPY . .(илиCOPY . <куда угодно>) встречается раньше, чемRUN pip install(тогда любое изменение кода сбрасывает кеш установки зависимостей);"root"— нет ни одной инструкцииUSER(контейнер работает от root).
Инструкции регистронезависимы, пустые строки и комментарии # пропускайте. Многострочные RUN с \ в задании не встречаются.
Какие тесты проверяют код (3)
good = """
# хороший Dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER app
CMD ["gunicorn", "config.wsgi"]
"""
assert lint_dockerfile(good) == []
bad = """
from python
add . /app
workdir /app
copy . .
run pip install -r requirements.txt
run apt-get install -y curl
cmd python main.py
"""
assert lint_dockerfile(bad) == ["add", "apt-cache", "cache-order", "latest", "root"]
assert lint_dockerfile("FROM python:latest\nUSER app") == ["latest"]
assert lint_dockerfile("FROM ghcr.io/org/base:1.2\nUSER app") == []
assert lint_dockerfile("FROM localhost:5000/python\nUSER app") == ["latest"]
assert lint_dockerfile("FROM python:3.12\nRUN pip install x\nCOPY . /srv\nUSER app") == []
def lint_dockerfile(text):
problems = set()
copied_all = False
has_user = False
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
instr, _, args = line.partition(" ")
instr, args = instr.upper(), args.strip()
if instr == "FROM":
image = args.split()[0]
name = image.rsplit("/", 1)[-1]
if ":" not in name or name.endswith(":latest"):
problems.add("latest")
elif instr == "ADD":
problems.add("add")
elif instr == "USER":
has_user = True
elif instr == "COPY":
if args.split()[0] == ".":
copied_all = True
elif instr == "RUN":
if "apt-get install" in args and "rm -rf /var/lib/apt/lists" not in args:
problems.add("apt-cache")
if "pip install" in args and copied_all:
problems.add("cache-order")
if not has_user:
problems.add("root")
return sorted(problems)
Почему в Dockerfile сначала копируют requirements.txt и ставят зависимости, а только потом копируют весь код?
Что использовать?
| volume | bind mount | переменная окружения | слой образа | |
|---|---|---|---|---|
| Данные PostgreSQL, которые должны пережить пересоздание контейнера | ||||
| Код на машине разработчика, чтобы изменения сразу были видны в контейнере | ||||
| Пароль к базе для продакшена | ||||
| Установленные Python-зависимости |
Какой командой docker compose поднимают все сервисы в фоне? (без слова docker)
Войдите, чтобы отмечать пройденные уроки.