Docker и CI: Dockerfile для Python, multi-stage, GitHub Actions

В production Python приложения деплоятся в контейнерах через Docker. Это даёт reproducibility (одинаковое окружение везде), portability (работает на любом хосте с Docker), изоляцию. CI/CD автоматизирует тесты и deploy. В этом уроке - Dockerfile для FastAPI-приложения, multi-stage builds (тот же приём на Go разбирали в треке Docker), GitHub Actions для CI.

Минимальный Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY pyproject.toml .
RUN pip install -e .

COPY . .

CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]

Билд и запуск:

docker build -t myapp .
docker run -p 8000:8000 myapp

Это работает но не оптимально. Проблемы:

  • Все pip пакеты в final image (toolchain)
  • Кеш Docker layers не оптимизирован
  • Запуск от root - security risk

Multi-stage build

Builder stage устанавливает зависимости, final stage только runtime:

# Stage 1: builder
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build deps
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

COPY pyproject.toml ./
RUN pip install --user --no-warn-script-location -e .

# Stage 2: final
FROM python:3.12-slim

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local

# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH

# Copy app code
COPY . .

# Run as non-root
RUN useradd -m -u 1000 app && chown -R app:app /app
USER app

EXPOSE 8000

CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]

Преимущества:

  • Меньший final image (без build tools)
  • Лучше cache (layers разделены)
  • Non-root user (security)

.dockerignore

.git
.venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.coverage
htmlcov
.mypy_cache
.env
node_modules
*.md
tests/

Без .dockerignore Docker копирует ВСЁ в context, включая .git (большой), кеши, тесты. Это замедляет build и раздувает image.

Использование poetry / uv

Если используешь poetry:

FROM python:3.12-slim AS builder

WORKDIR /app
RUN pip install poetry==1.8.0

COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && \
    poetry install --no-dev --no-root

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]

С uv (более быстрый):

FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

COPY . .
CMD [".venv/bin/uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]

docker-compose для development

# docker-compose.yml
version: "3.9"

services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+asyncpg://user:pass@db:5432/myapp
      JWT_SECRET: dev-secret-not-for-prod
    depends_on:
      - db
    volumes:
      - .:/app   # для hot reload в dev

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

Запуск:

docker-compose up        # с логами
docker-compose up -d     # detached
docker-compose down      # stop and remove
docker-compose logs app  # логи сервиса

Удобно для local dev - одной командой поднимается app+БД+зависимости.

Health checks

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

В FastAPI:

@app.get("/health")
def health():
    return {"status": "ok"}

Orchestrators (Kubernetes, Docker Swarm) используют для определения healthy/unhealthy containers. Unhealthy перезапускаются автоматически.

Multi-platform builds

# Build for ARM (Mac M1) и x86_64
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest --push .

В CI обычно собирается под обе платформы для maximum compatibility.

Image размер - best practices

ПодходРазмер
python:3.12~1GB
python:3.12-slim~150MB
python:3.12-alpine~50MB
Multi-stage + slim~150MB но без build tools

Slim хорошее middle ground. Alpine ещё меньше, но musl libc может ломать некоторые C extensions (numpy, pandas builds). Для FastAPI без heavy C extensions - alpine работает.

GitHub Actions CI

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install
        run: pip install -e ".[dev]"

      - name: Lint
        run: ruff check .

      - name: Type check
        run: mypy myapp/

      - name: Test
        env:
          DATABASE_URL: postgresql+asyncpg://postgres:test@localhost:5432/test
        run: pytest --cov=myapp --cov-fail-under=80

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest

Что делает:

  1. На push/PR - запускает тесты (lint + types + pytest)
  2. На push в main - билдит и пушит Docker image в GitHub Container Registry
  3. Можно добавить deploy step после build

Deployment пример (k8s)

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: app
        image: ghcr.io/me/myapp:latest
        ports:
        - containerPort: 8000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: myapp-secrets
              key: database-url
        - name: JWT_SECRET
          valueFrom:
            secretKeyRef:
              name: myapp-secrets
              key: jwt-secret
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
        resources:
          requests:
            cpu: 100m
            memory: 256Mi
          limits:
            cpu: 1000m
            memory: 1Gi

3 реплики, secrets из k8s Secret, health probes, resource limits. Stateful БД отдельно через StatefulSet или managed service (RDS, Cloud SQL).

Миграции в deploy

# k8s/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migrate-{{ .Values.imageTag }}
spec:
  template:
    spec:
      containers:
      - name: alembic
        image: ghcr.io/me/myapp:{{ .Values.imageTag }}
        command: ["alembic", "upgrade", "head"]
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: myapp-secrets
              key: database-url
      restartPolicy: Never

Init container или отдельный Job для миграций. Запускается перед update Deployment. Если миграция fails - блокируется release.

Логи в контейнерах

# Send to stdout/stderr - Docker и k8s сами соберут
import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s: %(message)s',
    stream=sys.stdout,
)

В контейнерах никогда не пиши в файлы. Stdout/stderr - стандарт. Docker logs драйверы (json-file, journald, fluentd, gelf) собирают и роутят в log aggregators (Loki, ELK, Datadog).

Secrets management

Не клади в Dockerfile или env переменные в коде:

ENV API_KEY=secret123    # ПЛОХО - в image, видно через history

Используй:

  • Docker secrets (docker-compose)
  • Kubernetes Secrets
  • Vault, AWS Secrets Manager, GCP Secret Manager
  • Передавай через env vars at runtime, не build time

Распространённые ошибки

1. Big context

docker build .   # tar'ит всё, включая .git, .venv, node_modules

.dockerignore обязателен. Build context должен быть минимальный.

2. COPY перед dependencies install

COPY . .
RUN pip install -e .   # ПЛОХО - cache invalidation на любое изменение в коде

Сначала COPY pyproject.toml, потом install, потом COPY кода. Тогда cache deps работает.

3. Run as root

CMD ["python", "main.py"]   # default root

Создавай user. Container compromise становится lateral movement если root.

4. Latest tag в production

image: myapp:latest

Latest неопределённый - что было закаталось туда последним. Используй конкретные version tags или SHA для reproducibility.

5. Нет healthcheck

Без healthcheck orchestrators не знают что container broken и не restart. Always add.

6. Hardcoded ports

uvicorn.run(app, port=8000)   # фикс порт

Используй env: port=int(os.getenv("PORT", 8000)). PaaS (Heroku, Cloud Run) assign port через env.

Сравнение с Go и PHP

Go Dockerfile обычно мелкий за счёт static binary:

FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app

FROM scratch
COPY --from=builder /app/app /app
CMD ["/app"]

Final image может быть 5-20MB (scratch + один binary). Очень compact.

PHP обычно php:8.3-fpm + nginx. Multi-container setup (compose):

services:
  app:
    image: php:8.3-fpm
  nginx:
    image: nginx

Python между ними. Multi-stage даёт slim images, но всё равно больше Go.

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

  1. Minimal Dockerfile для FastAPI:
FROM python:3.12-slim

WORKDIR /app

COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .

COPY . .

RUN useradd -m app && chown -R app /app
USER app

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t myapp .
docker run -p 8000:8000 myapp
  1. docker-compose с БД:
version: "3.9"
services:
  app:
    build: .
    ports: ["8000:8000"]
    environment:
      DATABASE_URL: postgresql://test:test@db/myapp
    depends_on: [db]
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: myapp
docker-compose up -d
  1. CI с GitHub Actions:
# .github/workflows/test.yml
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -e ".[dev]"
      - run: ruff check .
      - run: pytest

Что дальше

Освоили Docker и CI. В пайплайне выше крутятся ровно те инструменты, что разбирали раньше: pytest и ruff. В следующем уроке - финальный проект: соберём всё что выучили в полноценное приложение - REST API с FastAPI + Pydantic + SQLAlchemy + Alembic + Auth + Tests + Docker.

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