.NET Aspire 云原生编排

讲解 .NET Aspire 的应用模型与资源编排,覆盖 AppHost 分布式应用构建、服务发现与依赖注入、OpenTelemetry 遥测与健康检查、Dashboard 本地开发体验,以及从本地到生产部署的资源供给差异与清单生成的落地实践。

1. Aspire 的定位与核心概念

一句话总结: Aspire 不是运行时框架,而是开发期的编排与可观测层,用一份 C# 代码描述分布式应用的资源拓扑,并在本地一键拉起依赖与 Dashboard。

Aspire 要解决的是一个非常具体的问题:本地开发一个由 API、Worker、Redis、PostgreSQL、RabbitMQ 组成的系统时,过去要手写 docker-compose、手工拼接连接字符串、逐一对齐端口与环境变量,稍有偏差就出现「在我机器上能跑」。Aspire 把这份拓扑搬进 C# 代码,用强类型 API 声明资源与依赖关系,运行时自动注入连接信息、启动容器、聚合遥测。

它由四个部分构成,理解这四者的边界是使用它的前提:

  • AppHost(编排项目):一个控制台项目,引用 Aspire.Hosting.AppHost,用 DistributedApplication.CreateBuilder 声明所有资源。它只在开发期运行,不参与生产部署。
  • ServiceDefaults(共享默认值):一个类库,提供 AddServiceDefaults() 与 MapDefaultEndpoints() 两个扩展方法,统一配置 OpenTelemetry、健康检查、服务发现与弹性策略。
  • Aspire Dashboard:随 AppHost 启动的本地可观测面板,展示资源状态、结构化日志、分布式追踪与指标,即使应用没有配置 OTLP 导出也能接收。
  • Integrations(集成包):分为宿主侧(Aspire.Hosting.Redis)与客户端侧(Aspire.StackExchange.Redis),前者在 AppHost 里声明资源,后者在被编排的服务里消费连接信息。

最需要先建立的认知是:AppHost 是开发工具,不是生产清单。生产环境里 Redis 依然由你的 Kubernetes Operator 或托管服务提供,AppHost 的 AddRedis 只是本地替身。

ServiceDefaults 的骨架很薄,它把「每个服务都要写一遍」的横切代码收敛到一处:

public static class Extensions
{
    public static IHostApplicationBuilder AddServiceDefaults(
        this IHostApplicationBuilder builder)
    {
        builder.ConfigureOpenTelemetry();
        builder.AddDefaultHealthChecks();
        builder.Services.AddServiceDiscovery();
        builder.Services.ConfigureHttpClientDefaults(http =>
        {
            http.AddStandardResilienceHandler();
            http.AddServiceDiscovery();
        });
        return builder;
    }
}

这段代码是理解 Aspire 的钥匙:它不引入任何运行时依赖,只是把标准的 .NET 扩展方法按最佳实践组合起来。因此即便某天移除 Aspire,这些配置依然可以在每个服务里手工复制,不存在锁定。

var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");
var db = builder.AddPostgres("pg").AddDatabase("orders");
var queue = builder.AddRabbitMQ("queue");

var api = builder.AddProject<Projects.Orders_Api>("api")
    .WithReference(cache)
    .WithReference(db)
    .WithReference(queue)
    .WaitFor(db);

var worker = builder.AddProject<Projects.Orders_Worker>("worker")
    .WithReference(queue)
    .WithReference(db)
    .WaitFor(queue);

builder.AddProject<Projects.Orders_Gateway>("gateway")
    .WithReference(api)
    .WithReference(worker);

builder.Build().Run();

WaitFor 是关键的一笔:它让 API 在数据库真正就绪(健康检查通过)之后再启动,而不是仅仅在容器创建之后启动。这消除了本地开发中最常见的启动期竞态。

2. 应用模型与资源编排

一句话总结: 应用模型是资源与依赖关系的声明式图,资源类型覆盖项目、可执行文件、容器与外部端点,依赖通过 WithReference 与 WaitFor 显式表达。

应用模型的核心概念只有三个:资源(Resource)、引用(Reference)、关系(Relationship)。资源是一个可被启动或连接的目标,引用是把一个资源的连接信息注入到另一个资源,关系则是启动顺序、父子、健康依赖等语义。

资源类型大致分为四类:

类型API典型用途
项目AddProject<T>().NET 服务,自动处理构建与调试附加
可执行文件AddExecutable()Node、Python 等非 .NET 进程
容器AddContainer()任意镜像,如自建网关、模拟器
集成资源AddRedis() 等官方封装,自带健康检查与连接串格式

依赖注入的产物是一组环境变量。WithReference(cache) 会在目标服务的进程环境里写入形如 ConnectionStrings__cache=localhost:6379 的键值,而客户端集成包(如 Aspire.StackExchange.Redis)通过 AddRedisClient("cache") 读取它并注册 IConnectionMultiplexer。整条链路不需要在代码里出现任何硬编码地址:

builder.AddRedisClient("cache");
builder.AddNpgsqlDbContext<OrdersDbContext>("orders");
builder.AddRabbitMQClient("queue");

容器资源还可以做端口与卷的定制,这在需要挂载初始化脚本时很常见:

var pg = builder.AddPostgres("pg")
    .WithDataVolume("pg-data")
    .WithBindMount("./sql", "/docker-entrypoint-initdb.d")
    .WithPgAdmin();

var db = pg.AddDatabase("orders");

一个容易忽略的细节是:AddDatabase 返回的是子资源,它的连接串里已经包含了数据库名,因此客户端侧只需引用数据库资源而非数据库服务器资源。引用错误层级会导致连接串缺少 Database=,表现为「连上了服务器却找不到库」。

对于外部已有的依赖(比如团队共享的测试数据库),用 AddConnectionString 直接注入即可,不必强行容器化:

var legacy = builder.AddConnectionString("legacy-sql");
builder.AddProject<Projects.Orders_Api>("api").WithReference(legacy);

2.1 资源命名与端点约定

一句话总结: 资源名同时是环境变量键、服务发现逻辑名与 Dashboard 展示名,一旦确定就难以更改,命名应在动手前统一规划。

资源名是一个被严重低估的决策点,因为它在三个地方同时生效:

  1. 环境变量键:cache → ConnectionStrings__cache。
  2. 服务发现逻辑名:api → services__api__http__0。
  3. Dashboard 展示名:直接显示为资源行标题。

命名规范建议全部使用小写加连字符,避免空格与中文,因为环境变量键在部分平台上对大小写敏感。更重要的是避免在名字里编码环境信息,比如 cache-dev——本地与生产的差异应由 IsPublishMode 分支处理,而非名字。

端点(Endpoint)的声明有两种方式。多数集成资源会自动生成默认端点,也可以显式定制:

var api = builder.AddProject<Projects.Orders_Api>("api")
    .WithHttpEndpoint(port: 5200, name: "http")
    .WithHttpsEndpoint(port: 5201, name: "https")
    .WithExternalHttpEndpoints();

WithExternalHttpEndpoints 表示该端点在发布时对外暴露(生成 Ingress 或 LoadBalancer),不加则只在集群内部可达。这个标记与 Kubernetes 的 Service 类型直接对应,是本地声明映射到生产清单的典型例子。

命名与端点的组合决定了注入到客户端的环境变量形态。多端点时,逻辑名会带上端点名后缀,例如 services__api__https__0 与 services__api__http__0 并存。客户端按协议前缀选择对应端点,这就是为什么 http://api 与 https://api 会解析到不同地址。

3. 服务发现与依赖注入

一句话总结: 服务发现把资源地址翻译成稳定的逻辑名称,服务间调用只需写 http://api 这样的逻辑地址,真实主机与端口由解析层在运行时补齐。

Aspire 的服务发现有两套机制并存,理解它们的区别可以避免大量困惑。

第一套是配置注入式:WithReference 注入的 services__api__http__0=http://localhost:5432 之类的环境变量,由 Microsoft.Extensions.ServiceDiscovery 解析为具体地址。这套机制对 HTTP 客户端透明,适合 HttpClient 与 gRPC。

第二套是连接字符串式:面向数据库、缓存、消息队列等非 HTTP 依赖,由各客户端集成包读取 ConnectionStrings__xxx 并完成注册。

服务发现的核心价值是让代码里的地址保持逻辑化:

builder.Services.AddHttpClient<CatalogClient>(client =>
{
    client.BaseAddress = new Uri("http://catalog");
});

这里的 http://catalog 并不是一个真实域名,而是被注册的服务名。解析层会把它替换为实际的 http://localhost:5231(本地)或 http://catalog:8080(集群内 DNS)。同一个地址字符串在两种环境下都成立,这是它最大的收益。

解析顺序(Scheme 前缀决定策略):

  1. https:// 或 http:// 前缀 → 走服务发现,查配置或 DNS。
  2. 无前缀的裸主机名 → 默认按 http 处理。
  3. 解析失败 → 回退为字面地址,不会抛异常(这点容易掩盖配置错误)。

调试服务发现问题的第一手段是打开日志:

builder.Logging.AddFilter("Microsoft.Extensions.ServiceDiscovery", LogLevel.Debug);

开启后能看到每个逻辑名称被解析成了哪些端点、命中了哪条规则。常见的坑有三个:在容器里跑但引用了 localhost 资源(应为服务名)、协议不匹配(服务只暴露 HTTP 而客户端写了 HTTPS)、端点被健康检查标记为不健康后被摘除。

需要与既有弹性策略结合时,服务发现与 IHttpClientFactory 是天然互补的,可以叠加超时、重试与熔断,具体做法可参考 HttpClient 弹性与容错 。

3.1 服务发现与 Kubernetes 的衔接

一句话总结: 集群内服务发现本质是 DNS,Aspire 的解析层在检测到非本地环境时退化为直通,因此同一份逻辑地址在两种环境下都能工作。

Aspire 的服务发现实现遵循 .NET 的 IEndPointProvider 抽象,注册的顺序决定了优先级:

builder.Services.AddServiceDiscovery()
    .ConfigureHttpClientDefaults(b => b.AddServiceDiscovery());

解析器链依次尝试:配置提供的端点、DNS SRV 记录、DNS A 记录、最后回退为字面地址。在 Kubernetes 中,http://catalog 会被集群 DNS 解析为 catalog.default.svc.cluster.local,不需要任何额外配置。

这与服务网格的关系值得说明。若集群部署了 Istio 或 Linkerd,服务间调用会被边车接管,此时 Aspire 的解析层只是把逻辑名变成 Service 名,真正的流量治理(重试、熔断、mTLS)由网格完成。两者不冲突,但职责有重叠——如果你已经在网格里配置了重试策略,就应关闭 AddStandardResilienceHandler 的重试,避免双重重试放大故障。

另一个细节:当服务存在多副本时,DNS 解析会返回多个 A 记录,HttpClient 的连接池会做客户端负载均衡。这是集群内最常见的负载均衡形态,无需额外的服务发现组件。

4. 遥测与健康检查

一句话总结: ServiceDefaults 用一段代码统一了 OpenTelemetry 的日志、追踪与指标导出,并注册存活与就绪两类健康检查端点,各服务只需调用两个扩展方法。

AddServiceDefaults() 是所有服务共享的横切配置入口,它一次性完成四件事:配置 OpenTelemetry 的 trace/metric/log 三路管道、注册服务发现、注册标准弹性 HTTP 处理器、注册健康检查。在 Program.cs 里它只有一行:

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

var app = builder.Build();
app.MapDefaultEndpoints();

app.MapGet("/orders/{id}", async (int id, OrdersDbContext db) =>
    await db.Orders.FindAsync(id) is { } o ? Results.Ok(o) : Results.NotFound());

app.Run();

遥测部分的默认配置已经包含 ASP.NET Core、HttpClient、运行时(GC、线程池、异常)与 Npgsql/StackExchange.Redis 等集成包的仪表。真正需要自定义的是业务指标与追踪的采样:

public static class OrdersMetrics
{
    private static readonly Meter Meter = new("Orders.Service", "1.0.0");

    public static readonly Counter<long> OrdersCreated =
        Meter.CreateCounter<long>("orders.created");

    public static readonly Histogram<double> OrderAmount =
        Meter.CreateHistogram<double>("orders.amount", unit: "CNY");
}

MapDefaultEndpoints() 注册两个端点:/health(存活,只反映进程能否响应)与 /alive(就绪,包含依赖健康检查)。两者的区别至关重要:存活探针失败会触发重启,就绪探针失败只会被摘出负载均衡。如果把数据库健康检查挂到存活探针上,数据库抖动会导致整个服务被反复重启,反而放大故障。

给依赖加健康检查需要显式注册:

builder.Services.AddHealthChecks()
    .AddNpgSql(builder.Configuration.GetConnectionString("orders")!)
    .AddRedis(builder.Configuration.GetConnectionString("cache")!);

关于日志,Aspire 默认走 OpenTelemetry 的日志管道,配合结构化日志作用域能把追踪 ID 串起来。跨服务排查时这套机制的落地细节与采样策略,可以参考 日志与可观测性 。

一个实践要点:本地 Dashboard 通过 OTLP 接收数据,生产环境则把 OTEL_EXPORTER_OTLP_ENDPOINT 指向你的 Collector。同一份代码零改动地切换后端,这是 ServiceDefaults 抽象层的最大回报。

4.1 采样策略与导出配置

一句话总结: 本地全采样便于调试,生产必须下调采样率或改用尾部采样,否则追踪数据的存储与传输成本会失控。

OpenTelemetry 的默认采样器是 ParentBased(AlwaysOn),即全采样。在本地这完全合理,但生产环境的高吞吐服务每秒产生数万条 trace,全部导出会带来可观的网络与存储开销。

调整方式有三种,复杂度递增:

builder.Services.ConfigureOpenTelemetryTracerProvider(tracing =>
{
    tracing.SetSampler(new TraceIdRatioBasedSampler(0.05));
});

第一是固定比率采样,按 trace ID 哈希取 5%,实现简单但会均匀丢弃慢请求的样本。第二是基于父级的比率采样,保证同一条链路采样决策一致,避免断链。第三是尾部采样,在 Collector 侧根据耗时与错误状态决定保留,能精准抓住异常与慢请求,代价是需要部署 Collector。

对大多数团队,推荐的组合是:服务端用 5% 比率采样兜底,同时把 error 状态的 span 全量导出,两者在 Collector 中合并。配置写在 Collector 的 tail_sampling 处理器里,应用侧代码无需改动。

指标与日志的开销同样需要关注。运行时指标(GC 堆、线程池队列、异常计数)通常是排查问题的主力,建议全量保留;而自定义业务指标的基数(cardinality)必须严格控制,永远不要把用户 ID、订单号这类高基数值作为标签,否则指标后端会迅速膨胀。

5. 本地开发体验与 Dashboard

一句话总结: Dashboard 把资源状态、日志、追踪、指标与资源级控制台聚合到一个页面,是 Aspire 开发体验中最有说服力的部分。

启动 AppHost(dotnet run --project Orders.AppHost)后会自动打开 Dashboard,默认地址形如 https://localhost:17200。它的核心页面与用途:

  • Resources:每个资源的状态(Running/Finished/Failed)、端点、环境变量与容器日志。可以直接看到 api 拿到了哪些注入变量,排查引用问题极其高效。
  • Console logs:按资源分组的原始 stdout,容器启动失败时的第一现场。
  • Structured logs:解析后的结构化日志,可按级别、资源、追踪 ID 过滤。
  • Traces:分布式追踪瀑布图,能直接看到一次请求跨越 gateway → api → postgres 的耗时分布。
  • Metrics:实时指标图表,无需额外配置 Prometheus。

Dashboard 还有几个实用特性。资源操作按钮可以启动/停止单个资源,修改代码后不必重启整个拓扑;「WithReplicas」声明的多副本会以独立资源行展示,方便观察负载分布。

builder.AddProject<Projects.Orders_Api>("api")
    .WithReplicas(3);

对于需要热重载的服务,Aspire 支持把项目以调试器附加方式启动,配合 dotnet watch 可以在改动代码后自动重启该服务而不影响其他资源。

几个本地开发的坑:

  1. 端口冲突:Dashboard 与代理端口由 Aspire 动态分配,若手工写死了端口可能与既有服务冲突。
  2. HTTPS 证书:首次运行需执行 dotnet dev-certs https --trust,否则浏览器对 Dashboard 报证书错误。
  3. 容器引擎:需要 Docker Desktop、Podman 或 Rancher Desktop 之一在运行,否则容器资源会停在 Waiting 状态。
  4. 代理端口与真实端口:Dashboard 显示的端口是 Aspire 反向代理的入口,服务的真实端口可能不同;用代理端口访问是正确做法。

6. 本地到生产的差异

一句话总结: AppHost 不进入生产,生产部署靠生成清单交给 azd、Docker Compose 或 Helm 承接,差异集中在资源供给、服务发现与遥测导出三处。

这是 Aspire 最容易被误解的一点。AppHost 的价值在开发期,生产部署走的是「生成发布清单」这条路。aspire publish 或 azd 会读取应用模型,产出 Docker Compose、Kubernetes 清单或 Azure 资源定义。

差异可以归纳为三处:

维度本地生产
资源供给容器由 Aspire 启动托管服务或集群 Operator
服务发现配置注入 + 本地代理集群 DNS 或服务网格
遥测导出Dashboard 内置 OTLP外部 Collector 与后端
密钥明文环境变量Key Vault / Secret Manager

第一处差异的应对策略是把资源声明与资源供给解耦。生产环境里用 AddConnectionString 指向真实资源,或让部署工具把托管资源的连接串注入为同名环境变量:

var db = builder.ExecutionContext.IsPublishMode
    ? builder.AddConnectionString("orders")
    : builder.AddPostgres("pg").AddDatabase("orders");

IsPublishMode 让同一份 AppHost 在本地与发布两种模式下产出不同拓扑,是官方推荐的模式。

第二处差异:集群内的服务发现由 Kubernetes Service 提供,http://catalog 会被 DNS 解析,Microsoft.Extensions.ServiceDiscovery 在检测到非本地环境时会退化为直通模式,因此不需要改代码。

第三处差异:把 OTEL_EXPORTER_OTLP_ENDPOINT、OTEL_SERVICE_NAME、OTEL_RESOURCE_ATTRIBUTES 作为部署配置注入即可,ServiceDefaults 的代码无需改动。

关于部署形态与生命周期管理的完整细节,包括健康探针配置与滚动更新的配合,可参考 托管、生命周期与部署 。

最后一条经验:不要试图把 AppHost 变成生产编排器。它的定位是开发体验与本地一致性,生产编排交给成熟的工具链,两者通过清单衔接,职责清晰。

6.1 生成清单的形态与承接

一句话总结: Aspire 把应用模型翻译成 Docker Compose、Kubernetes 清单或云资源定义,由 azd 或 CI 流水线承接部署,AppHost 本身不进生产。

生成清单有两种路径。第一种是 aspire publish,产出一份完整的部署工件目录;第二种是通过 azd 工具链,它会识别 Aspire 项目并直接完成「构建镜像 → 推送仓库 → 应用清单」的全流程。

Kubernetes 清单的生成结果大致包含:每个项目资源一个 Deployment 与 Service、每个容器资源一个 StatefulSet 或 Deployment、连接信息以 ConfigMap 与 Secret 的形式注入。关键映射关系如下:

Aspire 声明Kubernetes 产物
AddProjectDeployment + Service
WithExternalHttpEndpointsIngress 或 LoadBalancer
WithReplicas(3)replicas: 3
AddConnectionStringSecret 引用的环境变量
WithDataVolumePersistentVolumeClaim

需要注意,生成的清单是起点而非终点。生产环境通常还需要叠加:资源请求与限制(resources.requests/limits)、亲和性与反亲和性、PodDisruptionBudget、网络策略、以及针对托管数据库的 Secret 外部注入。这些不属于 Aspire 的职责范围,应在清单生成后由 Helm 或 Kustomize 叠加。

一个务实的做法是:用 Aspire 保证本地开发的一致性,用一份精简的 Helm Chart 承接生产,两者共享同一套环境变量命名约定。约定一致,代码就无需感知部署形态——这正是 Aspire 应用模型带来的长期收益。

7. 工程实践与常见坑

一句话总结: 把 AppHost 当纯声明文件、把 ServiceDefaults 当唯一横切入口、把集成包版本与运行时对齐,能规避绝大多数 Aspire 使用问题。

实践建议:

  • AppHost 不写业务逻辑。它应该只有资源声明与少量条件分支,任何计算都放到服务里。
  • ServiceDefaults 保持无业务。它只做横切注册,不要塞入项目特有的服务。
  • 版本对齐。所有 Aspire.* 包应使用同一版本,且宿主包与被编排项目引用的客户端包版本一致,否则可能出现环境变量命名约定不匹配。
  • 集成包优先于手写连接串。AddNpgsqlDbContext 会一并处理重试、健康检查与遥测,手写 UseNpgsql(连接串) 会丢掉这些。
  • 追踪采样在生产要下调。默认全采样在本地无妨,生产环境会产生可观成本。

排错清单:

  1. 服务起不来但无日志 → 看 Dashboard 的 Console logs,通常是容器镜像拉取失败或端口占用。
  2. 连接串为 null → 检查 WithReference 是否加在了正确的资源层级(数据库子资源而非服务器资源)。
  3. 服务间调用 404/超时 → 打开服务发现 Debug 日志,确认逻辑名是否被解析。
  4. 追踪断链 → 确认所有服务都调用了 AddServiceDefaults(),且传播头未被中间件吞掉。
  5. 健康检查一直 Unhealthy → 就绪探针包含了尚未启动的依赖,用 WaitFor 修正启动顺序。

与 gRPC 服务结合时,服务发现同样生效,逻辑地址 http://catalog 会被解析为 HTTP/2 端点,无需特殊配置;服务间契约与流式调用的细节见 gRPC 与微服务通信 。

8. 总结

环节要点
定位开发期编排与可观测层,不进入生产运行时
应用模型资源、引用、关系三要素,用 C# 声明拓扑
服务发现逻辑地址 + 配置注入,本地与集群同一份代码
遥测ServiceDefaults 统一 OTel 三路管道与健康检查
Dashboard资源、日志、追踪、指标一体,本地首选排障入口
生产差异清单衔接,资源供给与服务发现由平台承接
实践AppHost 纯声明,集成包优先,版本对齐

Aspire 的真正价值不在「少写几行 docker-compose」,而在于把「本地一致性」变成工程约束:连接信息、健康语义、遥测管道都由框架统一定义,团队不再依赖口头约定。代价是引入了一层开发期抽象,需要团队理解 AppHost 与生产的边界。把这条边界守住,Aspire 会成为分布式 .NET 开发中收益最直接的一块基础设施。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 从 WCF 迁移到 gRPC 与 REST
  2. .NET 多租户 SaaS 架构
  3. Avalonia 跨平台桌面 UI