GoSuda

Exploring How to Run a Scalable Go Server in dotnet aspire

By snowmerak
views ...

dotnet aspire?

As the cloud-native environment expands, dotnet aspire is a tool created to assist developers in cloud-native development and configuration. This tool enables .NET developers to easily deploy .NET projects, various cloud-native infrastructures, and services or containers in other languages.

Naturally, it is released and operated from docker to k8s, and many fields, industries, and developers are moving to, and have already moved to, the cloud-native environment from the existing on-premises environment. It is now a mature field. Therefore, I believe there is no need to explain the existing inconveniences regarding host names, port configurations, firewalls, and metric management.

Thus, even from the above explanations, it will probably be difficult to grasp what dotnet aspire is. This is because Microsoft itself has not provided a precise definition. Therefore, I will not provide any specific definition either. However, since I will use the basic functions of dotnet aspire as I understand them in this article, please refer to it and determine your own stance.

Project Configuration

Creating a dotnet aspire Project

If you do not have the dotnet aspire template, you must first install the template. Install the template using the following command. If you do not have .NET, please install it yourself.

1dotnet new install Aspire.ProjectTemplates

Then, create a new solution in an appropriate folder.

1dotnet new sln

After that, execute the following command in the solution folder to create a project with the aspire-apphost template.

1dotnet new aspire-apphost -o AppHost

Then, an aspire-apphost project containing only simple code for setup is created.

Adding Valkey

Now, let's simply add Valkey.

Before adding it, dotnet aspire provides various third-party solutions through community hosting. Naturally, valkey can also receive support from this community hosting, and it can be easily used through the following NuGet package.

1dotnet add package Aspire.Hosting.Valkey

It also provides various other integrated hostings, which can be found here. Returning to valkey, open the Program.cs file in the AppHost project and modify it as follows:

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 is an implementation of the IResourceBuilder interface that holds information for building the valkey service.WithDataVolume creates a volume to store cache data, and WithPersistence enables the persistent storage of cache data. Up to this point, it appears to perform a role similar to the volumes in docker-compose. Of course, you can also create this without difficulty. However, this is beyond the scope of this article, so I will not discuss it now.

Creating a Go Language Echo Server

Now, let's add a simple Go language server. First, create a workspace using go work init in the solution folder. For .NET developers, a Go workspace can be considered similar to a solution.

Then, create a folder named EchoServer, navigate into it, and execute go mod init EchoServer. This command creates a Go module. A module can be considered analogous to a project for .NET developers. Next, create a main.go file and write the following code:

 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}

When this server is executed by Aspire AppHost, it reads the PORT environment variable, which specifies the port to listen on, and starts the server. It is a simple server that receives a name query and returns Hello, {name}.

Now, let's add this server to dotnet aspire.

Adding the Echo Server to Aspire

Return to the Aspire AppHost project where Valkey was added and add community hosting for the Go language.

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

Then, open the Program.cs file and add the following statement:

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

Here, echoServer is an implementation of the IResourceBuilder interface that holds information for building the Go language server. The AddGolangApp method, which was just added, is an extension method of the custom host for adding a Go language server. It can be confirmed that it uses a fixed port of 3000 and injects the PORT environment variable. Finally, WithExternalHttpEndpoints is for allowing external access.

If you access http://localhost:3000/?name=world for testing, you should be able to confirm that Hello, world is output.

However, currently, dotnet aspire has a significant penalty for non-.NET projects. That is...

Project Extension

How About Horizontal Scaling Then?

Currently, dotnet aspire provides the WithReplica option only for builders of .NET projects added using the AddProject method. However, it does not provide this option for external projects such as Go language hosts or AddContainer.

Therefore, it must be implemented directly using a separate load balancer or reverse proxy. However, if this is the case, the reverse proxy can become a SPOF, so it is preferable for the reverse proxy to provide the WithReplica option. This inevitably means that the reverse proxy must be a .NET project.

Until now, I have used methods such as nginx, trafik, and direct implementation for these issues, but when restricted to a .NET project, I had no immediate solution. Therefore, I looked for a reverse proxy implemented in .NET, and fortunately, there was an option called YARP. YARP is a reverse proxy implemented in .NET, and I determined it was a good choice because it can also act as a load balancer and provides various functions.

Now, let's add YARP.

Configuring Reverse Proxy with YARP

First, create a project for using YARP.

1dotnet new web -n ReverseProxy

Then, navigate to the project and install YARP.

1dotnet add package Yarp.ReverseProxy --version 2.2.0

Once the installation is complete, open the Program.cs file and write the following code:

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

This code is the basic code for using YARP.routes contains the route information that the reverse proxy will use, and clusters contains the cluster information that the reverse proxy will use. This information is loaded into the reverse proxy using the LoadFromMemory method. Finally, the reverse proxy is mapped and executed using the MapReverseProxy method.

For practical use, add a reference to the reverse proxy project in the aspire apphost project, and add and modify the following statement in the Program.cs file.

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

Now, the reverse proxy can reference the echo server. The structure is being changed so that requests from the outside are received by the reverse proxy and forwarded to the echo server.

Modifying Reverse Proxy

First, the listening address of the project assigned to the reverse proxy must be changed. Remove applicationUrl inside the Properties/launchSettings.json file. Then, open the Program.cs file and modify it extensively as follows:

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

First, modify the information for routes and clusters. Add echo-route and echo-cluster respectively to set up requests to be sent to the echo server. Then, modify it to read and use the address of the echo server from the environment variable.

The rule for this address is services__{service-name}__http__{index}. In the case of the echo server, the service name is echo-server, and since it is a single instance, 0 is used as the index. If an asp .net core server is added, multiple instances can be created using WithReplica, so you can use the index by incrementing it. The exception-handled http://localhost:8080 is a garbage value with no meaning.

Now, if you run the project and access http://localhost:3000/?name=world, you should be able to confirm that Hello, world is still being output.

Scaling Ideas

Now, we have verified that we can add a Go server to dotnet aspire and forward requests through a reverse proxy. Then, you should be able to extend this process to be implemented programmatically. For example, you can generate multiple instances by adding numbering after the service name for the echo server, and automatically add settings for the reverse proxy.

Modify the code in the Program.cs file of the aspire apphost project that uses the reverse proxy and echo server as follows:

 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}

And modify the Program.cs file of the reverse proxy project as follows:

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

Add destination settings for the 8 increased echo server instances. Now, the reverse proxy has destination information for the increased echo servers and can forward requests. If you access the existing http://localhost:3000/?name=world, you should still be able to confirm that Hello, world is output.

Conclusion

In this article, I explained the process of adding a Go server to dotnet aspire and forwarding requests through a reverse proxy. However, I have not yet fully written about scaling, and I have created an example that can be implemented more programmatically using environment variables in a separate repository. For detailed project configuration and code, please refer to snowmerak/AspireStartPack.

Personally, I expect that dotnet aspire can perform its own role as an alternative to docker compose and as a cloud deployment tool. A generator that creates docker compose or k8s manifests already exists, and I believe that it has improved the accessibility of infrastructure tools for general developers.