Running a Scalable Go Server with .NET Aspire

dotnet aspire?
dotnet aspire is a tool created to assist developers in cloud-native development and configuration as the cloud-native environment continues to grow. This tool enables .NET developers to easily deploy .NET projects, various cloud-native infrastructures, services written in other languages, and containers.
Naturally, as releases and operations range from Docker to Kubernetes (K8s), a significant number of fields, industries, and developers have migrated or are currently migrating from traditional on-premise environments to cloud-native environments. It is now a matured domain. Therefore, I believe there is no need to explain the traditional inconveniences associated with host names, port configurations, firewalls, and metric management.
Consequently, based on the descriptions above, you probably still cannot quite grasp what dotnet aspire actually is. This is because even Microsoft has not provided a precise definition for it. Therefore, I will not provide a definitive explanation either. However, since this article will utilize the fundamental functionalities of dotnet aspire as I understand them, you may refer to this and determine your own perspective.
Project Configuration
Creating a dotnet aspire Project
If the dotnet aspire templates are not installed, you must install them first. Install the templates using the following command. If you do not have .NET installed, please install it yourself.
1dotnet new install Aspire.ProjectTemplates
Next, create a new solution in an appropriate folder.
1dotnet new sln
Afterward, execute the following command within the solution folder to generate a project using the aspire-apphost template.
1dotnet new aspire-apphost -o AppHost
This generates an aspire-apphost project containing only simple code for initial setup.
Adding Valkey
Let us now add Valkey simply.
Before adding it directly, dotnet aspire provides various third-party solutions through community hosting.
Naturally, Valkey is also supported by this community hosting and can be easily utilized via the following NuGet package:
1dotnet add package Aspire.Hosting.Valkey
Various other integrated hosting options are provided, which can be checked 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 containing the information required to build the Valkey service.WithDataVolume creates a volume to store cache data, and WithPersistence enables persistent storage of the cache data.
Looking at this so far, it appears to serve a role similar to volumes in docker-compose.
Naturally, you can also construct this without difficulty.
However, as this falls outside the scope of this article, it will not be discussed further at this time.
Creating a Go Language Echo Server
Let us now add a simple Go language server.
First, initialize a workspace in the solution folder by running go work init.
For a .NET developer, a Go workspace can be understood as being similar to a solution.
Next, create a folder named EchoServer, navigate into it, and execute go mod init EchoServer.
This command generates a Go module. You can understand a module as being similar to a project for a .NET developer.
Then, 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 the Aspire AppHost runs, this server reads the PORT environment variable injected into it and starts the server on that specified port.
It is a simple server that receives a name query and returns Hello, {name}.
Let us now add this server to dotnet aspire.
Adding the Echo Server to Aspire
Navigate back to the Aspire AppHost project where Valkey was added, and add the community hosting package for the Go language.
1dotnet add package CommunityToolkit.Aspire.Hosting.Golang
Then, open the Program.cs file and add the following statements:
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 containing the information required to build the Go language server.
The AddGolangApp method just added is a custom host extension method for adding a Go language server.
It uses a fixed port of 3000 and injects the PORT environment variable.
Finally, WithExternalHttpEndpoints makes the service accessible from the outside.
For testing, if you access http://localhost:3000/?name=world, you will be able to confirm that Hello, world is printed.
However, there is currently a heavy penalty imposed on non-.NET projects in dotnet aspire.
That is...
Project Expansion
How Then Do We Handle Horizontal Scaling?
Currently, dotnet aspire only provides the WithReplica option for builders of .NET projects added via the AddProject method.
However, this option is not provided for external projects such as Go language hosts or AddContainer.
Consequently, one must implement a separate load balancer or reverse proxy manually.
However, since such a reverse proxy could become a single point of failure (SPOF), it is advisable for the reverse proxy to support the WithReplica option.
Thus, the reverse proxy inevitably needs to be a .NET project.
In the past, methods such as Nginx, Traefik, or custom implementations were used to address this issue, but given the restriction that it must be a .NET project, I had no immediate solution at hand.
Therefore, I searched for a reverse proxy implemented in .NET, and fortunately, YARP was available as an option.
YARP is a reverse proxy implemented in .NET that can also function as a load balancer and offers various features, leading me to conclude that it was an appropriate choice.
Let us now add YARP.
Configuring a Reverse Proxy with YARP
First, create a project to utilize 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:
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 foundational setup for utilizing YARP.routes contains the route information that the reverse proxy will use, and clusters contains the cluster information for the reverse proxy.
This information is loaded into the reverse proxy via the LoadFromMemory method.
Finally, the MapReverseProxy method is used to map and execute the reverse proxy.
For practical use, add a reference to the reverse proxy project within the Aspire AppHost project, and add and modify the following statements 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 architecture is being modified so that incoming external requests are received by the reverse proxy and forwarded to the echo server.
Modifying the Reverse Proxy
First, we must change the listening address of the project assigned to the reverse proxy.
Remove applicationUrl inside the Properties/launchSettings.json file.
Then, open the Program.cs file and substantially modify it as shown below:
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.
Configure them by adding echo-route and echo-cluster respectively, so that requests are routed to the echo server.
Also, modify the code to read and use the address of the echo server from the environment variables.
The naming convention 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, the index 0 is used.
If you were to add an ASP.NET Core server, multiple instances could be generated via WithReplica, in which case you would increment the index accordingly.
The fallback value http://localhost:8080 handled via exception logic is a meaningless placeholder value.
Now, if you run the project and access http://localhost:3000/?name=world, you will be able to verify that Hello, world is still outputted.
Scaling Ideas
We have now verified adding a Go server to dotnet aspire and forwarding requests through a reverse proxy.
We can now extend this process to be implemented programmatically.
For instance, we can generate multiple instances by appending numbers to the service name for the echo server, and automatically add the configurations for the reverse proxy.
Modify the code in the Program.cs file of the Aspire AppHost project to utilize 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}
Next, 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};
Destination settings are added for the eight increased echo server instances.
Now the reverse proxy possesses destination information for the expanded echo servers and can route requests to them.
If you access the existing http://localhost:3000/?name=world, you will confirm that Hello, world is still successfully printed.
Conclusion
In this article, the process of adding a Go server to dotnet aspire and forwarding requests through a reverse proxy was explained.
However, scaling has not been fully covered in its entirety here, and an example implementing a more programmatic approach using environment variables has been prepared in a separate repository.
For detailed project configuration and code, please refer to snowmerak/AspireStarterPack.
Personally, I expect dotnet aspire to fulfill its own role as an alternative to Docker Compose and as a cloud deployment tool.
Since generators that create Docker Compose or Kubernetes manifests already exist, I believe accessibility to infrastructure tools has become much greater for ordinary developers.