Классическая история. Сервис в проде живёт полгода, всё хорошо, мониторинг зелёный. И вдруг ночью алерт: на ноде кончилось место. Заходишь, а в /var/lib/docker/containers лежит файл на 80 гигабайт - это лог одного болтливого контейнера, который никто не ротировал. Дефолтный драйвер json-file пишет всё подряд и НЕ ограничивает размер, пока ты явно не скажешь. Это грабли номер один в логировании Docker, и на них наступали все.
Логирование - это не "посмотреть docker logs, когда что-то сломалось". В масштабе это контракт между приложением и платформой, политика хранения, ротация и централизованный сбор. В этом уроке разберём docker логирование вглубь: как работают драйверы под капотом, почему json-file без max-size опасен, когда брать local, journald, syslog или fluentd, и как организовать сбор логов docker через Fluent Bit или Vector в Loki/ELK. Поехали.

Контракт stdout/stderr: откуда вообще берутся логи
Правило, которое надо вбить в голову раз и навсегда: контейнер пишет логи в stdout и stderr, и больше никуда. Не в файл /app/log/app.log внутри контейнера, не в свой собственный logrotate - а в стандартные потоки. Это часть методологии двенадцати факторов (12factor, фактор "logs") и это контракт с платформой.
Почему так. Когда процесс PID 1 в контейнере что-то печатает в stdout/stderr, эти потоки перехватывает не сам контейнер, а демон dockerd (точнее, через containerd-shim). Демон берёт каждую строку, навешивает метаданные - время, поток (stdout или stderr) - и отдаёт настроенному логирующему драйверу. Драйвер решает, что с этой строкой делать дальше: записать в JSON-файл, отправить в journald, послать по сети в syslog или fluentd.
Код: Выделить всё
контейнер (PID 1)
| пишет в stdout / stderr
v
containerd-shim -> dockerd
| строка + метаданные (time, stream)
v
логирующий драйвер (json-file / local / journald / syslog / fluentd / gelf / awslogs)
|
v
файл на диске ИЛИ сеть/коллектор
json-file: дефолт, который обязан иметь ротацию
По умолчанию Docker использует драйвер docker json-file. Каждая строка превращается в одну JSON-запись и дописывается в файл на хосте по пути вида:
Код: Выделить всё
/var/lib/docker/containers/<container-id>/<container-id>-json.log
Код: Выделить всё
{"log":"GET /health 200 1.2ms\n","stream":"stdout","time":"2026-06-15T08:14:22.118431Z"}
- log - сама строка вывода приложения, вместе с символом перевода строки \n. Заметь: одна строка вывода = одна JSON-запись. Многострочный стектрейс развалится на несколько записей - это отдельная боль, к ней вернёмся.
- stream - откуда пришло: stdout или stderr. Полезно отделять ошибки от обычного вывода.
- time - метка времени в RFC3339 с наносекундами, в UTC.
Включаем ротацию глобально через /etc/docker/daemon.json:
Код: Выделить всё
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Можно задать ротацию точечно на конкретный контейнер - это переопределяет глобальную:
Код: Выделить всё
docker run -d \
--log-opt max-size=20m \
--log-opt max-file=5 \
nginx:1.27
Код: Выделить всё
services:
web:
image: nginx:1.27
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Код: Выделить всё
docker inspect --format '{{.HostConfig.LogConfig}}' web
# {json-file map[max-file:3 max-size:10m]}
Есть менее известный, но более правильный для дефолта драйвер - local. Он тоже хранит логи на диске, но в бинарном protobuf-формате, сжимает архивные файлы и ВКЛЮЧАЕТ ротацию по умолчанию (100m на файл, 5 файлов). То есть он защищён от переполнения из коробки - именно поэтому документация Docker рекомендует его для большинства локальных сценариев.
Код: Выделить всё
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
Где живёт docker logs и почему он работает не со всеми драйверами
Команда docker logs читает лог НЕ из приложения и не из сети - она читает локальный файл, который оставил драйвер. Поэтому критичное ограничение: docker logs работает только с драйверами json-file и local. Если ты переключил контейнер на syslog, fluentd, gelf или awslogs - логи уходят по сети наружу, локальной копии нет, и docker logs ответит ошибкой:
Код: Выделить всё
docker logs web
# Error response from daemon: configured logging driver does not support reading
Полезные флаги docker logs, которые реально применяют в эксплуатации:
Код: Выделить всё
docker logs -f web # follow, как tail -f
docker logs --tail 100 web # последние 100 строк
docker logs --since 10m web # за последние 10 минут
docker logs -t web # добавить таймстемпы
docker logs --since 2026-06-15T08:00:00 --until 2026-06-15T09:00:00 web
Когда какой драйвер: краткий разбор
- json-file - дефолт, читается docker logs, удобно для дев и отладки. В прод - только с обязательными max-size/max-file.
- local - лучший дефолт для прода на одной ноде: ротация и сжатие из коробки, экономит диск. docker logs работает.
- journald - отдаёт логи в systemd-journal хоста. Плюс: единый journalctl на всё, фильтрация по CONTAINER_NAME, structured-метаданные. Удобно на хостах, где всё уже под systemd. Читается через journalctl CONTAINER_NAME=web, docker logs тоже работает.
- syslog - шлёт в локальный или удалённый syslog (rsyslog/syslog-ng) по UDP/TCP/TLS. Классика для старых пайплайнов и сетевого оборудования. docker logs НЕ работает.
- fluentd - отправляет в Fluentd/Fluent Bit по их forward-протоколу, типичный вход в ELK или другой бэкенд. docker logs НЕ работает. Грабли: если коллектор недоступен и стоит mode по умолчанию (blocking), драйвер может ЗАБЛОКИРОВАТЬ приложение. Лечится fluentd-async=true и буфером.
- gelf - формат Graylog Extended Log Format, шлёт в Graylog или Logstash по UDP/TCP. docker logs НЕ работает.
- awslogs - прямо в AWS CloudWatch Logs. Для облака AWS удобно, но привязка к вендору. В RU-реалиях вместо этого чаще терминируют в Loki/ELK на своих мощностях или в Yandex Cloud Logging.
Код: Выделить всё
docker run -d \
--log-driver fluentd \
--log-opt fluentd-address=10.0.0.5:24224 \
--log-opt mode=non-blocking \
--log-opt max-buffer-size=4m \
myapp:latest
В масштабе grep по тексту не работает. Когда у тебя сотни контейнеров и миллионы строк, нужны структурные (JSON) логи: приложение печатает в stdout не "user 42 failed login from 1.2.3.4", а готовый JSON-объект:
Код: Выделить всё
{"ts":"2026-06-15T08:14:22Z","level":"error","msg":"failed login","user_id":42,"ip":"1.2.3.4","trace_id":"abc123"}
Тонкость: при драйвере json-file твой JSON попадёт ВНУТРЬ поля log как экранированная строка - получится JSON в JSON. Это нормально, коллектор распарсит двойную вложенность. Но именно поэтому многие сразу тейлят логи коллектором и не полагаются на формат драйвера.
Централизованный сбор: Fluent Bit / Vector -> Loki / ELK
Локальные файлы хороши для одной ноды. Но когда нод десятки, нужен централизованный сбор логов docker: агент на каждой ноде читает логи всех контейнеров и шлёт в общее хранилище, где их можно искать единым интерфейсом.
Два главных хранилища-стека:
- Loki + Grafana - индексирует не контент, а только метки (labels), поэтому дёшев по ресурсам и хранилищу. Логи смотришь в Grafana через LogQL. Популярен под Kubernetes и Docker.
- ELK / OpenSearch - Elasticsearch/OpenSearch + Kibana. Полнотекстовый поиск, мощно, но прожорливо по RAM и диску.
- Fluent Bit - лёгкий (на C), быстрый, вендоронейтральный, куча выходов. Стандарт де-факто для сбора логов в контейнерных средах. Тейлит /var/lib/docker/containers/*/*-json.log плагином tail, парсит JSON и шлёт в Loki/Elasticsearch/Kafka.
- Vector - на Rust, очень производительный, с мощным языком трансформаций VRL (фильтрация, обогащение, маскирование PII прямо в пайплайне). Отлично, когда нужно много преобразований на лету.
- Grafana Alloy - актуальная замена Promtail. Важно для 2026: Promtail достиг end-of-life в марте 2026, новых фич не получает, мигрировать надо на Alloy (это дистрибутив OpenTelemetry Collector от Grafana). Если поднимаешь стек сегодня - бери Alloy или Fluent Bit, а не Promtail.
Код: Выделить всё
services:
fluent-bit:
image: fluent/fluent-bit:3.1
volumes:
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro
ports:
- "2020:2020"
Код: Выделить всё
[INPUT]
Name tail
Path /var/lib/docker/containers/*/*-json.log
Parser docker
Tag docker.*
[OUTPUT]
Name loki
Match *
Host loki
Port 3100
Labels job=docker
Типичные грабли и антипаттерны
- json-file без ротации - переполнение диска, нода падает. Всегда max-size/max-file (или драйвер local).
- Ротация = потеря логов. Ротация удаляет старые файлы. Если агент-сборщик тейлит медленно или лежал, а файл уже ротировался, эти строки потеряны навсегда. Вывод: max-size должен быть достаточным буфером, чтобы агент успевал вычитывать даже после простоя.
- Приложение пишет в файл внутри контейнера - Docker логи не видит, docker logs пуст, слой пухнет. Перенаправляй в stdout/stderr.
- Сетевой драйвер в blocking-режиме - падение коллектора вешает приложение. В проде - non-blocking с буфером.
- Переключил на fluentd/syslog и удивляешься, что docker logs не работает - это by design, читается только json-file/local.
- Многострочные стектрейсы - json-file режет на строки, в хранилище прилетает по записи на строку. Склеивать надо парсером multiline на стороне агента (Fluent Bit multiline filter) или, лучше, писать ошибки в JSON одной строкой.
- Логирование чувствительных данных - пароли, токены, PII в логах = инцидент безопасности. Маскируй на стороне приложения или в пайплайне (Vector VRL, Fluent Bit modify/grep).
- Запусти болтливый контейнер БЕЗ ротации и посмотри, как растёт файл:
Код: Выделить всё
docker run -d --name noisy alpine sh -c 'while true; do echo "spam $(date)"; done'
docker inspect --format '{{.LogPath}}' noisy
# глянь размер файла через пару минут: ls -lh <путь из вывода>
- Останови и удали, теперь с ротацией:
Код: Выделить всё
docker rm -f noisy
docker run -d --name noisy \
--log-opt max-size=1m --log-opt max-file=2 \
alpine sh -c 'while true; do echo "spam $(date)"; done'
- Проверь, что файлов не больше двух и каждый около мегабайта:
Код: Выделить всё
ls -lh "$(dirname "$(docker inspect --format '{{.LogPath}}' noisy)")"
- Прочитай логи штатно и за последнюю минуту:
Код: Выделить всё
docker logs --tail 5 noisy
docker logs --since 1m noisy | wc -l
- Переключи глобальный дефолт на local в /etc/docker/daemon.json, сделай systemctl restart docker, пересоздай контейнер и убедись, что docker logs всё ещё работает, а формат на диске стал бинарным. Не забудь docker rm -f noisy в конце.
- Почему json-file по умолчанию опасен в проде и какими двумя опциями это лечится?
- С какими двумя драйверами работает docker logs и почему он не работает с syslog или fluentd?
- Чем драйвер local отличается от json-file и почему его рекомендуют как дефолт?
- Что произойдёт с приложением, если сетевой логирующий драйвер в blocking-режиме потеряет связь с коллектором, и как от этого защититься?
Логи в контейнерах - это контракт stdout/stderr плюс политика хранения. Дефолтный json-file без max-size/max-file тихо съест диск, поэтому ротация обязательна, а ещё лучше - драйвер local с ротацией из коробки. docker logs читает только локальные json-file/local, сетевые драйверы (syslog, fluentd, gelf, awslogs) шлют наружу и этой команде недоступны. В масштабе пиши структурные JSON-логи и собирай их агентом (Fluent Bit или Vector) в Loki или ELK, помня про non-blocking, буферы и риск потери строк при ротации. Настроишь это один раз правильно - и ночных алертов про забитый диск больше не будет.