6.6. Logging
Цели
После этого материала вы сможете:
- назвать две независимые причины, по которым логи Python не появляются в
docker logs; - настроить модуль
loggingдля контейнеризованного приложения; - реализовать structured logging в JSON без внешних зависимостей;
- обоснованно распределить вывод между stdout и stderr;
- объяснить, почему логи не пишут в файл внутри container;
- объединить логи приложения и сервера (Uvicorn, Gunicorn) в едином формате.
Предварительные знания
- 6.4. Environment variables — буферизация;
- 4.3. Exec, logs, inspect — как Docker собирает логи;
- базовое знакомство с модулем
logging.
Ключевые термины
| Термин | Объяснение |
|---|---|
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 — файл он не видит.
приложение ──► 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 против logging
Для простого скрипта print достаточен. Для сервиса — нет.
print | logging | |
|---|---|---|
| Уровни важности | нет | есть |
| Фильтрация без правки кода | нет | через уровень |
| Метка времени | вручную | автоматически |
| Имя модуля-источника | вручную | автоматически |
| Трассировка исключений | вручную | 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
Обычный лог — строка для человека:
2026-07-30 12:14:33 INFO [app.api] запрос обработан за 42 мс
Structured log — объект для машины:
{"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 — добавлять контекст к записи:
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 смешиваются два разных формата — это ломает машинную обработку.
Решение — направить логгеры сервера через тот же обработчик:
for name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
logger = logging.getLogger(name)
logger.handlers.clear()
logger.propagate = True
После этого записи сервера проходят через корневой логгер и получают ваш форматтер.
Внутренний механизм
Путь записи в logging
logger.info("сообщение")
│
▼
создаётся LogRecord
│
▼
проверка уровня логгера ──► отброшено, если ниже
│
▼
применяются фильтры
│
▼
передаётся handlers логгера
│
├─► propagate=True ──► handlers логгера-предка
│
▼
formatter превращает в строку
│
▼
handler пишет в назначение (stdout)
Понимание этой цепочки объясняет типичные проблемы: дублирование записей (обработчик и у логгера, и у корневого при propagate=True) и молчание (уровень логгера выше уровня записи).
Почему basicConfig иногда не работает
Функция logging.basicConfig настраивает корневой логгер, только если у него ещё нет обработчиков. Повторный вызов молча ничего не делает.
Проблема возникает, когда библиотека вызвала basicConfig раньше вашего кода. Признак — логи выводятся в неожиданном формате.
Надёжный способ — настраивать корневой логгер явно, очищая существующие обработчики:
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
Команды и примеры
Подготовка
mkdir -p /tmp/pylog && cd /tmp/pylog
Две причины пропажи логов
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
cause1 строк в docker logs: 0
cause2 строк в docker logs: 0
но в causa2 файл есть:
3
2026-07-30 12:31:07,842 INFO запись 2 — идёт в файл, не в stdout
Симптом одинаков, причины разные. Во втором случае логи существуют — просто не там, где их ищет Docker.
Различающая проверка:
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
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
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 отсутствует — уровень выше. Проверим:
docker run --rm -e LOG_LEVEL=DEBUG log:basic 2>&1 | head -2
2026-07-30T12:35:40 DEBUG [app.worker] отладочное — видно только при LOG_LEVEL=DEBUG
2026-07-30T12:35:40 INFO [app.worker] приложение запущено
Уровень меняется переменной окружения без пересборки образа.
Structured logging в JSON
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
{"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. Проверим и покажем практическую пользу:
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')})\")
"
=== все строки валидны как JSON? ===
5 строк, все валидны
=== запросы дольше 300 мс ===
req-000: 412 мс
req-002: 638 мс
=== только ошибки ===
ошибка обработки (request_id=req-003)
Это и есть смысл structured logging: фильтрация по полю вместо разбора строк регулярным выражением.
stdout и stderr
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
=== только stdout ===
INFO информационное сообщение
=== только stderr ===
WARNING предупреждение
ERROR ошибка
Разделение работает — но обратите внимание на MaxLevelFilter. Без него запись уровня ERROR попала бы в оба потока: обработчик stdout не имеет верхней границы.
Это частая ошибка: устанавливают setLevel(INFO) на обработчике stdout, думая, что это ограничит его сверху, — но setLevel задаёт нижнюю границу.
Correlation ID
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']}\")
"
req-3f2a1b4c запрос принят
req-3f2a1b4c валидация
req-3f2a1b4c сохранение в базу
req-3f2a1b4c запрос обработан
req-8e7d6c5b запрос принят
req-8e7d6c5b валидация
req-8e7d6c5b сохранение в базу
req-8e7d6c5b запрос обработан
Все записи одного запроса связаны идентификатором, при этом функции validate и persist его не принимают — значение берётся из контекста.
При конкурентной обработке записи разных запросов перемешаются в потоке, но request_id позволит их разделить.
Логи сервера в едином формате
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
все записи, включая 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:
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
драйвер: local, опции: map[max-file:3 max-size:1m]
Настройка по умолчанию для всех container задаётся в daemon.json (урок 1.3). Без ротации логи растут неограниченно — частая причина заполнения диска.
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=log:*') 2>/dev/null || true
rm -rf /tmp/pylog
Практическое упражнение
Задание. Напишите модуль logging_config.py, готовый к использованию в production-сервисе.
Требования:
- Structured logging в JSON, одна строка на запись.
- Уровень задаётся переменной
LOG_LEVELс проверкой допустимости. - Поддержка дополнительных полей через
extra. - Correlation ID через
contextvars, автоматически добавляемый в каждую запись. - Логи Uvicorn в том же формате.
- Переключение между JSON и человекочитаемым форматом переменной
LOG_FORMAT— для локальной разработки. - Маскирование полей с чувствительными именами (
password,token,secret,api_key).
Напишите тесты, проверяющие требования 1, 3, 4 и 7.
Подсказки
Подсказка 1
Для требования 7 достаточно проверять имя ключа по списку подстрок и заменять значение на ***.
Подсказка 2
Тестировать форматтер удобно напрямую: создать LogRecord и вызвать format, разобрав результат через json.loads.
Подсказка 3
Для требования 6 достаточно выбирать класс форматтера по значению переменной.
Решение
Сначала выполните задание самостоятельно.
Показать решение
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
..................... [100%]
21 passed in 0.07s
Проверка в работе:
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
=== 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 — автоматически такие случаи не отлавливаются.
Проверка результата
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 |
Контрольные вопросы
На понимание:
- Назовите две независимые причины, по которым
docker logsпуст. - Почему запись логов в файл внутри container — плохая практика? Приведите три аргумента.
- Почему
logging.basicConfigможет молча не сработать? - Почему
setLevel(INFO)на обработчике stdout не мешает попаданию туда записейERROR? - В чём практическое преимущество structured logging перед текстовым?
На применение:
- Как направить логи Uvicorn в том же формате, что и логи приложения?
- Как добавить к записи дополнительные поля, не конфликтуя со служебными?
- Как связать записи одного запроса, не передавая идентификатор параметром?
На диагностику:
- Логи выводятся дважды. Причина?
- Приложение пишет логи, файл
/var/log/app.logрастёт, ноdocker logsпуст. Что происходит?
Краткое резюме
- Две независимые причины пропажи логов: буферизация и запись в файл.
- Приложение в container пишет в stdout; ротация и хранение — забота инфраструктуры.
- Для сервиса используйте
logging, а неprint: уровни, формат, совместимость с библиотеками. - Настраивайте корневой логгер явно с
handlers.clear()—basicConfigненадёжен. setLevelна обработчике задаёт нижнюю границу; для верхней нуженFilter.- Structured logging в JSON даёт фильтрацию по полям вместо разбора строк.
- Дополнительные поля передавайте через вложенный словарь, чтобы не конфликтовать со служебными.
- Correlation ID через
contextvarsсвязывает записи одного запроса без передачи параметром. - Логгеры Uvicorn и Gunicorn настраиваются через
handlers.clear()иpropagate = True. - Секреты маскируются по имени поля, включая вложенные словари.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Python: logging | https://docs.python.org/3/library/logging.html | Уровни, обработчики, форматтеры, фильтры, propagate |
| Python: logging cookbook | https://docs.python.org/3/howto/logging-cookbook.html | Настройка нескольких обработчиков, фильтрация по уровню |
Python: logging.basicConfig | https://docs.python.org/3/library/logging.html#logging.basicConfig | Поведение при уже настроенных обработчиках |
Python: contextvars | https://docs.python.org/3/library/contextvars.html | Контекстные переменные для correlation ID |
| Docker: view container logs | https://docs.docker.com/engine/logging/ | Перехват stdout и stderr, драйверы логирования |
| Docker: configure logging drivers | https://docs.docker.com/engine/logging/configure/ | max-size, max-file, драйверы json-file и local |
| docker logs reference | https://docs.docker.com/reference/cli/docker/container/logs/ | Разделение потоков, фильтрация по времени |
| The Twelve-Factor App: Logs | https://12factor.net/logs | Логи как поток событий, а не файл |
| Uvicorn: settings | https://www.uvicorn.org/settings/ | Конфигурация логирования сервера |
| Gunicorn: logging | https://docs.gunicorn.org/en/stable/settings.html#logging | accesslog, errorlog, вывод в stdout |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Non-root user и permissions
Главное оглавление