.NET Aspire: локальная оркестрация распределённого приложения
Как .NET Aspire поднимает Postgres, Redis и RabbitMQ вместе с приложением, добавляет телеметрию и позволяет тестировать распределённую систему целиком.
Проблема, которую решает 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, в рантайм не попадёт — состав окружения становится явным контрактом, а не соглашением.
Похожие статьи
- .NET и Azure для AI-систем: референсная архитектура
- Модернизация legacy .NET: WCF, AppDomains и AI-мост
- Архитектура enterprise AI: четыре решения, которые всё определяют
- Собираем RAG-пайплайн на .NET: практический разбор
CTA
Если локальный запуск вашего распределённого приложения держится на docker compose и наборе строк подключения, посмотрите, что из этого можно описать кодом. Обычно AppHost занимает меньше страницы, а разрыв между «у меня работает» и «у коллеги не работает» исчезает вместе с ручной настройкой.
Пересобрать локальное окружение на Aspire — напишите нам.
.NET и Azure для AI-систем: референсная архитектура
Как собрать production-ready AI-систему на .NET и Azure: async-пайплайн, RAG, Polly, observability и контроль стоимости — с рабочим кодом.
Локальные LLM в .NET: Ollama, приватность и цена вопроса
Когда локальная модель выгоднее облачной: приватность, предсказуемая стоимость и работа офлайн. Разбираем Ollama, Gemma 4 12B и подключение к .NET.