【ハンズオン】k6で始める負荷テスト(第1回/全5回)

梅木 和弥
【ハンズオン】k6で始める負荷テスト(第1回/全5回)

はじめに

こんにちは。アプリケーションエンジニアの梅木です。

みなさんは、負荷テストに対してどのような印象を持っていますか?

  • APIの仕様を隅々まで整理しなきゃいけない
  • 非機能要件の定義が大変そう
  • そもそも検証用の実行環境を準備するのが億劫...

私自身、これまで負荷テストの経験が少なかったこともあり、「意外と考えることが多くて、腰が重くなってしまうリリース前の一大イベント」というイメージを抱いていました。
しかし、先日担当したプロダクトのリリースを控えたタイミングで、k6 という負荷テストツールに挑戦してみたところ、その印象はガラリと変わりました。

本記事では、「面倒じゃない、怖くないk6負荷テスト」をテーマに、全5回にわたるハンズオン形式の連載をお届けします。
初回の今回は、「10分で動かせるAPIテスト」として、k6を使った負荷試験の第一歩を踏み出してみましょう。

1. 負荷試験の立ち位置について

負荷試験を専任チームや外部ベンダーに依頼する形式だと、アプリエンジニアは「検証に携わらなくて済む」という利点はありました。
しかし、その分以下のような欠点も目立ちがちです。

従来であれば

負荷試験を "専任のチーム、外部のベンダーチーム"に依頼するような開発だと、シナリオ設計・検証環境の準備など、 悪く言えば携わらなくて済む、といった利点があるかと。
ただ、この開発フローだと欠点も目立ちやすいです。

例えば。

1. 仕様とかけ離れたシナリオ設計

シナリオ設計者が、仕様を一番理解している実装者(=アプリエンジニア)ではありません。

そのため、ユーザーの実際の挙動に即さない検証となるリスクがあります。

2. フィードバックループの遅延

「実装 → 外部での負荷測定 → ボトルネック発見 → 修正」というサイクルに時間がかかり、リリース直前に致命的な問題が発覚して慌てることになりかねません。

3. 「ブラックボックス」化

みなさんは、自分が書いたコードが実際のトラフィックでどう動くのか、限界点がどこなのかを説明出来ますか?
「インフラがよしなにしてくれるだろう」という前提でデプロイする不安が残ります。

アプリエンジニアが負荷試験に携わるべき理由

便利な検証ツールの台頭により、アプリエンジニアが積極的に負荷試験を行う理由は非常にポジティブなものになっています。

1. フィードバックの速度向上

開発終盤にまとめてやるのではなく、実装のついでに「ちょっと負荷をかける」ことが容易です。 負債が蓄積していない早い段階でボトルネックを見つけることで、修正コストを最小限に抑えられます。

2. パフォーマンスを「仕様」として定義できる

「レスポンスは速いほうがいい」という抽象的な目標を、「95%のリクエストが200ms以内に返ること」のようにコードで定義できます。期待するレスポンスタイムを閾値(Thresholds)としてコードに記述することで、機能要件と同じように「テスト可能な仕様」として扱えるようになります。

3. 「依頼して待つ」がなくなる

他チームへの依頼や調整コストをかけず、自分のローカル環境や開発フローの中で「思い立ったときに」測定を始められます。

2. k6 とは

今回の連載は k6 にフォーカスを当てました。
k6とは、grafana社が開発している負荷テストツールです。

特徴

  • JavaScript/TypeScriptで書ける:
    ユニットテストと同じ感覚で、普段使いの言語でシナリオを構築できます。
    ロジックのモジュール化、スクリプトの再利用、バージョン管理などが実現可能です。
  • プロトコル対応:
    ベーシックなHTTP通信だけでなく、WebSockets、gRPC、ブラウザ通信の負荷テストが実行できます。
  • コミュニティのエコシステムが大規模:
    k6を拡張して様々なニーズに対応が可能です。既に多くのユーザーが拡張機能をコミュニティで共有しているため、探索も可能です。
  • Go製で軽量かつ強力:
    実行バイナリ1つで動作し、低スペックマシンでも大量のトラフィックをシミュレーション可能です。
  • モダンなエコシステムとの親和性: GitHub Actionsでの自動化 や、Grafanaでの可視化 が標準でサポートされている。

3. ハンズオン: 手元の環境で動かしてみる

今回、教材を用意しました。 クローン後、すぐに検証が出来るものとなっています。

https://github.com/umekikazuya/k6-sandbox

Step 1: 開発環境のセットアップ

`Docker Compose` を使用して、検証用のモックサーバーを立ち上げます。

docker compose up -d mock-server

`curl` でのヘルスチェック確認。

# ヘルスチェック
curl http://localhost:3000/health

Step 2: 最初のテストを実行 (scenarios/01-simple-http.js)

リポジトリの `scenarios/01-basics/01-simple-http.js` を確認下さい。

k6の基本構造は非常にシンプルです。 「何人のユーザー(`VUs`)が」「何回(`Iterations`)実行するか」を `options` で定義します。

以下のシナリオでは、1ユーザーが1秒待機しながら10回繰り返す、というものです。

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

/**
 * 01. 最もシンプルなHTTPリクエスト
 * 
 * このスクリプトは、k6の最も基本的な使い方を示します。
 * - 1つのVirtual User(仮想ユーザー)で
 * - 10回のイテレーションを実行
 * - 各イテレーションで1秒待機
 */

export const options = {
  vus: 1,        // Virtual Users(仮想ユーザー数)
  iterations: 10, // 実行回数
};

export default function () {
  // GETリクエストを送信
  const response = http.get('http://localhost:3000/health');

  // レスポンスの基本情報をコンソールに出力
  console.log(`Status: ${response.status}, Body: ${response.body}`);

  // 次のイテレーションまで1秒待機
  sleep(1);
}

では、上記のシナリオで負荷テストの実行をします。

docker run --rm -i --network=host grafana/k6 run - < scenarios/01-basics/01-simple-http

Step 3: 結果の見方(基本メトリクス)

メトリクス

実行が完了すると、コンソールに統計レポートが表示されます。

まずは以下の3つの指標に注目しましょう。

  1. http_req_duration:
    リクエストの開始からレスポンス受信完了までの時間です。 p(95)(95%のリクエストがこの時間内に完了した)という値を確認する習慣をつけましょう。
  2. http_req_failed:
    リクエストの失敗率です。 最初のステップではここが 0.00% であることがゴールです。
  3. vus / iterations:
    意図した通りのユーザー数と回数で実行されたかを確認します。

5. 実務への第一歩:検証(`Checks`)と待機(`Sleep`)

5-1. 「正しく動いているか」を確かめる (`check`)

  k6の `check()` 関数を使うと、レスポンスのステータスコードや、ボディに含まれる特定のJSONフィールドを検証できます。
負荷試験中にサーバーがエラーを返していないか、論理的な検証を行うために必須の機能です。

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

/**
 * 03. レスポンスの検証(Checks)
 * 
 * k6のcheck機能を使用して、レスポンスが期待通りかを検証します。
 * - ステータスコードの確認
 * - レスポンスボディの確認
 * - レスポンスタイムの確認
 * - JSONの内容検証
 * 
 * 注意: checkが失敗してもテストは停止しません。
 * メトリクスとして記録され、最後にサマリーで表示されます。
 */

export const options = {
  vus: 2,
  duration: '10s',
};

export default function () {
  const baseUrl = 'http://localhost:3000/api';
  
  // ===== ヘルスチェックエンドポイント =====
  let response = http.get('http://localhost:3000/health');
  
  check(response, {
    'ヘルスチェック: ステータスは200': (r) => r.status === 200,
    'ヘルスチェック: レスポンスタイムは200ms以下': (r) => r.timings.duration < 200,
    'ヘルスチェック: statusフィールドは"ok"': (r) => {
      const body = JSON.parse(r.body);
      return body.status === 'ok';
    },
  });
  
  // ===== ユーザーAPI =====
  response = http.get(`${baseUrl}/users`);
  
  check(response, {
    'ユーザー取得: ステータスは200': (r) => r.status === 200,
    'ユーザー取得: successフィールドはtrue': (r) => {
      const body = JSON.parse(r.body);
      return body.success === true;
    },
    'ユーザー取得: dataフィールドが配列': (r) => {
      const body = JSON.parse(r.body);
      return Array.isArray(body.data);
    },
    'ユーザー取得: 最低1件のユーザーが存在': (r) => {
      const body = JSON.parse(r.body);
      return body.data.length > 0;
    },
  });
  
  // ===== POSTリクエストの検証 =====
  const payload = JSON.stringify({
    name: 'テストユーザー',
    email: 'test@example.com',
  });
  
  response = http.post(`${baseUrl}/users`, payload, {
    headers: { 'Content-Type': 'application/json' },
  });
  
  check(response, {
    'ユーザー作成: ステータスは201': (r) => r.status === 201,
    'ユーザー作成: IDが生成されている': (r) => {
      const body = JSON.parse(r.body);
      return body.data && body.data.id !== undefined;
    },
  });
  
  // ===== エラーケースの検証 =====
  response = http.get(`${baseUrl}/nonexistent`);
  
  check(response, {
    '存在しないエンドポイント: ステータスは404': (r) => r.status === 404,
  });
  
  sleep(1);
}

5-2. ユーザーの「思考時間」を再現する (sleep)

実世界のユーザーは、1秒間に何度もページをリロードしません。

sleep() を入れないテストは、サーバーへの過度な集中を招き、非現実的な負荷になってしまいます。
「1秒待機」などの適切なインターバルを設けることで、リアリティのある負荷を再現できます。

5-3. CRUD操作の網羅

02-http-methods.js では、GETだけでなくPOSTによるデータ作成や、PUT/DELETEによる更新・削除といった、RESTful APIの一連の操作をテストする方法を学べます。

以下、サンプルコードです。

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

/**
 * 02. HTTPメソッドのテスト
 * 
 * RESTful APIの基本的なCRUD操作をテストします。
 * - GET: リソースの取得
 * - POST: リソースの作成
 * - PUT: リソースの更新
 * - DELETE: リソースの削除
 */

export const options = {
  vus: 1,
  iterations: 5,
};

export default function () {
  const baseUrl = 'http://localhost:3000/api';

  // 1. GET - ユーザー一覧を取得
  console.log('=== GET Request ===');
  let response = http.get(`${baseUrl}/users`);
  console.log(`GET Status: ${response.status}`);

  // 2. POST - 新しいユーザーを作成
  console.log('\n=== POST Request ===');
  const payload = JSON.stringify({
    name: '山田太郎',
    email: 'yamada@example.com',
  });

  const params = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  response = http.post(`${baseUrl}/users`, payload, params);
  console.log(`POST Status: ${response.status}`);
  console.log(`Created User: ${response.body}`);

  // 3. PUT - ユーザー情報を更新
  console.log('\n=== PUT Request ===');
  const updatePayload = JSON.stringify({
    name: '山田花子',
    email: 'yamada.hanako@example.com',
  });

  response = http.put(`${baseUrl}/users/1`, updatePayload, params);
  console.log(`PUT Status: ${response.status}`);

  // 4. DELETE - ユーザーを削除
  console.log('\n=== DELETE Request ===');
  response = http.del(`${baseUrl}/users/1`);
  console.log(`DELETE Status: ${response.status}`);

  sleep(1);
}

5-4. 環境変数の外部化

JavaScriptでのテストコードの実装が可能です。
そのため、実行時に環境変数を利用することが可能です。

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

/**
 * 03. レスポンスの検証(Checks)
 * 
 * k6のcheck機能を使用して、レスポンスが期待通りかを検証します。
 * - ステータスコードの確認
 * - レスポンスボディの確認
 * - レスポンスタイムの確認
 * - JSONの内容検証
 * 
 * 注意: checkが失敗してもテストは停止しません。
 * メトリクスとして記録され、最後にサマリーで表示されます。
 */

export const options = {
  vus: 2,
  duration: '10s',
};

export default function () {
  const baseUrl = 'http://localhost:3000/api';
  
  // ===== ヘルスチェックエンドポイント =====
  let response = http.get('http://localhost:3000/health');
  
  check(response, {
    'ヘルスチェック: ステータスは200': (r) => r.status === 200,
    'ヘルスチェック: レスポンスタイムは200ms以下': (r) => r.timings.duration < 200,
    'ヘルスチェック: statusフィールドは"ok"': (r) => {
      const body = JSON.parse(r.body);
      return body.status === 'ok';
    },
  });
  
  // ===== ユーザーAPI =====
  response = http.get(`${baseUrl}/users`);
  
  check(response, {
    'ユーザー取得: ステータスは200': (r) => r.status === 200,
    'ユーザー取得: successフィールドはtrue': (r) => {
      const body = JSON.parse(r.body);
      return body.success === true;
    },
    'ユーザー取得: dataフィールドが配列': (r) => {
      const body = JSON.parse(r.body);
      return Array.isArray(body.data);
    },
    'ユーザー取得: 最低1件のユーザーが存在': (r) => {
      const body = JSON.parse(r.body);
      return body.data.length > 0;
    },
  });
  
  // ===== POSTリクエストの検証 =====
  const payload = JSON.stringify({
    name: 'テストユーザー',
    email: 'test@example.com',
  });
  
  response = http.post(`${baseUrl}/users`, payload, {
    headers: { 'Content-Type': 'application/json' },
  });
  
  check(response, {
    'ユーザー作成: ステータスは201': (r) => r.status === 201,
    'ユーザー作成: IDが生成されている': (r) => {
      const body = JSON.parse(r.body);
      return body.data && body.data.id !== undefined;
    },
  });
  
  // ===== エラーケースの検証 =====
  response = http.get(`${baseUrl}/nonexistent`);
  
  check(response, {
    '存在しないエンドポイント: ステータスは404': (r) => r.status === 404,
  });
  
  sleep(1);
}

 


 

基本的な負荷テストの記述例を記載させていただきました。

リポジトリには他にも認証が必要な負荷テストなど、多くのテンプレート集を用意していますのでぜひ足を運んで下さいね。

まとめ

負荷試験は「リリースの直前に一度だけ行う特別な行事」ではなく、コードとして管理し、機能開発のサイクルの中で日常的に実行できる「継続的な検証」です。
k6を使えば、アプリエンジニアの慣れ親しんだ言語で、簡単に取り掛かることが出来ます。

ここまで読んでいただきありがとうございます。恐縮ですが最後に課題を提示させていただきますね!
ぜひ以下の2つに挑戦してみて下さい!

  • リポジトリをクローンしてモックサーバーを立ててみる。
  • 自社のAPI(まずは1つだけ)に対して1VUでリクエストを投げてみる。

次回は、「通常負荷」から「システムの限界」までを把握する、6つの負荷テストパターンの使い分けについて解説します。

お楽しみに!

梅木 和弥/ アプリケーションエンジニア

Webのシステム開発における、設計・実装に携わっています。
業務ドメインを技術に翻訳する工程に注力しております。SOLID原則が僕の物差しです。

梅木 和弥 の書いた記事一覧

最新の関連記事

Download 資料ダウンロード

Drupalでの開発・運用、サーバー構築、Webサイト構築全般、制作費用などに関してお気軽にご相談ください。


Contact お問い合わせ

Drupalでの開発・運用、サーバー構築、Webサイト構築全般についてお気軽にご相談ください。専門スタッフによるDrupal無料相談も行なっております。