GoSuda

dotnet Aspireで拡張可能なGoサーバーを実行してみる

By snowmerak views ...

dotnet aspire?

dotnet aspireは、クラウドネイティブ環境の普及に伴い、開発者のクラウドネイティブ開発および構成を支援するために作られたツールです。このツールは、.NET開発者が.NETプロジェクトやさまざまなクラウドネイティブインフラ、そして他の言語で書かれたサービスやコンテナなどを容易にデプロイできるようにします。

当然ながら、dockerからk8sまでがリリースされ運用されており、従来のオンプレミス環境から、非常に多くの分野、産業、開発者がクラウドネイティブ環境へ移行しており、すでに移行が完了している状態です。もはや成熟した分野と言えるでしょう。そのため、ホスト名、ポート構成、ファイアウォール、メトリクス管理などに関して、従来の不便さを改めて説明する必要はないと考えます。

そのため、上記の説明だけでは、dotnet aspireが何なのか到底つかめないと思います。なぜなら、これはMicrosoftも正確な定義を示していないからです。ですので、私も特別な定義は下しません。ただし、この記事では私が理解したdotnet aspireの基本的な機能を用いますので、それを参考にして、ご自身なりの位置づけを決めていただければと思います。

プロジェクト構成

dotnet aspireプロジェクトの作成

dotnet aspireのテンプレートがない場合は、まずテンプレートをインストールする必要があります。次のコマンドでテンプレートをインストールします。.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ファイルを開いて、次のように修正します。

 1// 組み込みの設定を用いてビルダーを作成する
 2var builder = DistributedApplication.CreateBuilder(args);
 3
 4// Valkeyサービスを追加し、データボリュームと永続化を設定する
 5var cache = builder.AddValkey("cache")
 6    .WithDataVolume(isReadOnly: false)
 7    .WithPersistence(interval: TimeSpan.FromMinutes(5),
 8        keysChangedThreshold: 100);
 9
10builder.Build().Run();

cacheは、valkeyサービスをビルドするための情報を持つIResourceBuilderインターフェースの実装体です。WithDataVolumeはキャッシュデータを保存するためのボリュームを作成し、WithPersistenceはキャッシュデータを継続的に保存できるようにします。ここまで見ると、docker-composeのvolumesと似た役割を果たしているように見えます。当然のことながら、これは皆さまも難なく作成できるでしょう。ただし、この記事の範囲を超えるため、今は取り上げません。

Go言語のエコーサーバーの作成

では、簡単なGo言語サーバーを追加してみましょう。まず、ソリューションフォルダでgo work initを実行し、ワークスペースを作成します。 .NET開発者にとって、Goのワークスペースはソリューションに似たものだと考えていただければよいです。

次に、EchoServerというフォルダを作成し、その中に移動してからgo mod init EchoServerを実行します。このコマンドによってGoモジュールが作成されます。モジュールは.NET開発者にとってのプロジェクトに似たものと認識していただければよいです。そしてmain.goファイルを作成し、次のように記述します。

 1// パッケージmainを宣言する
 2package main
 3
 4import (
 5	"log"
 6	"net/http"
 7	"os"
 8)
 9
10func main() {
11	// PORT環境変数からリッスンするポートを取得する
12	addr := os.Getenv("PORT")
13	log.Printf("Server started on %s", addr)
14
15	// ルートパスへのリクエストに対してnameクエリを読み取り、応答を返す
16	http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
17		name := request.URL.Query().Get("name")
18		writer.Write([]byte("Hello, " + name))
19	})
20
21	// 指定されたポートでHTTPサーバーを起動する
22	http.ListenAndServe(":"+addr, nil)
23}

このサーバーは、Aspire AppHostの実行時にリッスンすべきPORT環境変数が注入されると、そのポートを読み取ってサーバーを起動します。簡単に言えば、nameクエリを受け取ってHello, {name}を返すサーバーです。

では、このサーバーをdotnet aspireに追加してみましょう。

エコーサーバーをAspireに追加する

先ほどValkeyを追加したAspire AppHostプロジェクトに戻り、Go言語のためのコミュニティホスティングを追加します。

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

そして、Program.csファイルを開き、次の構文を追加します。

 1// 組み込みの設定を用いてビルダーを作成する
 2var builder = DistributedApplication.CreateBuilder(args);
 3
 4// Valkeyサービスを追加し、データボリュームと永続化を設定する
 5var cache = builder.AddValkey("cache")
 6    .WithDataVolume(isReadOnly: false)
 7    .WithPersistence(interval: TimeSpan.FromMinutes(5),
 8        keysChangedThreshold: 100);
 9
10// Goアプリケーションを追加し、PORT環境変数を注入して外部からアクセス可能にする
11var echoServer = builder.AddGolangApp("echo-server", "../EchoServer")
12    .WithHttpEndpoint(port: 3000, env: "PORT")
13    .WithExternalHttpEndpoints();
14
15builder.Build().Run();

ここでechoServerは、Go言語サーバーをビルドするための情報を持つIResourceBuilderインターフェースの実装体です。先ほど追加したAddGolangAppメソッドは、Go言語サーバーを追加するためのカスタムホストの拡張メソッドです。固定的に3000番ポートを使用し、PORT環境変数を注入していることが確認できます。最後に、WithExternalHttpEndpointsは外部からアクセスできるようにするものです。

テストのためにhttp://localhost:3000/?name=worldにアクセスすると、Hello, worldが出力されることを確認できるでしょう。

しかし、現在のdotnet aspireには、non-dotnetプロジェクトに課せられる重いペナルティがあります。それは、まさに...

プロジェクトの拡張

水平スケーリングはどうすればよいのか?

現在、dotnet aspireはAddProjectメソッドで追加された.NETプロジェクトのビルダーにのみWithReplicaオプションを提供しています。しかし、Go言語ホストやAddContainerのような外部プロジェクトについては、このオプションを提供していません。

そのため、別途load balancerやreverse proxyを使用して直接実装しなければなりません。しかし、そうすると当該reverse proxyがSPOFになりうるため、reverse proxyにはWithReplicaオプションを提供してもらうのが望ましいです。そうなると、必然的にreverse proxyは.NETプロジェクトでなければなりません。

これまで、このような問題に対してnginx、traefik、直接実装などの方法を使ってきましたが、.NETプロジェクトという制約がかかると、私の手元にはすぐに使える方法がありませんでした。そこで、.NETで実装されたreverse proxyを探したところ、幸いにもYARPという選択肢がありました。 YARPは.NETで実装されたreverse proxyで、load balancerの役割も果たすことができ、さまざまな機能を提供しているため、良い選択だと判断しました。

それでは、YARPを追加してみましょう。

YARPによるreverse proxyの構成

まず、YARPを使用するためのプロジェクトを作成します。

1dotnet new web -n ReverseProxy

次に、プロジェクトに移動してYARPをインストールします。

1dotnet add package Yarp.ReverseProxy --version 2.2.0

インストールが完了したら、Program.csファイルを開き、次のように記述します。

 1// YARPの構成型を参照する
 2using Yarp.ReverseProxy.Configuration;
 3
 4// 組み込みの設定を用いてWebアプリケーションのビルダーを作成する
 5var builder = WebApplication.CreateBuilder(args);
 6
 7// ルートとクラスターの情報を格納するリストを作成する
 8var routes = new List<RouteConfig>();
 9var clusters = new List<ClusterConfig>();
10
11// メモリ上の設定からreverse proxyを読み込む
12builder.Services.AddReverseProxy()
13    .LoadFromMemory(routes, clusters);
14
15var app = builder.Build();
16
17// reverse proxyをマッピングし、PORT環境変数で指定されたポートで実行する
18app.MapReverseProxy();
19app.Run(url: $"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "5000"}");

このコードは、YARPを使用するための基本的なコードです。routesはreverse proxyが使用するルート情報を、clustersはreverse proxyが使用するクラスター情報を保持しています。これらの情報はLoadFromMemoryメソッドによってreverse proxyに読み込まれます。最後に、MapReverseProxyメソッドを使用してreverse proxyをマッピングし、実行します。

そして、実際の利用のために、aspire apphostプロジェクトにreverse proxyプロジェクトを参照として追加し、Program.csファイルに次の構文を追加および修正します。

1dotnet add reference ../ReverseProxy
1// Goアプリケーションを追加し、PORT環境変数を注入する
2var echoServer = builder.AddGolangApp("echo-server", "../EchoServer")
3    .WithHttpEndpoint(env: "PORT");
4
5// reverse proxyプロジェクトを追加し、echoServerを参照させ、外部からアクセス可能にする
6var reverseProxy = builder.AddProject<Projects.ReverseProxy>("gateway")
7    .WithReference(echoServer)
8    .WithHttpEndpoint(port: 3000, env: "PORT", isProxied: true)
9    .WithExternalHttpEndpoints();

これで、reverse proxyがecho serverを参照できるようになりました。外部から入ってくるリクエストはreverse proxyで受け取り、echo serverへ受け渡す構造に変わりつつあります。

reverse proxyの修正

まず、reverse proxyプロジェクトに割り当てられたリスニングアドレスを変更する必要があります。Properties/launchSettings.jsonファイル内のapplicationUrlを削除します。そして、Program.csファイルを開き、以下のように大幅に修正します。

 1// YARPの構成型を参照する
 2using Yarp.ReverseProxy.Configuration;
 3
 4// 組み込みの設定を用いてWebアプリケーションのビルダーを作成する
 5var builder = WebApplication.CreateBuilder(args);
 6
 7// echo serverへリクエストを送るためのルート設定を作成する
 8var routes = new List<RouteConfig>
 9{
10    new RouteConfig
11    {
12        ClusterId = "cluster-echo",
13        RouteId = "route-echo",
14        Match = new RouteMatch
15        {
16            Path = "/"
17        }
18    }
19};
20
21// 環境変数からecho serverのアドレスを読み取る(存在しない場合は既定値を使用する)
22var echoServerAddr = Environment.GetEnvironmentVariable("services__echo-server__http__0") ?? "http://localhost:8080";
23
24// echo serverを宛先とするクラスター設定を作成する
25var clusters = new List<ClusterConfig>
26{
27    new ClusterConfig
28    {
29        ClusterId = "cluster-echo",
30        Destinations = new Dictionary<string, DestinationConfig>
31        {
32            { "destination-echo", new DestinationConfig { Address = echoServerAddr } }
33        }
34    }
35};
36
37// メモリ上の設定からreverse proxyを読み込む
38builder.Services.AddReverseProxy()
39    .LoadFromMemory(routes, clusters);
40
41var app = builder.Build();
42
43// reverse proxyをマッピングし、PORT環境変数で指定されたポートで実行する
44app.MapReverseProxy();
45app.Run(url: $"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "5000"}");

まず、routesとclustersに関する情報を修正します。それぞれecho-routeとecho-clusterを追加し、echo serverへリクエストを送信するように設定します。そして、echo serverのアドレスを環境変数から読み取って使用するように修正します。

このアドレスの規則はservices__{service-name}__http__{index}です。 echo serverの場合、サービス名はecho-serverであり、単一インスタンスなので、インデックスとして0を使用します。もしasp .net coreサーバーを追加する場合、WithReplicaによって複数のインスタンスが作成されうるため、インデックスを増やして使用すればよいです。例外処理として設定されているhttp://localhost:8080は、何の意味もないゴミの値です。

それでは、プロジェクトを実行してhttp://localhost:3000/?name=worldにアクセスすると、相変わらずHello, worldが出力されることを確認できるでしょう。

拡張のアイデア

これで、dotnet aspireにGoサーバーを追加し、reverse proxyを通じてリクエストを転送することを確認しました。では、この過程をprogrammaticに実装できるように拡張することができるでしょう。例えば、echo serverについてサービス名の後ろに番号を付けて複数のインスタンスを作成し、reverse proxyの設定を自動的に追加することができます。

aspire apphostプロジェクトのProgram.csファイルで、reverse proxyとecho serverを利用するコードを次のように修正します。

 1// reverse proxyプロジェクトを追加し、外部からアクセス可能にする
 2var reverseProxy = builder.AddProject<Projects.ReverseProxy>("gateway")
 3    .WithHttpEndpoint(port: 3000, env: "PORT", isProxied: true)
 4    .WithExternalHttpEndpoints();
 5
 6// 8つのecho serverインスタンスを作成し、それぞれをreverse proxyに参照させる
 7for (var i = 0; i < 8; i++)
 8{
 9    var echoServer = builder.AddGolangApp($"echo-server-{i}", "../EchoServer")
10        .WithHttpEndpoint(env: "PORT");
11    reverseProxy.WithReference(echoServer);
12}

そして、reverse proxyプロジェクトのProgram.csファイルを次のように修正します。

 1// 8つのecho serverインスタンスそれぞれの宛先設定を格納する辞書を作成する
 2var echoServerDestinations = new Dictionary<string, DestinationConfig>();
 3for (var i = 0; i < 8; i++)
 4{
 5    // 環境変数から各インスタンスのアドレスを読み取って宛先として登録する(存在しない場合は既定値を使用する)
 6    echoServerDestinations[$"destination-{i}"] = new DestinationConfig
 7    {
 8        Address = Environment.GetEnvironmentVariable($"services__echo-server-{i}__http__0") ?? "http://localhost:8080"
 9    };
10}
11
12// 8つの宛先をまとめたクラスター設定を作成する
13var clusters = new List<ClusterConfig>
14{
15    new ClusterConfig
16    {
17        ClusterId = "cluster-echo",
18        Destinations = echoServerDestinations
19    }
20};

8つに増えたecho serverインスタンスについて、宛先の設定を追加しました。これで、reverse proxyは増加したecho serverの宛先情報を保持し、リクエストを転送できるようになりました。従来のhttp://localhost:3000/?name=worldにアクセスすると、相変わらずHello, worldが出力されることを確認できるでしょう。

おわりに

この記事では、dotnet aspireにGoサーバーを追加し、reverse proxyを通じてリクエストを転送する過程を説明しました。ただし、拡張についてはまだすべてを書いてはおらず、環境変数を通じてよりprogrammaticに実装できる例を別のリポジトリに作成しておきました。詳しいプロジェクト構成とコードについては、snowmerak/AspireStartPackをご参照ください。

私個人としては、dotnet aspireがdocker composeの代替として、またクラウドデプロイツールとして、独自の役割を果たせるものと期待しています。すでにdocker composeやk8s manifestを生成するジェネレーターも存在しており、一般の開発者がインフラストラクチャツールにより容易にアクセスできるようになったのではないかと考えています。