アプリケーションは開発や運用を続けていく中で、徐々にパフォーマンスが低下します。機能が追加されていったり、データが増える中で、ユーザーに快適な体験を提供し続けるためには、定期的に負荷テストを実施し、パフォーマンスの劣化を検知することが重要です。

そうしたパフォーマンスの測定に使えるツールとして、k6があります。k6はオープンソースの負荷テストツールで、HTTP/HTTPSだけでなく、gRPCやWebSocketなどもサポートしています。また、ブラウザーベースの負荷テストも可能です。

この記事では、k6の基本的な使い方を紹介します。

k6のインストール

k6は、OSによってインストール方法が異なります。

Windowsの場合

Windowsの場合、公式のインストーラーが提供されています。この他、Chocolateyや Windows Package Managerを使ってインストールすることも可能です。

# Chocolateyの場合
choco install k6
# Windows Package Managerの場合
winget install k6 --source winget

macOSの場合

macOSの場合、Homebrewを使ってインストールするのが簡単です。

brew install k6

Linuxの場合

Linuxの場合、公式のAPTリポジトリやYUMリポジトリを使ってインストールできます。例えば、Ubuntuの場合は以下のようにします。

sudo gpg -k
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 k6

Fedoraの場合は以下のようにします。

sudo dnf install https://dl.k6.io/rpm/repo.rpm
sudo dnf install k6

Dockerの場合

k6はDockerイメージも提供されています。Dockerを使っている場合は、以下のようにしてk6を実行できます。

docker pull grafana/k6

もしブラウザも利用する場合には、以下のイメージを利用します。

docker pull grafana/k6:master-with-browser

k6の基本的な使い方

k6はJavaScriptでテストスクリプトを記述します。まず、以下のようなコード script.js を記述します。

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  iterations: 10,
};

export default function () {
  http.get('https://quickpizza.grafana.com');
  sleep(1);
}

このコードは、 https://quickpizza.grafana.com に対して10回のHTTP GETリクエストを送信し、各リクエストの後に1秒間のスリープを行います。

実行する際には、 k6 run コマンドを使います。結果は以下のように表示されます。

% k6 run script.js 

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

     execution: local
        script: script.js
        output: -

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

  █ TOTAL RESULTS 

    HTTP
    http_req_duration..............: avg=250.75ms min=171.87ms med=187.06ms max=629.81ms p(90)=476.27ms p(95)=527.11ms
      { expected_response:true }...: avg=250.75ms min=171.87ms med=187.06ms max=629.81ms p(90)=476.27ms p(95)=527.11ms
    http_req_failed................: 0.00%  0 out of 230
    http_reqs......................: 230    7.48289/s

    EXECUTION
    iteration_duration.............: avg=1.31s    min=1.17s    med=1.18s    max=2.62s    p(90)=1.52s    p(95)=1.63s   
    iterations.....................: 230    7.48289/s
    vus............................: 10     min=10       max=10
    vus_max........................: 10     min=10       max=10

    NETWORK
    data_received..................: 786 kB 26 kB/s
    data_sent......................: 17 kB  566 B/s

running (0m30.7s), 00/10 VUs, 230 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs  30s

ブラウザを使った負荷テスト

ブラウザを使ったテストも書けます。k6では、Chromiumベースのブラウザを使って、実際のユーザーが行うような操作をシミュレートできます。以下は、ブラウザを使ったテストスクリプトの例です。

まずテンプレートを使って、新しいスクリプトを作成します。

k6 new --template browser browser-script.js

内容は以下のようになっています。

import http from "k6/http";
import exec from 'k6/execution';
import { browser } from "k6/browser";
import { sleep, fail } from 'k6';
import { expect } from "https://jslib.k6.io/k6-testing/0.5.0/index.js";

const BASE_URL = __ENV.BASE_URL || "https://quickpizza.grafana.com";

export const options = {
  scenarios: {
    ui: {
      executor: "shared-iterations",
      vus: 1,
      iterations: 1,
      options: {
        browser: {
          type: "chromium",
        },
      },
    },
  },
};

export function setup() {
  let res = http.get(BASE_URL);
  expect(res.status, Got unexpected status code ${res.status} when trying to setup. Exiting.).toBe(200);
}

export default async function() {
  let checkData;
  const page = await browser.newPage();

  try {
    await page.goto(BASE_URL);
    await expect.soft(page.locator("h1")).toHaveText("Looking to break out of your pizza routine?");

    await page.locator('//button[. = "Pizza, Please!"]').click();
    await page.waitForTimeout(500);

    await page.screenshot({ path: "screenshot.png" });
    await expect.soft(page.locator("div#recommendations")).not.toHaveText("");
  } catch (error) {
    fail(Browser iteration failed: ${error.message});
  } finally {
    await page.close();
  }

  sleep(1);
}

処理内容としては、 https://quickpizza.grafana.com にアクセスし、ページ内の特定の要素を検証し、ボタンをクリックしてからスクリーンショットを保存します。

実行すると、以下のような結果が得られます。

% k6 run browser-script.js

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

     execution: local
        script: browser-script.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):
              * ui: 1 iterations shared among 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)

  █ TOTAL RESULTS 

    HTTP
    http_req_duration..............: avg=450.95ms min=450.95ms med=450.95ms max=450.95ms p(90)=450.95ms p(95)=450.95ms
      { expected_response:true }...: avg=450.95ms min=450.95ms med=450.95ms max=450.95ms p(90)=450.95ms p(95)=450.95ms
    http_req_failed................: 0.00%  0 out of 1
    http_reqs......................: 1      0.081764/s

    EXECUTION
    iteration_duration.............: avg=5.82s    min=5.82s    med=5.82s    max=5.82s    p(90)=5.82s    p(95)=5.82s   
    iterations.....................: 1      0.081764/s
    vus............................: 1      min=0       max=1
    vus_max........................: 1      min=1       max=1

    NETWORK
    data_received..................: 7.2 kB 590 B/s
    data_sent......................: 563 B  46 B/s

    BROWSER
    browser_data_received..........: 339 kB 28 kB/s
    browser_data_sent..............: 7.1 kB 583 B/s
    browser_http_req_duration......: avg=996.01ms min=180.12ms med=916.58ms max=1.63s    p(90)=1.62s    p(95)=1.62s   
    browser_http_req_failed........: 0.00%  0 out of 25

    WEB_VITALS
    browser_web_vital_cls..........: avg=0.031895 min=0.031895 med=0.031895 max=0.031895 p(90)=0.031895 p(95)=0.031895
    browser_web_vital_fcp..........: avg=3.49s    min=3.49s    med=3.49s    max=3.49s    p(90)=3.49s    p(95)=3.49s   
    browser_web_vital_fid..........: avg=199.99µs min=199.99µs med=199.99µs max=199.99µs p(90)=199.99µs p(95)=199.99µs
    browser_web_vital_inp..........: avg=16ms     min=16ms     med=16ms     max=16ms     p(90)=16ms     p(95)=16ms    
    browser_web_vital_ttfb.........: avg=988ms    min=988ms    med=988ms    max=988ms    p(90)=988ms    p(95)=988ms   

running (00m12.2s), 0/1 VUs, 1 complete and 0 interrupted iterations
ui   ✓ [======================================] 1 VUs  00m10.6s/10m0s  1/1 shared iters

画像も保存されています。

APIテストの例

k6では、APIテストも簡単に記述できます。以下は、JSON/gRPC/WebSocketの各種APIテストの例です。

JSON APIテストの例

JSONリクエストを実行するようなAPIテストも可能です。

import http from 'k6/http';

export default function () {
  const payload = JSON.stringify({
    name: 'lorem',
    surname: 'ipsum',
  });
  const headers = { 'Content-Type': 'application/json' };
  http.post('https://quickpizza.grafana.com/api/post', payload, { headers });
}

これを実行した結果です。

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

     execution: local
        script: api-script.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)

  █ TOTAL RESULTS 

    HTTP
    http_req_duration..............: avg=180.2ms min=180.2ms med=180.2ms max=180.2ms p(90)=180.2ms p(95)=180.2ms
      { expected_response:true }...: avg=180.2ms min=180.2ms med=180.2ms max=180.2ms p(90)=180.2ms p(95)=180.2ms
    http_req_failed................: 0.00%  0 out of 1
    http_reqs......................: 1      0.650015/s

    EXECUTION
    iteration_duration.............: avg=1.53s   min=1.53s   med=1.53s   max=1.53s   p(90)=1.53s   p(95)=1.53s  
    iterations.....................: 1      0.650015/s
    vus............................: 1      min=1      max=1
    vus_max........................: 1      min=1      max=1

    NETWORK
    data_received..................: 4.5 kB 2.9 kB/s
    data_sent......................: 653 B  425 B/s

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

gRPCテストの例

以下はgRPCの例です。

import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';

// Download quickpizza.proto for grpc-quickpizza.grafana.com, located at:
// https://raw.githubusercontent.com/grafana/quickpizza/refs/heads/main/proto/quickpizza.proto
// and put it in the same folder as this script.
const client = new grpc.Client();
client.load(null, 'quickpizza.proto');

export default () => {
  client.connect('grpc-quickpizza.grafana.com:443', {
    // plaintext: false
  });

  const data = { ingredients: ['Cheese'], dough: 'Thick' };
  const response = client.invoke('quickpizza.GRPC/RatePizza', data);

  check(response, {
    'status is OK': (r) => r && r.status === grpc.StatusOK,
  });

  console.log(JSON.stringify(response.message));

  client.close();
  sleep(1);
};

実行結果です。

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

     execution: local
        script: grpc-script.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)

INFO[0001] {"starsRating":2}                             source=console

  █ TOTAL RESULTS 

    checks_total.......: 1       0.398957/s
    checks_succeeded...: 100.00% 1 out of 1
    checks_failed......: 0.00%   0 out of 1

    ✓ status is OK

    EXECUTION
    iteration_duration...: avg=2.5s     min=2.5s     med=2.5s     max=2.5s     p(90)=2.5s     p(95)=2.5s    
    iterations...........: 1      0.398957/s
    vus..................: 1      min=1      max=1
    vus_max..............: 1      min=1      max=1

    NETWORK
    data_received........: 4.1 kB 1.6 kB/s
    data_sent............: 814 B  325 B/s

    GRPC
    grpc_req_duration....: avg=180.74ms min=180.74ms med=180.74ms max=180.74ms p(90)=180.74ms p(95)=180.74ms

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

WebSocketテストの例

WebSocketの例です。

import { randomString, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import ws from 'k6/ws';
import { check, sleep } from 'k6';

const sessionDuration = randomIntBetween(3000, 6000); // user session between 3s and 6s

export const options = {
  vus: 10,
  iterations: 10,
};

export default function () {
  const url = wss://quickpizza.grafana.com/ws;
  const params = { tags: { my_tag: 'my ws session' } };
  const user = user_${__VU};

  const res = ws.connect(url, params, function (socket) {
    socket.on('open', function open() {
      console.log(VU ${__VU}: connected);

      socket.send(JSON.stringify({ msg: 'Hello!', user: user }));

      socket.setInterval(function timeout() {
        socket.send(
          JSON.stringify({
            user: user,
            msg: I'm saying ${randomString(5)},
            foo: 'bar',
          })
        );
      }, randomIntBetween(1000, 2000)); // say something every 1-2 seconds
    });

    socket.on('ping', function () {
      console.log('PING!');
    });

    socket.on('pong', function () {
      console.log('PONG!');
    });

    socket.on('close', function () {
      console.log(VU ${__VU}: disconnected);
    });

    socket.on('message', function (message) {
      const data = JSON.parse(message);
      console.log(VU ${__VU} received message: ${data.msg});
    });

    socket.setTimeout(function () {
      console.log(VU ${__VU}: ${sessionDuration}ms passed, leaving the website);
      socket.send(JSON.stringify({ msg: 'Goodbye!', user: user }));
    }, sessionDuration);

    socket.setTimeout(function () {
      console.log(Closing the socket forcefully 3s after graceful LEAVE);
      socket.close();
    }, sessionDuration + 3000);
  });

  check(res, { 'Connected successfully': (r) => r && r.status === 101 });
  sleep(1);
}

実行結果です。WebSocket接続が確立され、メッセージの送受信が行われていることがわかります。メッセージは大量なので、一部を省略しています。

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

     execution: local
        script: ws-script.js
        output: -

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

INFO[0002] VU 3: connected                               source=console
  :
INFO[0010] VU 3: disconnected                            source=console

  █ TOTAL RESULTS 

    checks_total.......: 10      0.86777/s
    checks_succeeded...: 100.00% 10 out of 10
    checks_failed......: 0.00%   0 out of 10

    ✓ Connected successfully

    EXECUTION
    iteration_duration....: avg=10.37s min=8.99s med=10.26s max=11.52s p(90)=11.31s p(95)=11.41s
    iterations............: 10    0.86777/s
    vus...................: 3     min=3       max=10
    vus_max...............: 10    min=10      max=10

    NETWORK
    data_received.........: 57 kB 4.9 kB/s
    data_sent.............: 11 kB 989 B/s

    WEBSOCKET
    ws_connecting.........: avg=1.67s  min=1.67s med=1.67s  max=1.67s  p(90)=1.67s  p(95)=1.67s 
    ws_msgs_received......: 156   13.537209/s
    ws_msgs_sent..........: 66    5.727281/s
    ws_session_duration...: avg=9.37s  min=7.99s med=9.26s  max=10.52s p(90)=10.3s  p(95)=10.41s
    ws_sessions...........: 10    0.86777/s

running (00m11.5s), 00/10 VUs, 10 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs  00m11.5s/10m0s  10/10 shared iters

HTMLパースの例

HTMLをパースして、テストも可能です。

import { parseHTML } from 'k6/html';
import http from 'k6/http';

export default function () {
  const res = http.get('https://k6.io');
  const doc = parseHTML(res.body); // equivalent to res.html()
  const pageTitle = doc.find('head title').text();
  const langAttr = doc.find('html').attr('lang');
}

Azure Active Directoryを使ったOAuth認証の例

さらに複雑な例として、Azure Active Directoryを使ったOAuth認証の例を示します。

import http from 'k6/http';

/**
 * Authenticate using OAuth against Azure Active Directory
 * @function
 * @param  {string} tenantId - Directory ID in Azure
 * @param  {string} clientId - Application ID in Azure
 * @param  {string} clientSecret - Can be obtained from https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-app#create-a-client-secret
 * @param  {string} scope - Space-separated list of scopes (permissions) that are already given consent to by admin
 * @param  {string} resource - Either a resource ID (as string) or an object containing username and password
 */
export function authenticateUsingAzure(tenantId, clientId, clientSecret, scope, resource) {
  let url;
  const requestBody = {
    client_id: clientId,
    client_secret: clientSecret,
    scope: scope,
  };

  if (typeof resource == 'string') {
    url = https://login.microsoftonline.com/${tenantId}/oauth2/token;
    requestBody['grant_type'] = 'client_credentials';
    requestBody['resource'] = resource;
  } else if (
    typeof resource == 'object' &&
    resource.hasOwnProperty('username') &&
    resource.hasOwnProperty('password')
  ) {
    url = https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token;
    requestBody['grant_type'] = 'password';
    requestBody['username'] = resource.username;
    requestBody['password'] = resource.password;
  } else {
    throw 'resource should be either a string or an object containing username and password';
  }

  const response = http.post(url, requestBody);

  return response.json();
}

まとめ

k6は、シンプルで使いやすい負荷テストツールであり、様々なプロトコルやシナリオに対応しています。実行を繰り返したり、要素を中酒したテストなども可能です。単純なネットワークアクセスはもちろん、ヘッドレスブラウザを使ったアクセスも可能なので、実際のユーザー行動に近い形での負荷テストも実現できます。

ぜひk6を活用して、効果的な負荷テストを行ってください。

Load testing for engineering teams | Grafana k6