Running a Go Server Extensibly in dotnet aspire
dotnet aspire?
dotnet aspire is a tool designed to assist developers with cloud-native development and configuration, in response to the increasing prevalence of cloud-native environments. This tool enables .NET developers to effortlessly deploy .NET projects, various cloud-native infrastructures, and services or containers written in other languages.
Naturally, with the release and operation of everything from Docker to Kubernetes, a significant number of sectors, industries, and developers have transitioned or are in the process of transitioning from traditional on-premise environments to cloud-native environments. This is now a mature domain. Consequently, I believe there is no need to elaborate on the existing inconveniences related to hostname, port configuration, firewalls, and metric management.
Therefore, even based on the preceding descriptions, you will likely have no clear understanding of what dotnet aspire is. This is because Microsoft itself has not provided a precise definition. Thus, I will not offer a distinct definition either. However, as this article will utilize the fundamental functionalities of dotnet aspire as I comprehend them, you may refer to it to establish your own perspective.
Project Configuration
Creating a dotnet aspire Project
If the dotnet aspire template is not available, it must first be installed. Install the template using the following command. If .NET is not installed, please install it yourself.
1dotnet new install Aspire.ProjectTemplates
Then, create a new solution in an appropriate folder.
1dotnet new sln
Subsequently, within the solution folder, execute the following command to create a project based on the aspire-apphost template.
1dotnet new aspire-apphost -o AppHost
This action will generate an aspire-apphost project containing only the minimal code required for setup.
Adding Valkey
Let us now proceed to add Valkey.
Before directly adding it, note that dotnet aspire offers various third-party solutions through community hosting. Valkey, naturally, can benefit from this community hosting support and can be easily utilized via the following NuGet package.
1dotnet add package Aspire.Hosting.Valkey
Various other integrated hosting options are available and 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, which holds information necessary to build the Valkey service.WithDataVolume creates a volume to store cached data, and WithPersistence enables continuous storage of cached data.
From this perspective, it appears to perform a function similar to volumes in docker-compose.
Naturally, you can easily create this yourself.
However, this topic extends beyond the scope of this article, so 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, create a workspace in the solution folder using go work init.
For .NET developers, a Go workspace can be regarded as analogous to a solution.
Next, create a folder named EchoServer, navigate into it, and execute go mod init EchoServer.
This command generates a Go module. Developers familiar with .NET can consider a module to be analogous to a project.
Then, create a main.go file and populate it with 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}
This server, when the Aspire AppHost is executed, will read the PORT environment variable injected for listening and then run the server on that port.
It is a simple server that receives a name query and returns Hello, {name}.
Now, let us integrate this server into dotnet aspire.
Adding the Echo Server to Aspire
Return to the Aspire AppHost project, where Valkey was added, and incorporate community hosting 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, which contains information necessary to build the Go language server.
The recently added AddGolangApp method is a custom host extension method for adding Go language servers.
It is evident that it consistently uses port 3000 and injects the PORT environment variable.
Finally, WithExternalHttpEndpoints makes it accessible externally.
For testing, if you access http://localhost:3000/?name=world, you should observe "Hello, world" displayed.
However, current dotnet aspire imposes a significant penalty on non-dotnet projects. That is...
Project Extension
How to Achieve 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 available for external projects, such as Go language hosts or those added with AddContainer.
Consequently, it is necessary to implement this directly using a separate load balancer or reverse proxy.
However, this approach could render the reverse proxy a Single Point of Failure (SPOF); thus, it is preferable for the reverse proxy to offer the WithReplica option.
This inherently implies that the reverse proxy must be a .NET project.
While various methods such as Nginx, Traefik, or custom implementations have been employed to address this issue, the constraint of a .NET project left me without an immediate solution. Therefore, I sought a reverse proxy implemented in .NET, and fortunately, YARP presented itself as an option. YARP, being a reverse proxy implemented in .NET, could also function as a load balancer and offered a diverse range of features, making it a favorable choice.
Let us now proceed to incorporate YARP.
Configuring a Reverse Proxy with YARP
First, create a project for utilizing YARP.
1dotnet new web -n ReverseProxy
Then, navigate to the project and install YARP.
1dotnet add package Yarp.ReverseProxy --version 2.2.0
Once installed, open the Program.cs file and add 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 represents the fundamental structure for employing YARP.routes contains the routing information to be utilized by the reverse proxy, while clusters holds the cluster information.
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 application, add the reverse proxy project as a reference in the aspire apphost project, and then 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 such that incoming requests from external sources are received by the reverse proxy and then forwarded to the echo server.
Modifying the Reverse Proxy
Firstly, the listening address of the project assigned to the reverse proxy must be altered.
Remove the applicationUrl within the Properties/launchSettings.json file.
Then, open the Program.cs file and extensively modify it 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, the information for routes and clusters is modified.echo-route and echo-cluster are added, respectively, to configure requests to be sent to the echo server.
Additionally, the echo server's address is modified to be read from an environment variable.
The pattern for this address is services__{service-name}__http__{index}.
For 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 were added, multiple instances could be created via WithReplica, in which case the index would be incremented.
The exception-handled http://localhost:8080 is an arbitrary, meaningless value.
Now, if you execute the project and access http://localhost:3000/?name=world, you should still observe "Hello, world" being displayed.
Extension Ideas
We have now confirmed the integration of a Go server into dotnet aspire and the forwarding of requests through a reverse proxy. Consequently, this process can now be extended to be implemented programmatically. For instance, multiple instances of the echo server can be created by appending numbering to the service name, and the reverse proxy settings can be automatically configured.
Modify the code that uses the reverse proxy and echo server in the Program.cs file of the aspire apphost project 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};
Destination settings are added for the eight expanded echo server instances.
The reverse proxy now possesses destination information for the increased number of echo servers and can forward requests.
If you access the original http://localhost:3000/?name=world, you should still observe "Hello, world" being displayed.
Conclusion
This article has described the process of adding a Go server to dotnet aspire and forwarding requests through a reverse proxy. However, the discussion regarding extension is not yet exhaustive, and an example illustrating a more programmatic implementation using environment variables has been provided in a separate repository. For detailed project configuration and code, please refer to snowmerak/AspireStartPack.
I personally anticipate that dotnet aspire will fulfill its role as an alternative to Docker Compose and as a cloud deployment tool. The existence of generators for creating Docker Compose and Kubernetes manifests has, I believe, improved the accessibility of infrastructure tools for general developers.