k6による負荷試験やってみた

エクストーンの豊田です。先日、k6 (https://k6.io/) というツールを利用してWebサービスの負荷試験を行ったので、そちらの紹介をしたいと思います。

k6について

k6はオープンソースで提供されている負荷試験ツールで、負荷試験のシナリオをJavaScriptで記述できる特徴があります。もちろん並列にリクエストを送るクライアントの数や、リクエスト数、テストの時間等も柔軟に指定することが出来ます。

import http from 'k6/http'

export default function () {
  http.get('https://test.example.com')
}

上記はテストシナリオの記述例です。GETリクエストだけでなく、POSTやPUT等の他のHTTPメソッドも容易に記述できますし、レスポンスの値を取得することも出来るため、個人的にはAPIの負荷試験は特に楽にシナリオを作成できるという印象を受けました。

負荷試験のシナリオを作る

負荷試験のシナリオを作るためには、まず負荷試験の目的を整理する必要があります。

  • 想定されている負荷に対して、正常にサービスが運用できているかどうか(ロードテスト)
  • 高負荷をかけた際に、想定外の挙動が発生していないかどうか(ストレステスト)

他にも現状のシステムがどのくらいの負荷まで耐えられるのかをテストする等もありますが、基本的には「想定した負荷に対して一定のパフォーマンスが出せるか」「想定外の負荷に対して、異常な現象が発生しないか」の2つに分かれると思います。

どちらも、まずは実際のアプリケーションのユースケースに従ったシナリオを作成する必要があります。

例えばECサイトのAPIサーバーの負荷試験を行うケースを考えましょう。買いたいものが決まっているユーザーはおそらくテキスト検索等でほしい商品の検索を行い、その結果から商品の個別ページへ遷移し、その商品をカートに入れた後に決済ページで届け先の設定や支払処理等を行うことが想定されます。これらの一連の流れを組み合わせてシナリオを作成します。

import http from 'k6/http'

export default function () {
  // 検索APIの呼び出し
  http.get('<検索用APIのURL>')
  // 商品詳細APIの呼び出し
  // できればランダムな商品を取得できるようにする
  http.get('<商品小策取得APIのURL>')
  // 商品を30%の確率でカートに入れる
  if (Math.random() < 0.3) {
    // カートに入れるAPI
    http.post('<商品をカートに入れるAPI>')
    // カートに入れた商品を50%の確率で決済する
    if (Math.random() < 0.5) {
      // 表示用にカートの中身の情報を取得
      http.get('<カートの情報を取得するAPIのURL>')
      // 届け先一覧を取得
      http.get('<保存済み住所一覧を取得するAPIのURL>')
      // 決済実行
      http.post('<決済実行APIのURL>')
    }
  }
}

k6でシナリオを記述する場合、JavaScriptでシナリオが書けるので、上記の例のように確率に基づくシナリオが容易に記述できます。他にもAPIを叩いた結果からレスポンスを抜き出して、その結果をサンプリングして次のリクエストを投げるといったことが実現できます。

リクエストの結果の集計

リクエストを行い、意図した結果が取得できているかどうかは check 関数によって確認します。下記の例ではレスポンスのステータスコードが 200 OK を返しているかどうかチェックしています。

import http from 'k6/http'

export default function () {
  const res = http.get('https://test.example.com')
  check(res, { 'is status 200' : (r) => r.status === 200 })
}

上記のテストを実行すると以下のように成功したテストの数が表示されます。

checks.........................: 100.00% ✓ 1        ✗ 0

ロードテスト時には成功しているかどうかをチェックすればよいですが、ストレステストの場合は以下の観点が発生します。

  • 正しいレスポンスを返すかどうか
  • 負荷でレスポンス時間が長くなった際に、意図したエラーが発生するかどうか

上記に関してはシナリオの check の条件を変えることで対応できます。まずは正しいレスポンスを返すかどうかのテストのまま負荷をかけ、失敗するレスポンスが現れるくらい負荷をかけたら条件を変更します。例えば一定の負荷がかかると 503 Service Unavailable を返すような設計にしている場合はその挙動が正しく動いているかどうかを確認します。

import http from 'k6/http'

export default function () {
  const res = http.get('https://test.example.com')
  // 503も成功とみなす
  check(res, { 'is status 200' : (r) => r.status === 200 || r.status === 503 })
}

このテストを実行した場合、503が返った場合も正常に処理が終わったとみなすようになるため、高負荷時の意図していない挙動が起こっているかどうかを判断することが出来ます。上記の例ではステータスコードで判断していますが、レスポンスボディ等で判断することも出来ます。

負荷試験の実行と検証

シナリオが実装が出来たら実際に実行してみましょう。ロードテストとストレステストでは設定する負荷が変わってくるので、それぞれについてどのような負荷設定をすればいいかを以下で解説します。

ロードテストの負荷設定

k6では負荷の調整はVU (並列実行数) とiteration (繰り返し実行数) で行います。JavaScriptで記述したシナリオを同時にどのくらい実行するか、また何回繰り返して実行するかを設定します。

ロードテストでは事前に想定されているユーザー数やPV数を元に並列実行数を算出します。算出の方法は以前別の記事で書いたフェルミ推定等を利用して行います。

k6のシナリオ実行結果は以下のように出力されます。

     checks.........................: 100.00% ✓ 13735      ✗ 0    
     data_received..................: 20 MB   338 kB/s
     data_sent......................: 61 MB   1.0 MB/s
     http_req_blocked...............: avg=144.28µs min=185ns       med=497ns    max=82.01ms  p(90)=730ns    p(95)=857ns   
     http_req_connecting............: avg=25.86µs  min=0s          med=0s       max=17.73ms  p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=29.73ms  min=3.96ms      med=27.51ms  max=162.72ms p(90)=42.49ms  p(95)=48.48ms 
       { expected_response:true }...: avg=29.73ms  min=3.96ms      med=27.51ms  max=162.72ms p(90)=42.49ms  p(95)=48.48ms 
     http_req_failed................: 0.00%   ✓ 0          ✗ 13755
     http_req_receiving.............: avg=674.03µs min=-15166344ns med=164.38µs max=40.09ms  p(90)=1.47ms   p(95)=2.83ms  
     http_req_sending...............: avg=154.25µs min=39.82µs     med=133.05µs max=11.19ms  p(90)=237.49µs p(95)=283.59µs
     http_req_tls_handshaking.......: avg=106.05µs min=0s          med=0s       max=66.31ms  p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=28.91ms  min=3.59ms      med=26.77ms  max=160.66ms p(90)=41.31ms  p(95)=47.11ms 
     http_reqs......................: 13755   229.378763/s
     vus............................: 30      min=30       max=30 
     vus_max........................: 30      min=30       max=30

上記のテスト結果のうち、特に注目してほしいのは checks , http_req_duration , http_reqs です。

checks については先ほど述べた通り、レスポンスに意図したとおりの結果が返ってきているかどうかを表します。こちらすべてのリクエストについて正しいレスポンスが返ってきていないと想定した負荷に対して対応できないことが予想されます。

http_req_duration については、HTTPリクエストを送ってからレスポンスが返ってくるまでの時間を表します。最小値、最大値、平均値、中間値、90%の値、95%の値がそれぞれ出力されています。例えば95%の値が48.48msというのは、全リクエストのうち95%が48.48ms以内にレスポンスを返していることを表しています。

http_reqs は総リクエスト数と秒間のスループットを表します。大事なのはスループットの方で、サイトの性能の指標になります。まだまだリソースに余裕がある場合は負荷を増やすとスループットもあわせて向上しますが、この値が頭打ちになると性能の限界がきたということが分かります。

想定した負荷をかけたうえで、異常なレスポンスが返っていないこと、レスポンスの時間が許容範囲であることを確認することがロードテストの目的となります。

ストレステストの負荷設定

ストレステストでは、想定以上の負荷をかけて、異常なレスポンスが返ってこないかどうか、またどのくらいの負荷でレスポンス速度が低下するか等を確認します。

基本的にはVUの値を増やしていくことで負荷を増やしますが、ある程度長時間負荷をかけ続けた際に発生する問題も存在するため、iterationの数も増やして長時間負荷をかけ続けるように設定します。

ストレステストを行う目的としては以下のものがあげられます。

  • 想定以上の負荷により、想定していないレスポンス等を返したりしないかを調べる
  • どのくらいの負荷でレスポンス速度が低下するかを調べる
  • レスポンス速度の低下や異常なレスポンスを返した場合、ボトルネックになっているリソースがどこかを調べる

例えばストレステストの結果、一定のリクエストでレスポンス速度の大幅な低下がみられた際、その際にどのリソースが飽和状態になっているかを確認することで、リクエスト数の上昇がみられた場合の対応方法を決めることが出来ます。

また、DBやAPIサーバーのメモリ使用率やCPU使用率を確認し、リソースの使用率がどのくらいになったらサービスのレスポンスが悪くなるかを調べることが出来るため、性能監視の指標を作ることが出来ます。

結果のレポート

k6では結果の出力に関して何も指定しない場合は標準出力にテスト結果のサマリーが出力されます。それに追加して、個々のリクエストに関するレポートを合わせて出力することも可能です。それにより、例えばストレステスト時に想定しないレスポンスがあった際、具体的にどのリクエストがどのようなレスポンスを返したかということを調べることが出来ます。

ローカルにCSVやJSON形式で出力できるほか、負荷試験実行中にリアルタイムで様々なサービスに実行ログをストリーミングすることが出来ます。これにより、長時間のストレステストを行う場合はその完了を待つことなくエラーが発生したリクエスト・レスポンスの情報を調査することが出来ます。

k6がサポートしているレポーティングのサービスは公式ドキュメントに一覧が記載されています。

おわりに

この記事では負荷試験ツールとしてk6の紹介と、それを利用してロードテスト、ストレステストを行う例について紹介しました。サービスを運用するにあたって、自分たちのサービスがどのくらいのユーザーを受け入れ可能なのかを知っておくことはとても重要です。また、通常のユニットテスト等では気づけないような「入力と出力は合ってるけど、実装方法に問題がありパフォーマンスが著しく悪い」処理を事前に発見することも出来ます。

皆様、ぜひ楽しい負荷試験ライフを!