Контейнеры в Kubernetes пишут логи в stdout и stderr, а рантайм (containerd или CRI-O) сохраняет эти потоки на ноду в файлах /var/log/pods/<namespace>_<pod>_<uid>/<container>/0.log. Каждая строка такого файла - отдельный JSON-объект. Читает их агент, запущенный по одному поду на каждую ноду в режиме DaemonSet, и отправляет дальше в Loki, Elasticsearch или OpenSearch.
Схема развязывает приложение и хранилище. Код не знает, куда уходят логи, а смена бэкенда сводится к правке конфига агента без пересборки сервисов. Новые ноды начинают отправлять логи автоматически: DaemonSet сам создаёт на них под агента.
Дальше разбираем три архитектуры сбора, сравниваем Fluent Bit и Promtail (и упоминаем Grafana Alloy), показываем конфиги DaemonSet, формат CRI-логов и ротацию, склейку Java stack trace, обогащение метаданными pod, namespace и labels, а также sidecar для приложений со своим лог-файлом.
Почему DaemonSet, а не Deployment или sidecar по умолчанию
Логи лежат на ноде, а не внутри пода приложения. Чтобы их прочитать, агент должен видеть каталог /var/log/pods через hostPath. Deployment так не умеет: его под не привязан к конкретному узлу. DaemonSet закрывает задачу напрямую.
| Подход | Когда использовать | Минусы |
|---|---|---|
| DaemonSet | Стандартный сбор stdout и stderr всех подов на ноде | Нужны tolerations для control-plane и RBAC на чтение метаданных pod |
| Sidecar | Приложение пишет в собственный файл, нужен отдельный парсер | Дополнительные 50-100 МБ RAM на каждый под, дублирование конфигов |
| Deployment | Централизованная обработка потока: агрегация, маршрутизация | Не читает локальные файлы ноды напрямую |
Чтобы агент запустился и на control-plane, добавьте toleration node-role.kubernetes.io/control-plane:NoSchedule. Для чтения метаданных pod достаточно ServiceAccount с правами get, list, watch на pods и namespaces.
Fluent Bit или Promtail: что выбрать в 2026 году
Оба агента читают файлы логов и отправляют записи в хранилище, но устроены по-разному и заточены под разные бэкенды. Fluent Bit написан на C, потребляет мало памяти и умеет писать в Elasticsearch, OpenSearch, Kafka, S3 и Loki. Promtail написан на Go, глубоко интегрирован с Grafana Loki и повторяет стиль конфигов Prometheus.
| Критерий | Fluent Bit | Promtail |
|---|---|---|
| Язык | C | Go |
| Потребление RAM на ноду | около 10-30 МБ | около 50-100 МБ |
| Бэкенды | Loki, Elasticsearch, OpenSearch, Kafka, S3 | Loki |
| Мультистрочность | Multiline.parser в tail | pipeline_stages multiline |
| Фильтры и трансформации | modify, grep, nest, kubernetes, lua | pipeline_stages, relabel_configs |
| Service discovery | kubernetes filter | kubernetes_sd_configs |
Значения по памяти - ориентир для типичной инсталляции, а не гарантия. Реальное потребление зависит от числа подов, объёма потока и набора включённых фильтров. Сверяйтесь с метриками агента на вашем кластере.
Отдельно про статус инструментов. В публичной документации Grafana для новых установок Loki продвигается единый агент Grafana Alloy, который наследует pipeline_stages от Promtail и работает на конфиге River. Первичных источников, подтверждающих это для вашей версии, у нас нет, поэтому сверьте актуальный статус Promtail и Alloy в документации Grafana перед стартом. Действующие конфиги Promtail при этом продолжают работать, и их много в продакшене.
Когда Fluent Bit выигрывает
Логи уходят в Elasticsearch, OpenSearch или Kafka, а не только в Loki. В этом случае нативный output избавляет от промежуточного сервиса. Встроенные фильтры modify, grep и nest позволяют разбирать и преобразовывать записи прямо в агенте, без внешнего обработчика. При кластере в 200 нод разница в памяти становится заметной: Fluent Bit держит около 15 МБ RSS на ноду, Promtail на той же нагрузке потребляет ближе к 80 МБ, то есть на весь парк это несколько гигабайт разницы.
Когда Promtail (или Grafana Alloy) удобнее
Вы уже держите Grafana и Loki. Тогда конфиг ближе к стилю Prometheus, pipeline_stages читаемы, а service discovery через kubernetes_sd_configs даёт метаданные pod без дополнительных плагинов. Подробный разбор агентов под Loki, включая Alloy и Vector, мы собрали в материале про сбор логов в Grafana Loki. Общая логика выбора такая: простые пайплайны и бэкенд Loki - Promtail или Alloy, а Elasticsearch, OpenSearch, Kafka и сложные трансформации - Fluent Bit. Если ещё определяетесь с хранилищем, пригодится сравнение ELK, Loki и Graylog по стоимости и сложности.
Разворачиваем Fluent Bit как DaemonSet: манифест и проверка
Порядок действий: ServiceAccount и RBAC, ConfigMap с конфигами, DaemonSet с томами и tolerations. Такой же подход разобран в руководстве по настройке Fluent Bit и Filebeat в Docker и Kubernetes, где добавлен расчёт дискового пространства под логи.
Какие тома и права реально нужны агенту
- hostPath /var/log/pods, режим readOnly - основной источник логов.
- hostPath /var/lib/containerd/io.containerd.grpc.v1.cri/containers, режим readOnly - нужен, если фильтру требуются данные по container ID.
- hostPath /etc/machine-id, режим readOnly - даёт уникальный идентификатор ноды.
- RBAC: только get, list, watch на pods и namespaces.
Монтировать docker.sock не нужно, режим privileged: true не требуётся. Минимального набора тома и прав достаточно для чтения stdout и stderr.
apiVersion: v1
kind: ServiceAccount
metadata:
name: fluent-bit
namespace: logging
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fluent-bit-read
rules:
- apiGroups: [""]
resources: ["pods", "namespaces"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fluent-bit-read
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fluent-bit-read
subjects:
- kind: ServiceAccount
name: fluent-bit
namespace: logging
ConfigMap с fluent-bit.conf:
[SERVICE]
Flush 1
Log_Level info
Parsers_File /fluent-bit/etc/parsers.conf
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
[INPUT]
Name tail
Path /var/log/pods/*/*/*.log
Tag kube.*
Parser cri
DB /var/log/flb_kube.db
Mem_Buf_Limit 50MB
Skip_Long_Lines On
Refresh_Interval 10
Rotate_Wait 5
[FILTER]
Name kubernetes
Match kube.*
Kube_URL https://kubernetes.default.svc:443
Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token
Kube_Tag_Prefix kube.var.log.pods.
Merge_Log On
Keep_Log Off
Labels On
Annotations Off
[OUTPUT]
Name loki
Match kube.*
Host loki-gateway.logging.svc.cluster.local
Port 80
Labels job=fluent-bit,namespace=$kubernetes['namespace_name'],app=$kubernetes['labels']['app'],container=$kubernetes['container_name']
parsers.conf с разбором строки CRI:
[PARSER]
Name cri
Format regex
Regex ^(?[^ ]+) (?stdout|stderr) (?[^ ]*) (?.*)$
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%L%z
Фрагмент DaemonSet с томами, tolerations и лимитами:
volumes:
- name: varlogpods
hostPath:
path: /var/log/pods
- name: varlibcontainerd
hostPath:
path: /var/lib/containerd/io.containerd.grpc.v1.cri/containers
- name: machineid
hostPath:
path: /etc/machine-id
- name: config
configMap:
name: fluent-bit-config
volumeMounts:
- name: varlogpods
mountPath: /var/log/pods
readOnly: true
- name: varlibcontainerd
mountPath: /var/lib/containerd/io.containerd.grpc.v1.cri/containers
readOnly: true
- name: machineid
mountPath: /etc/machine-id
readOnly: true
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
Проверка, что агент видит логи на каждой ноде
kubectl get ds -n logging kubectl get pods -n logging -o wide kubectl logs -n logging -l app=fluent-bit --tail=50 kubectl exec -n logging <fluent-bit-pod> -- ls /var/log/pods | head kubectl exec -n logging <fluent-bit-pod> -- tail -n 5 /var/log/pods/<ns>_<pod>_<uid>/<container>/0.log curl http://<pod-ip>:2020/api/v1/metrics
Вывод kubectl get pods -o wide показывает, что на каждой ноде ровно один под агента. Команда tail по умолчанию печатает последние 10 строк файла и входит в состав GNU coreutils в Ubuntu, поэтому доступна прямо внутри контейнера агента (источник). Если нужно следить за файлом в реальном времени, добавьте -f и фильтр: цепочка вида tail -f log | grep --line-buffered 'X' | grep -v 'Y' не будет выглядеть зависшей, тогда как без буферизации два grep подряд могут надолго молчать.
Разворачиваем Promtail как DaemonSet: конфиг и проверка
Логика та же, меняется конфиг и клиент. Сначала ServiceAccount с ClusterRole на pods, namespaces и nodes, затем ConfigMap с promtail.yaml, затем DaemonSet с hostPath /var/log/pods.
relabel_configs: как связать файл на ноде с pod и namespace
Promtail не угадывает, какому поду принадлежит файл. Блок kubernetes_sd_configs с role: pod отдаёт список подов с набором служебных меток __meta_kubernetes_pod_name, __meta_kubernetes_namespace и __meta_kubernetes_pod_label_*. Правила relabel_configs переносят их в обычные labels, а из __meta_kubernetes_pod_uid и имени контейнера собирается путь файла. Так одна метка __path__ превращается в конкретный файл /var/log/pods/<uid>/<container>/0.log.
server:
http_listen_port: 3101
positions:
filename: /run/promtail/positions.yaml
clients:
- url: http://loki-gateway.logging.svc.cluster.local/loki/api/v1/push
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: __host__
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
- source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
separator: /
replacement: /var/log/pods/*$1/*.log
target_label: __path__
pipeline_stages:
- cri: {}
- multiline:
firstline: '^\d{4}-\d{2}-\d{2}'
max_wait_time: 3s
max_lines: 1000
Проверка:
kubectl get ds -n logging kubectl logs -n logging -l app=promtail --tail=50 curl http://<pod-ip>:3101/metrics | grep promtail_sent_bytes_total
Метрика promtail_sent_bytes_total растёт, когда записи реально уходят в Loki. Если она стоит на нуле, проверьте, что клиент указывает на рабочий адрес и что positions.yaml пишется в доступный каталог.
Формат логов контейнеров и ротация: что нужно знать агенту
Рантайм хранит логи как JSON, по одному объекту на строку. Типичная запись выглядит так:
{"log":"2026-09-30T12:00:00.123Z INFO started\n","stream":"stdout","time":"2026-09-30T12:00:00.123456789Z"}
Путь строится из namespace, имени pod, UID и имени контейнера: /var/log/pods/<namespace>_<pod>_<uid>/<container>/0.log. Число в начале имени файла - номер попытки запуска контейнера: после рестарта появляется 1.log и далее. Поле stream различает stdout и stderr, поле time хранит метку рантайма, а поле log содержит саму строку приложения.
Как не потерять логи при ротации
Ротацией управляет kubelet через параметры containerLogMaxSize (по умолчанию 10Mi) и containerLogMaxFiles (по умолчанию 5). Для шумных приложений поднимите первое значение до 50-100Mi, а второе до 10. Параметры задаются в конфиге kubelet; при установке пакетов DEB или RPM им управляет systemd, а kubeadm позволяет распространить настройку на все узлы через структурированный объект и добавить индивидуальные правки для отдельных нод (источник).
При ротации создаются файлы вида 0.log.20260101-120000, и агент должен переоткрывать поток. Опция -F у tail переоткрывает файл после ротации или удаления, тогда как -f продолжает держать старый дескриптор и теряет новые строки (источник). В Fluent Bit за это отвечает опция Rotate_Wait в плагине tail вместе с базой DB, которая запоминает позицию. В Promtail переоткрытие встроено, но убедитесь, что маска __path__ покрывает и ротированные файлы. Практическая проверка: во время тестовой ротации выполните tail -F по активному файлу внутри пода агента и убедитесь, что новые строки продолжают поступать.
Мультистрочные логи Java: склеиваем stack trace в одно событие
log4j и logback пишут исключение многострочно: первая строка с датой и уровнем, затем десятки строк с at, Caused by и ... N more. CRI сохраняет каждую строку отдельным объектом, и в Loki или Elasticsearch приходит набор разрозненных записей. Решение - multiline-парсер на стороне агента, который склеивает продолжение в одно событие.
Готовый regex для log4j и logback
Для Fluent Bit объявите MULTILINE_PARSER и подключите его к tail через multiline.parser:
[MULTILINE_PARSER]
Name java
Type regex
Flush_timeout 3
Rule "start_state" "/(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})/" "cont"
Rule "cont" "/^\s+at |^Caused by:|^\s+\.\.\. \d+ more/" "cont"
В блоке [INPUT] добавьте строку multiline.parser java. Для logback с форматом ISO8601 начало строки другое, поэтому firstline принимает вид ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}. В Promtail то же самое делается стадией multiline в pipeline_stages:
pipeline_stages:
- multiline:
firstline: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}'
max_wait_time: 3s
max_lines: 1000
Если приложение уже пишет структурированный JSON, склейка не нужна: одна строка лога уже содержит полное событие, и достаточно JSON-парсера.
Как проверить, что событие склеилось
- Включите log_level debug у агента, выполните tail -f по файлу с исключением и посмотрите, что на выход уходит одна запись, а не десять.
- В Loki выполните запрос {namespace="test"} |= "Exception" и убедитесь, что весь stack trace виден в одной записи.
- Следите за метрикой multiline_errors_total: она должна оставаться на нуле, иначе правило не совпадает с реальным форматом строк.
Обогащение логов метаданными pod, namespace и labels
Без метаданных поиск логов сводится к текстовому grep по всему потоку. Метаданные дают фильтрацию по namespace, приложению и контейнеру. В Fluent Bit это делает фильтр kubernetes: он сопоставляет тег записи с подом и добавляет поля namespace_name, pod_name, container_name и labels при Merge_Log On и Labels On. В Promtail эту роль выполняют relabel_configs, которые переносят __meta_kubernetes_namespace, __meta_kubernetes_pod_name и __meta_kubernetes_pod_label_app в обычные labels. Примеры обогащения на стороне агента вместе с отправкой JSON-логов разобраны в кейсе по централизованному сбору логов микросервисов.
Какие labels добавлять, а какие нет
Правило простое: в label выносите то, по чему фильтруете в большинстве запросов. Подходящие кандидаты - namespace, app, container, node, env. Опасные - pod_uid, container_id, request_id, trace_id, user_id: их значения уникальны для каждой записи или пода. В Loki labels индексируются, и 5000 подов на 10 меток дают 50 000 активных серий, что уже перегружает индекс. Такую информацию держите как поля документа, а не как labels. В фильтре kubernetes это означает Annotations Off: аннотации часто меняются и раздувают кардинальность. Проверка простая: в Grafana выполните {namespace="prod", app="api"} и убедитесь, что возвращаются только нужные логи, а число серий в метриках Loki не растёт лавинообразно.
Sidecar для приложений с нестандартными лог-файлами
DaemonSet не поможет, когда приложение пишет не в stdout, а в собственный файл внутри своего контейнера. Типичные случаи: legacy-сервис пишет в /var/log/app/app.log, приложение не поддерживает вывод в stdout, либо нужен отдельный парсер под специфичный формат. Тогда рядом с приложением ставят второй контейнер-агент, и оба монтируют общий том.
Манифест sidecar-пода: app + Fluent Bit
- Тома: shared-logs (emptyDir) для логов и config (ConfigMap) для конфига агента.
- Контейнер app монтирует shared-logs в /var/log/app и пишет туда файлы.
- Контейнер fluent-bit монтирует shared-logs в /var/log/app и config в /fluent-bit/etc/.
- Input агента: tail, path /var/log/app/*.log, tag app.log. Output: Loki или Elasticsearch.
Проверка: kubectl logs <pod> -c fluent-bit. Такой подход стоит дополнительных 50-100 МБ RAM на под, усложняет обновление агента и дублирует конфиги. Прежде чем идти в sidecar, попробуйте заставить приложение писать в stdout, например через symlink на /dev/stdout или параметр логирования в файл вида /dev/stdout. Общий конвейер, где логи и метрики идут по одному маршруту, описан в материале про маршрутизацию логов и метрик.
Чек-лист: проверяем пайплайн сбора логов перед продакшеном
- DaemonSet запущен на всех нодах: kubectl get pods -o wide показывает по одному поду агента на узел.
- Агент видит /var/log/pods: ls внутри пода возвращает каталоги с логами.
- Ротация настроена: заданы containerLogMaxSize и containerLogMaxFiles под реальный объём логов.
- Multiline-парсер протестирован на настоящем stack trace, метрика multiline_errors_total на нуле.
- Labels не раздувают кардинальность: в них нет pod UID, container ID и request ID.
- Метрики агента собираются: Fluent Bit на порту 2020, Promtail на 3101.
- Настроен алерт на отсутствие логов от критичного namespace.
- Заданы requests и limits, чтобы агент не вытеснялся и не съедал память ноды.
- RBAC минимальный: только get, list, watch на pods и namespaces, без privileged и docker.sock.
- Конфиг хранится в Git, а не правится в ConfigMap руками, с автоматическим применением.
Если в кластере используется Promtail, отдельно зафиксируйте план миграции на Grafana Alloy: синтаксис pipeline_stages сохраняется, но конфиг верхнего уровня меняется на River. Начните с одного namespace, прогоните полный путь до бэкенда и только потом раскатывайте агент на весь кластер.