GoSuda

dotnet aspire'da Go sunucusunu ölçeklenebilir şekilde çalıştırmayı denemek

By snowmerak views ...

dotnet aspire nedir?

dotnet aspire, bulut yerel (cloud native) ortamların yaygınlaşmasıyla birlikte geliştiricilerin bulut yerel geliştirme ve yapılandırmasına yardımcı olmak amacıyla oluşturulmuş bir araçtır. Bu araç, .NET geliştiricilerinin .NET projelerini, çeşitli bulut yerel altyapıyı ve diğer dillerle yazılmış hizmetleri veya konteynerleri kolayca dağıtabilmesini sağlar.

Doğal olarak docker'dan k8s'e kadar yayına alınıp işletilmekte olup, geleneksel şirket içi (on-premise) ortamdan çok sayıda alan, sektör ve geliştirici bulut yerel ortama geçmekte ve geçmiş durumdadır. Artık olgunlaşmış bir alandır. Bu nedenle ana bilgisayar adları, bağlantı noktası yapılandırması, güvenlik duvarı, metrik yönetimi gibi konulardaki mevcut zorlukların açıklanmasına gerek olmadığını düşünüyorum.

Dolayısıyla yukarıdaki açıklamalardan yola çıkarak bile dotnet aspire'ın ne olduğuna dair hiçbir fikir edinemeyebilirsiniz. Çünkü bu, Microsoft'un bile kesin bir tanım vermediği bir konudur. Bu yüzden ben de özel bir tanım vermeyeceğim. Bununla birlikte, bu yazıda dotnet aspire'ın benim anladığım temel işlevlerini kullanacağım; dolayısıyla bunlara bakarak kendi konumunuzu belirleyebilirsiniz.

Proje Yapılandırması

dotnet aspire projesi oluşturma

Eğer dotnet aspire şablonu yoksa, önce şablonu kurmanız gerekir. Şablonu aşağıdaki komutla kurun. .NET yüklü değilse, bunu kendiniz yüklemeniz gerekir.

1dotnet new install Aspire.ProjectTemplates

Ardından uygun bir klasörde yeni bir çözüm (solution) oluşturun.

1dotnet new sln

Sonra çözüm klasöründe aşağıdaki komutu çalıştırarak aspire-apphost şablonuyla bir proje oluşturun.

1dotnet new aspire-apphost -o AppHost

Böylece kurulum için yalnızca basit kod içeren bir aspire-apphost projesi oluşturulmuş olacaktır.

Valkey ekleme

Şimdi Valkey'i basitçe ekleyelim.

Doğrudan eklemeden önce, dotnet aspire topluluk barındırması (community hosting) aracılığıyla çeşitli üçüncü taraf çözümler sunduğunu belirtmek gerekir.
Valkey de doğal olarak bu topluluk barındırmasının desteğinden yararlanabilir ve aşağıdaki NuGet paketi aracılığıyla kolayca kullanılabilir.

1dotnet add package Aspire.Hosting.Valkey

Bunun dışında çeşitli entegre barındırmalar da sunulmaktadır, bunları bu sayfada inceleyebilirsiniz.
Valkey'e geri dönersek, AppHost projesindeki Program.cs dosyasını açıp aşağıdaki gibi değiştirin.

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, Valkey hizmetini oluşturabilecek bilgileri içeren IResourceBuilder arayüzünün bir uygulamasıdır.
WithDataVolume, önbellek verilerini depolayacak bir birim (volume) oluşturur; WithPersistence ise önbellek verilerinin kalıcı olarak kaydedilmesini sağlar.
Buraya kadar bakıldığında, docker-compose içindeki volumes ile benzer bir rol üstlendiği görülebilir.
Elbette bunu sizler de zorlanmadan oluşturabilirsiniz.
Ancak bu, yazının kapsamının dışında kaldığı için şimdi ayrıntısına girmeyeceğim.

Go dilinde yankı (echo) sunucusu oluşturma

Şimdi basit bir Go dili sunucusu ekleyelim.
Öncelikle çözüm klasöründe go work init komutuyla bir çalışma alanı (workspace) oluşturun.
.NET geliştiricileri için Go çalışma alanı, çözüme (solution) benzer bir yapı olarak düşünülebilir.

Ardından EchoServer adında bir klasör oluşturun, içine girip go mod init EchoServer komutunu çalıştırın.
Bu komutla bir Go modülü oluşturulur. Modül, .NET geliştiricileri için projeye benzer bir yapı olarak kabul edilebilir.
Ardından main.go dosyasını oluşturup aşağıdaki gibi yazın.

 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}

Bu sunucu, Aspire AppHost çalıştırıldığında dinlemesi gereken PORT ortam değişkeni enjekte edildiğinde, bu bağlantı noktasını okuyarak sunucuyu başlatır.
Basitçe name sorgu parametresini alıp Hello, {name} değerini döndüren bir sunucudur.

Şimdi bu sunucuyu dotnet aspire'a ekleyelim.

Yankı sunucusunu aspire'a ekleme

Valkey'i eklediğimiz Aspire AppHost projesine geri dönüp Go dili için topluluk barındırmasını ekleyin.

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

Ardından Program.cs dosyasını açıp aşağıdaki ifadeyi ekleyin.

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

Burada echoServer, Go dili sunucusunu oluşturabilecek bilgileri içeren IResourceBuilder arayüzünün bir uygulamasıdır.
Az önce eklediğimiz AddGolangApp metodu, Go dili sunucusunu eklemek için özel bir host'un genişletme metodudur.
Sabit olarak 3000 numaralı bağlantı noktasını kullanır ve PORT ortam değişkenini enjekte ettiği görülebilir.
Son olarak WithExternalHttpEndpoints, uygulamaya dışarıdan erişilebilmesini sağlar.

Test için http://localhost:3000/?name=world adresine bağlandığınızda Hello, world çıktısını göreceksiniz.

Ancak mevcut haliyle dotnet aspire'da, .NET dışı projelere uygulanan ağır bir ceza bulunmaktadır.
Bu da tam olarak şudur...

Proje Genişletme

Yatay ölçeklendirme nasıl yapılır?

Şu anda dotnet aspire, yalnızca AddProject metoduyla eklenen .NET projeleri için oluşturulan builder'da WithReplica seçeneğini sunmaktadır.
Ancak Go dili host'u veya AddContainer gibi dış projeler için bu seçenek sunulmamaktadır.

Bu nedenle ayrı bir load balancer veya reverse proxy kullanarak bunu kendiniz uygulamanız gerekir.
Ancak bu durumda ilgili reverse proxy tek hata noktası (SPOF) haline gelebilir; dolayısıyla reverse proxy'nin de WithReplica seçeneğini sunması iyi olacaktır.
O halde reverse proxy'nin zorunlu olarak bir .NET projesi olması gerekir.

Şimdiye kadar bu sorunu nginx, traefik, doğrudan uygulama gibi yöntemlerle çözmeye çalıştım; ancak .NET projesi kısıtı getirildiğinde elimde hemen uygulanabilir bir yöntem kalmadı.
Bu yüzden .NET ile geliştirilmiş reverse proxy'leri araştırdım ve neyse ki YARP adlı bir seçenek buldum.
YARP, .NET ile geliştirilmiş bir reverse proxy'dir; load balancer görevi de görebilmekte ve çeşitli özellikler sunmaktadır, bu nedenle iyi bir seçim olduğunu düşündüm.

Şimdi YARP'ı ekleyelim.

YARP ile reverse proxy yapılandırma

Öncelikle YARP'ı kullanmak için bir proje oluşturun.

1dotnet new web -n ReverseProxy

Ardından projeye gidip YARP'ı yükleyin.

1dotnet add package Yarp.ReverseProxy --version 2.2.0

Kurulum tamamlandığında Program.cs dosyasını açıp aşağıdaki gibi yazın.

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

Bu kod, YARP'ı kullanmak için temel bir koddur.
routes, reverse proxy'nin kullanacağı yönlendirme (route) bilgilerini; clusters ise reverse proxy'nin kullanacağı küme (cluster) bilgilerini içerir.
Bu bilgiler LoadFromMemory metodu ile reverse proxy'ye yüklenir.
Son olarak MapReverseProxy metodu kullanılarak reverse proxy eşlenir ve çalıştırılır.

Gerçek kullanım için ayrıca aspire apphost projesine reverse proxy projesini referans olarak eklemeniz ve Program.cs dosyasına aşağıdaki ifadeyi eklemeniz/değiştirmeniz gerekir.

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

Artık reverse proxy, echo server'a başvurabilir.
Dışarıdan gelen istekler reverse proxy tarafından karşılanıp echo server'a iletilecek şekilde yapı değişmektedir.

Reverse proxy'yi düzenleme

Öncelikle reverse proxy projesine atanan dinleme (listening) adresini değiştirmeniz gerekir.
Properties/launchSettings.json dosyasının içindeki applicationUrl öğesini kaldırın.
Ardından Program.cs dosyasını açıp aşağıdaki gibi kapsamlı bir şekilde düzenleyin.

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

Öncelikle routes ve clusters bilgilerini düzenliyoruz.
Sırasıyla echo-route ve echo-cluster ekleyerek istekleri echo server'a gönderecek şekilde ayarlıyoruz.
Ardından echo server'ın adresini ortam değişkeninden okuyacak şekilde değiştiriyoruz.

Bu adresin kuralı services__{service-name}__http__{index} biçimindedir.
Echo server için hizmet adı echo-server olduğundan ve tek bir örnek bulunduğundan, indeks olarak 0 kullanılır.
Eğer bir asp .net core sunucusu eklersek, WithReplica ile birden fazla örnek oluşturulabileceğinden, indeksi artırarak kullanmak yeterlidir.
İstisna işleyicisinde yer alan http://localhost:8080 değeri anlamsız bir yedek değerdir.

Şimdi projeyi çalıştırıp http://localhost:3000/?name=world adresine bağlandığınızda, yine Hello, world çıktısını göreceksiniz.

Genişletme fikri

Artık dotnet aspire'a bir Go sunucusu ekleyip, reverse proxy aracılığıyla istekleri ilettiğimizi gördük.
Bu süreci programatik olarak uygulayacak şekilde genişletmek de mümkün olacaktır.
Örneğin, echo server için hizmet adının sonuna numara ekleyerek birden fazla örnek oluşturabilir ve reverse proxy yapılandırmasını otomatik olarak ekleyebilirsiniz.

Reverse proxy ve echo server'ı kullanan kodu aspire apphost projesinin Program.cs dosyasında aşağıdaki gibi düzenleyin.

 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}

Ardından reverse proxy projesinin Program.cs dosyasını aşağıdaki gibi düzenleyin.

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

8 adede çıkarılan echo server örneklerine yönelik hedef yapılandırmasını ekliyoruz.
Artık reverse proxy, çoğalan echo server'lara ait hedef bilgilerine sahip olup istekleri iletebilmektedir.
Mevcut http://localhost:3000/?name=world adresine bağlandığınızda yine Hello, world çıktısını göreceksiniz.

Sonuç

Bu yazıda dotnet aspire'a bir Go sunucusu eklemenin ve reverse proxy aracılığıyla istekleri iletmenin sürecini anlattım.
Ancak genişletme konusunun tamamını henüz yazmadım; ortam değişkenleri aracılığıyla daha programatik bir uygulama yapılabilecek bir örneği ise ayrı bir depoda hazırladım.
Ayrıntılı proje yapısı ve kod için snowmerak/AspireStartPack deposuna göz atabilirsiniz.

Ben kişisel olarak dotnet aspire'ın docker compose'a bir alternatif olarak ve bulut dağıtım aracı olarak kendine özgü bir rol üstlenebileceğini umuyorum.
Halihazırda docker compose veya k8s manifest dosyaları üreten jeneratörler bulunduğundan, sıradan geliştiricilerin altyapı araçlarına erişiminin daha da kolaylaştığını düşünüyorum.