Start Debugging

Aspire против Docker Compose для локальной разработки с несколькими сервисами

Aspire 13.4.6 выигрывает внутренний цикл разработки .NET, потому что запускает ваши проекты как процессы хоста, которые можно отлаживать, а Docker Compose выигрывает, когда compose-файл одновременно является вашим контрактом для CI и развёртывания. Измеренные времена запуска и цикла правка-выполнение для обоих, конфигурация, которую каждый инструмент внедряет за вас, и шесть тонкостей, которые решают дело.

Выбирайте Aspire, если сервисы, которые вы запускаете локально, представляют собой проекты .NET, собираемые вами из исходников: он запускает их как обычные процессы хоста, поэтому отладчик подключается сразу ко всем, и внедряет строки подключения и конфигурацию OpenTelemetry, которые иначе вы писали бы вручную. Выбирайте Docker Compose, если ваш docker-compose.yaml одновременно является вашим контрактом для CI, staging или продакшена, либо если основная часть вашего стека — это готовые образы, которые вы не пишете сами. Выбирать между ними не обязательно: aspire publish генерирует compose-файл из той же модели. Все числа и API ниже относятся к Aspire 13.4.6 (текущий стабильный выпуск, опубликован 2026-06-20) и Docker Compose v5.1.4 на .NET 10.

Замечание о названии: продукт отказался от префикса ”.NET” начиная с Aspire 13 в ноябре 2025 года, поэтому ”.NET Aspire” и “Aspire” — это одно и то же, а шаг dotnet workload install aspire исчез ещё в Aspire 9.0.

Матрица

Aspire 13.4.6Docker Compose v5.1.4
Формат конфигурацииC# или TypeScriptYAML
Как выполняется ваш собственный сервис .NETпроцесс хоста, запускаемый DCPконтейнер, собранный из Dockerfile
Подключение отладчикаF5 сразу ко всем проектамудалённый отладчик, настраивается для каждого сервиса
Строки подключениявнедряются как ConnectionStrings__<name>пишете сами
URL между сервисамивнедряются как services__<name>__<scheme>__0DNS контейнера по имени сервиса
Телеметрияконечная точка OTLP плюс dashboard, без настройкиотсутствует
Порядок запускаWaitFor() плюс health checksdepends_on с condition: service_healthy
Пользовательские сетиэквивалента нетnetworks:
Ограничения CPU и памятине моделируютсяdeploy.resources
Имена контейнеровслучайный суффикс (cache-mmsmckhq)детерминированные (<project>-cache-1)
Является ли это вашим артефактом развёртывания?нет, AppHost существует только на этапе разработкичасто да
Сервисы не на .NETNode, Bun, Python, Go или любой контейнерлюбой контейнер

Что каждый инструмент на самом деле запускает

Это то различие, из которого следует всё остальное. Compose запускает контейнеры, и только их. Каждый сервис в файле, включая тот, который вы сейчас редактируете, — это образ, который нужно собрать, прежде чем он сможет работать.

AppHost в Aspire запускает смесь. Всё, что вы объявили через AddProject<T>, работает как обычный процесс на вашей машине под управлением Developer Control Plane; контейнерами становится только то, что писали не вы, объявленное через AddContainer, AddRedis, AddPostgres и им подобные. Это видно в docker ps, пока приложение работает:

NAMES              IMAGE
cache-mmsmckhq     redis:8.6

Это полный список контейнеров для приложения из двух сервисов. API — это процесс dotnet, и именно поэтому Visual Studio и Rider могут поставить в нём точку останова без всякой настройки удалённой отладки, и поэтому пересборка вообще не затрагивает Docker.

Один и тот же стек, записанный дважды

Minimal API плюс Redis. Сначала вариант на Compose:

# docker-compose.yaml -- Docker Compose v5.1.4
services:
  cache:
    image: redis:8.2
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 15

  api:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - ConnectionStrings__cache=cache:6379
    ports:
      - "8080:8080"
    depends_on:
      cache:
        condition: service_healthy

Плюс Dockerfile, который не является необязательным и здесь не показан. Теперь вариант на Aspire, файл целиком:

// AppHost/AppHost.cs -- Aspire 13.4.6, .NET 10
var builder = DistributedApplication.CreateBuilder(args);

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

builder.AddProject<Projects.Api>("api")
       .WithHttpEndpoint(port: 8080, name: "public")
       .WithReference(cache)
       .WaitFor(cache);

builder.Build().Run();

В файле проекта три строки содержательного, и обратите внимание, что шаблон версии 13.4.6 теперь помещает SDK в атрибут Sdk, а не во вложенный элемент <Sdk>:

<!-- AppHost/AppHost.csproj -- Aspire 13.4.6 -->
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
  <ItemGroup>
    <ProjectReference Include="..\Api\Api.csproj" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.Redis" Version="13.4.6" />
  </ItemGroup>
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>

Оба стека выполняют один и тот же Program.cs, который читает ConnectionStrings:cache из конфигурации. В случае Compose это значение предоставили вы сами. В случае Aspire — нет.

Что Aspire записывает в ваш процесс

Я добавил отладочную конечную точку, выводящую интересующие переменные окружения, и запустил AppHost. Вот что получил процесс API без единой строки конфигурации с моей стороны:

ASPNETCORE_URLS=https://localhost:61681;http://localhost:61682;http://localhost:61683
ConnectionStrings__cache=localhost:58390,password=T9bjFegjra6EBk5HG3M9uq
OTEL_EXPORTER_OTLP_ENDPOINT=https://localhost:21089
OTEL_EXPORTER_OTLP_HEADERS=x-otlp-api-key=566b726e1f4c36c1b4e0474e80db9cd5
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_METRIC_EXPORT_INTERVAL=1000
OTEL_SERVICE_NAME=api
OTEL_TRACES_SAMPLER=always_on

Здесь стоит отметить две вещи. Aspire сгенерировал пароль для Redis и поместил его в строку подключения, поэтому локальный кеш не остаётся открытым на широко известном порту без аутентификации, как это происходит с redis:8.2 в compose-файле. А блок OTLP — это то, благодаря чему трассировки и метрики бесплатно появляются в dashboard; если вы хотите того же в Compose, вам придётся поднимать коллектор и подключать экспортёры самостоятельно, а это тема отдельной статьи о том, как использовать OpenTelemetry с .NET 11 и бесплатным бэкендом.

Для ссылок между проектами внедряемая переменная имеет вид services__<name>__<scheme>__0, например services__basket__https__0, и механизм обнаружения сервисов .NET разрешает https://basket через неё.

Измерения

Одна и та же машина, одно и то же приложение, один и тот же Redis: Intel Core Ultra 7 265KF (20 ядер), 32 ГБ ОЗУ, Windows 11 Pro 26200, Docker 29.5.3 с Compose v5.1.4, .NET SDK 10.0.201, Aspire CLI 13.4.6. Базовые образы были загружены до начала замеров, поэтому ни одно измерение не включает скачивание из реестра. Измеряется астрономическое время от старта команды до момента, когда HTTP GET к приложению возвращает свежесобранный код, с опросом каждые 250 мс. Правка — это однострочное изменение строкового литерала в Program.cs, и каждый раунд использует новое значение, чтобы ничто не могло быть отдано из кеша.

СценарийAspire 13.4.6Docker Compose v5.1.4
Холодный старт: ничего не собрано, стек поднят и отвечает15.5 с (dotnet clean, затем aspire run)10.8 с (7.0 с build --no-cache плюс 3.8 с up)
Однострочное изменение в C# до отдачи нового кода14.6 / 13.9 / 11.0 с, медиана 13.9 с5.4 / 5.6 / 5.3 с, медиана 5.4 с

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

Цикл Compose здесь — это трёхсекундная инкрементальная сборка docker build (слой восстановления пакетов взят из кеша, заново выполняются только COPY и dotnet publish) плюс пересоздание контейнера, для приложения, опубликованный вывод которого составляет около десяти килобайт моего кода. Цикл Aspire — это aspire resource api stop, полный вызов MSBuild и aspire resource api start, и на столь маленьком проекте доминируют собственные затраты MSBuild на запуск. Число Compose растёт вместе с размером пересобираемого слоя образа; число Aspire растёт вместе с графом MSBuild. Где эти кривые пересекаются, я не измерял, поэтому и утверждать точку пересечения не буду.

Более существенная оговорка в том, что строка Aspire измерена через CLI, а CLI — это не то, как Aspire используют большинство. В Visual Studio или Rider цикл состоит из F5 плюс Hot Reload, который патчит работающий процесс и вообще ничего не пересобирает. Для контейнеризованного сервиса эквивалента этому нет: docker compose watch синхронизирует файлы или пересобирает образ, но не патчит работающий процесс. Поэтому воспринимайте таблицу как верхнюю границу для внутреннего цикла Aspire и как честную оценку для Compose.

Когда Docker Compose — правильный ответ

Когда Aspire — правильный ответ

Тонкости, которые решают за вас

Синтаксис портов Compose не переводится буквально. ports: ["8080:8080"] выглядит как WithHttpEndpoint(port: 8080, targetPort: 8080), и такое сочетание выбрасывает исключение при запуске:

System.InvalidOperationException: The endpoint 'public' for resource 'api'
requested a proxy (IsProxied is true). Non-container resources cannot be
proxied when both TargetPort and Port are specified with the same value.

Aspire проксирует конечные точки проектов, поэтому порт хоста и целевой порт не могут иметь одно и то же значение. Указывайте только port: и позвольте ему выбрать целевой порт самостоятельно.

WithReference — это не depends_on. Руководство по миграции говорит об этом прямо: WithReference() настраивает только обнаружение сервисов и строки подключения и не управляет порядком запуска. Если вам нужно поведение condition: service_healthy из Compose, вам нужен WaitFor(), причём в дополнение к WithReference(), а не вместо него.

Имена контейнеров не стабильны. Compose даёт bench-cache-1, производное от имени проекта и сервиса. Aspire выдал мне cache-vvkhtnuf, затем cache-zwjpvzxh, затем cache-mmsmckhq за три запуска. Любой скрипт или привычка коллеги, построенные на docker exec -it myapp-cache-1 redis-cli, ломаются.

Версии образов по умолчанию меняются вместе с версией Aspire. AddRedis в 13.4.6 загрузил redis:8.6, а не тот redis:8.2, который был зафиксирован в моём compose-файле. Aspire 13.4 также сдвинул версию Postgres по умолчанию с 17.6 на 18.3, что несовместимо с уже существующим томом данных. Фиксируйте версию через WithImageTag, если для вас это важно.

Контексту сборки Compose нужен .dockerignore. Без него COPY Api/ Api/ отправляет ваши каталоги bin/ и obj/ с хоста в контекст сборки, что раздувает каждую сборку и инвалидирует слои при изменениях, которые исходного кода вообще не касались. Две строки решают проблему, и разница видна в логе сборки, где передача контекста для этого проекта падает до 1.18 КБ:

# .dockerignore
**/bin
**/obj

У Aspire такой проблемы нет, потому что он никогда не собирает образ для вашего проекта. Зато у него есть зеркальная: MSBuild не может перезаписать Api.dll, пока ресурс работает, поэтому пересборке из командной строки нужен aspire resource api stop перед dotnet build. IDE делает это за вас, а shell-скрипт — нет.

Прокси Aspire может пережить aspire stop и заслонить ваши контейнеры. Вот это стоило мне часа, пока я собирал приведённые выше числа. После aspire stop --force процесс dcp всё ещё был привязан к фиксированному порту хоста:

PID=70448 Name=dcp Addr=127.0.0.1
PID=70448 Name=dcp Addr=::1

Docker затем привязал тот же порт к ::, обе команды отчитались об успехе, и на каждый запрос к localhost:8080 отвечал брошенный прокси Aspire, а не контейнер. Ошибок нет никаких. docker compose ps показывает контейнер здоровым и с проброшенным портом, образ действительно содержит ваш новый код, а приложение продолжает возвращать ответы предыдущей сборки, потому что вы вообще разговариваете не с контейнером. Я какое-то время винил кеш слоёв Docker, прежде чем проверить, кому на самом деле принадлежит порт:

Get-NetTCPConnection -LocalPort 8080 -State Listen

Это кусается только тогда, когда вы фиксируете порт хоста через WithHttpEndpoint(port: ...), а именно так вы и делаете при переводе compose-файла. Динамические порты Aspire по умолчанию не конфликтуют.

Использовать оба

Выбор не является окончательным, потому что модель AppHost умеет генерировать compose-файл:

// AppHost/AppHost.cs -- Aspire 13.4.6
builder.AddDockerComposeEnvironment("compose")
       .WithDashboard(d => d.WithHostPort(8080));
aspire publish

Это порождает docker-compose.yaml плюс .env с незаполненными параметрами, и каждый ресурс модели становится сервисом Compose без дополнительного согласия. PublishAsDockerComposeService настраивает отдельный сервис (имя контейнера, метки, политику перезапуска), а ConfigureComposeFile правит весь документ перед записью. Так что разумное итоговое состояние выглядит так: Aspire для внутреннего цикла, сгенерированный Compose для сред, которым нужен YAML-файл, один источник истины. Учтите, что сам AppHost никогда не поставляется, ровно так же, как публикация образа контейнера через dotnet publish /t:PublishContainer — задача, отдельная от того, как вы запускали всё это локально.

Решение

Для решения на .NET, сервисы которого вы собираете сами, Aspire является лучшей средой локальной разработки, и причина здесь решительно не в скорости: Compose выиграл во всех замерах, которые я снял. Причина в том, что ваш код выполняется как процесс, который можно отлаживать, и в том, что AppHost пишет строки подключения, порты и конфигурацию OpenTelemetry, которые иначе вы поддерживали бы вручную в YAML и которые расходились бы между собой. Секунды на запуск дёшевы по сравнению с половиной дня, потраченной на выяснение, почему в контейнере устаревшая сборка или почему отладчик не подключается.

Оставайтесь на Docker Compose, когда у файла есть вторая работа. Если от этого YAML зависят CI, staging или runbook, честное сравнение звучит не как “Aspire против Compose”, а как “Aspire плюс сгенерированный Compose против одного Compose”, и если ваша команда невелика, а стек состоит из пяти образов, которые писали не вы, второй вариант остаётся вполне хорошим ответом в 2026 году.

Связанные статьи

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад