🐟

launce - LocustのシナリオをGoで書くためのライブラリを作った

2024/06/07に公開

はじめに

負荷試験ツールには JMeter, Locust, k6 など色々なものがありますが、私は Locust を好んで使っています。
Locust の優れている点としては以下のような点が挙げられます。

  • Web UI が便利
  • 分散実行が容易
  • シナリオをコードで記述可能
  • システムの Python 処理系で動くため、任意のライブラリを使用可能

Locust のこれらの特徴は非常に魅力的なのですが、実際に使っていると Python なので遅いというところが弱点となります。
ワーカーを大量に並べて殴るという方法もあるとは言え、リソースは有効に使いたいです。

Locust のパフォーマンスを改善する方法の 1 つとして boomer というライブラリがあります。
ワーカーを Go で書くことで、負荷試験シナリオを高速に実行できるというものです。
この boomer は Locust の機能を丸々提供するというよりは、 Locust のプロトコルに乗っかって関数を実行できるというような趣になっています。
そのため、例えばユーザーの概念がないためユーザーごとに状態を持たせるようなシナリオの実装が難しかったり、 Locust では取得できる情報が取得できないなど、使っていて困る場面がたびたびありました。

そこで Locust のように負荷試験シナリオを書けることと、高速に動作することを目指して、新しく Go のライブラリを作りました。

launce

launce は Locust のワーカーの仕組みを提供するライブラリです。
マスターの機能は持っていないので、 Locust でマスタープロセスを起動し、 Go で実装したワーカーをマスターに接続するという形で実行します。

https://github.com/qitoi/launce

特徴

launce は Go で Locust らしく Locust のシナリオを書けるようにすることを目的に作成したライブラリです。
Locust でシナリオを書く上での主な機能は一通りサポートしています。

  • WorkerRunner
  • User
  • TaskSet
  • WaitTime 関数
  • カスタムメッセージ
  • カスタム引数

一方、 Locust にある HttpUser, GrpcUser などは用意していません。リクエスト処理は自前で実装する必要があります。
LocalRunner 相当の機能も用意していませんので、ローカルで実行する際もマスターとワーカーをそれぞれ起動する必要があります。

使い方

既に述べたように launce はマスターの機能を提供していません。
マスターとして起動する Locust 用の locustfile.py と、 launce を使用したシナリオを実装する必要があります。

ここでは、ユーザーの行動をそのまま書き下すシンプルなシナリオと、 Locust のようにタスクセットを使用したシナリオの実装方法を説明します。

シンプルなユーザーのシナリオ

1. locustfile.py を作成する

まずは Locust に読み込ませるダミーの locustfile.py を作成します。
実際のユーザーの振る舞いは Go で記述するので、実装は空のクラスを定義するだけです。

locustfile.py
from locust import User

class MyUser(User):
	...
2. Go でシナリオを実装する

launce ではユーザーを定義し、それをワーカーに登録することでシナリオを実装します。

ユーザーは launce.User インターフェースを満たす必要がありますが、 launce.BaseUserImpl という構造体を用意しているので、これを埋め込むのが簡単です。
launce.BaseUserImpl を埋め込む場合は、別途 WaitTime メソッドと Process メソッドを実装し、 launce.BaseUser インターフェースを満たす必要があります。

以下が launce.BaseUserImpl を使用したソースコードの例です。

package main

import (
	"context"
	"log"
	"math/rand"
	"os/signal"
	"syscall"
	"time"

	"github.com/qitoi/launce"
)

var (
	_ launce.BaseUser = (*MyUser)(nil)
)

type MyUser struct {
	launce.BaseUserImpl
}

// Wait メソッドでスリープする時間を決定する
func (u *MyUser) WaitTime() launce.WaitTimeFunc {
	return launce.Constant(1 * time.Second)
}

// ユーザーの振る舞いを定義する
// テストが停止されるまで繰り返し実行され、エラーの場合も Exception として方向された上で実行が継続する
func (u *MyUser) Process(ctx context.Context) error {
	s := time.Now()

	// ここでリクエストなど、計測する処理を実行する
	time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)

	responseTime := time.Since(s)
	contentLength := rand.Int63n(1024 * 1024)

	// リクエストの結果を記録
	u.Report("GET", "/hello", responseTime, contentLength, nil)

	// WaitTime メソッドで指定した時間でスリープ
	if err := u.Wait(ctx); err != nil {
		return err
	}

	return nil
}

func main() {
	// マスターに接続するためのトランスポートを作成し、ワーカーを作成する
	transport := launce.NewZmqTransport("localhost", 5557)
	worker, err := launce.NewWorker(transport)
	if err != nil {
		log.Fatal(err)
	}

	// ユーザーをワーカーに登録する
	// ユーザー名は locustfile.py で定義したユーザークラス名と合わせる必要がある
	worker.RegisterUser("MyUser", func() launce.User {
		return &MyUser{}
	})

	// ワーカープロセスを終了させる場合 worker.Quit を呼ばないとマスターにゴミが残ってしまう
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		<-ctx.Done()
		worker.Quit()
	}()

	// マスターに接続し、ワーカーを開始する
	if err := worker.Join(); err != nil {
		log.Fatal(err)
	}
}
3. 負荷試験の実行

マスター、ワーカーそれぞれのプログラムを用意したので、後は実行するだけです。
Locust のマスターとして起動した後、 launce で実装したワーカーを起動するとマスターにワーカーが接続されます。

# master プロセスの起動
locust --master -f locustfile.py
# worker プロセスの起動
go run .

Web UI を開くと WORKERS: 1 となり、ワーカーが接続されていることが確認できます。
負荷試験を開始すると、ワーカーでシナリオが実行され、 GET /hello のエントリーが表示されます。

タスクセットを使ったシナリオ

Locust にはタスクセットという階層化された Web サイトの負荷試験のシナリオを書きやすくするための機能があります。

https://docs.locust.io/en/stable/tasksets.html

launce ではこのタスクセットの機能も用意しています。
タスクセットを使用するには、 launce.BaseUserImpl を使用したユーザーを実装する代わりに taskset.UserImpl を埋め込みます。
ユーザーが使用するタスクセットを TaskSet メソッドで定義することで、このタスクセットに従ってシナリオが実行されます。

package main

import (
	"context"
	"net/http"
	"time"

	"github.com/qitoi/launce"
	"github.com/qitoi/launce/taskset"
)

var (
	_ taskset.User = (*MyUser)(nil)
)

type MyUser struct {
	taskset.UserImpl
}

func (u *MyUser) WaitTime() launce.WaitTimeFunc {
	return launce.Between(100*time.Millisecond, 200*time.Millisecond)
}

func (u *MyUser) TaskSet() taskset.TaskSet {
	// Locust の TaskSet に相当する、タスクをランダムに実行するタスクセット
	return taskset.NewRandom(
		// 各タスクに実行するウェイトを指定
		taskset.Weight(taskset.TaskFunc(task1), 1),
		taskset.Weight(taskset.TaskFunc(u.task2), 2),
		taskset.Weight(
			// Locust の SequentialTaskSet に相当する、タスクを順次実行するタスクセット
			// タスクセットは入れ子にすることが可能
			taskset.NewSequential(
				taskset.TaskFunc(u.seq1),
				taskset.TaskFunc(u.seq2),
				&seq3{},
			),
			1,
		),
	)
}

func task1(_ context.Context, u launce.User, _ taskset.Scheduler) error {
	u.Report(http.MethodGet, "/task1", launce.NoneResponseTime, 0, nil)
	return nil
}

func (u *MyUser) task2(_ context.Context, _ launce.User, _ taskset.Scheduler) error {
	u.Report(http.MethodGet, "/task2", launce.NoneResponseTime, 0, nil)
	return nil
}

func (u *MyUser) seq1(_ context.Context, _ launce.User, _ taskset.Scheduler) error {
	u.Report(http.MethodGet, "/seq/1", launce.NoneResponseTime, 0, nil)
	return nil
}

func (u *MyUser) seq2(_ context.Context, _ launce.User, _ taskset.Scheduler) error {
	u.Report(http.MethodGet, "/seq/2", launce.NoneResponseTime, 0, nil)
	return nil
}

type seq3 struct{}

func (s *seq3) Run(_ context.Context, u launce.User, _ taskset.Scheduler) error {
	u.Report(http.MethodGet, "/seq/3", launce.NoneResponseTime, 0, nil)
	// InterruptTaskSet を返すことで現在の TaskSet の実行を終了する
	// タスクセットは終了させるためのエラーを返さない限り、親のタスクセットには戻らず現在のタスクセットが繰り返し実行される
	return taskset.InterruptTaskSet
}

このシナリオを実行すると、以下のような結果が得られます。
ウェイトとして設定した通り task1, task2 および SequentialTaskSet がそれぞれ 1, 2, 1 の割合で実行されています。

その他の機能

WaitTime 関数

Locust に用意されているものと同様の WaitTime 関数を用意しています。

  • launce.Between
  • launce.Constant
  • launce.ConstantPacing
  • launce.ConstantThroughtput

https://docs.locust.io/en/stable/api.html#module-locust.wait_time

イベント

ハンドラーを設定することで、テストシナリオの実行中に発生するイベントに合わせて任意の処理を実行できます。

  • Worker.OnConnect: ワーカーがマスターに接続された
  • Worker.OnTestStart: テストを開始した
  • Worker.OnTestStopping: テストが停止される
  • Worker.OnTestStop: テストが停止した
  • Worker.OnQuitting: ワーカーが終了する
  • Worker.OnQuit: ワーカーが終了した
  • User.OnStart: ユーザーが開始した
  • User.OnStop: ユーザーが停止した
  • TaskSet.OnStart: タスクセットが開始した
  • TaskSet.OnStop: タスクセットが停止した

https://github.com/qitoi/launce/blob/master/_examples/events
https://docs.locust.io/en/stable/api.html#event-hooks

カスタムメッセージ

マスター/ワーカー間で任意のメッセージのやりとりをするためのメソッドを用意しています。
Worker.SendMessage でマスターにメッセージを送信し、マスターからのメッセージは Worker.RegisterMessage で登録したハンドラーによって受け取ることができます。

https://github.com/qitoi/launce/blob/master/_examples/custom-messages
https://docs.locust.io/en/stable/running-distributed.html#communicating-across-nodes

カスタム引数

マスタープロセスの Locust で設定したコマンドラインオプション、およびカスタム引数を取得することができます。
Locust の parsed_options に相当する機能です。

https://github.com/qitoi/launce/blob/master/_examples/custom-arguments
https://docs.locust.io/en/stable/extending-locust.html#custom-arguments

パフォーマンス比較

Locust, boomer, launce で同じ内容のシナリオを実行して、パフォーマンスを比較してみます。
比較に使用したソースコードは以下のリポジトリに置いています。

https://github.com/qitoi/launce-benchmark

環境

ベンチマークは以下の EC2 インスタンス、実行環境、ライブラリを使用して実行しました。

Master Node: EC2 c7i.large (Ubuntu 20.04.6 LTS)
Worker Node: EC2 c7i.2xlarge (Ubuntu 20.04.6 LTS)
Python: 3.12.3
Locust: 2.28.0
Go: 1.22.3
boomer: master (07b799451751)
launce: v1.1.0

ワーカーの実行速度の比較

スリープ無しでマスターにひたすらダミーのレポートを投げ続けるというシナリオを用いて、ワーカーがどれだけ高速に動作するかを比較します。
Locust はワーカーが 1 プロセスの場合と、コア数分の 8 プロセスの場合で実施しています。

結果


ワーカーの実行速度の比較

launce はなかなか良いパフォーマンスが出せていると言えるのでないでしょうか。

ユーザー数が 10, 100 の場合に不自然に性能が劣化していますが、これは launce の作りによるものです。

launce のリクエスト情報の集約の仕組み

Locust のワーカーの実装で一番ボトルネックとなるのが、各ユーザーのリクエストの情報をマスターに送信するために統計情報として集約する処理です。単純に排他処理をしてしまうとシナリオの実行がそこで詰まってしまいます。
launce では大量のユーザーに対応するため、一定数のユーザーごとに集約プロセスを起動して分散できるようにしています。
集約プロセス 1 つに割り当てるユーザー数のデフォルト値が 100 人のため、今回のシナリオでは集約プロセスの処理能力を各ユーザーが取り合ってしまい、ユーザー数が 1 の場合と比べてパフォーマンスが下がります。

集約プロセス 1 つに 1 ユーザーを割り当てるように変更し、ベンチマークを実行した結果が以下のグラフです。


集約プロセスへのユーザー割り当て数を変更した場合の launce のパフォーマンス

ユーザー数 1, 10 の場合にパフォーマンスが改善している反面、 10,000 以上で劣化が見られます。
実際の負荷試験ではこのベンチマークのようにレポートが休みなく送られることはないでしょうから、あえて変更する必要はないかと思います。

ワーカーの並列性能の比較

タスク間に 100 ms のスリープを挟み、ユーザー単位では余裕のある状態でレポートを投げるシナリオです。
このシナリオではワーカーがどれだけのユーザー数に耐えられるかを比較します。

結果


ワーカーの並列性能の比較

1,000 ユーザーまではどのワーカーも理論値近い結果が出ていますが、 10,000 ユーザーを超えると差が出ています。
この比較でも launce は比較的良い結果を出せているようです。

まとめ

launce という Go で Locust のシナリオを書くためのライブラリを作りました。
Locust は好きだけど Python はなぁ…、という方は使ってみてください。

Discussion