Ejecutar un servidor Go de forma escalable con .NET Aspire

dotnet aspire?
dotnet aspire es una herramienta creada para ayudar a los desarrolladores en el desarrollo y la configuración nativos de la nube, a medida que crecen los entornos nativos de la nube. Esta herramienta permite que los desarrolladores de .NET desplieguen fácilmente proyectos de .NET, diversas infraestructuras nativas de la nube, así como servicios escritos en otros lenguajes o contenedores, entre otros.
Naturalmente, se lanza y opera desde Docker hasta K8s, y muchos campos, industrias y desarrolladores han migrado, o ya han migrado, de entornos locales tradicionales a entornos nativos de la nube. Hoy en día es un ámbito maduro. Por ello, supongo que no es necesario explicar las incomodidades existentes en torno a la gestión de nombres de host, la configuración de puertos, los cortafuegos o las métricas.
Así que, solo con las explicaciones anteriores, es probable que no tenga ni idea de qué es dotnet aspire. Esto se debe a que ni siquiera Microsoft ofrece una definición precisa. Por eso, yo tampoco daré una definición propia. Sin embargo, en este artículo utilizaré las funciones básicas de dotnet aspire que yo he comprendido, así que puede tomarlas como referencia y definir su propia posición.
Configuración del proyecto
Creación de un proyecto dotnet aspire
Si no tiene la plantilla de dotnet aspire, primero deberá instalarla. Instale la plantilla con el siguiente comando. Si no tiene .NET, instálelo por su cuenta.
1dotnet new install Aspire.ProjectTemplates
Luego, cree una nueva solución en una carpeta adecuada.
1dotnet new sln
A continuación, ejecute el siguiente comando en la carpeta de la solución para crear un proyecto de la plantilla aspire-apphost.
1dotnet new aspire-apphost -o AppHost
Así se creará un proyecto aspire-apphost que solo contiene un código sencillo para la configuración.
Añadir Valkey
Ahora, añadiremos Valkey de forma sencilla.
Antes de añadirlo directamente, dotnet aspire ofrece diversas soluciones de terceros mediante community hosting.
Por supuesto, Valkey también puede beneficiarse de este community hosting, y es fácil de usar mediante el siguiente paquete NuGet.
1dotnet add package Aspire.Hosting.Valkey
Además, se ofrecen diversas integraciones de hosting, que puede consultar aquí.
Volviendo a Valkey, abra el archivo Program.cs del proyecto AppHost y modifíquelo de la siguiente manera.
1var builder = DistributedApplication.CreateBuilder(args);
2
3var cache = builder.AddValkey("cache")
4 .WithDataVolume(isReadOnly: false)
5 .WithPersistence(interval: TimeSpan.FromMinutes(5),
6 keysChangedThreshold: 100);
7
8builder.Build().Run();
cache es una implementación de la interfaz IResourceBuilder que contiene la información necesaria para construir el servicio de Valkey.WithDataVolume crea un volumen para almacenar los datos de la caché, y WithPersistence permite guardar los datos de la caché de forma continua.
Hasta aquí, parece que cumple una función similar a volumes en docker-compose.
Naturalmente, esto también puede crearlo sin dificultad.
Sin embargo, esto excede el alcance de este artículo, por lo que no lo trataremos ahora.
Creación de un servidor de eco en Go
Ahora, añadiremos un servidor sencillo en Go.
Primero, en la carpeta de la solución, cree un workspace mediante go work init.
Para los desarrolladores de .NET, puede considerar que un workspace de Go es algo similar a una solución.
Luego, cree una carpeta llamada EchoServer, entre en ella y ejecute go mod init EchoServer.
Con este comando se crea un módulo de Go. Puede entender que un módulo es algo similar a un proyecto para los desarrolladores de .NET.
A continuación, cree el archivo main.go y escriba lo siguiente.
1package main
2
3import (
4 "log"
5 "net/http"
6 "os"
7)
8
9func main() {
10 addr := os.Getenv("PORT")
11 log.Printf("Server started on %s", addr)
12
13 http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
14 name := request.URL.Query().Get("name")
15 writer.Write([]byte("Hello, " + name))
16 })
17
18 http.ListenAndServe(":"+addr, nil)
19}
Este servidor, cuando se ejecuta Aspire AppHost, lee el puerto de la variable de entorno PORT que se le inyecta, es decir, el puerto en el que debe escuchar, y arranca el servidor en dicho puerto.
Es un servidor sencillo que recibe el parámetro de consulta name y devuelve Hello, {name}.
Ahora, añadamos este servidor a dotnet aspire.
Añadir el servidor de eco a Aspire
Volvamos al proyecto Aspire AppHost, donde añadimos Valkey, y añadamos el community hosting para Go.
1dotnet add package CommunityToolkit.Aspire.Hosting.Golang
Luego, abra el archivo Program.cs y añada la siguiente sentencia.
1var builder = DistributedApplication.CreateBuilder(args);
2
3var cache = builder.AddValkey("cache")
4 .WithDataVolume(isReadOnly: false)
5 .WithPersistence(interval: TimeSpan.FromMinutes(5),
6 keysChangedThreshold: 100);
7
8var echoServer = builder.AddGolangApp("echo-server", "../EchoServer")
9 .WithHttpEndpoint(port: 3000, env: "PORT")
10 .WithExternalHttpEndpoints();
11
12builder.Build().Run();
Aquí, echoServer es una implementación de la interfaz IResourceBuilder que contiene la información necesaria para construir el servidor en Go.
El método AddGolangApp que acabamos de añadir es un método de extensión del host personalizado para añadir un servidor en Go.
Utiliza el puerto 3000 de forma fija, y se puede comprobar que inyecta la variable de entorno PORT.
Por último, WithExternalHttpEndpoints permite el acceso desde el exterior.
Para probarlo, acceda a http://localhost:3000/?name=world y verá que se muestra Hello, world.
Sin embargo, actualmente dotnet aspire impone una penalización considerable a los proyectos que no son de .NET.
Esa penalización es precisamente...
Extensión del proyecto
Entonces, ¿cómo se hace el escalado horizontal?
Actualmente, dotnet aspire solo ofrece la opción WithReplica para los builders de proyectos .NET añadidos mediante el método AddProject.
Sin embargo, no ofrece esta opción para hosts en Go ni para proyectos externos como los añadidos con AddContainer.
Por ello, es necesario implementarlo manualmente mediante un balanceador de carga o un reverse proxy independiente.
No obstante, si el reverse proxy se convierte en un SPOF, sería conveniente que dicho reverse proxy ofreciera la opción WithReplica.
En ese caso, el reverse proxy debería ser necesariamente un proyecto de .NET.
Hasta ahora, para este problema hemos utilizado nginx, Traefik, implementaciones propias, entre otras opciones, pero con la restricción de que debía ser un proyecto de .NET, no tuve de inmediato ninguna alternativa.
Así que busqué reverse proxies implementados en .NET y, afortunadamente, encontré YARP.
YARP es un reverse proxy implementado en .NET que también puede desempeñar la función de balanceador de carga y ofrece diversas funcionalidades, por lo que consideré que era una buena elección.
Ahora, añadamos YARP.
Configuración de un reverse proxy con YARP
Primero, cree un proyecto para utilizar YARP.
1dotnet new web -n ReverseProxy
Luego, acceda al proyecto e instale YARP.
1dotnet add package Yarp.ReverseProxy --version 2.2.0
Una vez finalizada la instalación, abra el archivo Program.cs y escriba lo siguiente.
1using Yarp.ReverseProxy.Configuration;
2
3var builder = WebApplication.CreateBuilder(args);
4
5var routes = new List<RouteConfig>();
6var clusters = new List<ClusterConfig>();
7
8builder.Services.AddReverseProxy()
9 .LoadFromMemory(routes, clusters);
10
11var app = builder.Build();
12
13app.MapReverseProxy();
14app.Run(url: $"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "5000"}");
Este código es la configuración básica para utilizar YARP.routes contiene la información de las rutas que utilizará el reverse proxy, y clusters contiene la información de los clústeres que utilizará el reverse proxy.
Esta información se carga en el reverse proxy mediante el método LoadFromMemory.
Por último, se utiliza el método MapReverseProxy para mapear y ejecutar el reverse proxy.
Después, para el uso real, añada una referencia al proyecto del reverse proxy en el proyecto apphost de Aspire y añada o modifique en Program.cs la siguiente sentencia.
1dotnet add reference ../ReverseProxy
1var echoServer = builder.AddGolangApp("echo-server", "../EchoServer")
2 .WithHttpEndpoint(env: "PORT");
3
4var reverseProxy = builder.AddProject<Projects.ReverseProxy>("gateway")
5 .WithReference(echoServer)
6 .WithHttpEndpoint(port: 3000, env: "PORT", isProxied: true)
7 .WithExternalHttpEndpoints();
Ahora el reverse proxy puede referenciar al servidor de eco.
La estructura está cambiando para que las solicitudes externas se reciban en el reverse proxy y se transfieran al servidor de eco.
Modificación del reverse proxy
Primero, es necesario cambiar la dirección de escucha del proyecto asignado al reverse proxy.
Elimine el applicationUrl dentro del archivo Properties/launchSettings.json.
Luego, abra el archivo Program.cs y modifíquelo considerablemente como se indica a continuación.
1using Yarp.ReverseProxy.Configuration;
2
3var builder = WebApplication.CreateBuilder(args);
4
5var routes = new List<RouteConfig>
6{
7 new RouteConfig
8 {
9 ClusterId = "cluster-echo",
10 RouteId = "route-echo",
11 Match = new RouteMatch
12 {
13 Path = "/"
14 }
15 }
16};
17
18var echoServerAddr = Environment.GetEnvironmentVariable("services__echo-server__http__0") ?? "http://localhost:8080";
19
20var clusters = new List<ClusterConfig>
21{
22 new ClusterConfig
23 {
24 ClusterId = "cluster-echo",
25 Destinations = new Dictionary<string, DestinationConfig>
26 {
27 { "destination-echo", new DestinationConfig { Address = echoServerAddr } }
28 }
29 }
30};
31
32builder.Services.AddReverseProxy()
33 .LoadFromMemory(routes, clusters);
34
35var app = builder.Build();
36
37app.MapReverseProxy();
38app.Run(url: $"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "5000"}");
Primero, se modifica la información de routes y clusters.
Se añaden echo-route y echo-cluster respectivamente, para configurar que las solicitudes se envíen al servidor de eco.
Además, se modifica para leer la dirección del servidor de eco desde una variable de entorno y utilizarla.
La regla de esta dirección es services__{service-name}__http__{index}.
En el caso del servidor de eco, el nombre del servicio es echo-server y, al ser una única instancia, se utiliza 0 como índice.
Si añadimos un servidor ASP.NET Core, pueden crearse varias instancias mediante WithReplica, por lo que basta con incrementar el índice para utilizarlas.
El valor http://localhost:8080, que aparece como excepción controlada, es un valor basura sin ningún significado.
Ahora, ejecute el proyecto y acceda a http://localhost:3000/?name=world, y verá que sigue apareciendo Hello, world.
Ideas de extensión
Hasta aquí hemos comprobado que se puede añadir un servidor en Go a dotnet aspire y reenviar las solicitudes mediante un reverse proxy.
A partir de ahí, se puede ampliar para implementar este proceso de forma programática.
Por ejemplo, se pueden crear varias instancias del servidor de eco añadiendo una numeración tras el nombre del servicio, y añadir automáticamente la configuración correspondiente al reverse proxy.
Modifique el código que utiliza el reverse proxy y el servidor de eco en el archivo Program.cs del proyecto apphost de Aspire de la siguiente manera.
1var reverseProxy = builder.AddProject<Projects.ReverseProxy>("gateway")
2 .WithHttpEndpoint(port: 3000, env: "PORT", isProxied: true)
3 .WithExternalHttpEndpoints();
4
5for (var i = 0; i < 8; i++)
6{
7 var echoServer = builder.AddGolangApp($"echo-server-{i}", "../EchoServer")
8 .WithHttpEndpoint(env: "PORT");
9 reverseProxy.WithReference(echoServer);
10}
Y modifique el archivo Program.cs del proyecto del reverse proxy de la siguiente manera.
1var echoServerDestinations = new Dictionary<string, DestinationConfig>();
2for (var i = 0; i < 8; i++)
3{
4 echoServerDestinations[$"destination-{i}"] = new DestinationConfig
5 {
6 Address = Environment.GetEnvironmentVariable($"services__echo-server-{i}__http__0") ?? "http://localhost:8080"
7 };
8}
9
10var clusters = new List<ClusterConfig>
11{
12 new ClusterConfig
13 {
14 ClusterId = "cluster-echo",
15 Destinations = echoServerDestinations
16 }
17};
Se añade la configuración de destinos para las 8 instancias del servidor de eco.
Ahora el reverse proxy dispone de la información de destino de las instancias del servidor de eco y puede reenviar las solicitudes.
Si accede a http://localhost:3000/?name=world como antes, verá que sigue apareciendo Hello, world.
Conclusión
En este artículo hemos explicado el proceso de añadir un servidor en Go a dotnet aspire y reenviar las solicitudes mediante un reverse proxy.
Sin embargo, la parte de extensión aún no la hemos documentado completamente; en un repositorio aparte hemos preparado un ejemplo que permite implementarla de forma más programática mediante variables de entorno.
Para la estructura detallada del proyecto y el código, consulte snowmerak/AspireStartPack.
Personalmente, espero que dotnet aspire pueda desempeñar su propio papel como alternativa a docker compose y como herramienta de despliegue en la nube.
Ya existen generadores que crean manifiestos de docker compose o de k8s, por lo que creo que el acceso a las herramientas de infraestructura para los desarrolladores comunes se ha vuelto más sencillo.