投稿日:
更新日:

k6 で実現する負荷試験のモダナイゼーション

Authors

目次

banner.png

はじめに

Web アプリケーションは、高いパフォーマンスと信頼性が要求されます。 増大するリクエストに耐え得る設計になっているかどうかを開発者が把握しておくことは、スケーラビリティ(規模拡張性)の観点でも非常に重要になってきます。

リリース前や、大規模なイベント・セールの前に行いたい検証の一つに負荷試験があります。 実際にアクセスしてくるユーザ数やリクエスト数、もしくはそれ以上の同接数を想定し、システムが耐えられるかどうか、レスポンスタイムは妥当であるか等、いくつかのシナリオを検証します。 事前の負荷試験によって、ボトルネックや潜在的な問題を洗い出しておくことで、予期せぬサービスダウンを低減できたり、キャパシティプランニング に役立てることができます。

従来、Apache JMeterGatling, Locust 等、様々なツールが負荷試験に採用さてきましたが、設定やシナリオの定義が複雑だったり、GUI 経由であることから柔軟さに欠けたりと、いくつか課題があり、近年のマイクロサービスやクラウドネイティブな設計においては、さらにクローズアップされます。

また、一部では大規模な負荷試験を実施する際、Sender 側(負荷を発生させる側)のマシン性能や、負荷試験ツールの特性も気にする必要があり、トラフィックを思うようにシミュレートできない懸念がありました。

このような背景から、昨今では Kubernetes やクラウドネイティブな環境下での負荷試験において、Grafana Labs の k6 が注目されています。 k6 はコンテナフレンドリーな設計となっており、シナリオの定義も非常に容易です。 また、Kubernetes への導入には、オペレータ(k6-operator)を用いることで、大規模な負荷シナリオや分散実行、自動化されたパイプラインとの統合も可能です。

今回のブログでは、モダンな負荷試験ツールである k6 を取り上げ、導入方法や負荷試験環境の構築についてまとめてみたいと思います。

k6

k6.png

k6 is a modern load-testing tool, built on our years of experience in the performance and testing industries. It's built to be powerful, extensible, and full-featured. The key design goal is to provide the best developer experience.

k6 は、シングルバイナリで動作するためコンテナ化が容易であり、エコシステムも充実したクラウドネイティブな負荷試験ツールです。 k6 は、Grafana Labs ホスティングの下、OSS としてメンテナンスされており、新バージョンは約 2 ヶ月毎にリリースされます。

k6 自体は Go 言語で実装されており、Goroutine による分散実行や並列化機能が取り入れられているため、膨大なリクエストを生成できます。

また、負荷試験用のスクリプト(シナリオ)は、JavaScript ES2015/ES6 で記述することができます。 そのため、JavaScript に慣れている人であれば、基本的な構文が同じなのでキャッチアップコストが低くて済むのも魅力の一つです。

ただし、k6 は Node.js でもなければブラウザでもないため、例えば、osfs といった npm モジュールは機能しません。 npm module や NodeJS API を使用する場合、別途モジュールを作成して、ファイルからインポートしてやる必要があります。 例えば、Amazon API Gateway へリクエストを投げる場合は、browserify を利用して node_modules にインストールした後、インポートすることで k6 でも実行可能です。

k6 は、内部的に goja をフォークした Sobek を呼び出すことで、ECMAScript 2015+(ES6+)を解析・実行しています。参考

主な特徴

  • モニタリングツールとの親和性

k6 は Grafana Labs 傘下のプロダクトということもあり、Kubernetes 関連のモニタリングツールとのエコシステムが充実している印象です。 負荷試験の結果はメトリクスとして Prometheus に送りだすことで、Grafana ダッシュボードで可視化することができます。 他にも、New Relic や Datadog 等、SaaS ツールとの連携も可能です。

また、OpenTelemetry(Otel)を組み合わせてトレーシングを整備しておくことで、アプリケーションやネットワーク周りのボトルネックの改善に役立てることができます。

  • シングルバイナリ

先でも述べた通り、k6 は Go 言語で実装されています。 そのため、CLI 実行やコンテナ化が容易であり、シングルバイナリで動かせる点も、クラウドネイティブな負荷試験ツールと言われる所以だと思います。

オーソドックスな例では、シナリオをコピーした k6 イメージを準備し、コンテナレジストリにアップロードすることで、Kubernetes への展開ができます。

  • 直感的なスクリプト

k6 は JavaScript で定義したシナリオを元に、負荷試験を実行します。 例えば、以下のようなスクリプトを用いて、段階的な負荷をシミュレートできます。

import http from 'k6/http'

export const options = {
  stages: [
    { duration: '30s', target: 20 },
    { duration: '1m30s', target: 10 },
    { duration: '20s', target: 0 },
  ],
}

export default function () {
  http.get('https://test.k6.io')
}

この例では、https://test.k6.io というエンドポイントに GET リクエストを送信します。 30 秒間で 20 VUs に増加し、1 分 30 秒かけて 10 VUs に減少、さらに 20 秒かけて 0 VUs にコンバージョンします。 これだけで簡単な負荷試験は実行できるため、他のツールと比較してもハードルを下げることができるのではないでしょうか。

  • CI/CD との親和性

CI/CD パイプラインに統合することで ChatOps ライクな負荷テストを実装することができます。

例えば、run-k6-action では、負荷試験スクリプトを GitHub に Push して Job 用のカスタムリソースを生成し、負荷テストまでを自動化することができます。 CI Workflow に組み込むことで、インフラエンジニアや SRE でなくても容易に負荷試験を実行したり、その結果を報告したりすることができます。

CircleCI Developer Hub - grafana/k6

実際に、k6 を ChatOps 化した例が 株式会社 CAM SRE チームより紹介されています。

CAM TechBlog - 負荷試験環境に k6-operator を活用し、ChatOps ライクな負荷試験を実現できた話

cam-chatops-loadtesting.png

【アーキテクチャ】

cam-chatops-loadtesting-architecture

【スラッシュコマンドによる実行】

chatops-slash-command.png
  • Kubernetes オペレータ

k6 には、k6-operator という Kubernetes のオペレータが準備されています。 これにより、TestRun というカスタムリソースからシナリオを定義した ConfigMap を参照して Kubernetes 上で容易に負荷試験を実行することができます。


【2023 年 10 月 追記】

以前は、K6 というカスタムリソースが使用されてきましたが、2023 年 10 月にリリースされた v0.0.11rc3 から TestRun への移行が推奨されました。

⚠️ Future deprecation

K6 CRD is going to be deprecated in favor of TestRun CRD. The behaviour of both should be identical for now, but it is strongly recommended to switch to TestRun CRD in your workflows as K6 CRD will be removed in the future.


k6-operator-1.png

k6-operator は、まずシナリオをカスタムとして適用すると、コントローラが変更を検知してクラスタの状態を更新します。 その後、負荷試験用の Job が作成され、Sender Pod をデプロイしてリクエストを生成します。参考

k6-operator-2.png

例えば、以下のようなカスタムリソースを用いると、load-test.js というスクリプトを元にシナリオを実行し、結果を Remote-Write で Prometheus へ送信します。

apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: k6-operator-example01
  namespace: k6-operator
spec:
  parallelism: 1
  arguments: -o experimental-prometheus-rw ## Prometheus Remote-Write を使用
  script:
    configMap:
      name: k6-operator-example01
      file: load-test.js
  runner:
    env:
      - name: K6_PROMETHEUS_RW_SERVER_URL
        value: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090/api/v1/write

Prometheus の他にも InfluxDBKafka 等、セルフホスティング DB から, New RelicDatadog 等の SaaS にも流すことができます。

参考:k6 - Results output

実際に、負荷試験を実行する際は、k6-operator を使用することが殆どだと思います。 シナリオテンプレートを作成しておくことで、負荷試験の基盤を IaC や CaC で管理できるようになります。

懸念点

ここまで k6 のメリットを取り上げてきましたが、導入に際する懸念点も挙げておきます。

  • JavaScript だが Node.js ではない

こちらも先で述べていますが、k6 が利用する負荷スクリプトは JavaScript で定義できますが、Node.js ではありません。 npm モジュールのような NodeJS API は利用できないため、ニッチな負荷試験を行う場合は、必要に応じてツールのインストールやコール方法等、対応策を考える必要があります。

参考:k6 - JavaScript API リファレンス

  • デフォルトでは時系列変化の把握がしずらい

k6 CLI の場合、負荷試験の結果は以下のように出力されます。

$ k6 run load-test.js

         /\      Grafana   /‾‾/
    /\  /  \     |\  __   /  /
   /  \/    \    | |/ /  /   ‾‾\
  /          \   |   (  |  ()  |
 / __________ \  |_|\_\  \_____/

     execution: local
        script: load-test.js
        output: -

     scenarios: (100.00%) 1 scenario, 20 max VUs, 2m50s max duration (incl. graceful stop):
              * default: Up to 20 looping VUs for 2m20s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)


     data_received..................: 72 MB  512 kB/s
     data_sent......................: 632 kB 4.5 kB/s
     http_req_blocked...............: avg=4.35ms   min=0s       med=6µs      max=1.13s    p(90)=11µs     p(95)=14µs
     http_req_connecting............: avg=2.02ms   min=0s       med=0s       max=190.94ms p(90)=0s       p(95)=0s
     http_req_duration..............: avg=287.46ms min=171.78ms med=186.46ms max=2.15s    p(90)=382.26ms p(95)=589.08ms
       { expected_response:true }...: avg=287.46ms min=171.78ms med=186.46ms max=2.15s    p(90)=382.26ms p(95)=589.08ms
     http_req_failed................: 0.00%  0 out of 6194
     http_req_receiving.............: avg=12ms     min=14µs     med=115µs    max=1.52s    p(90)=313.69µs p(95)=171.54ms
     http_req_sending...............: avg=24.04µs  min=2µs      med=22µs     max=1.88ms   p(90)=40µs     p(95)=45µs
     http_req_tls_handshaking.......: avg=2.31ms   min=0s       med=0s       max=948.13ms p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=275.43ms min=171.56ms med=185.13ms max=2.15s    p(90)=377.05ms p(95)=576.24ms
     http_reqs......................: 6194   44.230332/s
     iteration_duration.............: avg=291.92ms min=171.87ms med=186.96ms max=2.15s    p(90)=406.84ms p(95)=593.94ms
     iterations.....................: 6194   44.230332/s
     vus............................: 1      min=1         max=20
     vus_max........................: 20     min=20        max=20


running (2m20.0s), 00/20 VUs, 6194 complete and 0 interrupted iterations
default ✓ [======================================] 00/20 VUs  2m20s

見ての通り最終的な結果のみで、k6 単体では時系列の変化を取得できないため、Grafana や Datadog 等、外部の可視化ツールと連携する必要があります。 その点では、JMeterTaurus 等と比較してやや面倒かもしれません。

ちょっとした検証を実施したい場合は、k6 の 拡張機能として用意されている xk6-dashboard(xk6) というツールを利用することができます。 xk6 は、負荷試験の結果を時系列で出力してくれるので、ローカルで実行する場合はこちらを利用するのが良いと思います。 ただし、自前でビルドしてあげる必要があるのでちょっと面倒です 😢

xk6-dashboard.png

また、単にレポートを取得したい場合は、サードパーティ製の k6-reporter を使用することで HTML ファイルとして保存することもできます。

import http from 'k6/http'
import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js' // k6-reporter を追加

export const options = {
  stages: [
    { duration: '30s', target: 20 },
    { duration: '1m30s', target: 10 },
    { duration: '20s', target: 0 },
  ],
}

export default function () {
  http.get('https://test.k6.io')
}

// レポート出力
export function handleSummary(data) {
  return {
    'summary.html': htmlReport(data),
  }
}
k6-reporter.png

多くの場合は、k6-operator を Kubernetes に導入してエコシステムと連携するので、このあたりは運用に際して然程問題にならないと思います。

ユースケース

k6 では以下のような試験を行うことができます。

参考:k6 - 負荷テストの種類

  • 負荷テスト

Load testing

最も基本的なユースケース。 k6 は、リソース消費を最小限に抑えるように最適化されているため、高負荷テスト(スパイク、ストレス、ソークテスト)を容易に実行することができます。

  • ブラウザテスト

Load testing websites

インターフェースレベルでアプリケーションのパフォーマンスを検証し、ページ要素が画面にどのように、いつ表示されるかを考慮したラウンドトリップメトリックを測定することができます。

  • カオスと回復力のテスト

Injecting faults with xk6-disruptor

カオス試験を行う際には、xk6-disruptor を用いて、k6 にフォールトインジェクション(障害注入)機能を追加することができます。 これにより、遅延やレスポンスエラー等の不安定な状況下でのアプリケーションの信頼性をテストすることが可能となっています。

  • パフォーマンスと統合モニタリング

Synthetic monitoring

継続的な本番環境監視のために k6 スモークテスト をスケジュールすることにより、k6 を統合監視ツールとして使用できます。

スモークテスト

ソフトウェアの新しいビルドやリリースが基本的に機能することを確認するために行われる一連の簡易テストのこと。

  • 基本機能の確認:アプリケーションの主要な機能が正しく動作するか
  • 初期品質の確認:開発の初期段階で重大な問題がないか
  • ビルドの安定性の確認:新しいビルドがテスト対象として十分に安定しているか

サポートしているプロトコル

2023 年 5 月現在、k6 は以下のプロトコルをサポートしています。

大規模な負荷試験

サービスによっては数百万から数千万人規模の同時アクセスを想定した負荷試験を実施しなければならないケースがあります。 実際に負荷試験を実施してみると、アプリケーションの性能を検証する以前に、環境構築が非常に厄介になってきます。 ネットワークミドルウェアに起因した問題であったり、リクエストを生成する Sender 側のマシンの方が先に限界を迎えてしまったりと、思うように負荷試験が進まず、頭を抱えるインフラエンジニアや SRE も少なくないと思います。

例えば、Locust は Python で実装されていますが、シングルスレッドで動作するため、リクエストを生成した際にマスタプロセスが落ちてデータ収集に苦戦するケースがありました。

このような大規模な負荷試験の際にも、k6 は Go 言語の特性を活かして対応することができるように工夫されています。

他の負荷試験ツールとの比較

ここでは Locust との比較を例に挙げたいと思います。

Locust は言語特性もありますが、シングルスレッド(1 vCPU)で動作するため、複数の CPU コアを効率的に使用できないという課題があります。 そのため、マルチ CPU サーバ上のすべての CPU を完全に使用するには、負荷生成を複数のプロセスに分散する必要があります。 負荷試験基盤の構築者はマスター・スレーブのような構成を取り、言わばマシンのパワーで押し進めるような状況に追い込まれます。

一方の k6 は、Goroutine を用いて高度にマルチスレッド化されているため、利用可能なすべての CPU コアを効果的に利用して並列実行することができます。

参考までに、Locust は単一インスタンスで実行すると最大でも 900 RPS 程度が限界 とされています。

Locust was run in distributed mode, which means that five Locust instances were started: one master instance and four slave instances (one slave for each CPU core). Locust is single-threaded so can’t use more than one CPU core, which means that you have to distribute load generation over multiple processes to fully use all the CPU on a multi-CPU server. (They should really integrate the master/slave mode into the app itself so it auto-detects when a machine has multiple CPUs and starts multiple processes by default.) If I had run Locust in just one instance it would only have been able to generate ~900 RPS.

これに対し、k6 は 理論上、30,000 ~ 40,000 VUs の同時リクエストを生成することが可能である とされています。

つまり、1 秒あたり 100,000 ~ 300,000 VUs(1 分あたり 600 ~ 1200 万)を超えるリクエストを必要としない限り、単一インスタンスでも十分な負荷試験が実施できるということです。

  • 実行時解釈の違い

Python と Go では実行時のコード解釈も異なります。 Python はインタプリタ言語なので実行時の逐次解釈の手続きを踏む必要がありますが、Go 言語はコンパイル型言語なのでそのような処理は不要です。 加えて、Go 言語はシングルバイナリで動作するため、外部への依存関係も無く、潜在的なボトルネックが生じずらいというメリットがあります。

  • スレッドハンドリングの違い

JVM やインタプリタベースのイテレーティブな実装では、1 VU を 1 スレッドに割り当てます。 1 スレッド 1 VU の場合、任意の VM が応答を待つ間、他のスレッドがブロックされて処理が進まなくなるという問題があります。 実際に、JMeter をはじめとする JVM スレッドはデフォルトの 1 MB(ヒープ領域)を使用するため、スレッドの増加に伴いヒープに負荷が掛かってメモリ不足に陥る事例はよく知られています。 また、直接ツールの利用者が気にすることは無いと思いますが、カーネル r スレッド間の通信は厄介で潜在的なボトルネックも発生します。参考

一方の k6 の場合は、各 VU を Goroutine に割り当てて実行するように実装されています。 Goroutine は単一のカーネルスレッド内で、Go のランタイムがユーザ空間スレッドをシミュレートします。 つまり、前述のような課題が非常に発生しずらくなります。 また、Go 言語は 理論的に数十万程度の Goroutine を同時に実行可能である とされています。

Go 言語がなぜ膨大なスレッドを扱えるようになっているのかは、こちら のブログでも紹介しています。

Orenge Diary TechBlog - Goroutine はなぜ軽量スレッドと称されるのか

goroutine-overview-thumbnail.png

以上を踏まえると、k6 は、Locust や JMeter と比較しても、少ないコンピューティングリソースで高いパフォーマンスを出せる傾向にあり、大規模負荷試験に伴ういくつかの課題を根本的に解決することができます。

検証事例

Depending on the resources of the load generator, JMeter can run about a thousand virtual users on average, and scaling up your test beyond that point will require a distributed execution setup. A single instance of k6, on the other hand, can run tens of thousands of virtual users given the same resources.

留意すべき点

  • メトリクスの保存

大規模な負荷試験を実施すれば、当然得られるデータも肥大化します。 実際に、k6 から得た負荷試験結果のメトリクスは、Persistent Disk のような永続ボリュームで管理します。

また、モニタリングにおいては、Persistent Disk からメトリクスを引き出す際にもタイムアウトが発生する懸念があります。 例えば、VictoriaMetricsThanos のような専用のメトリクス管理ツールの導入を検討する必要があるかもしれません。

  • カーネルパラメータのチューニング

近年のカーネルは、アプリケーションが作成できる同時ネットワーク接続数の制限が低くめに設定されています。 ネットワーク容量をすべて使用し、最大限パフォーマンスを発揮させたい場合は、必要に応じてカーネルパラメータ(Network capacity, Memory, CPU)のチューニングが必要になります。 特に負荷試験実行時には、ソケットディスクリプタが枯渇し、そもそも TCP セッションを開くことができなくなる場合もあります。 例えば、Amazon Linux(CentOS 系)で k6 を動かす場合、Sender VM の以下のファイルおよびパラメータを編集することで、ネットワーク、メモリ管理、プロセス・ファイルシステムの動作等、カーネルレベルの設定を変更できます。

### システムがブートアップする時に適用されるパラメータ
$ sudo vim /etc/sysctl.conf

### 通信セッションが開始される時に適用されるパラメータ
$ sudo vim /etc/security/limits.conf
### システムが使用するローカルポートの範囲
$ sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"

### TIME_WAIT 状態の TCP ソケットを再利用するかどうか
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=1

### TCP タイムスタンプを使用するかどうか
$ sudo sysctl -w net.ipv4.tcp_timestamps=1

### 単一ユーザに対する最大 FD 数
$ sudo ulimit -n 250000

ローカル環境でのお手軽負荷試験

負荷試験スクリプトは、基本的に以下の形式に沿って書いていきます。 各セクションは、JavaScript の Named Export や Default Export で定義します。

// 1. 初期化
// 2. API 実行前の処理
export function setup() {
  // ログイン、トークンの取得などAPI実行に必要な処理を実装する箇所
}

// 3. API 実行
export default function (data) {
  // API を実行するシナリオを実装する箇所
}

// 4. API 実行後の処理
export function teardown(data) {}

他にも、Headless Browser を使ったブラウザテストでは、ユーザの Web 操作(ナビゲーション、マウスとキーボードのアクション、スクリーンショットの撮影)を想定したシナリオも作成できます。 また、Chrome Extension からシナリオを作成できる k6 Browser Recorder という拡張機能もあるみたいです。

k6 CLI

k6 - k6 CLI Quick reference

  • 基本書式

    $ k6 run [シナリオ].js [コマンドオプション]
    
  • コマンドオプション

    • --vus:同時接続数(ユーザ数)
    • --duration:試験の実行時間
    • --rps:Request Per Second(秒間リクエスト数)
    • --iteration:シナリオを繰り返す回数
    • --out:出力形式

    例えば、-vus 10 --duration 10s とした場合、同時接続数 10 人で 10 秒間負荷をかけることができます。 なお、コマンドオプションは直接、JavaScript に記述することも可能です。

  • 出力結果

    ✓ http response status code is 200
    
    checks.........................: 100.00% ✓ 2824790
    data_received..................: 241 MB  4.0 MB/s
    data_sent......................: 28 MB   470 kB/s
    http_req_blocked...............: avg=12.14µs  min=1.49µs   med=3.31µs  max=105.32ms p(90)=5.09µs  p(95)=9.01µs
    http_req_connecting............: avg=3.43µs   min=0s       med=0s      max=104.73ms p(90)=0s      p(95)=0s
    http_req_duration..............: avg=20.76ms  min=190.42µs med=1.29ms  max=793.55ms p(90)=92.84ms p(95)=95.96ms
      { expected_response:true }...: avg=20.76ms  min=190.42µs med=1.29ms  max=793.55ms p(90)=92.84ms p(95)=95.96ms
    http_req_failed................: 0.00%   ✓ 0282479
    http_req_receiving.............: avg=378.45µs min=13.45µs  med=34.83µs max=695.53ms p(90)=76.13µs p(95)=190.08µs
    http_req_sending...............: avg=28.4µs   min=3.98µs   med=9.24µs  max=456.41ms p(90)=17.61µs p(95)=26.8µs
    http_req_tls_handshaking.......: avg=0s       min=0s       med=0s      max=0s       p(90)=0s      p(95)=0s
    http_req_waiting...............: avg=20.35ms  min=143.79µs med=1.21ms  max=287.99ms p(90)=92.66ms p(95)=95.78ms
    http_reqs......................: 282479  4702.492214/s
    iteration_duration.............: avg=21.17ms  min=255.46µs med=1.39ms  max=794.08ms p(90)=93.04ms p(95)=96.19ms
    iterations.....................: 282479  4702.492214/s
    vus............................: 199     min=0         max=199
    vus_max........................: 200     min=200       max=200
    
指標概要単位備考
checksリクエストが成功した割合
data_receivedレスポンスデータ量Total, /s
data_sentリクエエストデータ量Total, /s
http_req_blockedTCP 接続の順番待ちをした時間avg, min, med, max, p(90), p(95)
http_req_connectingTCP 接続にかかった時間avg, min, med, max, p(90), p(95)
http_req_durationhttp_req_sending + http_req_waiting + http_req_receiveing の合計avg, min, med, max, p(90), p(95)
expected_response正常応答のみの http_req_durationavg, min, med, max, p(90), p(95)正常な応答がない場合、この項目は表示されない
http_req_failedリクエストが失敗した割合%threshold をシナリオに追加することでエラー率を定義できる
http_req_receivingレスポンスの 1 バイト目が到達してから最後のバイトを受信するまでの時間avg, min, med, max, p(90), p(95)
http_req_sendingリクエストを送信するのにかかった時間avg, min, med, max, p(90), p(95)
http_req_tls_handshakingTLS コネクションの確立にかかった時間avg, min, med, max, p(90), p(95) http の場合は 0s
http_req_waitingリクエストが送信完了してからレスポンスが開始されるまでの時間(TTFB:Time To First Byte)avg, min, med, max, p(90), p(95) 
http_reqsリクエスト総数 Total, /s右側が RPS の実測値(この例では 100 RPS)
iteration_durationシナリオ 1 ループにかかった時間avg, min, med, max, p(90), p(95)
iterationsシナリオを繰り返した回数Total, /s
vus並列数(アクセスユーザ数)
vus_max最大並列数(ピークユーザ数)

Docker Compose で負荷試験スタックを起動

https://github.com/GotoRen/k6-operator-playground をベースに、ローカル環境に負荷試験基盤を構築してみます。 今回は、負荷試験の結果を InfluxDB と Prometheus の両方に保存する方法を紹介します。

まず、locals ディレクトリの compose.yaml を起動します。

### リポジトリをクローン
$ git clone [email protected]:GotoRen/k6-operator-playground.git
$ cd ./locals

### Docker Compose を起動
$ docker compose up -d

http://localhost:3000 (admin/password)から Grafana ダッシュボードにアクセスして、データソース に InfluxDB 2.0 と Prometheus が登録されていることを確認します。

data-sources.png

通常の k6 イメージは InfluxDB 2.0 に対応していないため、以下のような Dockerfile で xk6 をビルドしてから実行します。

FROM golang:1.23-alpine as builder
RUN apk --no-cache add git \
    && go install go.k6.io/xk6/cmd/xk6@latest \
    && xk6 build --with github.com/grafana/xk6-output-influxdb --output /tmp/k6

FROM alpine:3.20
RUN apk add --no-cache ca-certificates \
    && adduser -D -u 12345 -g 12345 k6
COPY --from=builder /tmp/k6 /usr/bin/k6
USER 12345
WORKDIR /home/k6
ENTRYPOINT ["k6"]

Docker Compose 上で負荷試験を実行

以下のコマンドで xk6 のビルドと負荷試験の実行、メトリクスを InfluxDB に保存するまでをワンライナーで行えます。

シナリオは、MakefileSCRIPT_PATH 環境変数で定義しているので、こちらを適宜変更します。

### 負荷試験を実施して、結果を InfluxDB に出力する
$ make run/influxdb-out
[+] Building 37.8s (13/13) FINISHED

(省略)

running (1m01.3s), 000/200 VUs, 23725 complete and 0 interrupted iterations
default ✓ [ 100% ] 000/200 VUs  1m0s

ダッシュボードを確認すると、負荷試験の実行状況が確認できます。

今回は こちら を参考にダッシュボードを準備しています。

【k6 performance test】

k6-performance-test.png

【K6 Test Results】

k6-test-results.png

また、http://localhost:8086 (admin/password)にアクセスして、InfluxDB の Data Explorer を確認すると、loadtest_result バケットに負荷試験データが格納されていることが分かります。

influxdb-dashboard.png

続いて、負荷試験の結果を Remote-Write で飛ばして Prometheus に保存する場合は、以下のコマンドを実行します。 こちらは、通常の k6 コマンドが使用できます。

### 負荷試験を実施して、結果を Prometheus に出力する
$ make run/prometheus-out

【Official k6 Test Result】

official-k6-test-result.png

以上のように、Docker Compose を用いれば、ローカル環境にも簡単に負荷試験基盤を構築することができます。

k6-operator による実用的な負荷試験

k6-operator を Kubernetes に導入した、より実用的な負荷試験の構成について紹介します。

Kubernetes に導入

先ほど、Docker Compose で試した構成を Kubernetes 上で構築すると以下のようになります。

k6-operator.png

この構成では、InfluxDB と Prometheus(VictoriaMetrics)の 2 つデータストアを使用していますが、実際は負荷試験の規模やメトリクス量に応じてどちらか一方を選択すれば十分かと思います。

小規模な負荷試験であれば、シンプルな構成の InfluxDB で十分だと思いますが、InfluxDB は、メトリクスの格納・引き出しの際に都度レコードの整形が必要なため、負荷をかけ過ぎるとタイムアウトが発生する可能性があります。

The flush operation took higher than the expected set push interval. If you see this message multiple times then the setup or configuration need to be adjusted to achieve a sustainable rate.
Couldn't send metrics points elapsed: context deadline exceeded (Client.Timeout exceeded while awaiting headers)
A query returned too many datapoints and the results have been truncated at 18881 points to prevent memory issues.

このようなエラーが発生した場合は、データフラッシュやタイムアウトの設定をよしなに変更したり、データポイント数を少なくしたりして調整してあげる必要があります。参考

influxdb-architecture-bottleneck.png

サービス規模が大きく、負荷試験規模が数百万から数千万人を想定するようなケースでは、Prometheus の採用をお勧めします。 それでも、Prometheus 単体で機能不足に陥る場合は、例えば、メトリクスの管理を VictoriaMetrics で行うようにします。 メトリクスデータの長期保存を考慮するなら、初めから VictoriaMetrics は用意しておいた方が良いかもしれません。(このラボはシンプルに Prometheus のみを用います。)

負荷試験コンポーネントのデプロイ

今回は、Docker Desktop の Kubernetes Engine を使用します。検証だけならシングルノードクラスタで構いません。

ただし、デフォルトでは Docker Desktop のリソース割り当ては低めに設定されているため、負荷試験を行う際は普段よりも多めに確保しておいた方が良さそうです。

とりあえず以下のように設定しました。

設定項目設定値
CPU limit12 vCPU
Memory limit32 GiB
Swap1 GiB
docker-desktop-setting.png

以下のコマンドでクラスタを確認します。

$ kubectl config current-context; kubectl get namespace
docker-desktop
NAME              STATUS   AGE
default           Active   38d
kube-node-lease   Active   38d
kube-public       Active   38d
kube-system       Active   38d

マニフェストはメンテンスの観点より、Helm on Kustomize で定義しています。

NamespaceNameChartsChart versionApplication version
monitoringkube-prometheus-stackhttps://prometheus-community.github.io/helm-chartsv62.7.0v0.76.1
kube-systemmetrics-serverhttps://kubernetes-sigs.github.io/metrics-serverv3.12.2v0.7.2
influxdbinfluxdb2https://helm.influxdata.comv2.1.2v2.7.4
k6-operatork6-operatorhttps://grafana.github.io/helm-chartsv.0.0.17v3.9.0

kube-prometheus-stack

### monitoring NS を作成
$ kubectl create namespace monitoring
### kube-prometheus-stack
$ cd ./manifests/platform/kube-prometheus-stack/playground

### Helm Chart でデプロイ
$ kustomize build . --enable-helm | kubectl apply -f - --server-side

kube-prometheus-stack の CRD には、annotations が 262144 bytes を超えるリソースが含まれているため、Server-Side Apply でデプロイします。(この問題、他の CD ツールでも厄介だからいい加減何とかしてほしい...。)

invalid: metadata.annotations: Too long: must have at most 262144 bytes

InfluxDB

### influxdb NS を作成
$ kubectl create namespace influxdb
### influxdb
$ cd ./manifests/platform/influxdb/playground

### Helm Chart でデプロイ
$ kustomize build . --enable-helm | kubectl apply -f -

k6-operator

### k6-operator NS を作成
$ kubectl create namespace k6-operator
### k6-operator
$ cd ./manifests/platform/k6-operator/playground

### Helm Chart でデプロイ
$ kustomize build . --enable-helm | kubectl apply -f -

metrics-server

必要に応じてデプロイします。

特に Docker Desktop Kubernetes では、ちょっとした検証でも OOM が発生する可能性があるため、metrics-server でリソース使用量を取得・把握して調整すると良いです。

### metrics-server
$ cd ./manifests/platform/metrics-server/playground

### Helm Chart でデプロイ
$ kustomize build . --enable-helm | kubectl apply -f -

nginx

負荷試験対象として nginx をデプロイします。

### nginx
$ cd ./manifests/services/nginx/playground

### Helm Chart でデプロイ
$ kustomize build . | kubectl apply -f -

データソースの確認

### Grafana に接続
$ kubectl port-forward -n monitoring service/kube-prometheus-stack-grafana 3000:80

http://localhost:3000 (admin/password)から Grafana ダッシュボードにアクセスして、データソース に InfluxDB 2.0 と Prometheus が登録されていることを確認します。

kube-prometheus-stack-grafana-data-sources.png

Kubernetes 上で負荷試験を実行

コマンド

基本的に以下で準備している 3 つの make コマンド で実行できるようにしています。

1. シナリオファイルから ConfigMap を生成してデプロイする(既存の ConfigMap があれば削除して更新する)

このコマンドにより、JavaScript で定義された負荷スクリプトから ConfigMapGenerator で ConfigMap リソースを生成・適用します。

$ make generate/configmap
sh ./load/scripts/gen-k6-scenarios-configmaps.sh
find ./load/scenarios/ -type f -name "configmap.yaml" -exec kubectl apply -f {} \;
configmap/k6-operator-example02 created
configmap/k6-operator-example01 created

2. カスタムリソースをデプロイして負荷試験を開始する(1 で生成した ConfigMap を参照)

TestRun カスタムリソースは、まず initializer Job を生成し、次に starter Job を起動、最後に負荷試験を実行する Sender Job を作成します。

$ make run/job
sh ./load/scripts/run-load-testing.sh example01 1
k6.k6.io/k6-operator-example01 created
Applied K6 CR

3. 負荷試験を停止して 2 でデプロイしたカスタムリソースを削除する

負荷試験を中断したい場合や、デプロイしたワークロードを削除する場合は以下のコマンドを実行します。

$ make stop/job
k6.k6.io "k6-operator-example01" deleted

定義ファイル

主に以下の 2 つのファイルのみを適宜変更して負荷試験を実行します。

ベースとなるカスタムリソースが定義されています。 base.yaml を元に、run/job make コマンドで以下のスクリプトを実行して、カスタムリソースファイルから各シナリオ毎に Job が生成されます。

#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

SCENARIOS_DIR=$(dirname "$0")/../

NAME=k6-operator-$1
PARAL=$2

if [ -z "${NAME}" ]; then
  NAME=k6-operator-no-name
fi

if [ -z "${PARAL}" ]; then
  PARAL=1
fi

yq ea '
  .metadata.name = "'"${NAME}"'" |
  .spec.parallelism = '${PARAL}' |
  .spec.script.configMap.name = "'"${NAME}"'"
' "${SCENARIOS_DIR}/k6/base.yaml" >"${SCENARIOS_DIR}/k6/k6.yaml"

kubectl apply -f "${SCENARIOS_DIR}/k6/k6.yaml"

echo "Applied K6 CR"
import http from 'k6/http'
import { check } from 'k6'

export const options = {
  stages: [
    { target: 100, duration: '1m30s' },
    { target: 200, duration: '1m30s' },
    { target: 300, duration: '1m30s' },
    { target: 400, duration: '1m30s' },
  ],
}

export default function () {
  const result = http.get('http://nginx.nginx.svc.cluster.local:8081')
  check(result, {
    'http response status code is 200': result.status === 200,
  })
}

シナリオを定義します。 便宜上、スクリプトファイルは一律で load-test.js という名前にします。 複数のシナリオを準備する場合は、example01, example02, example03, ... のように定義します。 ここで、example01 等はシナリオ名に相当します。

また、configmap.yaml は、上で説明している generate/configmap make コマンドで自動的に生成されます。

apiVersion: v1
data:
  load-test.js: |
    import http from "k6/http";
    import { check } from "k6";

    export const options = {
      stages: [
        { target: 100, duration: "1m30s" },
        { target: 200, duration: "1m30s" },
        { target: 300, duration: "1m30s" },
        { target: 400, duration: "1m30s" },
      ],
    };

    export default function () {
      const result = http.get("http://nginx.nginx.svc.cluster.local:8081");
      check(result, {
        "http response status code is 200": result.status === 200,
      });
    }
kind: ConfigMap
metadata:
  creationTimestamp: null
  name: k6-operator-example01

負荷試験の結果を Prometheus に送信

負荷試験を実行して、Prometheus に結果を出力する場合は、./load/k6/base.yaml に以下のカスタムリソースを使用します。

apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: k6-operator-example01
  namespace: k6-operator
spec:
  parallelism: 1
  arguments: -o experimental-prometheus-rw
  script:
    configMap:
      name: k6-operator-example01
      file: load-test.js
  runner:
    env:
      - name: K6_PROMETHEUS_RW_SERVER_URL
        value: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090/api/v1/write

まず、シナリオをクラスタにデプロイします。

$ make generate/configmap

run/job で負荷試験を開始します。 この時に、引数として任意のシナリオと、負荷試験の際の並列数を渡すこともできます。

$ make run/job NAME=[シナリオ名] PARAL=[同時実行数]

負荷試験が終了すると、Pod のログにも出力されます。

$ kubectl logs -f -n k6-operator [Sender Pod 名]

✓ http response status code is 200

checks.........................: 100.00% ✓ 2824790
data_received..................: 241 MB  4.0 MB/s
data_sent......................: 28 MB   470 kB/s
http_req_blocked...............: avg=12.14µs  min=1.49µs   med=3.31µs  max=105.32ms p(90)=5.09µs  p(95)=9.01µs
http_req_connecting............: avg=3.43µs   min=0s       med=0s      max=104.73ms p(90)=0s      p(95)=0s
http_req_duration..............: avg=20.76ms  min=190.42µs med=1.29ms  max=793.55ms p(90)=92.84ms p(95)=95.96ms
  { expected_response:true }...: avg=20.76ms  min=190.42µs med=1.29ms  max=793.55ms p(90)=92.84ms p(95)=95.96ms
http_req_failed................: 0.00%   ✓ 0282479
http_req_receiving.............: avg=378.45µs min=13.45µs  med=34.83µs max=695.53ms p(90)=76.13µs p(95)=190.08µs
http_req_sending...............: avg=28.4µs   min=3.98µs   med=9.24µs  max=456.41ms p(90)=17.61µs p(95)=26.8µs
http_req_tls_handshaking.......: avg=0s       min=0s       med=0s      max=0s       p(90)=0s      p(95)=0s
http_req_waiting...............: avg=20.35ms  min=143.79µs med=1.21ms  max=287.99ms p(90)=92.66ms p(95)=95.78ms
http_reqs......................: 282479  4702.492214/s
iteration_duration.............: avg=21.17ms  min=255.46µs med=1.39ms  max=794.08ms p(90)=93.04ms p(95)=96.19ms
iterations.....................: 282479  4702.492214/s
vus............................: 199     min=0         max=199
vus_max........................: 200     min=200       max=200

Grafana ダッシュボードに こちらの Official k6 Test Result をサンプルとして追加してみます。

load-testing-real-time.gif

こちらの GIF では、負荷試験の様子がリアルタイムにモニタリングできていることが分かるかと思います。

他にも多様なダッシュボードが公開されているので利用してみました。

loadtesting-datasource-prometheus-1.pngloadtesting-datasource-prometheus-2.png

実際は、サービス毎に見たい情報は様々かと思うので、必要なパネルを整備してあげてください。

負荷試験の結果を InfluxDB に送信

こちらは、./load/k6/base.yaml に以下のカスタムリソースを使用します。

apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: k6-operator-example01
  namespace: k6-operator
spec:
  parallelism: 1
  arguments: -o xk6-influxdb=http://influxdb2.influxdb.svc.cluster.local:80
  script:
    configMap:
      name: k6-operator-example01
      file: load-test.js
  runner:
    image: ren1007/k6:latest ## xk6 カスタムイメージを使用
    env:
      - name: K6_INFLUXDB_ORGANIZATION
        value: 'loadtest_organization'
      - name: K6_INFLUXDB_BUCKET
        value: 'loadtest_result'
      - name: K6_INFLUXDB_TOKEN
        value: 'admin_token'
      - name: K6_INFLUXDB_PUSH_INTERVAL ## メトリクスの送信間隔を調整
        value: '30s'
    resources: ## リソース割り当て量を調整
      requests:
        memory: '2Gi'
        cpu: '1'
      limits:
        memory: '4Gi'
        cpu: '2'

InfluxDB 2 系を使用する場合はいくつか注意点があります。 先でも述べている通り、現在、k6 は InfluxDB 1 系のみをサポートしており、2 系には対応していません。

なのでカスタムイメージを使用します。 こちら で定義している Dockerfile をコンテナレジストリ(Docker Hub)にアップロードしたものを Pull して使用します。 arm64 でビルドしているので、必要に応じて amd イメージを準備してください。

docker-hub-xk6-image.png

また、InfluxDB は陳腐なリソースで負荷試験を実施しようとすると、タイムアウトや OOM 等、度々嬉しくない事態が起きはじめる可能性があるので、予め K6_INFLUXDB_PUSH_INTERVAL でメトリクスの更新間隔をデフォルトの 1 秒から伸ばしたり、runner.resources でリソース割り当て量を調整したりします。

こちらも同様に、make generate/configmap でシナリオをデプロイし、make run/job で負荷試験を開始します。

Grafana の Explore からいくつか Flux クエリ を投げてみます。

grafana-flux-query.png
-- レスポンスタイム(ウィンドウ期間毎の平均応答時間)
from(bucket: v.defaultBucket)
  |> range(start: v.timeRangeStart, stop:v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "http_req_duration")
  |> filter(fn: (r) => r._field == "value")
  |> filter(fn: (r) => r.status == "200")
  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)
  |> yield(name: "mean")
-- ユーザ数
from(bucket: v.defaultBucket)
  |> range(start: v.timeRangeStart, stop:v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "vus")
-- リクエスト数
from(bucket: v.defaultBucket)
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "http_reqs")
  |> filter(fn: (r) => r["_field"] == "value")
  |> group(columns: ["_measurement"])
  |> aggregateWindow(every: 1s, fn: sum, createEmpty: false)
  |> yield(name: "sum")
loadtesting-datasource-influx-1.pngloadtesting-datasource-influx-2.png

ちゃんと取得できていそうですね。

こちら のダッシュボードを使用すると、以下のように負荷試験の結果がモニタリングできます。

loadtesting-datasource-influx-3.png

InfluxDB の Data Explorer や Notebooks でも負荷試験の結果をプロットすることができます。

k8s-influxdb-dashboard-1.pngk8s-influxdb-dashboard-2.png

まとめ

今回は、Docker Compose によるお手軽な負荷試験環境の構築と、Docker Desktop の Kubernetes Engine を使用した、より実用的な負荷試験環境の構築について紹介してみました。

ちなみに、ローカル環境で 100,000 RPS を超える負荷をかけてみましたが、Docker Desktop はもちろんのこと、PC 自体がクラッシュしそうになりました。 K9s でも普段見かけない表示が出ました 😇

resource-danger.png

当然のことながらローカル環境でかけられる負荷は本番で想定されるリクエストよりも遥かに低いため、実際は EKS や GKE 等のプロダクション向け Kubernetes を使用します。

実際に、クラウドサービス上で負荷試験を行う際は、ネットワーク環境やレートリミット等、関連する事項を考慮し、必要に応じて暖機やベンダとのやり取りが必要になる場合があります。 パブリッククラウドは Egress 通信料が高い傾向にあるため、不用意な負荷試験を行うとコストが爆発する可能性もあるので注意が必要です。

また、k6 に限った話ではありませんが、一般公開されているエンドポイントに負荷をかけると DoS/DDoS 攻撃として判定されたり、アカウント自体が BAN の対象になってしまったりする恐れがあるので、くれぐれも気を付けて扱ってください。

余談

k6 自体は Influx 1 系の利用を推奨しており、2 系に関してはほとんど整備されていません。

そもそも、InfluxDB 自体が 1 系 と 2 系 で破壊的な変更が入っており、『データベース(ブロックストレージ)』から『バケット(オブジェクトストレージ)』に変わっています。 クエリ言語も、1 系は SQL ライクな InfluxQL を使用しますが、InfluxDB 2 系では、Flux を使用します。参考

これに伴い各種パラメータや引数も変えてやる必要があるので、 Influx 2 系をチャレンジングに使用する場合はある程度の理解が必要になります。