Главная/Python внутри Container/Урок

6.6. Logging

Цели

После этого материала вы сможете:

  • назвать две независимые причины, по которым логи Python не появляются в docker logs;
  • настроить модуль logging для контейнеризованного приложения;
  • реализовать structured logging в JSON без внешних зависимостей;
  • обоснованно распределить вывод между stdout и stderr;
  • объяснить, почему логи не пишут в файл внутри container;
  • объединить логи приложения и сервера (Uvicorn, Gunicorn) в едином формате.

Предварительные знания

Ключевые термины

ТерминОбъяснение
handlerКомпонент logging, отправляющий записи в назначение
formatterПреобразует запись лога в строку
structured loggingЛоги в машиночитаемом формате, обычно JSON
correlation idИдентификатор, связывающий записи одного запроса
propagateПередача записи логгерам-предкам
log driverКомпонент Docker, определяющий, куда идут stdout и stderr

Теория

Две причины пропажи логов

Симптом одинаков — docker logs пуст, приложение работает. Причины две, и они независимы.

Причина 1: буферизация. Разбиралась в уроке 6.4. Вывод накапливается в буфере и не доходит до Docker. Решение — PYTHONUNBUFFERED=1.

Причина 2: запись в файл. Приложение пишет в /var/log/app.log внутри container. Docker перехватывает только stdout и stderr — файл он не видит.

text
   приложение ──► stdout ──► Docker ──► docker logs      ✓
   приложение ──► stderr ──► Docker ──► docker logs      ✓
   приложение ──► /var/log/app.log ──► writable layer    ✗ Docker не видит

Различить их просто: если файл существует и в нём есть записи — причина вторая; если логов нет нигде — первая.

Почему не писать в файл

Аргументов несколько, и каждый достаточен сам по себе.

АргументСледствие
Docker не видит файлdocker logs пуст, инструменты сбора не работают
Файл в writable layerИсчезает при docker rm вместе с историей инцидента
Нет ротацииЗаполняет диск; ротация внутри container — лишняя сложность
Copy-on-writeКаждая запись в унаследованный файл вызывает copy-up (урок 3.2)
Требует прав на записьМешает read-only root filesystem (раздел 11)
Разные container — разные файлыНет единого места для просмотра

Правильная модель: приложение пишет в stdout, всё остальное — забота инфраструктуры. Ротацией, агрегацией и хранением занимается logging driver Docker и внешние системы.

Это прямое следствие принципа Twelve-Factor: логи — поток событий, а не файл.

Для простого скрипта print достаточен. Для сервиса — нет.

printlogging
Уровни важностинетесть
Фильтрация без правки коданетчерез уровень
Метка временивручнуюавтоматически
Имя модуля-источникавручнуюавтоматически
Трассировка исключенийвручнуюexc_info=True
Разделение stdout и stderrвручнуючерез handlers
Единый формат с библиотекаминетда

Последняя строка решающая для контейнеризованного приложения: сторонние библиотеки используют logging, и без него их вывод будет в другом формате.

Куда направлять: stdout или stderr

Два подхода, оба встречаются.

Подход А: всё в stdout. Логи — единый поток событий; уровень указан в самой записи. Так делают большинство современных сервисов.

Подход Б: WARNING и выше в stderr, остальное в stdout. Позволяет разделить потоки средствами оболочки: docker logs <c> 2>/dev/null покажет только информационные записи.

Подход АПодход Б
Простота настройкивышениже
Разделение через docker logsнетесть
Совместимость со сборщикамиодинаковоодинаково
Порядок записейгарантированможет нарушаться при смешении

Последняя строка — аргумент за подход А: два независимых потока могут переупорядочиться при записи, и в docker logs записи окажутся не в хронологическом порядке.

Рекомендация курса: подход А для structured logging, подход Б — если нужна фильтрация средствами оболочки и логи читает человек.

Structured logging

Обычный лог — строка для человека:

text
2026-07-30 12:14:33 INFO [app.api] запрос обработан за 42 мс

Structured log — объект для машины:

json
{"ts":"2026-07-30T12:14:33.482Z","level":"INFO","logger":"app.api","message":"запрос обработан","duration_ms":42,"request_id":"a1b2c3"}

Преимущество проявляется при поиске: «все запросы дольше 500 мс за последний час» — это запрос к полю, а не разбор строк регулярным выражением.

Для реализации не нужна внешняя зависимость: достаточно наследника logging.Formatter, возвращающего JSON.

Готовые библиотеки (structlog, python-json-logger) добавляют удобство, но собственный форматтер в 20 строк покрывает большинство случаев и не тянет зависимость.

Дополнительные поля

Ключевая возможность structured logging — добавлять контекст к записи:

python
logger.info("запрос обработан", extra={"extra_fields": {"duration_ms": 42, "user_id": 17}})

Имя extra_fields — соглашение, которое понимает ваш форматтер. Стандартный параметр extra в logging кладёт ключи прямо в LogRecord, что может конфликтовать со служебными именами (message, levelname, args). Вложенный словарь этого избегает.

Correlation ID

В многокомпонентной системе одна операция порождает записи в нескольких сервисах. Чтобы их связать, каждому запросу присваивается идентификатор, который проходит через все записи и передаётся дальше по цепочке.

Реализация в Python — через contextvars: значение доступно во всех функциях обработки запроса, включая асинхронные, без передачи параметром.

Логи сервера приложений

Uvicorn и Gunicorn ведут собственные логи, по умолчанию в своём формате. В результате в docker logs смешиваются два разных формата — это ломает машинную обработку.

Решение — направить логгеры сервера через тот же обработчик:

python
for name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
    logger = logging.getLogger(name)
    logger.handlers.clear()
    logger.propagate = True

После этого записи сервера проходят через корневой логгер и получают ваш форматтер.


Внутренний механизм

Путь записи в logging

text
   logger.info("сообщение")
          │
          ▼
   создаётся LogRecord
          │
          ▼
   проверка уровня логгера ──► отброшено, если ниже
          │
          ▼
   применяются фильтры
          │
          ▼
   передаётся handlers логгера
          │
          ├─► propagate=True ──► handlers логгера-предка
          │
          ▼
   formatter превращает в строку
          │
          ▼
   handler пишет в назначение (stdout)

Понимание этой цепочки объясняет типичные проблемы: дублирование записей (обработчик и у логгера, и у корневого при propagate=True) и молчание (уровень логгера выше уровня записи).

Почему basicConfig иногда не работает

Функция logging.basicConfig настраивает корневой логгер, только если у него ещё нет обработчиков. Повторный вызов молча ничего не делает.

Проблема возникает, когда библиотека вызвала basicConfig раньше вашего кода. Признак — логи выводятся в неожиданном формате.

Надёжный способ — настраивать корневой логгер явно, очищая существующие обработчики:

python
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)

Команды и примеры

Подготовка

bash
mkdir -p /tmp/pylog && cd /tmp/pylog

Две причины пропажи логов

bash
cat > cause1.py <<'PY'
"""Причина 1: буферизация."""
import time

print("строка в stdout без flush")
time.sleep(6)
PY

cat > cause2.py <<'PY'
"""Причина 2: запись в файл внутри container."""
import logging
import time
from pathlib import Path

Path("/var/log/app").mkdir(parents=True, exist_ok=True)
logging.basicConfig(
    filename="/var/log/app/app.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

log = logging.getLogger("app")
for i in range(3):
    log.info("запись %s — идёт в файл, не в stdout", i)
    time.sleep(0.2)
time.sleep(6)
PY

for c in cause1 cause2; do
    cat > "Dockerfile.$c" <<EOF
FROM python:3.13-slim
COPY $c.py /app.py
CMD ["python", "/app.py"]
EOF
    docker build -q -f "Dockerfile.$c" -t "log:$c" . > /dev/null
    docker run -d --name "t-$c" "log:$c" > /dev/null
done

sleep 3
for c in cause1 cause2; do
    printf '%-8s строк в docker logs: %s\n' "$c" "$(docker logs "t-$c" 2>&1 | wc -l)"
done

echo
echo "но в causa2 файл есть:"
docker exec t-cause2 sh -c 'wc -l < /var/log/app/app.log; tail -1 /var/log/app/app.log' | sed 's/^/  /'

docker rm -f t-cause1 t-cause2 > /dev/null
text
cause1   строк в docker logs: 0
cause2   строк в docker logs: 0

но в causa2 файл есть:
  3
  2026-07-30 12:31:07,842 INFO запись 2 — идёт в файл, не в stdout

Симптом одинаков, причины разные. Во втором случае логи существуют — просто не там, где их ищет Docker.

Различающая проверка:

bash
diagnose_logs() {
    local c="$1"
    echo "── $c ──"
    printf '  docker logs: %s строк\n' "$(docker logs "$c" 2>&1 | wc -l)"
    printf '  файлы .log в container: '
    docker exec "$c" sh -c 'find / -name "*.log" -not -path "/proc/*" -not -path "/sys/*" 2>/dev/null | head -3' \
        | tr '\n' ' ' || echo "нет"
    echo
}

Если файлы найдены — причина вторая. Если нет — первая.

Правильная настройка logging

bash
cat > logging_setup.py <<'PY'
"""Настройка logging для контейнеризованного приложения."""
from __future__ import annotations

import logging
import sys


def configure(level: str = "INFO", stream=None) -> None:
    """Настраивает корневой логгер.

    Явная очистка обработчиков надёжнее basicConfig: та не сработает,
    если обработчик уже добавлен библиотекой.
    """
    handler = logging.StreamHandler(stream or sys.stdout)
    handler.setFormatter(
        logging.Formatter(
            fmt="%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
            datefmt="%Y-%m-%dT%H:%M:%S",
        )
    )

    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level.upper())
PY

cat > app_basic.py <<'PY'
import logging
import os
import time

from logging_setup import configure

configure(os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger("app.worker")

log.debug("отладочное — видно только при LOG_LEVEL=DEBUG")
log.info("приложение запущено")
log.warning("предупреждение")

try:
    1 / 0
except ZeroDivisionError:
    log.exception("поймано исключение")

time.sleep(0.3)
log.info("завершение")
PY

cat > Dockerfile.basic <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY logging_setup.py app_basic.py ./
CMD ["python", "app_basic.py"]
EOF

docker build -q -f Dockerfile.basic -t log:basic . > /dev/null
docker run --rm log:basic
text
2026-07-30T12:35:12 INFO     [app.worker] приложение запущено
2026-07-30T12:35:12 WARNING  [app.worker] предупреждение
2026-07-30T12:35:12 ERROR    [app.worker] поймано исключение
Traceback (most recent call last):
  File "/app/app_basic.py", line 14, in <module>
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero
2026-07-30T12:35:12 INFO     [app.worker] завершение

Метка DEBUG отсутствует — уровень выше. Проверим:

bash
docker run --rm -e LOG_LEVEL=DEBUG log:basic 2>&1 | head -2
text
2026-07-30T12:35:40 DEBUG    [app.worker] отладочное — видно только при LOG_LEVEL=DEBUG
2026-07-30T12:35:40 INFO     [app.worker] приложение запущено

Уровень меняется переменной окружения без пересборки образа.

Structured logging в JSON

bash
cat > json_logging.py <<'PY'
"""Structured logging в JSON без внешних зависимостей."""
from __future__ import annotations

import json
import logging
import sys
from datetime import datetime, timezone

# Служебные поля LogRecord — их не переносим в вывод как есть
_RESERVED = frozenset(vars(logging.LogRecord("", 0, "", 0, "", (), None)))


class JsonFormatter(logging.Formatter):
    """Одна строка JSON на запись."""

    def format(self, record: logging.LogRecord) -> str:
        payload: dict[str, object] = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc)
            .isoformat(timespec="milliseconds")
            .replace("+00:00", "Z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }

        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)

        # Дополнительные поля из extra={"extra_fields": {...}}
        for key, value in getattr(record, "extra_fields", {}).items():
            if key not in payload:
                payload[key] = value

        return json.dumps(payload, ensure_ascii=False, default=str)


def configure(level: str = "INFO") -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())

    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level.upper())
PY

cat > app_json.py <<'PY'
import logging
import os
import random
import time

from json_logging import configure

configure(os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger("app.api")

log.info("сервис запущен", extra={"extra_fields": {"version": "1.4.2", "pid": os.getpid()}})

for i in range(3):
    duration = random.randint(10, 800)
    log.info(
        "запрос обработан",
        extra={"extra_fields": {
            "request_id": f"req-{i:03d}",
            "method": "GET",
            "path": "/api/items",
            "status": 200,
            "duration_ms": duration,
        }},
    )
    time.sleep(0.1)

try:
    raise ValueError("некорректный параметр")
except ValueError:
    log.exception("ошибка обработки", extra={"extra_fields": {"request_id": "req-003"}})
PY

cat > Dockerfile.jsonlog <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY json_logging.py app_json.py ./
CMD ["python", "app_json.py"]
EOF

docker build -q -f Dockerfile.jsonlog -t log:json . > /dev/null
docker run --rm log:json | head -3
text
{"ts":"2026-07-30T12:40:11.203Z","level":"INFO","logger":"app.api","message":"сервис запущен","version":"1.4.2","pid":1}
{"ts":"2026-07-30T12:40:11.204Z","level":"INFO","logger":"app.api","message":"запрос обработан","request_id":"req-000","method":"GET","path":"/api/items","status":200,"duration_ms":412}
{"ts":"2026-07-30T12:40:11.305Z","level":"INFO","logger":"app.api","message":"запрос обработан","request_id":"req-001","method":"GET","path":"/api/items","status":200,"duration_ms":97}

Каждая строка — валидный JSON. Проверим и покажем практическую пользу:

bash
echo "=== все строки валидны как JSON? ==="
docker run --rm log:json | python3 -c "
import json, sys
n = 0
for line in sys.stdin:
    json.loads(line)
    n += 1
print(f'  {n} строк, все валидны')
"

echo
echo "=== запросы дольше 300 мс ==="
docker run --rm log:json | python3 -c "
import json, sys
for line in sys.stdin:
    r = json.loads(line)
    if r.get('duration_ms', 0) > 300:
        print(f\"  {r['request_id']}: {r['duration_ms']} мс\")
"

echo
echo "=== только ошибки ==="
docker run --rm log:json | python3 -c "
import json, sys
for line in sys.stdin:
    r = json.loads(line)
    if r['level'] == 'ERROR':
        print(f\"  {r['message']} (request_id={r.get('request_id')})\")
"
text
=== все строки валидны как JSON? ===
  5 строк, все валидны

=== запросы дольше 300 мс ===
  req-000: 412 мс
  req-002: 638 мс

=== только ошибки ===
  ошибка обработки (request_id=req-003)

Это и есть смысл structured logging: фильтрация по полю вместо разбора строк регулярным выражением.

stdout и stderr

bash
cat > split_streams.py <<'PY'
"""Разделение: INFO и ниже в stdout, WARNING и выше в stderr."""
from __future__ import annotations

import logging
import sys


class MaxLevelFilter(logging.Filter):
    """Пропускает записи не выше заданного уровня."""

    def __init__(self, max_level: int):
        super().__init__()
        self.max_level = max_level

    def filter(self, record: logging.LogRecord) -> bool:
        return record.levelno <= self.max_level


def configure(level: str = "INFO") -> None:
    fmt = logging.Formatter("%(levelname)-8s %(message)s")

    out = logging.StreamHandler(sys.stdout)
    out.setFormatter(fmt)
    out.addFilter(MaxLevelFilter(logging.INFO))   # только DEBUG и INFO

    err = logging.StreamHandler(sys.stderr)
    err.setFormatter(fmt)
    err.setLevel(logging.WARNING)                  # WARNING и выше

    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(out)
    root.addHandler(err)
    root.setLevel(level.upper())


if __name__ == "__main__":
    configure()
    log = logging.getLogger("app")
    log.info("информационное сообщение")
    log.warning("предупреждение")
    log.error("ошибка")
PY

cat > Dockerfile.split <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY split_streams.py /app.py
CMD ["python", "/app.py"]
EOF

docker build -q -f Dockerfile.split -t log:split . > /dev/null
docker run -d --name split-t log:split > /dev/null
sleep 1

echo "=== только stdout ==="
docker logs split-t 2>/dev/null | sed 's/^/  /'
echo "=== только stderr ==="
docker logs split-t 2>&1 1>/dev/null | sed 's/^/  /'
docker rm split-t > /dev/null
text
=== только stdout ===
  INFO     информационное сообщение
=== только stderr ===
  WARNING  предупреждение
  ERROR    ошибка

Разделение работает — но обратите внимание на MaxLevelFilter. Без него запись уровня ERROR попала бы в оба потока: обработчик stdout не имеет верхней границы.

Это частая ошибка: устанавливают setLevel(INFO) на обработчике stdout, думая, что это ограничит его сверху, — но setLevel задаёт нижнюю границу.

Correlation ID

bash
cat > correlation.py <<'PY'
"""Correlation ID через contextvars: доступен во всех функциях запроса."""
from __future__ import annotations

import contextvars
import json
import logging
import sys
import uuid
from datetime import datetime, timezone

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


class CorrelationFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc)
            .isoformat(timespec="milliseconds").replace("+00:00", "Z"),
            "level": record.levelname,
            "logger": record.name,
            "request_id": request_id_var.get(),
            "message": record.getMessage(),
        }
        for k, v in getattr(record, "extra_fields", {}).items():
            payload.setdefault(k, v)
        return json.dumps(payload, ensure_ascii=False, default=str)


def configure(level: str = "INFO") -> None:
    h = logging.StreamHandler(sys.stdout)
    h.setFormatter(CorrelationFormatter())
    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(h)
    root.setLevel(level.upper())


log = logging.getLogger("app")


def validate(payload: dict) -> None:
    """Вложенная функция: request_id доступен без передачи параметром."""
    log.info("валидация", extra={"extra_fields": {"fields": len(payload)}})


def persist(payload: dict) -> None:
    log.info("сохранение в базу")


def handle_request(payload: dict) -> None:
    token = request_id_var.set(f"req-{uuid.uuid4().hex[:8]}")
    try:
        log.info("запрос принят")
        validate(payload)
        persist(payload)
        log.info("запрос обработан")
    finally:
        request_id_var.reset(token)


if __name__ == "__main__":
    configure()
    for i in range(2):
        handle_request({"a": 1, "b": 2})
PY

cat > Dockerfile.corr <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY correlation.py /app.py
CMD ["python", "/app.py"]
EOF

docker build -q -f Dockerfile.corr -t log:corr . > /dev/null
docker run --rm log:corr | python3 -c "
import json, sys
for line in sys.stdin:
    r = json.loads(line)
    print(f\"  {r['request_id']}  {r['message']}\")
"
text
  req-3f2a1b4c  запрос принят
  req-3f2a1b4c  валидация
  req-3f2a1b4c  сохранение в базу
  req-3f2a1b4c  запрос обработан
  req-8e7d6c5b  запрос принят
  req-8e7d6c5b  валидация
  req-8e7d6c5b  сохранение в базу
  req-8e7d6c5b  запрос обработан

Все записи одного запроса связаны идентификатором, при этом функции validate и persist его не принимают — значение берётся из контекста.

При конкурентной обработке записи разных запросов перемешаются в потоке, но request_id позволит их разделить.

Логи сервера в едином формате

bash
cat > uvicorn_app.py <<'PY'
"""Логи Uvicorn в том же формате, что и логи приложения."""
from __future__ import annotations

import json
import logging
import os
import sys
from datetime import datetime, timezone

from fastapi import FastAPI


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc)
            .isoformat(timespec="milliseconds").replace("+00:00", "Z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        for k, v in getattr(record, "extra_fields", {}).items():
            payload.setdefault(k, v)
        return json.dumps(payload, ensure_ascii=False, default=str)


def configure(level: str = "INFO") -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())

    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level.upper())

    # Логгеры Uvicorn: убираем их обработчики, включаем передачу корневому
    for name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
        lg = logging.getLogger(name)
        lg.handlers.clear()
        lg.propagate = True


configure(os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger("app")

app = FastAPI()


@app.get("/")
async def root():
    log.info("обработка запроса", extra={"extra_fields": {"path": "/"}})
    return {"ok": True}
PY

cat > Dockerfile.uvicorn <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir "fastapi[standard]==0.141.1"
WORKDIR /app
COPY uvicorn_app.py .
CMD ["fastapi", "run", "uvicorn_app.py", "--port", "8000"]
EOF

docker build -q -f Dockerfile.uvicorn -t log:uvicorn . > /dev/null
docker run -d --name uv-t -p 8200:8000 log:uvicorn > /dev/null
sleep 6
curl -s localhost:8200/ > /dev/null

echo "все записи, включая uvicorn, в одном формате:"
docker logs uv-t 2>&1 | grep '^{' | python3 -c "
import json, sys
for line in sys.stdin:
    try:
        r = json.loads(line)
    except json.JSONDecodeError:
        continue
    print(f\"  [{r['logger']:<16}] {r['message'][:60]}\")
" | head -6
docker rm -f uv-t > /dev/null
text
все записи, включая uvicorn, в одном формате:
  [uvicorn.error   ] Started server process [1]
  [uvicorn.error   ] Waiting for application startup.
  [uvicorn.error   ] Application startup complete.
  [app             ] обработка запроса
  [uvicorn.access  ] 172.17.0.1:52134 - "GET / HTTP/1.1" 200

Записи приложения и сервера в едином JSON — их можно обрабатывать одним парсером.

Без настройки Uvicorn выводил бы свои строки в собственном текстовом формате, и парсер спотыкался бы на каждой.

Ротация логов Docker

Приложение пишет в stdout — ротацией занимается Docker:

bash
docker run -d --name rotate-t \
    --log-driver local \
    --log-opt max-size=1m \
    --log-opt max-file=3 \
    log:json > /dev/null 2>&1 || \
docker run -d --name rotate-t \
    --log-opt max-size=1m \
    --log-opt max-file=3 \
    log:json > /dev/null

sleep 2
docker inspect rotate-t --format 'драйвер: {{.HostConfig.LogConfig.Type}}, опции: {{.HostConfig.LogConfig.Config}}'
docker rm -f rotate-t > /dev/null
text
драйвер: local, опции: map[max-file:3 max-size:1m]

Настройка по умолчанию для всех container задаётся в daemon.json (урок 1.3). Без ротации логи растут неограниченно — частая причина заполнения диска.

Уборка

bash
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=log:*') 2>/dev/null || true
rm -rf /tmp/pylog

Практическое упражнение

Задание. Напишите модуль logging_config.py, готовый к использованию в production-сервисе.

Требования:

  1. Structured logging в JSON, одна строка на запись.
  2. Уровень задаётся переменной LOG_LEVEL с проверкой допустимости.
  3. Поддержка дополнительных полей через extra.
  4. Correlation ID через contextvars, автоматически добавляемый в каждую запись.
  5. Логи Uvicorn в том же формате.
  6. Переключение между JSON и человекочитаемым форматом переменной LOG_FORMAT — для локальной разработки.
  7. Маскирование полей с чувствительными именами (password, token, secret, api_key).

Напишите тесты, проверяющие требования 1, 3, 4 и 7.

Подсказки

Подсказка 1

Для требования 7 достаточно проверять имя ключа по списку подстрок и заменять значение на ***.

Подсказка 2

Тестировать форматтер удобно напрямую: создать LogRecord и вызвать format, разобрав результат через json.loads.

Подсказка 3

Для требования 6 достаточно выбирать класс форматтера по значению переменной.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
mkdir -p /tmp/logging-ex && cd /tmp/logging-ex

cat > logging_config.py <<'PY'
"""Настройка логирования для production-сервиса.

Возможности:
    - structured logging в JSON или человекочитаемый формат;
    - correlation ID через contextvars;
    - дополнительные поля через extra;
    - маскирование чувствительных значений;
    - единый формат для логов приложения и Uvicorn.
"""
from __future__ import annotations

import contextvars
import json
import logging
import os
import sys
from datetime import datetime, timezone

VALID_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})

# Подстроки в именах полей, значения которых маскируются
SENSITIVE_MARKERS = ("password", "passwd", "token", "secret", "api_key", "apikey", "authorization")

MASK = "***"

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


def is_sensitive(key: str) -> bool:
    lowered = key.lower()
    return any(marker in lowered for marker in SENSITIVE_MARKERS)


def mask_fields(data: dict[str, object]) -> dict[str, object]:
    """Заменяет значения чувствительных полей, включая вложенные словари."""
    result: dict[str, object] = {}
    for key, value in data.items():
        if is_sensitive(key):
            result[key] = MASK
        elif isinstance(value, dict):
            result[key] = mask_fields(value)
        else:
            result[key] = value
    return result


def _timestamp(created: float) -> str:
    return (
        datetime.fromtimestamp(created, tz=timezone.utc)
        .isoformat(timespec="milliseconds")
        .replace("+00:00", "Z")
    )


def _collect_extra(record: logging.LogRecord) -> dict[str, object]:
    fields = getattr(record, "extra_fields", None)
    if not isinstance(fields, dict):
        return {}
    return mask_fields(fields)


class JsonFormatter(logging.Formatter):
    """Одна строка JSON на запись."""

    def format(self, record: logging.LogRecord) -> str:
        payload: dict[str, object] = {
            "ts": _timestamp(record.created),
            "level": record.levelname,
            "logger": record.name,
            "request_id": request_id_var.get(),
            "message": record.getMessage(),
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        for key, value in _collect_extra(record).items():
            payload.setdefault(key, value)
        return json.dumps(payload, ensure_ascii=False, default=str)


class HumanFormatter(logging.Formatter):
    """Читаемый формат для локальной разработки."""

    def format(self, record: logging.LogRecord) -> str:
        rid = request_id_var.get()
        rid_part = f" [{rid}]" if rid != "-" else ""
        base = (
            f"{_timestamp(record.created)} {record.levelname:<8}"
            f" [{record.name}]{rid_part} {record.getMessage()}"
        )
        extra = _collect_extra(record)
        if extra:
            pairs = " ".join(f"{k}={v}" for k, v in extra.items())
            base = f"{base} | {pairs}"
        if record.exc_info:
            base = f"{base}\n{self.formatException(record.exc_info)}"
        return base


def configure(level: str | None = None, fmt: str | None = None) -> None:
    """Настраивает корневой логгер и логгеры Uvicorn."""
    level = (level or os.environ.get("LOG_LEVEL", "INFO")).upper()
    if level not in VALID_LEVELS:
        print(
            f"недопустимый LOG_LEVEL: {level}, использую INFO",
            file=sys.stderr,
        )
        level = "INFO"

    fmt = (fmt or os.environ.get("LOG_FORMAT", "json")).lower()
    formatter = HumanFormatter() if fmt == "human" else JsonFormatter()

    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(formatter)

    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level)

    # Логи Uvicorn — через тот же обработчик
    for name in ("uvicorn", "uvicorn.access", "uvicorn.error", "gunicorn.error", "gunicorn.access"):
        lg = logging.getLogger(name)
        lg.handlers.clear()
        lg.propagate = True


def set_request_id(value: str):
    """Устанавливает correlation ID; возвращает token для reset."""
    return request_id_var.set(value)


def reset_request_id(token) -> None:
    request_id_var.reset(token)
PY

cat > test_logging_config.py <<'PY'
"""Тесты конфигурации логирования."""
import json
import logging

import pytest

import logging_config as lc


def make_record(msg="сообщение", extra=None, level=logging.INFO):
    record = logging.LogRecord(
        name="test.logger", level=level, pathname=__file__,
        lineno=1, msg=msg, args=(), exc_info=None,
    )
    if extra is not None:
        record.extra_fields = extra
    return record


@pytest.fixture(autouse=True)
def reset_context():
    token = lc.request_id_var.set("-")
    yield
    lc.request_id_var.reset(token)


# ── Требование 1: валидный JSON ──

def test_output_is_valid_json():
    out = lc.JsonFormatter().format(make_record())
    parsed = json.loads(out)
    assert parsed["message"] == "сообщение"
    assert parsed["level"] == "INFO"
    assert parsed["logger"] == "test.logger"


def test_json_is_single_line():
    out = lc.JsonFormatter().format(make_record("строка"))
    assert "\n" not in out


def test_json_has_timestamp():
    parsed = json.loads(lc.JsonFormatter().format(make_record()))
    assert parsed["ts"].endswith("Z")


# ── Требование 3: дополнительные поля ──

def test_extra_fields_included():
    rec = make_record(extra={"duration_ms": 42, "status": 200})
    parsed = json.loads(lc.JsonFormatter().format(rec))
    assert parsed["duration_ms"] == 42
    assert parsed["status"] == 200


def test_extra_does_not_override_core_fields():
    rec = make_record(extra={"level": "ПОДМЕНА", "message": "ПОДМЕНА"})
    parsed = json.loads(lc.JsonFormatter().format(rec))
    assert parsed["level"] == "INFO"
    assert parsed["message"] == "сообщение"


# ── Требование 4: correlation ID ──

def test_request_id_default():
    parsed = json.loads(lc.JsonFormatter().format(make_record()))
    assert parsed["request_id"] == "-"


def test_request_id_from_context():
    token = lc.set_request_id("req-abc123")
    try:
        parsed = json.loads(lc.JsonFormatter().format(make_record()))
        assert parsed["request_id"] == "req-abc123"
    finally:
        lc.reset_request_id(token)


def test_request_id_reset():
    token = lc.set_request_id("req-xyz")
    lc.reset_request_id(token)
    parsed = json.loads(lc.JsonFormatter().format(make_record()))
    assert parsed["request_id"] == "-"


# ── Требование 7: маскирование ──

@pytest.mark.parametrize(
    "field",
    ["password", "PASSWORD", "db_password", "api_key", "apiKey", "token",
     "access_token", "secret", "client_secret", "authorization"],
)
def test_sensitive_fields_masked(field):
    rec = make_record(extra={field: "значение-которое-нельзя-логировать"})
    parsed = json.loads(lc.JsonFormatter().format(rec))
    assert parsed[field] == lc.MASK
    assert "значение-которое-нельзя-логировать" not in json.dumps(parsed)


def test_nested_sensitive_masked():
    rec = make_record(extra={"config": {"host": "db", "password": "secret123"}})
    parsed = json.loads(lc.JsonFormatter().format(rec))
    assert parsed["config"]["password"] == lc.MASK
    assert parsed["config"]["host"] == "db"


def test_non_sensitive_not_masked():
    rec = make_record(extra={"user_id": 17, "path": "/api"})
    parsed = json.loads(lc.JsonFormatter().format(rec))
    assert parsed["user_id"] == 17
    assert parsed["path"] == "/api"


# ── Требование 6: человекочитаемый формат ──

def test_human_format_readable():
    rec = make_record("привет", extra={"count": 3})
    out = lc.HumanFormatter().format(rec)
    assert "привет" in out
    assert "count=3" in out
    assert not out.startswith("{")


def test_human_format_masks_too():
    rec = make_record(extra={"token": "abc123"})
    out = lc.HumanFormatter().format(rec)
    assert "abc123" not in out
    assert lc.MASK in out


# ── Требование 2: проверка уровня ──

def test_invalid_level_falls_back(capsys):
    lc.configure(level="VERBOSE")
    assert logging.getLogger().level == logging.INFO
    assert "недопустимый LOG_LEVEL" in capsys.readouterr().err


def test_valid_level_applied():
    lc.configure(level="DEBUG")
    assert logging.getLogger().level == logging.DEBUG
PY

python3 -m pytest -q test_logging_config.py 2>&1 | tail -3
text
.....................                                                    [100%]
21 passed in 0.07s

Проверка в работе:

bash
cat > demo.py <<'PY'
import logging
import uuid

from logging_config import configure, set_request_id, reset_request_id

configure()
log = logging.getLogger("app.api")

log.info("сервис запущен", extra={"extra_fields": {"version": "1.0"}})

token = set_request_id(f"req-{uuid.uuid4().hex[:8]}")
try:
    log.info("вход пользователя", extra={"extra_fields": {
        "user": "alice",
        "password": "hunter2",
        "api_key": "sk-live-abc123",
        "duration_ms": 34,
    }})
finally:
    reset_request_id(token)
PY

echo "=== JSON (production) ==="
python3 demo.py

echo
echo "=== human (разработка) ==="
LOG_FORMAT=human python3 demo.py
text
=== JSON (production) ===
{"ts":"2026-07-30T13:02:44.118Z","level":"INFO","logger":"app.api","request_id":"-","message":"сервис запущен","version":"1.0"}
{"ts":"2026-07-30T13:02:44.118Z","level":"INFO","logger":"app.api","request_id":"req-7f3a2b1c","message":"вход пользователя","user":"alice","password":"***","api_key":"***","duration_ms":34}

=== human (разработка) ===
2026-07-30T13:02:44.201Z INFO     [app.api] сервис запущен | version=1.0
2026-07-30T13:02:44.201Z INFO     [app.api] [req-7f3a2b1c] вход пользователя | user=alice password=*** api_key=*** duration_ms=34

Четыре решения, определяющие качество модуля.

payload.setdefault вместо прямого присваивания. Дополнительные поля не могут перезаписать level, message и другие служебные — тест test_extra_does_not_override_core_fields это фиксирует. Без этого случайное поле level в extra испортило бы фильтрацию по уровню во всей системе сбора логов.

Рекурсивное маскирование. Секреты часто попадают в логи внутри вложенных словарей — например, при логировании конфигурации целиком. Проверка только верхнего уровня пропустила бы их.

Маскирование по подстроке, а не по точному совпадению. Список имён полей бесконечен: password, db_password, PASSWORD, user_password. Проверка вхождения подстроки в нижнем регистре покрывает все варианты одним правилом.

Маскирование работает в обоих форматах. Тест test_human_format_masks_too не случаен: человекочитаемый формат используется локально, но код может попасть в production с LOG_FORMAT=human, и защита должна работать и там.

Ограничение подхода. Маскирование по имени поля не спасёт, если секрет попал в само сообщение: log.info(f"подключаюсь с паролем {password}"). Здесь помогает только дисциплина и проверка на code review — автоматически такие случаи не отлавливаются.

Проверка результата

bash
mkdir -p /tmp/vlog && cd /tmp/vlog
cat > a.py <<'PY'
import logging, sys
h = logging.StreamHandler(sys.stdout)
h.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
r = logging.getLogger(); r.handlers.clear(); r.addHandler(h); r.setLevel("INFO")
logging.getLogger("app").info("видно сразу")
PY
printf 'FROM python:3.13-slim\nENV PYTHONUNBUFFERED=1\nCOPY a.py /a.py\nCMD ["python","/a.py"]\n' > Dockerfile
docker build -q -t vlog:1 . > /dev/null
docker run --rm vlog:1
docker rmi vlog:1 > /dev/null; cd /tmp && rm -rf /tmp/vlog

Ожидается INFO видно сразу.

Типичные ошибки

ОшибкаПричинаИсправление
Логи в файл внутри containerПривычка из обычных приложенийDocker не видит файл; писать в stdout
Нет PYTHONUNBUFFEREDНе знают о буферизацииЛоги не появляются; см. урок 6.4
print вместо logging в сервисеПрощеНет уровней, формата и совместимости с библиотеками
basicConfig не сработалОбработчик уже добавлен библиотекойНастраивать корневой логгер явно с handlers.clear()
setLevel на обработчике как верхняя границаНазвание вводит в заблуждениеsetLevel задаёт нижнюю; нужен Filter
Дублирование записейОбработчик и у логгера, и у корневого при propagate=TrueУбрать один из них
Логи Uvicorn в другом форматеСвои обработчики у логгеров сервераhandlers.clear() и propagate = True
Секреты в логахЛогируют объект целикомМаскирование по имени поля
extra без вложенного словаряКлючи попадают прямо в LogRecordКонфликт со служебными именами; использовать extra_fields
Нет ротации логовНастройка по умолчаниюДиск заполняется; max-size и max-file

Контрольные вопросы

На понимание:

  1. Назовите две независимые причины, по которым docker logs пуст.
  2. Почему запись логов в файл внутри container — плохая практика? Приведите три аргумента.
  3. Почему logging.basicConfig может молча не сработать?
  4. Почему setLevel(INFO) на обработчике stdout не мешает попаданию туда записей ERROR?
  5. В чём практическое преимущество structured logging перед текстовым?

На применение:

  1. Как направить логи Uvicorn в том же формате, что и логи приложения?
  2. Как добавить к записи дополнительные поля, не конфликтуя со служебными?
  3. Как связать записи одного запроса, не передавая идентификатор параметром?

На диагностику:

  1. Логи выводятся дважды. Причина?
  2. Приложение пишет логи, файл /var/log/app.log растёт, но docker logs пуст. Что происходит?

Краткое резюме

  1. Две независимые причины пропажи логов: буферизация и запись в файл.
  2. Приложение в container пишет в stdout; ротация и хранение — забота инфраструктуры.
  3. Для сервиса используйте logging, а не print: уровни, формат, совместимость с библиотеками.
  4. Настраивайте корневой логгер явно с handlers.clear()basicConfig ненадёжен.
  5. setLevel на обработчике задаёт нижнюю границу; для верхней нужен Filter.
  6. Structured logging в JSON даёт фильтрацию по полям вместо разбора строк.
  7. Дополнительные поля передавайте через вложенный словарь, чтобы не конфликтовать со служебными.
  8. Correlation ID через contextvars связывает записи одного запроса без передачи параметром.
  9. Логгеры Uvicorn и Gunicorn настраиваются через handlers.clear() и propagate = True.
  10. Секреты маскируются по имени поля, включая вложенные словари.

Официальные источники

ИсточникСсылкаЧто подтверждает
Python: logginghttps://docs.python.org/3/library/logging.htmlУровни, обработчики, форматтеры, фильтры, propagate
Python: logging cookbookhttps://docs.python.org/3/howto/logging-cookbook.htmlНастройка нескольких обработчиков, фильтрация по уровню
Python: logging.basicConfighttps://docs.python.org/3/library/logging.html#logging.basicConfigПоведение при уже настроенных обработчиках
Python: contextvarshttps://docs.python.org/3/library/contextvars.htmlКонтекстные переменные для correlation ID
Docker: view container logshttps://docs.docker.com/engine/logging/Перехват stdout и stderr, драйверы логирования
Docker: configure logging drivershttps://docs.docker.com/engine/logging/configure/max-size, max-file, драйверы json-file и local
docker logs referencehttps://docs.docker.com/reference/cli/docker/container/logs/Разделение потоков, фильтрация по времени
The Twelve-Factor App: Logshttps://12factor.net/logsЛоги как поток событий, а не файл
Uvicorn: settingshttps://www.uvicorn.org/settings/Конфигурация логирования сервера
Gunicorn: logginghttps://docs.gunicorn.org/en/stable/settings.html#loggingaccesslog, errorlog, вывод в stdout

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Non-root user и permissions
Главное оглавление

Markdown на GitHub ↗