Kubernetes для самых маленьких. Часть 33: Как собирать логи и трассы?
Все части серии «Kubernetes для самых маленьких»В части 31 логи контейнера исчезали вместе с подом, а события — через час. В прошлой части Prometheus научился хранить метрики неделями и сообщать, что и когда пошло не так. Сегодня — две оставшиеся опоры наблюдаемости: логи, собранные со всех узлов в одно место с поиском, и трассы, которые показывают путь отдельного запроса через сервисы. Разберём, как устроены логи контейнеров на узле, поставим агент сбора на каждый узел и Loki для хранения, включим аудит API-сервера, добавим в hello трассировку на OpenTelemetry с хранением в Tempo — и свяжем всё в Grafana так, чтобы от точки на графике можно было перейти к логу, а от строки лога — к трассе запроса.
Логи контейнеров на узле
Приложение пишет в stdout и stderr — в файловые дескрипторы 1 и 2. В контейнере они не связаны с терминалом: containerd при создании контейнера подключает их к именованным каналам (FIFO) через свой процесс-посредник (shim), а CRI-плагин containerd читает каналы и построчно пишет всё, что пришло, в файл на узле. Путь к файлу задаёт kubelet при создании контейнера:
/var/log/pods/<неймспейс>_<под>_<uid пода>/<контейнер>/<номер запуска>.log
Номер запуска — restartCount: после рестарта появится 1.log, затем 2.log, и kubelet хранит файлы текущего и предыдущего запуска — это и есть kubectl logs --previous. Формат строки — формат логов CRI:
user@k8s-node-1:~$ sudo tail -3 /var/log/pods/demo_hello-9ffdcbdf5-92v6k_ab2f90cc-01f9-40c7-ac10-0102698ef2cd/hello/0.log
2026-11-04T05:31:35.903831685Z stderr F 2026/11/04 05:31:35 172.20.211.2:41660 GET /
2026-11-04T05:31:35.914578454Z stderr F 2026/11/04 05:31:35 172.20.211.2:41718 GET /
2026-11-04T05:31:35.915862288Z stderr F 2026/11/04 05:31:35 172.20.211.2:41722 GET /
Четыре поля через пробел: время, когда containerd принял строку (RFC 3339 с наносекундами), поток (stdout или stderr), тег и сама строка. hello пока пишет через стандартный пакет log из Go, а он по умолчанию выводит в stderr, поэтому здесь stderr. Тег F — строка полная; P — часть длинной строки: containerd режет строки длиннее 16 КиБ (max_container_log_line_size в его конфигурации), и сборщик логов должен склеить последовательность P … P F обратно. Под, который вывел строку в 40 000 символов и затем слово short, оставляет в файле четыре записи:
user@k8s-node-1:~$ sudo cut -c1-60 /var/log/pods/default_longline_a313dbbb-a488-4179-ad33-9eb550d113eb/longline/0.log
2026-11-04T05:31:53.41651155Z stdout P xxxxxxxxxxxxxxxxxxxxx
2026-11-04T05:31:53.4165661Z stdout P xxxxxxxxxxxxxxxxxxxxxx
2026-11-04T05:31:53.416670001Z stdout F xxxxxxxxxxxxxxxxxxxx
2026-11-04T05:31:53.41667465Z stdout F short
kubectl logs склеивает их сам и показывает строку целиком. Этот же файл читает и crictl logs.
Ротация — забота kubelet, не приложения. Каждые 10 секунд (containerLogMonitorInterval) он проверяет размер файлов и, если текущий больше containerLogMaxSize (по умолчанию 10Mi), просит containerd переоткрыть файл: старый переименовывается в 0.log.<время>, более старые сжимаются gzip, и всего хранится не больше containerLogMaxFiles (по умолчанию 5) файлов на контейнер. Оба параметра задаются в KubeletConfiguration. Значит, на узле лежит самое большее около 50 МиБ логов на контейнер, а kubectl logs показывает только текущий файл: всё, что ушло в ротацию, через kubectl уже не увидеть.
Для удобства сборщиков kubelet ведёт ещё каталог /var/log/containers/ — плоский список символьных ссылок вида <под>_<неймспейс>_<контейнер>-<id контейнера>.log, указывающих на текущие файлы в /var/log/pods/.
Почему stdout, а не файл
Приложение, которое пишет логи в файл внутри контейнера, ломает всю эту цепочку:
- файл растёт в записываемом слое контейнера (верхнем слое overlayfs) или в
emptyDir, это расходуетephemeral-storageпода, и kubelet в конце концов выселит под сEvicted, как в части 31; - ротации нет, если приложение не делает её само, а делать её внутри каждого приложения — плохая идея;
- файл исчезает вместе с контейнером при рестарте;
- его не видят ни
kubectl logs, ни агенты сбора, которые читают/var/log/pods/.
Поэтому правило простое: приложение в Kubernetes пишет логи в stdout и stderr, по одной записи на строку, а хранение, ротацию и доставку берёт на себя платформа. Если программа умеет писать только в файл, рядом ставят sidecar-контейнер (часть 6), который читает файл из общего emptyDir и выводит его в свой stdout (tail -F), — это обходной путь, а не норма.
Структурированные логи
Строку request served method=GET path=/ status=200 человек прочтёт, а машине придётся разбирать её регулярным выражением, которое сломается при первом изменении формата. Структурированный лог — запись в машиночитаемом формате, чаще всего JSON, по объекту на строку: поля с именами и типами, которые можно фильтровать и агрегировать. Бонус — многострочные сообщения (трассировки стека) упаковываются в одно JSON-поле и перестают разваливаться на отдельные записи. В Go для этого есть стандартный пакет log/slog, и в версии 1.2 hello переходит на него:
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.InfoContext(ctx, "request served",
"method", r.Method, "path", r.URL.Path, "status", http.StatusOK, "duration_ms", ms)
Запись теперь уходит в stdout, а вместо адреса и пути в строке — поля с именами. Дальше по тексту логи hello уже такие. Сборка, трассировка и другие изменения версии 1.2 — в разделе о трассах.
Что не должно попадать в лог ни в каком формате: пароли, токены, заголовки Authorization, cookie, персональные данные. Логи читает больше людей, чем секреты, и хранятся они дольше.
Сбор логов: агент на каждом узле
Файлы лежат на узлах, а искать нужно по всем сразу и после удаления пода. Стандартная схема — агент на каждом узле (DaemonSet, часть 8): он монтирует /var/log/pods узла через hostPath, читает новые строки (запоминая позицию в каждом файле), разбирает формат CRI, добавляет метки из API Kubernetes — неймспейс, под, контейнер, метки приложения — и отправляет в хранилище.
k8s-node-1 k8s-node-2
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ hello ──stdout──► containerd │ │ hello ──stdout──► containerd │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ /var/log/pods/.../0.log │ │ /var/log/pods/.../0.log │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ агент (DaemonSet): CRI, метки │ │ агент (DaemonSet): CRI, метки │
└──────────┼─────────────────────┘ └──────────┼─────────────────────┘
│ HTTP push │ HTTP push
│ │
└─────────────────────┬─────────────────────┘
▼
Loki ──► S3: чанки и индекс
▲
│ LogQL
Grafana
Какой агент взять:
- Grafana Alloy — агент Grafana Labs, собирающий логи, метрики и трассы; конфигурация описывается графом компонентов на собственном языке конфигурации Alloy. Это рекомендуемый агент для Loki.
- Promtail — прежний агент Loki, который многие ещё встретят в статьях и чартах. Он снят с поддержки 2 марта 2026 года: обновлений больше не будет, а конфигурацию Promtail переводит в конфигурацию Alloy команда
alloy convert --source-format=promtail. В новых установках его не используют. - Fluent Bit — лёгкий сборщик и обработчик логов на C из проекта CNCF Fluent; тоже ставится DaemonSet'ом, умеет отправлять в Loki, Elasticsearch, S3 и десятки других мест. Хороший выбор, если логи нужно раздавать в несколько систем.
Мы берём Alloy.
Конфигурация Alloy
Конфигурация — набор компонентов, связанных выходами и входами. Для логов подов нужно пять:
// поды только своего узла: каждый экземпляр DaemonSet читает свои файлы
discovery.kubernetes "pods" {
role = "pod"
selectors {
role = "pod"
field = "spec.nodeName=" + sys.env("HOSTNAME")
}
}
// метки Loki из метаданных пода и путь к файлу лога
discovery.relabel "pods" {
targets = discovery.kubernetes.pods.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
target_label = "container"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
target_label = "app"
}
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
target_label = "node"
}
rule {
source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
separator = "/"
target_label = "__path__"
replacement = "/var/log/pods/*$1/*.log"
}
}
local.file_match "pods" {
path_targets = discovery.relabel.pods.output
}
loki.source.file "pods" {
targets = local.file_match.pods.targets
forward_to = [loki.process.pods.receiver]
}
loki.process "pods" {
// разобрать формат CRI и склеить строки с тегом P
stage.cri {}
// из JSON-строк вынести trace_id в структурированные метаданные
stage.json {
expressions = { trace_id = "trace_id" }
}
stage.structured_metadata {
values = { trace_id = "" }
}
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki-gateway.monitoring.svc/loki/api/v1/push"
}
}
discovery.kubernetes следит за подами через API, discovery.relabel превращает метаданные в метки и вычисляет __path__ — шаблон пути к файлам контейнера; local.file_match раскрывает шаблон в реальные файлы, loki.source.file читает их с запомненной позиции, loki.process разбирает строки, loki.write отправляет пачками в Loki. Ограничение по spec.nodeName важно: без него каждый экземпляр Alloy следил бы за всеми подами кластера. Переменная HOSTNAME здесь — имя узла, а не пода: чарт задаёт её через fieldRef на spec.nodeName. Метка app берётся из app.kubernetes.io/name, той самой метки hello из прошлых частей: точка и косая черта в имени метки превращаются в подчёркивания. Две метки Alloy добавляет сам: filename с путём к файлу и stream с именем потока; service_name проставляет уже Loki.
Ставится Alloy Helm-чартом одной командой (подробно Helm — в части 27); конфигурация выше кладётся в values:
# alloy-values.yaml
controller:
type: daemonset
tolerations: # чтобы агент встал и на мастер, ниже он читает там аудит-лог
- key: node-role.kubernetes.io/control-plane
effect: NoSchedule
alloy:
mounts:
varlog: true # hostPath /var/log узла, только чтение
configMap:
content: |
// конфигурация выше
$ helm repo add grafana https://grafana.github.io/helm-charts
$ helm install alloy grafana/alloy --version 1.15.0 -n monitoring -f alloy-values.yaml
Поду Alloy нужен hostPath на /var/log, а тома hostPath запрещены уже на уровне baseline Pod Security Admission из части 30. Неймспейс monitoring и так работает на уровне privileged: node-exporter из прошлой части тоже монтирует каталоги узла и живёт в его сетевом namespace. Поэтому в такой неймспейс не ставят ничего, кроме компонентов наблюдаемости, а права на создание подов в нём дают узкому кругу администраторов.
События кластера живут час (часть 31), и Alloy умеет сохранять их в Loki — компонент loki.source.kubernetes_events читает события через API и превращает в строки лога. В DaemonSet он собирал бы одни и те же события на каждом узле, поэтому его запускают в отдельном экземпляре Alloy с одной репликой или включают кластеризацию Alloy, которая делит работу между экземплярами. Нам хватит второго релиза того же чарта:
# alloy-events-values.yaml
controller:
type: deployment
replicas: 1
alloy:
configMap:
content: |
loki.source.kubernetes_events "cluster" {
job_name = "kubernetes-events"
log_format = "json"
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki-gateway.monitoring.svc/loki/api/v1/push"
}
}
Loki
Loki — система хранения логов от Grafana Labs, устроенная «как Prometheus, только для логов». Главное отличие от систем вроде Elasticsearch — что индексируется.
Elasticsearch и похожие системы строят полнотекстовый индекс: каждое слово каждой строки попадает в инвертированный индекс, поиск по любой подстроке мгновенный, но индекс сравним по размеру с самими данными и требует много памяти и дисков. Loki индексирует только метки — небольшой набор пар вроде {namespace="demo", container="hello"}. Строки с одинаковым набором меток образуют поток (stream); строки потока складываются в чанки, сжимаются и пишутся в объектное хранилище. Запрос сначала по индексу выбирает потоки, а потом перебирает их чанки «грубой силой», распараллеливая чтение. Индекс получается крошечным, хранение — дешёвым, а скорость поиска зависит от того, насколько точно метки сужают выборку.
Отсюда главное правило Loki — низкая кардинальность меток. Каждое новое сочетание значений меток — новый поток со своими чанками. Метки — то, по чему выбирают источник: кластер, неймспейс, приложение, контейнер, узел. Не метки — всё, у чего много значений: trace id, id пользователя, путь запроса, IP клиента, код ответа. Такие поля остаются в тексте строки и извлекаются при запросе, а то, по чему часто нужен точный поиск (как trace id), с Loki 3 кладут в структурированные метаданные — пары ключ — значение, прикреплённые к каждой строке, но не создающие новых потоков. Именно это делает stage.structured_metadata в конфигурации Alloy.
Как Loki хранит данные
Строки приходят в distributor, который проверяет лимиты и раскладывает потоки по ingester'ам. Ingester накапливает чанки в памяти (с журналом предзаписи на локальном диске на случай падения) и сбрасывает их в объектное хранилище, когда чанк заполнен или поток затих. В объектное хранилище уходят и чанки, и индекс — формат TSDB (тот же подход, что у Prometheus), по файлу на сутки. Compactor раз в несколько минут объединяет мелкие файлы индекса и удаляет данные старше срока хранения. Querier выполняет запросы, читая свежее из ingester'ов, а старое — из хранилища.
Объектное хранилище — S3 или совместимое с ним (часть 25): оно дёшево, масштабируется само и снимает с Loki задачу репликации данных. Локальная файловая система как основное хранилище допустима только для экспериментов.
Режимы развёртывания
Все компоненты Loki собраны в одном двоичном файле, а флаг -target выбирает, какие из них запускать:
| Режим | Как устроен | Когда |
|---|---|---|
| Монолитный | все компоненты в одном процессе, -target=all |
до ~20 ГБ логов в сутки; с несколькими репликами на общем S3 — с отказоустойчивостью |
| Simple Scalable | три группы: write, read, backend |
объявлен устаревшим, будет удалён в Loki 4.0; в новых установках не использовать |
| Микросервисы | каждый компонент отдельным Deployment или StatefulSet | очень большие объёмы, отдельное масштабирование каждого компонента |
Для нашего кластера — монолитный режим с одной репликой. Helm-чарты Loki, Tempo и Grafana в 2026 году переданы сообществу и живут в репозитории grafana-community; ставятся они оттуда же, из OCI-реестра.
Установка
Сервер S3 — Garage на s3.example.com из части 25, и порт 3900 на нём открыт для узлов. Заводим бакеты и отдельный ключ с правами только на них:
user@s3:~$ sudo garage -c /etc/garage.toml bucket create loki-chunks
user@s3:~$ sudo garage -c /etc/garage.toml bucket create loki-ruler
user@s3:~$ sudo garage -c /etc/garage.toml key create loki
user@s3:~$ sudo garage -c /etc/garage.toml bucket allow --read --write --owner loki-chunks --key loki
user@s3:~$ sudo garage -c /etc/garage.toml bucket allow --read --write --owner loki-ruler --key loki
Ключи доступа — в Secret, а в конфигурацию они попадут через переменные окружения (-config.expand-env=true разрешает Loki подставлять ${...} в своём конфиге):
$ kubectl create secret generic loki-s3 -n monitoring \
--from-literal=S3_ACCESS_KEY_ID=... --from-literal=S3_SECRET_ACCESS_KEY=...
# loki-values.yaml
deploymentMode: Monolithic
loki:
auth_enabled: false # один арендатор; без этого каждый запрос требует X-Scope-OrgID
commonConfig:
replication_factor: 1
schemaConfig:
configs:
- from: "2026-10-01"
store: tsdb
object_store: s3
schema: v13
index:
prefix: loki_index_
period: 24h
storage:
type: s3
bucketNames:
chunks: loki-chunks
ruler: loki-ruler
s3:
endpoint: s3.example.com:3900
region: garage # регион входит в подпись запроса, Garage принимает только свой
accessKeyId: ${S3_ACCESS_KEY_ID}
secretAccessKey: ${S3_SECRET_ACCESS_KEY}
s3ForcePathStyle: true
insecure: true # в учебной сети Garage отвечает по HTTP
limits_config:
retention_period: 30d
allow_structured_metadata: true
compactor:
retention_enabled: true
delete_request_store: s3
singleBinary:
replicas: 1
extraArgs: ["-config.expand-env=true"]
extraEnvFrom:
- secretRef:
name: loki-s3
persistence:
storageClass: local-path # WAL ingester'а — на локальном диске
size: 10Gi
# кэши memcached и тестовые компоненты на маленьком кластере не нужны
chunksCache: { enabled: false }
resultsCache: { enabled: false }
lokiCanary: { enabled: false }
test: { enabled: false }
# реплики других режимов — нули
backend: { replicas: 0 }
read: { replicas: 0 }
write: { replicas: 0 }
ingester: { replicas: 0 }
querier: { replicas: 0 }
queryFrontend: { replicas: 0 }
queryScheduler: { replicas: 0 }
distributor: { replicas: 0 }
compactor: { replicas: 0 }
indexGateway: { replicas: 0 }
bloomPlanner: { replicas: 0 }
bloomBuilder: { replicas: 0 }
bloomGateway: { replicas: 0 }
$ helm install loki oci://ghcr.io/grafana-community/helm-charts/loki \
--version 20.4.1 -n monitoring -f loki-values.yaml
$ kubectl get pods -n monitoring -l app.kubernetes.io/instance=loki
NAME READY STATUS RESTARTS AGE
loki-0 2/2 Running 0 112s
loki-gateway-7ccfd99688-5p9kl 2/2 Running 0 113s
schemaConfig — версия формата хранения с датой начала действия; при смене формата добавляют новую запись с будущей датой, старые данные читаются по старой схеме. loki-gateway — nginx перед Loki, единая точка входа для записи и чтения. Собственной аутентификации у Loki нет: при auth_enabled: false любой, кто достучится до шлюза, читает все логи, поэтому доступ к loki-gateway ограничивают NetworkPolicy — писать может Alloy, читать Grafana.
LogQL
Язык запросов Loki похож на PromQL. Запрос начинается с селектора потоков в фигурных скобках — он работает по индексу и должен быть как можно точнее:
{namespace="demo", container="hello"}
Дальше идёт конвейер этапов через |. Фильтры строк — самые дешёвые, работают по сырому тексту:
{namespace="demo", container="hello"} |= "error" != "healthz"
{namespace="demo", container="hello"} |~ "\"status\":5[0-9]{2}"
Парсеры превращают строку в поля: | json для JSON, | logfmt для key=value, | pattern "<ip> - - <_> \"<method> <path> <_>\" <status> <_>" для фиксированных форматов вроде access-логов. После парсера доступны фильтры по полям с типами, форматирование строки и структурированные метаданные как обычные поля:
{namespace="demo", container="hello"} | json | status >= 500 and duration_ms > 100
{namespace="demo", container="hello"} | json | line_format "{{.method}} {{.path}} {{.status}} {{.duration_ms}}ms"
{namespace="demo", container="hello"} | trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
Метрические запросы превращают логи во временные ряды, и по ним можно строить графики и даже алерты в Loki ruler:
sum by (pod) (rate({namespace="demo", container="hello"} | json | status >= 500 [5m]))
quantile_over_time(0.95, {namespace="demo", container="hello"} | json | unwrap duration_ms [5m]) by (pod)
rate считает строки в секунду, count_over_time — строки за окно, unwrap берёт числовое поле из строки и позволяет считать по нему квантили и суммы. Если поле с таким именем уже есть в структурированных метаданных, парсер json кладёт значение из строки рядом, с суффиксом: у нас появляется trace_id_extracted.
Аудит API-сервера
Логи приложений отвечают на вопрос «что делала программа», аудит-лог kube-apiserver — на вопрос «кто и что сделал с кластером»: каждый запрос к API с пользователем, адресом, объектом, глаголом, решением авторизации и, при желании, телом запроса и ответа. По умолчанию в kubeadm он выключен.
Что и насколько подробно писать, задаёт политика — список правил, проверяемых по порядку; срабатывает первое подходящее. Уровни:
| Уровень | Что пишется |
|---|---|
None |
ничего |
Metadata |
кто, когда, откуда, что за объект, глагол, код ответа — без тел |
Request |
то же плюс тело запроса |
RequestResponse |
то же плюс тело ответа |
Каждый запрос проходит стадии — RequestReceived, ResponseStarted (для долгих запросов вроде watch), ResponseComplete, Panic — и на каждой может записаться событие. Разумная политика для начала:
# /etc/kubernetes/audit/policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived # то же событие придёт на ResponseComplete
rules:
# шум: проверки здоровья, события, продление lease'ов
- level: None
nonResourceURLs: ["/healthz*", "/livez*", "/readyz*", "/version"]
- level: None
resources:
- group: ""
resources: ["events"]
- group: "coordination.k8s.io"
resources: ["leases"]
- level: None
users: ["system:kube-proxy"]
verbs: ["watch"]
# секреты, ConfigMap и токены — только метаданные: тела содержат сами секреты
- level: Metadata
resources:
- group: ""
resources: ["secrets", "configmaps", "serviceaccounts/token"]
- group: "authentication.k8s.io"
resources: ["tokenreviews"]
# изменения — с телом запроса
- level: Request
verbs: ["create", "update", "patch", "delete", "deletecollection"]
# всё остальное — метаданные
- level: Metadata
Порядок важен: правило для секретов стоит до правила «изменения с телом», иначе kubectl create secret записал бы пароль в аудит-лог открытым текстом.
Включается аудит флагами kube-apiserver. В kubeadm их задают в ClusterConfiguration вместе с томами, потому что API-сервер — статический под и видит только то, что ему смонтировали:
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
apiServer:
extraArgs:
- name: audit-policy-file
value: /etc/kubernetes/audit/policy.yaml
- name: audit-log-path
value: /var/log/kubernetes/audit/audit.log
- name: audit-log-maxage
value: "7"
- name: audit-log-maxbackup
value: "5"
- name: audit-log-maxsize
value: "100"
extraVolumes:
- name: audit-policy
hostPath: /etc/kubernetes/audit
mountPath: /etc/kubernetes/audit
readOnly: true
pathType: DirectoryOrCreate
- name: audit-log
hostPath: /var/log/kubernetes/audit
mountPath: /var/log/kubernetes/audit
pathType: DirectoryOrCreate
На работающем кластере файл политики кладут в /etc/kubernetes/audit/ на мастере, конфигурацию сохраняют в ConfigMap kubeadm-config (sudo kubeadm init phase upload-config kubeadm --config kubeadm.yaml) и пересоздают манифест командой sudo kubeadm init phase control-plane apiserver --config kubeadm.yaml; kubelet заметит изменение файла в /etc/kubernetes/manifests/ и перезапустит API-сервер. Править сам манифест руками бесполезно: при следующем kubeadm upgrade (часть 35) он будет перегенерирован из kubeadm-config.
Пишет аудит много. На нашем учебном кластере из трёх узлов почти без приложений файл растёт на 3,7 МБ в минуту, в основном за счёт контроллеров и Calico, поэтому лимиты audit-log-maxsize и audit-log-maxbackup выше (файл на 100 МБ и пять старых) держат на диске около трёх часов. Рассчитывать на срок audit-log-maxage: 7 здесь нельзя, и это ещё один повод отправлять аудит с узла сразу.
Аудит-лог — файл на мастере, а значит, его надо собрать тем же Alloy. Экземпляр на k8s-master-1 появился благодаря toleration в values, а в конфигурацию добавляются два компонента:
local.file_match "audit" {
path_targets = [{
"__path__" = "/var/log/kubernetes/audit/audit.log",
"job" = "audit",
"node" = sys.env("HOSTNAME"),
}]
}
loki.source.file "audit" {
targets = local.file_match.audit.targets
forward_to = [loki.write.default.receiver]
}
На воркерах такого файла нет, и экземпляры Alloy на них просто ничего не читают. Каждая строка — JSON-событие:
{
"kind": "Event",
"apiVersion": "audit.k8s.io/v1",
"level": "Metadata",
"auditID": "a32fbc03-5ff5-4de7-9f3f-70bf2a485ae8",
"stage": "ResponseComplete",
"requestURI": "/api/v1/namespaces/demo/secrets/hello-db",
"verb": "get",
"user": {
"username": "alice",
"groups": ["developers", "system:authenticated"],
"extra": {
"authentication.kubernetes.io/credential-id": ["X509SHA256=900edb442a1e478b5d2011e9678383508ec7165174129f232fc65e20895eb22e"]
}
},
"sourceIPs": ["192.168.10.17"],
"userAgent": "kubectl/v1.36.5 (linux/amd64) kubernetes/ad950d1",
"objectRef": { "resource": "secrets", "namespace": "demo", "name": "hello-db", "apiVersion": "v1" },
"responseStatus": { "metadata": {}, "code": 200 },
"requestReceivedTimestamp": "2026-11-04T05:54:39.382006Z",
"stageTimestamp": "2026-11-04T05:54:39.382953Z",
"annotations": {
"authorization.k8s.io/decision": "allow",
"authorization.k8s.io/reason": "RBAC: allowed by RoleBinding \"dev-secrets/demo\" of Role \"secret-reader\" to Group \"developers\""
}
}
Аннотации авторизации показывают, какая привязка RBAC из части 29 разрешила запрос, — это лучший способ найти слишком широкие права. Запрет виден так же: у попытки alice прочитать чужой секрет код 403 и authorization.k8s.io/decision: forbid. Значения секрета в файле нет: kubectl create secret записан на уровне Metadata, без тела. В LogQL: {job="audit"} | json | objectRef_resource="secrets" and verb="get" | line_format "{{.user_username}} {{.objectRef_namespace}}/{{.objectRef_name}}".
Трассы
Метрика говорит: «95-й перцентиль времени ответа вырос до 800 мс». Лог говорит: «запрос к /orders обработан за 812 мс». Ни то ни другое не отвечает, куда ушли эти 800 мс, если запрос прошёл через Traefik, сервис заказов, сервис платежей и базу. Это показывает трасса.
Трасса (trace) — дерево операций, выполненных ради одного внешнего запроса. Каждая операция — спан (span): имя, время начала и конца, идентификатор спана, идентификатор родительского спана, атрибуты (http.request.method, http.response.status_code, db.statement), события внутри спана, статус. Все спаны одной трассы несут общий trace id — 16 случайных байт, записываемых 32 шестнадцатеричными символами.
trace id 4bf92f3577b34da6a3ce929d0e0e4736
─────────────────────────────────────────────────────────────────────► время
[traefik: GET /orders 812 мс]
[orders: GET /orders 798 мс]
[orders: SELECT orders 31 мс]
[orders: POST payments/check 744 мс]
[payments: POST /check 739 мс]
[payments: call bank API 722 мс]
Контекст в заголовке traceparent
Чтобы спаны разных сервисов сложились в одну трассу, каждый сервис должен передать следующему trace id и id своего спана. Формат этой передачи стандартизован W3C — Trace Context: HTTP-заголовок traceparent из четырёх полей через дефис:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ └─ флаги: 01 — трасса сэмплирована
│ │ └─ id спана вызывающей стороны (8 байт)
│ └─ trace id (16 байт)
└─ версия формата
Второй заголовок, tracestate, переносит дополнительные данные конкретных систем трассировки. Сервис, получивший запрос с traceparent, создаёт свой спан как дочерний к указанному и при каждом исходящем вызове ставит traceparent уже со своим id спана. Если заголовка нет, сервис начинает новую трассу. Обычные прокси, включая Traefik из части 18, пропускают заголовок насквозь; Traefik с включённой трассировкой ещё и добавляет два собственных спана: серверный на входящий запрос и клиентский ReverseProxy на обращение к сервису.
Сэмплирование. Записывать каждую трассу дорого, поэтому решают, какие сохранить. Head sampling — решение в начале трассы, в первом сервисе: например, 10 % трасс, и решение (флаг 01) передаётся дальше в traceparent, чтобы трасса была либо целой, либо никакой. Tail sampling — решение в коллекторе после того, как собраны все спаны трассы: «сохранить все трассы с ошибками и медленнее секунды, из остальных — 5 %». Второе точнее, но требует, чтобы все спаны трассы попадали в один экземпляр коллектора.
OpenTelemetry
OpenTelemetry (OTel) — проект CNCF, стандартизующий сбор телеметрии: API и SDK для языков программирования, протокол OTLP (OpenTelemetry Protocol — передача трасс, метрик и логов поверх gRPC на порту 4317 или HTTP на 4318), соглашения об именах атрибутов (semantic conventions) и коллектор. Приложение инструментируется один раз, а куда отправлять данные — решается конфигурацией.
- API — интерфейсы для создания спанов, которыми пользуются библиотеки и код приложения.
- SDK — реализация: генерирует идентификаторы, принимает решения о сэмплировании, копит спаны пачками и отправляет экспортёром по OTLP. Настраивается переменными окружения
OTEL_*. - Инструментирующие библиотеки — обёртки для HTTP-серверов и клиентов, драйверов баз данных, gRPC: они создают спаны и переносят
traceparentбез участия программиста. - OpenTelemetry Collector — отдельный сервис, конвейер «приёмники → процессоры → экспортёры». Приложения отправляют данные в него, а он обогащает их, фильтрует, сэмплирует и пересылает в хранилище. Коллектор отвязывает приложения от хранилища: смена Tempo на что-то другое не требует пересборки сервисов.
Tempo
Tempo — хранилище трасс от Grafana Labs. Как и Loki, он почти ничего не индексирует и хранит всё в объектном хранилище: спаны раскладываются по колонкам в файлах формата Apache Parquet, и запрос читает только нужные колонки. Принимает OTLP, Jaeger и Zipkin, ищет трассу по trace id и по условиям на языке TraceQL.
На момент написания актуальна ветка Tempo 3.x (2.x ещё получает исправления). В третьей версии изменилась внутренняя архитектура: в режиме микросервисов запись идёт через Kafka-совместимую очередь, которая служит журналом предзаписи, свежие данные обслуживает live-store вместо прежних ingester'ов, а сжатие и удаление старых блоков делают backend scheduler и backend worker вместо compactor'а. В монолитном режиме (-target=all) Kafka не нужна: все компоненты в одном процессе, блоки уходят в S3. Его и берём.
# tempo-values.yaml
tempo:
retention: 168h
extraArgs:
config.expand-env: true
extraEnvFrom:
- secretRef:
name: tempo-s3
storage:
trace:
backend: s3
s3:
bucket: tempo-traces
endpoint: s3.example.com:3900
region: garage
access_key: ${S3_ACCESS_KEY_ID}
secret_key: ${S3_SECRET_ACCESS_KEY}
forcepathstyle: true
insecure: true
wal:
path: /var/tempo/wal
persistence:
enabled: true
storageClassName: local-path
size: 10Gi
$ helm install tempo oci://ghcr.io/grafana-community/helm-charts/tempo \
--version 3.2.1 -n monitoring -f tempo-values.yaml
Бакет tempo-traces, отдельный ключ и Secret tempo-s3 заводятся так же, как для Loki. Чарт 3.2.1 ставит Tempo 3.1.1. Приёмники по умолчанию — OTLP на портах 4317 и 4318 и несколько протоколов Jaeger; выключить Jaeger значением jaeger: null не получится: шаблон сервиса чарта ждёт его настройки и падает с nil pointer evaluating interface {}.protocols.
Если включить в Tempo metrics-generator, он будет строить из трасс метрики RED (запросы, ошибки, длительность) по сервисам и граф вызовов между сервисами и отправлять их в Prometheus по remote write — для этого у Prometheus включают приём (enableRemoteWriteReceiver: true в prometheusSpec).
Коллектор
Коллектор ставим Helm-чартом проекта OpenTelemetry в режиме Deployment. Образ — дистрибутив opentelemetry-collector-k8s, в котором есть процессор k8sattributes: по IP-адресу, с которого пришли спаны, он находит под и добавляет к ним атрибуты k8s.namespace.name, k8s.pod.name, k8s.deployment.name, k8s.node.name. Внутри кластера поды ходят к коллектору без NAT (часть 14), поэтому адрес источника — это адрес пода.
# otel-collector-values.yaml
fullnameOverride: otel-collector
mode: deployment
image:
repository: otel/opentelemetry-collector-k8s
presets:
kubernetesAttributes:
enabled: true # процессор k8sattributes и права на чтение подов
config:
receivers:
otlp:
protocols:
grpc:
endpoint: ${env:MY_POD_IP}:4317
http:
endpoint: ${env:MY_POD_IP}:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 25
batch: {}
exporters:
otlp_grpc/tempo:
endpoint: tempo.monitoring.svc:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp_grpc/tempo]
$ helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
$ helm install otel-collector open-telemetry/opentelemetry-collector \
--version 0.178.2 -n monitoring -f otel-collector-values.yaml
memory_limiter стоит первым: при нехватке памяти коллектор начинает отказывать приёму, а не падает по OOM. batch собирает спаны в пачки перед отправкой. Экспортёр OTLP по gRPC в свежих версиях коллектора называется otlp_grpc. Трафик между коллектором и Tempo идёт внутри кластера без TLS, поэтому порты Tempo закрывают NetworkPolicy так, чтобы писать в него мог только коллектор.
Трассировка в hello
Версия 1.2 hello получает настройку SDK при старте, обёртку HTTP-обработчика и trace id в логах. Заодно рукописный /metrics из части 5 заменяется клиентской библиотекой Prometheus: к прежним hello_requests_total и hello_build_info добавляется гистограмма hello_request_duration_seconds, а только библиотека умеет прикреплять к её точкам exemplars, ссылки на трассы.
$ go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
github.com/prometheus/client_golang/prometheus
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
// Адрес коллектора, имя сервиса и сэмплер SDK берёт из переменных OTEL_*.
func setupTracing(ctx context.Context) (func(context.Context) error, error) {
exp, err := otlptracegrpc.New(ctx)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp))
otel.SetTracerProvider(tp)
// читать и передавать дальше заголовки traceparent и baggage
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{}))
return tp.Shutdown, nil
}
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ctx := r.Context()
sc := trace.SpanContextFromContext(ctx) // спан, созданный otelhttp
requests.Inc()
w.Header().Set("X-App-Version", version)
fmt.Fprintf(w, "hello from %s\n", hostname)
elapsed := time.Since(start)
if sc.IsSampled() {
// exemplar: точка гистограммы со ссылкой на трассу
requestDuration.(prometheus.ExemplarObserver).ObserveWithExemplar(
elapsed.Seconds(), prometheus.Labels{"trace_id": sc.TraceID().String()})
} else {
requestDuration.Observe(elapsed.Seconds())
}
slog.InfoContext(ctx, "request served",
"method", r.Method, "path", r.URL.Path, "status", http.StatusOK,
"duration_ms", float64(elapsed.Microseconds())/1000,
"trace_id", sc.TraceID().String(), "span_id", sc.SpanID().String(),
"trace_sampled", sc.IsSampled())
})
// пробы kubelet и опросы Prometheus в трассах не нужны
handler := otelhttp.NewHandler(mux, "hello", otelhttp.WithFilter(func(r *http.Request) bool {
return r.URL.Path == "/"
}))
В main вызывается setupTracing, а при остановке по SIGTERM — функция, которую она вернула, чтобы отправить накопленные спаны. Обёртка otelhttp на каждый входящий запрос читает traceparent, создаёт серверный спан с атрибутами HTTP и кладёт его в контекст запроса; имя спана она берёт из шаблона маршрута, поэтому в трассах он называется GET /{$}. Dockerfile из части 5 меняется в одной строке: появился go.sum, и его тоже надо скопировать перед go mod download. Чтобы гистограмма отдавала exemplars, обработчик /metrics создаётся с promhttp.HandlerOpts{EnableOpenMetrics: true}: exemplars существуют только в формате OpenMetrics, и Prometheus запрашивает его сам. Выглядит это так:
hello_request_duration_seconds_bucket{le="0.0001"} 101 # {trace_id="482625a9a77fdc350ae275042e5007af"} 4.675e-06 1.793771092125748e+09
hello_request_duration_seconds_bucket{le="0.00025"} 101
В конце строки, после #, — trace id, измеренное значение и его время.
Настройки — в манифесте Deployment, а не в коде:
env:
- name: OTEL_SERVICE_NAME
value: hello
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.monitoring.svc:4317
- name: OTEL_TRACES_SAMPLER
value: parentbased_traceidratio
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.1"
- name: OTEL_RESOURCE_ATTRIBUTES
value: service.version=1.2
parentbased_traceidratio с аргументом 0.1: если запрос пришёл с traceparent, слушаться решения вызывающей стороны, если без — сохранить 10 % трасс. Под нагрузкой это видно по числам: hello обслуживал 261 запрос в секунду, а TraceQL насчитал в Tempo 27 спанов в секунду, как раз около 10 %. Trace id генерируется для каждого запроса и попадает в лог, но в Tempo окажется только сэмплированная трасса; поле trace_sampled в логе сразу говорит, есть ли смысл её искать. Схема http:// в адресе коллектора означает соединение без TLS.
TraceQL
Запрос TraceQL выбирает спаны по условиям в фигурных скобках и возвращает трассы, в которых они есть. Атрибуты пишутся с областью: resource. — атрибуты источника (сервис, под), span. — атрибуты спана; без области — искать в обеих.
{ resource.service.name = "hello" && span.http.response.status_code >= 500 }
{ resource.service.name = "hello" && duration > 200ms }
{ resource.k8s.namespace.name = "demo" } >> { span.db.system = "postgresql" && duration > 50ms }
{ resource.service.name = "hello" } | rate() by (resource.k8s.pod.name)
Третий запрос — оператор >>, «потомок»: трассы, где под спаном из demo есть медленный запрос к PostgreSQL. У hello базы нет, поэтому на нашем стенде он ничего не вернёт, но в приложении с инструментированным драйвером это самый полезный вид запроса. Четвёртый — метрики из трасс (TraceQL metrics): частота спанов по подам, построенная прямо по сохранённым трассам.
Связка в Grafana: метрики, логи, трассы
Три источника данных становятся полезны вместе, когда из одного можно перейти в другой по общему ключу. Ключей два: метки (namespace, pod одинаковы в Prometheus, Loki и в атрибутах трасс благодаря k8sattributes) и trace id.
Prometheus Loki Tempo
┌─────────────────────┐ метки ┌─────────────────────┐ derived ┌─────────────────────┐
│ задержка hello │ ns, pod │ {"msg":"request …", │ field │ трасса 4bf92f35… │
│ + exemplar trace_id ├────────►│ "trace_id":"4bf9…"}├──────────►│ спаны по сервисам │
└──────────┬──────────┘ └─────────────────────┘ └──┬──────────────────┤
│ ▲ │ ▲
│ │ trace to logs │ │
│ └─────────────────────────┘ │
│ exemplar: сразу в трассу │
└────────────────────────────────────────────────────────────────────────────┘
- Exemplars — Prometheus хранит рядом с точками гистограммы отдельные примеры с trace id (нужна функция
exemplar-storage), Grafana рисует их точками на графике задержки; клик ведёт в Tempo. От «выброса на графике» к конкретному медленному запросу — один клик. - Derived fields в источнике Loki — регулярное выражение, которое находит trace id в строке лога и превращает его в ссылку на Tempo.
- Trace to logs в источнике Tempo — из спана открыть логи того же пода за то же время с фильтром по trace id.
Всё это — настройки источников данных Grafana, которые задаются в values kube-prometheus-stack из прошлой части:
# дополнение к kps-values.yaml
prometheus:
prometheusSpec:
enableFeatures: [exemplar-storage]
grafana:
sidecar:
datasources:
exemplarTraceIdDestinations:
datasourceUid: tempo
traceIdLabelName: trace_id
additionalDataSources:
- name: Loki
uid: loki
type: loki
url: http://loki-gateway.monitoring.svc
jsonData:
derivedFields:
- name: TraceID
matcherRegex: '"trace_id":"([0-9a-f]{32})"'
datasourceUid: tempo
url: '$${__value.raw}'
urlDisplayLabel: Открыть трассу
- name: Tempo
uid: tempo
type: tempo
url: http://tempo.monitoring.svc:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
spanStartTimeShift: -5m
spanEndTimeShift: 5m
tags:
- key: k8s.namespace.name
value: namespace
- key: k8s.pod.name
value: pod
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
$$ в url — экранирование: в файлах настройки Grafana одиночный $ означает подстановку переменной окружения, а здесь нужен буквальный ${__value.raw} — значение, найденное регулярным выражением. Grafana снимает одно экранирование, и в настройках источника остаётся ${__value.raw}. tags в tracesToLogsV2 сопоставляют атрибуты трассы с метками Loki: из спана с k8s.pod.name=hello-… Grafana строит запрос к потоку {namespace="demo", pod="hello-…"} и оставляет в нём строки, где встречается trace id, в тексте или в структурированных метаданных.
Смотрим руками
Ротация на узле. Каталог контейнера после нескольких ротаций:
user@k8s-node-1:~$ sudo ls -l /var/log/pods/demo_hello-5878b94bb8-mt56n_70a2bcc9-9f5f-4df4-99bf-cf2a35e2c18d/hello/
-rw-r----- 1 root root 4582200 Nov 4 06:10 0.log
-rw-r----- 1 root root 1720726 Nov 4 06:07 0.log.20261104-055949.gz
-rw-r----- 1 root root 10583775 Nov 4 06:07 0.log.20261104-060709
user@k8s-node-1:~$ ls -l /var/log/containers/ | grep hello-5878b94bb8-mt56n
lrwxrwxrwx 1 root root 90 Nov 4 05:41 hello-5878b94bb8-mt56n_demo_hello-33f551c709bffd09f6c5ac74e3cde6c6c7ea43b28744f82bf8c6cff1e0b0b2d7.log -> /var/log/pods/demo_hello-5878b94bb8-mt56n_70a2bcc9-9f5f-4df4-99bf-cf2a35e2c18d/hello/0.log
Текущий файл, последний ротированный несжатым (его агент мог не дочитать) и более старый в gzip: JSON сжимается в шесть раз. kubectl logs видит только текущий файл, а десятки тысяч строк из двух других ему недоступны:
user@k8s-node-1:~$ sudo wc -l /var/log/pods/demo_hello-5878b94bb8-mt56n_70a2bcc9-9f5f-4df4-99bf-cf2a35e2c18d/hello/0.log
16622 /var/log/pods/demo_hello-5878b94bb8-mt56n_70a2bcc9-9f5f-4df4-99bf-cf2a35e2c18d/hello/0.log
$ kubectl logs -n demo hello-5878b94bb8-mt56n | wc -l
16622
Запрос с заданным traceparent. Сгенерируем trace id сами и отправим запрос через Traefik с флагом «сэмплировать»:
$ TRACE=$(openssl rand -hex 16); SPAN=$(openssl rand -hex 8); echo $TRACE
94b7f00c2965404ab99ccf31b1a034f4
$ curl -s -H "traceparent: 00-$TRACE-$SPAN-01" https://hello.apps.example.com/
hello from hello-5878b94bb8-mt56n
От лога к трассе. logcli — консольный клиент Loki, он лежит отдельным файлом в выпусках Loki на GitHub; через port-forward к шлюзу найдём строку по структурированным метаданным:
$ kubectl port-forward -n monitoring svc/loki-gateway 3100:80 &
$ export LOKI_ADDR=http://localhost:3100
$ logcli query --quiet --since=10m '{namespace="demo", container="hello"} | trace_id="94b7f00c2965404ab99ccf31b1a034f4"'
2026-11-04T06:06:38Z {} {"time":"2026-11-04T06:06:38.434392517Z","level":"INFO","msg":"request served","method":"GET","path":"/","status":200,"duration_ms":0.003,"trace_id":"94b7f00c2965404ab99ccf31b1a034f4","span_id":"cd05e89b3ff79fed","trace_sampled":true}
{} на месте меток означает, что все они общие для найденных строк: logcli выводит их отдельной строкой, которую --quiet убирает. trace_id в логе совпадает с заданным: otelhttp прочитал traceparent и продолжил нашу трассу, а span_id — уже собственный спан hello. Трасса в Tempo — через его HTTP API:
$ kubectl port-forward -n monitoring svc/tempo 3200 &
$ curl -s localhost:3200/api/v2/traces/94b7f00c2965404ab99ccf31b1a034f4 | jq -r '
.trace.resourceSpans[] | .resource.attributes as $r | .scopeSpans[].spans[] |
"\(($r[] | select(.key=="k8s.pod.name") | .value.stringValue)) \(.name) \(.kind)"'
hello-5878b94bb8-mt56n GET /{$} SPAN_KIND_SERVER
Спан один: наш curl не записывал своего спана, а Traefik без включённой трассировки только передал заголовок. Атрибут k8s.pod.name добавил коллектор. Если включить трассировку и в Traefik, с тем же запросом их становится три:
traefik | GET | SPAN_KIND_SERVER | 973.824 us
traefik | ReverseProxy | SPAN_KIND_CLIENT | 831.232 us
hello | GET /{$} | SPAN_KIND_SERVER | 69.888 us
Здесь видно и главное, ради чего нужны трассы: из почти миллисекунды в самом hello прошло 70 микросекунд. Поиск по TraceQL — тем же API. hello отвечает так быстро, что порог в 1 мс отбирает только редкие запросы:
$ curl -sG localhost:3200/api/search --data-urlencode 'q={ resource.service.name = "hello" && duration > 1ms }' --data-urlencode 'limit=3' \
| jq -r '.traces[] | "\(.traceID) \(.rootTraceName) \(.durationMs) ms"'
59bb1283ad496d0f0281ae8b14b8a30a GET /{$} 1 ms
6e7da5bc93db26c51119f9c4bc2f22b9 GET /{$} 1 ms
4c2c2b5de9fe53ef061b0e6dd804e659 GET /{$} 1 ms
От графика к трассе. Exemplars видны и через API Prometheus — trace id, привязанные к точкам гистограммы, со значением в секундах:
$ curl -sG localhost:9090/api/v1/query_exemplars \
--data-urlencode 'query=hello_request_duration_seconds_bucket{namespace="demo"}' \
--data-urlencode "start=$(date -d '-10 min' +%s)" --data-urlencode "end=$(date +%s)" \
| jq -r '.data[0].exemplars[:2][] | "\(.labels.trace_id) \(.value)"'
6b205cf6da6e77d400deccb09a6e3307 0.000001889
27ac9e1ff24eb94d1548bbd19dfbbebc 0.000004743
В Grafana это выглядит так: на панели с histogram_quantile по задержке hello поверх линии идут ромбы exemplars, а настройка exemplarTraceIdDestinations ведёт из них в Tempo по полю trace_id. На странице трассы кнопка «Logs for this trace» открывает рядом панель Loki со строкой лога того же пода и тем же trace id.
Метрики из логов. Те же запросы hello можно посчитать и по логам:
$ logcli instant-query --quiet 'sum by (pod) (rate({namespace="demo", container="hello"} | json | status >= 200 [5m]))' | jq -c '.[] | [.metric.pod, .value[1]]'
["hello-5878b94bb8-hkxd2","87.10666666666667"]
["hello-5878b94bb8-jpbmw","86.85"]
["hello-5878b94bb8-mt56n","86.50666666666666"]
Сумма сходится со 261 запросом в секунду, которые показывает hello_requests_total в Prometheus.
События. Под с несуществующим образом уже удалён, а его события остались:
$ logcli query --quiet --since=5m --limit=2 '{job="kubernetes-events"} |= "ev-test"'
2026-11-04T05:51:04Z {detected_level="error"} {"count":2,"eventRV":"2007593","kind":"Pod","msg":"Error: ImagePullBackOff","name":"ev-test","objectAPIversion":"v1","objectRV":"2007513","reason":"Failed","reportingcontroller":"kubelet","reportinginstance":"k8s-node-1","sourcecomponent":"kubelet","sourcehost":"k8s-node-1","type":"Warning"}
2026-11-04T05:51:04Z {detected_level="unknown"} {"count":2,"eventRV":"2007590","kind":"Pod","msg":"Back-off pulling image \"busybox:1.37-does-not-exist\"","name":"ev-test","objectAPIversion":"v1","objectRV":"2007513","reason":"BackOff","reportingcontroller":"kubelet","reportinginstance":"k8s-node-1","sourcecomponent":"kubelet","sourcehost":"k8s-node-1","type":"Normal"}
Аудит. Кто читал секрет hello-db за последние полчаса:
$ logcli query --quiet --since=30m '{job="audit"}
| json user="user.username", verb="verb", ip="sourceIPs[0]", res="objectRef.resource", name="objectRef.name"
| res="secrets" and name="hello-db" and user="alice"
| line_format "{{.user}} {{.verb}} {{.ip}}"'
2026-11-04T05:54:39Z {} alice get 192.168.10.17
Парсер json с выражениями берёт только нужные поля, включая вложенные (user.username) и элементы массивов (sourceIPs[0]); без выражений он извлекает все поля, склеивая вложенные имена через подчёркивание (objectRef_resource), но массивы пропускает.
Метрики, логи и трассы теперь хранятся вне узлов и связаны между собой, а события кластера и аудит API переживают час и перезапуск пода. Блок о наблюдаемости закончен. Следующий — об эксплуатации самого кластера, и начинается он с компонента, без которого кластера нет вовсе.
Источники
- Kubernetes: Logging Architecture — файлы логов узла,
containerLogMaxSize,containerLogMaxFiles. - Kubernetes: Auditing — стадии, уровни, политика, флаги kube-apiserver.
- Grafana Alloy, миграция с Promtail,
loki.source.kubernetes_events. - Loki: режимы развёртывания, структурированные метаданные, LogQL.
- Tempo: что нового в версии 3.0, TraceQL.
- Чарты grafana-community — Loki, Tempo и Grafana.
- W3C Trace Context — заголовки
traceparentиtracestate. - OpenTelemetry: коллектор, SDK для Go, переменные окружения
OTEL_*. - Prometheus: feature flags —
exemplar-storage.
Дальше — часть 34: etcd. Как он хранит состояние кластера, что такое Raft и кворум, как работать с ним через etcdctl, снимать снапшоты и восстанавливаться из них.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.