k6拡張機能を活用したgRPC Webサーバーの負荷試験

ogp

この記事はenechain Advent Calendar 2024の6日目の記事です。

はじめに

2024年10月9日にローンチしたeSquare Liveは、電力卸取引のオンラインマーケットプレイスです。 新規サービスであり利用者の限られるtoBのサービスではあるものの、リアルタイム電力取引場というプロダクトの特性上、ローンチ直後から一定水準以上のパフォーマンスが求められます。 スケールすることを意識して開発してきましたが、約10ヶ月の開発期間のほとんどを機能開発に費やしていたため、リリース直前の段階で実際どの程度のパフォーマンスが出ているのかの計測ができていない状況でした。

そこで、リリース前の1ヶ月を使って負荷試験を実施し、パフォーマンスの計測とそこで発見されたボトルネックの解消をすることとしました。 この記事では、k6の拡張機能開発を中心に、実際にリリース前に行ったk6によるgRPC WebのAPIサーバーへの負荷試験の話をします。

試験対象のサービスについて

最初に試験対象のサービスの構成について簡単に説明させていただきます。

取引所の機能を提供するAPIサーバーはGoで実装されたgRPCサーバーです。 Webブラウザからアクセスできるようにするため、Envoyをリバースプロキシとして立ててgRPC Webのリクエストを受け付けるようになっています。

他にも、市場でおきたイベントを配信するためのSocket.IOサーバーや、非同期イベントを捌くワーカーなどを立てていますが、ここではこのgRPC WebのAPIサーバーを負荷試験の対象として話を進めます。

gRPC Web

gRPC WebはブラウザからgRPCサーバーへアクセスできるようにすることを目的に作られたプロトコルです。

gRPCはHTTP/2の上で通信するRPCのプロトコルですが、 ブラウザではHTTP/2を直接扱うことが難しいので、gRPC WebのようなHTTP/1.1で動作するプロトコルを利用する必要があります。 前述したEnvoyのようなプロキシを噛ませる必要があったり、Client-sideとBi-directionalのStreamingが使えないなどの制約はありますが、gRPCを呼ぶのと同じようなインターフェースで利用できます。 メッセージフレーミングやContent-Typeなど、gRPC over HTTP/2との間にはプロトコルの差分がいくつかあり、gRPCのリポジトリにまとめられています。

k6

今回の試験に利用したgrafana/k6は、オープンソースのパフォーマンステストツールです。 利用者はJavaScriptでシナリオを記述し、k6のCLIを使ってこのスクリプトを実行することで簡単に負荷をかけることができます。

HTTP、WebSockets、gRPCといった複数のプロトコルでリクエストを送ることができますが、gRPC Webはデフォルトではサポートされていません。 今回は、リバースプロキシを含めたシステム全体のパフォーマンス測定とチューニングを行う目的があるため、k6のテストスクリプトからはgRPC Webのメッセージをprotobufでシリアライズして送受信できることが望ましいです。 幸いk6はGoで実行バイナリを拡張する方法を提供しているので、これを活用してk6を拡張し、gRPC Webサーバーの負荷試験を実施することにします。

k6 extensions

k6 extensionsの説明に先立って、k6がパフォーマンステストスクリプトをどのように実行するか確認しましょう。

k6のユーザはテストシナリオをJavaScriptで書きますが、このJavaScriptはGoで実装されたJavaScript runtimeであるgrafana/sobekを使って実行されます。 sobekはdop251/gojaからフォークしてGrafanaがメンテナンスをしているライブラリで、ECMAScript 5.1を純粋にGoのみで実装しています。 k6の実装を見ると、k6もGoのみで書かれたCLIツールで、runサブコマンドによる実行時にsobek.Runtimeを使ってJavaScriptを実行してることが分かります。 ポータビリティの高いGoのバイナリとしてツールを配布しつつ、利用者はJavaScriptで簡潔にスクリプトを書けるような設計になっています。

xk6は、JavaScript側から利用できるモジュールを拡張したカスタムk6バイナリを生成するためのツールです。 k6はgo.k6.io/k6/js/modules.RegisterをGoのinit関数で呼ぶことで、k6バイナリが実行するJavaScriptから呼べるmoduleを追加できます。 これは標準パッケージのdatabase/sqlでSQL Driverを追加するときにも見られるGoの実装パターンです。 xk6はこの機能を利用するk6のビルドツールです。 拡張機能を作りたい開発者は、init関数にてmodules.Registerを呼び出して作成したモジュールを登録するGoのパッケージを公開し、xk6でそのパッケージを指定してk6のバイナリをビルドすることになります。

簡単なサンプルとしてGoのlog/slogで標準出力に構造化ログを出力する拡張機能を作成してみます。

package slog

import (
    "log/slog"

    "go.k6.io/k6/js/modules"
)

func init() {
    modules.Register("k6/x/slog", new(module))
}

type module struct{}

func (m *module) NewModuleInstance(VU modules.VU) modules.Instance {
    return &instance{}
}

type instance struct{}

func (i *instance) Exports() modules.Exports {
    return modules.Exports{
        Default: slogExport{},
    }
}

type slogExport struct{}

func (slogExport) Info(msg string, data map[string]string) {
    var attrs []any
    for k, v := range data {
        attrs = append(attrs, slog.String(k, v))
    }
    slog.Info(msg, attrs...)
}

xk6 build --with xk6-slog=.のようにパッケージパスを指定して実行すると、k6/x/slogという名前のモジュールを利用できるk6バイナリが生成されます。 ここで作成したバイナリを利用すると、以下のようなテストスクリプトを実行できます。

import slog from "k6/x/slog";

export default () => {
  slog.info("Hello, eSquare Live!", {
    time: new Date().toISOString(),
  });
};

上記の例はあくまでサンプルですが、xk6を利用する便利な拡張機能はすでにコミュニティによって数多く実装されています。 SQLサーバーに対して負荷をかけるxk6-sql、Kubernetes Clusterを操作するxk6-kubernetes、フォールト・インジェクションを行うxk6-disruptorなどがあります。 k6に標準で用意されていない機能を使いたくなった場合は、自分で作成する前に拡張の一覧GithubのTopicに実装済みの拡張がないか探してみるとよいでしょう。

gRPC Webに対応したk6 extensionsの実装

gRPC Webに対応したextensionsの実装は現状無さそうだったので、今回は自分で書いていこうと思います。 gRPC向けのモジュールの実装はすでにk6本体に入っているのでそれを参考にインターフェースを決めて、内部のクライアントからはgRPC Webのリクエストを送信するようにします。 gRPC Webのリクエストを送る部分についてですが、connect-goのClientを内部では利用しています。 connect-goはConnectだけではなくgRPC, gRPC Webのリクエストも送信できるようになっており、GoでgRPC Webのリクエストを送りたい今回の用途に適しています。

作成したパッケージがshota3506/xk6-grpc-webになります。

k6でgRPCの負荷試験を行うときと同じ書き味でgRPC Webのリクエストを投げることができます。 以下にスクリプトのサンプルを記載しています。 protoファイルへのパスやgRPCサービス名は適宜読み替えてください。

import grpcweb from "k6/x/grpc-web";
import { check } from "k6";
import exec from "k6/execution";

const GRPC_WEB_ADDR = __ENV.GRPC_WEB_ADDR || "http://localhost:8080";

let client = new grpcweb.Client();

client.load([], "./server/helloworld/helloworld.proto");

export default () => {
  if (exec.vu.iterationInScenario === 0) {
    client.connect(GRPC_WEB_ADDR);
  }

  const response = client.invoke("/helloworld.Greeter/SayHello", {
    name: "eSquare Live",
  });
  check(response, {
    "status is OK": (r) => r && r.status === grpcweb.StatusOK,
  });
};

このスクリプトはshota3506/xk6-grpc-webを組み込んでビルドしたk6バイナリで実行できます。

$ xk6 build --with github.com/shota3506/xk6-grpc-web
$ ./k6 run script.js --duration 10s --vus 3


          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: script.js
        output: -

     scenarios: (100.00%) 1 scenario, 3 max VUs, 40s max duration (incl. graceful stop):
              * default: 3 looping VUs for 10s (gracefulStop: 30s)


     ✓ status is OK

     checks...............: 100.00% ✓ 40075       ✗ 0
     data_received........: 10 MB   1.0 MB/s
     data_sent............: 13 MB   1.3 MB/s
     grpc_req_duration....: avg=687.2µs  min=386.54µs med=614.83µs max=7.95ms p(90)=854.54µs p(95)=1.12ms
     iteration_duration...: avg=743.86µs min=418.45µs med=663.37µs max=8.09ms p(90)=933.05µs p(95)=1.2ms
     iterations...........: 40075   4007.232718/s
     vus..................: 3       min=3         max=3
     vus_max..............: 3       min=3         max=3


running (10.0s), 0/3 VUs, 40075 complete and 0 interrupted iterations
default ✓ [======================================] 3 VUs  10s

手元で立ち上げることができるgRPC Webのサーバーとスクリプトのサンプルを置いているので、気になる人はぜひ手元で動かして確認してみてください。

負荷試験の実施

ここで作成したxk6-grpc-webは、サービスローンチ前の負荷試験に利用し、パフォーマンスの計測とボトルネックの発見に役立てられました。 実際に行った負荷試験の設計とプロセスに関して簡単に説明します。

負荷試験の設計

リリース前の限られた期間での負荷試験であるため、過度に複雑なシナリオを避けて、重要なAPIに対して想定されうる簡単なシナリオを作成し試験を実施することにしました。 特に時間をかけて試験を実施したのが、1) 注文板の情報を取得するAPI、2) 取引所に対して新規で注文を発行するAPIです。

前者の注文板取得については、フロントエンドでのデータの持ち方の都合上、頻繁に呼ばれ、かつ特定のタイミングでスパイクすることが予想されます。 大量のクライアントから同時にリクエストが投げられるようなシナリオでの試験が重要になります。 また、このAPIのパフォーマンスは注文板に乗っている注文の数などに大きく影響されるため、注文板のコンディションを変えて複数パターン試験をすることとしました。

後者の注文発行処理については、リリース初期から大量のリクエストが飛んでくることは予想されないものの、注文の受付後にPub/Sub経由で実行される非同期の注文確定・マッチングの処理が重く、ここのパフォーマンスを測定する必要がありました。 負荷をかけるk6側では非同期処理の処理速度に関しては計測できないため、弊社が利用しているモニタリングサービスのDatadogでこれを計測し表示できるようにしました。 注文を受け付けるRPCの呼び出しが完了するまで、RPCの呼び出しのなかでPub/Subにメッセージを送信し非同期の処理が発火するまで、非同期の処理が完了するまでの時間をそれぞれ測れるように事前にログを仕込みダッシュボードを作りました。 加えて、ボトルネックの特定のために、トレースの送信やGoのProfilerの有効化、データベースとして利用しているGoogle Cloud SQLのQuery Insightsなどの整備をしました。

試験の実施とボトルネックの解消

テストシナリオの作成とモニタリングの準備が整ったら、これ以降は負荷試験の実施とボトルネックの解消の繰り返しです。 実行したシナリオはかなりシンプルだったものの、パフォーマンス上の問題を発見するには十分でした。 最初は、サーバー内部で利用していたインメモリのロックが悪さをして不要に待機時間が発生していたことと、このサービスが呼び出している別の社内サービスのAPIレスポンスの遅延がボトルネックになりました。 これらの問題を解消した後は、主にDBに関するチューニングに時間を当てました。 意図しない高負荷なSQLクエリの発行を止める、DBのインデックスを適切に張り替える、注文板取得時に頻繁に利用されるデータをキャッシュするなど一通りの改善をしました。 最後はSQLサーバーにつなぐコネクションのコネクションプールの設定の見直しや、Kubernetesリソースの増強まで一通りおこなって、リリースを迎えることができました。

負荷試験中に解消した問題はどれもリリース前に並行して実施されていたQAでは発見されなかったもので、改めてリリース前に負荷試験を実施することの重要性を実感しました。 また、副次的な効果にはなりますが、試験結果の確認のためにログ、メトリクス、トレースなどのモニタリングの整備ができたのも、きちんと負荷試験を実施した恩恵だと感じています。

試験を実施していく中で難しいと感じた点は、現実的なパフォーマンスの数値目標の設定と負荷シナリオの作成でした。 どのような負荷がかかったときにどの程度のレスポンス速度、スループットが出ればよいのか目標を決めあぐねてしまい、やや曖昧に数値目標を追いかけていたように感じます。 今後はプロダクトの実際の利用状況に則したより現実的なベンチマークシナリオを作成し、適切な目標設定をしたうえで負荷試験を実施していきたいと考えています。 また、分散負荷試験によるより高負荷な環境でのパフォーマンス計測や、負荷試験をリリースフローに組み込むなど、もう一歩踏み込んだ負荷試験の運用も検討しています。

おわりに

この記事では、k6 extensionsを利用してgRPC WebのAPIサーバーへの負荷試験を行う方法について紹介しました。 k6がGoでJavaScriptを実行する珍しい設計になっており、他の拡張を参考に拡張機能を自作するなかでその挙動を理解できたと思います。 ここで自作したツールはややニッチな用途での利用に限られますが、拡張機能は様々なシーンで活用できると思うので、k6を使った負荷試験の際には参考にしていただければと思います。

また、新規サービスローンチを前に負荷試験を実施した際の経験についても触れさせていただきました。 試験を実施したことの効果を実感するとともに、今後の負荷試験運用の改善点についても考える機会となりました。

herp.careers

herp.careers