Деплой, наблюдаемость, 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, полезно понимать, какие вообще бывают артефакты и почему почти всегда выбирают образ.
публикация на PyPI
ставится через pip/uv"] B -->|"CLI-утилита"| ZIP["zipapp / shiv / PEX
один файл, нужен внешний Python"] B -->|"десктоп"| FRZ["PyInstaller / Nuitka / briefcase
интерпретатор внутри, 60+ МБ"] B -->|"сервис"| IMG["OCI-образ
интерпретатор + venv + код"] WHL --> PYPI["PyPI / внутренний индекс"] IMG --> REG["Registry"] REG --> K8S["Kubernetes / Nomad / ECS"] REG --> VM["docker compose на VPS"] ZIP --> SRV["scp на сервер + systemd"] K8S --> RUN["Процессы-воркеры
gunicorn / uvicorn / celery"] VM --> RUN SRV --> RUN
Практические выводы из этой развилки:
- 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 секунд на типичном коммите.
# 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» лечится не магией, а пониманием порядка событий.
контроллеру нужны секунды G->>G: перестать принимать новые соединения G->>W: SIGTERM (доработать текущие) LB-->>G: новый запрос по старому endpoint Note over G,LB: вот он, источник 502 W->>C: 200 OK по старому запросу W->>G: воркер завершился G->>K8s: процесс вышел с кодом 0 Note over K8s: если не уложились в
terminationGracePeriodSeconds — SIGKILL
Рецепт, который убирает 502:
preStop: sleep 10в манифесте пода — контейнер продолжает обслуживать запросы, пока балансировщик выпиливает его из своих таблиц. Это единственный надёжный способ победить гонку удаления endpoint.terminationGracePeriodSeconds>preStop sleep+graceful_timeoutgunicorn.- Readiness-проба начинает возвращать ошибку сразу по флагу «выключаемся».
- В приложении — обработчик, который дожидается фоновых задач:
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 |
Логи: 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-проекта
ruff format --check"] --> T["mypy --strict"] T --> U["pytest -m 'not slow'
+ coverage"] end FAST --> SLOW subgraph SLOW["Тяжёлые — параллельно"] M["Матрица 3.11 / 3.12 / 3.13"] I["Интеграция
testcontainers"] A["pip-audit
SBOM"] end SLOW --> BUILD["uv build → wheel
docker buildx → образ
тег = git sha"] BUILD --> STG["Деплой на staging
смоук-тесты"] STG --> GATE{"Тег vX.Y.Z?"} GATE -->|нет| STOP["Стоп: только staging"] GATE -->|да| PROD["Прод: canary 5% → 50% → 100%"] PROD --> WATCH["Автооткат по SLO:
error rate, p99, насыщение"]
Ключевая идея — порядок по стоимости: то, что ловит 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, без долгоживущих токенов
Типичные ошибки уровня процесса
- «Соберём образ на проде из git». Артефакт должен быть один и тот же от staging до прода. Пересборка = другой набор транзитивных зависимостей.
pip installбез лока в Dockerfile. Сборка сегодня и через месяц дают разный софт. Воспроизводимость начинается с лок-файла.- Разные версии Python локально и в CI. Ловится
requires-pythonвpyproject.tomlи.python-versionдля uv. - Секреты в образе.
ENV DATABASE_URL=...остаётся в слоях навсегда, даже если переопределён позже. Секреты — только через окружение или secret-mount. - Логи в файл внутри контейнера. Контейнер эфемерен: логи умирают вместе с ним, а диск переполняется. Только stdout.
- Алерт на каждый ERROR. Через неделю дежурный перестаёт их читать. Алертят на бюджет ошибок и SLO.
assertдля проверки бизнес-инвариантов. Сpython -Oассерты вырезаются компилятором, и проверка исчезает. Только явныеif ...: raise— см. https://courses.digitable.life/post/python/08-errors-and-exceptions/.- Один воркер на всё. Веб-воркеры и фоновые задачи (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.
Блоги и люди, за которыми стоит следить
- Ned Batchelder — автор coverage.py; доклад Facts and Myths about Python names and values стоит показывать каждому новичку.
- Brett Cannon — core-разработчик, пишет про импорты и упаковку изнутри.
- Hynek Schlawack — автор
attrsиstructlog; лучшие тексты про Docker и Python и про упаковку. - Itamar Turner-Trauring / Python⇒Speed — производительность, память и контейнеры, всегда с замерами.
- Trey Hunner — короткие разборы идиом.
- Victor Stinner — внутренности CPython и работа над C-API.
- Armin Ronacher — автор Flask, много честной критики экосистемы.
- Anthony Sottile (deadsnakes / pre-commit) и его канал с разборами — прикладной Python без прикрас.
Регулярное чтение и слушание
- PyCoder’s Weekly — лучший еженедельный дайджест.
- Talk Python To Me — интервью с авторами библиотек; Python Bytes — новости за 30 минут.
- Real Python — качественные обучающие статьи среднего уровня.
- Доклады с PyCon US — классика, которая не устарела: Raymond Hettinger, Transforming Code into Beautiful, Idiomatic Python; David Beazley, Understanding the Python GIL; James Powell, «So you want to be a Python expert?».
Как учиться дальше, чтобы это работало
Чтение статей само по себе не двигает уровень. Работает другое:
- Читайте исходники stdlib.
dataclasses.py,contextlib.py,functools.py,enum.py— это несколько тысяч строк образцовой идиоматики, лежащих у вас на диске. - Опубликуйте один пакет на PyPI. Даже крошечный: вы за день узнаете про сборку, метаданные, версии и CI больше, чем из десяти статей.
- Профилируйте свой код. Не чужой бенчмарк, а свой сервис под своей нагрузкой. Интуиция об узких местах ошибается примерно всегда.
- Возьмите issue с меткой
easyв CPython или в любой библиотеке, которой пользуетесь. Один принятый PR учит больше, чем курс. - Ведите свой список граблей. Каждый прод-инцидент, который вы разобрали до конца, стоит абзаца в личных заметках — через год это ваш главный актив.
Итог трека
Мы прошли путь от PyObject с его счётчиком ссылок до OTel-спанов в проде. Если
собрать всё в одну мысль: Python — язык, в котором почти всё разрешено, и поэтому
профессионализм здесь измеряется не знанием синтаксиса, а знанием ограничений. Модель
данных объясняет, почему объекты ведут себя так, а не иначе. GIL объясняет, какую модель
конкурентности выбирать. Отсутствие компилятора объясняет, зачем mypy и тесты. Формат
артефакта объясняет, почему деплой идёт через контейнер. Всё связано, и человек, который
видит эти связи, пишет системы, живущие годами.
Что дальше
Трек «Python» закончен. Логичные продолжения, в зависимости от того, куда вы двигаетесь:
- Инфраструктура и эксплуатация — DevOps: Kubernetes, GitOps, облака и стоимость владения на порядок глубже, чем мы успели здесь.
- Второй язык под другие задачи — Go для дешёвых по ресурсам сервисов, Rust для расширений и системного слоя, Elixir для распределённой отказоустойчивости.
- Проектирование систем — Архитектурные паттерны, DDD и Распределённые системы.
- Данные и ML — Data Engineering, Machine Learning и AI Engineering: всё это экосистема, в которой Python — язык по умолчанию.
- Инженерные основы — Алгоритмы, Базы данных, Производительность и Безопасность.
Общая карта портала и рекомендованный порядок треков — в дорожной карте.