GoSuda

dotnet aspireにおける拡張可能なGoサーバーの実行を試みる

By snowmerak
views ...

dotnet aspire?

dotnet aspire는 클라우드 네이티브 환경이 증가함에 따라, 개발자들의 클라우드 네이티브 개발 및 구성을 지원하기 위해 고안된 도구입니다. 이 도구는 .NET 개발자들이 닷넷 프로젝트와 다양한 클라우드 네이티브 인프라, 그리고 다른 언어로 작성된 서비스나 컨테이너 등을 용이하게 배포할 수 있도록 합니다.

당연히 docker에서 k8s까지 배포 및 운영이 가능하며, 기존의 온프레미스 환경에서 상당수의 분야, 산업, 개발자들이 클라우드 네이티브 환경으로 이전하고 있거나 이미 이전한 상태입니다. 이제는 성숙한 분야입니다. 따라서 호스트 이름, 포트 구성, 방화벽, 메트릭 관리 등에 대한 기존의 불편함에 대해서는 추가적인 설명이 필요 없을 것이라고 판단됩니다.

그러므로 위의 설명만으로는 dotnet aspire가 무엇인지 정확히 이해하기 어려울 것입니다. 이는 Microsoft에서도 명확한 정의를 내리지 않고 있기 때문입니다. 따라서 저 또한 별도의 정의를 내리지는 않겠습니다. 다만, 본 글에서는 제가 이해한 dotnet aspire의 기본적인 기능을 사용할 것이므로, 이를 참고하여 독자 여러분께서 스스로의 관점을 정립하시면 좋을 듯합니다.

프로젝트 구성

dotnet aspire 프로젝트 생성

만약 dotnet aspire 템플릿이 존재하지 않는다면, 템플릿부터 설치해야 합니다. 다음 명령어를 사용하여 템플릿을 설치하십시오. 만약 .NET이 설치되어 있지 않다면, .NET 설치를 먼저 진행해주시기 바랍니다.

1dotnet new install Aspire.ProjectTemplates

그리고 적절한 폴더에서 새로운 솔루션을 생성합니다.

1dotnet new sln

그 후, 솔루션 폴더에서 다음 명령어를 실행하여 aspire-apphost 템플릿의 프로젝트를 생성합니다.

1dotnet new aspire-apphost -o AppHost

그러면 설정을 위한 간단한 코드만 포함된 aspire-apphost 프로젝트가 생성됩니다.

Valkey 추가

다음으로 Valkey를 추가해보겠습니다.

추가하기에 앞서, dotnet aspire는 community hosting을 통해 다양한 서드파티 솔루션을 제공합니다. Valkey 또한 이러한 커뮤니티 호스팅 지원을 받을 수 있으며, 다음 NuGet 패키지를 통해 손쉽게 이용할 수 있습니다.

1dotnet add package Aspire.Hosting.Valkey

이 외에도 다양한 통합 호스팅을 제공하므로, 이곳에서 확인하실 수 있습니다. 이제 Valkey로 돌아와서, AppHost 프로젝트의 Program.cs 파일을 열어 다음과 같이 수정합니다.

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 서비스를 빌드할 수 있는 정보를 포함하는 IResourceBuilder 인터페이스의 구현체입니다.WithDataVolume은 캐시 데이터를 저장할 볼륨을 생성하며, WithPersistence는 캐시 데이터를 지속적으로 저장할 수 있도록 설정합니다. 여기까지 보면 docker-composevolumes와 유사한 역할을 수행하는 것으로 보입니다. 물론 이러한 기능은 여러분께서도 어렵지 않게 구현하실 수 있습니다. 하지만 본 글의 범위를 벗어나므로 여기서는 다루지 않겠습니다.

Go 언어 에코 서버 생성

다음으로 간단한 Go 언어 서버를 추가해보겠습니다. 먼저 솔루션 폴더에서 go work init 명령어를 실행하여 워크스페이스를 생성합니다. 닷넷 개발자에게 Go 워크스페이스는 솔루션과 유사한 개념으로 이해할 수 있습니다.

그리고 EchoServer라는 폴더를 생성하고, 해당 폴더로 이동한 후 go mod init EchoServer 명령어를 실행합니다. 이 명령어를 통해 Go 모듈을 생성합니다. 모듈은 닷넷 개발자에게 프로젝트와 유사한 개념으로 인지할 수 있습니다. 다음으로 main.go 파일을 생성하고 다음과 같이 작성합니다.

 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}

이 서버는 Aspire AppHost가 실행될 때, listening에 사용할 PORT 환경 변수를 주입해주면, 해당 포트를 읽어 서버를 실행합니다. 간단하게 name 쿼리를 받아서 Hello, {name}을 반환하는 서버입니다.

이제 이 서버를 dotnet aspire에 추가해보겠습니다.

에코 서버를 aspire에 추가하기

Valkey를 추가했던 Aspire AppHost 프로젝트로 다시 이동하여 Go 언어를 위한 커뮤니티 호스팅을 추가합니다.

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

그리고 Program.cs 파일을 열어 다음 구문을 추가합니다.

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

여기서 echoServer는 Go 언어 서버를 빌드할 수 있는 정보를 포함하는 IResourceBuilder 인터페이스의 구현체입니다. 추가된 AddGolangApp 메서드는 Go 언어 서버 추가를 위한 커스텀 호스트의 확장 메서드입니다. 고정적으로 3000 포트를 사용하며, PORT 환경 변수를 주입하는 것을 확인할 수 있습니다. 마지막으로 WithExternalHttpEndpoints는 외부에서 접근할 수 있도록 설정하는 메서드입니다.

테스트를 위해 http://localhost:3000/?name=world로 접속해보면, Hello, world가 출력되는 것을 확인할 수 있습니다.

그러나 현재 dotnet aspire에는 non-dotnet 프로젝트에 대해 무거운 페널티가 존재합니다. 그것은 바로...

프로젝트 확장

수평 확장은 그럼 어떻게 해야 할까?

현재 dotnet aspire는 AddProject 메서드로 추가된 닷넷 프로젝트의 빌더에만 WithReplica 옵션을 제공합니다. 하지만 Go 언어 호스트나 AddContainer와 같은 외부 프로젝트에 대해서는 이 옵션을 제공하지 않습니다.

따라서 별도의 로드 밸런서나 리버스 프록시를 사용하여 직접 구현해야 합니다. 그러나 이 경우 해당 리버스 프록시가 SPOF(Single Point of Failure)가 될 수 있으므로, 리버스 프록시 또한 WithReplica 옵션을 제공하는 것이 바람직합니다. 그렇다면 필연적으로 리버스 프록시는 닷넷 프로젝트여야 합니다.

지금까지 이러한 문제에 대해 nginx, trafik, 직접 구현 등의 방법을 사용해왔지만, 닷넷 프로젝트라는 제약이 추가되면 현재로서는 해결책이 없었습니다. 그래서 닷넷으로 구현된 리버스 프록시를 찾아보았고, 다행히 YARP라는 대안을 찾을 수 있었습니다. YARP는 닷넷으로 구현된 리버스 프록시로, 로드 밸런서 역할도 수행할 수 있으며, 다양한 기능을 제공하므로 좋은 선택이라고 판단했습니다.

그럼 이제 YARP를 추가해보겠습니다.

YARP를 이용한 리버스 프록시 구성

먼저 YARP를 사용하기 위한 프로젝트를 생성합니다.

1dotnet new web -n ReverseProxy

그리고 프로젝트로 이동하여 YARP를 설치합니다.

1dotnet add package Yarp.ReverseProxy --version 2.2.0

설치가 완료되면, Program.cs 파일을 열고 다음과 같이 작성합니다.

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

이 코드는 YARP를 사용하기 위한 기본적인 코드입니다.routes는 리버스 프록시가 사용할 라우트 정보를, clusters는 리버스 프록시가 사용할 클러스터 정보를 포함합니다. 이 정보는 LoadFromMemory 메서드를 통해 리버스 프록시에 로드됩니다. 마지막으로 MapReverseProxy 메서드를 사용하여 리버스 프록시를 매핑하고 실행합니다.

그리고 실제 사용을 위해 aspire apphost 프로젝트에서 리버스 프록시 프로젝트를 참조로 추가하고, Program.cs 파일에 다음 구문을 추가 및 수정합니다.

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

이제 리버스 프록시는 에코 서버를 참조할 수 있습니다. 외부에서 들어오는 요청은 리버스 프록시에서 받고 에코 서버로 전달하는 구조로 변경됩니다.

리버스 프록시 수정

먼저 리버스 프록시에 할당된 프로젝트의 listening 주소를 변경해야 합니다.Properties/launchSettings.json 파일 내부의 applicationUrl을 제거합니다. 그리고 Program.cs 파일을 열어 다음과 같이 전체적으로 수정합니다.

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

먼저 routesclusters에 대한 정보를 수정합니다. 각각 echo-routeecho-cluster를 추가하여 에코 서버로 요청을 전달하도록 설정합니다. 그리고 에코 서버의 주소를 환경 변수로부터 읽어와서 사용하도록 수정합니다.

이 주소의 규칙은 services__{service-name}__http__{index}입니다. 에코 서버의 경우, 서비스 이름이 echo-server이고, 단일 인스턴스이므로 인덱스로 0을 사용합니다. 만약 asp .net core 서버를 추가한다면, WithReplica를 통해 여러 인스턴스가 생성될 수 있으므로 인덱스를 증가시켜 사용하면 됩니다. 예외 처리되어 있는 http://localhost:8080은 임의의 값입니다.

이제 프로젝트를 실행하고, http://localhost:3000/?name=world로 접속해보면, 여전히 Hello, world가 출력되는 것을 확인할 수 있습니다.

확장 아이디어

이제 dotnet aspire에 Go 서버를 추가하고, 리버스 프록시를 통해 요청을 전달하는 것을 확인했습니다. 그러면 이제 이 과정을 프로그래밍 방식으로 구현할 수 있도록 확장할 수 있을 것입니다. 예를 들어, 에코 서버에 대해 서비스 이름 뒤에 번호를 추가하여 여러 인스턴스를 생성하고, 리버스 프록시에 대한 설정을 자동으로 추가할 수 있습니다.

aspire apphost 프로젝트의 Program.cs 파일에서 리버스 프록시와 에코 서버를 사용하는 코드를 다음과 같이 수정합니다.

 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}

그리고 리버스 프록시 프로젝트의 Program.cs 파일을 다음과 같이 수정합니다.

 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개로 늘어난 에코 서버 인스턴스에 대한 목적지 설정을 추가합니다. 이제 리버스 프록시는 늘어난 에코 서버들에 대한 목적지 정보를 가지고, 요청을 전달할 수 있게 되었습니다. 기존의 http://localhost:3000/?name=world로 접속해보면, 여전히 Hello, world가 출력되는 것을 확인할 수 있습니다.

마치며

본 글에서는 dotnet aspire에 Go 서버를 추가하고, 리버스 프록시를 통해 요청을 전달하는 과정을 설명했습니다. 다만 확장과 관련된 부분은 아직 완벽하게 작성하지 않았으며, 환경 변수를 통해 좀 더 프로그래밍 방식으로 구현할 수 있는 예제를 별도의 저장소에 작성해 놓았습니다. 자세한 프로젝트 구성 및 코드는 snowmerak/AspireStartPack을 참고해주시기 바랍니다.

개인적으로 dotnet aspire가 docker compose의 대안으로서, 그리고 클라우드 배포 도구로서 고유한 역할을 수행할 수 있을 것으로 기대합니다. 이미 docker compose나 k8s manifest를 생성하는 제너레이터가 존재하므로, 일반 개발자가 인프라 도구에 더 쉽게 접근할 수 있게 되었다고 생각합니다.