ISUCON本のハンズオンでk6を使ってみた

## はじめに
現在、プログラミング学習コミュニティでISUCONの勉強会に参加しており、[『達人が教えるWebパフォーマンスチューニング〜ISUCONから学ぶ高速化の実践』](https://gihyo.jp/book/2022/978-4-297-12846-3)を読みながら学習を進めています。この記事はその学習記録として作成しました。
過去の記事はこちらです。
[:page_facing_up:『ISUCON本で初めてのパフォーマンスチューニングに挑戦してみた』](https://qiita.com/kaito_399/items/e1d8a383bacd534db641)

## 動作環境
- mac OS Sonoma 14.2.1
- Homebrew 4.2.4

## ハンズオンの概要
4章ではk6というテストツールを使って、iscogramというパフォーマンスチューニング練習用のWebアプリに対してシナリオを持った負荷試験を実行する方法が書かれています。
しかし、そのままハンズオンをしてもつまらないので、今回は自分がポートフォリオとして作成中のSNSアプリに対してシナリオを持った負荷試験を実行してみました！

[1. k6を使った負荷試験を試してみる](#k6を使った負荷試験を試してみる)
[2. シナリオを作成する](#シナリオを作成する)
[3. シナリオを使った負荷試験を実行する](#シナリオを使った負荷試験を実行する)

## k6を使った負荷試験を試してみる
### k6とは
- 負荷試験を実行することができるオープンソースのテストツール
- 負荷試験のシナリオはJavaScriptで作成することができる
- 開発者が使いやすいAPIを持ったCLIが提供されている

<details><summary>API</summary>

- Application Programming Interfaceの略称
- アプリケーション同士で相互に情報をやり取りするためのインターフェースのこと
</details>

<details><summary>CLI</summary>

- Command Line Interfaceの略称
- コンピューターやソフトウェアとの対話を行うためのインターフェースのこと
- テキストベースのコマンドを入力して操作することができる
</details>

### k6をインストールする
私はmac環境なのでHomebrewを使ってインストールしました。
他の環境の方は下記を参照してください。
[:page_facing_up:k6公式ドキュメント](https://k6.io/docs/get-started/installation/)

```
$ brew install k6
```
バージョンを確認して表示されればOK
```
$ k6 --version
=> k6 v0.50.0 (go1.22.1, darwin/arm64)
```

### シンプルなシナリオを作成する
ログインページにGETリクエストを送信するだけのシナリオを作成してみます。
```:test.js
// HTTPリクエストを送信するための関数をインポート
import http from "k6/http";

// 負荷試験に使用するURLを定義
const BASE_URL = "http://123.456.78.90/login";

// GETリクエストを送信する関数を定義
export default function(){
	http.get(`${BASE_URL}/`);
}
```
`test.js`があるディレクトリで以下のコマンドを実行します。
`—vus`オプションで並列度を、`--duration`オプションで実行時間[s]を指定してしています。
```
$ k6 run --vus 1 --duration 30s test.js
```
以下のような実行結果が表示されればOK!
<details><summary>実行結果</summary>

```
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 1 looping VUs for 30s (gracefulStop: 30s)


     data_received..................: 5.6 MB 185 kB/s
     data_sent......................: 75 kB  2.5 kB/s
     http_req_blocked...............: avg=40.93µs  min=1µs     med=4µs     max=32.15ms p(90)=8µs     p(95)=10µs    
     http_req_connecting............: avg=35.61µs  min=0s      med=0s      max=31.66ms p(90)=0s      p(95)=0s      
     http_req_duration..............: avg=33.53ms  min=31.21ms med=33.39ms max=44.39ms p(90)=34.71ms p(95)=35.2ms  
       { expected_response:true }...: avg=33.53ms  min=31.21ms med=33.39ms max=44.39ms p(90)=34.71ms p(95)=35.2ms  
     http_req_failed................: 0.00%  ✓ 0         ✗ 889
     http_req_receiving.............: avg=198.91µs min=51µs    med=149µs   max=7.03ms  p(90)=227µs   p(95)=268.59µs
     http_req_sending...............: avg=24.21µs  min=7µs     med=23µs    max=117µs   p(90)=36µs    p(95)=41µs    
     http_req_tls_handshaking.......: avg=0s       min=0s      med=0s      max=0s      p(90)=0s      p(95)=0s      
     http_req_waiting...............: avg=33.31ms  min=30.17ms med=33.19ms max=44.26ms p(90)=34.51ms p(95)=34.96ms 
     http_reqs......................: 889    29.622859/s
     iteration_duration.............: avg=33.73ms  min=31.34ms med=33.57ms max=67.11ms p(90)=34.9ms  p(95)=35.39ms 
     iterations.....................: 889    29.622859/s
     vus............................: 1      min=1       max=1
     vus_max........................: 1      min=1       max=1
```
</details>

各メトリクスの意味を調べてみたのでトグルにまとめておきます。
とりあえず、スループットに相当する`http_req`と、レイテンシに相当する`iteration_duration`を覚えておけば良さそうです。
<details><summary>k6のメトリクス一覧</summary>

| メトリクス                      | 説明                                                       |
|-------------------------------|-----------------------------------------------------------|
| data_received                  | サーバーから受信したデータの合計量                              |
| data_sent                      | クライアントから送信したデータの合計量                           |
| http_req_blocked              | リクエストがブロックされた時間の                                |
| http_req_connecting           | リクエストが接続を確立するのに要した時間                          |
| http_req_duration             | リクエストの完了に要した時間（レイテンシ）                        |             
| http_req_failed               | リクエストが失敗した割合                                       |
| http_req_receiving            | レスポンスを受信するのに要した時間                               |
| http_req_sending              | リクエストを送信するのに要した時間                               |
| http_req_tls_handshaking      | TLSハンドシェイクが行われた時間                                 |
| http_req_waiting              | リクエストがサーバーの応答を待機していた時間                      |
| http_reqs                     | リクエストの総数およびリクエストの実行速度（スループット）   |
| iteration_duration            | イテレーション（1回のテストサイクル）の完了に要した時間             |
| iterations                    | イテレーションの総数およびイテレーションの実行速度         |
| vus                           | 同時に実行されている仮想ユーザー数                               |
| vus_max                       | テスト実行中に最大で同時に実行されていた仮想ユーザー数              |
</details>

## シナリオを作成する
**＜共通で使用する関数を定義する＞**
負荷試験対象のURLを生成するための関数を定義してconfig.jsとして保存する。
```:config.js
// 負荷試験に使用するベースURLを定義
const BASE_URL = "http://123.456.90";

// 引数としてpathを受け取り、完全なURLを生成して返す関数を定義
export function url(path) {
  return `${BASE_URL}${path}`;
}
```

**＜シナリオで使用するアカウント情報を定義する＞**
今回のシナリオではログイン処理を入れようと思います。
並列度を上げてシナリオを実行する際、複数のアカウントでリクエストを送信できるように複数のアカウント情報をJSON形式のファイルで用意します。

```:accounts.json
[
    {
        "email": "test_01@example.com",
        "password": "password_01"
    },
    {
        "email": "test_02@example.com",
        "password": "password_02"
    },
    {
        "email": "test_03@example.com",
        "password": "password_03"
    },
    {
        "email": "test_04@example.com",
        "password": "password_04"
    }
]
```
**＜アカウント情報を読み込むモジュールを定義する＞**
JSON形式のアカウント情報をSharedArray型として読み込むモジュールを作成します。

<details><summary>SharedArray型とは</summary>

- k6で用意されている配列的のような使い方のできるオブジェクト
- [:page_facing_up:k6公式ドキュメント:SharedArray](https://k6.io/docs/javascript-api/k6-data/sharedarray/)
</details>

```:get_account.js
// SharedArrayをimport
import { SharedArray } from "k6/data";

// accounts.jsonを読み込んでSharedArray型で保持する
const accounts = new SharedArray('accounts', function () {
    return JSON.parse(open('./accounts.json'));
});

// SharedArrayからアカウント情報をランダムに1件取り出して返す
export function getAccount() {
    return accounts[Math.floor(Math.random() * accounts.length)]
}
```

**＜ログインして投稿一覧を表示するシナリオを作成する＞**
以下のシナリオを持つ関数を定義する。
- ログインページにGETリクエストを送信する
- ログインフォームのCSRF対策用トークンを取得する
- ログインのPOSTリクエストを送信する
- 投稿一覧ページにGETリクエストを送信する

<details><summary>CSRFとは</summary>

- Cross-Site Request Forgeryの略称
- Webアプリケーションの脆弱性を利用した攻撃の一種
- Webサイトとのログイン状態(セッション)が維持されている状態で、攻撃者が用意した他のサイトのリンクを踏むとユーザーが意図しないリクエストが送信される攻撃
- [:page_facing_up:Railsガイドの解説](https://railsguides.jp/security.html#%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA%EF%BC%88csrf%EF%BC%89)
</details>

```:login_and_view_tweets.js
// k6からhttp処理のmuduleをimport
import http from "k6/http";

// k6からHTMLをパースする関数をimport
import { parseHTML } from 'k6/html';

// k6からcheck関数をimport
import { check } from "k6";

// config.jsで定義したurl関数をimport
import { url } from "./config.js";

// get_account.jsで定義したgetAccount関数をimport
import { getAccount } from "./get_account.js";

// ログインしてから投稿一覧ページを表示するシナリオ関数
export default function (){
	// ログインページをGETリクエストで取得
	const loginPage = http.get(url("/login"));

	// HTMLをパース
	const doc = parseHTML(loginPage.body);

	// action="/login"のフォーム要素を見つける
	const form = doc.find('form[action="/login"]');

	// CSRFトークンの値を取得
	const loginCsrfToken = form.find('input[name="authenticity_token"]').attr('value');

	// アカウント情報を取得
	const account = getAccount();

	// /loginに対してemailとパスワードを送信
	const loginResponse = http.post(url("/login"), {
		email: account.email,
		password: account.password,
		authenticity_token: loginCsrfToken
	});

	// レスポンスのステータスコードが200であることを確認
	check(loginResponse, {
		"is status 200": (r) => r.status === 200, 
	});

	// 投稿一覧ページ"/tweets"にGETリクエストを送信
	const tweetsResponse = http.get(url("/tweets"));

	// レスポンスのステータスコードが200であることを確認
	check(tweetsResponse, {
		"is status 200": (r) => r.status === 200,
	});
}
```

## シナリオを使った負荷試験を実行する
### 並列度を変えてシナリオを実行してみる
作成した`login_and_view_tweets.js`を使って負荷試験を30[s]実行してみます。
並列度`--vus`を1から4まで変更して結果を比較してみました。
```
k6 run --vus 1 --duration 30s login_and_view_tweets.js
```

<details><summary>並列度1の実行結果</summary>

```
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: login_and_view_tweets.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 1 looping VUs for 30s (gracefulStop: 30s)


     ✓ is status 200

     checks.........................: 100.00% ✓ 118      ✗ 0  
     data_received..................: 1.6 MB  52 kB/s
     data_sent......................: 114 kB  3.8 kB/s
     http_req_blocked...............: avg=139.66µs min=1µs      med=6µs      max=31.29ms  p(90)=12µs     p(95)=13µs    
     http_req_connecting............: avg=130.3µs  min=0s       med=0s       max=30.75ms  p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=126.73ms min=30.91ms  med=41.73ms  max=457.16ms p(90)=395.92ms p(95)=397.76ms
       { expected_response:true }...: avg=126.73ms min=30.91ms  med=41.73ms  max=457.16ms p(90)=395.92ms p(95)=397.76ms
     http_req_failed................: 0.00%   ✓ 0        ✗ 236
     http_req_receiving.............: avg=226.18µs min=69µs     med=216.5µs  max=1.55ms   p(90)=309µs    p(95)=327.25µs
     http_req_sending...............: avg=39.3µs   min=7µs      med=37.5µs   max=247µs    p(90)=56µs     p(95)=61.24µs 
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=126.46ms min=30.74ms  med=41.47ms  max=456.85ms p(90)=395.59ms p(95)=397.42ms
     http_reqs......................: 236     7.856294/s
     iteration_duration.............: avg=509.12ms min=449.32ms med=511.73ms max=577.97ms p(90)=555.07ms p(95)=568.57ms
     iterations.....................: 59      1.964073/s
     vus............................: 1       min=1      max=1
     vus_max........................: 1       min=1      max=1
```
</details>

<details><summary>並列度2の実行結果</summary>

```
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: login_and_view_tweets.js
        output: -

     scenarios: (100.00%) 1 scenario, 2 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 2 looping VUs for 30s (gracefulStop: 30s)


     ✓ is status 200

     checks.........................: 100.00% ✓ 148      ✗ 0  
     data_received..................: 2.0 MB  65 kB/s
     data_sent......................: 143 kB  4.7 kB/s
     http_req_blocked...............: avg=182.95µs min=1µs      med=8µs      max=25.98ms  p(90)=11µs     p(95)=12µs    
     http_req_connecting............: avg=167.15µs min=0s       med=0s       max=24.76ms  p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=203.71ms min=30.98ms  med=52.19ms  max=759.74ms p(90)=676.97ms p(95)=681.69ms
       { expected_response:true }...: avg=203.71ms min=30.98ms  med=52.19ms  max=759.74ms p(90)=676.97ms p(95)=681.69ms
     http_req_failed................: 0.00%   ✓ 0        ✗ 296
     http_req_receiving.............: avg=889.63µs min=62µs     med=206.5µs  max=8.43ms   p(90)=3.54ms   p(95)=4.84ms  
     http_req_sending...............: avg=38.79µs  min=7µs      med=36µs     max=355µs    p(90)=55µs     p(95)=66µs    
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=202.79ms min=30.79ms  med=50.41ms  max=759.46ms p(90)=676.76ms p(95)=681.45ms
     http_reqs......................: 296     9.783835/s
     iteration_duration.............: avg=817.04ms min=711.12ms med=819.68ms max=910.95ms p(90)=840.35ms p(95)=858.03ms
     iterations.....................: 74      2.445959/s
     vus............................: 2       min=2      max=2
     vus_max........................: 2       min=2      max=2
```
</details>

<details><summary>並列度3の実行結果</summary>

```
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: login_and_view_tweets.js
        output: -

     scenarios: (100.00%) 1 scenario, 3 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 3 looping VUs for 30s (gracefulStop: 30s)


     ✓ is status 200

     checks.........................: 100.00% ✓ 172       ✗ 0  
     data_received..................: 2.3 MB  74 kB/s
     data_sent......................: 166 kB  5.4 kB/s
     http_req_blocked...............: avg=277.36µs min=0s       med=5µs      max=31.17ms p(90)=11µs     p(95)=12µs    
     http_req_connecting............: avg=263.14µs min=0s       med=0s       max=30.23ms p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=264.32ms min=31.56ms  med=55.82ms  max=1.48s   p(90)=722.24ms p(95)=891.29ms
       { expected_response:true }...: avg=264.32ms min=31.56ms  med=55.82ms  max=1.48s   p(90)=722.24ms p(95)=891.29ms
     http_req_failed................: 0.00%   ✓ 0         ✗ 344
     http_req_receiving.............: avg=1.25ms   min=54µs     med=216.5µs  max=9.51ms  p(90)=5.15ms   p(95)=6.31ms  
     http_req_sending...............: avg=38.16µs  min=7µs      med=33µs     max=554µs   p(90)=53µs     p(95)=57µs    
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s      p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=263.03ms min=31.41ms  med=54.49ms  max=1.48s   p(90)=721.93ms p(95)=890.95ms
     http_reqs......................: 344     11.230755/s
     iteration_duration.............: avg=1.05s    min=786.46ms med=872.17ms max=1.69s   p(90)=1.57s    p(95)=1.59s   
     iterations.....................: 86      2.807689/s
     vus............................: 3       min=3       max=3
     vus_max........................: 3       min=3       max=3
```
</details>

<details><summary>並列度4の実行結果</summary>

```
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: login_and_view_tweets.js
        output: -

     scenarios: (100.00%) 1 scenario, 4 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 4 looping VUs for 30s (gracefulStop: 30s)


     ✓ is status 200

     checks.........................: 100.00% ✓ 172       ✗ 0  
     data_received..................: 2.3 MB  73 kB/s
     data_sent......................: 166 kB  5.3 kB/s
     http_req_blocked...............: avg=381.42µs min=1µs      med=4µs     max=32.4ms  p(90)=10µs  p(95)=12µs   
     http_req_connecting............: avg=367.85µs min=0s       med=0s      max=31.66ms p(90)=0s    p(95)=0s     
     http_req_duration..............: avg=361.05ms min=30.35ms  med=65.57ms max=1.32s   p(90)=1.25s p(95)=1.29s  
       { expected_response:true }...: avg=361.05ms min=30.35ms  med=65.57ms max=1.32s   p(90)=1.25s p(95)=1.29s  
     http_req_failed................: 0.00%   ✓ 0         ✗ 344
     http_req_receiving.............: avg=1.03ms   min=41µs     med=176µs   max=13.44ms p(90)=4ms   p(95)=5.2ms  
     http_req_sending...............: avg=33.35µs  min=8µs      med=27µs    max=453µs   p(90)=46µs  p(95)=50.84µs
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s      max=0s      p(90)=0s    p(95)=0s     
     http_req_waiting...............: avg=359.98ms min=30.23ms  med=64.13ms max=1.32s   p(90)=1.25s p(95)=1.29s  
     http_reqs......................: 344     11.053335/s
     iteration_duration.............: avg=1.44s    min=815.44ms med=1.43s   max=2.13s   p(90)=1.54s p(95)=1.6s   
     iterations.....................: 86      2.763334/s
     vus............................: 4       min=4       max=4
     vus_max........................: 4       min=4       max=4
```
</details>

### 結果
並列度を変えた結果は以下のようになりました。
ログインして投稿一覧を表示するだけの簡単なシナリオですが、並列度3で頭打ちとなっているようです。
詳しく調べていませんが、nginxのworker_processesを2に設定しているのがボトルネックになっているのかなーと予想しています。
（EC2のインスタンスがt2.microなのでworker_processesを増やしても変わらないかも...）
| クライアント数| レイテンシ[ms]| スループット[request/sec] |
|:---------:|:----------:|:----------------------:|
| 1 | 126.7 | 7.85 |
| 2 | 203.7 | 9.78 |
| 3 | 264.3 | 11.23 |
| 4 | 361.1 | 11.05 |

## まとめ
今回のハンズオンを通して、k6を使ったシナリオを持った負荷試験について学ぶことができました。
実際に自分でシナリオを書くことで、Railsではログイン時にCSRF対策用トークンが必要であることなど新たな学びも得られてよかったです。

最後までお読みいただきありがとうございます！