Уровень логирования задаёт приложение, а не контейнерная платформа. Поле level со значениями debug, info, warn, error или fatal появляется в сообщении потому, что библиотека логирования внутри процесса (zap, logrus, pino, log4j, Python logging) записала событие с этой серьёзностью. Docker принимает stdout и stderr процесса и передаёт их драйверу логирования. Kubernetes читает те же потоки через kubelet и складывает файлы на узле. Ни Docker, ни kubelet не разбирают содержимое строки, поэтому фильтр «покажи только error» средствами платформы не работает.
Практический вывод: приложение должно писать машиночитаемый формат, обычно JSON с полем level, а фильтрация выполняется после сбора, в jq, grep, LogQL или SQL. Если сервис пишет plain text вида "ERROR: connection refused", остаётся поиск по подстроке, и точность падает: слово error встречается внутри текста сообщения и даёт ложные срабатывания. Структурированный лог снимает эту проблему целиком.
Ниже разбираем, как устроены уровни в контейнерных средах, какие драйверы логирования выбрать, как развернуть централизованный сбор через DaemonSet или sidecar, как фильтровать записи в kubectl, jq и Grafana и как настроить хранение, чтобы логи не съели диск и не потерялись при деплое.
Как уровни логирования работают в Docker и Kubernetes
Уровень (log level, severity) живёт внутри процесса. Docker и Kubernetes работают с потоками байтов и метаданными контейнера, поэтому интерпретация уровня целиком ложится либо на клиент, который читает логи, либо на систему сбора после парсинга.
Почему Docker не управляет уровнями логирования
В конфигурации драйверов Docker нет параметров level или severity, а у команды docker logs нет флага фильтрации по содержимому. Доступны --since, --until, --tail и --timestamps, то есть отбор по времени и количеству строк. Драйвер получает готовую запись и добавляет к ней атрибуты контейнера: имя, ID, время, иногда метки.
Проверить поведение можно за минуту:
docker run -d --name logdemo alpine sh -c 'echo "{\"level\":\"info\",\"msg\":\"started\"}"; echo "{\"level\":\"error\",\"msg\":\"failed\"}"; sleep 120'
docker logs --tail 10 logdemo
Команда вернёт обе строки. Разделить их можно только внешними средствами: grep, jq, awk или агент сбора, который распарсит JSON и вынесет level в отдельное поле.
Как Kubernetes работает с логами контейнеров
kubelet собирает stdout и stderr каждого контейнера в файлы на узле: /var/log/pods/<namespace>_<pod>_<uid>/<container>/0.log, а каталог /var/log/containers содержит символические ссылки на них в удобном для парсинга виде. Ротацию выполняет kubelet: параметры containerLogMaxSize (по умолчанию 10Mi) и containerLogMaxFiles (по умолчанию 5) задаются в конфигурационном файле kubelet. containerLogMaxSize определяет максимальный размер файла лога до ротации, например "5Mi" или "256Ki", а containerLogMaxFiles — максимальное число файлов логов, которые могут присутствовать для контейнера. После ротации старые файлы удаляются, и данные исчезают безвозвратно.
kubectl logs читает эти файлы через API-сервер. Фильтра по уровню в kubectl нет, доступны --tail, --since, --since-time, --timestamps, --prefix, --previous и --all-containers. Отбор ошибок делается или на клиенте, или в централизованном хранилище. Второй вариант надёжнее: агрегированные логи переживают ротацию и удаление пода.
Отдельный нюанс: если контейнеры в кластере запускает containerd, файлы пишет CRI-плагин рантайма, и настройки драйверов Docker на них не влияют. Собирать в этом случае нужно те же пути /var/log/pods и /var/log/containers, а не /var/lib/docker/containers.
Официальное описание архитектуры логирования Kubernetes и настроек kubelet приведено в документации Logging Architecture и в справочнике KubeletConfiguration.
Выбор и настройка драйвера логирования Docker
Драйвер определяет, где окажутся строки: в локальном файле на хосте, в journald, в syslog или сразу в коллекторе. От этого зависит и ротация, и то, останется ли лог на узле для команды docker logs.
Сравнение драйверов: json-file, local, syslog, fluentd
| Драйвер | Куда пишет | Ротация | Когда выбирать |
|---|---|---|---|
| json-file | Файлы /var/lib/docker/containers/<id>/<id>-json.log | max-size, max-file | Дев-окружения и хосты с небольшим потоком логов, где важна читаемость файлов |
| local | Локальные бинарные файлы с индексами | max-size, max-file | Продакшен на одном хосте, где нужны экономия диска и меньшая нагрузка на CPU |
| journald | systemd journal | Средствами journald (SystemMaxUse, MaxRetentionSec) | Хосты, где journal уже собирается агентами journald |
| syslog | Syslog-сервер по RFC 5424 или 3164 | На стороне сервера | Парк серверов с единым syslog-хранилищем |
| fluentd | Fluentd или Fluent Bit по forward-протоколу | Буферизация на стороне коллектора | Потоковая отправка в централизованное хранилище без агента на хосте |
| gelf | Graylog по UDP или TCP | На стороне Graylog | Инфраструктура, где Graylog уже развёрнут |
json-file остаётся драйвером по умолчанию для Docker Engine, если в daemon.json не задано иное: Docker сохраняет его без ротации для обратной совместимости со старыми версиями и для случаев, когда Docker используется как runtime для Kubernetes. Опции ротации max-size (максимальный размер лога до ротации, по умолчанию -1, то есть без ограничения) и max-file (максимальное число файлов логов, по умолчанию 1) документированы именно для json-file. Для syslog, journald, fluentd и gelf ограничение размера задаётся на принимающей стороне. Ещё одно следствие: при драйверах syslog, fluentd и gelf команда docker logs не покажет данные, потому что локальной копии нет.
Практические детали по драйверам, ротации и централизованной отправке разобраны в отдельном материале про драйверы логирования Docker и ротацию.
Пример настройки daemon.json с ротацией
Ограничение размера логов ставится на уровне демона и действует для всех новых контейнеров. Чтобы использовать json-file как драйвер по умолчанию, задайте ключи log-driver и log-opts в файле daemon.json:
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Такая конфигурация держит не больше 30 МБ логов на контейнер: три файла по 10 МБ. Для локального запуска и в Docker Compose это выглядит так:
services:
api:
image: ghcr.io/acme/api:1.4
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
После правки daemon.json перезапустите демон командой systemctl restart docker. Уже созданные контейнеры сохранят прежние настройки драйвера: параметры логирования фиксируются при создании контейнера, поэтому их нужно пересоздать. Для разовой проверки подойдёт запуск с флагами: docker run --log-driver json-file --log-opt max-size=10m --log-opt max-file=5.
Если логи нужно отправлять напрямую в коллектор, драйвер fluentd настраивается так:
{
"log-driver": "fluentd",
"log-opts": {
"fluentd-address": "127.0.0.1:24224",
"fluentd-async": "true",
"tag": "docker.{{.Name}}"
}
}
Docker подключается к Fluentd в фоновом режиме, а сообщения буферизуются до установления соединения. Когда fluentd-async включён, опция fluentd-async-reconnect-interval задаёт интервал в миллисекундах, с которым переустанавливается соединение с fluentd-address. Старая опция --log-opt fluentd-async-connect устарела в пользу --log-opt fluentd-async. Подробности — в документации драйвера Fluentd logging driver и в списке устаревших возможностей Docker CLI.
Сбор логов в Kubernetes: архитектура и инструменты
Есть два рабочих подхода. Node-level сбор читает файлы всех подов на узле одним агентом в DaemonSet. Sidecar-контейнер обслуживает один под и берёт на себя парсинг логов конкретного приложения.
| Подход | Что читает | Плюсы | Минусы |
|---|---|---|---|
| DaemonSet на узле | /var/log/containers/*.log | Не требует правок приложений, один агент на узел, покрывает все поды включая системные | Нужны права на хостовые пути, мультистрочные логи парсятся сложнее |
| Sidecar | Файл или поток одного приложения | Полный контроль над парсингом, подходит сервисам, которые пишут в файлы | Расход CPU и памяти в каждом поде, копия агента на каждый экземпляр |
Общая архитектура конвейера, сравнение агентов и хранилищ описаны в материале про централизованный сбор и анализ логов.
Node-level сбор через DaemonSet: Fluent Bit и Fluentd
Fluent Bit написан на C, потребляет мало памяти и штатно ставится в DaemonSet почти на любой узел. Fluentd на Ruby даёт более широкий набор плагинов и подходит как тяжёлый агрегатор, который принимает поток от Fluent Bit и дозаписывает данные в хранилище. На практике на узлах держат Fluent Bit, а Fluentd ставят выше по конвейеру.
apiVersion: v1
kind: ConfigMap
metadata:
name: fluent-bit-config
namespace: logging
data:
fluent-bit.conf: |
[SERVICE]
Flush 5
Log_Level info
[INPUT]
Name tail
Path /var/log/containers/*.log
multiline.parser docker, cri
Tag kube.*
Mem_Buf_Limit 50MB
Skip_Long_Lines On
[FILTER]
Name kubernetes
Match kube.*
Merge_Log On
K8S-Logging.Parser On
K8S-Logging.Exclude On
[OUTPUT]
Name loki
Match kube.*
Host loki.logging.svc.cluster.local
Port 3100
Labels job=fluent-bit, namespace=$kubernetes['namespace_name'], app=$kubernetes['labels']['app']
Фильтр kubernetes добавляет метаданные pod_name, namespace_name, container_name и labels, а Merge_Log разбирает JSON-строку приложения и поднимает поля на верхний уровень: level становится обычным полем записи. Мультистрочный парсер docker, cri склеивает стектрейсы Java и Python, которые иначе развалятся на отдельные строки.
DaemonSet монтирует хостовые каталоги только для чтения:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluent-bit
namespace: logging
spec:
selector:
matchLabels:
app: fluent-bit
template:
metadata:
labels:
app: fluent-bit
spec:
tolerations:
- operator: Exists
containers:
- name: fluent-bit
image: fluent/fluent-bit:3.1
volumeMounts:
- name: varlog
mountPath: /var/log
readOnly: true
- name: config
mountPath: /fluent-bit/etc/
volumes:
- name: varlog
hostPath:
path: /var/log
- name: config
configMap:
name: fluent-bit-config
Тому, кто работает с containerd, каталог /var/lib/docker/containers не нужен: достаточно /var/log. Тег образа фиксируйте явно, иначе обновление DaemonSet может неожиданно принести новый парсер. Пошаговая сборка такого DaemonSet вместе с Filebeat и расчётом дискового пространства описана в руководстве по настройке Fluent Bit, Filebeat и драйверов Docker.
Sidecar-контейнер для парсинга логов приложения
Sidecar нужен, когда приложение не умеет писать в stdout или формирует несколько файлов с разными уровнями детализации. Контейнеры делят каталог через emptyDir, приложение пишет в него, а агент читает и отправляет данные:
apiVersion: v1
kind: Pod
metadata:
name: billing
annotations:
fluentbit.io/parser: json
spec:
containers:
- name: app
image: ghcr.io/acme/billing:2.3
volumeMounts:
- name: logs
mountPath: /var/log/app
- name: log-shipper
image: fluent/fluent-bit:3.1
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
volumeMounts:
- name: logs
mountPath: /var/log/app
readOnly: true
volumes:
- name: logs
emptyDir: {}
Аннотации вида fluentbit.io/parser читает фильтр kubernetes в конфигурации агента: по ним коллектор выбирает парсер для конкретного пода. Имена аннотаций зависят от вашего конфига, проверьте их соответствие перед раскаткой. Помните о накладных расходах: агент живёт в каждом поде и потребляет память, а emptyDir исчезает вместе с подом, поэтому буфер sidecar должен отправлять данные до остановки контейнера.
Сбор логов через Loki и Promtail
Promtail читает те же файлы /var/log/pods и добавляет метки из Kubernetes. Минимальный конфиг с извлечением уровня:
server:
http_listen_port: 3101
clients:
- url: http://loki.logging.svc:3100/loki/api/v1/push
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
pipeline_stages:
- docker: {}
- json:
expressions:
level: level
- labels:
level:
Осторожно с метками: pod_name и uid в качестве label порождают десятки тысяч потоков и замедляют Loki. Метками делайте namespace, app и level, а имя пода оставляйте в теле записи. Важно учитывать актуальный статус инструментов: Promtail достиг end of life 2 марта 2026 года и больше не поддерживается, поэтому при новых развёртываниях следует мигрировать на Grafana Alloy. Встроенная команда alloy convert позволяет перенести конфигурацию Promtail в конфигурацию Alloy. Официальные инструкции — в документации Migrate from Promtail to Grafana Alloy.
Фильтрация логов по уровню: kubectl, grep, jq и Grafana
Скорость поиска ошибки зависит от того, есть ли в логе отдельное поле level. Если приложение пишет JSON, отбор точный. Если plain text, остаётся поиск по подстроке с риском ложных совпадений.
Быстрые команды kubectl для фильтрации
kubectl logs deploy/checkout --tail=200 --since=1h | grep -Ei '"level":"(error|warn)"' kubectl logs -l app=checkout --tail=100 --prefix --all-containers kubectl logs checkout-7d9f8b6c5-abcde -c app --previous kubectl logs deploy/checkout --since-time=2026-10-01T09:00:00Z | jq -c 'select(.level=="error")' kubectl logs deploy/checkout -f --timestamps | jq -r 'select(.level=="error") | "\(.ts) \(.msg)"'
Флаг --prefix добавляет имя пода к каждой строке и незаменим при чтении нескольких реплик, --previous показывает логи упавшего контейнера до перезапуска, --since ограничивает окно. Комбинация с jq работает только для JSON-строк: каждая запись парсится отдельно, поэтому невалидная строка ломает поток. Для смешанного вывода используйте jq -R 'fromjson? | select(.level=="error")'.
У grep по подстроке есть ловушка: запись "msg":"error while saving" попадёт в выборку при поиске уровня error, хотя её уровень info. Ищите по ключу с кавычками, как в примере выше, а не по одиночному слову.
Фильтрация в Grafana с LogQL
Loki не индексирует содержимое строк, индексируются только метки потоков. Поэтому порядок операций такой: сначала сужаем поток селектором меток, потом фильтруем строки, потом парсим и применяем фильтр по полю. Примеры запросов:
{namespace="prod", app="checkout"} | json | level="error"
{namespace="prod"} |= "level=error" | logfmt | level="error"
sum by (level) (count_over_time({namespace="prod", app="checkout"} | json [5m]))
{namespace="prod"} | json | level="warn" | msg =~ "(?i)timeout"
Оператор |= отбрасывает строки до парсинга и заметно ускоряет запрос. Если level добавлен как метка на этапе сбора, переменную дашборда удобно строить через label_values({namespace="prod"}, level), а панель рисует график частоты ошибок по уровням. Разбор конвейера и маршрутизации телеметрии в Grafana дан в статье про маршрутизацию логов и метрик.
Централизованное хранение и ретеншн логов
Хранилище выбирают по типу поиска. Loki индексирует только метки, а тела записей держит в объектном хранилище сжатыми блоками, поэтому стоит дешевле при больших объёмах и хорошо ищет по меткам и регуляркам. Elasticsearch и OpenSearch строят полнотекстовый индекс: поиск по любому слову быстрее, но диск и CPU расходуются заметно больше. Сравнение по стоимости, сложности и конфигам для Kubernetes собрано в обзоре ELK, Loki и Graylog.
Настройка retention в Loki
Удаление старых данных выполняет compactor, ему нужен доступ к объектному хранилищу:
limits_config: retention_period: 720h reject_old_samples: true reject_old_samples_max_age: 168h compactor: working_directory: /loki/compactor retention_enabled: true delete_request_store: s3 compaction_interval: 10m
720h это 30 дней. Параметр reject_old_samples защищает от загрузки записей с неверными метками времени, например когда приложение пишет локальное время вместо UTC. Правило retention можно задавать отдельно для части потоков через блок retention_stream, если, скажем, аудит нужно держать год, а отладочные логи только три дня. Проверить состояние хранилища проще всего запросом curl -s http://loki:3100/ready.
Буферизация Fluentd для надёжности
Логи теряются не на диске, а в момент сбоя сети между агентом и хранилищем. Дисковый буфер это компенсирует:
<match app.**>
@type loki
url http://loki.logging.svc:3100
<buffer tag, level>
@type file
path /var/log/fluentd-buffer
flush_mode interval
flush_interval 5s
retry_forever true
retry_max_interval 300
overflow_action block
</buffer>
</match>
Буфер на диске переживает перезапуск агента, overflow_action block останавливает приём новых записей вместо их молчаливого отбрасывания. У Fluent Bit аналог настраивается через storage.type filesystem, storage.path и storage.total_limit_size с ограничением размера, иначе буфер сам займёт весь диск. Следите за занятым местом под буферы отдельной метрикой: узкое место обычно находят именно там.
Чек-лист проверки и типичные ошибки
Перед выкаткой в продакшен прогоните конфигурацию по пунктам. Каждый проверяется командой, а не на глаз.
Чек-лист перед продакшеном
- Приложение пишет JSON с полем level: kubectl logs deploy/app --tail=1 и проверьте структуру строки.
- Драйвер логирования и ротация заданы: docker info --format '{{.LoggingDriver}}' и docker inspect --format '{{json .HostConfig.LogConfig}}' <container>.
- DaemonSet собирает логи со всех узлов: kubectl get ds -n logging, число READY равно числу узлов (kubectl get nodes --no-headers | wc -l).
- Парсинг работает: запрос {namespace="prod"} | json | level="error" возвращает записи, а не пустой результат.
- Retention включён и compactor не отстаёт: в конфиге задан retention_period, хранилище отвечает на /ready.
- Буферы не переполняются: df -h на узлах и метрика размера буфера агента.
- Чувствительные данные маскируются: поиск по логам не находит токены, пароли, e-mail и номера карт.
- Ротация подтверждена: размер /var/lib/docker/containers и /var/log/pods не растёт монотонно.
Проверку проводите на staging с реальным объёмом логов: узкое место обычно проявляется на пиковой нагрузке, а не на тестовом потоке в десять строк в секунду.
Как не потерять логи при перезапуске пода
stdout живёт в файле на узле до ротации и до удаления пода. Когда под исчезает, kubelet чистит /var/log/pods, и всё, что агент не успел забрать, пропадает. Отсюда правила:
- Держите интервал отправки агента в пределах нескольких секунд, чтобы буфер успевал разгружаться.
- Для батч-джобов, которые завершаются за минуты, node-level сбор с задержкой не спасёт: пишите результат в sidecar-файл или отправляйте лог в хранилище до завершения процесса.
- При агрессивной ротации (малый containerLogMaxSize) часть строк теряется между циклами чтения. Увеличьте число файлов containerLogMaxFiles, а не размер одного файла.
- Не полагайтесь на синхронную отправку через драйвер Docker: при недоступном коллекторе она тормозит контейнер. Используйте fluentd-async.
- Добавьте preStop с задержкой в несколько секунд, чтобы агент успел прочитать последние строки перед остановкой.
Типичные ошибки, которые встречаются чаще всего: отсутствие ротации и переполненный диск узла, grep по слову error вместо поля level, метка pod_name в Loki с взрывной кардинальностью, логи только в stdout у короткоживущих джобов, отладочный уровень в продакшене и PII в сообщениях. Начните с двух шагов: включите ротацию в daemon.json или в секции logging Compose, затем поднимите DaemonSet с парсингом JSON и метками namespace, app, level. После этого фильтрация по уровню занимает одну строку в LogQL, а не полчаса ручного перебора.