k6 Load Testing: Stress-Test Your APIs Before Launch

Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.5k 語数|

Performance & Scalabilityシリーズの一部

完全ガイドを読む

k6 負荷テスト: 起動前に API のストレス テストを行う

負荷テストを行わずに製品を出荷するのはギャンブルです。ユーザーが限界点を見つけるまでは、通常は製品の発売時、バイラルな瞬間、売上の急増時に限界点が分かりません。 k6 は、JavaScript でテストを作成し、CI から実行して、ユーザーが行う前にパフォーマンスの上限を発見できる最新の負荷テスト ツールです。開発者にとって使いやすく、リソース効率が高く (k6 はスレッドではなくゴルーチンを使用します)、リアルタイム メトリクスのために Grafana および Prometheus ときれいに統合されます。

このガイドでは、最初のスクリプトから複雑なマルチシナリオ負荷テスト、カスタム メトリクス、しきい値、CI 統合、Node.js/NestJS API の実稼働環境に安全なテスト パターンまで、k6 について説明します。

重要なポイント

  • k6 スクリプトは JavaScript ですが、Go ランタイムで実行されます — Node.js API はありません (requirefssetTimeout はありません)
  • 仮想ユーザー (VU) は、シミュレートされた同時ユーザーです。反復は個々のスクリプトの実行です
  • 実行前に常にしきい値を設定します。しきい値に失敗するとテストが停止し、CI が失敗します。
  • シナリオ (一定対、ランピング対、一定到着率) を使用して実際のトラフィック パターンをモデル化する
  • 運用と調整せずにテスト本番環境をロードしないでください。ステージングまたは本番環境のクローンに対してテストします。
  • http_req_duration メトリックは主要な SLA メトリックです。p95 と p99 は平均よりも重要です
  • k6 ランナーのレート制限 — 負荷テストのトラフィックがレート制限の予算を使い果たさないようにする必要があります
  • 地理的領域にわたる分散負荷テストには k6 Cloud または Grafana k6 を使用します

インストールと最初のスクリプト

# macOS
brew install k6

# Ubuntu/Debian
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

# Windows (via Chocolatey)
choco install k6

最初の k6 スクリプト:

// k6/homepage-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

// Custom metric: error rate
const errorRate = new Rate('error_rate');

export const options = {
  // Ramp up to 50 VUs over 1 minute, hold 2 minutes, ramp down
  stages: [
    { duration: '1m', target: 50 },
    { duration: '2m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th pct < 500ms
    http_req_failed:   ['rate<0.01'],                // Less than 1% errors
    error_rate:        ['rate<0.01'],
  },
};

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

export default function () {
  const res = http.get(`${BASE_URL}/`);

  const ok = check(res, {
    'status is 200':              (r) => r.status === 200,
    'response time < 500ms':     (r) => r.timings.duration < 500,
    'body contains ECOSIRE':     (r) => r.body.includes('ECOSIRE'),
  });

  errorRate.add(!ok);
  sleep(1); // 1 second think time between iterations
}

実行してください:

k6 run k6/homepage-load.js
k6 run --env BASE_URL=https://staging.ecosire.com k6/homepage-load.js
k6 run --vus 100 --duration 30s k6/homepage-load.js  # Quick smoke test

認証を使用した API 負荷テスト

// k6/api-load.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';

const apiCalls = new Counter('api_calls');
const authDuration = new Trend('auth_duration');

export const options = {
  scenarios: {
    // Constant arrival rate: 100 requests/second regardless of VU count
    constant_load: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '3m',
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
  },
  thresholds: {
    http_req_duration:            ['p(95)<300', 'p(99)<500'],
    'http_req_duration{type:api}': ['p(95)<200'],
    http_req_failed:              ['rate<0.005'], // 0.5% error budget
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001';

// setup() runs once before VUs start
export function setup() {
  // Authenticate and return tokens for VUs to use
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: __ENV.TEST_EMAIL,
    password: __ENV.TEST_PASSWORD,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(loginRes, { 'login successful': (r) => r.status === 200 });

  const { accessToken } = loginRes.json();
  return { accessToken };
}

export default function (data) {
  const { accessToken } = data;
  const headers = {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  };

  group('Contacts API', () => {
    // List contacts
    const listRes = http.get(`${BASE_URL}/contacts?page=1&limit=20`, {
      headers,
      tags: { type: 'api', endpoint: 'contacts-list' },
    });

    check(listRes, {
      'list contacts: 200': (r) => r.status === 200,
      'list contacts: has items': (r) => r.json('data') !== null,
    });

    apiCalls.add(1);
    sleep(0.5);

    // Create a contact
    const createRes = http.post(`${BASE_URL}/contacts`, JSON.stringify({
      name: `Load Test User ${Date.now()}`,
      email: `load-test-${Date.now()}@example.com`,
    }), {
      headers,
      tags: { type: 'api', endpoint: 'contacts-create' },
    });

    check(createRes, {
      'create contact: 201': (r) => r.status === 201,
    });

    apiCalls.add(1);
  });

  sleep(1);
}

// teardown() runs once after all VUs finish
export function teardown(data) {
  // Clean up test data if needed
  console.log(`Test complete. Data: ${JSON.stringify(data)}`);
}

VU のランピング シナリオ

複数の段階とシナリオを使用して実際のトラフィック パターンをモデル化します。

// k6/stress-test.js
export const options = {
  scenarios: {
    // Scenario 1: Normal traffic baseline
    normal_traffic: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 20 },
        { duration: '5m', target: 20 },
      ],
      gracefulRampDown: '30s',
    },

    // Scenario 2: Sudden spike (marketing campaign goes live)
    traffic_spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      startTime: '7m', // Starts after baseline is stable
      stages: [
        { duration: '30s', target: 200 }, // Rapid spike
        { duration: '2m',  target: 200 }, // Hold spike
        { duration: '30s', target: 20  }, // Fall back
      ],
    },

    // Scenario 3: Soak test for memory leaks
    soak_test: {
      executor: 'constant-vus',
      vus: 10,
      duration: '30m', // Run for 30 minutes
      startTime: '10m',
    },
  },
  thresholds: {
    'http_req_duration{scenario:normal_traffic}': ['p(95)<300'],
    'http_req_duration{scenario:traffic_spike}':  ['p(95)<800'],
    http_req_failed:                              ['rate<0.02'],
  },
};

ブログ/サイトマップのクロール テスト

// k6/sitemap-crawl.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

// Load URLs from sitemap (pre-fetched and saved as JSON)
const urls = new SharedArray('sitemap urls', function () {
  // In real usage, fetch sitemap XML and parse URLs beforehand
  // SharedArray is loaded once and shared across all VUs (memory efficient)
  return JSON.parse(open('./sitemap-urls.json'));
});

export const options = {
  vus: 20,
  duration: '5m',
  thresholds: {
    http_req_duration: ['p(95)<2000'], // Public pages can be slower
    http_req_failed:   ['rate<0.01'],
  },
};

export default function () {
  // Each VU picks a random URL from the sitemap
  const url = urls[Math.floor(Math.random() * urls.length)];

  const res = http.get(url, {
    headers: {
      // Identify as load tester in logs
      'User-Agent': 'k6-load-tester/1.0',
    },
    timeout: '10s',
  });

  check(res, {
    'status is 200':           (r) => r.status === 200,
    'no error page':           (r) => !r.body.includes('Error'),
    'has canonical tag':       (r) => r.body.includes('rel="canonical"'),
    'response time < 2000ms': (r) => r.timings.duration < 2000,
  });

  sleep(Math.random() * 2 + 1); // Random 1-3 second think time
}

カスタムメトリクスとしきい値

// k6/checkout-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Rate, Trend, Gauge } from 'k6/metrics';

// Custom metrics for business-specific tracking
const checkoutSuccesses = new Counter('checkout_successes');
const checkoutFailures  = new Counter('checkout_failures');
const checkoutDuration  = new Trend('checkout_duration');
const activeCheckouts   = new Gauge('active_checkouts');

export const options = {
  stages: [
    { duration: '1m', target: 10 },
    { duration: '3m', target: 30 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    checkout_duration:   ['p(95)<3000'],   // Checkout < 3s at p95
    checkout_failures:   ['count<10'],     // Max 10 absolute failures
    http_req_duration:   ['p(99)<2000'],
  },
};

export default function (data) {
  activeCheckouts.add(1);
  const start = Date.now();

  // Step 1: Add to cart
  const cartRes = http.post('/api/cart/add', JSON.stringify({
    productId: 'prod_odoo_customization',
    quantity: 1,
  }), { headers: { 'Content-Type': 'application/json' } });

  if (!check(cartRes, { 'add to cart: 200': (r) => r.status === 200 })) {
    checkoutFailures.add(1);
    activeCheckouts.add(-1);
    return;
  }

  sleep(2);

  // Step 2: Create checkout session
  const checkoutRes = http.post('/api/billing/checkout', JSON.stringify({
    cartId: cartRes.json('cartId'),
  }), { headers: { 'Content-Type': 'application/json' } });

  const success = check(checkoutRes, {
    'checkout session created': (r) => r.status === 200,
    'has session URL':          (r) => r.json('url') !== null,
  });

  if (success) {
    checkoutSuccesses.add(1);
    checkoutDuration.add(Date.now() - start);
  } else {
    checkoutFailures.add(1);
  }

  activeCheckouts.add(-1);
  sleep(1);
}

Grafana 出力の統合

k6 メトリクスを InfluxDB にストリーミングし、テスト中に Grafana で視覚化します。

# Run with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 k6/api-load.js

# Or use the k6 Grafana integration (k6 v0.45+)
k6 run --out experimental-prometheus-rw k6/api-load.js
# docker-compose.monitoring.yml
services:
  influxdb:
    image: influxdb:2.7
    ports: ["8086:8086"]
    environment:
      INFLUXDB_DB: k6
      INFLUXDB_ADMIN_USER: admin
      INFLUXDB_ADMIN_PASSWORD: password

  grafana:
    image: grafana/grafana:latest
    ports: ["3030:3000"]
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
    volumes:
      - ./grafana/dashboards:/etc/grafana/provisioning/dashboards
      - ./grafana/datasources:/etc/grafana/provisioning/datasources

CI の統合

# .github/workflows/ci.yml (load test section)
load-tests:
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main' # Only on main branch
  needs: [deploy-staging]
  steps:
    - uses: actions/checkout@v4

    - name: Install k6
      run: |
        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

    - name: Run smoke test (30 VUs, 60s)
      run: |
        k6 run --vus 30 --duration 60s \
          --env BASE_URL=${{ secrets.STAGING_URL }} \
          --env TEST_EMAIL=${{ secrets.TEST_EMAIL }} \
          --env TEST_PASSWORD=${{ secrets.TEST_PASSWORD }} \
          apps/api/k6/api-load.js

    - name: Upload k6 results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: k6-results
        path: k6-results/

パフォーマンスのベースライン

ベースラインを確立し、回帰を警告します。

エンドポイントp50p95p99エラーバジェット
GET / (ホームページ)120ミリ秒400ミリ秒800ミリ秒0.1%
コード050ミリ秒150ミリ秒300ミリ秒0.5%
コード080ミリ秒200ミリ秒400ミリ秒0.5%
コード0200ミリ秒500ミリ秒1000ミリ秒1%
コード0500ミリ秒1500ミリ秒3000ミリ秒1%
コード0100ミリ秒300ミリ秒600ミリ秒0.1%

よくある質問

VU と 1 秒あたりのリクエストの違いは何ですか?

VU (仮想ユーザー) は、シミュレートされた同時ユーザーです。各 VU はデフォルトの関数をループで実行します。 1 秒あたりのリクエスト数は、各反復の実行速度によって異なります。各 VU の反復に 2 秒かかり (スリープ時間を含む)、VU が 100 個ある場合、約 50 RPS が得られます。反復期間に関係なく、特定の RPS をターゲットにする必要がある場合は、constant-arrival-rate エグゼキュータを使用します。

本番環境またはステージングに対して負荷テストを行うべきですか?

常にステージングを好みます。実稼働負荷テストでは、実際のユーザーに影響を与え、レート制限を使い果たし、テストデータから実際の注文を作成し、実際の Webhook イベントをトリガーするリスクがあります。運用環境をテストする必要がある場合は、トラフィックが少ない時間帯にテストし、テスト支払いトークンを使用し、ロールバック計画を立ててください。 k6 Cloud は地理的に分散された負荷生成をサポートしているため、オリジンに触れることなく CDN エッジのパフォーマンスをテストできます。

長いテスト中に認証トークンの有効期限が切れた場合、どのように処理すればよいですか?

短いテスト (15 分未満) の場合は、setup() でトークンを取得し、data 経由ですべての VU に渡します。長いテスト (15 分以上) の場合は、テスト スクリプトにトークンの更新を実装します。デフォルトの関数でトークンの有効期間を確認し、有効期限が近づいたら更新します。トークンを各 VU のローカルな JavaScript 変数に保存します。

stagesscenarios の違いは何ですか?

stages は単一の ramping-vus シナリオの短縮形であり、単純なランプアップ/ホールド/ランプダウン パターンに適しています。 scenarios を使用すると、複数の同時トラフィック パターン、シナリオごとの異なるエグゼキュータ、シナリオごとのしきい値、メトリクスでのシナリオのタグ付けなどを完全に制御できます。現実的なマルチパターン テスト (ベースライン + スパイク + ソークを同時に行う) には、scenarios を使用します。

単一の k6 プロセスはいくつの VU を処理できますか?

最新のハードウェア上の単一の k6 プロセスは、スクリプトの複雑さと応答サイズに応じて、5,000 ~ 10,000 の VU を維持し、50,000 ~ 100,000 の RPS を生成できます。負荷が高い場合は、k6 cloud を使用するか、負荷ディストリビューターの背後で複数の k6 インスタンスを実行します。各 VU は非常に軽量 (ゴルーチン) です。k6 は、同等の VU 数に対して JMeter や Gatling よりもはるかに少ないリソースを使用します。


次のステップ

負荷テストにより、ユーザーがアプリケーションにストレスを感じる前に、アプリケーションの真のパフォーマンス プロファイルが明らかになります。このガイドのパターン (ランピング シナリオ、カスタムしきい値、CI 統合、Grafana ダッシュボード) は、パフォーマンスのボトルネックを継続的に見つけて修正するためのインフラストラクチャを提供します。

ECOSIRE は、ホームページ、API エンドポイント、チェックアウト フロー、完全なサイトマップ クロールをカバーする k6 負荷テストを使用して、パフォーマンスが検証された NestJS API を構築します。 当社のバックエンド エンジニアリング サービスをご覧ください して、初日からパフォーマンスを向上させるために当社がどのように構築しているかをご覧ください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット