DMM.comの、一番深くておもしろいトコロ。

k8s 上の負荷試験基盤でロードテストを効率化するために新機能を追加した話

k8s 上の負荷試験基盤でロードテストを効率化するために新機能を追加した話

  • このエントリーをはてなブックマークに追加

はじめに

この記事は、DMM グループ Advent Calendar 2022 12 日目の記事です。

プラットフォーム事業本部マイクロサービスアーキテクトグループの 2022 年度新卒入社のN9tE9です。

この記事では、DMM プラットフォーム事業本部で運用している k8s 上の負荷試験基盤でロードテストを効率化するために新機能を追加した話をします。

負荷試験基盤のアーキテクチャや概要については負荷試験基盤の初期リリースを終えた話で紹介されているので、そちらをご覧ください。

前提として、負荷試験基盤は locust と boomer を利用していて、master/worker 構成になっています。

ロードテストを効率化するための新機能を追加する過程について紹介します。

  • rps を指定できる機能
  • worker をスケールさせる機能

rps を指定できるようにする

既存の負荷試験基盤が抱えていた rps の指定に関する課題や、rps を指定できるようにするために技術的にどのように解決したのか、それによって負荷試験基盤利用者の作業負担がどのように減ったのかを説明します。 また、今回の新機能追加だけでは解決できなかった点を説明します。

負荷をかけるときに rps を指定して実行できなかった課題

負荷試験基盤は、locust で構成されているため 1worker の rps は、並列数(locust web UI の user)と spawn rate で調整できます。 これに加えて、worker の数を調整できる Pod 数の指定ができるようになっており、主に並列数と Pod 数で rps を調整できるようになっていました。 既存の負荷試験基盤で rps を調整する際は、並列数と Pod 数を調整していました。 ただ、並列数と Pod 数で目標の rps に調整するには試行を重ねる必要があり、この作業に時間がかかるといった課題がありました。

また、並列数と Pod 数の調整のみで rps を調整できない場合は、スリープ処理をタスクのスクリプト中に挟む必要がありました。

RateLimiter を用いて利用者から rps を指定できるようにする

負荷試験基盤では、boomer を用いることで負荷をかけるスクリプトを Go で記述できるようになっています。 利用者は、負荷をかけるためにリクエストを送る処理をタスク関数の中で記述します。

以下のように、利用者はタスク関数を実装します。

func GetRequestTask(client *http.Client) taskFunc {
  return func(ctx context.Context, l *zap.Logger, b *boomer.Boomer) {
    req, err := http.NewRequestWithContext(
      ctx,
      http.MethodGet,
      "http://nginx:80",
      nil)

    start := time.Now()
    res, err := client.Do(req)
    duration := time.Since(start).Milliseconds()

    if err != nil {
      errMsg := "サーバーとの通信に失敗しました"
      b.RecordFailure(sampleAPIGetRequestType, sampleAPIGetRequestName, duration, errMsg)
      l.Error(errMsg, zap.Error(err))
      return
    }

    defer res.Body.Close()
    return
  }
}

boomer は、上記のようなタスク関数の実行回数を制御する RateLimiter を提供しています。 この RateLimiter を利用して rps を指定できるようにしました。

RateLimiter は、時間当たりの実行回数を固定して制御する StableRateLimiter と徐々に時間当たりの実行回数を増やして上限で実行回数が固定になる RumpupRataLimiter があります。 今回は、前段階での調査やロードテストの効率化に StableRateLimiter で要件を満たせたので、StableRateLimiter を利用しています。

StableRateLimiter を使った rps を設定は、StableRateLimiter の利用例 に記述されています。 上記のリンクの以下の箇所で RateLimiter は設定されています。

  ratelimiter := boomer.NewStableRateLimiter(100, time.Second)
  log.Println("the max rps is limited to 100/s.")
  globalBoomer.SetRateLimiter(ratelimiter)

RateLimiter は、タスク関数の時間当たりの実行回数を制限するだけなので、直接的に rps を制御していません。 そのため、負荷試験基盤では、1 秒間に実行されるタスク関数の回数を OPS(Operation Per Second) として定義しました。 例えば、3 OPS で設定されると、1 秒間にタスク関数が 3 回実行されます。 基本的には、OPS = rps として考えて良いのですが、タスク関数の実装次第で変わってきます。

タスク関数の中でループや、ハードコーディングでリクエストを複数回送る場合は、入力した OPS の値と同じ rps になりません。 以下のようにタスクの中で 10 回リクエストを送る場合は、入力した OPS の値 ×10 の rps で負荷がかけられます。

func GetRequestTask(client *http.Client) taskFunc {
  return func(ctx context.Context, l *zap.Logger, b *boomer.Boomer) {
    req, err := http.NewRequestWithContext(
      ctx,
      http.MethodGet,
      "http://nginx:80",
      nil)

    for i := 0; i < 10; i++ {
      start := time.Now()
      res, err := client.Do(req)
      duration := time.Since(start).Milliseconds()

      if err != nil {
        errMsg := "サーバーとの通信に失敗しました"
        b.RecordFailure(sampleAPIGetRequestType, sampleAPIGetRequestName, duration, errMsg)
        l.Error(errMsg, zap.Error(err))
        return
      }

      res.Body.Close()
    }
    return
  }
}

負荷試験基盤利用者は、以下のように負荷試験基盤の入力から 1Pod あたりの OPS を指定することで、rps を制御できるようになりました。

今回の rps を指定できる機能の追加により、ロードテストの前に並列数と Pod 数を調整しながら、rps を設定する必要がなくなりました。 負荷をアプリケーションにかける際に 1Pod あたりの OPS を指定できるようになり、結果としてロードテストにおける負荷試験利用者の作業負荷を軽減できました。 Pod 数と並列数から rps を割り出す必要がなくなったので、負荷をかける際の指標が分かりやすくなりました。

また、並列数と Pod 数で調整できなかった場合に利用していた、タスク関数にスリープ処理を埋め込む必要がなくなりました。

解決できなかった点

並列数と Pod 数を調整し、負荷試験基盤を動かしながら rps を調査する必要は無くなったのですが、1 worker が最大でどのくらい負荷をかけれるかの調査は必要になります。 例えば、1000rps で負荷をかけたい場合に、1worker が 1000rps の負荷をかけれるかの調査は必要になるということです。 1worker が 200rps しか負荷をかけれない場合は、Pod 数を 5 に設定して全体で 1000rps の負荷をかける必要があります。

RateLimiter は 1worker の rps を制御するだけなので、複数 worker が必要になる場合には、Pod 数と指定する OPS を調整する必要があります。

worker をスケールさせる機能

負荷試験対象アプリケーションの性能限界を調べるキャパシティテストで、既存の負荷試験基盤がどのような課題を抱えていたか、その課題をどのようにして解決したかを説明します。 負荷試験基盤が worker をスケールさせることで、負荷試験利用者の作業負担がどのように減ったか、そして解決できなかった点について説明します。

既存の負荷試験基盤が抱えていたキャパシティテストにおける課題

既存の負荷試験基盤では、並列数や Pod 数で負荷を調整するようになっていました。 キャパシティテストでは、主に Pod 数を徐々に負荷を増やしながら何度か負荷試験する必要がありました。 そこで、worker をスケールさせることによって、負荷をアプリケーションの性能限界まで増やすことで、キャパシティテストをスムーズに行うことができると考えました。

Job から Deployment と HPA に変更し、 Pod をスケールさせて解決する

既存の負荷試験基盤では、負荷をかける locust master/worker は、両方 Job として実行していました。 Job が採用されていたのは、以下のように Job が追跡している Pod が負荷試験終了時に Job に指定した完了数に達したとき、Pod のリソースが自動的に削除されるからです。

Job では HPA を使ってスケールすることができないので、worker を Deployment に変更し、HPA でスケールできるようにしました。 ここで、worker を Job から Deployment と HPA に変更するにあたっていくつか問題がありました。

まず、Deployment と HPA は、Job のように完了数に Pod が達すると終了するといった機能がないため、負荷試験が終了しても worker が自動的に終了しないという問題がありました。 実際には、負荷をかけていた worker は終了しているのですが、Deployment の仕様で終了しても新規に worker の Pod を立ち上げているという具合です。 master の Pod が終了しているので、新規で作成される worker の Pod は負荷をかけることはないのですが、クラスタ上に不要な Pod が残っていました。 worker の Deployment と HPA を削除するために、負荷試験終了時に master の Pod から削除するようにしました。 具体的には、負荷試験の実行が終わると master の Pod において kubectl コマンドで worker の Deployment と HPA を削除しています。 master の Pod から kubectl コマンドを実行させるために master の Job に Deployment と HPA を削除する権限を持ったサービスアカウントを付与しています。

この方法で、負荷試験終了時に worker を master から終了/削除する流れは以下のようになります。 Job Pod で worker を終了させるときよりも複雑なフローになりましたが、worker がスケールし、終了時にリソースが適切に削除されるようになります。

Deployment と HPA に変更することで、スケール時の worker Pod の上限を決めるパラメータを考慮する必要がありました。 スケール時の worker Pod の上限は、負荷試験基盤の GitHub Actions workflow から設定できるようにしました。 HPA では、worker の Pod の CPU 使用率が 90%を超えたら、worker の Pod がスケールするように設定しています。 利用者は、worker のスケールの閾値を設定できないようにしています。 利用者が閾値を設定できないようにしている理由は、アプリケーションに負荷をかける際に worker のスケールの詳細については利用者が考える内容でないからです。 利用者は、スケール時の最大 Pod 数を指定するだけで worker のスケールする際の設定を意識せずに利用することができます。

Deployment に HPA を適用させ、Deployment と HPA を削除する機能を master の Pod に設けることで Job のように振る舞うスケールする worker を実装できました。

この機能追加で、キャパシティテストにおいて、利用者がパラメータを調整して試行する必要がなくなり、利用者の作業負担を軽減できました。

リリース時の失敗について

worker を Job から Deployment に変更したことで、自分のチームで既存の負荷試験ができないといった問題がありました。 負荷試験基盤は 3 つのクラスタをサポートしており、その内の 1 つのクラスタでこの問題が発生しました。 具体的には、Job から Deployment に変わったことによる修正漏れで Docker イメージを取得する際の External Secret が設定されなくなっていました。 リソースの構成を変更したことでどのような影響があるかを事前に 3 つのクラスタで動作確認しなかったのは反省点でした。

解決できなかった点

キャパシティテストで、性能限界を検知した後 master/worker が自動的に終了しないようになっています。 キャパシティテストでは、負荷試験対象アプリケーションの性能限界がわかればいいので、アプリケーションが負荷に耐えられなかったら終了するといった機能はまだ実装できていません。

スケールする Job のようなコンポーネントをカスタム・コントローラーで実装するという選択肢もありました。 ただ、カスタム・コントローラーを実装自体に工数が大きくかかるといった問題や、負荷試験基盤の構成が今回の変更以上に変わるため、今回は Job を Deployment と HPA に変更してスケールさせるようにしました。 現状の運用で難しくなった場合や、カスタム・コントローラーに改修することでより負荷試験での費用削減や効率化による利益が得られる場合には、カスタム・コントローラーに改修することを検討しています。

まとめ

DMM プラットフォーム事業本部で利用されている負荷試験基盤の、ロードテストを効率化するための新機能について説明しました。

  • rps を指定できるようにする
  • worker をスケールさせる

rps を指定できるようになり、ロードテスト時の作業負担の軽減と、負荷の設定をしやすくなりました。 また、worker をスケールさせる機能では、キャパシティテストにおいて負荷試験対象アプリケーションが性能限界に達するまで worker をスケールさせることができるようになり、キャパシティテストにおける回数の削減ができました。

簡単に Job から Deployment と HPA で、スケールするような Job の機能を k8s 上で実装するパターンについて考える機会があったので、今後このようなパターンが出てきた時に使えそうと思いました。

rps の指定については、RateLimiter を使って並列数を増やした時や Pod 数を増やした時、rps がどのように制限されるかを考えながら実装できました。 その上で、boomer の実装をみながら worker がどのように動いているか把握できて、OSS のコードを見ることの大切さを実感しました。