.NET Aspire: creación de aplicaciones nativas de la nube de la manera correcta
Si alguna vez ha creado una aplicación distribuida en .NET, ya conoce el procedimiento. Activa una API web, agrega un trabajador en segundo plano, agrega Redis para almacenamiento en caché, PostgreSQL para persistencia, tal vez RabbitMQ para mensajería y, de repente, pasa más tiempo cableando infraestructura que escribiendo lógica de negocios. Cadenas de conexión dispersas en archivos appsettings.json, comprobaciones de estado que olvidaste configurar, observabilidad que siempre es “el problema del próximo sprint”.
He estado allí. Más veces de las que me gustaría admitir.
Ese es exactamente el problema para el cual .NET Aspire fue diseñado. Después de ejecutarlo en producción durante varios meses, quiero compartir lo que he aprendido: lo bueno, lo excelente y las trampas.
¿Qué es .NET Aspire?
.NET Aspire es una pila obstinada para crear aplicaciones distribuidas, observables y listas para producción con .NET. No es un marco en el sentido tradicional: no reemplaza ASP.NET Core ni lo obliga a adoptar un nuevo modelo de programación. En cambio, se basa en lo que ya sabe y llena los vacíos que siempre han existido al crear aplicaciones nativas de la nube.
Básicamente, Aspire le ofrece cuatro cosas:
- AppHost: un proyecto que define toda la topología de su aplicación distribuida. Qué servicios existen, de qué dependen y cómo se conectan.
- Valores predeterminados del servicio: un proyecto compartido que configura preocupaciones transversales como controles de estado, políticas de resiliencia y OpenTelemetry, una vez, para todos sus servicios.
- Componentes: paquetes NuGet que brindan integraciones estandarizadas con servicios de respaldo como Redis, PostgreSQL, RabbitMQ, Azure Storage y más.
- Panel de desarrollador: una interfaz de usuario en tiempo real que muestra registros, seguimientos y métricas de toda su aplicación distribuida durante el desarrollo local.
La filosofía es simple: si cada aplicación de nube .NET necesita estas cosas, ¿por qué las implementamos desde cero cada vez?
Configuración de su primer proyecto Aspire
Comenzar es sencillo. Necesitará .NET 8 o posterior y la carga de trabajo de Aspire instalada:
dotnet workload update
dotnet workload install aspire
Ahora cree un nuevo proyecto inicial de Aspire:
dotnet new aspire-starter -n MyCloudApp
Esto genera una solución con cuatro proyectos:
MyCloudApp.AppHost— El orquestadorMyCloudApp.ServiceDefaults— Configuración compartidaMyCloudApp.ApiService— Una API web de muestraMyCloudApp.Web— Una interfaz de Blazor
Ejecute AppHost e inmediatamente verá el panel de Aspire abierto en su navegador, mostrando todos sus servicios, sus registros y su estado. No hay archivo Docker Compose. Sin gestión manual de puertos. Simplemente funciona.
dotnet run --project MyCloudApp.AppHost
Esa primera experiencia es la que me enganchó. En menos de un minuto, tendrá una aplicación distribuida totalmente orquestada con observabilidad incorporada.
El patrón AppHost
AppHost es donde vive la magia. Es una pequeña aplicación de consola que utiliza un patrón de creación para definir la topología de su aplicación distribuida: qué recursos existen y cómo se conectan los servicios a ellos.
Así es como se ve un AppHost realista:
var builder = DistributedApplication.CreateBuilder(args);
// Infrastructure resources
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.AddDatabase("catalogdb");
var redis = builder.AddRedis("cache");
var rabbitmq = builder.AddRabbitMQ("messaging")
.WithManagementPlugin();
// Application services
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api")
.WithReference(postgres)
.WithReference(redis)
.WithReference(rabbitmq);
var orderApi = builder.AddProject<Projects.OrderApi>("order-api")
.WithReference(postgres)
.WithReference(rabbitmq);
var frontend = builder.AddProject<Projects.WebFrontend>("frontend")
.WithExternalHttpEndpoints()
.WithReference(catalogApi)
.WithReference(orderApi);
builder.Build().Run();
```Lea ese código en voz alta. Prácticamente se documenta a sí mismo. "La API del catálogo hace referencia a PostgreSQL, Redis y RabbitMQ. La interfaz hace referencia a la API del catálogo y a la API de pedidos". Esa es su arquitectura, expresada en código.
Algunas cosas que vale la pena señalar:
- **`WithReference`** hace el trabajo pesado. Inyecta automáticamente cadenas de conexión y URL de servicio en el proyecto consumidor a través de variables de entorno y configuración. Sus servicios no necesitan saber *dónde* se está ejecutando Redis: Aspire se encarga de ello.
- **`WithPgAdmin()`** y **`WithManagementPlugin()`** activan las IU de administración para PostgreSQL y RabbitMQ junto con los servicios reales. Durante el desarrollo, estos son invaluables.
- **`WithExternalHttpEndpoints()`** marca un servicio como accesible externamente, lo cual es importante en el momento de la implementación.
### Configuración de recursos
Puede configurar recursos con control detallado:
```csharp
var postgres = builder.AddPostgres("postgres")
.WithEnvironment("POSTGRES_MAX_CONNECTIONS", "200")
.WithDataVolume("postgres-data")
.AddDatabase("catalogdb");
var redis = builder.AddRedis("cache")
.WithDataVolume("redis-data");
Los volúmenes de datos garantizan que sus datos de desarrollo local sobrevivan a los reinicios de contenedores. Pequeño detalle, gran mejora en la calidad de vida.
Valores predeterminados del servicio: el héroe anónimo
El proyecto ServiceDefaults es la parte más subestimada de Aspire. Es una biblioteca compartida a la que hace referencia cada servicio de su solución y configura todas las preocupaciones transversales que de otro modo olvidaría o implementaría de manera inconsistente.
Así es como se ve un Extensions.cs típico en 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;
}
public static IHostApplicationBuilder ConfigureOpenTelemetry(
this IHostApplicationBuilder builder)
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddGrpcClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
public static IHostApplicationBuilder AddDefaultHealthChecks(
this IHostApplicationBuilder builder)
{
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(),
["live"]);
return builder;
}
}
Luego, en el Program.cs de cada servicio, una línea lo hace todo:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Your service-specific configuration...
var app = builder.Build();
app.MapDefaultEndpoints();
app.Run();
Esa única llamada AddServiceDefaults() te brinda:
- OpenTelemetry con registro estructurado, métricas y seguimiento distribuido
- Controles de estado con criterios de valoración de vida y preparación
- Descubrimiento de servicios para que los servicios puedan encontrarse entre sí por nombre
- Políticas de resiliencia en todas las llamadas HTTP salientes (reintentos, disyuntores, tiempos de espera)
Esto es lo que separa una demostración “funciona en mi máquina” de un sistema listo para producción. Y Aspire lo convierte en el valor predeterminado, no en una idea de último momento.
Componentes de Aspire
Los componentes de Aspire son paquetes NuGet que estandarizan la forma en que sus servicios se conectan a la infraestructura de respaldo. Son más que simples bibliotecas de clientes: incluyen controles de estado, registros, rastreo y resiliencia configurable listos para usar.
Redis
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache");
Eso es todo. La cadena de conexión proviene de AppHost a través de WithReference. El componente registra un IDistributedCache respaldado por Redis, con controles de estado e instrumentación OpenTelemetry ya conectados.
También puedes usar Redis para el almacenamiento en caché de resultados:
builder.AddRedisOutputCache("cache");
PostgreSQL con Entity Framework Core
builder.AddNpgsqlDbContext<CatalogDbContext>("catalogdb", settings =>
{
settings.DisableRetry = false;
});
Esto registra tu DbContext con una conexión a la base de datos catalogdb definida en AppHost. Incluye agrupación de conexiones, comprobaciones de estado y políticas de reintento.
ConejoMQ
builder.AddRabbitMQClient("messaging");
Registra un IConnection de la biblioteca cliente RabbitMQ, completamente configurado y con control de estado.
Integraciones de Azure
Aspire también cuenta con componentes de primera clase para servicios de Azure:
// Azure Blob Storage
builder.AddAzureBlobClient("blobs");
// Azure Service Bus
builder.AddAzureServiceBusClient("servicebus");
// Azure Key Vault
builder.AddAzureKeyVaultClient("secrets");
```El patrón es siempre el mismo: una línea en AppHost para definir el recurso, una línea en el servicio consumidor para usarlo. Los detalles de la conexión fluyen automáticamente.
## El panel de desarrollador
El panel de Aspire es una de esas características que parece agradable tener hasta que realmente lo usas; entonces no puedes imaginarte trabajando sin él.
Cuando ejecuta su AppHost localmente, el panel se inicia y le brinda:
- **Resumen de recursos**: todos sus servicios e infraestructura de un vistazo, con indicadores de estado
- **Registros estructurados**: transmisión de registros en tiempo real desde cada servicio, filtrable y con capacidad de búsqueda
- **Seguimientos distribuidos**: seguimientos de solicitudes de un extremo a otro que abarcan múltiples servicios, visualizados como gráficos de llamas
- **Métricas**: tasas de solicitudes HTTP, tasas de error, latencias y métricas personalizadas en tiempo real
- **Registros de consola**: salida estándar/stderr sin procesar de cada contenedor y proyecto
El rastreo distribuido es particularmente valioso. Cuando una solicitud llega a su interfaz, fluye a través de la API del catálogo, toca Redis y PostgreSQL: ve la cadena completa con el tiempo para cada salto. Ya no tendrás que adivinar dónde está el cuello de botella.
El panel de control me resultó más útil durante la depuración. En lugar de seguir varias ventanas de terminal o saltar entre archivos de registro, todo está en un solo lugar con ID de correlación que vinculan eventos relacionados entre servicios.
El panel también está disponible como contenedor independiente, lo que significa que puede usarlo en entornos de prueba o canalizaciones de CI:
```bash
docker run --rm -p 18888:18888 \
mcr.microsoft.com/dotnet/aspire-dashboard:latest
Implementación
Aspire ayuda durante el desarrollo, pero ¿qué pasa con la producción? Aquí es donde las cosas se vuelven prácticas.
Aplicaciones de contenedor de Azure
La ruta de implementación más sencilla es Azure Container Apps (ACA), que cuenta con soporte Aspire de primera clase. Puede implementar directamente mediante la CLI de desarrollador de Azure:
azd init
azd up
El comando azd init detecta su Aspire AppHost y genera la infraestructura como código necesaria. azd up aprovisiona todo (registro de contenedores, aplicaciones de contenedores, bases de datos, instancias de Redis) en función de su topología de AppHost.
Su AppHost esencialmente se convierte en su manifiesto de implementación. El mismo código que define “catalog-api depende de PostgreSQL y Redis” impulsa el aprovisionamiento de la infraestructura.
Kubernetes
Para las implementaciones de Kubernetes, Aspire no genera manifiestos directamente, pero su topología de AppHost se asigna claramente a los recursos de Kubernetes. La herramienta comunitaria aspirate (Aspir8) puede generar gráficos de Helm o manifiestos de Kubernetes desde su AppHost:
dotnet tool install -g aspirate
aspirate generate
aspirate apply
Consideraciones de implementación
Algunas cosas a tener en cuenta:- Las cadenas de conexión cambian entre entornos. Localmente, Aspire activa contenedores e inyecta cadenas de conexión automáticamente. En producción, señalará los servicios administrados. Aspire utiliza la configuración .NET estándar, por lo que las variables de entorno y Azure Key Vault funcionan según lo esperado.
- AppHost no se ejecuta en producción. Es una herramienta de orquestación de desarrollo e implementación. En producción, sus servicios se ejecutan de forma independiente, configurados a través de variables de entorno y orquestadores.
- Los recursos de infraestructura se convierten en servicios administrados. Su contenedor de PostgreSQL local se convierte en Azure Database para PostgreSQL. Su contenedor de Redis local se convierte en Azure Cache para Redis. El código de consumo no cambia.
Consejos del mundo real
Después de ejecutar Aspire en producción por un tiempo, estas son las lecciones que nos ahorraron tiempo:
1. Utilice comprobaciones personalizadas del ciclo de vida de los recursos
No confíe únicamente en el inicio del contenedor para determinar si un recurso está listo. PostgreSQL podría aceptar conexiones TCP antes de estar realmente listo para atender consultas.
var postgres = builder.AddPostgres("postgres")
.AddDatabase("catalogdb")
.WithHealthCheck();
Aspire puede ejecutar controles de estado de los recursos y mantener los servicios dependientes hasta que estén realmente listos.
2. Extraiga patrones comunes en extensiones
Si varios servicios comparten configuraciones similares, cree métodos de extensión:
public static class AppHostExtensions
{
public static IResourceBuilder<ProjectResource> AddWorkerService(
this IDistributedApplicationBuilder builder,
string name,
IResourceBuilder<IResourceWithConnectionString> db,
IResourceBuilder<IResourceWithConnectionString> messaging)
{
return builder.AddProject<Projects.WorkerService>(name)
.WithReference(db)
.WithReference(messaging)
.WithReplicas(3);
}
}
3. Aproveche WithReplicas para pruebas de carga
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api")
.WithReference(postgres)
.WithReference(redis)
.WithReplicas(5);
WithReplicas activa múltiples instancias de un servicio localmente. Esto es excelente para probar el equilibrio de carga, los errores de concurrencia y el comportamiento del almacenamiento en caché distribuido sin implementar en un clúster.
4. Utilice parámetros para valores sensibles
No codifique las credenciales, ni siquiera para el desarrollo local:
var dbPassword = builder.AddParameter("db-password", secret: true);
var postgres = builder.AddPostgres("postgres", password: dbPassword)
.AddDatabase("catalogdb");
Cuando se ejecuta localmente, Aspire solicitará el valor o lo leerá de los secretos del usuario. En CI/CD, proviene de variables de entorno.
5. Las pruebas de integración se vuelven triviales
Aspire incluye un paquete de pruebas que hace que las pruebas de integración de aplicaciones distribuidas sean notablemente fáciles:
[Fact]
public async Task CatalogApiReturnsProducts()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyCloudApp_AppHost>();
await using var app = await appHost.BuildAsync();
await app.StartAsync();
var httpClient = app.CreateHttpClient("catalog-api");
var response = await httpClient.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
var products = await response.Content
.ReadFromJsonAsync<List<Product>>();
Assert.NotEmpty(products);
}
Esto pone en marcha su aplicación distribuida completa, incluidas las bases de datos y los intermediarios de mensajes, ejecuta su prueba y la derriba. Pruebas de integración reales contra infraestructura real, en su canal de CI. Sin burlas.
6. Supervisar el uso de recursos localmente
Cuando ejecute varios contenedores localmente, esté atento al consumo de recursos. Una instancia de PostgreSQL, Redis y RabbitMQ con interfaz de usuario de administración puede consumir fácilmente entre 2 y 3 GB de RAM. Si está en una máquina restringida, considere usar configuraciones de recursos más livianas:
var redis = builder.AddRedis("cache")
.WithContainerRuntimeArgs("--memory=256m");
Conclusión
.NET Aspire ha cambiado fundamentalmente la forma en que construyo aplicaciones distribuidas. No porque introduzca conceptos revolucionarios: los controles de estado, OpenTelemetry y la orquestación de contenedores no son nuevos. Sino porque los convierte en predeterminados. Toma las cien pequeñas decisiones que normalmente tendría que tomar, las implementa con valores predeterminados sensatos y le permite anularlas cuando sea necesario.El patrón AppHost es, en mi opinión, la mayor ventaja. Tener la topología de su aplicación distribuida expresada como código (no dispersada en archivos Docker Compose, manifiestos de Kubernetes y documentos README) hace que el sistema sea comprensible. Los nuevos miembros del equipo pueden abrir Program.cs en AppHost y comprender toda la arquitectura en minutos.
Si está creando aplicaciones distribuidas con .NET, eche un vistazo serio a Aspire. Comience con la plantilla aspire-starter, explore el panel y adopte gradualmente los componentes a medida que los necesite. No es necesario que haga todo lo posible desde el primer día: Aspire es aditivo por diseño.
Se acabaron los días en los que pasabas el primer sprint cableando la infraestructura estándar. Deje que Aspire se encargue de la plomería para que usted pueda concentrarse en lo que realmente importa: su aplicación.