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
Что делает:
- На push/PR - запускает тесты (lint + types + pytest)
- На push в main - билдит и пушит Docker image в GitHub Container Registry
- Можно добавить 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.
Мини-задание
- 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
- 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
- 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.