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を設定しがちですが、これは実運用で以下の問題を引き起こします。
- エラーバジェットが即座に枯渇する: 99.9%可用性のSLOでは月間約43分の障害しか許容されない。レイテンシSLOを厳しくすると、デプロイ時の一時的な遅延だけでバジェットを消費してしまう
- 開発速度が低下する: SLOが厳しすぎると、すべてのPRでパフォーマンステストが失敗し、開発者がテストを信頼しなくなる
- アラート疲れ: 本来注目すべき本番障害のシグナルがノイズに埋もれる
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-action、run-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-rateexecutorで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時の自動品質検証を開始する
- 本番トラフィックデータを分析し、キャパシティプランニングの
targetRPSを決定する - k6以外のツール(Locust、Gatling)との比較やツール選定の基礎については、負荷テストツール比較の関連記事も参考になる
参考
- Google SRE Book - Service Level Objectives
- k6 Thresholds Documentation
- Running Distributed Tests - Grafana k6
- k6 OpenTelemetry Output Documentation
- k6 Operator GitHub Repository
- How to Configure K6 Operator for Distributed Load Testing - OneUptime (2026)
- How to Use k6 Thresholds for SLOs - OneUptime (2026)
- Performance Testing Best Practices - Grafana Labs (2025)
- SLI Guide - SRE School (2026)
- OpenTelemetry and Grafana Labs: What's New in 2026
- Performance Testing with k6 and GitHub Actions - Grafana Labs
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。