Продакшн-архитектура: слои, зависимости, конфигурация, логирование
Есть момент, который переживал каждый Python-проект. Первые полгода всё летит: один файл app.py,
пара функций, os.environ читается там, где нужен, соединение с базой создаётся глобально на
уровне модуля. Через год в проекте сорок модулей, from .models import * в двадцати местах,
изменение одного поля роняет три сценария, а запуск тестов требует поднятой Postgres, потому что
import app уже коннектится к БД.
Ничего из этого не случилось из-за плохих программистов. Это прямое следствие двух свойств языка.
Первое: импорт модуля — это исполнение кода, а не объявление символов, поэтому любая небрежность
на уровне модуля превращается в побочный эффект при старте. Второе: у Python нет ни компилятора,
который проверит границы, ни private-модулей — from anything import anything достанет что угодно
из чего угодно. В Go есть internal/, в Java — модули и видимость пакетов, в Rust — pub(crate).
В Python есть подчёркивание в имени и договорённость. Значит, границы приходится держать
инструментами и дисциплиной — и делать это осознанно, а не «когда-нибудь отрефакторим».
Эта статья — про каркас сервиса, который не стыдно эксплуатировать: слои и правило зависимостей,
внедрение зависимостей без фреймворка, конфигурация как типизированный объект, логирование как
данные, а не как строки, и жизненный цикл процесса от старта до SIGTERM. Общая теория слоистой и
гексагональной архитектуры разобрана в
«Слоистая, гексагональная и чистая архитектура» —
здесь нас интересует именно питоновская реализация и питоновские грабли. Азы синтаксиса не
повторяем: они в курсе «Программирование с нуля».
Что архитектура вообще оптимизирует
Архитектура — не про красоту каталогов. Она про стоимость изменения: сколько файлов придётся открыть, чтобы поменять базу, добавить второй способ оплаты или выкатить фичу за флагом. Метрика простая и проверяемая: возьмите три реальные задачи из бэклога и посчитайте, сколько модулей меняет каждая. Если «сменить платёжного провайдера» трогает домен и HTTP-слой — архитектуры нет, есть каталоги.
В Python цена ошибки специфична и измеряется тремя типовыми симптомами:
| Симптом | Настоящая причина | Что чинится архитектурой |
|---|---|---|
ImportError: cannot import name ... |
циклический импорт между модулями одного уровня | однонаправленные зависимости |
Тесты требуют Postgres/Redis для import |
побочные эффекты на уровне модуля | ленивое создание в точке сборки |
| Правка формата ответа API ломает расчёт скидки | ORM-модель используется как доменный тип | разделение доменных и транспортных типов |
| «Поменяли переменную окружения — ничего не изменилось» | конфиг прочитан на импорте и закеширован | единый объект Settings, переданный явно |
| Невозможно запустить два теста с разными настройками | глобальный синглтон | явная передача зависимостей |
Соразмерность важнее чистоты. Скрипт на 200 строк не нуждается в кольцах — ему нужны функции и тесты. Кольца начинают окупаться, когда у сервиса больше одного входа (HTTP плюс воркер плюс CLI) или больше одного варианта внешней системы. Об этом же — «модульный монолит» в архитектурных паттернах.
Слои и правило зависимостей
Правило одно и оно единственное, что нужно запомнить: зависимости направлены внутрь. Внешние
кольца знают о внутренних, внутренние о внешних — нет. Домен не импортирует ни SQLAlchemy, ни
FastAPI, ни httpx.
Практическая раскладка каталогов поверх src layout из
«Модулей и пакетирования»:
orderflow/
├── pyproject.toml
└── src/orderflow/
├── domain/ # сущности и правила. Импортирует ТОЛЬКО stdlib
├── app/ # сценарии использования и порты (Protocol). Импортирует domain
├── adapters/ # postgres/, payments/, events/. Импортируют app и domain
├── entrypoints/ # api/, worker/, cli/. Импортируют всё, кроме друг друга
├── config.py # Settings — читается один раз
├── logging_setup.py # dictConfig — вызывается один раз
└── bootstrap.py # точка сборки: создаёт адаптеры и связывает
api, worker, cli"] --> AD["adapters/
postgres, payments, events"] E --> A AD --> A["app/
use cases + ports"] A --> D["domain/
сущности и правила"] AD --> D B["bootstrap.py
точка сборки"] --> E B --> AD B --> A C["config.py
Settings"] --> B D -.->|"запрещено: нет обратных стрелок"| AD linkStyle 9 stroke:#c2413f,stroke-dasharray:5 5
Домен — обычные Python-объекты. Никакого наследования от Base, никаких декораторов ORM:
# src/orderflow/domain/order.py — ни одного импорта из adapters, фреймворков и драйверов
from __future__ import annotations
from dataclasses import dataclass, replace
from decimal import Decimal
from enum import StrEnum
class OrderStatus(StrEnum):
NEW = "new"
PAID = "paid"
CANCELLED = "cancelled"
class OrderError(Exception):
"""База для ошибок предметной области: транспорт ловит её, а не голый Exception."""
class AlreadyPaid(OrderError):
pass
class CannotCancelPaid(OrderError):
pass
@dataclass(frozen=True, slots=True) # неизменяемость + экономия памяти
class Order:
id: str
customer_id: str
total: Decimal # деньги — Decimal, никогда не float
status: OrderStatus = OrderStatus.NEW
def pay(self) -> Order:
if self.status is OrderStatus.PAID:
raise AlreadyPaid(self.id) # инвариант живёт рядом с данными
return replace(self, status=OrderStatus.PAID)
def cancel(self) -> Order:
if self.status is OrderStatus.PAID:
raise CannotCancelPaid(self.id)
return replace(self, status=OrderStatus.CANCELLED)
Почему frozen=True: неизменяемый объект нельзя случайно испортить из другого слоя, и его безопасно
класть в кеш или передавать между задачами. Подробнее про dataclass, slots и стоимость
атрибутов — в «ООП в Python».
Порты — это Protocol, а не абстрактный базовый класс
Порт объявляет потребитель, то есть слой app. Это ключевой разворот: не адаптер решает, какой
у него интерфейс, а сценарий описывает, что ему нужно. В Python для этого есть структурная типизация
через typing.Protocol — адаптеру не нужно ни от чего наследоваться, достаточно совпасть по форме.
Это принципиально отличается от abc.ABC: там адаптер обязан импортировать порт и наследоваться,
и «стрелка» зависимости остаётся, но становится жёстче.
# src/orderflow/app/ports.py
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from types import TracebackType
from typing import Protocol, runtime_checkable
from orderflow.domain.order import Order
class OrderRepository(Protocol):
async def get(self, order_id: str) -> Order | None: ...
async def save(self, order: Order) -> None: ...
class PaymentGateway(Protocol):
async def charge(
self, customer_id: str, amount: Decimal, idempotency_key: str
) -> str: ...
class Clock(Protocol):
def now(self) -> datetime: ... # время — тоже зависимость, иначе тесты флакают
@runtime_checkable # нужен, только если реально делаете isinstance
class EventPublisher(Protocol):
async def publish(self, topic: str, payload: dict[str, object]) -> None: ...
class UnitOfWork(Protocol):
"""Границей транзакции управляет сценарий, а не репозиторий."""
async def __aenter__(self) -> UnitOfWork: ...
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> bool | None: ...
async def commit(self) -> None: ...
Держите порты узкими. Порт OrderRepository с двадцатью методами — это не порт, а протекший в
приложение интерфейс ORM; проверить его реализацию в тесте станет дороже, чем поднять базу. Правило
из «Связности и связанности» работает
буквально: интерфейс тем полезнее, чем меньше в нём методов.
Сценарий использования — объект с зависимостями в конструкторе и одним публичным методом:
# src/orderflow/app/use_cases/pay_order.py
from dataclasses import dataclass
from orderflow.app.ports import Clock, EventPublisher, OrderRepository, PaymentGateway, UnitOfWork
from orderflow.domain.order import Order
class OrderNotFound(Exception):
pass
@dataclass(slots=True)
class PayOrder:
orders: OrderRepository # зависимости переданы, а не найдены импортом
payments: PaymentGateway
events: EventPublisher
uow: UnitOfWork
clock: Clock
async def __call__(self, order_id: str, idempotency_key: str) -> Order:
async with self.uow: # граница транзакции — здесь, не в репозитории
order = await self.orders.get(order_id)
if order is None:
raise OrderNotFound(order_id)
paid = order.pay() # доменное правило: может бросить AlreadyPaid
await self.payments.charge(paid.customer_id, paid.total, idempotency_key)
await self.orders.save(paid)
await self.events.publish( # outbox пишет в ту же транзакцию
"order.paid",
{"order_id": paid.id, "at": self.clock.now().isoformat()},
)
await self.uow.commit()
return paid
Три вещи, которые здесь важнее кода. Транзакция принадлежит сценарию, а не репозиторию: только
сценарий знает, что «списать деньги и сохранить заказ» — одна операция. Внешний вызов внутри
транзакции — осознанный компромисс; в реальном проекте платёж чаще выносят наружу с idempotency
key и outbox-таблицей, см. саги.
Никаких try/except ради логирования — исключение всплывает до границы, где его один раз
превратят в ответ; почему именно так, разбиралось в
«Исключениях».
Адаптер — обычный класс, который просто совпал по форме:
# src/orderflow/adapters/postgres/orders.py
import asyncpg
from orderflow.domain.order import Order, OrderStatus
class PgOrderRepository: # НЕ наследует Protocol — совместимость структурная
def __init__(self, conn: asyncpg.Connection) -> None:
self._conn = conn
async def get(self, order_id: str) -> Order | None:
row = await self._conn.fetchrow(
"SELECT id, customer_id, total, status FROM orders WHERE id = $1", order_id
)
if row is None:
return None
return Order( # маппинг строки в домен живёт в адаптере
id=row["id"],
customer_id=row["customer_id"],
total=row["total"],
status=OrderStatus(row["status"]),
)
async def save(self, order: Order) -> None:
await self._conn.execute(
"INSERT INTO orders (id, customer_id, total, status) VALUES ($1, $2, $3, $4)"
" ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status",
order.id, order.customer_id, order.total, order.status.value,
)
Чтобы mypy проверял совместимость адаптера с портом в момент правки адаптера, а не в момент сборки, добавьте однострочную проверку присваивания:
# src/orderflow/adapters/postgres/orders.py — в конце файла
if TYPE_CHECKING:
from orderflow.app.ports import OrderRepository
_: type[OrderRepository] = PgOrderRepository # mypy падёт здесь, если сигнатуры разошлись
Подробности про Protocol, вариантность и настройку строгости — в
«Аннотациях типов».
Как удержать правило зависимостей: линтер вместо честного слова
Договорённость без проверки живёт до первого дедлайна. Границы в Python проверяет import-linter — он строит граф импортов пакета и валит CI, если появилась запрещённая стрелка.
# .importlinter
[importlinter]
root_packages = orderflow
[importlinter:contract:layers]
name = Слои: зависимости только вниз
type = layers
layers =
orderflow.entrypoints
orderflow.adapters
orderflow.app
orderflow.domain
[importlinter:contract:pure-domain]
name = Домен не знает про инфраструктуру
type = forbidden
source_modules =
orderflow.domain
forbidden_modules =
sqlalchemy
httpx
fastapi
pydantic
orderflow.adapters
[importlinter:contract:independent-entrypoints]
name = Входы не импортируют друг друга
type = independence
modules =
orderflow.entrypoints.api
orderflow.entrypoints.worker
orderflow.entrypoints.cli
Запуск: lint-imports в том же шаге CI, где ruff и mypy. Если тащить ещё одну зависимость не
хочется, минимальный аналог пишется на ast за двадцать строк и живёт среди тестов:
# tests/test_architecture.py
import ast
import pathlib
DOMAIN = pathlib.Path("src/orderflow/domain")
BANNED = {"sqlalchemy", "httpx", "fastapi", "orderflow.adapters", "orderflow.app"}
def _is_banned(module: str) -> bool:
return any(module == b or module.startswith(f"{b}.") for b in BANNED)
def _imported_modules(path: pathlib.Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
names.add(node.module)
return names
def test_domain_is_pure() -> None:
for path in DOMAIN.rglob("*.py"):
for module in _imported_modules(path):
assert not _is_banned(module), f"{path}: запрещён импорт {module}"
Сложность — O(N) по числу файлов домена, время исполнения — миллисекунды; это дешевле любого ревью.
Отдельно полезно правило ruff TID252 (запрет неявных относительных импортов) и banned-api
для точечных запретов вроде «нигде, кроме config.py, нельзя трогать os.environ».
Циклический импорт — самый частый признак нарушенного слоя. Лечится тремя способами по убыванию качества: развернуть зависимость через порт; вынести общий тип в модуль ниже; в крайнем случае — импорт только для типов:
from typing import TYPE_CHECKING
if TYPE_CHECKING: # во время выполнения импорта нет — цикл разорван
from orderflow.app.services import Billing
def charge(billing: "Billing") -> None: # либо from __future__ import annotations
...
Обратите внимание: if TYPE_CHECKING — это заплатка, а не решение. Если он нужен между app и
domain, слои перепутаны.
Внедрение зависимостей без контейнера
DI в Python — это передача аргументов в конструктор. Ни аннотаций, ни рефлексии, ни XML. Всё дерево собирается в одном месте — точке сборки (composition root), и это единственный модуль, которому разрешено импортировать всё.
# src/orderflow/bootstrap.py
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
import asyncpg
import httpx
from orderflow.adapters.clock import SystemClock
from orderflow.adapters.events.outbox import OutboxPublisher
from orderflow.adapters.payments.http import HttpPaymentGateway
from orderflow.adapters.postgres.orders import PgOrderRepository
from orderflow.adapters.postgres.uow import PgUnitOfWork
from orderflow.app.use_cases.pay_order import PayOrder
from orderflow.config import Settings
@dataclass(slots=True, frozen=True)
class Container:
"""Плоский набор готовых сценариев. Никакой магии разрешения зависимостей."""
pool: asyncpg.Pool
pay_order_factory: Callable[[asyncpg.Connection], PayOrder]
@asynccontextmanager
async def build(settings: Settings) -> AsyncIterator[Container]:
async with AsyncExitStack() as stack: # закрытие в обратном порядке — гарантировано
pool = await stack.enter_async_context(
asyncpg.create_pool(
dsn=str(settings.db.dsn),
min_size=settings.db.pool_min,
max_size=settings.db.pool_max,
command_timeout=settings.db.command_timeout,
)
)
http = await stack.enter_async_context(
httpx.AsyncClient(
base_url=str(settings.payments.base_url),
timeout=httpx.Timeout(settings.payments.timeout), # таймаут ВСЕГДА явный
)
)
def pay_order_factory(conn: asyncpg.Connection) -> PayOrder:
# соединение живёт один запрос, сценарий собирается вокруг него
return PayOrder(
orders=PgOrderRepository(conn),
payments=HttpPaymentGateway(http, settings.payments.api_key),
events=OutboxPublisher(conn),
uow=PgUnitOfWork(conn),
clock=SystemClock(),
)
yield Container(pool=pool, pay_order_factory=pay_order_factory)
AsyncExitStack — важная деталь, а не украшение: он гарантирует, что уже открытые ресурсы закроются,
если пятый по счёту упадёт при инициализации. Ручная последовательность try/finally на пять
ресурсов нечитаема и всегда где-то ошибочна. Разбор with, contextlib и порядка раскрутки — в
«Идиоматичном Python».
Про DI-контейнеры (dependency-injector, punq, wireup) честно: они окупаются на десятках
сервисов с общей платформенной библиотекой и не окупаются в одном сервисе, где всё дерево — тридцать
строк явного кода. Магия разрешения по аннотациям в обмен на потерю навигации в IDE и стек-трейсы
из недр контейнера — плохая сделка. FastAPI.Depends — это тоже DI-контейнер, но у него есть
оправдание: он живёт на границе и умеет то, чего не умеет конструктор (зависимости от запроса,
скоупы, переопределение в тестах через dependency_overrides). Правило: Depends не проникает
глубже роутера.
# src/orderflow/entrypoints/api/routers/orders.py
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from orderflow.app.use_cases.pay_order import OrderNotFound, PayOrder
from orderflow.domain.order import AlreadyPaid
from orderflow.entrypoints.api.schemas import OrderResponse # DTO транспорта, не домен
router = APIRouter(prefix="/orders", tags=["orders"])
async def get_pay_order(request: Request) -> AsyncIterator[PayOrder]:
container = request.app.state.container
async with container.pool.acquire() as conn: # одно соединение на запрос
yield container.pay_order_factory(conn)
@router.post("/{order_id}/pay", status_code=status.HTTP_200_OK)
async def pay(
order_id: str,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
use_case: Annotated[PayOrder, Depends(get_pay_order)],
) -> OrderResponse:
try:
order = await use_case(order_id, idempotency_key)
except OrderNotFound:
raise HTTPException(status.HTTP_404_NOT_FOUND, "заказ не найден") from None
except AlreadyPaid:
raise HTTPException(status.HTTP_409_CONFLICT, "заказ уже оплачен") from None
return OrderResponse.from_domain(order) # доменный объект в DTO — здесь
Лучше — вынести перевод доменных ошибок в HTTP из каждого хендлера в один
app.add_exception_handler(OrderError, ...); тогда роутер остаётся на четыре строки, а таблица
«исключение → код» живёт в одном файле. Подробнее про FastAPI, схемы и валидацию — в
«Веб и API».
Вот как это выглядит во времени — от запроса до записи лога:
request_id participant R as Router FastAPI participant U as PayOrder — app participant D as Order — domain participant P as PgOrderRepository participant L as logging → stdout C->>MW: POST /orders/42/pay MW->>MW: request_id = uuid4 в ContextVar MW->>R: передаёт запрос R->>U: use_case для заказа 42 U->>P: get заказа P-->>U: Order со статусом NEW U->>D: order.pay D-->>U: Order со статусом PAID U->>P: save заказа U-->>R: Order R-->>MW: 200 OK MW->>L: INFO http_request — status 200, ms 37, request_id L-->>C: лог в stdout, ответ клиенту Note over MW,L: request_id проставляется фильтром автоматически,
сценарий и домен о логировании ничего не знают
Конфигурация: один типизированный объект, собранный на старте
Конфигурация — это внешние данные, а любые внешние данные валидируются на границе. Правильная модель: прочитать всё один раз, провалидировать, заморозить и передать вниз явно.
# src/orderflow/config.py — единственный модуль, которому позволено знать про окружение
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl, PostgresDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
dsn: PostgresDsn
pool_min: int = Field(default=1, ge=1)
pool_max: int = Field(default=10, ge=1, le=100)
command_timeout: float = Field(default=5.0, gt=0)
class PaymentsSettings(BaseModel):
base_url: HttpUrl
api_key: SecretStr # не попадёт ни в repr, ни в лог, ни в трейсбек
timeout: float = Field(default=3.0, gt=0)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_nested_delimiter="__", # APP_DB__POOL_MAX=20
env_file=".env", # только dev: в образ файл не кладём
secrets_dir="/run/secrets", # docker/k8s secrets как файлы
frozen=True, # конфиг не мутируют в рантайме
extra="forbid", # опечатка APP_TIMEOUTT упадёт на старте
)
env: Literal["dev", "stage", "prod"] = "dev"
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
log_format: Literal["json", "console"] = "json"
db: DatabaseSettings
payments: PaymentsSettings
feature_new_pricing: bool = False # безопасный дефолт — фича выключена
Валидация происходит в момент Settings(), то есть до открытия портов. Сервис, который стартовал
с пустым DSN и упал на первом запросе, хуже сервиса, который не стартовал вовсе: второй не примет
трафик и остановит выкатку. Это тот же принцип, что и в
двенадцати факторах — конфиг в окружении,
а не в коде.
Главная питоновская ловушка — популярный «удобный» паттерн:
# ❌ ТАК НЕ НАДО: глобальный синглтон, читаемый на импорте
settings = Settings() # выполняется при import config
# в любом модуле:
from orderflow.config import settings # неявная зависимость всего от всего
Что ломается: тест не может создать два разных конфига в одном процессе; import падает, если
переменной нет (даже у --help); mypy не подскажет, кто чем пользуется; подмена значения в тесте
требует monkeypatch плюс перезагрузки модуля. Кеширующая обёртка не спасает — она лишь прячет
проблему:
# ⚠️ КОМПРОМИСС: работает, но требует дисциплины
from functools import lru_cache
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings() # в тестах не забыть get_settings.cache_clear()
Приемлемо для маленького сервиса — и только если в conftest.py есть автоиспользуемая фикстура,
которая чистит кеш. В сервисе, который живёт годами, лучше платить явной передачей: Settings
создаётся в main() и передаётся в build(), дальше вниз идут не «настройки», а конкретные
значения (timeout: float), потому что сценарию не нужен весь конфиг.
Про хранение самих секретов — отдельный разговор в
«Управлении секретами»: переменные
окружения наследуются дочерними процессами и видны в /proc/PID/environ, поэтому по-настоящему
чувствительные значения монтируют файлами и перечитывают при ротации.
Логирование: не строки, а события с полями
logging устроен как иерархия логгеров по точечным именам, к которым прикреплены хендлеры;
устройство LogRecord и путь записи разбирались в
«Стандартной библиотеке». Здесь — продакшн-практика.
Три правила, из которых следует всё остальное:
- Библиотечный и прикладной код только получает логгер:
logger = logging.getLogger(__name__). НикакихbasicConfig, хендлеров и уровней в модулях. - Конфигурация — один вызов
dictConfigв точке входа, до создания приложения. - Логи идут в stdout одной строкой JSON. Ротация, отправка и хранение — забота платформы,
а не процесса. Файлы и
RotatingFileHandlerв контейнере — путь к потерянным логам.
# src/orderflow/logging_setup.py
import contextvars
import json
import logging
import logging.config
from datetime import UTC, datetime
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
# набор «служебных» полей LogRecord — всё остальное считаем пользовательским
_RESERVED = frozenset(
logging.LogRecord("", 0, "", 0, "", (), None).__dict__
) | {"message", "asctime", "taskName"}
class ContextFilter(logging.Filter):
"""Проставляет сквозные поля из contextvars в каждую запись."""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id.get()
return True
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
"ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"request_id": getattr(record, "request_id", "-"),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
# всё, что пришло через extra=..., едет отдельными полями
payload.update({k: v for k, v in record.__dict__.items() if k not in _RESERVED})
return json.dumps(payload, ensure_ascii=False, default=str)
def configure(level: str, fmt: str) -> None:
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False, # иначе логгеры библиотек замолчат
"filters": {"context": {"()": ContextFilter}},
"formatters": {
"json": {"()": JsonFormatter},
"console": {"format": "%(asctime)s %(levelname)-7s %(name)s [%(request_id)s] %(message)s"},
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": fmt,
"filters": ["context"],
}
},
"root": {"level": level, "handlers": ["stdout"]},
"loggers": { # чужой шум приглушаем точечно
"uvicorn.access": {"level": "WARNING"},
"httpx": {"level": "WARNING"},
"asyncio": {"level": "WARNING"},
},
})
logging.captureWarnings(True) # DeprecationWarning тоже попадут в логи
Как это выглядит на вызывающей стороне:
logger = logging.getLogger(__name__)
logger.info(
"order_paid", # СОБЫТИЕ, а не предложение на русском
extra={"order_id": order.id, "amount": str(order.total), "duration_ms": 37},
)
# {"ts":"2026-07-16T10:00:00.123+00:00","level":"INFO","logger":"orderflow.app.use_cases",
# "msg":"order_paid","request_id":"7f3c…","order_id":"42","amount":"199.00","duration_ms":37}
Событие с полями ищется в Loki/Elastic как msg="order_paid" and duration_ms > 500. Строка
«Заказ 42 успешно оплачен за 37 мс» не ищется никак — её придётся парсить регулярками.
Питоновские грабли логирования, каждая из которых стоила кому-то ночи:
| Грабля | Что происходит | Как правильно |
|---|---|---|
logger.info(f"user {uid} paid") |
строка строится всегда, даже при WARNING; события не группируются |
logger.info("user_paid", extra={"uid": uid}); включите правило ruff G004 |
extra={"name": ...} или {"module": ...} |
конфликт с атрибутом LogRecord → KeyError в рантайме |
префикс полей или вложенный extra={"ctx": {...}} |
Хендлер добавлен и на root, и на orderflow |
каждая запись печатается дважды | хендлеры только на root, дочерние — через propagate |
propagate = False у своего логгера |
записи не доходят до общего хендлера, логи пропадают | не трогать propagate без причины |
logging.info(...) вместо logger.info(...) |
неявный basicConfig() создаёт хендлер на stderr мимо вашей конфигурации |
всегда через именованный логгер |
| Логирование в файл или по сети из asyncio | блокирующий I/O внутри цикла событий, latency скачет | QueueHandler + QueueListener (cookbook) |
Общий файловый хендлер в multiprocessing |
перемешанные и обрезанные строки | stdout или очередь на один процесс-писатель |
except Exception: logger.exception(...); raise |
одна ошибка в логах трижды | логировать один раз на границе |
В логи попал SecretStr/токен/номер карты |
утечка PII в хранилище с широким доступом | SecretStr, фильтр-редактор, ревью полей |
disable_existing_loggers по умолчанию True |
логгеры, созданные до dictConfig, замолкают навсегда |
явно False |
Про structlog: он даёт удобные процессоры, привязку контекста (bind) и хороший цветной вывод в
консоли, но не отменяет logging — правильная конфигурация направляет structlog в стандартные
хендлеры, чтобы логи библиотек и вашего кода жили в одном формате. Стоит брать, когда контекста
много (log = log.bind(order_id=...) вместо ручного extra в каждом вызове). Документация —
structlog.org.
Связь с трассировкой: если в проекте есть OpenTelemetry, добавьте trace_id/span_id в тот же
ContextFilter — тогда из строки лога можно прыгнуть в трейс. Метрики, трейсы и алерты — тема
следующей статьи и трека
«Наблюдаемость».
Жизненный цикл процесса: старт, готовность, остановка
Сервис — это не только обработчик запросов, но и конечный автомат: пока пулы не открыты — он не
готов, после SIGTERM — обязан дожить текущие запросы и закрыться.
В FastAPI это выражается через lifespan — и заметьте, что вся сборка идёт внутри, а не на уровне
модуля:
# src/orderflow/entrypoints/api/main.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from orderflow import bootstrap, logging_setup
from orderflow.config import Settings
from orderflow.domain.order import OrderError
from orderflow.entrypoints.api.errors import domain_error_handler
from orderflow.entrypoints.api.routers import orders
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings() # тест может подсунуть свой конфиг
logging_setup.configure(settings.log_level, settings.log_format)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with bootstrap.build(settings) as container:
app.state.container = container # ресурсы живут ровно столько, сколько приложение
yield
# выход из async with = корректное закрытие пулов при остановке
app = FastAPI(title="orderflow", lifespan=lifespan)
app.include_router(orders.router)
app.add_exception_handler(OrderError, domain_error_handler)
return app
Ключевое отличие от «как обычно пишут»: app = FastAPI() на уровне модуля с глобальным движком БД
работает ровно до первого теста, которому нужна другая база. Фабрика create_app(settings)
превращает приложение в обычную функцию — его можно создать в тесте сколько угодно раз.
Про сигналы стоит знать одну питоновскую особенность: в синхронном коде обработчик сигнала
выполняется между байткодами основного потока, поэтому SIGTERM во время блокирующего recv()
обработается только после его завершения. В asyncio пользуйтесь
loop.add_signal_handler(), в WSGI/ASGI-серверах — их штатным graceful shutdown
(gunicorn --graceful-timeout, uvicorn --timeout-graceful-shutdown). Детали моделей исполнения —
в «Конкурентности», детали выкатки — в
следующей статье.
Необработанные исключения тоже нужно перехватить в логи, иначе трейсбек уйдёт в stderr мимо
JSON-формата:
import asyncio
import logging
import sys
import threading
log = logging.getLogger("unhandled")
sys.excepthook = lambda *a: log.critical("unhandled_exception", exc_info=a)
threading.excepthook = lambda args: log.critical("thread_exception", exc_info=args[:3])
asyncio.get_running_loop().set_exception_handler(
lambda loop, ctx: log.error("asyncio_exception", extra={"detail": str(ctx.get("message"))})
)
Сколько архитектуры нужно вашему проекту
Слои стоят денег: больше файлов, больше косвенности, дольше онбординг. Решение зависит от того, сколько у проекта входов и как часто меняются внешние системы.
Практический критерий вместо догмы: вводите порт тогда, когда у вас есть или вот-вот появится вторая реализация — вторая БД, тестовый дубль вместо мока, второй провайдер. Порт с единственной реализацией и без тестового дубля — это лишний файл.
Тестируемость — прямое следствие архитектуры, а не отдельная работа. Когда порты узкие, тесты пишутся
на подставных объектах, а не на unittest.mock.patch: подмена по строковому пути ломается при любом
переименовании и не проверяется mypy.
# tests/fakes.py — дубль вместо мока: mypy проверит его совместимость с портом
class InMemoryOrderRepository:
def __init__(self) -> None:
self._items: dict[str, Order] = {}
async def get(self, order_id: str) -> Order | None:
return self._items.get(order_id)
async def save(self, order: Order) -> None:
self._items[order.id] = order
# tests/test_pay_order.py
async def test_pay_order_marks_paid() -> None:
repo = InMemoryOrderRepository()
await repo.save(Order(id="42", customer_id="c1", total=Decimal("199.00")))
use_case = PayOrder(orders=repo, payments=FakeGateway(), events=NullPublisher(),
uow=NullUow(), clock=FrozenClock())
result = await use_case("42", idempotency_key="k1")
assert result.status is OrderStatus.PAID # ни базы, ни сети, ни моков
Подробности про pytest, фикстуры и границы тестирования — в «Тестировании».
Честно: где Python в проде выигрывает и где проигрывает
Выигрывает. Скорость изменения — новый сценарий пишется и выкатывается за часы. Экосистема данных: если сервису нужны numpy/pandas/ML-модель рядом с бизнес-логикой, альтернатив почти нет (см. «Работу с данными»). Читаемость чужого кода при найме. Богатые фреймворки с валидацией из коробки. Роль клея между системами, где 95 % времени — ожидание I/O, а не вычисления.
Проигрывает. Память: каждый воркер — отдельный процесс на 100–400 МБ, и горизонтальное масштабирование в 4–8 раз дороже по RAM, чем у Go или Rust. Латентность p99: GIL и сборщик мусора дают всплески, которые не убираются оптимизацией кода («Конкурентность»). Рефакторинг большой кодовой базы без аннотаций — это переименование строкой и надежда; с mypy становится терпимо, но всё равно дороже, чем в языках со статической проверкой. Развёртывание: вместо одного бинарника — интерпретатор, колёса, C-расширения и образ на сотни мегабайт. Холодный старт в serverless — секунды, а не миллисекунды. И отдельная категория: ошибки, которые компилятор поймал бы бесплатно, здесь ловятся линтерами, тестами и продом.
Куда не тащить. Компоненты с бюджетом в микросекунды и жёстким реальным временем; ядра CPU-bound-вычислений без выноса в C/Rust/numpy («Производительность»); прошивки и системный слой («Системное программирование на C»); высоконагруженные прокси и сетевые узлы, где Go или Rust дают тот же результат на десятой части железа. Типичное здоровое разделение в продакшене: Python — бизнес-логика, интеграции и данные; горячие пути — вынесенные расширения или отдельный сервис на другом языке.
Сводка типичных ошибок
| Ошибка | Симптом в проде | Лечение |
|---|---|---|
| Соединение с БД на уровне модуля | import падает в CI, тесты требуют инфраструктуры |
создание ресурсов в lifespan/build() |
| ORM-модель как доменный тип | правка схемы БД ломает бизнес-правила | отдельные доменные dataclass и маппинг в адаптере |
| Конфиг-синглтон, читаемый на импорте | нельзя запустить два конфига в одном процессе | Settings создаётся в main() и передаётся явно |
os.environ в глубине кода |
«поменяли переменную — не подхватилось» | все чтения окружения только в config.py |
| Порт на 20 методов | тестовый дубль дороже реальной БД | узкие Protocol под конкретный сценарий |
if TYPE_CHECKING между app и domain |
скрытый цикл, зависящий от порядка импорта | пересмотреть слои, а не прятать цикл |
| Транзакция открыта в репозитории | частичные записи при ошибке во втором вызове | UnitOfWork на уровне сценария |
| HTTP-клиент без таймаута | зависшие воркеры, пул исчерпан, каскад отказов | явный httpx.Timeout + ретраи с backoff |
| Логи в файл внутри контейнера | логи теряются при рестарте пода | stdout, одна строка JSON |
logger.exception в каждом слое |
одна ошибка размножена в трёх записях | логирование только на границе |
Секрет в str вместо SecretStr |
токен в трейсбеке и в Sentry | SecretStr, фильтры, ревью полей |
Нет /readyz отдельно от /healthz |
трафик приходит до прогрева пулов | разные ручки, readyz = 503 при сливе |
Глобальный lru_cache на объекте с состоянием |
утечка памяти и связность тестов | кеш только на чистых функциях |
Мини-итог
Продакшн-архитектура в Python держится на четырёх решениях, принятых один раз и защищённых
инструментами. Зависимости направлены внутрь, порты объявлены потребителем через Protocol,
а соблюдение проверяет import-linter, а не совесть. Все зависимости передаются в конструкторы,
дерево собирается в единственной точке сборки на AsyncExitStack, и Depends не покидает границу
транспорта. Конфигурация — типизированный неизменяемый объект, собранный и провалидированный до
открытия портов, а os.environ читается ровно в одном модуле. Логи — события с полями в stdout,
контекст приезжает через contextvars и фильтр, а прикладной код умеет только getLogger(__name__).
Всё остальное — размер и соразмерность. Три модуля тоже архитектура, если правило зависимостей в них соблюдается; двенадцать слоёв вокруг CRUD — не архитектура, а издержки.
Источники
- Harry Percival, Bob Gregory, Architecture Patterns with Python — бесплатная онлайн-версия; порты, адаптеры, Unit of Work и репозитории на живом Python.
- Logging HOWTO и Logging Cookbook — включая блокирующие хендлеры, очереди и многопроцессность.
- logging.config — dictConfig schema — полная схема конфигурации.
- contextvars и PEP 567 — сквозной контекст в потоках и корутинах.
- contextlib.AsyncExitStack — корректное управление набором ресурсов.
- typing.Protocol и PEP 544 — структурная типизация как основа портов.
- pydantic-settings — источники
конфигурации, приоритет,
settings_customise_sources. - import-linter — контракты слоёв и независимости.
- structlog: интеграция со stdlib logging.
- FastAPI: Lifespan Events — жизненный цикл приложения и ресурсы.
- The Twelve-Factor App — канонический источник про конфиг в окружении.
- Brandon Rhodes, доклады про чистую архитектуру и паттерны в Python — почему «функциональное ядро, императивная оболочка» работает лучше слоёв ради слоёв.
- Robert C. Martin, Clean Architecture — первоисточник правила зависимостей.
- OpenTelemetry Python — трассировка и связь логов с трейсами.
Что дальше
Деплой, наблюдаемость, SDLC и лучшие ресурсы по Python — соберём образ, который весит разумно и стартует быстро, настроим gunicorn/uvicorn и проверки готовности, добавим метрики и трассировку поверх готовых логов и разберём, какие книги, блоги и конференции реально стоит читать дальше.