Высокий уровень 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.name — gocd-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.

