GoSuda

Ausprobieren, einen Go-Server skalierbar mit .NET Aspire auszuführen

By snowmerak views ...

dotnet aspire?

dotnet aspire ist ein Werkzeug, das mit der zunehmenden Verbreitung cloud-nativer Umgebungen entwickelt wurde, um Entwicklern bei der Entwicklung und Konfiguration cloud-nativer Anwendungen zu helfen. Dieses Werkzeug ermöglicht .NET-Entwicklern, .NET-Projekte sowie verschiedene cloud-native Infrastrukturen und darüber hinaus Dienste in anderen Sprachen oder Container auf einfache Weise bereitzustellen.

Selbstverständlich reicht dies von Docker bis Kubernetes und wird produktiv betrieben. Viele Bereiche, Branchen und Entwickler migrieren von klassischen On-Premises-Umgebungen in cloud-native Umgebungen, und diese Migration ist bereits vollzogen. Mittlerweile handelt es sich um einen ausgereiften Bereich. Daher dürfte es nicht notwendig sein, die bisherigen Unannehmlichkeiten bei Hostnamen, Portkonfiguration, Firewalls, Metrikverwaltung und Ähnlichem ausführlich zu erläutern.

Aufgrund der obigen Erläuterungen dürfte es Ihnen daher kaum möglich sein, eine Vorstellung davon zu entwickeln, was dotnet aspire eigentlich ist. Der Grund dafür ist, dass Microsoft selbst keine präzise Definition dafür liefert. Deshalb werde auch ich keine eigene Definition vornehmen. Stattdessen werde ich in diesem Beitrag die grundlegenden Funktionen von dotnet aspire verwenden, wie ich sie verstanden habe. Sie können sich daran orientieren und sich Ihre eigene Position bilden.

Projektaufbau

Erstellen eines dotnet aspire-Projekts

Falls die dotnet aspire-Vorlage nicht vorhanden ist, muss sie zunächst installiert werden. Installieren Sie die Vorlage mit dem folgenden Befehl. Falls .NET nicht installiert ist, installieren Sie es bitte selbst.

1dotnet new install Aspire.ProjectTemplates

Erstellen Sie anschließend in einem geeigneten Ordner eine neue Solution.

1dotnet new sln

Führen Sie danach im Solution-Ordner den folgenden Befehl aus, um ein Projekt der Vorlage aspire-apphost zu erstellen.

1dotnet new aspire-apphost -o AppHost

Daraufhin wird ein aspire-apphost-Projekt erzeugt, das lediglich einen einfachen Code für die Konfiguration enthält.

Hinzufügen von Valkey

Nun fügen wir Valkey auf einfache Weise hinzu.

Bevor wir es hinzufügen, sei erwähnt, dass dotnet aspire über Community Hosting eine Vielzahl von Drittanbieterlösungen bereitstellt. Selbstverständlich kann auch Valkey von diesem Community Hosting profitieren und lässt sich über das folgende NuGet-Paket einfach verwenden.

1dotnet add package Aspire.Hosting.Valkey

Darüber hinaus werden zahlreiche weitere integrierte Hostings angeboten, die Sie hier einsehen können. Kehren wir nun zu Valkey zurück: Öffnen Sie im AppHost-Projekt die Datei Program.cs und passen Sie sie wie folgt an.

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 ist eine Implementierung der Schnittstelle IResourceBuilder, die die Informationen enthält, mit denen ein Valkey-Dienst erstellt werden kann.WithDataVolume erstellt ein Volume zur Speicherung der Cache-Daten, und WithPersistence sorgt dafür, dass die Cache-Daten kontinuierlich gespeichert werden. Betrachtet man dies bis hierhin, scheint es eine ähnliche Funktion wie volumes in docker-compose zu erfüllen. Selbstverständlich können auch Sie dies ohne Schwierigkeiten umsetzen. Da dies jedoch über den Rahmen dieses Beitrags hinausgeht, werde ich nicht weiter darauf eingehen.

Erstellen eines Go-Echo-Servers

Nun fügen wir einen einfachen Go-Server hinzu. Erstellen Sie zunächst im Solution-Ordner mit go work init einen Workspace. Für .NET-Entwickler kann man den Go-Workspace etwa als Entsprechung zu einer Solution betrachten.

Erstellen Sie anschließend einen Ordner namens EchoServer, wechseln Sie hinein und führen Sie go mod init EchoServer aus. Mit diesem Befehl wird ein Go-Modul erstellt. Für .NET-Entwickler kann man ein Modul etwa als Entsprechung zu einem Projekt betrachten. Erstellen Sie danach die Datei main.go und schreiben Sie Folgendes.

 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}

Dieser Server liest, sofern das Aspire AppHost beim Start die Umgebungsvariable PORT injiziert, den darin angegebenen Port und startet den Server auf diesem. Es handelt sich um einen einfachen Server, der den Query-Parameter name entgegennimmt und Hello, {name} zurückgibt.

Nun fügen wir diesen Server zu dotnet aspire hinzu.

Hinzufügen des Echo-Servers zu Aspire

Wechseln Sie zurück zum Aspire-AppHost-Projekt, in dem Sie Valkey hinzugefügt haben, und fügen Sie das Community Hosting für die Go-Sprache hinzu.

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

Öffnen Sie anschließend die Datei Program.cs und fügen Sie die folgende Anweisung hinzu.

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

Hier ist echoServer eine Implementierung der Schnittstelle IResourceBuilder, die die Informationen enthält, mit denen ein Go-Server erstellt werden kann. Die soeben hinzugefügte Methode AddGolangApp ist eine Erweiterungsmethode eines benutzerdefinierten Hosts, um einen Go-Server hinzuzufügen. Sie verwendet fest den Port 3000 und injiziert die Umgebungsvariable PORT, wie Sie sehen können. Schließlich ermöglicht WithExternalHttpEndpoints den Zugriff von außen.

Zum Testen rufen Sie http://localhost:3000/?name=world auf, und Sie sollten die Ausgabe Hello, world sehen.

Allerdings gibt es in der aktuellen Version von dotnet aspire einen erheblichen Nachteil für Nicht-.NET-Projekte. Dieser besteht darin, dass...

Erweiterung des Projekts

Wie funktioniert dann die horizontale Skalierung?

Derzeit stellt dotnet aspire die Option WithReplica nur für Builder von .NET-Projekten bereit, die über die Methode AddProject hinzugefügt wurden. Für Go-Hosts oder externe Projekte wie AddContainer wird diese Option jedoch nicht angeboten.

Daher muss man einen separaten Load Balancer oder Reverse Proxy einsetzen und die Lösung selbst implementieren. Da dieser Reverse Proxy jedoch zu einem SPOF werden kann, empfiehlt es sich, dass der Reverse Proxy die Option WithReplica unterstützt. Daraus folgt zwangsläufig, dass der Reverse Proxy ein .NET-Projekt sein muss.

Bisher habe ich für solche Probleme Ansätze wie nginx, Traefik oder eine eigene Implementierung verwendet. Sobald jedoch die Einschränkung auf .NET-Projekte gilt, hatte ich dafür unmittelbar keine Lösung. Daher habe ich nach einem in .NET implementierten Reverse Proxy gesucht und glücklicherweise die Option YARP gefunden. YARP ist ein in .NET implementierter Reverse Proxy, der auch die Rolle eines Load Balancers übernehmen kann und zahlreiche Funktionen bietet. Daher habe ich es als gute Wahl beurteilt.

Nun fügen wir YARP hinzu.

Aufbau eines Reverse Proxys mit YARP

Erstellen Sie zunächst ein Projekt zur Verwendung von YARP.

1dotnet new web -n ReverseProxy

Wechseln Sie anschließend in das Projekt und installieren Sie YARP.

1dotnet add package Yarp.ReverseProxy --version 2.2.0

Nach Abschluss der Installation öffnen Sie die Datei Program.cs und schreiben Folgendes.

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

Dieser Code ist der grundlegende Code zur Verwendung von YARP.routes enthält die Routeninformationen, die der Reverse Proxy verwendet, und clusters enthält die Clusterinformationen, die der Reverse Proxy verwendet. Diese Informationen werden mit der Methode LoadFromMemory in den Reverse Proxy geladen. Abschließend wird mit der Methode MapReverseProxy der Reverse Proxy zugeordnet und ausgeführt.

Für den praktischen Einsatz fügen Sie im Aspire-AppHost-Projekt das Reverse-Proxy-Projekt als Referenz hinzu und ergänzen bzw. ändern Sie die Datei Program.cs wie folgt.

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

Nun kann der Reverse Proxy auf den Echo-Server verweisen. Die Struktur wurde so geändert, dass eingehende externe Anfragen vom Reverse Proxy entgegengenommen und an den Echo-Server weitergeleitet werden.

Anpassung des Reverse Proxys

Zunächst muss die Listening-Adresse des dem Reverse Proxy zugewiesenen Projekts geändert werden. Entfernen Sie die applicationUrl in der Datei Properties/launchSettings.json. Öffnen Sie anschließend die Datei Program.cs und passen Sie sie wie folgt umfassend an.

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

Zunächst werden die Angaben zu routes und clusters angepasst. Jeweils werden echo-route und echo-cluster hinzugefügt, sodass Anfragen an den Echo-Server weitergeleitet werden. Anschließend wird die Adresse des Echo-Servers aus der Umgebungsvariable gelesen und verwendet.

Die Regel für diese Adresse lautet services__{service-name}__http__{index}. Beim Echo-Server lautet der Dienstname echo-server, und da es sich um eine einzelne Instanz handelt, wird der Index 0 verwendet. Falls ein ASP.NET-Core-Server hinzugefügt wird, können über WithReplica mehrere Instanzen erzeugt werden; in diesem Fall kann der Index entsprechend erhöht werden. Der als Ausnahmebehandlung hinterlegte Wert http://localhost:8080 ist ein bedeutungsloser Platzhalter.

Starten Sie nun das Projekt und rufen Sie http://localhost:3000/?name=world auf. Sie sollten weiterhin die Ausgabe Hello, world sehen.

Ideen zur Erweiterung

Nun haben wir gesehen, wie ein Go-Server zu dotnet aspire hinzugefügt wird und Anfragen über einen Reverse Proxy weitergeleitet werden. Anschließend lässt sich dieser Prozess programmatisch erweitern. Beispielsweise kann man für den Echo-Server Instanzen mit fortlaufender Nummerierung im Dienstnamen erzeugen und die Konfiguration des Reverse Proxys automatisch hinzufügen.

Passen Sie den Code in der Datei Program.cs des Aspire-AppHost-Projekts, der den Reverse Proxy und den Echo-Server verwendet, wie folgt an.

 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}

Passen Sie anschließend die Datei Program.cs des Reverse-Proxy-Projekts wie folgt an.

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

Für die auf 8 Stück erweiterten Echo-Server-Instanzen werden Zielkonfigurationen hinzugefügt. Der Reverse Proxy verfügt nun über die Zielinformationen aller erweiterten Echo-Server und kann Anfragen entsprechend weiterleiten. Rufen Sie wie zuvor http://localhost:3000/?name=world auf, und Sie sollten weiterhin die Ausgabe Hello, world sehen.

Schlussbemerkung

In diesem Beitrag wurde beschrieben, wie ein Go-Server zu dotnet aspire hinzugefügt und Anfragen über einen Reverse Proxy weitergeleitet werden. Die Erweiterung haben wir allerdings noch nicht vollständig ausgearbeitet. Ein Beispiel, das die Umsetzung über Umgebungsvariablen noch stärker programmatisch gestaltet, habe ich in einem separaten Repository abgelegt. Details zur Projektstruktur und zum Code finden Sie unter snowmerak/AspireStartPack.

Persönlich erwarte ich, dass dotnet aspire als Alternative zu Docker Compose sowie als Werkzeug für Cloud-Deployments eine eigene Rolle übernehmen kann. Da bereits Generatoren existieren, die Docker-Compose- oder Kubernetes-Manifeste erzeugen, meine ich, dass der Zugang normaler Entwickler zu Infrastrukturwerkzeugen dadurch bereits leichter geworden ist.