DFrame - C#でテストシナリオを書く分散負荷テストフレームワーク

と、いうものをリリースしました。Web UIとなるDFrame.Controllerと、負荷テストシナリオをC#で書くDFrame.Workerの組み合わせで成り立っていて、DFrame.Workerをウェブ上のクラスターに配置することで(Controllerと接続するただの常駐アプリなので、配置先はオンプレでもVMでもコンテナでもKuberenetesでもなんでもいい)、1から数千のワーカーが連動して、大量のリクエストを発生させます。また、テストシナリオをプレーンなC#で記述できるということは、HTTP/1だけではなく、あらゆる種類の通信をカバーできます。WebSocket、HTTP/2、gRPC、MagicOnion、あるいはPhotonや自作のTCPトランスポート、更にはRedisやデータベースなどが対象になります。

DFrame.Workerは通常の.NETの他に、Unityにも対応しています!つまり、大量のHeadless Unity、あるいはデバイスファームに配置することで、Unityでしか動かないような独自通信フレームワークであっても負荷テストをかけることが可能です。

また、あまり注目されていませんが負荷テストツールにもパフォーマンスの違いは「かなり」あり、性能の良さは重要で、そこのところにもかなりチューニングしました。

image

Web UI(DFrame.Controller)はBlazor Serverで作られていて、分散ワーカーとの通信はMagicOnionで行っています。自動化のためのWeb APIの口もあるため、Blazor Server, ASP.NET Minimum API, MagicOnionのキメラ同居なアーキテクチャでC#でフル活用なのが設計的にも面白いポイントです。

C#で負荷テストシナリオを書く意義

負荷テストフレームワークは世の中に山のようにあります。代表的なものでもab, jMeter, k6, Artillery, Gatling, wrk, bombardier, Locust、k6やArtillery、GatlingなどはSaaSとしても提供していますし、クラウドサービス側も、Azure Load Testing(Managed jMeter)のようなマネージドサービスを出していますし、.NETでもdotnet/crankというものが存在していたりします。

DFrameはこの中でいうとアーキテクチャ含めLocustに近い(Controller-Worker構成やWebUIなど)のですが、その特徴の中で重要な点として挙げられているのが、シナリオをコードで書けること、です。よくわからんUIで設定させたり、複雑怪奇なXMLやYAMLやJSON書かせたりせず、プレーンなコードで書ける。これが大事。LocustはPythonですが、他にk6はJavaScriptで書けるようになっています。

じゃあLocustでいいじゃん、k6でいいじゃん、という話になるのですが、C#で書きたいんですね、シナリオを。これは別にただ単に自分の好きな言語で書きたいからというわけではなくて、サーバーあるいはクライアント言語と負荷試験シナリオ作成言語は同一のものであるべきだからです。例えばUnityのゲームを開発している場合(サーバーサイドの言語は何でもいい)、UnityのゲームはC#で記述されていますが、その場合C#でテストシナリオが書けるのなら

  • 最初からクライアントSDK(エンドポイントと型付きのRequest/Response)に相当するものがある
  • クライアントの実装と完全に等しいのでゲームのドメインロジックが最初からある

となります。それによりテストシナリオの記述の手間を大幅に削減できます。もちろん、Unity依存の部分を引き剥がすなどの追加の作業は必要ですが、完全に書き起こすなどといった無駄は発生しません。もしPythonでもJavaScriptでもLuaでも、とにかく異なる言語である場合は、比較にならないほどに作業量が膨大になってきます。

そして実際のクライアントコードとある程度共通になることで、サーバー/クライアント側の変化への追随が用意になります。それにより一回のリリースのための負荷テストではなく、継続的な負荷テスト環境を作っていけます。

また、プレーンなC#で記述できることで、冒頭にも書きましたがあらゆる通信の種類をカバーできるのは、通信プロトコルが多様化している昨今、大きな利点となります。

DFrameApp.Run

NuGetからDFrameをパッケージ参照したうえで、一行で起動します。テストシナリオ(Workload)の記述の行数もありますが、それでもこれだけで。

using DFrame;

DFrameApp.Run(7312, 7313); // WebUI:7312, WorkerListen:7313

public class SampleWorkload : Workload
{
    public override async Task ExecuteAsync(WorkloadContext context)
    {
        Console.WriteLine($"Hello {context.WorkloadId}");
    }
}

これで http://localhost:7312 をブラウザで開けば、SampleWorkloadがいます。

image

と、いうわけで、WorkloadのExecuteAsyncにコードを書くのが基本です。ExecuteAsync前の準備用としてSetupAsync、後始末としてTeardownAsyncもあります。単純なgRPCのテストを書くとこなります。

public class GrpcTest : Workload
{
    GrpcChannel? channel;
    Greeter.GreeterClient? client;

    public override async Task SetupAsync(WorkloadContext context)
    {
        channel = GrpcChannel.ForAddress("http://localhost:5027");
        client = new Greeter.GreeterClient(channel);
    }

    public override async Task ExecuteAsync(WorkloadContext context)
    {
        await client!.SayHelloAsync(new HelloRequest(), cancellationToken: context.CancellationToken);
    }

    public override async Task TeardownAsync(WorkloadContext context)
    {
        if (channel != null)
        {
            await channel.ShutdownAsync();
            channel.Dispose();
        }
    }
}

Concurrencyの数だけWorkloadが生成されて、Total Request / Workers / Concurrencyの数だけExecuteAsyncが実行されます。コードで書くと言っても別にそう複雑なこともなく、よくわからんDSLで書くわけでもないので、むしろ(C#が書けるなら)とても書きやすいでしょう。中身も見てのとおり単純なので、gRPCでもMagicOnionでも何でも実行できます。

引数を受け取ることも可能なので、任意のURLを渡すようなものも作れます。コンストラクタでは、パラメーター、あるいはDIでインジェクトしたインスタンスを受け取れます。

using DFrame;
using Microsoft.Extensions.DependencyInjection;

// use builder can configure services, logging, configuration, etc.
var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureServices(services =>
{
    services.AddSingleton<HttpClient>();
});
await builder.RunAsync();

public class HttpGetString : Workload
{
    readonly HttpClient httpClient;
    readonly string url;

    // HttpClient is from DI, URL is passed from Web UI
    public HttpGetString(HttpClient httpClient, string url)
    {
        this.httpClient = httpClient;
        this.url = url;
    }

    public override async Task ExecuteAsync(WorkloadContext context)
    {
        await httpClient.GetStringAsync(url, context.CancellationToken);
    }
}

image

WebUI画面にString urlの入力箇所が現れて、好きなURLを叩き込むことができるようになりました。

なお、単純なHTTPのGET/POST/PUT/DELETEをテストしたいという場合は、IncludesDefaultHttpWorkloadを有効にしてもらうと、内蔵のパラメーターを受け取るWorkloadが追加されます。

using DFrame;

var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureWorker(x =>
{
    x.IncludesDefaultHttpWorkload = true;
});
builder.Run();

分散テスト

Workerは起動時に指定したControllerのアドレスにHTTP/2(MagicOnion/gRPC)で繋ぎに行って、常駐します。という普通の(?)アプリケーションなので、ウェブサーバーを分散させるのと同様に複数のWorkerを立ち上げてもらえれば、自動的に繋がります。

構成としては、以下の画像のようにControllerとWorkerのプロジェクトを分けるのが正当派(?)ですが

同居させてしまって、起動時のコマンドライン引数でどちらかのモード(あるいは両方)が起動するようにすることも、ローカルでの開発がしやすくなるのでお薦めです。 DFrameApp.CreateBuilder にはそのための補助的な機構が用意されています。

using DFrame;

var builder = DFrameApp.CreateBuilder(5555, 5556); // portWeb, portListenWorker

if (args.Length == 0)
{
    // local, run both(host WebUI on http://localhost:portWeb)
    await builder.RunAsync();
}
else if (args[0] == "controller")
{
    // listen http://*:portWeb as WebUI and http://*:portListenWorker as Worker listen gRPC
    await builder.RunControllerAsync();
}
else if (args[0] == "worker")
{
    // worker connect to (controller) address.
    // You can also configure from appsettings.json via builder.ConfigureWorker((ctx, options) => { options.ControllerAddress = "" });
    await builder.RunWorkerAsync("http://foobar:5556");
}

ローカルでWorkerの.exeを複数実行する、とかでも手元でとりあえずのWorker connectionsが増える様は確認できます。

Workerを増やすと表がにぎやかになって楽しい。実行するWorkerの数はスライダーで調整できるので、各種パラメーターを台数1で調整したあとに、徐々に実行Workerを増やしていく、といった使い方も可能です。また、その辺を自動でやってくれるRepeatモード(TotalRequestとWorkerを完了後に指定数増やして繰り返す)も用意しました。jMeterでいうところのRamp-Upの代わりに使えればいいかな、という想定でもあります。

アーキテクチャ的に最初から分散前提で作られているというのもあり、増やしても性能が劣化しない、リニアに性能が向上していくように作りました。Controllerは単一なのでスケールしないのですが、なるべく多くのWorkerをぶら下げられるように工夫しています。Controller <-> WorkerはMagicOnionで通信しているので、DFrame自身がMagicOnionの負荷テストになっているのです。

パフォーマンス

多数ある負荷テストフレームワークですが、パフォーマンスはそれぞれかなり異なります。詳しくはk6のブログOpen source load testing tool review 2020に非常に詳細に書かれていますが、例えばとにかくwrkがぶっちぎって他の数十倍~数百倍速かったりする、と。パフォーマンスは当然ながらとても重要で、ワーガーの非力さでターゲットに負荷をかけきれなかったりします。それに対応するためクラスターを組んでいくにしても、多くの台数やより高いスペックのマシンが必要になって、色々と辛い。

というわけでパフォーマンスは高ければ高いほうがいいのですが、先のブログに書かれている通り、拡張性の口やレポート取り出しの口などは必要です。その点でWrkは機能を満たさないということで、ブログではなんか結果から取り除かれてますね(その対応がいいのかどうかはなんとも言えませんが、まぁk6自身のアピールのためでもあるのでしょうがないね)。ちなみにフレームワークのパフォーマンスの指標として使われているTechEmpower Web Framework Benchmarksの負荷クライアントはwrkのようです。

さて、で、DFrameはどうかというと、かなり良好です。というのも、DFrameはライブラリとして提供されて、実行時は全てがC#の実行ファイルとしてコンパイル済みの状態になるのですね。スクリプトを動的に読んで実行するから遅くなってしまう、みたいなことがない。比較的高速な言語であるC#をそのまま利用するので、その時点である程度はいける。理論上。理屈上。

と、いう甘い見込みのもと実際作っていくと、さすがにそこまでさっくりとはいかず、相応にチューニングが必要だったのですが、最終的にはかなりの数字が出るようになりました。比較としてabとk6で測ってみると

image

image

image

本来はターゲットとワーカーは別マシンにしないといけないのですが(ワーカーの負荷でCPUが跳ね上がる影響をサーバー側がモロに影響受けてしまうので)、それでもそれなりに数字は変動しますし動きはするしマシンパワーも結構強め(Ryzen 9 5950x)なので、ちょっと手抜きでlocalhost上の無を返すHTTP/1サーバーをターゲットに、32並列(-c 32, -32VUs, Concurrency=32)で実行。

abが、6287 req/sec、k6が125619 req/sec、DFrameが207634 req/secです。abは、厳しい、厳しい……。もっと出るはずと思っているんですが、私の環境(Windows)だと昔からこんな感じなので、性能的には信用できないかなぁ。Windowsだとダメだったりするのかもしないのかもしれませんね。DFrameの場合Concurrencyにまだ余裕があって、増やすとまだまだ伸びたのですが、k6は割と頭打ちでした。

また、画像は出してませんがLocustは残念ながらかなり遅い上にCPUを食いまくるという感じで(Pythonだしね……)、いくらクラスタ化が容易とはいえ、ここまで1ワーカーあたりの性能が低いと、ないかなあ、という感想です。JMeterはそこまで悪くはないですが、パフォーマンスに影響を与える地雷コンフィグを必死にかいくぐってなおそこそこ程度なのはしんどみ。

ちなみになんで圧倒的性能番長であるwrkと比較しないのかというと、Windowsで動かすのが大変だからです。すみません……。

自動化のためのREST API

最初はいいけど、毎回GUIでポチポチやるの面倒で、それはそれで嫌だよね。CIで定期的に回したりもできないし。というわけで、バッチ起動モード、はついていないのですが、代わりにREST APIが自動で有効になっています。例えば /api/connections で現在接続中のワーカーコネクション数が取れます。実行パラメーターなどはPostでJSONを投げる形になっています。

REST APIでJSONをやり取りするだけなので、どの言語から叩くことも可能ですが、C#の場合は DFrame.RestSdk パッケージにて型付けされたクライアントが用意されているので、手間なくはじめられます。

using DFrame.RestSdk;

var client = new DFrameClient("http://localhost:7312/");

// start request
await client.ExecuteRequestAsync(new()
{
    Workload = "SampleWorkload",
    Concurrency = 10,
    TotalRequest = 100000
});

// loadtest is running, wait complete.
await client.WaitUntilCanExecute();

// get summary and results[]
var result = await client.GetLatestResultAsync();

実行状況は全て連動しているので、REST APIから実行した進捗もWeb UI側でリアルタイムに状況確認できます。

Unityでも動く

Unityで動かしやすいかといったら全然そんなことないので、動かせるようにするのはもはや執念という感じではあるのですが、Unity対応しました。冒頭で書いたようにヘッドレスUnityを並べてコントロールする、みたいな用途は考えられます。まぁ、あと普通の負荷テストでも、通信部分のC#を普通の .NET に切り出すのが面倒だという場合に、ヘッドレスUnityでとりあえずビルドすることで何もしなくてもOK(そうか?)という策もあります。

Unityで動かす場合は、依存の解決(MagicOnion、gRPC、MessagePack for C#)が大変です!まぁ、それは置いておいて。それが出来ているなら、以下のようなMonoBehaviourに寿命をくっつけたインスタンスで起動させると良い感じです(MagicOnionというかネイティブgRPCは適切にコネクションをCloseしないとUnity Editorがフリーズするという酷い問題があるのですが、このコードは問題なくちゃんとクリーンアップしてくれるようになっています)。

public class DFrameWorker : MonoBehaviour
{
    DFrameWorkerApp app;

    [RuntimeInitializeOnLoadMethod]
    static void Init()
    {
        new GameObject("DFrame Worker", typeof(SampleOne));
    }

    private void Awake()
    {
        DontDestroyOnLoad(gameObject);
    }

    async void Start()
    {
        // setup your controller address
        app = new DFrameWorkerApp("localhost:7313");
        await app.RunAsync();
    }

    private void OnDestroy()
    {
        app.Dispose();
    }
}

[Preserve]
public class SampleWorkload : Workload
{
    public override Task ExecuteAsync(WorkloadContext context)
    {
        Debug.Log("Exec");
        return Task.CompletedTask;
    }

    public override Task TeardownAsync(WorkloadContext context)
    {
        Debug.Log("Teardown");
        return Task.CompletedTask;
    }
}

// Preserve for Unity IL2CPP

internal class PreserveAttribute : System.Attribute
{
}

Editor上の確認だとこんな具合です。

image

ライブラリかツールか

DFrame.Controller、他の設定を入れなければただのウェブアプリなので、ビルド済みのexeとしての提供も可能です。Locustなど他のツールも入れたら、とりあえず実行できる、のに比べると、必ず自分で組み込んでビルドしなきゃいけない。のは欠点に見える。

なのでビルド済みコンテナをDocker Hubかなんかで提供するという案もあったのですが、Workerはどうしても自分で組み込んでビルドする必要があるので、そこだけ省けても利点あるのかな?と考えて、最終的に却下しました。かわりに DFrameApp.Run の一行だけでController+Workerの同居が起動できるようにして、最初の一歩の面倒臭さをライブラリデザインの工夫で乗り切ることにしました。Controller自体も、Microsoft.NET.Sdk.Webではなく、コンソールアプリケーションのテンプレートのMicrosoft.NET.Sdkから起動できるようにしました。

DFrame.Controllerがライブラリとして提供されていることのメリットは、コンフィグが通常のコードやASP.NETの仕組みに乗っかったほうが圧倒的にシンプルになります。DIで好きなロガーを設定して、URLの指定やSSLなどもappsettings.jsonで行うのは、大量の複雑怪奇なコマンドラインオプションよりもずっと良いでしょう。

ログの永続化処理も、プラグイン的に用意するのではなく、普通にDIでインジェクトしてもらう(IExecutionResultHistoryProviderというものが用意されていて、これを実装したものをDIに登録してもらえば、結果をデータベースに入れたり時系列DBに入れたりして統計的な参照ができるようになります)ほうが、使いやすいはずです。

Blazor Server + MagicOnion

DFrame.ControllerはBlazor ServerとMagicOnion(grpc-dotnet)が同居した構成になっています。これは中々面白い構成で、Web UIとMagicOnion(Server側)が同じメモリを共有しているので、末端のMagicOnion(Client側)の変更をダイレクトにC#だけを通してブラウザにまで届けているんですね。逆もしかりで、APIからのアクセス含めて、全てがリアルタイムに伝搬して画面も同期しているのですが、普通にやるとかなり複雑怪奇になるはずが、かなりシンプルに実装できています。

と、いうわけで、Cysharpではこの組み合わせに可能性を感じていて、別のサービスも同種のアーキテクチャで絶賛制作中なので興味ありましたら以下略。

紆余曲折

最初のバージョンは2年ぐらい前に作っていました。コンセプトは「自己分裂する分散バッチフレームワーク」ということで、自分自身のコピーを動的に作って無限大に分散して実行していくというもので。分散のための基盤としてKubernetesを使って。クラウドネイティブ!かっこいい!そして、一応動くものはできていました。あとは仕上げていくだけ、といったところで、放置していました。完成させなきゃ、と思いつつ、内心薄々あんまいい感じではないな、と思っていたため手が進まず無限放置モードへ。そして時が流れ、社内でもがっつり使うことになり引っ張り出されてきたそれは、やはりあまりいい感じではなく。で、最終的に言われたんですね、そもそも分裂機能いらなくね?と。

それでようやく気づくわけです、コンセプトから完全に間違っているからうまくいくわけがない!

反省として良くなかった理由としては、まず、現代のクラウドコンピューターを過大に評価していた。「自己分裂する」のは、一瞬で無限大にスケールして即起動、そして終わったら即終了、ならば、まぁそれでいいんですが、現実のスケールする時間はそんなに立派じゃない。サーバーレスといいつつ、別に1リクエスト毎にコンテナが起動して処理するわけはなく、常駐してリクエストを待つ。そりゃそうだ、と。自己分裂のコンセプトだと、分裂コストが重たいのは否めない。

もう一つは分裂するためのコードがDFrame内に記述されている。Kuberentesをコントロールするコードがたっぷり入ってしまって。そのせいでコードサイズが膨らんでしまったし、使う方も複雑なコンフィグをDFrame側に埋めなきゃいけなくなってしまった。これは二重にイケてない。作るのも複雑で、使うのも複雑ですからね、いいところがない……。

と、いうわけで、最初のかっこいいコンセプトを否定して、自己分裂しない。単純に繋ぎに行くだけ。としたことで、頭を抱えてうまくいかないと感じていた行き詰まりは解消したのでした。

まとめ

もう少し早くに作って提供したかった、という後悔がめっちゃあるのですが、同時に .NET 6だから出来たという要素もめっちゃあるので(パラメーター渡しの仕組みなどはConsoleAppFramework v4の設計の経験からスムーズに実装できた)、しょーがない。という気もする。Blazor Serverなどの進化も必要だったし。

しかし↑で書いたとおり最初に立てたコンセプトが間違っていて、長いこと軌道修正できず放置してしまっていたというのは個人的には割と手痛い経験です……。まぁ、間違ったコンセプトのまま進行してしまうというのは別によくあるので、それはしょーがないものとして別にいいんですが、自力で気づいてパーッと作り上げられてたらなあ、みたいな、みたいな。。。

ともあれ、完成したものとしてはかなり良い感じで(私の出すものとしては珍しくUIもちゃんとついているし!←UI作業は他の人に助力を請うてます)、ちょっとニッチ感もありますがC#アプリケーション開発の必需品として成り得る出来だと思っていますので、ぜひぜひお試しください。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive