GoSuda

Executando um servidor Go de forma escalável com .NET Aspire

By snowmerak views ...

dotnet aspire?

dotnet aspire é uma ferramenta criada para ajudar os desenvolvedores na construção e configuração de aplicações nativas da nuvem, à medida que os ambientes nativos da nuvem se expandem. Esta ferramenta permite que desenvolvedores .NET implantem com facilidade projetos em .NET, diversas infraestruturas nativas da nuvem, além de serviços em outras linguagens ou contêineres.

Naturalmente, desde o docker até o k8s, a tecnologia já está lançada e em operação, e muitos campos, setores e desenvolvedores, a partir dos ambientes locais (on-premises) tradicionais, estão migrando, ou já migraram, para ambientes nativos da nuvem. Hoje, trata-se de um campo maduro. Por isso, acredito que não há necessidade de explicar os incômodos já existentes em relação a nome de host, configuração de portas, firewall, gerenciamento de métricas e outros aspectos.

Assim, mesmo pelas explicações acima, é provável que você ainda não tenha uma ideia clara do que é o dotnet aspire. Isso ocorre porque nem mesmo a Microsoft oferece uma definição precisa. Por isso, também não vou apresentar uma definição própria. Entretanto, neste texto utilizarei as funcionalidades básicas do dotnet aspire conforme eu as compreendi, então, use-as como referência para estabelecer o seu próprio entendimento.

Configuração do projeto

Criação de um projeto dotnet aspire

Caso o template do dotnet aspire não esteja disponível, é necessário instalá-lo primeiro. Instale o template com o seguinte comando. Caso não tenha o .NET instalado, instale-o por conta própria.

1dotnet new install Aspire.ProjectTemplates

Em seguida, crie uma nova solução em uma pasta adequada.

1dotnet new sln

Depois, na pasta da solução, execute o seguinte comando para criar um projeto do template aspire-apphost.

1dotnet new aspire-apphost -o AppHost

Será criado um projeto aspire-apphost que contém apenas um código simples para configuração.

Adicionando o Valkey

Agora, vamos adicionar o Valkey de forma simples.

Antes de adicioná-lo diretamente, vale mencionar que o dotnet aspire oferece diversas soluções de terceiros por meio de community hosting.
Naturalmente, o valkey também pode receber suporte desse community hosting, e pode ser facilmente utilizado por meio do seguinte pacote NuGet.

1dotnet add package Aspire.Hosting.Valkey

Além disso, há diversas outras integrações de hosting disponíveis, que podem ser consultadas aqui.
Voltando ao valkey, abra o arquivo Program.cs no projeto AppHost e modifique-o da seguinte forma.

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 é uma implementação da interface IResourceBuilder que contém as informações necessárias para construir o serviço valkey.
WithDataVolume cria um volume para armazenar os dados do cache, e WithPersistence permite que os dados do cache sejam armazenados continuamente.
Até aqui, parece que ele desempenha um papel semelhante ao de volumes no docker-compose.
Naturalmente, isso também pode ser feito sem muita dificuldade por vocês.
Contudo, isso foge do escopo deste texto, então não entrarei nesse assunto agora.

Criação de um servidor de eco em Go

Agora, vamos adicionar um servidor simples em Go.
Primeiro, na pasta da solução, crie um workspace por meio de go work init.
Para desenvolvedores .NET, o workspace do Go pode ser entendido como algo semelhante a uma solução.

Em seguida, crie uma pasta chamada EchoServer, entre nela e execute go mod init EchoServer.
Com esse comando, é criado um módulo Go. Para desenvolvedores .NET, o módulo pode ser entendido como algo semelhante a um projeto.
Depois, crie o arquivo main.go e escreva o seguinte.

 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 lê a porta indicada na variável de ambiente PORT, que o Aspire AppHost injeta quando é executado para que o servidor escute nela, e então inicia o servidor.
Trata-se de um servidor simples que recebe o parâmetro de consulta name e retorna Hello, {name}.

Agora, vamos adicionar este servidor ao dotnet aspire.

Adicionando o servidor de eco ao aspire

Volte ao projeto Aspire AppHost, onde o Valkey foi adicionado, e instale o community hosting para Go.

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

Em seguida, abra o arquivo Program.cs e adicione a seguinte instrução.

 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();

Aqui, echoServer é uma implementação da interface IResourceBuilder que contém as informações necessárias para construir o servidor em Go.
O método AddGolangApp, que acabamos de adicionar, é um método de extensão do host personalizado para incluir um servidor em Go.
Ele utiliza a porta 3000 de forma fixa e injeta a variável de ambiente PORT.
Por fim, WithExternalHttpEndpoints permite que o serviço seja acessado externamente.

Para testar, acesse http://localhost:3000/?name=world, e você verá Hello, world sendo exibido.

No entanto, atualmente o dotnet aspire impõe uma pesada penalidade a projetos que não são .NET.
Essa penalidade é justamente...

Extensão do projeto

Então, como fazer a escalabilidade horizontal?

Atualmente, o dotnet aspire oferece a opção WithReplica apenas para o builder de projetos .NET adicionados pelo método AddProject.
Porém, essa opção não é fornecida para hosts em Go nem para projetos externos como os adicionados por AddContainer.

Por isso, é necessário implementar manualmente, utilizando um load balancer ou reverse proxy separado.
Contudo, como esse reverse proxy poderia se tornar um SPOF, o ideal é que ele também ofereça a opção WithReplica.
Nesse caso, o reverse proxy necessariamente precisaria ser um projeto .NET.

Até agora, para lidar com esse problema, utilizei abordagens como nginx, traefik e implementação direta, mas, diante da restrição de ser um projeto .NET, não tive, no momento, nenhuma solução viável.
Assim, procurei um reverse proxy implementado em .NET e, felizmente, encontrei uma opção: o YARP.
O YARP é um reverse proxy implementado em .NET que pode atuar também como load balancer e oferece diversos recursos, por isso considerei uma boa escolha.

Agora, vamos adicionar o YARP.

Configuração do reverse proxy com YARP

Primeiro, crie um projeto para utilizar o YARP.

1dotnet new web -n ReverseProxy

Em seguida, acesse o projeto e instale o YARP.

1dotnet add package Yarp.ReverseProxy --version 2.2.0

Após a instalação, abra o arquivo Program.cs e escreva o seguinte.

 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 é a configuração básica para utilizar o YARP.
routes contém as informações de rota que o reverse proxy utilizará, e clusters contém as informações de cluster que o reverse proxy utilizará.
Essas informações são carregadas no reverse proxy pelo método LoadFromMemory.
Por fim, o método MapReverseProxy mapeia e executa o reverse proxy.

Além disso, para o uso real, adicione o projeto do reverse proxy como referência no projeto aspire apphost e inclua ou modifique o seguinte trecho no arquivo Program.cs.

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();

Agora, o reverse proxy pode referenciar o servidor de eco.
A estrutura está sendo alterada para que as requisições externas sejam recebidas pelo reverse proxy e encaminhadas ao servidor de eco.

Modificação do reverse proxy

Primeiro, é preciso alterar o endereço de escuta do projeto atribuído ao reverse proxy.
Remova o applicationUrl no arquivo Properties/launchSettings.json.
Depois, abra o arquivo Program.cs e modifique-o amplamente como abaixo.

 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"}");

Primeiro, modifique as informações de routes e clusters.
Adicione, respectivamente, echo-route e echo-cluster, configurando-os para encaminhar requisições ao servidor de eco.
Além disso, altere para que o endereço do servidor de eco seja lido a partir de uma variável de ambiente.

A regra desse endereço é services__{service-name}__http__{index}.
No caso do servidor de eco, o nome do serviço é echo-server e, como se trata de uma instância única, utiliza-se 0 como índice.
Caso um servidor ASP.NET Core seja adicionado, múltiplas instâncias poderão ser criadas por meio de WithReplica, bastando incrementar o índice para utilizá-las.
O endereço http://localhost:8080, usado como tratamento de exceção, é um valor sem nenhum significado.

Agora, execute o projeto e acesse http://localhost:3000/?name=world, e você verá, como antes, Hello, world sendo exibido.

Ideias de extensão

Agora, verificamos que é possível adicionar um servidor Go ao dotnet aspire e encaminhar requisições por meio de um reverse proxy.
A partir daí, é possível expandir o processo para implementá-lo de forma programática.
Por exemplo, pode-se adicionar uma numeração ao nome do serviço do servidor de eco para criar múltiplas instâncias e configurar automaticamente o reverse proxy.

Modifique o arquivo Program.cs do projeto aspire apphost, que utiliza o reverse proxy e o servidor de eco, da seguinte forma.

 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}

Em seguida, modifique o arquivo Program.cs do projeto do reverse proxy da seguinte maneira.

 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};

Adicione as configurações de destino para as 8 instâncias do servidor de eco.
Agora, o reverse proxy possui as informações de destino de todos os servidores de eco ampliados e pode encaminhar as requisições.
Ao acessar http://localhost:3000/?name=world, como antes, você verá Hello, world sendo exibido.

Considerações finais

Neste texto, foi descrito o processo de adicionar um servidor Go ao dotnet aspire e encaminhar requisições por meio de um reverse proxy.
Contudo, a parte de extensão ainda não foi totalmente escrita, e preparei, em um repositório separado, um exemplo que permite implementar de forma mais programática por meio de variáveis de ambiente.
Para a estrutura completa do projeto e o código, consulte snowmerak/AspireStartPack.

Pessoalmente, espero que o dotnet aspire possa desempenhar um papel próprio como alternativa ao docker compose e como ferramenta de implantação em nuvem.
Já existem geradores que criam manifests do docker compose ou do k8s, e acredito que isso tornou as ferramentas de infraestrutura mais acessíveis para desenvolvedores comuns.