Введение
Привет, Хабр! На связи снова разработчик из компании АльфаСтрахование. Наша скрам-команда занимается автоматизацией бизнес-процессов Операционного блока, и для решения многих задач нам часто требуются фоновые операции. В этой статье я расскажу о шаблоне фонового сервиса на .NET, который мы используем в своей работе.
Сам шаблон был создан в нашей команде еще до моего прихода. Однако, приступив к проекту «Исходящий Диадок», я заметил, что прежний шаблон недостаточно гибко справляется с поставленными задачами и требует доработки. В этой статье я поделюсь нашим опытом и расскажу, как мы развивали шаблон фоновых сервисов для решения новых вызовов.
Шаблон 1.0

Изначально фоновые операции были простыми с точки зрения требований к запуску. Это отражалось и на простоте самого шаблона 1.0. По сути, он запускал менеджеры очередей (на рисунке — QueueManager), зарегистрированные в .NET-приложении, по таймеру.
Использование шаблона начиналось с создания различных менеджеров очередей. Затем они регистрировались в контейнере внедрения зависимостей (Dependency Injection). После этого необходимо было создать объект-строитель, который настраивал приложение на вызов менеджеров в фоне. Этот строитель вызывался в файле Program.cs при инициализации приложения. На последнем этапе в конфигурационном файле указывался интервал запуска — в секции TimerConfig:IntervalSeconds (использование этой секции было захардкожено, альтернативы не предусматривалось).
Ниже — пример использования шаблона:
// 1. Нужно создать csproj проект для воркеров <Project Sdk="Microsoft.NET.Sdk.Worker">
// 2. В проекте создать менеджеров очередей, унаследованных от интерфейса
// IQueueManager, для обработки элементов очереди
public class ExampleQueueManager : IQueueManager
{
public void ProcessQueue()
{
// Код обрабатывающий очередь элементов
}
}
// 3. Сформировать builder для воркера, в котором будет происходить регистрация зависимостей
// (в том числе менеджеров очередей)
public class ExampleWorkerHostBuilder : BaseWorkerHostBuilder<QueueIntervalWorker>
{
public ExampleWorkerHostBuilder(
HostBuilderContext hostContext,
IServiceCollection services)
: base(hostContext, services) { }
protected override void Configure(IConfiguration configuration)
{
Services.AddTransient<IQueueManager, ExampleQueueManager>();
}
}
// 4. Применить builder-а в Program.cs
public class Program
{
public static void Main(string[] args)
{
Logger logger = NLogBuilder
.ConfigureNLog("nlog.config")
.GetCurrentClassLogger();
try
{
Host.CreateDefaultBuilder(args)
.UseWindowsService()
.ConfigureServices((hostContext, services) =>
{
var builder = new ExampleWorkerHostBuilder(
hostContext,
services);
builder.Build();
})
.UseNLog()
.Build()
.Run();
}
catch (Exception ex)
{
logger.Error(ex);
}
finally
{
LogManager.Shutdown();
}
}
}
// 5. В конфигурации приложения в секции TimerConfig:IntervalSeconds
// указать интервал запуска менеджеров
Анализ проблем и формирование требований
Из проблем, которые имел шаблон 1.0, можно выделить 4 основных:
Нет запуска по расписанию. Шаблон позволяет запускать задачи только по таймеру. Если нужно, чтобы задача выполнялась каждый день в определённое время, приходится вручную запускать приложение тогда, когда это требуется — даже в нерабочие часы;
Нельзя настроить отдельный таймер для каждого менеджера очередей. Все менеджеры запускаются с одним и тем же интервалом, индивидуально настроить частоту для каждого не представляется возможным;
Экземпляр менеджера очередей создаётся один раз при первом запуске. Это усложняет работу с зависимостями, особенно если использовать такие ресурсы, как контекст базы данных или HTTP-клиент;
-
Сложности интеграции с брокерами сообщений. Из-за того, что приложение по таймеру подряд запускает все менеджеры очередей, для взаимодействия с брокерами приходится заводить отдельный менеджер, который бесконечно слушает очередь. В итоге:
становится неочевидно, что приложение вообще работает с брокером сообщений;
нельзя удобно настраивать работу одновременно с несколькими брокерами или несколькими очередями одного брокера в рамках одного приложения.
Большинство неудобств долгое время не мешали работе — с ними можно было мириться или обходить их стороной. Однако игнорирование недостатков не могло длиться вечно. Критическим моментом стал проект исходящий Диадок: в его рамках нам понадобилось запускать в одном приложении несколько фоновых задач, причём каждая из них должна была стартовать в разное время. Это особо остро высветило и другие проблемы, которые прежде удавалось избегать.
Исходя из указанных проблем, мы сформировали список требований к новому шаблону фоновых сервисов:
Минимальные изменения при подключении воркера. Подключение шаблона воркера не должно требовать серьёзных изменений существующего кода, бизнес-логики или архитектуры взаимодействия модулей;
Гибкая настройка триггеров через конфигурацию. Должно быть возможно задавать тип триггера (таймер, расписание cron, Kafka и др.) и его параметры через конфигурационные файлы (appsettings.json, переменные среды и пр.);
Пользовательские и расширяемые триггеры. Возможность реализовывать и подключать собственные типы событий-триггеров (например, через расширение или наследование), помимо встроенных;
Индивидуальные триггеры для разных менеджеров. Для каждого менеджера очередей должен настраиваться свой собственный триггер, конфигурируемый отдельно;
Поддержка нескольких менеджеров очередей. Приложение должно работать сразу с несколькими менеджерами (например, чтобы слушать несколько топиков Kafka; один менеджер запускать по таймеру, а другой по расписанию);
-
Гибкость в одновременном запуске задач. Когда несколько событий-триггеров срабатывают одновременно — должно быть предусмотрено:
последовательное выполнение менеджеров очередей;
либо немедленный параллельный запуск, если хватает ресурсов (вариант выбирается в конфигурации);
Обработка сообщений из Kafka. Необходимо предусмотреть возможность обработки очередей, основанных на топиках Kafka (как основной вариант, либо с последующей расширяемостью);
Запуск задач по расписанию или таймеру. Новый шаблон должен поддерживать оба варианта;
Работа через Dependency Injection (DI). Все основные компоненты (менеджеры очередей, обработчики событий, триггеры и вспомогательные сервисы) должны регистрироваться и разрешаться через стандартный механизм dependency injection в .NET.
Выбор решения
В первую очередь мы рассмотрели существующие готовые решения:
Требования |
Hangfire |
Quartz.NET |
KafkaFlow |
Confluent.Kafka |
Минимальные изменения при подключении воркера |
~ Средняя |
✓ Низкая |
✗ Высокая |
✗ Высокая |
Гибкая настройка триггеров через конфигурацию |
~ Средне |
✓ Легко |
✓ Легко |
✗ Сложно |
Пользовательские и настраиваемые триггеры |
✗ Сложно |
✓ Легко |
✓ Легко |
✗ Сложно |
Индивидуальные триггеры для разных менеджеров |
~ Средне (только разграничением) |
✓ Легко |
✓ Легко |
✗ Сложно |
Поддержка нескольких менеджеров очередей |
✓ Да |
✓ Да |
✓ Да |
✓ Да |
Варианты одновременного запуска |
✓ Да |
✓ Да |
✓ Да |
✓ Да |
Обработка сообщений из Kafka |
~ Средняя (через job) |
~ Средняя (через job) |
✓ Легко |
✓ Легко |
Обработка задач по таймеру/расписанию |
✓ Да |
✓ Да |
✗ Нет |
✗ Нет |
Работа через dependency injection (DI) |
✓ Да |
✓ Да |
✓ Да |
✓ Легко (без DI, но интегрируется легко) |
Сравнив разные варианты, мы пришли к выводу, что ни одно из существующих решений полностью не покрывает наши требования — каждая из библиотек хорошо справляется только с отдельной группой задач.
Hangfire, Quartz.NET и им подобные — отличные планировщики задач, но возникают трудности, когда требуется обрабатывать бесконечные очереди (например, Kafka или RabbitMQ).
KafkaFlow, Confluent.Kafka и аналоги, в свою очередь, изначально рассчитаны на работу с бесконечными потоками сообщений, но слабо подходят для обычного планирования задач по расписанию или таймеру.
Исходя из этого, мы решили не искать компромиссное готовое решение, а заняться улучшением собственного шаблона. Это позволило нам точечно решить именно наши задачи — без избыточной функциональности или компромиссов.
Шаблон 2.0
Очередь фоновых действий

Сразу отмечу: на последующих схемах я буду опускать ранее изображенные детали и показывать только изменившиеся элементы — это упростит восприятие иллюстраций.
Первым шагом реализации стала очередь фоновых действий (далее — ОФД). Мы не стали изобретать велосипед и взяли за основу реализацию от Microsoft, немного дополнив её под наши задачи. Для этого мы создали обёртку для действий, выполняемых в фоне — BackAction. Этот вспомогательный класс инкапсулирует не только само действие, которое необходимо выполнить, но и связанную с ним задачу (Task), позволяющую отслеживать завершение работы во внешнем коде. В приложении появилась очередь этих действий, а также фоновая служба, которая на протяжении всего времени работы .NET-приложения читает эту очередь и запускает фоновые действия.
В большинстве случаев доступ к сервису управления очередью действий осуществляется через интерфейс IBackActionQueueService: добавляете действие — оно автоматически попадёт в ОФД и будет выполнено службой QueuedHostedService.
Запуск фонового менеджера в рамках фоновых действий

На этой схеме показан запуск фоновых менеджеров с использованием ОФД.
Сначала про терминологию: в шаблоне 2.0 мы отказались от термина «менеджер очередей». Вместо него мы ввели новый термин — фоновый менеджер (ФМ): это сервис, который вызывается фоновой службой и выполняет работу по событию. Эту работу можно реализовать так, что она будет обрабатывать очередь элементов — как это делал менеджер очередей раньше, но без жёсткой привязки к очереди сообщений.
Центральное место на схеме занимает PushBackgroundManagerHostedService — фоновая служба, отвечающая за взаимодействие с ФМ, которые нужно запускать в рамках ОФД. Сначала служба определяет все объекты BmInfo, относящиеся к ней. Для каждого такого объекта ожидается определённое событие-триггер. При наступлении события формируется BackAction, который отправляется в ОФД. В рамках одного BackAction заложено два действия: создать новый экземпляр ФМ и запустить его. После выполнения действия сервис снова начинает ждать следующее событие-триггер для этого BmInfo, повторяя цикл.
Стоит отметить, что PushBackgroundManagerHostedService — это обобщённое понятие. На практике таких служб несколько, и каждая может отслеживать своё событие: срабатывание таймера, наступление определённого времени, получение сообщения из Kafka и так далее. Такой подход позволяет легко расширять функциональность и добавлять новые сценарии.
Запуск фоновых менеджеров в обрабатывающих службах

Как видно из схемы, здесь отличие в одном: вместо того чтобы отправлять BackAction в ОФД, служба RunBackgroundManagerHostedService сама занимается созданием и запуском экземпляров ФМ напрямую.
Такой способ полезен, например, когда приложению требуется обрабатывать несколько топиков Kafka независимо друг от друга.
Пример использования
Пример использования ОФД в WebAPI
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Подключаем ОФД
builder.Host.UseBackgroundManagers();
// Добавляем контроллеры
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
// Файл контроллера CountersController.cs
[ApiController]
[Route("[controller]")]
public class CountersController(IBackActionQueueService backActionQueueService) : Controller
{
private static int _counter;
[HttpGet]
public int Get()
{
return _counter;
}
[HttpPost]
public async ValueTask PostAsync(CancellationToken cancellationToken)
{
BackAction backAction = new(_ =>
{
_counter++;
return default;
});
await backActionQueueService.EnqueueAsync(backAction, cancellationToken);
}
}
Пример использования ФМ в WebAPI
Пример конфигурации ФМ в appsettings.json файле:
{
"Managers": {
// Конфигурация менеджера, запускаемого по таймеру в рамках очереди фоновых действий
"ExampleTimer": {
"RunMode": "QueueBackAction",
"TriggerType": "Timer",
"Timer": "00:00:40", // Запускаем каждые 40 секунд
"RunOnStartupApp": true // Первый запуск при старте приложения
},
}
}
Пример использования ФМ в WebAPI:
// ExampleTimerConfig.cs | Создаем конфигурацию для нашего ФМ
public class ExampleTimerConfig: BaseBackgroundManagerConfig;
// ExampleTimerManager | Создаем менеджера с некоторой логикой
public class ExampleTimerManager (
IOptionsSnapshot<ExampleTimerConfig> options,
ILogger<ExampleTimerManager> logger) : IBackgroundManager
{
public ValueTask ExecuteAsync(Dictionary<string, object> args, CancellationToken cancellationToken)
{
ExampleTimerConfig config = options.Value;
logger.LogInformation($"Мод запуска: {config.RunMode}, Тип события-триггера: {config.TriggerType}, Таймер: {config.Timer}");
return default;
}
}
// Program.cs | Добавляем сервисы для работы ФМ и сам ФМ в DI
var builder = WebApplication.CreateBuilder(args);
// Подключаем сервисы для фоновых менеджеров в DI
builder.Host.UseBackgroundManagers();
// Добавляем контроллеры
builder.Services.AddControllers();
// Добавляем фоновых менеджеров в DI
builder.Services.AddBackgroundManager<ExampleTimerManager, ExampleTimerConfig>(builder.Configuration, "Managers:ExampleTimer");
var app = builder.Build();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
Результат
Шаблон 2.0 показал себя на практике очень достойно — интеграция с существующими приложениями проходит легко и удобно. Перевести более 30 наших фоновых сервисов на новый шаблон удалось всего за пару двухнедельных спринтов (с учётом тестирования). Стоит отметить, что основное время ушло именно на тестирование: само переписывание всех приложений под шаблон 2.0 занило до 5 дней.
После внедрения нового шаблона мы получили не только выполнение всех поставленных требований, но и несколько приятных бонусов.
Во-первых, стало возможным запускать фоновые действия непосредственно — без обязательного создания отдельных менеджеров. Это удобно, например, когда в ходе HTTP-запроса нужно инициировать какие-то дополнительные фоновые действия — они уходят в очередь, освобождая основной поток.
Во-вторых, новый шаблон оказалось гораздо проще встраивать в .NET-приложение. Это упростило и ускорило создание новых воркеров. А также позволило легко и быстро «переобувать» сервисы из Worker Service в WebAPI и обратно, что существенно упрощает и ускоряет миграцию с Windows-сервисов на контейнеры в Kubernetes.
Также важно отметить, что шаблон изначально проектировался как расширяемое решение и уже сейчас поддерживает несколько популярных сценариев запуска фоновых действий:
выполнение по таймеру: задание откладывается на N секунд
плановый запуск по расписанию — реализовано на базе hangfireIO Cronos
реакция на поступление сообщений из Kafka — реализовано на базе confluent kafka dotnet
реакция на поступление сообщений из очереди RabbitMQ — реализовано на базе easynetq
Ознакомиться с реализацией шаблона 2.0 можно по этой ссылке. Примеры использования находятся в том же репозитории — смотрите тут. Выложенная версия шаблона заметно упрощена: убран функционал работы с брокерами сообщений, а еще пару моментов — так проще разобраться с основными идеями.
P.S. Спасибо, что дочитали до конца!
Комментарии (7)

WhiteBehemoth
30.01.2026 19:25А не рассматривали выделение фоновых процессов в azure functions? Их не обязательно хостить в Azure, они прекрасно живут в docker, "на ура" масштабируются в k8s и у них "из коробки" куча триггеров на события очередей, таймер, http hook и т.п.

GetcuReone Автор
30.01.2026 19:25Отличный вопрос, который в статье описан не совсем прозрачно. Если отвечать кратко, то нет, не рассматривали.
Основных причин для это 2
Мобильность. В силу разных ограничений (платформы, ИБ и т.д.) у нас есть ряд приложений, которые административно нельзя или технически пока не получается вынести в k8s. Такие приложения хоститься на обычных windows или linux машинах. Нам нужно было решение, которое в этом плане будет платформо независимым, и с которым приложение сможет с минимальными затратами быстро качевать с одной платформы на другую
Минимазация затрат на переписывание кода. У нас есть фоновые приложения, которые могут запускать разный функционал с разными интервалами времения, разными расписаниями или разными подключениями к разным брокерам сообщений. Ресурса на переписывание взаимодействия с такими приложения хватает далеко не всегда. Поэтому нам нужно было решение, которое может совладать и с этим препятсвием

monco83
30.01.2026 19:25Я так и не понял, почему вам для задач по расписанию не использовать Quartz, а для задач из Кафки - решение для Кафки. Зачем вам нужно было "бескомпромиссное решение"?

GetcuReone Автор
30.01.2026 19:25Здравствуйте!
Спасибо за вопрос. Мы выбрали единое решение не просто так, а по практическим причинам — чтобы создавать согласованные, легко настраиваемые и управляемые фоновые сервисы.
Когда у вас есть единый шаблон, вы можете:
Шаблонно создавать и быстро менять как настройки очередей, так и сами менеджеры
Легко расширять его — например, мы добавили аналог
ActionFilterиз ASP.NET Core для фоновых менеджеров, что решает типовые задачи: логирование, обработки ошибок, метрикиСнизить когнитивную нагрузку на разработчиков — не нужно каждый раз выбирать между Quartz, Confluent.Kafka и другими инструментами
Важно отметить: наш шаблон не заменяет Quartz или Confluent.Kafka. Это адаптерный слой, который:
Использует проверенные библиотеки (Cronos для расписаний, Confluent.Kafka для очередей)
Приводит их к единому интерфейсу и конфигурации
Добавляет недостающую функциональность: DI, мониторинг, управление жизненным циклом
Что это даёт на практике:
Разработчику не нужно думать, как реализовать очередную фоновую задачу с учётом требований по логированию, метрикам и обработке ошибок
Всё работает согласованно: один конфиг, один стиль кода, один подход к тестированию
Если появится новый брокер сообщений — мы встроим его в шаблон один раз, и все разработчики смогут использовать его без переписывания инфраструктурного кода
В итоге мы получаем скорость разработки без потери контроля и качества
ujinjinjin
Отличное чтиво, спасибо за статью. А что по персистентности? Что произойдет с ОФД, если процесс упадет? Потеря данных или храните очередь в каком-то хранилище?
GetcuReone Автор
Спасибо!
Зависит от реализации ваших фоновых менеджеров. Падение прям приложения или процесса в приложении у нас не частая история, но если происходит, то обычно срабатывает один из кейсов:
Если обработка по таймеру или расписанию, то скорее всего при запуске менеджеров обрабатываются очереди основанные таблицах в БД. И в случаее падения приложения просто не сохраняется транзакция. Тогда при рестарте приложение просто также находит эти элементы в этих таблицах и обрабатывает их
Если идет обработка очереди в Kafka или Rabbit. То пока элемент очереди поступивший из брокера не будет успешно обработан, то в брокер не поступит сигнал о комите данного сообщения. Тогда при рестарте приложение просто также подхватит этот же элемент из брокера. Правда в этом кейс уже именно реализация PushBackgroundManagerHostedService так устроена. Поэтому в менеджере с транзакциями, подключением и прочим колдовать не надо
GetcuReone Автор
Также опишу еще один классный полученный от этого инструмента бонус. При разработке мы руководствовались желанием чтобы обработка элементов очереди была приближена к обработке HTTP запросов в контроллерах. Поэтому чуть позже мы также реализовали аналог ActionFilter для наших менеджеров. Они часто спасают нас в ситуациях аналогичных тем, в которых часто используют ActionFilter