8 мин чтения

.NET Aspire: локальная оркестрация распределённого приложения

Как .NET Aspire поднимает Postgres, Redis и RabbitMQ вместе с приложением, добавляет телеметрию и позволяет тестировать распределённую систему целиком.

Виктор Бузин · Founder & Principal Engineer, AnetLab
16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

Проблема, которую решает Aspire

Распределённое приложение на .NET в разработке собирается из кусков: база, кэш, брокер сообщений, два-три сервиса, фронтенд. Каждый кусок поднимается отдельно — docker compose, ручной набор строк подключения, отдельный файл конфигурации на каждую машину. На середине проекта это превращается в постоянную мелкую работу: поднять окружение под новую фичу, воспроизвести сбой у коллеги, проверить, что после изменения одной строки в конфигурации не посыпались три сервиса.

Aspire — это слой оркестрации, который убирает ручную часть. Документация .NET Aspire — описание ресурсов и AppHost. Вы описываете ресурсы приложения в одном месте — кодом, в так называемом AppHost — а рантайм сам поднимает контейнеры, прокидывает строки подключения в сервисы, расставляет health check и умеет ждать готовности.

Ключевая мысль: описание окружения становится частью исходников. Оно попадает в репозиторий, ревьюится, версионируется вместе с кодом. Отдельной сущности «как поднять локально» больше нет.

AppHost: весь оркестр в одном файле

Описание выглядит прямолинейно. Создаётся builder, в него добавляются ресурсы, между ними прокидываются ссылки:

var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres")
    .AddDatabase("inventorydb");

var cache = builder.AddRedis("cache");
var messaging = builder.AddRabbitMQ("messaging");

var api = builder.AddProject<Projects.InventoryManagement_ApiService>("api")
    .WithReference(postgres)
    .WithReference(cache)
    .WithReference(messaging)
    .WaitFor(cache);

builder.AddProject<Projects.InventoryManagement_Web>("webfrontend")
    .WithExternalHttpEndpoints()
    .WithHttpHealthCheck("/health")
    .WithReference(api)
    .WithReference(cache);

await builder.Build().RunAsync();

По существу здесь происходит вот что. AddPostgres и AddRedis описывают не «подключиться к Postgres», а «в составе этого приложения будет Postgres, живой он или нет — рантайм разберётся». AddDatabase("inventorydb") создаёт базу внутри Postgres и выдаёт её как отдельный ресурс — с собственной строкой подключения, которую можно передать сервису.

WithReference — это прокидывание строки подключения в сервис. Неявное, по имени ресурса: сервис получает переменную окружения с адресом и учётными данными, и ему не нужно знать, откуда она взялась. Тот же сервис, запущенный в продакшене, получает строку подключения из конфигурации хостинга — код приложения не меняется.

WaitFor(cache) задаёт порядок запуска: API не стартует, пока кэш не будет здоров. Это снимает целый класс ошибок вида «сервис поднялся раньше зависимости и упал при первом обращении».

WithExternalHttpEndpoints помечает веб-фронтенд как доступный извне: рантайм поднимет его на порту, который можно открыть в браузере, и сам подставит адрес в ссылки внутри приложения. WithHttpHealthCheck("/health") указывает, по какому маршруту проверять готовность.

Средства на этом не заканчиваются. Кроме показанных Postgres, Redis и RabbitMQ, поддерживаются AddSqlServer, произвольные контейнеры через AddContainer и исполняемые файлы — то, что не описано в AppHost, в рантайм не попадёт, и это делает состав окружения явным. Состав ресурсов и сам AppHost — в репозитории dotnet/aspire.

Отдельно стоит сказать про WithReference. Это не «прописать строку подключения руками», а объявление связи между ресурсами. Строка подключения не пишется в конфигурацию сервиса вообще — рантайм сгенерирует её и передаст сервису через переменную окружения с именем по имени ресурса. Практическое следствие: в коде сервиса нет ни адреса базы, ни логина, ни пароля, и при смене способа доступа (например, с локального контейнера на управляемый Postgres) код сервиса не меняется — меняется только AppHost.

Есть у WithReference и ограничение, о котором стоит знать заранее. Он передаёт учётные данные и адрес, а не права доступа в полном смысле: схема базы, миграции, политики RLS — всё это остаётся зоной приложения. Aspire собирает окружение, но не разворачивает схему данных за вас. Для локальной разработки это нормально, потому что схему создаёт контейнер с базой; для сценария, где база уже существует и наполнена, AddPostgres заменяют на AddConnection с готовым адресом.

Телеметрия: не «добавьте пакет», а одна строка

Наблюдаемость в Aspire — не отдельный слой, который собирают вручную. В проекте ServiceDefaults (он создаётся вместе с AppHost) уже настроен OpenTelemetry, и подключение сводится к добавлению метрик и трассировок:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics.AddMeter("MyApp.Custom"))
    .WithTracing(tracing => tracing.AddSource("MyApp.Custom"));

Через ту же точку расширения подключаются логи, метрики и трассировки. Дальше запускается дашборд Aspire, где видно граф сервисов, их состояние, логи и распределённые трассы. Плюс в том, что это локальная картина, а не догадка: в разработке вы видите ровно те же связи, которые потом появятся в observability-стеке.

Механика простая: WithMetrics подписывает дашборд на ваш счётчик, WithTracing — на ваш источник трассировок. Счётчик — это Meter из System.Diagnostics.Metrics, трассировка — ActivitySource из того же пространства имён. Никакой отдельной инфраструктуры: те же самые метрики и спаны, которые вы потом отправите в OpenTelemetry-бэкенд, просто подхватываются локально.

Для распределённой системы это меняет цену эксперимента. Проверка «а что будет, если кэш отвалится» перестаёт быть отдельной задачей с ручной сборкой окружения и становится одним переключателем. А вопрос «кто именно медленнее — приложение или база» перестаёт быть догадкой: на дашборде виден разрез времени между вызовом и ответом по каждому сервису.

Тестирование: интеграционный тест без ручной настройки

Отдельная ценность Aspire — тесты. Обычная интеграционная проверка требует поднять зависимости вручную или обернуть это в docker compose на каждый набор тестов. В Aspire тот же AppHost используется как источник конфигурации для теста:

public async Task WebFrontend_ReturnsOk()
{
    // Arrange
    var builder = await DistributedApplicationTestingBuilder
        .CreateAsync<Projects.InventoryManagement_AppHost>();

    builder.Services.ConfigureHttpClientDefaults(clientBuilder =>
        clientBuilder.AddStandardResilienceHandler());

    await using var app = await builder.BuildAsync();
    await app.StartAsync();

    // Act
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    await app.ResourceNotifications.WaitForResourceHealthyAsync(
        "webfrontend", cts.Token);

    var httpClient = app.CreateHttpClient("webfrontend");
    var response = await httpClient.GetAsync("/");

    // Assert
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

Что здесь принципиально: тест поднимает то же приложение, что поднимает разработчик, — с реальными Postgres, Redis и RabbitMQ, с теми же строками подключения и тем же порядком запуска. Расхождение между «как у меня работает» и «как в тесте» исчезает, потому что это одно и то же описание.

Билдер тестов лежит в пространстве имён Aspire.Hosting.Testing — без соответствующей директивы using тип не находится, и ошибка выглядит как «не найден идентификатор», хотя с самим API всё в порядке.

Порядок вызовов важен. WaitForResourceHealthyAsync с таймаутом в 30 секунд идёт до создания HTTP-клиента и запроса: пока ресурс не здоров, создавать клиент и стучаться в приложение бессмысленно — тест упал бы на гонке, когда приложение ещё поднимается.

Что даёт Aspire на практике

Первый эффект — воспроизводимость. Поднять окружение под новую машину разработчика или под CI теперь одна команда, и результат не зависит от того, что кто-то когда-то настроил руками.

Второй — исчезновение класса расхождений. Строка подключения, зашитая в appsettings.Development.json в обход всего остального, рано или поздно расходится с реальностью. Когда описание окружения одно и лежит в репозитории, такого расхождения не возникает: сервис получает адрес из ресурса, а не из копии конфигурации.

Третий — наблюдаемость по умолчанию. Телеметрия подключается в ServiceDefaults, а не в каждом сервисе отдельно. Новый сервис в проекте получает её вместе с шаблоном.

Четвёртый, и самый недооценённый, — тесты, которые проверяют систему целиком. Сценарий «заказ создан, событие ушло в брокер, потребитель его обработал» становится обычным интеграционным тестом, а не ручной проверкой.

Границы применимости

У Aspire есть границы, и часть из них видна сразу.

Инфраструктура в облаке. AppHost описывает локальную разработку и публикацию, но не заменяет инфраструктурный код в вашем облаке: для сложной топологии, сетевых ограничений и политик безопасности инфраструктура остаётся отдельным слоем.

Секреты. Описание окружения не заменяет управление секретами. Строки подключения, которые AppHost передаёт сервисам, в локальной разработке формируются самим рантаймом, а в продакшене приходят из менеджера секретов. Смешивать эти два механизма — верный способ получить ключ в репозитории.

Связность с платформой. Aspire — часть экосистемы .NET, и перенос на другой стек означает отказ от него. Для проекта, который целиком на .NET, это не ограничение.

Внешние зависимости в тестах. Связка AppHost с тестами работает лучше всего, когда зависимости поднимаются в контейнерах. Внешние базы, доступные по сети, тестировать можно, но воспроизводимость такого теста ниже — он зависит от состояния, которое команда не контролирует.

Публикация. AppHost описывает локальное окружение, а не инфраструктуру продакшена. При публикации описание превращается в ресурсы целевой платформы — контейнеры, managed-сервисы, секреты, — но сложные сценарии вроде сетевой изоляции, приватных эндпоинтов и политик доступа в контур не попадают. Их по-прежнему описывают отдельно, и в этом нет противоречия: Aspire отвечает за то, как приложение запускается, а не за то, как устроена инфраструктура вокруг него.

AppHost — это код. Описание окружения меняется вместе с кодом, и к нему применимы обычные правила ревью: изменение в AddPostgres или в WithReference видно в diff так же хорошо, как изменение обработчика запроса. Это не отдельный конфигурационный файл, который однажды настроили и забыли.

О чём стоит помнить

  • Aspire переносит описание локального окружения из конфигурационных файлов в исходники: AppHost — это код, который проходит ревью.
  • WithReference прокидывает строки подключения автоматически, WaitFor задаёт порядок готовности, WithExternalHttpEndpoints и WithHttpHealthCheck описывают доступность и health check.
  • Телеметрия подключается в ServiceDefaults через OpenTelemetry, а дашборд Aspire показывает граф сервисов, логи и трассы локально.
  • Тесты используют тот же AppHost, что и разработка: DistributedApplicationTestingBuilder поднимает ту же конфигурацию, что и локальный запуск.
  • AppHost не заменяет управление секретами и не покрывает сложную инфраструктуру продакшена.
  • Схема базы, миграции и политики доступа остаются зоной приложения: Aspire собирает окружение, но не разворачивает схему данных.
  • То, что не описано в AppHost, в рантайм не попадёт — состав окружения становится явным контрактом, а не соглашением.

Похожие статьи

CTA

Если локальный запуск вашего распределённого приложения держится на docker compose и наборе строк подключения, посмотрите, что из этого можно описать кодом. Обычно AppHost занимает меньше страницы, а разрыв между «у меня работает» и «у коллеги не работает» исчезает вместе с ручной настройкой.

Пересобрать локальное окружение на Aspire — напишите нам.

Читайте также

Заявка

Обсудить задачу

Оставьте имя и email — отвечу в течение рабочего дня. Если задача срочная, проще написать напрямую наproject@anetlab.ru.