Уровни логирования в Docker и Kubernetes: настройка, сбор и фильтрация логов в 2026 году | AdminWiki

Уровни логирования в Docker и Kubernetes: настройка, сбор и фильтрация логов в 2026 году

01 октября 2026 13 мин. чтения

Уровень логирования задаёт приложение, а не контейнерная платформа. Поле 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.logmax-size, max-fileДев-окружения и хосты с небольшим потоком логов, где важна читаемость файлов
localЛокальные бинарные файлы с индексамиmax-size, max-fileПродакшен на одном хосте, где нужны экономия диска и меньшая нагрузка на CPU
journaldsystemd journalСредствами journald (SystemMaxUse, MaxRetentionSec)Хосты, где journal уже собирается агентами journald
syslogSyslog-сервер по RFC 5424 или 3164На стороне сервераПарк серверов с единым syslog-хранилищем
fluentdFluentd или Fluent Bit по forward-протоколуБуферизация на стороне коллектораПотоковая отправка в централизованное хранилище без агента на хосте
gelfGraylog по 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 с ограничением размера, иначе буфер сам займёт весь диск. Следите за занятым местом под буферы отдельной метрикой: узкое место обычно находят именно там.

Чек-лист проверки и типичные ошибки

Перед выкаткой в продакшен прогоните конфигурацию по пунктам. Каждый проверяется командой, а не на глаз.

Чек-лист перед продакшеном

  1. Приложение пишет JSON с полем level: kubectl logs deploy/app --tail=1 и проверьте структуру строки.
  2. Драйвер логирования и ротация заданы: docker info --format '{{.LoggingDriver}}' и docker inspect --format '{{json .HostConfig.LogConfig}}' <container>.
  3. DaemonSet собирает логи со всех узлов: kubectl get ds -n logging, число READY равно числу узлов (kubectl get nodes --no-headers | wc -l).
  4. Парсинг работает: запрос {namespace="prod"} | json | level="error" возвращает записи, а не пустой результат.
  5. Retention включён и compactor не отстаёт: в конфиге задан retention_period, хранилище отвечает на /ready.
  6. Буферы не переполняются: df -h на узлах и метрика размера буфера агента.
  7. Чувствительные данные маскируются: поиск по логам не находит токены, пароли, e-mail и номера карт.
  8. Ротация подтверждена: размер /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, а не полчаса ручного перебора.

Поделиться: