DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

Goによるロードテスト

はじめに

SWETグループGoチームの金子 (@theoden9014) です。

弊社が運営するライブコミュニケーションアプリであるPococha(ポコチャ)においてロードテストを実施する際、Go言語を利用して独自のロードテストツール開発しました。今回は、その知見を共有したいと思います。

この記事はDeNA Advent Calendar 2020の4日目の記事です。

本記事ではシステムをWebシステムと前提としていますのでご注意ください。

ロードテストとは (Load Testing)

JSTQBISTQBにおいては性能テスト (Performance Testing) の1つと定義されています。 性能テストとはシステムテストにおける非機能テストの1つです。 ロードテスト、耐久性テスト、ストレステスト等があり、それぞれ目的に応じて実施します。

ロードテストは、システムがユーザ負荷のピーク時でも継続して稼働できるか検証します。 サーバ等のインフラも含めて本番規模のシステム構築するケースが多く、非常にコストが掛かる検証です。 よって、システム導入時や大規模なシステム改修時など、要所要所で単発的に実施する事が多いです。

検証する観点はサービスの性質やシステムの特性によって違うので一般化することは難しいです。例えば、想定されるスパイクアクセスに耐えられるか、想定される同時アクセス数を捌く事ができるかのような観点で検証します。

ロードテストはパフォーマンスの目標値をまず最初に設定します。この目標値を満たすことができるかロードテストツール使って検証し、満たしていない場合はシステム全体でボトルネックとなっている箇所を特定し、そのボトルネックを改善します。

この検証、分析、改善のサイクルを目標値を満たすまで繰り返し行います。

ロードテストにおいてボトルネックの特定は最も重要な要素です。 ロードテストを実施する際にはロードテストツールのメトリクスでは不十分で、システムの可観測性を高めておく必要があります。 しかし、今回は記事の主旨がロードテストツール側にあるので、検証対象のシステムについての可観測性については割愛させていただきます。

ロードテストツールの選定

最初に設定した目標値を元に、具体的な負荷シナリオや変動させるパラメータの設計をします。

静的なリクエストを生成するにはapachebenchvegetaなどのツールがあります。 ロジックに基づいたリクエストパラメータの生成やURLの生成などの、動的なリクエストを生成するにはGatlingJMeterLocust等のツールがあります。

このようなロードテストツールは他にもいくつかあります。 しかし、どうしてもユースケースに合わなかったり、欲しい機能が存在しなかったりといったケースが発生することもあります。 そういった場合は既存ツールを拡張するか、独自でロードテストツールを開発することになります。

今回のロードテストの要件

今回は今後想定されるユーザ数のリクエストを問題なく捌けるか検証したかったので、重要機能においての特定ユースケースのロードテストを実施しました。 同時利用ユーザ数、APIエンドポイント毎の秒間実行回数を目標値として設定し、その他変動させる必要のあるパラメータの設計をしました。

今回は様々な要件がありましたがその中でも難しかったのは、キャッシュのヒット率を下げるためにユーザの行動に重み付けをした上でランダム性を持たせなければならない、というところでした。例えば、ライブ視聴中におけるユーザの行動を挙げると以下のようなパラメータです。

  • コメントやアイテムやいいねの送信、他ユーザへの拍手、等々の行動の選択
  • 送信するコメント内容、アイテムの種類、拍手する対象ユーザの選択

認証やライブ視聴中のKeepAliveなど他にも技術的な要件や課題がいくつかあり、既存のロードテストツールでそれらを実現しようとすると通常の機能だけでは難しいと判断し、今回は使い慣れているGo言語を使ってロードテストツールを独自で実装することにしました。

Goによるロードテストツールの実装

Goではgoroutineとchannelを利用することで並行処理をシンプルに記述できます。 今回は以下のようなアーキテクチャで開発しました。

20200930130505

全てのコードの解説は行えないので、今回はポイントとなるコンポーネントであるEvent (+EventGenerator) とDispatcherを抜粋してご紹介します。 EventGeneratorではリクエストパラメータにランダム性を担保させるようにし、Dispatcherではそれを実行するユーザにランダム性を担保させるようにしています。

まずEventについてです。 Eventは処理を行う単位としており、行いたい処理の種類によって構造体とそれを生成するための関数を定義していきます。

一定の間隔でランダムなパラメータの生成を行い、channelに対してEventを送信します。

import (
    "context"
    "time"
)

type Event interface {
    Emit(ctx context.Context, c *APIClient) error
    Name() string
}

type commentEvent struct {
    // イベント生成側と処理側で共有するパラメータをここに記述します
    text string
}

// イベントの処理内容、複数のAPIで整合性が必要な処理もここにまとめます
// ここでは認証などの必要な初期化処理を行ったAPIClient(ユーザ)を再利用したいので引数で渡せるようにしています
func (e *sendCommentEvent) Emit(ctx context.Context, c *APIClient) error {
    if err := c.LiveViewing.SendComment(ctx, e.text, e.toUserID); err != nil {
        return err
    }
    return nil
}

// トレーシングや統計情報を集計する際に別のイベントと区別する為のイベント名
func (e *sendCommentEvent) Name() string {
    return "request: send comment"
}

// 任意のユーザがコメントを送信するイベントを発生させます
func commentEventGen(ctx context.Context, d time.Duration) chan Event {
    // EventGenはd間隔でイベントを生成して戻り値のチャネルに対して送信します
    return EventGen(ctx, d, func(tm time.Time, ev chan Event) {
    // 今回は適当にイベント生成時の時間を生成しています
    // ランダム性のあるパラメータはここで担保できるようになります
        ev <- &commentEvent{
            text: tm.String(),
        }
    })
}

// EventGenはイベントを一定間隔で生成する為の関数を生成する為のヘルパー関数
// 第三引数の関数でパラメータを与えてEventを生成してチャネルに送信する処理を記述します
func EventGen(ctx context.Context, d time.Duration, f func(time.Time, chan Event)) chan Event {
    eventChan := make(chan Event)
    if d == 0 {
        return eventChan
    }

    go func() {
        ticker := time.NewTicker(d)
        defer ticker.Stop()

        for {
            select {
            case t := <-ticker.C:
                f(t, eventChan)
            case <-ctx.Done():
                close(eventChan)
                return
            }
        }
    }()

    return eventChan
}

次に、生成されたイベント毎のchannelを1つに集約し、各ワーカーに対して処理を委任していく役割であるDispatcherについてです。 このDispatcherは一番重要な箇所です。 様々な種類のEventを1つのchannelにまとめます。その1つのchannelを複数のワーカーが受信することによって偏りを持たせずに処理を行えるようになります。

1ワーカー1ユーザとなるようにしているので、これによって各ユーザが実行するイベントの種類にランダム性を持たせてることが可能になります。 今回は記事の尺の都合上、他コンポーネントについては割愛させていただくのでinterfaceとして定義しております。

import (
    "context"
    "sync"
)

type Worker interface {
    WaitEvent(context.Context) Event
    Process(context.Context, Event) error
    Close() error
}
type WorkerPool interface {
    Get() Worker
    Put(Worker)
}

type Dispatcher struct {
    mu     sync.RWMutex
    inputs []<-chan Event

    pool WorkerPool
}

// 処理するイベントのchannelをセットします
// 複数のchannelをセットすることが可能です
func (d *Dispatcher) In(ev chan Event) {
    d.mu.Lock()
    defer d.mu.Unlock()
    d.inputs = append(d.inputs, ev)
}

type WorkerFunc func(context.Context, Event) error

// これを利用してWorker追加時に、WorkerFuncのクロージャ内にAPIClientを閉じ込めることによって
// 1Worker---1APIClient--1User の紐付けを行うことができます。
func (d *Dispatcher) NewWorker(id string, f WorkerFunc, r <-chan Event) {
    w := &worker{
        id: id,
        f:  f,
        r:  r,
    }
    d.pool.Push(w)
}

// Startはchannelを繋ぎ合わせ、設定したWorkerを起動していくメソッドです
func (d *Dispatcher) Start(ctx context.Context) {
    // d.inputs のチャネルを1つのチャネルに集約していきます
    input := make(chan Event, len(d.inputs))
    for _, evc := range d.inputs {
        evc := evc
        go func(evc <-chan Event) {
            for {
                select {
                case ev := <-evc:
                    input <- ev
                case <-ctx.Done():
                    return
                }
            }
        }(evc)
    }

    // WorkerPoolからWorkerを取得して、
    // それぞれのWorkerをgoroutineでイベントループで起動していきます
    for w := d.pool.Get(); w != nil; w = d.pool.Get() {
        w := w
        go func() {
            defer w.Close()
            for {
                select {
                case ev := <-input:
                    if ev == nil { // closed Event channel
                        return
                    }
                    w.Process(ctx, ev)
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
}

これらのコードを以下のように繋ぎ合わせることで、複数種類のイベントを、パラメータと実行するユーザにランダム性を持たせることができます。

// 第二引数でイベントの生成間隔を指定、
// それ以降の引数はGeneratorの生成アルゴリズムによって任意の値を指定
cChan := commentEventGen(ctx, 1/10*time.Second)
iChan := itemEventGen(ctx, 1/20*time.Second, itemIds...)
.
.
.
dispatcher := NewDispatcher()
dispatcher.In(cChan)
dispatcher.In(iChan)
.
.
.

// テスト用のユーザデータは予め用意しておいて読み込むようにしておきます
for _, user := range users {
    // クライアントの認証や、
    // その他細かいクライアント毎のセットアップ処理
    auth := NewDebugAuthenticator(user)
    // 今回はアプリケーションの負荷を確認したかったのでコネクションプールを共通化していますが、
    // インフラ側のテストも兼ねて共通化したくない場合は、
    // APIクライアント毎にコネクションプールを作った方が良いでしょう。
    client := NewAPIClient(baseURL, http.DefaultClient, auth)
    // 生成されたイベントはchannelを通じて、
    // クロージャの第二引数であるEventに渡ってきます
    dispatcher.NewWorker(user.Name, func(ctx context.Context, ev Event) error {
        return ev.Emit(ctx, client)
    })
}

dispatcher.Start(ctx)

ボトルネックの可視化

runtime/traceパッケージ

Goでは標準パッケージのruntime/traceを利用することでトレーシングを行うことができます。これを使うとランタイムレベルのトレースと、ユーザアノテーションを使ったトレーシングを行うことができます。 今回はユーザアノテーションを使ったトレーシングについてお話ししたいと思います。

ユーザアノテーションをするにはtrace.Tasktrace.Regionの2つを使って計測したい区間を自分で定義していきます。

trace.Taskはgoroutineを跨いで計測できます。 trace.Regionを使うとgoroutine内の細かいトレース情報を取得できます。しかし、計測範囲をgoroutine内に絞る必要があります。

トレーシングの実施方法

runtime/traceは常にトレーシングを行っているわけではなく、ランタイム内のトレーシングフラグが有効な際にトレーシングが行われます。 以下の2つの方法でトレーシングをフラグを有効にして結果を取得できます。

1つはテスト時にトレーシングを行う方法です。go test -trace=${OUTPUT_FILE}フラグを追加することで${OUTPUT_FILE}にトレース結果のファイルを生成できます。

もう1つはプログラム実行中にhttpハンドラー経由でトレーシングを行う方法です。net/http/pprofを空インポートをするとhttp.DefaultMuxにトレーシングとプロファイリングのエンドポイント用のハンドラーが追加されます。 http.ListenAndServe(":8080", nil) 等でHTTPサーバを立ち上げて:8080/debug/pprof/traceへアクセスするとデフォルトで30秒間、その間だけトレーシングを実施して結果を取得できます。

これらのトレース結果ファイルはgo tool trace ${OUTPUT_FILE} コマンドでWebUIから確認できます

ロードテストツールでの利用

今回はruntime/traceを利用してイベントの種類(今回はAPIエンドポイント)毎にRegionを定義しました。 go tool traceを使うとWebUIから以下のようにイベントの種類毎の処理時間分布を確認できるので、処理が遅くなるエンドポイントを特定できました。(以下の画像はサンプル用に用意したものです)

20201202210836

OpenCensusOpenTelemetryのような分散トレーシングを使っても良いとは思いますが、今回はオーバースペックだと判断したので利用はしませんでした。

参考までにですが、以下のようなMakefileを用意しておくと簡単にプロファイルとトレーシング結果を取得できます。

GO          := go
PPROF_URL   := http://localhost:6060/debug/pprof
DEBUG_TYPES := profile.pdf goroutine.pdf heap.pdf block.pdf mutex.pdf trace.out
DEBUGDIR    := ./debug

.PHONY: debug
debug: debug/clean $(DEBUG_FILES)

.PHONY: debug/clean
debug/clean:
    @rm -f $(DEBUGDIR)/*

$(DEBUGDIR)/%.pdf:
    $(GO) tool pprof -pdf -output $@ $(PPROF_URL)/$*?seconds=30

$(DEBUGDIR)/%.out:
    curl -fsL -o $@ $(PPROF_URL)/$*?seconds=30
    $(GO) tool trace $@

まとめ

これらによって、負荷を生成しつつAPIエンドポイント毎の処理時間の分布が簡単に確認できるようになり、ボトルネックとなっているAPIエンドポイントを特定できました。

このツールはiOSアプリのパフォーマンステストでも利用しているので、興味がある方はこちらの記事もご覧ください。

ご紹介したソースコードは実装の概要をご紹介する為に一部簡略化している箇所や書き換えている箇所がございますのでご注意ください。他にも、実際はgolang.org/x/time/rateを利用してトークンバケット方式でバースト制御して想定外のスパイク負荷を発生させないようにしたりといった様々な工夫をしておりますが今回は省略させて頂いております。

このようにGoを利用するとロードテストツールもシンプルに実装可能です。 みなさまがパフォーマンステストを行う際の選択肢の1つとして助けになると幸いです。

私事ではありますが、汎用化を目的としてパフォーマンステストフレームワークを趣味で開発しているので、もし興味がございましたらご覧ください。

https://github.com/theoden9014/evbundler

関連リンク

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします! また DeNA 公式 Twitter アカウント @DeNAxTech では、Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!