GoSuda

Uruchamianie skalowalnego serwera Go w .NET Aspire

By snowmerak views ...

dotnet aspire?

dotnet aspire to narzędzie stworzone, aby pomagać deweloperom w rozwoju i konfiguracji aplikacji chmurowych w miarę jak środowiska cloud native się rozrastają. To narzędzie umożliwia deweloperom .NET łatwe wdrażanie projektów .NET, różnorodnej infrastruktury cloud native, a także usług napisanych w innych językach lub kontenerów.

Oczywiście, od Dockera aż po K8s, technologie te są wdrażane i eksploatowane, a wiele dziedzin, branż i deweloperów przenosi się z tradycyjnych środowisk on-premise do środowisk cloud native, i większość z nich już to zrobiła. Ten obszar jest już dojrzały. Dlatego uważam, że nie ma potrzeby wyjaśniać dotychczasowych niedogodności związanych z nazwami hostów, konfiguracją portów, zaporami sieciowymi czy zarządzaniem metrykami.

Dlatego już na podstawie powyższych wyjaśnień może być Pan/Pani zupełnie nie wiedzieć, czym właściwie jest dotnet aspire. Dzieje się tak, ponieważ Microsoft nie podaje dokładnej definicji tego narzędzia. Dlatego ja również nie będę podawać własnej definicji. Zamiast tego, w tym artykule wykorzystam podstawowe funkcje dotnet aspire, tak jak je rozumiem, więc może Pan/Pani, korzystając z tego, wyrobić sobie własne zdanie.

Konfiguracja projektu

Tworzenie projektu dotnet aspire

Jeśli szablon dotnet aspire nie jest zainstalowany, należy najpierw zainstalować szablon. Szablon instalujemy następującym poleceniem. Jeśli nie ma Pan/Pani zainstalowanego .NET, prosimy o samodzielną instalację.

1dotnet new install Aspire.ProjectTemplates

Następnie w odpowiednim folderze tworzymy nowe rozwiązanie.

1dotnet new sln

Potem, w folderze rozwiązania, uruchamiamy poniższe polecenie, aby utworzyć projekt na podstawie szablonu aspire-apphost.

1dotnet new aspire-apphost -o AppHost

W ten sposób zostanie utworzony projekt aspire-apphost zawierający jedynie prosty kod konfiguracyjny.

Dodawanie Valkey

Teraz w prosty sposób dodamy Valkey.

Zanim jednak to zrobimy, warto wiedzieć, że dotnet aspire udostępnia różnorodne rozwiązania firm trzecich poprzez community hosting. Oczywiście Valkey również może korzystać z tego wsparcia społeczności, a łatwo go używać dzięki poniższemu pakietowi NuGet.

1dotnet add package Aspire.Hosting.Valkey

Poza tym dostępne są różne zintegrowane hostingi, które można sprawdzić tutaj.
Wracając do Valkey, w projekcie AppHost otwieramy plik Program.cs i modyfikujemy go w następujący sposób.

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 jest implementacją interfejsu IResourceBuilder, która zawiera informacje potrzebne do zbudowania usługi Valkey.
WithDataVolume tworzy wolumen do przechowywania danych pamięci podręcznej, a WithPersistence pozwala na ciągły zapis danych pamięci podręcznej.
Patrząc na to, wydaje się, że pełni on podobną rolę jak volumes w docker-compose.
Oczywiście Państwo również mogą to zrobić bez trudu.
Jednak wykracza to poza zakres tego artykułu, dlatego nie będziemy o tym teraz mówić.

Tworzenie prostego serwera echo w Go

Teraz dodamy prosty serwer w języku Go.
Najpierw w folderze rozwiązania tworzymy workspace poleceniem go work init.
Dla deweloperów .NET workspace Go można traktować jako odpowiednik rozwiązania.

Następnie tworzymy folder EchoServer, przechodzimy do niego i uruchamiamy go mod init EchoServer.
To polecenie tworzy moduł Go. Dla deweloperów .NET moduł można traktować jako odpowiednik projektu.
Następnie tworzymy plik main.go i piszemy w nim następujący kod.

 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}

Ten serwer, gdy AppHost Aspire jest uruchamiany i wstrzykuje zmienną środowiskową PORT, na której serwer ma nasłuchiwać, odczytuje ten port i uruchamia serwer.
Jest to prosty serwer, który odbiera parametr zapytania name i zwraca Hello, {name}.

Teraz dodamy ten serwer do dotnet aspire.

Dodawanie serwera echo do aspire

Wracamy do projektu Aspire AppHost, w którym dodaliśmy Valkey, i instalujemy community hosting dla języka Go.

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

Następnie otwieramy plik Program.cs i dodajemy poniższą instrukcję.

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

Tutaj echoServer jest implementacją interfejsu IResourceBuilder, która zawiera informacje potrzebne do zbudowania serwera w Go.
Dodana przed chwilą metoda AddGolangApp jest metodą rozszerzającą niestandardowego hosta, służącą do dodawania serwera w Go.
Serwer używa stałego portu 3000 i, jak widać, wstrzykuje zmienną środowiskową PORT.
Na koniec WithExternalHttpEndpoints umożliwia dostęp z zewnątrz.

Aby przetestować, proszę otworzyć http://localhost:3000/?name=world, a zobaczy Pan/Pani, że wyświetla się Hello, world.

Jednak obecnie dotnet aspire nakłada na projekty niebędące projektami .NET znaczną karę.
Jest nią właśnie...

Skalowanie projektu

A jak zatem wykonać skalowanie poziome?

Obecnie dotnet aspire udostępnia opcję WithReplica wyłącznie dla projektów .NET dodanych metodą AddProject.
Nie udostępnia jej jednak dla hostów Go ani zewnętrznych projektów, takich jak te dodane metodą AddContainer.

Dlatego trzeba to zaimplementować samodzielnie, używając osobnego load balancera lub reverse proxy.
Jednak wtedy reverse proxy może stać się SPOF (Single Point of Failure), dlatego dobrze byłoby, aby reverse proxy również udostępniał opcję WithReplica.
W takim przypadku reverse proxy musi być nieuchronnie projektem .NET.

Dotychczas do rozwiązania tego problemu używałem nginx, Traefika lub własnej implementacji, jednak gdy pojawia się ograniczenie do projektów .NET, nie miałem wtedy żadnego rozwiązania pod ręką.
Dlatego poszukałem reverse proxy zaimplementowanego w .NET i, na szczęście, znalazłem YARP.
YARP to reverse proxy zaimplementowane w .NET, które może pełnić rolę load balancera i oferuje wiele funkcji, dlatego uznałem go za dobry wybór.

Teraz dodamy YARP.

Konfiguracja reverse proxy z YARP

Najpierw tworzymy projekt, który będzie używał YARP.

1dotnet new web -n ReverseProxy

Następnie przechodzimy do projektu i instalujemy YARP.

1dotnet add package Yarp.ReverseProxy --version 2.2.0

Po zakończeniu instalacji otwieramy plik Program.cs i piszemy następujący kod.

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

Ten kod to podstawowa konfiguracja potrzebna do korzystania z YARP.
routes zawiera informacje o trasach, z których będzie korzystać reverse proxy, a clusters zawiera informacje o klastrach, z których będzie korzystać reverse proxy.
Informacje te są ładowane do reverse proxy metodą LoadFromMemory.
Na koniec metoda MapReverseProxy mapuje reverse proxy i je uruchamia.

Aby korzystać z tego w praktyce, w projekcie aspire apphost dodajemy referencję do projektu reverse proxy, a w pliku Program.cs dodajemy lub modyfikujemy następującą instrukcję.

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

Teraz reverse proxy może odwoływać się do serwera echo.
Struktura została zmieniona tak, że żądania przychodzące z zewnątrz są odbierane przez reverse proxy i przekazywane do serwera echo.

Modyfikacja reverse proxy

Na początek należy zmienić adres nasłuchiwania projektu przypisanego do reverse proxy.
Usuwamy applicationUrl z pliku Properties/launchSettings.json.
Następnie otwieramy plik Program.cs i wprowadzamy poniższe, znaczące zmiany.

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

Najpierw modyfikujemy informacje o routes i clusters.
Dodajemy odpowiednio echo-route i echo-cluster, aby żądania były kierowane do serwera echo.
Następnie zmieniamy kod tak, aby adres serwera echo był odczytywany z zmiennych środowiskowych.

Reguła dla tego adresu to services__{service-name}__http__{index}.
W przypadku serwera echo nazwa usługi to echo-server, a ponieważ jest to pojedyncza instancja, jako indeks używamy 0.
Jeśli dodamy serwer ASP.NET Core, za pomocą WithReplica można utworzyć wiele instancji, więc wystarczy zwiększać indeks.
Wartość awaryjna http://localhost:8080 jest bezsensownym, pozbawionym znaczenia ciągiem.

Teraz uruchamiamy projekt i otwieramy http://localhost:3000/?name=world, a zobaczy Pan/Pani, że nadal wyświetla się Hello, world.

Pomysły na rozszerzenie

Dodaliśmy już serwer Go do dotnet aspire i potwierdziliśmy, że żądania są przekazywane przez reverse proxy.
Teraz można rozszerzyć ten proces tak, aby był realizowany programowo.
Na przykład, dla serwera echo można dodać numerację po nazwie usługi, tworząc wiele instancji, i automatycznie dodawać konfigurację do reverse proxy.

W pliku Program.cs projektu aspire apphost modyfikujemy kod korzystający z reverse proxy i serwera echo w następujący sposób.

 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}

Następnie modyfikujemy plik Program.cs projektu reverse proxy w następujący sposób.

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

Dodajemy konfigurację miejsc docelowych dla 8 instancji serwera echo.
Teraz reverse proxy posiada informacje o miejscach docelowych rozszerzonych serwerów echo i może przekazywać do nich żądania.
Po otwarciu http://localhost:3000/?name=world nadal zobaczy Pan/Pani wyświetlone Hello, world.

Zakończenie

W tym artykule opisałem proces dodawania serwera Go do dotnet aspire oraz przekazywania żądań przez reverse proxy.
Nie napisałem jeszcze wszystkiego dotyczącego rozszerzeń, a przykład, który pozwala na bardziej programowe podejście z użyciem zmiennych środowiskowych, umieściłem w osobnym repozytorium.
Szczegółową strukturę projektu i kod proszę znaleźć w snowmerak/AspireStartPack.

Osobiście oczekuję, że dotnet aspire może odegrać własną rolę jako alternatywa dla docker compose oraz jako narzędzie do wdrażania w chmurze.
Istnieje już generator, który tworzy manifesty docker compose i k8s, dlatego uważam, że dostęp zwykłych deweloperów do narzędzi infrastrukturalnych stał się łatwiejszy.