k6やってみた（WordPressはどれだけの負荷に耐えられるのか？！）



# テスト対象

* Raspberry Pi4上に構築したWordPress
* サンプルで作成される`Hello World`のページを使用

![192.168.1.113_(1920x1080).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/209bd32f-6ae6-6da7-21ac-05baa5dbecf4.png)

※環境構築手順は末尾です。

# テスト実行

## 1. とりあえず実行

JavaScriptでテスト用のコードを書いて実行します。

```javascript:script_v1.js
import http from 'k6/http';
import { check, sleep, } from 'k6';

export default function () {
    const BASE_URL = 'http://192.168.1.113/'

    const res = http.get(BASE_URL, { tags: { name: 'base' } })

    check(res, {
        'status was 200': (r) => {
            return r.status == 200
        }
    })

    const responses = http.batch([
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/style.min.css?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/js/wp-emoji-release.min.js?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/style.css?ver=1.2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/view.min.js?ver=009e29110e016c14bac4ba0ecc809fcd`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/images/flight-path-on-transparent-d.png`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/favicon.ico`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/images/w-logo-blue-white-bg.png`, null, { tags: { name: 'png' } }],
    ]);

    responses.forEach(res => {
        check(res, {
            'status was 200': (r) => {
                return r.status == 200
            }
        })
    });
    
    sleep(1);
}
```

ブラウザからのアクセスを想定し、htmlの取得とページ中のjsやcssなども続けて取得する形にしてみました。
これを実行してみます。

```console
$ k6 run script_v1.js 
```
```console
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: script_v1.js
     output: -

  scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):
           * default: 1 iterations for each of 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)


running (00m01.5s), 0/1 VUs, 1 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  00m01.5s/10m0s  1/1 iters, 1 per VU

     ✓ status was 200

     checks.........................: 100.00% ✓ 9        ✗ 0  
     data_received..................: 646 kB  426 kB/s
     data_sent......................: 1.3 kB  887 B/s
     http_req_blocked...............: avg=5.93ms   min=5.97µs   med=8.02ms   max=11.12ms  p(90)=10.77ms  p(95)=10.94ms 
     http_req_connecting............: avg=2.43ms   min=0s       med=0s       max=10.62ms  p(90)=8.1ms    p(95)=9.36ms  
     http_req_duration..............: avg=74.13ms  min=8.42ms   med=18.62ms  max=349.01ms p(90)=155.72ms p(95)=252.37ms
       { expected_response:true }...: avg=74.13ms  min=8.42ms   med=18.62ms  max=349.01ms p(90)=155.72ms p(95)=252.37ms
     http_req_failed................: 0.00%   ✓ 0        ✗ 10 
     http_req_receiving.............: avg=18.1ms   min=212.95µs med=1.23ms   max=101.51ms p(90)=50.78ms  p(95)=76.14ms 
     http_req_sending...............: avg=159.56µs min=28.54µs  med=101.63µs max=415.48µs p(90)=383.11µs p(95)=399.29µs
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=55.87ms  min=7.83ms   med=14.87ms  max=321.85ms p(90)=152.4ms  p(95)=237.12ms
     http_reqs......................: 10      6.590794/s
     iteration_duration.............: avg=1.51s    min=1.51s    med=1.51s    max=1.51s    p(90)=1.51s    p(95)=1.51s   
     iterations.....................: 1       0.659079/s
     vus............................: 1       min=1      max=1
     vus_max........................: 1       min=1      max=1
```

1回のアクセスを行い、結果が出力されます。1回だけなので負荷はかかってないです。


## 2. 繰り返し実行させる

繰り返し実行させるには`options`を指定します。スモークテストを例にすると、`vus`(仮想ユーザー)が1で、`1m`(1分間)連続でアクセスし、99%のレスポンスが1.5秒以内だと成功となります。

```javascript:script_v2.js
import http from 'k6/http';
import { check, sleep, } from 'k6';

// Smoke testing
export const options = {
    vus: 1, // 1 user looping for 1 minute
    duration: '1m',
    thresholds: {
        http_req_duration: ['p(99)<1500'], // 99% of requests must complete below 1.5s
    },
};

export default function () {
    const BASE_URL = 'http://192.168.1.113/'

    const res = http.get(BASE_URL, { tags: { name: 'base' } })

    check(res, {
        'status was 200': (r) => {
            return r.status == 200
        }
    })

    const responses = http.batch([
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/style.min.css?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/js/wp-emoji-release.min.js?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/style.css?ver=1.2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/view.min.js?ver=009e29110e016c14bac4ba0ecc809fcd`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/images/flight-path-on-transparent-d.png`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/favicon.ico`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/images/w-logo-blue-white-bg.png`, null, { tags: { name: 'png' } }],
    ]);

    responses.forEach(res => {
        check(res, {
            'status was 200': (r) => {
                return r.status == 200
            }
        })
    });

    sleep(1);
}
```

```console
k6 run script_v2.js 
```
```console
          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: script_v2.js
     output: -

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


running (1m00.2s), 0/1 VUs, 42 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  1m0s

     ✓ status was 200

     checks.........................: 100.00% ✓ 378      ✗ 0  
     data_received..................: 27 MB   451 kB/s
     data_sent......................: 57 kB   938 B/s
     http_req_blocked...............: avg=111.51µs min=1.6µs   med=6.95µs  max=10.75ms  p(90)=15.2µs   p(95)=17.09µs 
     http_req_connecting............: avg=74.84µs  min=0s      med=0s      max=7.39ms   p(90)=0s       p(95)=0s      
   ✓ http_req_duration..............: avg=71.68ms  min=5.41ms  med=25.2ms  max=327.56ms p(90)=226.82ms p(95)=277.36ms
       { expected_response:true }...: avg=71.68ms  min=5.41ms  med=25.2ms  max=327.56ms p(90)=226.82ms p(95)=277.36ms
     http_req_failed................: 0.00%   ✓ 0        ✗ 420
     http_req_receiving.............: avg=21.83ms  min=48.98µs med=3.04ms  max=205.44ms p(90)=92.69ms  p(95)=111.21ms
     http_req_sending...............: avg=41.36µs  min=8.52µs  med=32.74µs max=526.69µs p(90)=59.85µs  p(95)=95.62µs 
     http_req_tls_handshaking.......: avg=0s       min=0s      med=0s      max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=49.81ms  min=4.13ms  med=14.97ms max=303.21ms p(90)=197.59ms p(95)=258.42ms
     http_reqs......................: 420     6.972959/s
     iteration_duration.............: avg=1.43s    min=1.4s    med=1.42s   max=1.5s     p(90)=1.45s    p(95)=1.48s   
     iterations.....................: 42      0.697296/s
     vus............................: 1       min=1      max=1
     vus_max........................: 1       min=1      max=1
```

## 3. Grafanaで可視化

コンソールに出力される結果だけだと分かりづらいので、InfluxDBにデータを投入し、Grafanaで可視化ができます。
実行時に`--out`で宛先を指定します。InfluxDBはv1にしか対応していないようです。

```console
k6 run --out influxdb=http://localhost:8086/myk6db script_v2.js
```

![localhost_3000_d_c7TyZPX7z_k6-load-testing-results_orgId=1&refresh=5s&from=now-15m&to=now(1920x1080).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/d86b7c16-db33-b476-5b42-cf8f57d83067.png)

# 4. どれだけのユーザー数に耐えられるか検証

以下の条件で試験を実施しました。

* 15秒ごとに1ユーザーずつ増加
* p95のリクエストが1.5秒以内に返却できることをしきい値として設定

`options`に`thresholds`を追加しています。`abortOnFail`などの指定により、条件を満たさなくなった際にテストが終了します。

```javascript:script_v3.js
import http from 'k6/http';
import { check, sleep, } from 'k6';

export const options = {
    stages: [
        { duration: '30m', target: 120 }, // below normal load
        { duration: '10m', target: 0 }, // scale down. Recovery stage.
    ],
    thresholds: {
        http_req_duration: [{ threshold: 'p(95)<1500', abortOnFail: true, delayAbortEval: '1m' }],
    },
};

export default function () {
    const BASE_URL = 'http://192.168.1.113/'; // make sure this is not production

    const res = http.get(BASE_URL, { tags: { name: 'base' } })

    check(res, {
        'status was 200': (r) => {
            return r.status == 200
        }
    })

    const responses = http.batch([
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/style.min.css?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/js/wp-emoji-release.min.js?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/style.css?ver=1.2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/view.min.js?ver=009e29110e016c14bac4ba0ecc809fcd`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/images/flight-path-on-transparent-d.png`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/favicon.ico`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/images/w-logo-blue-white-bg.png`, null, { tags: { name: 'png' } }],
    ]);

    responses.forEach(res => {
        check(res, {
            'status was 200': (r) => {
                return r.status == 200
            }
        })
    });

    sleep(1);
}
```

結果は、36ユーザーでしきい値に達しました。（これが多いのか、少ないのか、はどちらなんでしょう）

![localhost_3000_d_c7TyZPX7z_k6-load-testing-results_orgId=1&refresh=5s&from=now-15m&to=now(1920x1080) (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/3282174e-531a-9bdb-cc08-3d454a8e4565.png)


# 5. ソークテストもやってみた。

10分でソークテストなのかわかりませんが、段階的にアクセスを増やしてみます。

```javascript:script_v4.js
import http from 'k6/http';
import { check, sleep, fail } from 'k6';
import { Counter } from 'k6/metrics';

export const options = {
    stages: [
        { duration: '2m', target: 10 }, // below normal load
        { duration: '10m', target: 10 }, // below normal load
        { duration: '2m', target: 15 }, // below normal load
        { duration: '10m', target: 15 }, // below normal load
        { duration: '2m', target: 20 }, // below normal load
        { duration: '10m', target: 20 }, // below normal load
        { duration: '2m', target: 25 }, // below normal load
        { duration: '10m', target: 25 }, // below normal load
        { duration: '2m', target: 30 }, // below normal load
        { duration: '10m', target: 30 }, // below normal load
        { duration: '10m', target: 0 }, // scale down. Recovery stage.
    ],
    thresholds: {
        // fail and abort the test if 95th percentile response goes above 500ms
        // delay the threshold evaluation for 1 min to gather enought data
        http_req_duration: [{ threshold: 'p(95)<1500', abortOnFail: true, delayAbortEval: '1m' }],
    },
};



http.setResponseCallback(http.expectedStatuses({ min: 200, max: 399 }));

const errors = new Counter('errors');

export default function () {
    const BASE_URL = 'http://192.168.1.113/'; // make sure this is not production

    const res = http.get(BASE_URL, { tags: { name: 'base' } })

    check(res, {
        'status was 200': (r) => {
            return r.status == 200
        }
    })
    check(res, {
        'error code': (r) => {
            if (r.error_code != 0) {
                errors.add(1, { error_code: r.error_code })
                fail('error code was *not* 0');
            }
            return r.error_code == 0
        }
    })

    const responses = http.batch([
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/style.min.css?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/js/wp-emoji-release.min.js?ver=6.0`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/style.css?ver=1.2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/blocks/navigation/view.min.js?ver=009e29110e016c14bac4ba0ecc809fcd`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/images/flight-path-on-transparent-d.png`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/favicon.ico`, null, { tags: { name: 'png' } }],
        ['GET', `${BASE_URL}/wp-includes/images/w-logo-blue-white-bg.png`, null, { tags: { name: 'png' } }],
    ]);

    responses.forEach(res => {
        check(res, {
            'status was 200': (r) => {
                return r.status == 200
            }
        })
        check(res, {
            'error code': (r) => {
                if (r.error_code != 0) {
                    errors.add(1, { error_code: r.error_code })
                    fail('error code was *not* 0');
                }
                return r.error_code == 0
            }
        })
    });

    sleep(1);
}
```

うまく行きそうでしたが最後の最後でしきい値を超えましたね、残念。

![localhost_3000_d_c7TyZPX7z_k6-load-testing-results_orgId=1&refresh=5s&from=now-15m&to=now(1920x1080) (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/981f6ebd-491e-008b-9d64-17a18166f677.png)


# 参考情報（レスポンスタイムについて）

[k6のサイト](https://k6.io/docs/testing-guides/automated-performance-testing#know-your-goals)には[Nielsen Norman Groupからの引用](https://www.nngroup.com/articles/response-times-3-important-limits/)として以下の内容が記載されていました。

> * 0.1秒は、システムが瞬時に反応しているとユーザーに感じさせる限界です。つまり、結果を表示する以外に特別なフィードバックは必要ありません。
> * 1.0秒は、ユーザーが遅延に気付いたとしても、ユーザーの思考の流れが途切れないようにするための限界です。通常、0.1秒以上1.0秒未満の遅延の間は特別なフィードバックは必要ありませんが、ユーザーはデータを直接操作している感覚を失います。
> * 10秒は、ユーザーの注意を対話に集中させ続けるための制限についてです。より長い遅延の場合、ユーザーはコンピューターが終了するのを待っている間に他のタスクを実行したいので、コンピューターがいつ完了すると予想されるかを示すフィードバックをユーザーに提供する必要があります。応答時間が大きく変動する可能性がある場合、遅延中のフィードバックは特に重要です。これは、ユーザーが何を期待できるかわからないためです。

なるほどという思いと、結構シビアだなという思い。


# 環境構築

## テストされる側（Raspberry Pi4）

```yaml:docker-compose.yaml
version: '3.1'

services:

  nginx:
    image: nginx
    restart: always
    ports:
      - 80:80
    volumes:
      - wordpress:/var/www/html:ro
      - ./default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - wordpress
      - db

  wordpress:
    image: wordpress:6.0.0-fpm
    restart: always
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: exampleuser
      WORDPRESS_DB_PASSWORD: examplepass
      WORDPRESS_DB_NAME: exampledb
    volumes:
      - wordpress:/var/www/html
    depends_on:
      - db

  db:
    image: mariadb
    restart: always
    environment:
      MARIADB_DATABASE: exampledb
      MARIADB_USER: exampleuser
      MARIADB_PASSWORD: examplepass
      MARIADB_RANDOM_ROOT_PASSWORD: '1'
    volumes:
      - db:/var/lib/mysql

volumes:
  wordpress:
  db:
```

# テストする側

## k6のインストール

https://k6.io/docs/getting-started/installation/

```shell
sudo mkdir -p /root/.gnupg/
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install -y k6
```

## InfluxDBとGrafanaのインストール

InfluxDBは1.x系しか対応していないようです。

```yaml:docker-compose.yaml
version: '3'

services:
  influxdb:
    image: influxdb:1.2
    ports:
      - "8086:8086"
    expose:
      - 8086
    volumes:
      - ./influxdb:/var/lib/influxdb
    networks:
      - influx-grafana-network
  grafana:
    image: grafana/grafana-oss
    ports:
      - "3000:3000"
    volumes:
      - ./grafana:/var/lib/grafana
    networks:
      - influx-grafana-network
    user: "0"

networks:
  influx-grafana-network:
    external:
      name: influx-grafana-network
```

Grafanaのダッシュボードはこちら(2587)

https://grafana.com/grafana/dashboards/2587