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 переживают час и перезапуск пода. Блок о наблюдаемости закончен. Следующий — об эксплуатации самого кластера, и начинается он с компонента, без которого кластера нет вовсе.


Источники


Дальше — часть 34: etcd. Как он хранит состояние кластера, что такое Raft и кворум, как работать с ним через etcdctl, снимать снапшоты и восстанавливаться из них.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.