Высокий уровень observability в наше время — такое же необходимое условие для любого проекта, как и высокая доступность или производительность. Мы стараемся собрать и сохранить максимум метрик, чтобы понимать, в каком состоянии находится наша система и куда это состояние дрейфует. Собираем максимум логов, чтобы понимать, что происходило. Но даже когда логов и метрик настолько много, что для них собираются свои логи и метрики, иногда этого бывает недостаточно. Иногда нужно понимать не ЧТО и КОГДА, а ПОЧЕМУ.

В мире Kubernetes такой источник есть — events: именно они отвечают, почему под перезапустился, почему не смог подняться, почему не встал на ноду. Парадоксально, но эти чрезвычайно важные сведения довольно неудобно извлекать, а по умолчанию через час они уже исчезают. С этим обычно мирятся — ровно до утра, когда нужно объяснить, почему ночью упали сборки. Дальше история одного такого утра: начинается она с двух неудачных пайплайнов, заканчивается часовым окном нестабильности, задевшим 46 namespace'ов, и ни одной улики к моменту расследования в кластере уже не остаётся.

Утро: инцидент, которого нет

Тестовые пайплайны GoCD не прошли. Агенты GoCD — это elastic-агенты: под создаётся под конкретную джобу и удаляется сразу после неё, хорошо хоть сам сервер хранит логи. В логах сборки — ошибки обращения к внутреннему registry, у нас это Harbor.

Идём смотреть registry:

$ kubectl -n harbor-registry get pods -o wide | grep harbor-registry-core
harbor-registry-core-8c6c55574-4jwzq          1/1     Running   0          6d7h  
harbor-registry-core-8c6c55574-b4r8l          1/1     Running   0          6d7h  

Оба пода живы, рестартов ноль, в логах ошибок нет. Registry выглядит безупречно здоровым.

На всякий случай проверяем ивенты:

$ kubectl -n harbor-registry get events
LAST SEEN   TYPE     REASON         OBJECT           MESSAGE
3m51s       Normal   nodeAssigned   service/harbor   announcing from node "worker03" with protocol "layer2"

Но и здесь пусто.

Итого: сборки падали, registry здоров, улик нет. Для кластера инцидента не существует.

Почему в кластере пусто

Два разных механизма забвения сработали одновременно.

Первый — часовой TTL. Events: <none> не означает «ничего не было», а означает «час назад ещё было, но уже удалено». Второй — эфемерность объектов. Удаляется под — уходит и всё, что о нём знал кластер, включая RESTARTS и события. Для CI-агентов, Job'ов, CronJob'ов и spot-подов это не редкость, а норма жизни.

Собирать события так же необходимо, как логи и метрики. Но здесь, внезапно, начинаются сложности. И причина — в природе самих событий. Они не пишутся в файл и не стримятся в stdout: Kubernetes хранит их в etcd. Если не рассматривать прямые запросы туда (технически возможно, но делать так не стоит), единственный практичный способ их получить — запрос к Kubernetes API.

Распространённые лог-шиперы такие запросы делать не умеют. Logstash, Filebeat, Vector — все мимо. Хотя варианты всё же есть: Fluent Bit или Grafana Alloy.

Что уже есть в опенсорсе

Заменять уже работающий шипер только ради того, чтобы собирать ивенты, выглядит идеей так себе. Это слишком долго и болезненно. Значит, нужен специализированный инструмент — и логично сначала поискать готовый. Результат оказался на удивление небогатым: opsgenie/kubernetes-event-exporter — архивирован, bitnami/kubernetes-event-exporter — недоступен. Живых и поддерживаемых вариантов с нативной записью в VictoriaLogs, которая у нас используется для хранения логов, с ходу не нашлось.

Ниша выглядела не то чтобы абсолютно пустой, но и не переполненной. Прекрасный повод запилить собственный проект, не в последнюю очередь потому, что именно такие проекты и наращивают компетенцию. Инженерная форма не живёт на автопилоте. Так появился KENT — Kubernetes Events Notifier: https://github.com/lev-stas/KENT

Первая версия: под свою инфраструктуру

KENT задумывался как небольшая утилита и писался под конкретные требования.

Во-первых, он должен быть очень лёгким: это вспомогательный компонент, он не имеет права заметно есть ресурсы кластера. Во-вторых, раз уж пишем свой инструмент — пусть отправляет сразу в хранилище логов. В-третьих, он должен без боли разворачиваться на нескольких кластерах и при этом иметь понятные и простые способы разделять ивенты из разных кластеров и писать в раздельные пространства VictoriaLogs.

Первая версия была максимально простой, можно сказать примитивной. Но главное она делала: события сохранялись.

Через несколько месяцев работы в проде стало ясно, что инструмент полезный. Появилась идея заопенсорсить KENT, а для этого первым делом стоило сделать его универсальнее. У нас VictoriaLogs, у кого-то Elasticsearch, а кто-то использует Loki. Писать адаптер под каждое хранилище дорого по времени, а свободного времени на пет-проект всегда меньше, чем хочется. Поэтому следующая версия научилась писать в stdout — самое дешёвое решение: дальше события забирает любой лог-шипер и кладёт куда угодно. Оба коннектора включаются независимо друг от друга.

Год в проде: три проблемы

В общей сложности KENT проработал в проде почти год. За это время вылезли три вещи, которые отделяли «работает у меня» от «можно предлагать людям».

Проблема первая: терялись события. В ранних версиях недоставленный батч просто дропался. Любой реконнект, рестарт хранилища или самого экспортера означал потерю — и по закону подлости терялось как раз интересное.

Сейчас недоставленный батч уходит в ретраи с экспоненциальным бэкоффом. Если батч действительно битый — хранилище отвечает 4xx, кроме 429, — он отбрасывается, но не молча, а с фиксацией в метрике: потеря должна быть видимой. При graceful shutdown очередь недоставленных событий сначала дописывается и только потом процесс завершается.

Проблема вторая: мусор на выходе. Событие в Kubernetes живёт как объект с накопительным счётчиком повторений, и наивный экспорт превращает одну серию в десятки почти одинаковых записей. Плюс к этому в поток попадали deleted-нотификации, а после каждого реконнекта watch начинался заново и заливал в хранилище повторный дамп уже собранного.

Что сделано: дедупликация по UID и счётчику повторений, deleted-нотификации не экспортируются, удаление протухшего объекта не логируется, а после разрыва watch продолжается с последнего resourceVersion — ровно с места обрыва. На нашем dev-кластере это сократило количество записей в 2,3 раза при полном сохранении покрытия: каждое событие, которое видела старая версия, видит и новая.

Проблема третья: коннект к хранилищу считался доверенным. Первая реализация работала без авторизации и шифрования. Теперь поддерживаются basic auth, bearer token и SSL-сертификат.

И отдельным пунктом — наблюдаемость самого экспортера: любой уважающий себя мониторинг должен иметь собственный мониторинг. Появился эндпоинт /metrics: сколько событий принято, отфильтровано, дедуплицировано, отправлено и потеряно, ошибки отправки, длина очереди, реконнекты. Плюс /healthz и /ready.

Сколько это стоит

Можно было бы сказать, что практически не потребляет ресурсов. Но это плохая формулировка, поэтому вот цифры. Пять кластеров, потребление одного экземпляра KENT:

событий в час

CPU, mCPU

RAM, MiB (среднее / максимум)

9 921

2,03

14,0 / 17,4

6 799

0,98

17,5 / 22,2

2 933

0,79

12,1 / 16,4

2 381

0,48

10,9 / 13,8

502

0,73

10,6 / 13,6

Потребление определяется базовой стоимостью процесса, а не потоком событий. Нагляднее всего это видно в последней строке — кластер с 502 событиями в час съел больше CPU, чем кластер с 2 381: разброс между инсталляциями оказывается больше, чем вклад самого потока. Маржинальная цена события — доли миллисекунды CPU, а память не растёт вовсе.

Двадцать мегабайт памяти и пара милликор — вот цена ответа на вопрос «что было ночью».

Возвращаемся к тому утру

Теперь то же самое утро, но с экспортированными событиями. Все запросы — LogsQL к VictoriaLogs.

Что происходило в namespace, которого нет

Начинаем оттуда, где кластер показал No resources found:

_time:2026-08-10 {clusterID="k8s-dev", k8s.namespace="gocd"}
| stats count() records,
        count() if (_msg:"ErrImagePull") errimagepull,
        count() if (_msg:"ImagePullBackOff") backoff

429 записей за сутки, из них семь ErrImagePull и девятнадцать ImagePullBackOff. Образ во всех случаях один — образ агента GoCD.

Смотрим их по времени:

_time:2026-08-10 {clusterID="k8s-dev", k8s.namespace="gocd"} event.type:Warning
| sort by (_time)
| fields _time, k8s.name, event.reason, event.count, event.source, _msg
05:31:17  Failed  count=1   Failed to pull image "registry.example.net/ci/debian13-php84-gocd-agent:3e54d74c": received unexpected HTTP status: 503 Slow Down
05:31:17  Failed  count=1   Error: ErrImagePull
05:31:20  Failed  count=1   Error: ImagePullBackOff
          (промежуточные ретраи опущены)
05:34:08  Failed  count=1   … Error response from daemon: received unexpected HTTP status: 500 Internal Server Error
05:51:24  Failed  count=62  Error: ImagePullBackOff
05:55:06  Failed  count=1   … 500 Internal Server Error          ← второй под
05:56:30  Failed  count=72  Error: ImagePullBackOff

Картина восстанавливается по минутам: первый под elastic-агента упёрся в registry в 05:31:17 и провисел в бэкоффе 25 минут, до 05:56:30, а счётчик серии ImagePullBackOff вырос с 1 до 72. Второй агент стартовал в 05:55 и сразу получил 500 — то есть дело было не в конкретном невезучем поде.

Одна запись целиком выглядит так:

{
  "_msg": "Failed to pull image \"registry.example.net/ci/debian13-php84-gocd-agent:3e54d74c\": Error response from daemon: received unexpected HTTP status: 500 Internal Server Error",
  "_time": "2026-08-10T05:34:08Z",
  "clusterID": "k8s-dev",
  "k8s.kind": "Pod",
  "k8s.name": "gocd-agent-job-2712fcc3-ff2d-4706-99f4-128dc599a830.18ca6b2e2d663427",
  "k8s.namespace": "gocd",
  "event.reason": "Failed",
  "event.type": "Warning",
  "event.source": "kubelet",
  "event.count": "1",
  "level": "warning",
  "logType": "event"
}

clusterID и k8s.namespace вынесены в stream fields, поэтому фильтр по ним дешёвый; остальное — обычные поля.

Это вообще только про GoCD?

Вопрос, который стоит задать раньше, чем начинать чинить GoCD. Снимаем ограничение по namespace и спрашиваем весь кластер:

_time:2026-08-10 {clusterID="k8s-dev"} event.reason:Failed _msg:"Failed to pull image" _msg:"registry.example.net"
| stats count() records, count_uniq(k8s.namespace) namespaces,
        min(_time) first, max(_time) last

244 отказа за сутки в 46 namespace'ах. Первая отметка — 04:19:34, последняя — 09:15:40. Пытаемся сузить окно событий:

_time:[2026-08-10T05:00:00Z, 2026-08-10T06:00:00Z) {clusterID="k8s-dev"} event.reason:Failed _msg:"Failed to pull image" _msg:"registry.example.net"
| stats count() records, count_uniq(k8s.namespace) namespaces

240 отказов из 244 уложились в час с 05:00 до 06:00 UTC и задели все те же 46 namespace'ов. Крайние отметки в 04:19 и 09:15 — единичные выбросы, а не растянутая деградация. То есть проблема была не в GoCD и не в конкретном образе, а в самом registry.

Две записи из 244 не HTTP, а сетевые:

05:18:25  Failed to pull image "registry.example.net/…": dial tcp 192.168.24.20:443: connect: no route to host
05:18:25  Failed to pull image "registry.example.net/…": dial tcp 192.168.24.20:443: connect: no route to host

За тринадцать минут до первого HTTP-отказа registry был недоступен на сетевом уровне.

А что в это время делал сам registry

Harbor живёт в другом кластере — том самом, где утром всё выглядело безупречно. Спрашиваем его собственные события:

_time:1d {clusterID="k8s-mgmt", k8s.namespace="harbor-registry"} k8s.name:harbor-registry-nginx*
| stats by (event.reason) count() events
Unhealthy         65
Created            6
Pulled             6
Started            6
Killing            6
Scheduled          2
SuccessfulCreate   2

93 записи, 38 уникальных объектов Event — в кластере, напомню, не осталось ни одного. Убираем шум проб и смотрим таймлайн:

_time:1d {clusterID="k8s-mgmt", k8s.namespace="harbor-registry"} k8s.name:harbor-registry-nginx* -event.reason:Unhealthy
| sort by (_time)
| fields _time, k8s.kind, event.reason, event.source, _msg
04:11:43  Pod         Killing           kubelet                Container nginx failed liveness probe, will be restarted
04:12:13  Pod         Pulled/Created/Started
04:12:49  Pod         Killing           kubelet                Container nginx failed liveness probe, will be restarted
04:13:19  Pod         Pulled/Created/Started
05:12:59  Pod         Killing           kubelet                Container nginx failed liveness probe, will be restarted
05:16:53  Pod         Killing           kubelet                Container nginx failed liveness probe, will be restarted
06:23:33  Pod         Killing           kubelet                Stopping container nginx
06:23:33  ReplicaSet  SuccessfulCreate  replicaset-controller  Created pod: harbor-registry-nginx-5d6f45dc5-7tbvp
06:23:33  Pod         Scheduled         default-scheduler      Successfully assigned … to worker03
06:27:17  ReplicaSet  SuccessfulCreate  replicaset-controller  Created pod: harbor-registry-nginx-5d6f45dc5-prsql

Что именно ломалось:

_time:1d {clusterID="k8s-mgmt", k8s.namespace="harbor-registry"} k8s.name:harbor-registry-nginx* event.reason:Unhealthy
| stats count() records,
        count() if (_msg:"Liveness probe failed") liveness,
        count() if (_msg:"Readiness probe failed") readiness

65 записей: 15 liveness и 50 readiness. Тексты — context deadline exceeded, request canceled while waiting for connection, connect: connection refused, read: connection reset by peer. Фронт registry переставал отвечать на :8443 целиком, а не отдавал ошибку.

Сводим

04:11–04:13  nginx registry дважды валит liveness-пробу, контейнеры перезапускаются
04:19:34     единичный отказ image pull в кластере-потребителе
05:12–05:16  вторая волна: снова провалы проб и перезапуски
05:18:25     registry недоступен на сетевом уровне: no route to host
05:31–05:56  registry отвечает 503 Slow Down и 500 — 240 отказов в 46 namespace'ах
             (в их числе два пода GoCD, из-за которых всё и началось)
06:00        тишина
06:23, 06:27 поды nginx заменены целиком — после этого от инцидента не остаётся
             даже ненулевого счётчика рестартов
09:15:40     последний одиночный выброс

Час нестабильности локализован по краям, с точностью до минуты, и понятно, что registry сначала лёг на сетевом уровне, а потом ещё двадцать пять минут отвечал ошибками всему кластеру. Первопричина этого окна — отдельная история и не предмет статьи. Здесь важно другое: восстановить его целиком удалось по записям о событиях, которых в кластере уже нет — ни у подов GoCD, потому что подов больше не существует, ни у registry, потому что события протухли по TTL.

Началось всё с одного упавшего пайплайна.

Два нюанса, о которых стоит знать

Раз уж речь про честность: у текущей реализации есть две особенности, на которых легко обжечься.

event.count — кумулятивный счётчик серии, а не число за окно. Видно прямо в таймлайне выше: у серии ImagePullBackOff счётчик идёт 1 → 62 → 72, и это одна и та же серия, а не 135 отдельных событий. Суммировать поле по записям нельзя: sum(event.count) по 65 записям Harbor даёт 1141, что не значит ничего. Корректно — сначала максимум по серии, потом сумма:

… event.reason:Unhealthy
| stats by (k8s.name) max(event.count) c
| stats sum(c) probe_failures_total, count() series

Получается 288 провалов проб по 18 сериям — с оговоркой, что счётчики накапливались за всё время жизни серий и часть пришла со вчерашнего дня.

k8s.name содержит имя объекта Event, а не имя пода. Видно в записи выше: k8s.kind — это Pod, а k8s.namegocd-agent-job-2712fcc3-….18ca6b2e2d663427, то есть имя самого объекта Event в формате <имя объекта>.<hex-суффикс>. Практическое следствие: сгруппировать по имени пода или деплоймента нельзя, приходится ловить префиксом. Работает это только потому, что имя события начинается с имени объекта — но это соглашение kubelet и контроллеров, а не гарантия API.

Это уже в очереди на починку.

Что дальше и чего пока нет

Честные ограничения версии 0.3.0: экспортер рассчитан на один экземпляр — запустив два, вы получите дубли; фильтров по содержимому событий пока нет, отсекать заведомо бесполезное придётся на стороне хранилища.

Ровно это и есть ближайшие планы: multi-instance режим без дублирования записей, обогащение событий структурными полями — лейблами и аннотациями, плоские фильтры и то самое отдельное поле с именем объекта. Работа идёт по мере сил и свободного времени.

Если проект покажется полезным — забирайте, ставьте, приносите issue и предложения: https://github.com/lev-stas/KENT

А пока вопрос к вам: как вы храните события в своих кластерах — и храните ли вообще? Отдельно интересны инсталляции, где эта задача закрыта чем-то, чего в этом обзоре не оказалось.


Устройство самого экспортера здесь намеренно осталось за кадром — эта статья про расследование. Всё «мясо» — архитектура, определение версии API, watch-сессия и реконнекты, механика дедупликации, доставка с ретраями и graceful shutdown, что KENT просит от кластера и как его поставить — разобрано отдельно, на моём сайте: https://flexygrid.ru/blog/3ffcd53c-616c-4003-8da5-3ee84a4a3302?utm_source=habr&utm_medium=referral&utm_campaign=kent&utm_content=article. А быстрее всего анонсы появляются в телеграм-канале «Инфраструктура без магии»: https://t.me/infra_bez_magii.