0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

k6とSLOで実践するキャパシティプランニングと分散負荷テスト運用戦略

0
Last updated at Posted at 2026-04-03

k6とSLOで実践するキャパシティプランニングと分散負荷テスト運用戦略

負荷テストは「リリース前に1回実行して終わり」ではなく、SLO(Service Level Objective)を軸にしたキャパシティプランニングの一環として継続的に運用するものです。本記事では、SLOからテスト設計を逆算するアプローチ、k6 thresholdsによるSLO自動検証、Kubernetes上での分散負荷テスト運用、そしてOpenTelemetryを活用した負荷テストとObservabilityの統合までを実践的に解説します。

この記事でわかること

  • SLO/SLI/エラーバジェットから負荷テストシナリオを逆算する設計手法
  • k6 thresholdsでSLOをコード化し、CI/CDパイプラインの自動品質ゲートとして運用する方法
  • k6 OperatorによるKubernetes上の分散負荷テストの構築と運用
  • OpenTelemetry出力を活用した負荷テスト結果とAPMメトリクスの相関分析
  • キャパシティプランニングを継続的プロセスとして組織に定着させるための実践パターン

対象読者

  • 想定読者: バックエンドサービスの信頼性に責任を持つSRE・バックエンドエンジニア
  • 必要な前提知識:
    • HTTP/RESTful APIの基本的な仕組み
    • JavaScriptの基礎文法(k6スクリプト記述に使用)
    • SLO/SLIの概念(詳しくなくても本記事で実装レベルまで解説します)
    • Kubernetesの基本操作(kubectl)— 分散テストのセクションで使用
    • 機械学習エンジニアの方へ: ML推論サービスのレイテンシSLO管理にも応用可能な内容です

結論・成果

SLOベースの負荷テスト設計をCI/CDに組み込むことで、以下の効果が報告されています。

  • 障害の早期検出: Grafana Labsの公式ブログによると、Shift-Leftアプローチでパフォーマンス問題の検出を開発初期に移すことで、本番障害の修正コストを削減できると報告されている(Performance testing best practices - Grafana Labs
  • リソース効率: k6はgoroutineベースのアーキテクチャにより、1VUあたり約100KBのメモリで動作する。JMeterのスレッドモデル(1スレッドあたり約1MB)と比較して約10倍のメモリ効率が報告されている(Vervali 2026 comparison
  • SLO違反の自動検出: k6 thresholdsの非ゼロ終了コードにより、CI/CDパイプラインでSLO違反を自動的にブロックし、品質ゲートとして機能させることが可能

以下の図は、本記事で解説するSLOベース負荷テストの全体アーキテクチャです。

SLOからテストシナリオを逆算して設計する

負荷テストの設計で陥りがちな失敗は、「とりあえず1000VUで5分間回す」といった根拠のないパラメータ設定です。SLOを起点にテストを設計することで、テスト結果がビジネス要件と直結するようになります。

SLO・SLI・エラーバジェットの関係を整理する

まず、SLO(Service Level Objective)、SLI(Service Level Indicator)、エラーバジェットの関係を整理しましょう。これらは負荷テストの「合格基準」を決定する根拠になります。

概念 定義 負荷テストでの対応
SLI サービス品質を測定する具体的メトリクス k6のカスタムメトリクス(Trend, Rate等)
SLO SLIに対する目標値 k6 thresholdsの判定条件
エラーバジェット 100% - SLO目標値(許容される障害量) テスト失敗時の消費量計算
SLA SLOに罰則を付けた契約 負荷テストでは直接扱わない

Google SRE Bookでは、SLIの選定について「ユーザー体験を反映するメトリクスを選ぶ」ことが推奨されています(Google SRE Book - Service Level Objectives)。具体的には、以下の4つのSLIカテゴリが代表的です。

SLOからk6テスト設計へ変換する

SLOが「p95レイテンシ500ms以下、エラー率0.1%未満、月間稼働率99.9%」と定義されている場合、これを負荷テストの設計に変換する手順を見ていきましょう。

ステップ1: ピーク負荷を推定する

キャパシティプランニングの出発点は、実際のトラフィックデータです。過去のアクセスログやAPMツールから以下を取得します。

  • 通常時のRPS(Requests Per Second)
  • ピーク時のRPS(通常時の何倍か)
  • 成長率(月次/四半期)

ステップ2: テストシナリオを負荷パターンに対応付ける

推定したピーク負荷をもとに、k6のexecutorを選択します。

テスト目的 k6 Executor 設計ポイント
SLO維持確認(通常負荷) constant-arrival-rate 本番RPS相当を一定時間維持
キャパシティ上限探索 ramping-arrival-rate 段階的にRPSを増加させ限界を特定
スパイク耐性確認 ramping-vus 急激なVU増加でオートスケール検証
長時間安定性確認(Soak) constant-vus 数時間の継続負荷でリソースリーク検出

ステップ3: k6スクリプトに変換する

以下は、SLOを直接thresholdsに組み込んだ実践的な設計例です。

// slo-based-load-test.js
// SLO: p95 < 500ms, エラー率 < 0.1%, 可用性 >= 99.9%
import http from 'k6/http';
import { check } from 'k6';
import { Rate, Trend } from 'k6/metrics';

// カスタムメトリクス: エンドポイント別SLI
const apiLatency = new Trend('api_latency');
const apiErrorRate = new Rate('api_error_rate');

export const options = {
  scenarios: {
    // シナリオ1: 通常負荷でSLO維持を確認
    steady_state: {
      executor: 'constant-arrival-rate',
      rate: 200,              // 200 RPS(本番ピークの80%想定)
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 100,
      maxVUs: 300,
      tags: { test_type: 'steady' },
    },
    // シナリオ2: キャパシティ上限を探索
    capacity_ramp: {
      executor: 'ramping-arrival-rate',
      startRate: 100,
      timeUnit: '1s',
      preAllocatedVUs: 200,
      maxVUs: 500,
      stages: [
        { duration: '2m', target: 200 },   // 通常負荷
        { duration: '2m', target: 400 },   // 2倍負荷
        { duration: '2m', target: 600 },   // 3倍負荷(限界探索)
        { duration: '1m', target: 0 },     // クールダウン
      ],
      startTime: '6m',  // steady_state完了後に開始
      tags: { test_type: 'capacity' },
    },
  },

  // SLOをthresholdsとして定義
  thresholds: {
    // グローバルSLO
    http_req_duration: [
      'p(95)<500',   // SLO: p95レイテンシ500ms以下
      'p(99)<1000',  // 安全マージン: p99は1秒以下
    ],
    http_req_failed: [
      'rate<0.001',  // SLO: エラー率0.1%未満
      {
        threshold: 'rate<0.05',  // 5%超えたら即停止
        abortOnFail: true,
        delayAbortEval: '30s',   // ウォームアップ猶予
      },
    ],
    // シナリオ別SLO
    'http_req_duration{test_type:steady}': ['p(95)<400'],
    'http_req_duration{test_type:capacity}': ['p(95)<800'],
    // カスタムメトリクスSLO
    api_latency: ['p(50)<200', 'p(95)<500', 'p(99)<1000'],
    api_error_rate: ['rate<0.001'],
  },
};

export default function () {
  const res = http.get('https://api.example.com/v1/items');
  const latency = res.timings.duration;

  // カスタムメトリクスにデータ追加
  apiLatency.add(latency);

  const success = check(res, {
    'status is 2xx': (r) => r.status >= 200 && r.status < 300,
    'latency under SLO': (r) => r.timings.duration < 500,
  });

  if (!success) {
    apiErrorRate.add(1);
  } else {
    apiErrorRate.add(0);
  }
}

なぜこの設計を選んだか:

  • constant-arrival-rateは「一定のリクエストレート」を維持するため、SLO検証に適している。VUベースのexecutor(constant-vus)だとレスポンスが遅い場合にRPSが下がり、実際の負荷が減ってしまう
  • ramping-arrival-rateで段階的にRPSを上げることで、SLOを維持できる上限値を特定できる
  • abortOnFailはエラー率5%超の場合のみ適用。閾値を厳しくしすぎると、ウォームアップ中の一時的なエラーでテストが止まってしまう

注意: constant-arrival-rateはリクエストレートを保証するが、VUが不足するとリクエストがドロップされる。preAllocatedVUsはピーク時の想定VU数の1.5倍程度を設定し、maxVUsはさらに余裕を持たせることを推奨する。

SLO設計でよくある間違い

最初は「p95レイテンシ200ms以下」のように厳しいSLOを設定しがちですが、これは実運用で以下の問題を引き起こします。

  1. エラーバジェットが即座に枯渇する: 99.9%可用性のSLOでは月間約43分の障害しか許容されない。レイテンシSLOを厳しくすると、デプロイ時の一時的な遅延だけでバジェットを消費してしまう
  2. 開発速度が低下する: SLOが厳しすぎると、すべてのPRでパフォーマンステストが失敗し、開発者がテストを信頼しなくなる
  3. アラート疲れ: 本来注目すべき本番障害のシグナルがノイズに埋もれる

SRE Schoolのガイドでは「2-3個のユーザー影響メトリクスに絞り、アラート疲れを防ぐ」ことが推奨されています(SLI Guide - SRE School)。

k6 OperatorでKubernetes分散負荷テストを構築する

単一マシンでは生成できない大規模負荷が必要な場合、k6 Operatorを使ってKubernetes上で分散負荷テストを実行します。k6 Operator 1.0がリリースされ、本番運用レベルの安定性に達しています。

k6 Operatorのアーキテクチャ

k6 Operatorは、KubernetesのOperatorパターンを実装したコントローラです。TestRun CRD(Custom Resource Definition)でテストを定義し、Operatorが指定された並列度でRunner Podを自動生成・管理します。

インストールとセットアップ

Helmを使ったインストールが推奨されています(k6 Operator GitHub)。

# Helmリポジトリ追加
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

# k6 Operatorインストール
helm install k6-operator grafana/k6-operator \
  --namespace k6-operator-system \
  --create-namespace

あるいはmanifestで直接インストールする場合は以下のようになります。

kubectl create namespace k6-operator-system
kubectl apply -f https://github.com/grafana/k6-operator/releases/latest/download/bundle.yaml

分散テストの設定と実行

テストスクリプトをConfigMapに格納し、TestRun CRDで実行します(OneUptime k6 Operator guide)。

# configmap.yaml - テストスクリプトの格納
apiVersion: v1
kind: ConfigMap
metadata:
  name: slo-load-test
data:
  test.js: |
    import http from 'k6/http';
    import { check } from 'k6';

    export const options = {
      thresholds: {
        http_req_duration: ['p(95)<500'],
        http_req_failed: ['rate<0.001'],
      },
    };

    export default function () {
      const res = http.get(`${__ENV.TARGET_URL}/api/v1/items`);
      check(res, {
        'status 2xx': (r) => r.status >= 200 && r.status < 300,
      });
    }
# testrun.yaml - 分散テスト定義
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: slo-distributed-test
spec:
  parallelism: 10  # 10 Podに分散
  script:
    configMap:
      name: slo-load-test
      file: test.js
  runner:
    env:
      - name: TARGET_URL
        value: "http://my-service.default.svc.cluster.local"
      # Prometheus Remote Write出力
      - name: K6_PROMETHEUS_RW_SERVER_URL
        value: "http://prometheus.monitoring.svc:9090/api/v1/write"
      - name: K6_PROMETHEUS_RW_PUSH_INTERVAL
        value: "5s"
    resources:
      requests:
        cpu: "500m"
        memory: "512Mi"
      limits:
        cpu: "1000m"
        memory: "1Gi"
    # Pod分散配置(同一ノードに集中しないようにする)
    affinity:
      podAntiAffinity:
        preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                  - key: app
                    operator: In
                    values:
                      - k6
              topologyKey: kubernetes.io/hostname
# テスト実行
kubectl apply -f configmap.yaml
kubectl apply -f testrun.yaml

# テスト状態の確認
kubectl get testrun slo-distributed-test -w

# Runner Podのログ確認
kubectl logs -l app=k6 -f

なぜparallelism=10を選んだか:

  • parallelismはk6スクリプトのVU数をPod間で均等分配する。例えば、スクリプトで100VUを設定しparallelism=10とすると、各Podが10VUを担当する
  • Pod数が多すぎるとKubernetesのスケジューリングオーバーヘッドが増える。経験的には、1 Pod あたり50-200VUが効率的
  • podAntiAffinityでPodを異なるノードに分散させることで、単一ノードのネットワーク帯域がボトルネックになることを防ぐ

注意: k6 Operatorは全Runner Podの同期開始を保証するが、テストスクリプト内のSmokeテストのように極端に短い(数秒の)テストでは、Pod起動のオーバーヘッドが支配的になる。分散テストは5分以上の持続的なテストで効果を発揮する。

定期実行の設定

キャパシティプランニングの一環として、負荷テストを定期的に実行することが推奨されます。KubernetesのCronJobと組み合わせることで、スケジュール実行が可能です。

# cronjob.yaml - 6時間ごとの定期負荷テスト
apiVersion: batch/v1
kind: CronJob
metadata:
  name: k6-scheduled-test
spec:
  schedule: "0 */6 * * *"  # 6時間ごと
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: k6-runner
          containers:
            - name: k6-trigger
              image: bitnami/kubectl:latest
              command:
                - /bin/sh
                - -c
                - |
                  kubectl apply -f /tests/testrun.yaml
                  # テスト完了まで待機
                  kubectl wait --for=condition=TestRunRunning=false \
                    testrun/slo-distributed-test --timeout=600s
          restartPolicy: OnFailure

OpenTelemetryで負荷テストとObservabilityを統合する

負荷テストの結果が「p95は450msでSLO達成」とわかっても、それだけではボトルネックの特定や改善につながりません。OpenTelemetry出力を活用して、負荷テスト中のメトリクス・トレース・ログを統合的に分析する手法を解説します。

k6 OpenTelemetry出力の設定

k6はOpenTelemetry Protocol(OTLP)によるメトリクス出力をサポートしています(k6 OpenTelemetry documentation)。

# gRPCプロトコルでOTLPコレクターに送信
k6 run --out opentelemetry=grpc://localhost:4317 slo-based-load-test.js

# HTTPプロトコルの場合
k6 run --out opentelemetry=http://localhost:4318 slo-based-load-test.js

環境変数での設定も可能です。

export K6_OUT=opentelemetry
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_SERVICE_NAME=k6-load-test

k6 run slo-based-load-test.js

Observability統合アーキテクチャ

OpenTelemetryを介して、k6メトリクスとアプリケーションのAPMデータを同一のGrafanaダッシュボードに集約します。

相関分析の実践: 負荷テスト中のボトルネック特定

k6メトリクスとアプリケーションのトレースを相関させることで、「p95レイテンシが悪化した原因」を特定できます。

以下はOTel Collectorの設定例です。

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
    send_batch_size: 1024
  # k6メトリクスにテスト実行IDを付与
  attributes:
    actions:
      - key: test.run_id
        value: ${env:K6_TEST_RUN_ID}
        action: upsert

exporters:
  prometheusremotewrite:
    endpoint: http://mimir:9009/api/v1/push
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [batch, attributes]
      exporters: [prometheusremotewrite]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/tempo]

Grafanaダッシュボードでは、以下のPromQLクエリでk6メトリクスとアプリケーションメトリクスを並べて表示できます。

# k6のp95レイテンシ
histogram_quantile(0.95, sum(rate(k6_http_req_duration_bucket[1m])) by (le))

# アプリケーション側のp95レイテンシ(比較用)
histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket[1m])) by (le))

# k6テスト中のエラー率
rate(k6_http_req_failed_total[1m])

トレードオフ: OpenTelemetry出力を有効にすると、k6のメモリ使用量が10-15%程度増加する。大規模テスト(10万VU以上)ではOTel Collectorへの送信がボトルネックになる場合があるため、batch processorのバッファサイズを調整するか、サンプリングを導入する必要がある。

CI/CDパイプラインにSLO品質ゲートを組み込む

k6 thresholdsの非ゼロ終了コード機能を活用して、CI/CDパイプラインにSLO検証を自動化します。

GitHub Actionsでの実装

Grafana k6の公式GitHub Actions(setup-k6-actionrun-k6-action)を使用した実装例です(Performance testing with k6 and GitHub Actions - Grafana Labs)。

# .github/workflows/slo-gate.yml
name: SLO Performance Gate

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  smoke-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup k6
        uses: grafana/setup-k6-action@v1

      - name: Run smoke test
        run: |
          k6 run \
            --env BASE_URL=${{ secrets.STAGING_URL }} \
            tests/load/smoke.js

  load-test:
    runs-on: ubuntu-latest
    needs: smoke-test  # Smokeテスト通過後に実行
    steps:
      - uses: actions/checkout@v4

      - name: Setup k6
        uses: grafana/setup-k6-action@v1

      - name: Run SLO load test
        run: |
          k6 run \
            --env BASE_URL=${{ secrets.STAGING_URL }} \
            --out json=results.json \
            tests/load/slo-based-load-test.js

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

handleSummaryでカスタムレポートを生成する

k6のhandleSummary関数を使って、SLO判定結果を人間が読みやすい形式で出力します。

// handle-summary.js
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';

export function handleSummary(data) {
  // SLO判定結果を集計
  const thresholdResults = {};
  let allPassed = true;

  for (const [metricName, metricData] of Object.entries(data.metrics)) {
    if (metricData.thresholds) {
      thresholdResults[metricName] = {};
      for (const [thresholdName, thresholdData] of Object.entries(metricData.thresholds)) {
        thresholdResults[metricName][thresholdName] = thresholdData.ok;
        if (!thresholdData.ok) allPassed = false;
      }
    }
  }

  // SLOレポート生成
  const sloReport = {
    timestamp: new Date().toISOString(),
    overall_result: allPassed ? 'PASS' : 'FAIL',
    threshold_details: thresholdResults,
    key_metrics: {
      p95_latency: data.metrics.http_req_duration?.values?.['p(95)'],
      p99_latency: data.metrics.http_req_duration?.values?.['p(99)'],
      error_rate: data.metrics.http_req_failed?.values?.rate,
      total_requests: data.metrics.http_reqs?.values?.count,
      rps: data.metrics.http_reqs?.values?.rate,
    },
  };

  console.log('\n' + '='.repeat(60));
  console.log(`SLO GATE: ${sloReport.overall_result}`);
  console.log(`p95: ${sloReport.key_metrics.p95_latency?.toFixed(2)}ms`);
  console.log(`Error rate: ${(sloReport.key_metrics.error_rate * 100)?.toFixed(3)}%`);
  console.log(`RPS: ${sloReport.key_metrics.rps?.toFixed(1)}`);
  console.log('='.repeat(60) + '\n');

  return {
    stdout: textSummary(data, { indent: ' ', enableColors: true }),
    'slo-report.json': JSON.stringify(sloReport, null, 2),
  };
}

なぜこの構成を選んだか:

  • Smokeテストを先行させることで、基本的な疎通問題(デプロイ失敗、設定ミスなど)を低コストで検出し、無駄なLoadテストの実行を避ける
  • --out jsonで結果をJSON出力し、後続のステップやダッシュボードで分析可能にする
  • handleSummaryでSLO判定の概要を標準出力に表示することで、CIログを確認するだけでテスト結果を把握できる

ハマりポイント: k6はthresholdsが1つでも失敗すると終了コード99を返す。GitHub ActionsではこれがStep失敗として扱われるため、if: always()を付けないと後続のアーティファクトアップロードがスキップされる。テスト結果のアーカイブは障害分析に必要なため、必ずif: always()を設定すること。

よくある問題と解決方法

負荷テストの設計・運用で頻繁に遭遇する問題と対処法を整理します。

問題 原因 解決方法
VU数を増やしてもRPSが上がらない テスト対象のレスポンスが遅く、VUが応答待ちで詰まっている constant-arrival-rate executorに切り替え、RPSを直接制御する
ローカルでは再現しないレイテンシ劣化 負荷テスト用マシンのCPU/ネットワーク帯域が飽和 k6 statsでk6自体のリソース使用率を確認。分散テストに移行する
テスト開始直後にthresholdが失敗する アプリケーションのウォームアップ(JIT、コネクションプール)が完了していない delayAbortEval: '30s' でウォームアップ猶予を設定する
分散テストで結果がばらつく Runner Pod間のネットワーク条件が異なる podAntiAffinityでPodを分散配置し、同一AZ内にテスト対象を配置する
CI/CDで毎回テストが遅い 毎回フルのLoadテストを実行している Smokeテスト(1-2分)をPR時に実行し、フルテストはmainマージ後のみにする
thresholds通過後に本番で障害 テスト環境と本番のインフラ差異(DBサイズ、キャッシュ状態、外部依存) テスト環境のデータ量を本番同等にし、外部依存はモックではなくサンドボックスを使用する

まとめと次のステップ

まとめ:

  • 負荷テストはSLOを起点に設計することで、テスト結果がビジネス要件と直結する。SLO→SLI→k6 thresholdsの変換パターンを確立することが重要
  • k6のconstant-arrival-rate / ramping-arrival-rate executorでRPSベースのテストを実行し、VUベースのテストでは見逃すボトルネックを検出する
  • k6 Operator 1.0により、Kubernetes上での分散負荷テストが本番運用レベルの安定性に達している。parallelismパラメータで簡単にスケールアウト可能
  • OpenTelemetry出力で負荷テスト結果とAPMデータを同一ダッシュボードに統合し、ボトルネックの根本原因を特定する
  • CI/CDパイプラインにSLO品質ゲートを組み込み、SLO違反を自動的にブロックする仕組みを構築する

次にやるべきこと:

  • 自チームのSLO/SLIを定義し、k6 thresholdsに変換する(まだSLOがない場合は、p95レイテンシとエラー率から始める)
  • CI/CDにSmokeテスト(1-2分)を組み込み、PR時の自動品質検証を開始する
  • 本番トラフィックデータを分析し、キャパシティプランニングのtarget RPSを決定する
  • k6以外のツール(Locust、Gatling)との比較やツール選定の基礎については、負荷テストツール比較の関連記事も参考になる

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?