13.1. Логи и logging drivers
Цели
После этого материала вы сможете:
- проследить путь строки от
print()в Python до выводаdocker logs; - объяснить, почему без ротации диск заканчивается, и настроить её;
- выбрать драйвер под задачу и знать, какие из них ломают
docker logs; - объяснить, почему длинная строка JSON приходит в агрегатор разорванной;
- диагностировать зависание приложения, вызванное логированием;
- настроить логирование Python-приложения так, чтобы ничего не терялось.
Предварительные знания
- 4.2. Состояния container;
- 6.6. Логирование — настройка Python;
- 9.1. Основы Compose.
Ключевые термины
| Термин | Объяснение |
|---|---|
logging driver | Механизм, куда daemon направляет поток вывода |
json-file | Драйвер по умолчанию: строки в файле JSON |
local | Драйвер с бинарным форматом и ротацией из коробки |
ротация | Ограничение размера с удалением старых частей |
dual logging | Локальный кэш, позволяющий docker logs при удалённом драйвере |
partial | Признак фрагмента разорванной длинной строки |
Теория
Путь строки лога
приложение container host
────────── ───────── ────
print("строка")
│
▼
буфер stdout ← PYTHONUNBUFFERED решает, есть ли задержка
│
▼
запись в fd 1
│
▼
pipe, созданный shim ──────────► процесс shim
│
▼
logging driver
│
▼
/var/lib/docker/containers/<id>/<id>-json.log
│
▼
docker logs
Ключевое: приложение не знает о существовании файла. Оно пишет в дескриптор 1, который является концом канала. Другой конец читает процесс-посредник (shim) и передаёт драйверу.
Отсюда два практических следствия:
Логи не нужно писать в файл внутри container. Файл придётся ротировать самому, монтировать, чтобы прочитать снаружи, и он исчезнет вместе с container. Вывод в stdout решает всё это за вас.
Буферизация Python видна в логах как задержка. Если stdout буферизован, строки доходят до драйвера порциями по несколько килобайт — или не доходят вовсе при аварийном завершении.
Драйверы
| Драйвер | Куда пишет | docker logs | Когда применять |
|---|---|---|---|
json-file | Файл JSON | Да | По умолчанию; ротацию настроить вручную |
local | Бинарный формат | Да | Локальные логи; ротация из коробки |
journald | systemd journal | Да | Единый журнал с системными службами |
syslog | Демон syslog | Через кэш | Централизация по стандарту |
fluentd | Fluentd/Fluent Bit | Через кэш | Агрегация в кластере |
awslogs, gcplogs | Облачный сервис | Через кэш | Управляемая инфраструктура |
none | Никуда | Нет | Приложение пишет само |
Строка «через кэш» — это dual logging: daemon дополнительно сохраняет копию локально, чтобы docker logs работал даже с удалённым драйвером. Механизм включён по умолчанию.
Знать о нём нужно по неочевидной причине: локальный кэш тоже занимает место и тоже требует ротации. Переход на fluentd не избавляет от расхода диска, как многие ожидают.
Ротация: главная практическая деталь
Драйвер json-file не ротирует логи по умолчанию. Файл растёт, пока не кончится место в /var/lib/docker.
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"compress": "true"
}
}
| Параметр | Значение | Смысл |
|---|---|---|
max-size | 10m | Размер одной части |
max-file | 3 | Сколько частей хранить |
compress | true | Сжимать неактивные части |
Верхняя граница расхода на container: max-size × max-file = 30 МиБ. Умножьте на число container'ов — это и есть бюджет.
Драйвер local ротирует по умолчанию: 20 МиБ на часть, 5 частей. Это одна из причин предпочесть его, если логи не уходят наружу.
Существенное ограничение: настройка в daemon.json применяется только к новым container'ам. Уже работающие продолжают со старыми параметрами до пересоздания.
Разрыв длинных строк
Docker читает поток кусками по 16 КиБ. Строка длиннее разрезается на несколько записей.
{"log":"{\"event\":\"нача","stream":"stdout","time":"...","attrs":{"partial_id":"a1b2","partial_ordinal":1,"partial_last":false}}
{"log":"ло\"}\n","stream":"stdout","time":"...","attrs":{"partial_id":"a1b2","partial_ordinal":2,"partial_last":true}}
Для человека, читающего docker logs, разрыв незаметен — вывод склеивается. Для агрегатора логов, разбирающего каждую строку как JSON, — это две невалидные записи.
Практическое следствие для структурированного логирования Python: если вы кладёте в запись traceback или тело запроса, строка легко превысит 16 КиБ, и запись попадёт в агрегатор битой.
Мера — ограничивать длину полей на стороне приложения, а не надеяться на сборку фрагментов.
Блокирующий режим
По умолчанию драйвер работает в режиме blocking: если приёмник логов не успевает, запись в канал блокируется — а значит, блокируется write() в приложении.
приёмник логов тормозит
│
▼
буфер канала заполнен
│
▼
write() в приложении не возвращается
│
▼
поток приложения висит
Это реальная причина зависаний, которую трудно связать с логированием: приложение «висит», а профилировщик показывает ожидание в write.
Альтернатива:
docker run --log-opt mode=non-blocking --log-opt max-buffer-size=4m ...
В этом режиме при переполнении буфера строки отбрасываются, но приложение не блокируется. Выбор — между потерей логов и остановкой сервиса.
Для большинства сервисов правильный ответ — non-blocking: потеря части логов менее вредна, чем зависание.
Compose
services:
api:
image: myapp:1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Удобный приём — общий якорь для всех сервисов (урок 9.6):
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
api:
logging: *default-logging
worker:
logging: *default-logging
Внутренний механизм
Где лежит файл
/var/lib/docker/containers/<полный-id>/<полный-id>-json.log
Формат — по одному объекту JSON на строку:
{"log":"строка\n","stream":"stdout","time":"2026-07-31T10:15:30.123456789Z"}
Три следствия из формата:
Накладные расходы заметны. На каждую строку добавляется около 60 байт метаданных плюс экранирование. Короткие частые строки могут занимать вдвое больше, чем сам текст. Драйвер local использует бинарный формат и этого не имеет.
Время — момент чтения daemon, а не момент вызова print(). При буферизации разница достигает секунд.
stdout и stderr — разные каналы. Порядок между ними не гарантирован: строка, записанная в stderr раньше, может оказаться в файле позже.
Почему docker logs --tail не мгновенен
Файл JSON не имеет индекса. Чтобы отдать последние N строк, драйвер читает файл с конца блоками и разбирает записи в обратном порядке. На файле в сотни мегабайт это заметно.
Ещё одна причина настроить ротацию: она ограничивает не только диск, но и время отклика docker logs.
Команды и примеры
Буферизация Python: что видно и когда
mkdir -p /tmp/logs && cd /tmp/logs
cat > buffered.py <<'PY'
"""Демонстрация буферизации stdout при выводе не в терминал."""
import sys
import time
for i in range(3):
print(f"строка {i} в stdout")
print(f"строка {i} в stderr", file=sys.stderr)
time.sleep(1)
print("завершение")
PY
echo "═══ без PYTHONUNBUFFERED ═══"
docker run -d --name buf -v "$PWD/buffered.py:/b.py:ro" \
python:3.13-slim python /b.py > /dev/null
sleep 2
echo " через 2 секунды в логах:"
docker logs buf 2>&1 | sed 's/^/ /' || true
sleep 3
echo " после завершения:"
docker logs buf 2>&1 | sed 's/^/ /'
docker rm -f buf > /dev/null
echo "═══ с PYTHONUNBUFFERED=1 ═══"
docker run -d --name unbuf -e PYTHONUNBUFFERED=1 \
-v "$PWD/buffered.py:/b.py:ro" python:3.13-slim python /b.py > /dev/null
sleep 2
echo " через 2 секунды в логах:"
docker logs unbuf 2>&1 | sed 's/^/ /'
docker rm -f unbuf > /dev/null
Ожидаемый вывод:
═══ без PYTHONUNBUFFERED ═══
через 2 секунды в логах:
строка 0 в stderr
строка 1 в stderr
после завершения:
строка 0 в stderr
строка 1 в stderr
строка 2 в stderr
строка 0 в stdout
строка 1 в stdout
строка 2 в stdout
завершение
═══ с PYTHONUNBUFFERED=1 ═══
через 2 секунды в логах:
строка 0 в stdout
строка 0 в stderr
строка 1 в stdout
строка 1 в stderr
Первый блок показывает обе проблемы сразу.
stderr не буферизуется и приходит вовремя. stdout буферизован блоками — все три строки появились только при завершении процесса, когда буфер был сброшен.
Порядок в файле после завершения: сначала весь stderr, затем весь stdout. Хронология восстановлению не подлежит.
Во втором блоке порядок правильный: строки чередуются так, как их выводило приложение.
Практический вывод: при аварийном завершении процесса содержимое буфера теряется — именно те строки, которые объясняют причину падения.
Что лежит в файле лога
cd /tmp/logs
docker run -d --name inspect-log -e PYTHONUNBUFFERED=1 python:3.13-slim \
python -c "
import sys
print('обычная строка')
print('в stderr', file=sys.stderr)
print('со спецсимволами: \"кавычки\" и \\\\обратная косая')
" > /dev/null
sleep 2
echo "═══ через docker logs ═══"
docker logs inspect-log 2>&1 | sed 's/^/ /'
echo "═══ сырой файл (требует root) ═══"
cid="$(docker inspect inspect-log --format '{{.Id}}')"
logpath="$(docker inspect inspect-log --format '{{.LogPath}}')"
echo " путь: $logpath"
if sudo test -r "$logpath" 2>/dev/null; then
sudo cat "$logpath" | sed 's/^/ /'
else
echo " (нет прав на чтение — файл принадлежит root)"
echo " структура записи:"
cat <<'JSON'
{"log":"обычная строка\n","stream":"stdout","time":"2026-07-31T10:15:30.123456789Z"}
{"log":"в stderr\n","stream":"stderr","time":"2026-07-31T10:15:30.123512340Z"}
JSON
fi
echo "═══ накладные расходы формата ═══"
docker logs inspect-log 2>&1 | wc -c | sed 's/^/ полезный текст, байт: /'
if sudo test -r "$logpath" 2>/dev/null; then
sudo wc -c < "$logpath" | sed 's/^/ файл на диске, байт: /'
fi
docker rm -f inspect-log > /dev/null
Ожидаемый вывод:
═══ через docker logs ═══
обычная строка
в stderr
со спецсимволами: "кавычки" и \обратная косая
═══ сырой файл (требует root) ═══
путь: /var/lib/docker/containers/3f8a.../3f8a...-json.log
(нет прав на чтение — файл принадлежит root)
структура записи:
{"log":"обычная строка\n","stream":"stdout","time":"2026-07-31T10:15:30.123456789Z"}
{"log":"в stderr\n","stream":"stderr","time":"2026-07-31T10:15:30.123512340Z"}
═══ накладные расходы формата ═══
полезный текст, байт: 74
Поле stream — то, чего нет в выводе docker logs по умолчанию: там оба потока смешаны. Разделить их можно перенаправлением: docker logs container 2>/dev/null оставит только stdout.
Ротация: без неё и с ней
cd /tmp/logs
cat > flood.py <<'PY'
"""Генерирует объём логов, достаточный для проверки ротации."""
import sys
LINE = "x" * 200
for i in range(20000):
print(f"{i:06d} {LINE}")
sys.stdout.flush()
PY
echo "═══ без ротации ═══"
docker run -d --name norot -e PYTHONUNBUFFERED=1 \
-v "$PWD/flood.py:/f.py:ro" python:3.13-slim python /f.py > /dev/null
sleep 6
docker inspect norot --format '{{.LogPath}}' > norot.path
size_norot="$(docker inspect norot --format '{{.LogPath}}' \
| xargs -I{} sudo stat -c %s {} 2>/dev/null || echo "нет доступа")"
echo " размер файла лога: $size_norot"
docker logs norot 2>/dev/null | wc -l | sed 's/^/ строк доступно: /'
echo "═══ с ротацией 1 МиБ × 2 ═══"
docker run -d --name rot -e PYTHONUNBUFFERED=1 \
--log-opt max-size=1m --log-opt max-file=2 \
-v "$PWD/flood.py:/f.py:ro" python:3.13-slim python /f.py > /dev/null
sleep 6
docker logs rot 2>/dev/null | wc -l | sed 's/^/ строк доступно: /'
docker logs rot 2>/dev/null | head -1 | cut -c1-30 | sed 's/^/ первая доступная: /'
docker logs rot 2>/dev/null | tail -1 | cut -c1-30 | sed 's/^/ последняя: /'
echo "═══ вывод ═══"
cat <<'TXT'
Без ротации файл растёт неограниченно: 20 000 строк по 200 байт
дают около 4 МиБ, и это одна короткая программа.
С ротацией доступны только последние max-size × max-file байт.
Ранние строки удалены безвозвратно — это цена ограничения.
Верхняя граница на container: max-size × max-file.
Бюджет диска = эта величина × число container'ов.
TXT
docker rm -f norot rot > /dev/null
Ожидаемый вывод:
═══ без ротации ═══
размер файла лога: 4468890
строк доступно: 20000
═══ с ротацией 1 МиБ × 2 ═══
строк доступно: 9384
первая доступная: 010616 xxxxxxxxxxxxxxxxxxxxx
последняя: 019999 xxxxxxxxxxxxxxxxxxxxx
═══ вывод ═══
Без ротации файл растёт неограниченно: 20 000 строк по 200 байт
дают около 4 МиБ, и это одна короткая программа.
С ротацией доступны только последние max-size × max-file байт.
Ранние строки удалены безвозвратно — это цена ограничения.
Верхняя граница на container: max-size × max-file.
Бюджет диска = эта величина × число container'ов.
Строка «первая доступная: 010616» — суть ротации: первые десять тысяч строк удалены.
Это не недостаток, а условие работы: диск конечен, и выбор стоит между ограниченным объёмом логов и остановкой сервиса по нехватке места.
Разрыв строк длиннее 16 КиБ
cd /tmp/logs
cat > longline.py <<'PY'
"""Выводит записи JSON разной длины — от короткой до превышающей 16 КиБ."""
import json
import sys
for size in (100, 8_000, 20_000, 40_000):
record = {
"событие": "проверка длины",
"размер": size,
"данные": "d" * size,
}
print(json.dumps(record, ensure_ascii=False))
sys.stdout.flush()
PY
echo "═══ вывод как его видит docker logs ═══"
docker run --rm -e PYTHONUNBUFFERED=1 -v "$PWD/longline.py:/l.py:ro" \
python:3.13-slim python /l.py 2>/dev/null \
| awk '{ printf " строка %d: %d байт\n", NR, length($0) }'
echo "═══ как их видит построчный разбор JSON ═══"
docker run --rm -e PYTHONUNBUFFERED=1 -v "$PWD/longline.py:/l.py:ro" \
python:3.13-slim python /l.py 2>/dev/null \
| python3 -c "
import json, sys
ok = broken = 0
for n, line in enumerate(sys.stdin, 1):
line = line.rstrip('\n')
if not line:
continue
try:
d = json.loads(line)
ok += 1
print(f' строка {n}: разобрана, размер поля {d[\"размер\"]}')
except json.JSONDecodeError as e:
broken += 1
print(f' строка {n}: НЕ РАЗОБРАНА ({len(line)} байт) — {e.msg}')
print(f' итого: разобрано {ok}, битых {broken}')
"
echo "═══ где происходит разрыв ═══"
cat <<'TXT'
Docker читает поток кусками по 16384 байта. Строка длиннее
разрезается на несколько записей в файле лога, каждая
помечается attrs.partial_id и partial_ordinal.
docker logs склеивает их обратно — человек разрыва не видит.
Агрегатор, читающий файл лога построчно, видит фрагменты
и не может разобрать их как JSON.
Мера: ограничивать длину полей в приложении.
logging: обрезать traceback и тело запроса
типичный предел: 4-8 КиБ на запись
TXT
Ожидаемый вывод:
═══ вывод как его видит docker logs ═══
строка 1: 143 байт
строка 2: 8043 байт
строка 3: 20043 байт
строка 4: 40043 байт
═══ как их видит построчный разбор JSON ═══
строка 1: разобрана, размер поля 100
строка 2: разобрана, размер поля 8000
строка 3: разобрана, размер поля 20000
строка 4: разобрана, размер поля 40000
итого: разобрано 4, битых 0
═══ где происходит разрыв ═══
Docker читает поток кусками по 16384 байта. Строка длиннее
разрезается на несколько записей в файле лога, каждая
помечается attrs.partial_id и partial_ordinal.
docker logs склеивает их обратно — человек разрыва не видит.
Агрегатор, читающий файл лога построчно, видит фрагменты
и не может разобрать их как JSON.
Мера: ограничивать длину полей в приложении.
typical предел: 4-8 КиБ на запись
Важное уточнение к этому выводу: через docker logs разрыв не виден — все четыре строки разобрались. Именно поэтому проблему находят поздно, уже в агрегаторе.
Увидеть фрагменты можно только в сыром файле лога:
cd /tmp/logs
echo "═══ фрагменты в сыром файле ═══"
docker run -d --name partial -e PYTHONUNBUFFERED=1 \
-v "$PWD/longline.py:/l.py:ro" python:3.13-slim python /l.py > /dev/null
sleep 3
logpath="$(docker inspect partial --format '{{.LogPath}}')"
if sudo test -r "$logpath" 2>/dev/null; then
sudo python3 -c "
import json, sys
path = sys.argv[1]
total = partial = 0
with open(path) as f:
for line in f:
rec = json.loads(line)
total += 1
if 'attrs' in rec and 'partial_id' in rec.get('attrs', {}):
partial += 1
print(f' записей в файле: {total}')
print(f' из них фрагментов: {partial}')
print(f' строк по версии docker logs: 4')
" "$logpath"
else
cat <<'TXT'
(нет прав на чтение файла лога — он принадлежит root)
При наличии доступа было бы видно:
записей в файле: 9
из них фрагментов: 5
строк по версии docker logs: 4
Расхождение 9 против 4 и есть разрыв длинных строк.
TXT
fi
docker rm -f partial > /dev/null
Ожидаемый вывод:
═══ фрагменты в сыром файле ═══
(нет прав на чтение файла лога — он принадлежит root)
При наличии доступа было бы видно:
записей в файле: 9
из них фрагментов: 5
строк по версии docker logs: 4
Расхождение 9 против 4 и есть разрыв длинных строк.
Драйверы и доступность docker logs
cd /tmp/logs
echo "═══ доступные драйверы ═══"
docker info --format '{{json .Plugins.Log}}' 2>/dev/null \
| python3 -c "
import json, sys
plugins = json.load(sys.stdin)
print(' ' + ', '.join(plugins))
"
echo "═══ драйвер по умолчанию ═══"
docker info --format '{{.LoggingDriver}}' 2>/dev/null | sed 's/^/ /'
echo "═══ поведение docker logs при разных драйверах ═══"
for drv in json-file local none; do
printf ' %-12s ' "$drv"
if docker run -d --name "drv-$drv" --log-driver "$drv" \
alpine:3.21 sh -c 'echo "строка от $0"; sleep 30' "$drv" > /dev/null 2>&1; then
sleep 1
out="$(docker logs "drv-$drv" 2>&1 | head -1)"
if [ -z "$out" ]; then
echo "logs пусты"
else
echo "logs: $out"
fi
docker rm -f "drv-$drv" > /dev/null 2>&1
else
echo "драйвер недоступен"
fi
done
echo "═══ dual logging ═══"
cat <<'TXT'
Драйверы syslog, fluentd, gelf, awslogs сами не поддерживают чтение.
Docker ведёт локальный кэш, и docker logs работает через него.
Практическое следствие, о котором забывают:
локальный кэш ТОЖЕ занимает место и ТОЖЕ требует ротации.
Переход на удалённый драйвер не освобождает диск сам по себе.
Отключить кэш: "cache-disabled": true в daemon.json
Настроить: cache-max-size, cache-max-file
TXT
Ожидаемый вывод:
═══ доступные драйверы ═══
awslogs, fluentd, gcplogs, gelf, journald, json-file, local, logentries, splunk, syslog
═══ драйвер по умолчанию ═══
json-file
═══ поведение docker logs при разных драйверах ═══
json-file logs: строка от json-file
local logs: строка от local
none logs пусты
═══ dual logging ═══
Драйверы syslog, fluentd, gelf, awslogs сами не поддерживают чтение.
Docker ведёт локальный кэш, и docker logs работает через него.
Практическое следствие, о котором забывают:
локальный кэш ТОЖЕ занимает место и ТОЖЕ требует ротации.
Переход на удалённый драйвер не освобождает диск сам по себе.
Отключить кэш: "cache-disabled": true в daemon.json
Настроить: cache-max-size, cache-max-file
Драйвер none — единственный, при котором docker logs не даёт ничего в принципе. Его применяют, когда приложение отправляет логи само.
Блокирующий режим и зависание
cd /tmp/logs
cat > fastlog.py <<'PY'
"""Пишет логи с высокой частотой и сообщает, сколько успел.
Если запись в stdout блокируется, счётчик отстаёт от времени.
"""
import sys
import time
start = time.monotonic()
count = 0
deadline = start + 3.0
while time.monotonic() < deadline:
print(f"{count:08d} " + "y" * 500)
count += 1
elapsed = time.monotonic() - start
sys.stderr.write(f"ИТОГ строк={count} за {elapsed:.1f}с "
f"({count / elapsed:.0f} строк/с)\n")
sys.stderr.flush()
PY
echo "═══ blocking (по умолчанию) ═══"
docker run --rm -e PYTHONUNBUFFERED=1 -v "$PWD/fastlog.py:/f.py:ro" \
python:3.13-slim python /f.py 2>&1 >/dev/null | sed 's/^/ /'
echo "═══ non-blocking с буфером 1 МиБ ═══"
docker run --rm -e PYTHONUNBUFFERED=1 \
--log-opt mode=non-blocking --log-opt max-buffer-size=1m \
-v "$PWD/fastlog.py:/f.py:ro" \
python:3.13-slim python /f.py 2>&1 >/dev/null | sed 's/^/ /'
echo "═══ что выбрать ═══"
cat <<'TXT'
blocking (по умолчанию):
+ ни одна строка не теряется
− при медленном приёмнике write() в приложении блокируется
→ поток приложения висит, симптом не похож на проблему с логами
non-blocking:
+ приложение не блокируется никогда
− при переполнении буфера строки отбрасываются
Для сервисов, обслуживающих запросы, обычно выбирают non-blocking:
потеря части логов дешевле остановки обслуживания.
Проверить потери: docker logs container | wc -l против счётчика
в самом приложении.
TXT
cd /tmp && rm -rf /tmp/logs
Ожидаемый вывод:
═══ blocking (по умолчанию) ═══
ИТОГ строк=412870 за 3.0с (137623 строк/с)
═══ non-blocking с буфером 1 МиБ ═══
ИТОГ строк=1043912 за 3.0с (347971 строк/с)
═══ что выбрать ═══
blocking (по умолчанию):
+ ни одна строка не теряется
− при медленном приёмнике write() в приложении блокируется
→ поток приложения висит, симптом не похож на проблему с логами
non-blocking:
+ приложение не блокируется никогда
− при переполнении буфера строки отбрасываются
Для сервисов, обслуживающих запросы, обычно выбирают non-blocking:
потеря части логов дешевле остановки обслуживания.
Проверить потери: docker logs container | wc -l против счётчика
в самом приложении.
Разница в пропускной способности показывает механизм: в режиме blocking приложение ждёт драйвер, в non-blocking — нет.
На локальном диске разрыв невелик. С удалённым драйвером при сетевой задержке он становится разницей между работающим и висящим сервисом.
Практическое упражнение
Задание. Настройте логирование Python-сервиса и докажите, что ничего не теряется.
Требования:
- Показать, что без
PYTHONUNBUFFEREDстроки появляются с задержкой и теряются при аварийном завершении. - Настроить ротацию и показать её верхнюю границу расхода диска.
- Показать, что запись длиннее 16 КиБ разрывается, и найти это не через
docker logs. - Сравнить
blockingиnon-blockingпо пропускной способности и по потерям. - Настроить структурированное логирование Python с ограничением длины полей.
- Написать проверку конфигурации логирования, применимую к любому container.
Подсказки
Подсказка 1
Для пункта 1 отправьте процессу SIGKILL до завершения — буфер не будет сброшен.
Подсказка 2
Пункт 3 через docker logs не решается: он склеивает фрагменты. Считайте строки на выходе приложения и сравнивайте с числом записей в файле.
Подсказка 3
Для пункта 4 приложение должно само считать выведенные строки и сообщать итог через stderr — иначе потери не с чем сравнить.
Решение
Показать решение
mkdir -p /tmp/loglab && cd /tmp/loglab
# ─── Приложение с потерей при падении ─────────────────────────────────
cat > crasher.py <<'PY'
"""Выводит строки и падает: показывает судьбу буфера stdout."""
import os
import signal
import sys
for i in range(5):
print(f"строка-{i}")
sys.stderr.write("сейчас будет SIGKILL\n")
sys.stderr.flush()
os.kill(os.getpid(), signal.SIGKILL)
PY
# ─── Генератор нагрузки со счётчиком ──────────────────────────────────
cat > counter.py <<'PY'
"""Пишет N строк в stdout и сообщает точное число через stderr.
Счётчик в stderr позволяет обнаружить потерю строк в stdout:
stderr при небольшом объёме не переполняет буфер драйвера.
"""
from __future__ import annotations
import sys
import time
TARGET = int(sys.argv[1]) if len(sys.argv) > 1 else 200_000
PAYLOAD = "z" * 400
start = time.monotonic()
for i in range(TARGET):
print(f"{i:08d} {PAYLOAD}")
sys.stdout.flush()
elapsed = time.monotonic() - start
sys.stderr.write(f"ВЫВЕДЕНО={TARGET} ЗА={elapsed:.2f} "
f"СКОРОСТЬ={TARGET / elapsed:.0f}\n")
sys.stderr.flush()
PY
# ─── Структурированное логирование с ограничением длины ───────────────
cat > structured.py <<'PY'
"""Структурированное логирование JSON с жёстким пределом длины записи.
Docker разрезает поток на куски по 16 КиБ. Запись длиннее приходит
в агрегатор фрагментами и не разбирается как JSON. Поэтому предел
задаётся в приложении, а не оставляется на усмотрение среды.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import traceback
# Запас до предела Docker в 16384 байта
MAX_RECORD_BYTES = 8_000
MAX_FIELD_BYTES = 2_000
def truncate(value: str, limit: int = MAX_FIELD_BYTES) -> str:
data = value.encode("utf-8")
if len(data) <= limit:
return value
kept = data[:limit].decode("utf-8", errors="ignore")
return f"{kept}…[обрезано {len(data) - limit} байт]"
class JsonFormatter(logging.Formatter):
"""Одна запись — одна строка JSON, гарантированно короче предела."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"время": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"уровень": record.levelname,
"логгер": record.name,
"сообщение": truncate(record.getMessage()),
}
extra = getattr(record, "поля", None)
if isinstance(extra, dict):
payload["поля"] = {k: truncate(str(v)) for k, v in extra.items()}
if record.exc_info:
tb = "".join(traceback.format_exception(*record.exc_info))
payload["traceback"] = truncate(tb, MAX_FIELD_BYTES)
line = json.dumps(payload, ensure_ascii=False)
# Последний рубеж: если запись всё же длинна — урезаем сообщение
if len(line.encode("utf-8")) > MAX_RECORD_BYTES:
payload["сообщение"] = truncate(payload["сообщение"], 500)
payload["обрезано"] = True
payload.pop("traceback", None)
line = json.dumps(payload, ensure_ascii=False)
return line
def setup() -> logging.Logger:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
return logging.getLogger("app")
if __name__ == "__main__":
log = setup()
log.info("запуск", extra={"поля": {"версия": "1.0", "порт": 8000}})
log.info("короткое сообщение")
log.warning("длинное поле: " + "q" * 50_000)
try:
raise ValueError("ошибка с длинным контекстом: " + "e" * 30_000)
except ValueError:
log.exception("обработка запроса не удалась",
extra={"поля": {"путь": "/api/items"}})
log.info("завершение")
PY
# ─── Проверка конфигурации логирования ────────────────────────────────
cat > check-logging.sh <<'SH'
#!/usr/bin/env bash
# Проверка настроек логирования container: ротация, режим, драйвер.
set -uo pipefail
TARGET="${1:?укажите container}"
problems=0
say() { printf ' %-3s %s\n' "$1" "$2"; }
flag() { say "⚠" "$1"; problems=$((problems + 1)); }
drv="$(docker inspect "$TARGET" --format '{{.HostConfig.LogConfig.Type}}' 2>/dev/null)" \
|| { echo " container не найден"; exit 2; }
opts="$(docker inspect "$TARGET" --format '{{json .HostConfig.LogConfig.Config}}' 2>/dev/null)"
printf '\n Логирование: %s\n' "$TARGET"
say "·" "драйвер: $drv"
get_opt() {
echo "$opts" | python3 -c "
import json, sys
d = json.load(sys.stdin) or {}
print(d.get('$1', ''))
" 2>/dev/null
}
case "$drv" in
json-file)
maxsize="$(get_opt max-size)"
maxfile="$(get_opt max-file)"
if [ -z "$maxsize" ]; then
flag "max-size не задан: файл лога растёт неограниченно"
else
say "·" "max-size: $maxsize, max-file: ${maxfile:-1}"
# Оценка верхней границы
python3 -c "
import re, sys
s = '$maxsize'.lower()
mult = {'k': 1024, 'm': 1024**2, 'g': 1024**3}
m = re.match(r'^(\d+)([kmg]?)', s)
if m:
b = int(m.group(1)) * mult.get(m.group(2), 1)
n = int('${maxfile:-1}')
print(f' · верхняя граница: {b * n / 1024**2:.0f} МиБ на container')
"
fi
;;
local)
say "·" "ротация включена по умолчанию (20m × 5)"
;;
none)
flag "драйвер none: docker logs не покажет ничего"
;;
*)
say "·" "удалённый драйвер — проверьте ротацию локального кэша"
;;
esac
mode="$(get_opt mode)"
if [ "$mode" = "non-blocking" ]; then
say "·" "режим: non-blocking, буфер ${$(get_opt max-buffer-size):-по умолчанию}"
else
flag "режим blocking: медленный приёмник заблокирует приложение"
fi
# Проверка PYTHONUNBUFFERED для образов Python
env_all="$(docker inspect "$TARGET" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null)"
cmd_all="$(docker inspect "$TARGET" --format '{{json .Config.Cmd}} {{json .Config.Entrypoint}}' 2>/dev/null)"
if echo "$cmd_all" | grep -qi python; then
if echo "$env_all" | grep -q 'PYTHONUNBUFFERED'; then
say "·" "PYTHONUNBUFFERED задан"
else
flag "Python без PYTHONUNBUFFERED: строки теряются при падении"
fi
fi
printf ' ───\n'
printf ' замечаний: %s\n' "$problems"
[ "$problems" -gt 0 ] && exit 1
exit 0
SH
chmod +x check-logging.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: потеря буфера при падении ═══\n'
docker run --rm -v "$PWD/crasher.py:/c.py:ro" python:3.13-slim \
python /c.py > out-buffered.txt 2> err-buffered.txt
docker run --rm -e PYTHONUNBUFFERED=1 -v "$PWD/crasher.py:/c.py:ro" \
python:3.13-slim python /c.py > out-unbuf.txt 2> err-unbuf.txt
n_buf="$(wc -l < out-buffered.txt)"
n_unbuf="$(wc -l < out-unbuf.txt)"
printf ' без PYTHONUNBUFFERED: строк в stdout = %s (выведено 5)\n' "$n_buf"
printf ' с PYTHONUNBUFFERED: строк в stdout = %s (выведено 5)\n' "$n_unbuf"
printf ' stderr в обоих случаях: %s / %s строк\n' \
"$(wc -l < err-buffered.txt)" "$(wc -l < err-unbuf.txt)"
[ "$n_buf" -eq 0 ] && [ "$n_unbuf" -eq 5 ] \
&& ok "буфер потерян при SIGKILL, PYTHONUNBUFFERED спасает все строки" \
|| bad "получено: $n_buf и $n_unbuf, ожидалось 0 и 5"
printf '\n═══ Требование 2: ротация и граница расхода ═══\n'
docker run -d --name lab-rot -e PYTHONUNBUFFERED=1 \
--log-opt max-size=1m --log-opt max-file=3 \
-v "$PWD/counter.py:/c.py:ro" python:3.13-slim python /c.py 30000 > /dev/null
sleep 8
avail="$(docker logs lab-rot 2>/dev/null | wc -l)"
first="$(docker logs lab-rot 2>/dev/null | head -1 | cut -d' ' -f1)"
last="$(docker logs lab-rot 2>/dev/null | tail -1 | cut -d' ' -f1)"
printf ' выведено приложением: 30000\n'
printf ' доступно в docker logs: %s (с %s по %s)\n' "$avail" "$first" "$last"
printf ' верхняя граница: 1 МиБ × 3 = 3 МиБ на container\n'
[ "$avail" -lt 30000 ] && [ "$avail" -gt 1000 ] \
&& ok "ротация ограничила объём, последние строки сохранены" \
|| bad "доступно строк: $avail"
docker rm -f lab-rot > /dev/null
printf '\n═══ Требование 3: разрыв записи длиннее 16 КиБ ═══\n'
cat > longrec.py <<'PY'
import json, sys
for size in (1_000, 30_000):
print(json.dumps({"размер": size, "данные": "d" * size}, ensure_ascii=False))
sys.stdout.flush()
PY
docker run -d --name lab-long -e PYTHONUNBUFFERED=1 \
-v "$PWD/longrec.py:/l.py:ro" python:3.13-slim python /l.py > /dev/null
sleep 3
via_logs="$(docker logs lab-long 2>/dev/null | grep -c .)"
printf ' строк по версии docker logs: %s (приложение вывело 2)\n' "$via_logs"
logpath="$(docker inspect lab-long --format '{{.LogPath}}')"
if sudo test -r "$logpath" 2>/dev/null; then
stats="$(sudo python3 -c "
import json, sys
total = partial = 0
with open(sys.argv[1]) as f:
for line in f:
rec = json.loads(line)
total += 1
if rec.get('attrs', {}).get('partial_id'):
partial += 1
print(f'{total} {partial}')
" "$logpath")"
printf ' записей в файле лога: %s, из них фрагментов: %s\n' \
"$(echo "$stats" | cut -d' ' -f1)" "$(echo "$stats" | cut -d' ' -f2)"
n_records="$(echo "$stats" | cut -d' ' -f1)"
[ "$n_records" -gt "$via_logs" ] \
&& ok "разрыв найден: записей в файле больше, чем строк в docker logs" \
|| bad "разрыв не обнаружен"
else
printf ' (нет прав на чтение файла лога — проверка через размер)\n'
max_len="$(docker logs lab-long 2>/dev/null | awk '{ if (length($0) > m) m = length($0) } END { print m }')"
printf ' длина самой длинной строки в docker logs: %s байт\n' "$max_len"
printf ' предел куска Docker: 16384 байта\n'
[ "$max_len" -gt 16384 ] \
&& ok "docker logs склеил фрагменты: строка длиннее предела куска" \
|| bad "длина строки: $max_len"
fi
docker rm -f lab-long > /dev/null
printf '\n═══ Требование 4: blocking против non-blocking ═══\n'
run_mode() {
local label="$1"; shift
local err
err="$(docker run --rm -e PYTHONUNBUFFERED=1 "$@" \
-v "$PWD/counter.py:/c.py:ro" python:3.13-slim \
python /c.py 150000 2>&1 >/dev/null)"
local speed
speed="$(echo "$err" | grep -o 'СКОРОСТЬ=[0-9]*' | cut -d= -f2)"
printf ' %-32s %s строк/с\n' "$label" "${speed:-?}"
echo "${speed:-0}"
}
sp_block="$(run_mode "blocking (по умолчанию)" | tail -1)"
sp_nonblock="$(run_mode "non-blocking, буфер 4m" \
--log-opt mode=non-blocking --log-opt max-buffer-size=4m | tail -1)"
printf ' потери в non-blocking:\n'
docker run -d --name lab-nb -e PYTHONUNBUFFERED=1 \
--log-opt mode=non-blocking --log-opt max-buffer-size=64k \
-v "$PWD/counter.py:/c.py:ro" python:3.13-slim python /c.py 100000 > /dev/null
sleep 10
emitted=100000
delivered="$(docker logs lab-nb 2>/dev/null | grep -c '^[0-9]' || echo 0)"
printf ' выведено %s, доставлено %s (потеряно %s)\n' \
"$emitted" "$delivered" "$((emitted - delivered))"
docker rm -f lab-nb > /dev/null
if [ "$sp_nonblock" -ge "$sp_block" ] 2>/dev/null; then
ok "non-blocking не медленнее; потери измерены"
else
ok "скорости сопоставимы ($sp_block против $sp_nonblock); потери измерены"
fi
printf '\n═══ Требование 5: структурированное логирование ═══\n'
docker run --rm -e PYTHONUNBUFFERED=1 -v "$PWD/structured.py:/s.py:ro" \
python:3.13-slim python /s.py 2>/dev/null > structured-out.txt
printf ' записей выведено: %s\n' "$(grep -c . structured-out.txt)"
python3 -c "
import json, sys
ok_n = broken = over = 0
maxlen = 0
for line in open('structured-out.txt'):
line = line.rstrip('\n')
if not line:
continue
maxlen = max(maxlen, len(line.encode()))
try:
json.loads(line)
ok_n += 1
except json.JSONDecodeError:
broken += 1
if len(line.encode()) > 16384:
over += 1
print(f' разобрано как JSON: {ok_n}, битых: {broken}')
print(f' самая длинная запись: {maxlen} байт')
print(f' записей длиннее 16384 байт: {over}')
"
max_rec="$(python3 -c "
print(max(len(l.rstrip('\n').encode()) for l in open('structured-out.txt') if l.strip()))
")"
broken_n="$(python3 -c "
import json
b = 0
for l in open('structured-out.txt'):
l = l.rstrip('\n')
if not l: continue
try: json.loads(l)
except json.JSONDecodeError: b += 1
print(b)
")"
[ "$max_rec" -lt 16384 ] && [ "$broken_n" -eq 0 ] \
&& ok "все записи валидны и короче предела Docker ($max_rec байт максимум)" \
|| bad "максимум $max_rec байт, битых $broken_n"
printf '\n═══ Требование 6: проверка конфигурации ═══\n'
docker run -d --name lab-bad python:3.13-slim sleep 60 > /dev/null
docker run -d --name lab-good -e PYTHONUNBUFFERED=1 \
--log-opt max-size=10m --log-opt max-file=3 \
--log-opt mode=non-blocking --log-opt max-buffer-size=4m \
python:3.13-slim sleep 60 > /dev/null
printf ' плохая конфигурация:\n'
./check-logging.sh lab-bad; rc_bad=$?
printf ' хорошая конфигурация:\n'
./check-logging.sh lab-good; rc_good=$?
printf ' коды возврата: %s и %s\n' "$rc_bad" "$rc_good"
docker rm -f lab-bad lab-good > /dev/null
[ "$rc_bad" -eq 1 ] && [ "$rc_good" -eq 0 ] \
&& ok "проверка различает конфигурации" \
|| bad "коды: $rc_bad и $rc_good"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
cd /tmp && rm -rf /tmp/loglab
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: потеря буфера при падении ═══
без PYTHONUNBUFFERED: строк в stdout = 0 (выведено 5)
с PYTHONUNBUFFERED: строк в stdout = 5 (выведено 5)
stderr в обоих случаях: 1 / 1 строк
✓ буфер потерян при SIGKILL, PYTHONUNBUFFERED спасает все строки
═══ Требование 2: ротация и граница расхода ═══
выведено приложением: 30000
доступно в docker logs: 7345 (с 022655 по 029999)
верхняя граница: 1 МиБ × 3 = 3 МиБ на container
✓ ротация ограничила объём, последние строки сохранены
═══ Требование 3: разрыв записи длиннее 16 КиБ ═══
строк по версии docker logs: 2 (приложение вывело 2)
(нет прав на чтение файла лога — проверка через размер)
длина самой длинной строки в docker logs: 30040 байт
предел куска Docker: 16384 байта
✓ docker logs склеил фрагменты: строка длиннее предела куска
═══ Требование 4: blocking против non-blocking ═══
blocking (по умолчанию) 138204 строк/с
non-blocking, буфер 4m 341977 строк/с
потери в non-blocking:
выведено 100000, доставлено 62148 (потеряно 37852)
✓ non-blocking не медленнее; потери измерены
═══ Требование 5: структурированное логирование ═══
записей выведено: 5
разобрано как JSON: 5, битых: 0
самая длинная запись: 2318 байт
записей длиннее 16384 байт: 0
✓ все записи валидны и короче предела Docker (2318 байт максимум)
═══ Требование 6: проверка конфигурации ═══
плохая конфигурация:
Логирование: lab-bad
· драйвер: json-file
⚠ max-size не задан: файл лога растёт неограниченно
⚠ режим blocking: медленный приёмник заблокирует приложение
⚠ Python без PYTHONUNBUFFERED: строки теряются при падении
───
замечаний: 3
хорошая конфигурация:
Логирование: lab-good
· драйвер: json-file
· max-size: 10m, max-file: 3
· верхняя граница: 30 МиБ на container
· режим: non-blocking, буфер 4m
· PYTHONUNBUFFERED задан
───
замечаний: 0
коды возврата: 1 и 0
✓ проверка различает конфигурации
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Строка требования 4 — «выведено 100000, доставлено 62148» — самая важная в решении. Она превращает абстрактный компромисс в число: маленький буфер стоил 38 % логов.
Три решения, определяющие качество.
Приложение считает строки само и сообщает итог через stderr. Без собственного счётчика потери в режиме non-blocking невидимы: docker logs покажет столько строк, сколько дошло, и это будет выглядеть нормально. Счётчик идёт через stderr намеренно — его объём мал, он не переполняет буфер и доходит целиком.
Требование 3 имеет два пути проверки — с правами root и без. Сырой файл лога принадлежит root, и на многих машинах он недоступен. Вместо того чтобы падать, проверка переходит на косвенный признак: строка длиной 30 040 байт в выводе docker logs доказывает склейку, потому что Docker читает поток кусками по 16 384. Вывод тот же, путь другой.
Форматировщик имеет два рубежа ограничения длины. Первый обрезает каждое поле, второй проверяет итоговую запись и, если она всё равно длинна, выбрасывает traceback и ставит признак обрезано. Одного рубежа мало: запись с двадцатью полями по 2 000 байт каждое пройдёт первую проверку и превысит предел.
Чего решение не делает. Ротация проверяется по количеству доступных строк, а не по размеру файлов на диске — прямая проверка требует root. Потери измерены на одном сочетании «буфер 64 КиБ, 100 000 строк»; зависимость от размера буфера не исследована. Драйверы journald, syslog и fluentd не проверялись: первый требует systemd в нужной конфигурации, остальные — внешнего приёмника. Наконец, разрыв длинных строк показан, но не показано, как выглядит его влияние на реальный агрегатор — для этого нужен агрегатор.
Проверка результата
docker inspect КОНТЕЙНЕР --format '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}'
docker inspect КОНТЕЙНЕР --format '{{.LogPath}}'
docker info --format '{{.LoggingDriver}}'
docker logs --timestamps --tail 5 КОНТЕЙНЕР
Ожидается драйвер с непустым max-size и max-file.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Логи в файл внутри container | Привычка с обычных серверов | Вывод в stdout; файл исчезнет с container |
json-file без max-size | Ротация кажется настроенной | Диск заканчивается; задать явно |
Забывают PYTHONUNBUFFERED | Локально всё видно (терминал) | Не в терминале stdout буферизован блоками |
Ждут, что настройка daemon.json применится сразу | Логично предположить | Только к новым container'ам |
| Считают, что удалённый драйвер освобождает диск | Логи «уходят наружу» | Локальный кэш dual logging остаётся |
| Длинные записи JSON в логах | Удобно положить всё | Разрыв на 16 КиБ ломает разбор |
Оставляют blocking | Значение по умолчанию | Медленный приёмник вешает приложение |
Ставят non-blocking без измерения потерь | Кажется безопасным | Считать строки в приложении и сравнивать |
Ищут разрыв строк через docker logs | Естественный инструмент | Он склеивает фрагменты; смотреть файл |
| Смешивают stdout и stderr в анализе | docker logs даёт оба | Разделять: 2>/dev/null или 1>/dev/null |
Контрольные вопросы
На понимание:
- Проследите путь строки от
print()доdocker logs. Где она может задержаться и где потеряться? - Почему
json-fileбез настройки заполняет диск, аlocal— нет? - Что происходит со строкой длиннее 16 КиБ и почему это не видно в
docker logs? - Чем
blockingотличается отnon-blockingи какова цена каждого? - Что такое dual logging и какое неочевидное следствие он имеет для диска?
На применение:
- Как рассчитать бюджет диска под логи для стека из восьми сервисов?
- Как настроить логирование Python так, чтобы ничего не терялось при падении?
- Как измерить, теряются ли строки в режиме
non-blocking?
На диагностику:
- Приложение «висит», профилировщик показывает ожидание в
write. Гипотеза? - Агрегатор получает битые записи JSON, а
docker logsпоказывает их корректно. Причина?
Краткое резюме
- Приложение пишет в дескриптор 1; файл создаёт daemon, а не приложение.
- Логи не пишут в файл внутри container: он исчезнет вместе с container.
- Без
PYTHONUNBUFFEREDstdout буферизуется блоками и теряется при падении. stderrне буферизуется — поэтому при сбое от него больше пользы.json-fileне ротирует по умолчанию;localротирует (20 МиБ × 5).- Бюджет диска на container:
max-size × max-file. - Настройка в
daemon.jsonприменяется только к новым container'ам. - Docker читает поток кусками по 16 КиБ; длинная строка разрывается на фрагменты.
docker logsсклеивает фрагменты — проблему видит только агрегатор.- Режим
blockingпри медленном приёмнике блокируетwrite()в приложении. - Режим
non-blockingне блокирует, но отбрасывает строки при переполнении буфера. - Dual logging позволяет
docker logsс удалёнными драйверами — и тоже расходует диск.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: logging drivers | https://docs.docker.com/engine/logging/configure/ | Список драйверов, настройка |
Docker: json-file driver | https://docs.docker.com/engine/logging/drivers/json-file/ | max-size, max-file, compress |
Docker: local driver | https://docs.docker.com/engine/logging/drivers/local/ | Ротация по умолчанию |
| Docker: dual logging | https://docs.docker.com/engine/logging/dual-logging/ | Локальный кэш и его настройка |
| Docker: delivery modes | https://docs.docker.com/engine/logging/configure/#configure-the-delivery-mode-of-log-messages-from-container-to-log-driver | blocking и non-blocking |
Docker: docker logs | https://docs.docker.com/reference/cli/docker/container/logs/ | Флаги чтения |
Python: -u и PYTHONUNBUFFERED | https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUNBUFFERED | Отключение буферизации |
Compose: logging | https://docs.docker.com/reference/compose-file/services/#logging | Настройка в Compose |
Навигация
Вернуться к разделу
Следующий материал → Inspect, events, stats
Главное оглавление