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

梅木 和弥
メインビジュアル

はじめに

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

前回は、Load Test や Stress Test といった「負荷のかけ方のパターン」について解説しました。

しかし、いざ自社のプロダクトで組み込もうとしたとき、以下のような壁にぶつかるのではないでしょうか?

  • ログインしないとAPIが叩けない...
  • 画像アップロード機能の負荷ってどう測るの?
  • ハードコードしたパラメータで検証したら、2回目以降エラーになる...

単にトップページを連打するだけなら簡単ですが、実務の負荷テストでは「文脈( Context )」を持ったシナリオが求められます。

第3回となる今回は、リポジトリの scenarios/03-realistic にあるコードを使いながら、
実務で必ず必要になる3つの実践テクニックに挑戦してみましょう。

第一回: https://www.mochiya.ad.jp/blog/system-dev/detail/load-test-01
第二回: https://www.mochiya.ad.jp/blog/system-dev/detail/load-test-02
教材: https://github.com/umekikazuya/k6-sandbox

1. 認証

ほとんどのWebアプリケーションには「認証」がありますよね。

k6ではJavaScriptでシナリオを書くことが出来ます。
そのため、ブラウザでの操作と同じ手順をスクリプトで表現する方針を取ります。

シナリオ構成

それでは早速、以下の構成で認証APIのテストを行ってみましょう。

  1. ログインAPIを叩く
    レスポンスからトークンを取り出す
    ※ アプリケーションの特性に応じた取り出しを実装して下さい
  2. トークンを使って認証APIにアクセス
  3. 無効なトークンでのエラーハンドリング

これで、「ログイン済みユーザー」としての振る舞いを加味した負荷テストを実施することが出来ます。

書き方

リポジトリの scenarios/03-realistic/01-authentication.js を参照下さい。

export const options = {
  vus: 5,
  duration: '30s',
  
  thresholds: {
    'http_req_duration{name:login}': ['p(95)<500'],
    'http_req_duration{name:authenticated}': ['p(95)<300'],
    'group_duration{group:::01_Login}': ['p(95)<1000'],
    'group_duration{group:::02_Authenticated_Request}': ['p(95)<500'],
  },
};

const BASE_URL = __ENV.BASE_URL || '<http://localhost:3000>';

export default function () {
  let authToken;
  
  // グループ1: ログイン
  group('01_Login', () => {
    const loginPayload = JSON.stringify({
      username: 'testuser',
      password: 'testpass',
    });
    
    const loginRes = http.post(
      `${BASE_URL}/api/auth/login`,
      loginPayload,
      {
        headers: { 'Content-Type': 'application/json' },
        tags: { name: 'login' },
      }
    );
    
    check(loginRes, {
      'ログイン: ステータスは200': (r) => r.status === 200,
      'ログイン: トークンが取得できた': (r) => {
        const body = JSON.parse(r.body);
        return body.success && body.data && body.data.token;
      },
    });
    
    // レスポンスからトークンを抽出
    if (loginRes.status === 200) {
      const body = JSON.parse(loginRes.body);
      authToken = body.data.token;
    }
  });
  
  sleep(1);
  
  // グループ2: 認証が必要なリクエスト
  if (authToken) {
    group('02_Authenticated_Request', () => {
      const headers = {
        'Authorization': `Bearer ${authToken}`,
      };
      
      const meRes = http.get(
        `${BASE_URL}/api/auth/me`,
        {
          headers: headers,
          tags: { name: 'authenticated' },
        }
      );
      
      check(meRes, {
        '認証リクエスト: ステータスは200': (r) => r.status === 200,
        '認証リクエスト: ユーザー情報が取得できた': (r) => {
          const body = JSON.parse(r.body);
          return body.success && body.data && body.data.userId;
        },
      });
    });
  }
  
  sleep(1);
  
  // グループ3: 認証エラーのテスト
  group('03_Invalid_Token', () => {
    const invalidHeaders = {
      'Authorization': 'Bearer invalid-token',
    };
    
    const invalidRes = http.get(
      `${BASE_URL}/api/auth/me`,
      { headers: invalidHeaders }
    );
    
    check(invalidRes, {
      '無効なトークン: ステータスは401': (r) => r.status === 401,
    });
  });
  
  sleep(1);
}

実行

k6 run scenarios/03-realistic/01-authentication.js
# 任意のURLで実施したい場合は、環境変数をセットしてコマンドを実行。
# BASE_URL=http://{your-api}.com k6 run scenarios/03-realistic/01-authentication.js

本記事では触れませんが、応用すると以下のような検証を組み込むことも可能です。

  • トークンの有効期限テスト
  • リフレッシュトークン
  • 複数ユーザーでの同時ログイン

2. データ相関

データ相関というとちょっと分かりづらいかもですが、
以下のようなシチュエーションでの検証を想定していただければ伝わるかなと。

  • ユーザー一覧を取得して、その中からランダムに1人選んで詳細を見る
  • 新規作成したユーザーのIDを使って、そのユーザーを更新する

これを専門用語でデータ相関 ( Data Correlation )と呼びます。

要するに、「前のリクエストの結果を次のリクエストで使用する」検証です。

シナリオ構成

  1. ユーザー一覧を取得
  2. レスポンスからランダムにIDを抽出
  3. そのIDでユーザー詳細を取得
  4. 新規ユーザーを作成
  5. 作成したユーザーを更新
  6. 更新したユーザーを削除

書き方

リポジトリの scenarios/03-realistic/03-data-correlation.js を参照下さい。


// テストデータをSharedArrayで管理(メモリ効率的)
const testData = new SharedArray('users', function () {
  return [
    { name: '山田太郎', email: 'yamada@example.com' },
    { name: '佐藤花子', email: 'sato@example.com' },
    { name: '鈴木一郎', email: 'suzuki@example.com' },
    { name: '田中美咲', email: 'tanaka@example.com' },
    { name: '高橋健太', email: 'takahashi@example.com' },
  ];
});

export const options = {
  vus: 5,
  duration: '1m',
  
  thresholds: {
    http_req_failed: ['rate<0.05'],
    http_req_duration: ['p(95)<500'],
  },
};

const BASE_URL = __ENV.BASE_URL || '<http://localhost:3000>';

export default function () {
  let userId;
  let userName;
  let userEmail;
  
  group('Data_Correlation_Flow', () => {
    // ステップ1: ユーザー一覧を取得
    group('Step1_Get_Users', () => {
      const listRes = http.get(`${BASE_URL}/api/users`);
      
      const listCheck = check(listRes, {
        'ユーザー一覧取得成功': (r) => r.status === 200,
        'ユーザーデータが配列': (r) => {
          try {
            const body = JSON.parse(r.body);
            return Array.isArray(body.data);
          } catch (e) {
            return false;
          }
        },
      });
      
      // レスポンスからランダムにユーザーIDを抽出
      if (listCheck && listRes.status === 200) {
        try {
          const body = JSON.parse(listRes.body);
          if (body.data && body.data.length > 0) {
            const randomIndex = Math.floor(Math.random() * body.data.length);
            const user = body.data[randomIndex];
            userId = user.id;
            console.log(`選択されたユーザーID: ${userId}`);
          }
        } catch (e) {
          console.error('JSONパースエラー:', e);
        }
      }
      
      sleep(1);
    });
    
    // ステップ2: 抽出したIDでユーザー詳細を取得
    if (userId) {
      group('Step2_Get_User_Detail', () => {
        const detailRes = http.get(`${BASE_URL}/api/users/${userId}`);
        
        check(detailRes, {
          'ユーザー詳細取得成功': (r) => r.status === 200,
          '取得したIDが一致': (r) => {
            try {
              const body = JSON.parse(r.body);
              return body.data.id === userId;
            } catch (e) {
              return false;
            }
          },
        });
        
        sleep(1);
      });
    }
    
    // ステップ3: 新しいユーザーを作成してIDを取得
    group('Step3_Create_User', () => {
      const userData = testData[Math.floor(Math.random() * testData.length)];
      
      const createPayload = JSON.stringify({
        name: `${userData.name}_${Date.now()}`,
        email: `${Date.now()}_${userData.email}`,
      });
      
      const createRes = http.post(
        `${BASE_URL}/api/users`,
        createPayload,
        {
          headers: { 'Content-Type': 'application/json' },
        }
      );
      
      const createCheck = check(createRes, {
        'ユーザー作成成功': (r) => r.status === 201,
        '作成されたユーザーにIDが付与されている': (r) => {
          try {
            const body = JSON.parse(r.body);
            return body.data && body.data.id !== undefined;
          } catch (e) {
            return false;
          }
        },
      });
      
      // 作成したユーザーのIDを抽出
      if (createCheck && createRes.status === 201) {
        try {
          const body = JSON.parse(createRes.body);
          userId = body.data.id;
          userName = body.data.name;
          userEmail = body.data.email;
          console.log(`作成したユーザー: ID=${userId}, Name=${userName}`);
        } catch (e) {
          console.error('JSONパースエラー:', e);
        }
      }
      
      sleep(1);
    });
    
    // ステップ4: 作成したユーザーを更新
    if (userId) {
      group('Step4_Update_User', () => {
        const updatePayload = JSON.stringify({
          name: `${userName}_updated`,
          email: `updated_${userEmail}`,
        });
        
        const updateRes = http.put(
          `${BASE_URL}/api/users/${userId}`,
          updatePayload,
          {
            headers: { 'Content-Type': 'application/json' },
          }
        );
        
        check(updateRes, {
          'ユーザー更新成功': (r) => r.status === 200,
        });
        
        sleep(1);
      });
      
      // ステップ5: 更新したユーザーを削除
      group('Step5_Delete_User', () => {
        const deleteRes = http.del(`${BASE_URL}/api/users/${userId}`);
        
        check(deleteRes, {
          'ユーザー削除成功': (r) => r.status === 200,
        });
        
        sleep(1);
      });
    }
  });
  
  sleep(2);
}

このように、前のリクエストの結果(createRes)を変数に格納し、次のリクエストのURLやボディに埋め込むことで、何度実行しても壊れない、冪等性を気にしなくて良いシナリオを作ることができます。

実行

k6 run scenarios/03-realistic/03-data-correlation.js
# 任意のURLで実施したい場合は、環境変数をセットしてコマンドを実行。
# BASE_URL=http://{your-api}.com k6 run scenarios/03-realistic/03-data-correlation.js

よく使われるユースケース

以下のようなシチュエーションで使われることが多いです。

  • 動的なIDの取得と使用
  • セッションIDやトークンの引き継ぎ
  • 注文番号の取得と追跡
  • ページネーションのカーソル

3. ファイルアップロード

最後は、冒頭でお話したファイルアップロードの負荷テストに挑戦しましょう。
k6では、「プロフィール画像の変更」や「CSVインポート機能」などを検証の対象とすることが出来ます。

k6では http.file() を使うことで、マルチパートフォームデータ(multipart/form-data)のリクエストを簡単に構築できます。

シナリオ構成

  1. 小さなファイル(1KB)のアップロード
  2. 中サイズのファイル(10KB)のアップロード
  3. FormDataを使用したマルチパートアップロード

実装例

リポジトリの scenarios/03-realistic/04-file-upload.js を参照下さい。

export const options = {
  vus: 3,
  duration: '30s',
  
  thresholds: {
    http_req_failed: ['rate<0.05'],
    'http_req_duration{type:small_file}': ['p(95)<1000'],
    'http_req_duration{type:medium_file}': ['p(95)<2000'],
  },
};

const BASE_URL = __ENV.BASE_URL || '<http://localhost:3000>';

// ダミーファイルデータを生成
function generateFileContent(sizeInKB) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  const bytesNeeded = sizeInKB * 1024;
  let content = '';
  
  for (let i = 0; i < bytesNeeded; i++) {
    content += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  
  return content;
}

export default function () {
  // パターン1: シンプルなJSONペイロード(小さなデータ)
  const smallPayload = JSON.stringify({
    filename: 'small_file.txt',
    content: generateFileContent(1), // 1KB
    size: 1024,
  });
  
  let response = http.post(
    `${BASE_URL}/api/upload`,
    smallPayload,
    {
      headers: { 'Content-Type': 'application/json' },
      tags: { type: 'small_file' },
    }
  );
  
  check(response, {
    '小ファイル: アップロード成功': (r) => r.status === 200,
    '小ファイル: レスポンスにファイル名': (r) => {
      try {
        const body = JSON.parse(r.body);
        return body.data && body.data.filename !== undefined;
      } catch (e) {
        return false;
      }
    },
  });
  
  sleep(1);
  
  // パターン2: 中サイズのファイル
  const mediumPayload = JSON.stringify({
    filename: 'medium_file.txt',
    content: generateFileContent(10), // 10KB
    size: 10240,
  });
  
  response = http.post(
    `${BASE_URL}/api/upload`,
    mediumPayload,
    {
      headers: { 'Content-Type': 'application/json' },
      tags: { type: 'medium_file' },
    }
  );
  
  check(response, {
    '中ファイル: アップロード成功': (r) => r.status === 200,
  });
  
  sleep(1);
  
  // パターン3: FormDataを使用したマルチパートアップロード
  const fd = new FormData();
  fd.append('file', http.file(generateFileContent(5), 'test.txt'));
  fd.append('description', 'テストファイル');
  fd.append('category', 'document');
  
  response = http.post(
    `${BASE_URL}/api/upload`,
    fd.body(),
    {
      headers: { 'Content-Type': 'multipart/form-data; boundary=' + fd.boundary },
      tags: { type: 'multipart' },
    }
  );
  
  check(response, {
    'マルチパート: アップロード成功': (r) => r.status === 200,
  });
  
  sleep(2);
}

分析のポイント

ファイルアップロードでは以下に注目してレポートが書けるとよいかなと。

  • ファイルサイズによるレスポンスタイムの違い
  • 同時アップロード数の制限
  • ネットワーク帯域の使用率
  • サーバー側のディスク書き込み性能

注目すべき監視メトリクス

  • http_req_sending: データ送信時間
  • http_req_waiting: サーバー処理時間
  • http_req_receiving: レスポンス受信時間
  • data_sent: 送信データ量

k6はGo製で効率が良いとはいえ、巨大なファイルを大量のVUでアップロードすると、負荷をかける側のメモリが枯渇することがあります。 最初は小さなファイルサイズから試して見て下さい。

シナリオの実装におけるアドバイス

1. エラーハンドリングを必ず実装

Try-CatchでJSONパースエラー等、実行時のエラーをしっかりとキャッチしてハンドリングしましょう。

try {
  const body = JSON.parse(response.body);
  // 処理
} catch (e) {
  console.error("JSONパースエラー:", e);
  console.log("レスポンス:", response.body);
}

最近だと Result型 流行ってますよね。
大規模アプリケーションの検証を書く場合だと、組織のデファクトスタンダートを負荷テストに部分的に組み込むのもありだと思います。

2. Think Time を適切に設定

実際のユーザー行動を再現して設定をして下さい。
「ただエディタに向かってシナリオ実装する」のではなく、「ブラウザで実際に手を動かしながらシナリオを実装」すると実際の操作に基づいた根拠のあるテストを書くことが出来るかなと。

最近だと、この辺の思考についてもAIに分析させてユーザー操作を何パターンか提示させるのもありかと。

3. デバッグ情報を適切に出力

k6はメトリクスがかなり洗練されています。
そのため、console.log でレポートで扱いたい重要な値を出力することを意識すると、テスト実行後の分析がスムーズになるかと。

--http-debug オプションで詳細を確認するモードも用意されています。

# リクエストの詳細を確認
k6 run --http-debug scenarios/03-realistic/01-authentication.js

4. グループとタグで整理

以下のようにテストを整理することで、「分析のしやすさ」の向上に繋がります。

グループ化
import { group } from "k6";

group("Login Flow", () => {
  // ログイン関連の処理
});

group("Browse Products", () => {
  // 商品閲覧関連の処理
});
タグ分割
http.get(url, {
  tags: {
    name: 'login',
    type: 'api',
    version: 'v2',
  },
});

// 閾値で特定のタグのみ指定
thresholds: {
  'http_req_duration{name:login}': ['p(95)<500'],
}

運用はチーム方針にもよりますが、 groups() で論理的分割、tags でメトリクスを分類、のようにするとよいかと。

まとめ

今回は、APIテストをより実践的にするための3つのシナリオ構成を中心にご紹介をしました。

  1. 認証
    トークンを動的に取得して使い回す
  2. データ相関
    前のレスポンスの内容を次のリクエストに活かす
  3. ファイルアップロード
    マルチパートリクエストも送信可能

k6では、単なるURLの死活監視ではなく、「ユーザーが商品を探し、カートに入れ、購入して、確認メールを受け取る」 といった、 一連のユーザージャーニー(scenarios/03-realistic/02-user-journey.js)をテストシナリオとして表現できます。

教材では他にも、「複数のリクエストを効率的に実行するパターン」や「エラーハンドリング」や「メトリクスの活用法」も紹介しております。
ぜひご活用下さい。

ここまで来れば、k6をかなり自由に扱えるようになっているはずです。

次回は、テスト結果をより深く分析するための「カスタムメトリクスと可視化」について解説します。

「平均レスポンスタイム」だけ見て安心していませんか? Bizに直結する数値の測り方をお伝え出来ればと。

以上です。
次回もお楽しみに!

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

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

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

最新の関連記事

Tag

Category

Contents

Download

失敗から学ぶWebマーケティング7箇条

失敗から学ぶWebマーケティング7箇条

多くの企業が陥る「戦略なきWeb投資」の失敗事例を、実態調査に基づき紹介。担当者が陥りがちな失敗と、脱却への「7つの教訓」を解説した、現場のための改善ガイド。

資料ダウンロード

Download 資料ダウンロード

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


Contact お問い合わせ

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