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

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

> For the index of this section of the site, see [llms.txt](https://anetlab.ru/llms.txt)

22 октября 2026 г.8 мин чтения

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

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

[Виктор Бузин](https://anetlab.ru/about.md) · Founder & Principal Engineer, AnetLab

16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

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

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

Aspire — это слой оркестрации, который убирает ручную часть. [Документация .NET Aspire](https://learn.microsoft.com/dotnet/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](https://github.com/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-систем: референсная архитектура](https://anetlab.ru/blog/dotnet-azure-ai-architecture.md)
*   [Модернизация legacy .NET: WCF, AppDomains и AI-мост](https://anetlab.ru/blog/legacy-dotnet-modernisation.md)
*   [Архитектура enterprise AI: четыре решения, которые всё определяют](https://anetlab.ru/blog/enterprise-ai-architecture.md)
*   [Собираем RAG-пайплайн на .NET: практический разбор](https://anetlab.ru/blog/rag-pipeline-dotnet.md)

## CTA

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

Пересобрать локальное окружение на Aspire — [напишите нам](https://anetlab.ru/contact.md).

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

13 октября 2026 г..NET

### [.NET и Azure для AI-систем: референсная архитектура](https://anetlab.ru/blog/dotnet-azure-ai-architecture.md)

Как собрать production-ready AI-систему на .NET и Azure: async-пайплайн, RAG, Polly, observability и контроль стоимости — с рабочим кодом.

[Читать разбор](https://anetlab.ru/blog/dotnet-azure-ai-architecture.md)

[AI/LLM](https://anetlab.ru/blog/?tag=AI%2FLLM.md)[Архитектура](https://anetlab.ru/blog/?tag=%D0%90%D1%80%D1%85%D0%B8%D1%82%D0%B5%D0%BA%D1%82%D1%83%D1%80%D0%B0.md)[RAG](https://anetlab.ru/blog/?tag=RAG.md)

22 октября 2026 г.AI/LLM

### [Локальные LLM в .NET: Ollama, приватность и цена вопроса](https://anetlab.ru/blog/local-llm-dotnet.md)

Когда локальная модель выгоднее облачной: приватность, предсказуемая стоимость и работа офлайн. Разбираем Ollama, Gemma 4 12B и подключение к .NET.

[Читать разбор](https://anetlab.ru/blog/local-llm-dotnet.md)

[.NET](https://anetlab.ru/blog/?tag=.NET)[Локальные модели](https://anetlab.ru/blog/?tag=%D0%9B%D0%BE%D0%BA%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D0%B5%20%D0%BC%D0%BE%D0%B4%D0%B5%D0%BB%D0%B8.md)[Приватность](https://anetlab.ru/blog/?tag=%D0%9F%D1%80%D0%B8%D0%B2%D0%B0%D1%82%D0%BD%D0%BE%D1%81%D1%82%D1%8C.md)