Esecuzione di un server Go scalabile con .NET Aspire

dotnet aspire?
dotnet aspire è uno strumento creato per assistere gli sviluppatori nello sviluppo e nella configurazione di applicazioni cloud native, man mano che gli ambienti cloud native si diffondono. Questo strumento consente agli sviluppatori .NET di distribuire facilmente progetti .NET, varie infrastrutture cloud native, nonché servizi o container scritti in altri linguaggi.
Naturalmente, dal docker fino a k8s, questi ambienti vengono rilasciati e gestiti, e molti settori, industrie e sviluppatori stanno migrando dagli ambienti on-premise tradizionali verso ambienti cloud native, e in molti casi la migrazione è già completata. Ormai si tratta di un campo maturo. Pertanto, ritengo non sia necessario spiegare le difficoltà già note relative alla gestione dei nomi host, alla configurazione delle porte, ai firewall, alla gestione delle metriche e simili.
Quindi, anche solo alla luce delle spiegazioni precedenti, sarà probabilmente difficile farsi un'idea di cosa sia dotnet aspire. Il motivo è che nemmeno Microsoft fornisce una definizione precisa di questo strumento. Pertanto, anch'io non fornirò una definizione particolare. Tuttavia, in questo articolo utilizzerò le funzionalità di base di dotnet aspire così come le ho comprese, quindi potete farvi riferimento per definire la vostra posizione personale.
Configurazione del progetto
Creazione di un progetto dotnet aspire
Se il template di dotnet aspire non è disponibile, è necessario installarlo innanzitutto. Installate il template con il seguente comando. Se .NET non è installato, provvedete voi stessi a installarlo.
1dotnet new install Aspire.ProjectTemplates
Poi create una nuova soluzione in una cartella appropriata.
1dotnet new sln
Successivamente, eseguite il seguente comando dalla cartella della soluzione per creare un progetto basato sul template aspire-apphost.
1dotnet new aspire-apphost -o AppHost
Verrà così creato un progetto aspire-apphost contenente solo un codice semplice per la configurazione.
Aggiunta di Valkey
Ora aggiungiamo rapidamente Valkey.
Prima di procedere, occorre sapere che dotnet aspire fornisce diverse soluzioni di terze parti tramite il community hosting. Naturalmente, anche Valkey può beneficiare di questo supporto di community hosting, ed è facilmente utilizzabile tramite il seguente pacchetto NuGet.
1dotnet add package Aspire.Hosting.Valkey
Sono disponibili anche altre integrazioni di hosting, che potete consultare qui. Tornando a Valkey, aprite il file Program.cs nel progetto AppHost e modificatelo come segue.
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 è un'implementazione dell'interfaccia IResourceBuilder che contiene le informazioni necessarie per costruire il servizio Valkey.WithDataVolume crea un volume in cui archiviare i dati della cache, mentre WithPersistence consente di salvare in modo persistente i dati della cache.
Da quanto visto finora, sembra svolgere un ruolo simile a volumes di docker-compose.
Naturalmente, anche voi potete realizzarlo senza difficoltà.
Tuttavia, ciò esula dallo scopo di questo articolo, quindi non ne parlerò ora.
Creazione di un server echo in Go
Ora aggiungiamo un semplice server in linguaggio Go.
Innanzitutto, nella cartella della soluzione creiamo un workspace tramite go work init.
Per gli sviluppatori .NET, il workspace di Go può essere considerato simile a una soluzione.
Poi create una cartella chiamata EchoServer, spostatevi al suo interno ed eseguite go mod init EchoServer.
Questo comando crea un modulo Go. Per gli sviluppatori .NET, il modulo è assimilabile a un progetto.
Quindi create il file main.go e scrivetelo come segue.
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}
Questo server, quando viene eseguito da Aspire AppHost, legge la porta sulla quale deve restare in ascolto dalla variabile d'ambiente PORT iniettata e avvia il server su tale porta.
Si tratta di un server che riceve il parametro di query name e restituisce Hello, {name}.
Ora aggiungiamo questo server a dotnet aspire.
Aggiunta del server echo ad Aspire
Tornate al progetto Aspire AppHost in cui avete aggiunto Valkey e installate il community hosting per Go.
1dotnet add package CommunityToolkit.Aspire.Hosting.Golang
Poi aprite il file Program.cs e aggiungete la seguente istruzione.
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();
Qui echoServer è un'implementazione dell'interfaccia IResourceBuilder che contiene le informazioni necessarie per costruire il server Go.
Il metodo AddGolangApp, appena aggiunto, è un metodo di estensione dell'host personalizzato per aggiungere un server in linguaggio Go.
Viene utilizzata in modo fisso la porta 3000, e si può notare che viene iniettata la variabile d'ambiente PORT.
Infine, WithExternalHttpEndpoints consente l'accesso dall'esterno.
Per verificare, provate ad accedere a http://localhost:3000/?name=world: dovreste vedere comparire Hello, world.
Tuttavia, l'attuale dotnet aspire comporta una pesante penalizzazione per i progetti non-dotnet. Si tratta proprio di...
Estensione del progetto
Come si gestisce allora la scalabilità orizzontale?
Attualmente dotnet aspire fornisce l'opzione WithReplica solo per il builder dei progetti .NET aggiunti tramite il metodo AddProject.
Tuttavia, non la fornisce per gli host in linguaggio Go né per progetti esterni come quelli aggiunti tramite AddContainer.
Di conseguenza, occorre implementarla direttamente utilizzando un load balancer o un reverse proxy separato.
Tuttavia, in questo modo il reverse proxy potrebbe diventare un SPOF, quindi è opportuno che il reverse proxy stesso fornisca l'opzione WithReplica.
Ne consegue inevitabilmente che il reverse proxy debba essere un progetto .NET.
Finora, per affrontare questo problema, avevo utilizzato nginx, traefik, un'implementazione diretta e altri approcci, ma con il vincolo di dover usare un progetto .NET non avevo, al momento, soluzioni a portata di mano. Pertanto ho cercato un reverse proxy implementato in .NET e, per fortuna, esisteva l'opzione YARP. YARP è un reverse proxy implementato in .NET che può svolgere anche la funzione di load balancer e offre numerose funzionalità, quindi ho ritenuto che fosse una buona scelta.
Ora aggiungiamo YARP.
Configurazione di un reverse proxy con YARP
Innanzitutto, creiamo un progetto per utilizzare YARP.
1dotnet new web -n ReverseProxy
Poi spostatevi nel progetto e installate YARP.
1dotnet add package Yarp.ReverseProxy --version 2.2.0
Al termine dell'installazione, aprite il file Program.cs e scrivete quanto segue.
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"}");
Questo codice è la configurazione di base per utilizzare YARP.routes contiene le informazioni sulle route che il reverse proxy utilizzerà, mentre clusters contiene le informazioni sui cluster che il reverse proxy utilizzerà.
Queste informazioni vengono caricate nel reverse proxy tramite il metodo LoadFromMemory.
Infine, il metodo MapReverseProxy consente di mappare ed eseguire il reverse proxy.
Per l'uso effettivo, nel progetto aspire apphost aggiungete un riferimento al progetto reverse proxy e modificate o aggiungete al file Program.cs la seguente istruzione.
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();
Ora il reverse proxy può fare riferimento al server echo. La struttura è stata modificata in modo che le richieste esterne vengano ricevute dal reverse proxy e poi inoltrate al server echo.
Modifica del reverse proxy
Anzitutto occorre modificare l'indirizzo di ascolto del progetto assegnato al reverse proxy.
Rimuovete applicationUrl dal file Properties/launchSettings.json.
Quindi aprite il file Program.cs e modificatelo in modo sostanziale come segue.
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"}");
Innanzitutto modifichiamo le informazioni relative a routes e clusters.
Aggiungiamo rispettivamente echo-route e echo-cluster, configurandoli in modo che le richieste vengano inviate al server echo.
Inoltre, modifichiamo il codice in modo che l'indirizzo del server echo venga letto dalle variabili d'ambiente e utilizzato.
La regola per questo indirizzo è services__{service-name}__http__{index}.
Nel caso del server echo, il nome del servizio è echo-server e, trattandosi di una singola istanza, si utilizza l'indice 0.
Se si aggiunge un server ASP.NET Core, più istanze possono essere create tramite WithReplica, quindi è sufficiente incrementare l'indice per utilizzarle.
L'indirizzo http://localhost:8080, usato come gestione delle eccezioni, è un valore privo di significato.
Ora eseguite il progetto e accedete a http://localhost:3000/?name=world: dovreste vedere comparire ancora Hello, world.
Idee per l'estensione
Abbiamo ora verificato che è possibile aggiungere un server Go a dotnet aspire e inoltrarvi le richieste tramite un reverse proxy. A questo punto, il processo può essere esteso in modo da essere implementato in modo programmatico. Ad esempio, si potrebbe aggiungere una numerazione al nome del servizio del server echo per creare più istanze e aggiungere automaticamente la configurazione al reverse proxy.
Modificate il file Program.cs del progetto aspire apphost, che utilizza il reverse proxy e il server echo, come segue.
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}
Poi modificate il file Program.cs del progetto reverse proxy come segue.
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};
Aggiungiamo la configurazione delle destinazioni per le otto istanze del server echo.
Ora il reverse proxy dispone delle informazioni sulle destinazioni di tutti i server echo aggiunti e può inoltrare le richieste.
Accedendo come prima a http://localhost:3000/?name=world, dovreste vedere comparire ancora Hello, world.
Conclusioni
In questo articolo abbiamo illustrato come aggiungere un server Go a dotnet aspire e come inoltrare le richieste tramite un reverse proxy. Tuttavia, non ho ancora scritto per intero la parte relativa all'estensione; ho invece preparato in un repository separato un esempio che consente di implementarla in modo più programmatico tramite le variabili d'ambiente. Per la struttura dettagliata del progetto e il codice, consultate snowmerak/AspireStartPack.
Personalmente ritengo che dotnet aspire possa svolgere un ruolo proprio come alternativa a docker compose e come strumento di distribuzione cloud. Esistono già generatori in grado di produrre docker compose o manifest k8s, e penso che ciò abbia reso più accessibili gli strumenti infrastrutturali anche per gli sviluppatori generici.