Привет! Меня зовут Александр Илларионов, я бэкенд‑разработчик в Altenar. Мы работаем на довольно сложном и при этом очень интересном рынке: собираем данные о спортивных матчах со всего мира — например, время и место их проведения, описания чемпионатов и участников, а также вероятности наступления ключевых событий вроде голов, аутов и пенальти. И всё это в live‑режиме.
В этой статье рассказываю, как мы переосмыслили роль Heartbeat‑компонента в нашей системе — от обычного сигнала до инструмента, который реально управляет работой всей системы. Наш опыт будет полезен тем, кто работает с .NET, распределёнными системами или строит инфраструктуру под высоконагруженные real‑time потоки данных.
Зачем нужен Heartbeat
Представьте ситуацию: вы решили в субботу вечером отдохнуть, пожарить шашлыки, семья рядом… и тут сигналы:
— Кажется, у вас сервис упал!
— Меньше данных льётся, всё ли у вас ок?
Тут же придётся залезать в логи, думать, где же именно произошла неисправность. А потом, может быть, окажется, что в системе всё нормально работает, а проблемы на стороне пользователя.
И вот вы понимаете, как было бы удобно иметь механизм, который информирует о состоянии системы. Это и есть Heartbeat.
Heartbeat как сигнал о здоровье
Определение Heartbeat можно понимать по‑разному, всё зависит от контекста. Это могут быть сигналы на уровне сети (ICMP/Ping), keep‑alive сообщения для протоколов или, например, механизмы Probes в Kubernetes — о них я расскажу дальше.
В контексте нашего проекта и нашей разработки мы имеем в виду конкретную вещь. У нас микросервисная архитектура: много компонентов, каждый отвечает за свой домен и выполняет свою часть работы. И Heartbeat — это отдельный компонент, который наблюдает за всей этой системой и передаёт потребителю информацию о её состоянии через периодические сигналы.
Heartbeat — это периодический сигнал, который посылается для подтверждения работоспособности. Тут хорошо подходит аналогия с сердечным пульсом: есть сигнал — значит, система в порядке.

Что было в начале
У нас есть система, которая состоит из нескольких сервисов, и эти сервисы обрабатывают данные, допустим, по футбольным матчам. Есть Heartbeat: он наблюдает за ними и отправляет клиенту сигналы, показывая, что всё работает.
Если происходит какая‑то неисправность — например, один из сервисов отказал — Heartbeat это замечает и перестаёт отправлять сигналы клиентам. Клиент, в свою очередь, понимает, что в нашей системе что‑то идёт не так, и считает, что мы «умерли».

На маленькой системе это вроде бы логично, но проблема в том, что система не стоит на месте — она развивается. Появляются новые виды спорта, новые компоненты. Например, мы начали обрабатывать не только футбольные, но и баскетбольные матчи.
И что происходит в этом случае? Если ломается какой‑то футбольный сервис, Heartbeat по‑прежнему перестаёт отправлять сигналы. Клиент думает, что всё сломалось. Но это не совсем так, ведь мы можем продолжать обработку данных по баскетболу, данные по этому спорту могут быть корректными. И в обратную сторону получается та же самая ситуация.

Получается, что такой Heartbeat слишком капризный при росте сложности системы. Он также неявно связывает независимые сервисы. И, как следствие, система теряет доступность там, где не должна.
Задача — переделать Heartbeat
Нам нужно было изменить Heartbeat так, чтобы:
независимые сервисы не влияли друг на друга и систему в целом;
подсистема оставалась расширяемой и настраиваемой;
сохранялись гарантии надёжности.
Последний пункт особенно важен. Например, если выходит из строя какой‑то сервис, обрабатывающий футбольные данные, мы не «кладём» всю систему целиком.
Мы хотим сообщить клиенту о том, что проблема затрагивает именно футбольные матчи, но сразу это сделать не можем. Потому что реальный пайплайн обычно значительно длиннее. Кроме того, в нём могут присутствовать не только последовательные этапы обработки, но и параллельные (если один из сервисов работает некорректно, обработка в других сервисах может продолжаться, а это может привести к передаче клиенту некорректных данных).
Именно поэтому для нас принципиально важна последовательность в этих действиях: сначала заблокировать трафик, а потом уже отключить его и сообщить клиенту о проблеме.

Поиск решения
На поверхности было несколько вариантов: страница статусов, uptime‑мониторинг системы или сервис‑меш, но все они нам не подходили.
Причины:
Нам требовалась автореакция. Страница статусов её не обеспечивает, поскольку это лишь информирование — в лучшем случае инженеры увидят уведомление и подключатся. Это может ускорить реакцию, но нам нужно моментальное решение на уровне программы.
Нам нужно отправлять сигналы не куда угодно, а в RabbitMQ. Несмотря на широкий выбор вариантов по мониторингу, подходящих нотификаций там не было. Circuit breaker нам также не подходил по следующим требованиям.
Нужна гранулярность. Мы не хотим отключать сразу весь спорт или все матчи. Нам нужно как можно более детально управлять передаваемыми данными. Даже в рамках одного спорта может быть сто параллельно идущих матчей, где девяносто из них могут обрабатываться корректно. Отдельный сервис может влиять только на часть этих матчей, и нам необходимо максимально точно разделять их. Для этого нужно учитывать часть доменной логики. Недостаточно просто отключать трафик, как это делает сервис‑меш, блокируя все пакеты. Нам требуется более точечное управление.
Нужна чистота данных. Это гарантии надёжности и корректности в коммуникации с внешним потребителем.

Каждое из готовых решений само по себе полезно, и где‑то мы могли бы их комбинировать. Однако мы пришли к выводу, что Heartbeat для нас должен быть не просто пингом и мониторингом. Он должен стать полноценным компонентом управления системой. Его задача — не просто сообщать пользователю о том, что идёт не так в системе. Нам было важно, чтобы продукт приносил ценность, то есть это уже история про отказоустойчивость.

Дальше о том, как мы это реализовывали.
Аудит сервисов и спецификации
План был такой:
поделить сервисы на группы в зависимости от того, кто и как влияет на спорты и матчи;
определить критерий неисправности;
определить приемлемое/неприемлемое время простоя совместно с бизнесом;
определить критерий восстановления.
Разберу по порядку.
Деление на группы
Мы придумали единый формат описания влияния конкретного сервиса на матчи — Heartbeat‑спецификацию.
Heartbeat‑спецификация — это шаблон признаков, по которому необходимо принять решение о блокировке матча.
Например, в такую спецификацию входят:
время, после которого сервис считается недоступным;
виды спорта, на которые он влияет;
статусы матчей;
источники данных;
и другие параметры.
{ "hb_loss_time": "00:05:00", "pattern_affected_matches": { "sport_ids": [ 2 ], "match_statuses": [ "active" ], "required_sources": [ "some_source" ], "..." } }
Мы составили такие спецификации для всех сервисов, которые считали важными. После этого стало понятно, какой сервис в каком случае влияет на систему и насколько дорогой для нас его потенциальный отказ.
Критерий неисправности
Дальше нужно было научиться определять неисправный сервис. Для этого мы используем механизмы Health Checks. Например, в используемом нами ASP.NET Core фреймворке они идут по умолчанию: мы создаём билдер, добавляем Health Checks и маппим их.
Microsoft.Extensions.Diagnostics.HealthChecks (nuget)
var builder = WebApplication.CreateBuilder(args); builder.Services.AddHealthChecks(); var app = builder.Build(); app.MapHealthChecks("/hc"); app.Run();
В такой конфигурации Health Checks отвечают только на один вопрос: может ли Kestrel‑сервер обрабатывать запросы. Если не может, то Health Check не проходит. Это отчасти отсылает к рекомендации не делать тяжёлые или долгие StartAsync в Hosted Service'ах, потому что до завершения метода StartAsync сервер не будет отвечать.
Вообще существует множество нативных пакетов, которые позволяют проверять инфраструктурные зависимости.
Например, есть готовые проверки для PostgreSQL, Kafka, RabbitMQ и других систем.
AspNetCore.HealthChecks.NpgSql (nuget)
builder.Services.AddHealthChecks() .AddCheck<MyCheck>(..., tags: ["tag1"]) // IHealthCheck .AddNpgSql( builder.Configuration.GetConnectionString("DB"), tags: ["tag2"]);
Также можно писать собственные Health Checks, добавлять кастомные проверки и метки, которые можно маппить на разные пути.
app.MapHealthChecks("/hc/live", new() { Predicate = (opt) => opt.Tags.Contains("tag1") }); app.MapHealthChecks("/hc/ready", new() { Predicate = (opt) => opt.Tags.Contains("tag2") });
Зачем это нужно? Некоторые Health Checks проверяют сервис более поверхностно, например, что kestrel отвечает на запросы, или наш легкий самописный hc работает. Другие проверяют уже более детально, погружаются во всю инфраструктуру. Более тяжёлые проверки Health Checks нецелесообразно выполнять постоянно, поэтому на них можно повесить разное время опрашивания. Кроме того, можно кэшировать результат проверки.
Таким образом, механизм Health Checks помог нам определять состояние сервиса: отдаёт ли он health check или нет. Если не отдаёт, мы перекрываем соответствующий поток данных.
Однако возникла проблема. Сервисы обычно не работают в одной реплике — они реплицируются на несколько инстансов. Нужен способ инспектировать их состояние так, чтобы не опрашивать каждую реплику вручную, особенно когда сервисов много.
Здесь пришёл на помощь Kubernetes. В нём есть механизм Probes, который частично решает эту задачу.
Например, startupProbe проверяют, успешна ли инициализация сервиса. Одной из проблем Health Checks было то, что они не учитывают прогрев сервиса, его запуск или процесс релиза — из‑за этого сервис мог считаться «умершим» преждевременно.
spec: containers: - name: my-dotnet-service startupProbe: httpGet: path: /hc port: 8080 failureThreshold: 3 # Даем 3 попытки periodSeconds: 10 # Раз в 10 секунд
Liveness‑пробы позволяют перезапускать контейнеры при ошибках. Соответственно, можно замапить те же самые пути, которые указали в Health Checks, выставить задержки и другие параметры.
spec: containers: - name: my-dotnet-service livenessProbe: # Если не прошли — рестарт контейнера httpGet: path: /hc # Или hc/live port: 8080 initialDelaySeconds: 15 # Можно ждать здесь без startup periodSeconds: 10
readinessProbe передают трафик. Ниже один пример, а так можно опрашивать и по другим протоколам — по TCP, например.
spec: containers: - name: my-dotnet-service readinessProbe: # Влияет на передачу трафика httpGet: path: /hc # Или hc/ready port: 8080 periodSeconds: 5
Итак, мы внедрили Kubernetes, у нас есть deployment или statefulset. Дальше мы добавили пользовательские метки (is‑observable: 'true'), чтобы понимать, какие сервисы должны попадать в Heartbeat.
kind: Deployment metadata: name: my-dotnet-service labels: is-observable: 'true' spec: replicas: 2 status: replicas: 2 updatedReplicas: 2 readyReplicas: 2 availableReplicas: 2
expected replicas — ожидаемое количество реплик, которое мы должны поддерживать;
status replicas — общее количество реплик;
ready replicas — реплики, прошедшие readiness‑пробу;
available replicas — как ready, но с учётом некоторого времени (по умолчанию стоит 0).
Через официальный nuget‑пакет в Kubernetes мы вытягиваем информацию по deployment или statefulset с нужного namespace и метками.
KubernetesClient (nuget)
var deployments = await _kube.AppsV1.ListNamespacedDeploymentAsync( _configuration.TargetNamespace, labelSelector: _configuration.TargetLabelSelector, ...);
И всё: теперь мы можем эту информацию обернуть в свои доменные модели и дальше использовать их для нашей логики.
return deployments.Items.Select(x => new ServiceDescription( new(x.Name()), new(x.Status.AvailableReplicas ?? 0, x.Status.UpdatedReplicas ?? 0, expectedReplicas: x.Spec.Replicas ?? 0, totalReplicas: x.Status.Replicas ?? 0), x.Spec.Selector.MatchLabels.AsReadOnly()));
И также проверяли интересующие поля: работающие и ожидаемое количество реплик.
if (description.ReplicasParameters.AvailableReplicas == description.ReplicasParameters.ExpectedReplicas) { // ToDo }
Казалось бы, проблема решена. Но нет.
Дальше мы столкнулись со следующими подводными камнями.
-
Нужно учитывать обновления (rolling update). Когда релизятся новые версии или руками что‑то меняется, то нужно сверяться именно с expected replicas, а не со total replicas. Если сверяться с total replicas, то при стратегии rolling update может появиться дополнительная реплика, что приведёт уже к некорректному сравнению.
Также если используется не только deployment, но и statefulsets, то важно знать, что стратегия rolling update не поддерживается. Обновления идут в стиле recreate — останавливается один pod, затем поднимается следующий. Ориентироваться на expected replicas бессмысленно, поскольку они всегда будут расходиться. Поэтому в этом случае мы смотрим на параметр updated replicas, который показывает, сколько реплик нужного релиза поднялось.
-
Нужно учитывать рескалирование (особенно актуально вместе с KEDA). KEDA — инструмент автоскалирования для Kubernetes по различным событиям.
Когда количество реплик растёт/уменьшается, expected и actual расходятся. Это сложнее проинспектировать, ориентируясь на updated replicas и даже на observed generation — параметр, который инкрементируется при изменении манифестов YAML. Поэтому нам пришлось запоминать предыдущее состояние для отслеживания scale‑фактора.
Инфраструктурное обслуживание создаёт сложности. Во время переезда нод стратегия min‑max available для pod’ов не всегда спасает. Чтобы поддерживать реплики, мы настроили Pod Disruption Budgets (PDB) — они не дают опустить количество реплик ниже определённого минимума. Эта настройка позволила снизить деградацию производительности.
Но нужно понимать, что PDB будет корректно работать только с сервисами с более 1 репликой. Поэтому необходимо обеспечить высокую доступность (high availability) для сервисов, а значит, масштабировать их. Конечно, это тоже может принести сложности, потому что не все сервисы легко поддаются масштабированию. Например, сервисы, которые работали в одной реплике и должны работать с механизмом выбора лидера или, допустим, имеют in‑memory стейт.
Несмотря на всё это, часть реплик всё равно будет оставаться недоступной какое‑то время. И примитивный Heartbeat, который смотрит на статусы, не сможет различить эту ситуацию. Вот почему мы вернулись к началу: нам всё‑таки пришлось перейти к проверке конкретных pod’ов, связанных с deployment.
Критерии восстановления
С критериями неисправности разобрались. Дальше — про критерии восстановления.
Понятно, что если сервис отдаёт Health Checks и реплики в нужном состоянии — с ним всё хорошо. Но не всегда работающий сервис отвечает требованиям. Например, он мог долго лежать, накопить лаг. Такой сервис при последующей обработке можно считать не полностью подготовленным. Поэтому мы ввели понятие «времени восстановления»: мы считаем, что сервис «ожил» не сразу, как начал отдавать положительный health check, а спустя какое‑то время.
Итог
Мы разделили влияние сервисов на систему, потому что придумали и описали спецификации. Сделали систему расширяемой и настраиваемой благодаря спецификациям гранулярного отсечения трафика. И сохранили гарантию блокировки некорректных данных.
Но на этом история не закончилась.
У системы есть подсистема Heartbeat, которая инспектирует остальные сервисы и может давать информацию конечному пользователю. Но подсистема состоит из нескольких компонентов, и если сами эти компоненты выйдут из строя или с ними что‑то случится, то мы не можем сохранить никакие гарантии.

Поэтому такие сервисы мы назвали критичными и вынесли их проверку в отдельный компонент, который смотрит за Observer и шлюзом. Если они неисправны — нет возможности гарантировать работоспособность, а значит, мы считаем это отказом всей системы. Если сам Heartbeat отваливается, он тоже ничего не шлёт — и это тоже считаем отказом.
В итоге мы сделали систему, которая гранулярно сообщает о неисправностях и блокирует трафик непосредственно на уровне домена. В то же время уменьшает время простоя и количество ручных действий со стороны поддержки и мониторинга.
Польза Heartbeat может быть неочевидна сразу, но можно рассматривать его как страховку. Он поможет оперативно среагировать, предоставить агрегированную информацию и локализовать проблему. В нашем случае это особенно важно на выходных — именно тогда проходит большая часть популярных матчей, финалов и крупных турниров, поэтому любой сбой обходится дороже. И теперь, если нам звонят в субботу или воскресенье, разговоры длятся меньше или вовсе отсутствуют, потому что пользователи и так понимают, работаем мы или нет.