Python Деплой, наблюдаемость, SDLC и лучшие ресурсы по Python
0%

Деплой, наблюдаемость, SDLC и лучшие ресурсы по Python

Деплой, наблюдаемость, SDLC и лучшие ресурсы по Python

Шестнадцать статей назад мы начали с байт-кода и модели данных. Сейчас у нас есть сервис: он разложен по слоям (https://courses.digitable.life/post/python/16-architecture-production/), покрыт тестами (https://courses.digitable.life/post/python/12-testing/), типизирован (https://courses.digitable.life/post/python/07-typing/) и собран в пакет (https://courses.digitable.life/post/python/09-modules-and-packaging/). Осталась часть, которая отделяет «работает у меня» от «работает у ста тысяч пользователей, а когда не работает — вы знаете почему за пять минут».

У Python здесь есть неприятная особенность, о которой стоит сказать сразу. Go отдаёт статический бинарник: скопировал файл — запустил. Java отдаёт JAR под известную JVM. Python не отдаёт ничего самодостаточного. Артефакт Python-приложения — это всегда кортеж «интерпретатор конкретной версии + дерево зависимостей + скомпилированные под конкретную libc C-расширения + переменные окружения». Любой рассинхрон в этом кортеже даёт классическое ImportError: /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.34 not found в три часа ночи. Поэтому деплой Python в 2020-х почти всегда означает контейнер: это единственный дешёвый способ зафиксировать весь кортеж целиком.

Общая теория CI/CD, Kubernetes и облаков разобрана в треке «DevOps», а процессная сторона — в «SDLC и карьера». Здесь — только то, что специфично для Python, и то, обо что режутся именно Python-команды.

Что мы, собственно, деплоим

Прежде чем писать Dockerfile, полезно понимать, какие вообще бывают артефакты и почему почти всегда выбирают образ.

Практические выводы из этой развилки:

  • Wheel — артефакт библиотеки, а не сервиса. Он не фиксирует версии зависимостей (только диапазоны) и не фиксирует интерпретатор. Для сервиса этого мало.
  • zipapp/shiv/PEX имеют смысл там, где контейнеры недоступны: внутренние CLI на парке машин с одинаковым Python. shiv умеет паковать даже C-расширения, но требует совпадения платформы.
  • PyInstaller и Nuitka — для десктопа и для «отдать заказчику exe». Дают 60–150 МБ, ломаются на динамических импортах (importlib.import_module по строке из конфига анализатор не увидит) и требуют явных --hidden-import.
  • Образ — дефолт для серверного Python. Дальше говорим про него.

Dockerfile, который не стыдно

Разница между наивным и грамотным образом — это 1,8 ГБ против 340 МБ и 6 минут сборки против 20 секунд на типичном коммите.

Слои Docker-образа Python-сервиса: наивная однослойная сборка против multi-stage

# syntax=docker/dockerfile:1.7
# ---------- Стадия сборки: здесь можно всё, она не попадёт в финал ----------
FROM python:3.12-slim-bookworm AS builder

# Версию uv пиньте явно: :latest ломает воспроизводимость сборки.
COPY --from=ghcr.io/astral-sh/uv:0.8.4 /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=never

WORKDIR /app

# Сначала только манифесты — слой зависимостей зависит ТОЛЬКО от них.
# --no-install-project: сам проект ставим отдельно, чтобы правка кода
# не инвалидировала слой с внешними пакетами.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-install-project

# Теперь код — самый верхний и самый лёгкий слой.
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

# ---------- Финальная стадия: только рантайм ----------
FROM python:3.12-slim-bookworm AS runtime

# Непривилегированный пользователь: в контейнере по умолчанию root,
# и это первое, что найдёт любой аудит безопасности.
RUN groupadd --system --gid 1001 app \
    && useradd --system --uid 1001 --gid app --create-home app

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONFAULTHANDLER=1 \
    PYTHONHASHSEED=random

WORKDIR /app
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/src /app/src

USER app
EXPOSE 8000

# exec-форма CMD обязательна: иначе PID 1 — это /bin/sh,
# который не пробрасывает SIGTERM в питон, и k8s убьёт под по таймауту.
CMD ["gunicorn", "app.main:app", "-c", "/app/src/app/gunicorn_conf.py"]

Что здесь важно и почему:

Приём Что даёт Что будет без него
Два uv sync (сначала без проекта) слой зависимостей кешируется правка одной строки пересобирает 700 МБ
--mount=type=cache кеш колёс между сборками, но не в слое +200 МБ мусора в образе
UV_COMPILE_BYTECODE=1 .pyc готовы заранее первый запрос после старта медленнее на сотни мс, а на read-only FS кеш не пишется вообще
PYTHONUNBUFFERED=1 логи в stdout сразу логи появляются пачками при падении, а иногда теряются
PYTHONFAULTHANDLER=1 трейсбек при segfault и по сигналу при падении C-расширения — молчание
exec-форма CMD питон получает SIGTERM нет graceful shutdown, оборванные запросы
USER app контейнер не от root эскалация при любой RCE

Отдельно про alpine. Соблазн понятен: базовый образ 8 МБ вместо 80. Но Alpine использует musl вместо glibc, а колёса на PyPI собираются под стандарт manylinux (PEP 600), то есть под glibc. Формат musllinux (PEP 656) существует, но выкладывают такие колёса далеко не все. Итог: pip install pandas на alpine начинает собирать pandas из исходников, тянет за собой gcc и заголовки, идёт десять минут и даёт образ больше, чем slim. Разбор с цифрами — у Итамара Тёрнер-Траурига, Using Alpine can make Python Docker builds 50x slower. Дефолт: python:3.12-slim-bookworm.

.dockerignore не менее важен, чем сам Dockerfile:

.git
.venv
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.env
*.sqlite3
notebooks/

Без него COPY . . затаскивает в образ историю git (часто сотни мегабайт), локальный .venv с чужими бинарниками под другую libc и .env с продовыми секретами.

Модель процессов в проде: сколько воркеров и почему

Это точка, где GIL из https://courses.digitable.life/post/python/11-concurrency/ превращается в строку в конфиге. Один процесс CPython исполняет байт-код в один поток. Значит, утилизация N ядер — это N процессов, и деплой Python-сервиса всегда включает менеджер процессов.

# src/app/gunicorn_conf.py — конфиг gunicorn как обычный питон-модуль
import multiprocessing
import os

def _cpu_quota() -> float:
    """Реальный лимит CPU: os.cpu_count() видит ядра ХОСТА, а не лимит cgroup.

    В Kubernetes с `limits.cpu: 2` на 64-ядерной ноде os.cpu_count() вернёт 64,
    формула 2*N+1 даст 129 воркеров, и под умрёт по OOM ещё на старте.
    """
    try:
        with open("/sys/fs/cgroup/cpu.max") as f:          # cgroup v2
            quota, period = f.read().split()
        if quota != "max":
            return int(quota) / int(period)
    except FileNotFoundError:
        pass
    return float(os.cpu_count() or 1)

_cpus = max(1, int(_cpu_quota()))

bind = "0.0.0.0:8000"
# Для ASGI: пакет uvicorn-worker (с 2024 года вынесен из uvicorn).
worker_class = "uvicorn_worker.UvicornWorker"
# Асинхронный воркер сам держит тысячи соединений — по воркеру на ядро.
# Для синхронного worker_class="sync" берут 2*ядра+1, но каждый воркер
# обрабатывает ровно один запрос за раз.
workers = int(os.getenv("WEB_CONCURRENCY", _cpus))

# Плановая переработка воркера: страховка от медленных утечек памяти
# в C-расширениях, которые нечем починить быстро. Джиттер — чтобы
# воркеры не перезапустились все одновременно.
max_requests = 2000
max_requests_jitter = 200

# Сколько ждать завершения текущих запросов после SIGTERM.
# Должно быть МЕНЬШЕ terminationGracePeriodSeconds в манифесте пода.
graceful_timeout = 25
timeout = 30
keepalive = 5

accesslog = "-"          # в stdout, разбирать будет сборщик логов
errorlog = "-"
logconfig_dict = {"version": 1, "disable_existing_loggers": False}

def child_exit(server, worker):
    """Нужен для prometheus_client в multiprocess-режиме (см. ниже)."""
    from prometheus_client import multiprocess
    multiprocess.mark_process_dead(worker.pid)

Три грабли, на которых спотыкаются практически все.

Первая — память. Каждый воркер это полноценный интерпретатор со своей кучей. Сервис на FastAPI + pydantic + sqlalchemy стартует с 120–200 МБ RSS. Восемь воркеров — это 1,5 ГБ до первого запроса. preload_app = True в gunicorn загружает приложение до fork(), и страницы вроде бы разделяются через copy-on-write… но не разделяются: при любом обращении к объекту CPython меняет ob_refcnt прямо в его заголовке, страница становится грязной и копируется. Инстаграм решал это вызовом gc.freeze() сразу после загрузки — он переносит все существующие объекты в «вечное» поколение, чтобы сборщик их не трогал (Dismissing Python Garbage Collection at Instagram). Приём рабочий, но проверяйте эффект замером, а не верой.

Вторая — preload_app и соединения. Если приложение при импорте создаёт пул соединений к БД, после fork() все воркеры унаследуют одни и те же сокеты и будут писать в них вперемешку. Пулы, event loop и любые файловые дескрипторы создавайте после форка — в хуке post_fork или в lifespan-событии приложения.

Третья — таймауты. timeout в gunicorn для sync-воркеров означает «воркер не отвечал арбитру N секунд — убить». Для асинхронного воркера это почти всегда значит, что кто-то заблокировал event loop синхронным вызовом, и тогда убийство уносит все одновременно обрабатываемые запросы, а не только виновный.

Graceful shutdown: что происходит при выкатке

Самая частая жалоба «после деплоя у пользователей 502» лечится не магией, а пониманием порядка событий.

Рецепт, который убирает 502:

  1. preStop: sleep 10 в манифесте пода — контейнер продолжает обслуживать запросы, пока балансировщик выпиливает его из своих таблиц. Это единственный надёжный способ победить гонку удаления endpoint.
  2. terminationGracePeriodSeconds > preStop sleep + graceful_timeout gunicorn.
  3. Readiness-проба начинает возвращать ошибку сразу по флагу «выключаемся».
  4. В приложении — обработчик, который дожидается фоновых задач:
import asyncio
import contextlib
import signal
from collections.abc import AsyncIterator

from fastapi import FastAPI

class Lifecycle:
    """Флаг готовности + учёт фоновых задач, которые нельзя рвать."""
    def __init__(self) -> None:
        self.ready = False
        self._tasks: set[asyncio.Task[None]] = set()

    def spawn(self, coro) -> None:
        task = asyncio.create_task(coro)
        self._tasks.add(task)
        # Без сильной ссылки задача может быть собрана GC прямо на лету —
        # известная ловушка asyncio.create_task.
        task.add_done_callback(self._tasks.discard)

    async def drain(self, timeout: float = 20.0) -> None:
        if not self._tasks:
            return
        _done, pending = await asyncio.wait(self._tasks, timeout=timeout)
        for task in pending:
            task.cancel()          # не дождались — отменяем явно
        with contextlib.suppress(asyncio.CancelledError):
            await asyncio.gather(*pending, return_exceptions=True)

lifecycle = Lifecycle()

@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # Пулы, клиенты, соединения создаём здесь — уже после fork().
    await open_db_pool()
    lifecycle.ready = True
    try:
        yield
    finally:
        lifecycle.ready = False    # readiness отваливается первым
        await lifecycle.drain()
        await close_db_pool()

app = FastAPI(lifespan=lifespan)

Healthchecks: три пробы, которые часто путают

# Фрагмент манифеста Deployment
startupProbe:            # «приложение вообще поднялось?»
  httpGet: {path: /healthz, port: 8000}
  failureThreshold: 30   # даём до 150 с на прогрев: импорты, миграции, кеши
  periodSeconds: 5
livenessProbe:           # «процесс жив или завис?» — перезапуск при провале
  httpGet: {path: /healthz, port: 8000}
  periodSeconds: 10
  timeoutSeconds: 2
readinessProbe:          # «можно слать трафик?» — исключение из endpoints
  httpGet: {path: /readyz, port: 8000}
  periodSeconds: 5
@app.get("/healthz", status_code=200)
async def healthz() -> dict[str, str]:
    """Liveness: НИЧЕГО не проверяет во внешнем мире.

    Классическая катастрофа: liveness ходит в БД. База на минуту тормозит,
    k8s считает все поды мёртвыми, перезапускает весь Deployment,
    после старта они синхронно бьют по уже страдающей базе — и всё ложится
    окончательно. Liveness отвечает ровно на вопрос «интерпретатор жив».
    """
    return {"status": "ok"}

@app.get("/readyz")
async def readyz(response: Response) -> dict[str, object]:
    """Readiness: проверяет зависимости, но с жёстким таймаутом и кешем."""
    checks = {"db": await ping_db(timeout=0.5), "cache": await ping_redis(timeout=0.3)}
    ok = lifecycle.ready and all(checks.values())
    response.status_code = 200 if ok else 503
    return {"ready": ok, "checks": checks}

Миграции БД: где их запускать

Ошибка №1 — запускать alembic upgrade head в entrypoint контейнера. При десяти репликах десять процессов одновременно берут блокировку на таблицу версий, девять ждут, один падает по таймауту, под уходит в CrashLoopBackOff. Миграции — отдельный шаг релиза: init-контейнер с podAntiAffinity, отдельный Job, или ручной шаг пайплайна для рискованных изменений.

Ошибка №2 — считать, что миграция и код выкатываются одновременно. Во время rolling update старые и новые поды работают одновременно с одной схемой. Отсюда паттерн expand–contract: любое ломающее изменение разбивается на несколько релизов.

Между «релизом 3» и «удалить колонку» специально оставлен зазор: пока старая колонка жива, откат на предыдущую версию кода безопасен. Подробнее про схемы и блокировки — в треке «Базы данных», про стратегии выкатки — в «CD и стратегии релизов».

Наблюдаемость: логи, метрики, трассы

Три сигнала отвечают на разные вопросы, и подменять один другим дорого.

Сигнал Отвечает на вопрос Стоимость Python-инструмент
Метрики «сколько и как быстро?» копейки, кардинальность ограничена prometheus_client, OTel metrics
Логи «что именно произошло в этом случае?» дорого при высоком RPS logging + structlog
Трассы «где ушло время и кто виноват?» дорого, спасает сэмплирование opentelemetry-sdk

Waterfall трассы одного HTTP-запроса с N+1 запросами к БД

Логи: logging из stdlib никуда не делся

Полная настройка модуля разобрана в https://courses.digitable.life/post/python/10-stdlib/, здесь — продовые детали.

import logging.config

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
                 "format": "%(asctime)s %(levelname)s %(name)s %(message)s"},
    },
    "handlers": {
        # Сам обработчик пишет в stdout, но НЕ из рабочего потока.
        "stdout": {"class": "logging.StreamHandler", "formatter": "json"},
        # QueueHandler отдаёт запись в очередь, а листенер пишет в фоне.
        # Без этого write(2) в переполненный stdout блокирует event loop
        # и добавляет миллисекунды к каждому запросу.
        "queue": {"class": "logging.handlers.QueueHandler",
                  "handlers": ["stdout"], "respect_handler_level": True},
    },
    "root": {"level": "INFO", "handlers": ["queue"]},
    "loggers": {
        # Шумные библиотеки глушим адресно, а не понижением root.
        "urllib3": {"level": "WARNING"},
        "sqlalchemy.engine": {"level": "WARNING"},
    },
}
logging.config.dictConfig(LOGGING)

Конфигурация QueueHandler прямо из dictConfig появилась в Python 3.12 (документация); до 3.12 очередь собирают руками через QueueListener.

Корреляция запросов — то, ради чего логи вообще читают. contextvars из stdlib работает и в asyncio (контекст копируется при создании задачи), и в потоках:

import contextvars, logging, uuid

from opentelemetry import trace

request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")

class ContextFilter(logging.Filter):
    """Подмешивает request_id и OTel-идентификаторы в каждую запись."""
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id.get()
        span = trace.get_current_span().get_span_context()
        record.trace_id = format(span.trace_id, "032x") if span.is_valid else "-"
        return True

@app.middleware("http")
async def add_request_id(request, call_next):
    rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex
    token = request_id.set(rid)          # set возвращает токен для reset
    try:
        response = await call_next(request)
        response.headers["X-Request-ID"] = rid
        return response
    finally:
        request_id.reset(token)          # иначе значение утечёт в соседний запрос

Грабли логирования, которые видно в каждом втором проекте:

  • logger.info(f"user {user_id} paid {amount}") — f-строка форматируется всегда, даже если уровень INFO выключен, и вы теряете возможность группировать записи по шаблону. Правильно: logger.info("user %s paid %s", user_id, amount).
  • logging.warning(...) на уровне модуля вместо logging.getLogger(__name__) — все записи уходят в root, настроить их отдельно уже нельзя.
  • except Exception: logger.error(str(e)) — теряется трейсбек. Нужно logger.exception("не удалось провести платёж") внутри except.
  • Логирование PII: телефоны, токены, тела запросов. Это уже вопрос комплаенса, см. «Приватность и комплаенс».

Метрики: главная ловушка — множество процессов

prometheus_client хранит счётчики в памяти процесса. Запустили восемь воркеров gunicorn — и /metrics попадает в случайный воркер, отдавая одну восьмую правды.

# Обязательно: переменная окружения PROMETHEUS_MULTIPROC_DIR указывает
# на пустой (!) каталог в tmpfs, куда воркеры пишут mmap-файлы.
from prometheus_client import (
    CONTENT_TYPE_LATEST, CollectorRegistry, Histogram, generate_latest, multiprocess,
)

REQUEST_TIME = Histogram(
    "http_request_duration_seconds",
    "Длительность обработки HTTP-запроса",
    labelnames=("method", "route", "status"),
    # Бакеты подбирают под SLO, а не берут дефолтные:
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
)

@app.get("/metrics")
def metrics() -> Response:
    registry = CollectorRegistry()
    multiprocess.MultiProcessCollector(registry)   # склеивает файлы всех воркеров
    return Response(generate_latest(registry), media_type=CONTENT_TYPE_LATEST)

Правила, которые экономят месяцы:

  • Каталог PROMETHEUS_MULTIPROC_DIR чистят на старте контейнера, иначе файлы мёртвых воркеров копятся и портят суммы (документация режима).
  • Никогда не кладите в лейбл user_id, order_id, полный URL с параметрами. Это взрыв кардинальности: миллион серий вместо десяти, и Prometheus умирает. Лейбл route — это шаблон /orders/{id}, а не /orders/8172.
  • Считайте по RED: Rate, Errors, Duration — для сервисов; USE (Utilization, Saturation, Errors) — для ресурсов. Отдельно смотрите на длину очереди задач и на возраст самой старой задачи.

Трассы: OpenTelemetry

Автоинструментация покрывает 80 % потребностей без единой строки кода:

uv add opentelemetry-distro opentelemetry-exporter-otlp
uv run opentelemetry-bootstrap -a install     # доставит инструментации под ваши библиотеки

export OTEL_SERVICE_NAME=orders-api
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=prod,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.05           # 5 % трасс

# Обёртка подменяет импорты и вешает хуки на FastAPI, requests, psycopg, redis...
uv run opentelemetry-instrument gunicorn app.main:app -c gunicorn_conf.py

Ручные спаны нужны там, где интересна доменная операция, а не библиотечный вызов:

from opentelemetry import trace

tracer = trace.get_tracer(__name__)

async def calc_total(order: Order) -> Money:
    with tracer.start_as_current_span("order.calc_total") as span:
        span.set_attribute("order.items_count", len(order.items))
        span.set_attribute("order.customer_tier", order.customer.tier)
        try:
            total = await _calc(order)
        except PricingError as exc:
            span.record_exception(exc)                      # трейсбек в спане
            span.set_status(trace.StatusCode.ERROR, str(exc))
            raise
        span.set_attribute("order.total_minor", total.minor_units)
        return total

Практика: атрибуты — низкой кардинальности и осмысленные (customer_tier, а не customer_id); имена спанов — тоже шаблоны, иначе бэкенд не сможет их агрегировать. Официальные материалы — OpenTelemetry Python и семантические соглашения. Общая методология алертов и дежурств — в «Наблюдаемость и on-call».

Диагностика живого процесса: суперсила Python

Здесь Python внезапно выигрывает у компилируемых языков. Интерпретатор хранит фреймы в куче, поэтому стек любого потока можно прочитать снаружи, ничего не перезапуская.

# Что процесс делает прямо сейчас? Не нужен ни рестарт, ни флаг сборки.
py-spy dump --pid 1                    # снимок стеков всех потоков
py-spy top --pid 1                     # «top» по функциям в реальном времени
py-spy record --pid 1 -d 30 -o prof.svg --subprocesses   # флеймграф за 30 с

# В Kubernetes процессу нужны права на ptrace:
#   securityContext: {capabilities: {add: ["SYS_PTRACE"]}}
# либо ephemeral-контейнер с shareProcessNamespace: true.

memray attach 1 -o leak.bin            # профиль аллокаций живого процесса
memray flamegraph leak.bin             # где именно течёт память

Плюс встроенный аварийный люк, который не требует вообще ничего:

import faulthandler, signal
# Теперь `kill -USR1 <pid>` печатает стеки всех потоков в stderr.
# Работает даже когда процесс висит в дедлоке и не отвечает на HTTP.
faulthandler.register(signal.SIGUSR1, all_threads=True)

Инструменты: py-spy, memray, Sentry для агрегации исключений с привязкой к релизу. Профилирование в спокойной обстановке — тема https://courses.digitable.life/post/python/13-performance/ и трека «Производительность».

Безопасность цепочки поставок

Средний Python-сервис тянет 40 прямых зависимостей и 300 транзитивных. Каждая из них исполняет ваш код в вашем проде с вашими секретами. Это, а не SQL-инъекции, сегодня главный вектор.

Что делает pip install со sdist: скачивает архив и выполняет setup.py — то есть произвольный код, ещё до того как что-то импортировано. Отсюда набор дисциплин:

# 1. Лок-файл с хешами. uv.lock содержит их из коробки;
#    для pip-мира — pip-compile --generate-hashes.
uv lock
uv sync --frozen                 # падает, если lock разошёлся с pyproject

# 2. Только бинарные колёса: setup.py не выполняется вовсе.
uv pip install --only-binary=:all: -r requirements.txt

# 3. Аудит известных уязвимостей (данные из PyPA Advisory DB и OSV).
uv tool run pip-audit --strict

# 4. SBOM для комплаенса и для быстрого ответа «а мы затронуты?».
uv tool run cyclonedx-py environment -o sbom.json

Реальные инциденты, чтобы это не звучало теоретически: подмена зависимости torchtriton в ночных сборках PyTorch в декабре 2022 — атака через dependency confusion, пакет с тем же именем на публичном PyPI перебил внутренний индекс (разбор от PyTorch). Плюс регулярные тайпсквоттинг-волны: python-dateutil против python3-dateutil, requests против request.

Отдельно — публикация. Долгоживущий API-токен PyPI в секретах CI это то, что утекает. С 2023 года есть Trusted Publishing: CI получает короткоживущий OIDC-токен, привязанный к конкретному репозиторию и workflow (docs.pypi.org/trusted-publishers), а PEP 740 добавляет к артефактам подписанные аттестации происхождения. Глубже — в «Безопасность цепочки поставок».

CI/CD-пайплайн Python-проекта

Ключевая идея — порядок по стоимости: то, что ловит 80 % ошибок за 20 секунд, идёт первым. ruff проверяет большой проект за доли секунды, mypy — за единицы секунд с кешем, и только потом запускается всё остальное.

# .github/workflows/ci.yml — минимальный, но честный пайплайн
name: ci
on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true                 # кеш колёс между запусками
          cache-dependency-glob: "uv.lock"
      - run: uv sync --frozen --all-extras --dev
      - run: uv run ruff check --output-format=github .
      - run: uv run ruff format --check .
      - run: uv run mypy src/
      - run: uv run pytest -q --cov=src --cov-report=xml

  matrix:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv run --python ${{ matrix.python }} --frozen pytest -q

  publish:
    needs: [quality, matrix]
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write                        # OIDC для Trusted Publishing
      attestations: write
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv build
      - uses: pypa/gh-action-pypi-publish@release/v1   # без единого токена в секретах

Матрица версий нужна не только библиотекам. Она отвечает на вопрос «сможем ли мы обновиться на 3.13 в следующем квартале» — и отвечает сейчас, а не в момент, когда 3.11 уйдёт с поддержки. Календарь релизов зафиксирован в PEP 745 и на странице статусов версий: годовой цикл релизов, пять лет поддержки, из них два — исправление любых багов.

Ветвление и версии

Trunk-based с короткоживущими ветками и тегами-релизами — дефолт для сервисов; подробнее про стратегии в «Ветвление в Git». Версию не дублируйте руками в трёх местах — берите её из метаданных установленного пакета:

from importlib.metadata import PackageNotFoundError, version

try:
    __version__ = version("orders-api")     # то, что записано в дистрибутиве
except PackageNotFoundError:                # запуск из исходников без установки
    __version__ = "0.0.0.dev0"

А саму версию в pyproject.toml генерируйте из git-тега (hatch-vcs или setuptools-scm), чтобы «версия в API», «тег образа» и «git sha» всегда сходились. Для библиотек — SemVer; для внутренних сервисов часто удобнее CalVer вида 2026.7.3, потому что «ломающее изменение» у сервиса определяется контрактом API, а не номером.

Чек-лист production-ready Python-сервиса

Сборка

  • Multi-stage Dockerfile на -slim, финальный образ без компилятора
  • Байт-код скомпилирован на этапе сборки (UV_COMPILE_BYTECODE=1)
  • USER app, read-only rootfs, .dockerignore на месте
  • Образ помечен git sha, версией и OCI-лейблами; тег latest в проде не используется

Рантайм

  • Число воркеров считается от cgroup-лимита, а не от os.cpu_count()
  • Пулы и соединения создаются после fork() / в lifespan
  • exec-форма CMD, обработка SIGTERM, preStop и terminationGracePeriodSeconds
  • Все внешние вызовы с таймаутом; ретраи с экспоненциальным откатом и джиттером
  • Заданы limits.memory; известно, сколько RSS ест один воркер под нагрузкой

Наблюдаемость

  • Логи в stdout, JSON, с request_id и trace_id в каждой записи
  • RED-метрики с бакетами под SLO и без высококардинальных лейблов
  • Трассировка включена, сэмплирование настроено, ошибки сэмплируются всегда
  • faulthandler на сигнале, у команды есть право на py-spy в проде
  • Алерты на симптомы (ошибки, latency, насыщение очередей), а не на причины

Процесс

  • uv.lock в репозитории, CI падает при рассинхроне
  • pip-audit в пайплайне, обновления через Dependabot/Renovate
  • Матрица версий Python, включая следующую
  • Миграции — отдельный шаг, схема совместима на два релиза
  • Публикация через Trusted Publishing, без долгоживущих токенов

Типичные ошибки уровня процесса

  1. «Соберём образ на проде из git». Артефакт должен быть один и тот же от staging до прода. Пересборка = другой набор транзитивных зависимостей.
  2. pip install без лока в Dockerfile. Сборка сегодня и через месяц дают разный софт. Воспроизводимость начинается с лок-файла.
  3. Разные версии Python локально и в CI. Ловится requires-python в pyproject.toml и .python-version для uv.
  4. Секреты в образе. ENV DATABASE_URL=... остаётся в слоях навсегда, даже если переопределён позже. Секреты — только через окружение или secret-mount.
  5. Логи в файл внутри контейнера. Контейнер эфемерен: логи умирают вместе с ним, а диск переполняется. Только stdout.
  6. Алерт на каждый ERROR. Через неделю дежурный перестаёт их читать. Алертят на бюджет ошибок и SLO.
  7. assert для проверки бизнес-инвариантов. С python -O ассерты вырезаются компилятором, и проверка исчезает. Только явные if ...: raise — см. https://courses.digitable.life/post/python/08-errors-and-exceptions/.
  8. Один воркер на всё. Веб-воркеры и фоновые задачи (Celery, ARQ, Dramatiq) деплоят раздельно: у них разные профили нагрузки, разные лимиты и разная критичность.

Честно: где Python в проде выигрывает и где проигрывает

Это последняя статья трека, поэтому итог без комплиментов.

Выигрывает там, где узкое место — скорость изменений, а не машинное время. Продуктовые API, внутренние сервисы, ETL и оркестрация, весь ML/AI-контур, автоматизация и инфраструктурные утилиты. Экосистема покрывает почти всё, а «слишком медленно» чинится спуском на слой ниже — numpy, Rust-расширение, вынос горячего цикла в C.

Проигрывает по трём осям.

Ресурсы. 150–200 МБ RSS на воркер и потребность в нескольких процессах на ядро делают Python дорогим на масштабе: там, где Go занимает один под, Python занимает три-четыре. На тысячах инстансов это уже статья бюджета.

Старт и упаковка. Импорт крупного приложения — сотни миллисекунд, а с pandas и torch секунды. Для serverless с холодными стартами и для CLI, которые запускают в цикле, это больно. Артефакт несамодостаточен — отсюда весь этот разговор про образы.

Гарантии. Компилятор не ловит ничего. Опечатка в имени атрибута доживает до прода, если нет теста или mypy. Это компенсируется дисциплиной, но дисциплина — расходуемый ресурс: на команде из тридцати человек без строгого mypy и CI-ворот код деградирует быстрее, чем в языках с настоящей системой типов.

Куда не стоит тащить: hard real-time и системы с жёсткими гарантиями задержки; высоконагруженные прокси и сетевые дата-плейны; библиотеки, которые должны потреблять чужие экосистемы (JVM, .NET, браузер); мобильные и десктопные приложения массовой дистрибуции; всё, где стоимость инстанса доминирует над стоимостью разработчика. В этих нишах смотрите на https://courses.digitable.life/post/golang/00-overview/, https://courses.digitable.life/post/rust/00-overview/ или https://courses.digitable.life/post/csharp/00-overview/.

Зрелая позиция звучит так: Python — отличный язык интеграции и итерации и посредственный язык экономии ресурсов. Большинство систем упирается в первое, поэтому он и занимает своё место. Проблемы начинаются, когда команда не замечает момент, где приоритеты поменялись.

Куда смотреть дальше: карта ресурсов

Официальные источники — обязательный минимум

  • docs.python.org/3 — не только туториал: разделы Language Reference (семантика языка) и Data model — самые недооценённые страницы в мире Python.
  • What’s New in Python — читайте перед каждым обновлением: раздел «Deprecated» и «Removed» экономит день отладки.
  • PEP Index — первоисточник по любой фиче. Ключевые для практики: PEP 8 (стиль), PEP 20 (Zen), PEP 484 и PEP 695 (типы), PEP 517 и PEP 621 (сборка), PEP 703 (free-threading).
  • Python Developer’s Guide — как устроен сам CPython, как его собрать и как отправить туда патч.
  • Python Packaging User Guide — канонические ответы про wheels, метаданные и публикацию.
  • typing.readthedocs.io — спецификация системы типов.
  • discuss.python.org — место, где язык обсуждают до того, как он меняется.

Книги, которые действительно меняют уровень

  • Luciano Ramalho, Fluent Python, 2nd ed. — главная книга по идиоматике и модели данных. Если читать одну — эту.
  • Brett Slatkin, Effective Python, 3rd ed. — сто с лишним коротких правил «делай так, а не так», каждое с обоснованием.
  • Harry Percival, Bob Gregory, Architecture Patterns with Python — бесплатна онлайн, лучший текст про слои, порты и тестируемость именно на Python.
  • Micha Gorelick, Ian Ozsvald, High Performance Python, 2nd ed. — профилирование, numpy, Cython, PyPy на реальных задачах.
  • Patrick Viafore, Robust Python — про типы и структуру кода в больших командах.
  • Anthony Shaw, CPython Internals — если хочется дочитать до конца то, что мы начали в https://courses.digitable.life/post/python/02-data-model/.
  • David Beazley, Python Distilled — плотный, без воды пересказ языка от автора легендарных докладов про GIL.

Блоги и люди, за которыми стоит следить

Регулярное чтение и слушание

Как учиться дальше, чтобы это работало

Чтение статей само по себе не двигает уровень. Работает другое:

  1. Читайте исходники stdlib. dataclasses.py, contextlib.py, functools.py, enum.py — это несколько тысяч строк образцовой идиоматики, лежащих у вас на диске.
  2. Опубликуйте один пакет на PyPI. Даже крошечный: вы за день узнаете про сборку, метаданные, версии и CI больше, чем из десяти статей.
  3. Профилируйте свой код. Не чужой бенчмарк, а свой сервис под своей нагрузкой. Интуиция об узких местах ошибается примерно всегда.
  4. Возьмите issue с меткой easy в CPython или в любой библиотеке, которой пользуетесь. Один принятый PR учит больше, чем курс.
  5. Ведите свой список граблей. Каждый прод-инцидент, который вы разобрали до конца, стоит абзаца в личных заметках — через год это ваш главный актив.

Итог трека

Мы прошли путь от PyObject с его счётчиком ссылок до OTel-спанов в проде. Если собрать всё в одну мысль: Python — язык, в котором почти всё разрешено, и поэтому профессионализм здесь измеряется не знанием синтаксиса, а знанием ограничений. Модель данных объясняет, почему объекты ведут себя так, а не иначе. GIL объясняет, какую модель конкурентности выбирать. Отсутствие компилятора объясняет, зачем mypy и тесты. Формат артефакта объясняет, почему деплой идёт через контейнер. Всё связано, и человек, который видит эти связи, пишет системы, живущие годами.

Что дальше

Трек «Python» закончен. Логичные продолжения, в зависимости от того, куда вы двигаетесь:

Общая карта портала и рекомендованный порядок треков — в дорожной карте.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов