この記事で分かること
- 負荷テストとは何か、なぜ「リリース前」に自動化すべきなのか
- k6のインストールから最初の負荷テスト実行まで
- テストシナリオの書き方(しきい値・チェック・段階的負荷)
- GitHub Actionsに組み込んでPRごとに自動実行する具体的な手順
- よくあるハマりどころと対処法
はじめに
「テスト環境では問題なかったのに、本番にデプロイしたらレスポンスが遅い」「セール開始直後にサーバーが落ちた」——こうした問題は、コードの機能テスト(単体テスト・E2Eテスト)だけでは検出できません。
原因は負荷をかけた状態でのパフォーマンスを事前に確認していないことにあります。手動で負荷テストを実施するチームもありますが、リリースのたびに人手で実行するのは現実的ではなく、やがて省略されがちです。
この記事では、k6(ケーシックス)という負荷テストツールとGitHub Actionsを組み合わせて、「PRを出すたびに自動で負荷テストが走り、基準を満たさなければマージできない」仕組みを構築します。
前提知識
- 負荷テスト: 多数のユーザーが同時にアクセスする状況をシミュレーションし、システムの応答速度やエラー率を測定するテストです。レストランに例えると「開店前にスタッフ総出でお客さん役をやり、オペレーションが回るか確認する」ようなものです
- VU(Virtual User): k6が生成する仮想的なユーザーのことです。VU数を増やすほど、同時アクセスが増えます
- p(95): レスポンスタイムの95パーセンタイル値です。「100回リクエストしたら95回はこの時間以内に返る」という意味で、極端に遅い外れ値に引っ張られにくい指標です
- GitHub Actions: GitHubリポジトリにプッシュやPR作成などのイベントが発生した際に、自動でスクリプトを実行する仕組みです
なぜk6を選ぶのか
負荷テストツールには JMeter、Gatling、Locust など多くの選択肢があります。k6を選ぶ理由は以下の通りです。
| 観点 | k6 | JMeter | Gatling |
|---|---|---|---|
| テスト定義 | JavaScript(コードで書く) | GUI / XML | Scala / Java |
| CI/CD親和性 | CLI実行、終了コードで判定 | JARファイルで実行可能 | sbt/Mavenで実行 |
| 学習コスト | JSが書ければすぐ開始 | GUIだが概念が多い | Scalaの知識が必要 |
| リソース消費 | Go製で軽量 | JVM、メモリ消費大 | JVM、中程度 |
| 公式CI連携 | grafana/k6-action |
なし(自前構築) | 公式プラグインあり |
k6はJavaScriptでテストシナリオを書けるため、フロントエンド・バックエンドを問わず多くのエンジニアが馴染みやすく、Go製のバイナリ1つで動作するため、CIランナー上でも軽量に実行できます。
負荷テストの種類を知る
k6では目的に応じて異なるパターンの負荷をかけます。すべてを最初から実施する必要はなく、まずはSmokeテストから始めるのがおすすめです。
| パターン | 知りたいこと | 負荷の形 | いつやるか |
|---|---|---|---|
| Smoke | そもそも動くか | 1〜2VU、数回 | CI/CDで毎回 |
| Load | 通常アクセスで大丈夫か | 台形(段階的に上下) | スプリントごと |
| Stress | どこまで耐えるか | 階段(段階的に上げる) | リリース前 |
| Spike | 急増に耐えるか | 針(急増→急減) | スケーリング検証 |
| Soak | 長時間で劣化しないか | 一定負荷を長時間 | 定期的に |
| Breakpoint | 何人で壊れるか | 上げ続ける | キャパシティ計画時 |
ハンズオン:k6で最初の負荷テストを書く
Step 1: k6のインストール
# macOS
brew install k6
# Linux (Debian/Ubuntu)
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
# Docker
docker run --rm -i grafana/k6 version
Step 2: 最初のテストスクリプト
load-tests/smoke.js を作成します。
import http from 'k6/http';
import { check, sleep } from 'k6';
// テストの設定
export const options = {
// Smokeテスト: 1VUで10回実行
vus: 1,
iterations: 10,
// しきい値: これを超えたらテスト失敗
thresholds: {
http_req_failed: ['rate<0.05'], // エラー率5%未満
http_req_duration: ['p(95)<500'], // 95%のリクエストが500ms以内
},
};
export default function () {
// テスト対象のAPIを呼び出す
const res = http.get('https://test-api.k6.io/public/crocodiles/');
// レスポンスの検証
check(res, {
'ステータスが200': (r) => r.status === 200,
'200ms以内に応答': (r) => r.timings.duration < 200,
});
// 実際のユーザー行動を模倣して待機
sleep(1);
}
このスクリプトのポイントを解説します。
-
options: テストの実行条件を定義します。thresholds(しきい値)を設定しておくと、基準を満たさない場合にk6がゼロ以外の終了コードを返します。これがCI連携の要です -
default function: VU(仮想ユーザー)が繰り返し実行する処理です。1回の実行を「イテレーション」と呼びます -
check(): レスポンスの内容を検証します。ただし、check()が失敗してもテストは続行されます。テスト自体を失敗させたい場合はthresholdsで制御します -
sleep(): 実際のユーザーはページを読んだりフォームを入力したりするため、リクエスト間に待機時間を入れます。これを入れ忘れると、現実離れした負荷がかかります
Step 3: テスト実行
k6 run load-tests/smoke.js
実行結果には以下のようなメトリクスが表示されます。
✓ ステータスが200
✓ 200ms以内に応答
checks.........................: 100.00% ✓ 20 ✗ 0
http_req_duration..............: avg=45.2ms min=38.1ms max=62.3ms p(95)=58.7ms
http_req_failed................: 0.00% ✓ 0 ✗ 10
主要メトリクスの読み方
k6が出力するメトリクスのうち、特に重要なものを押さえておきましょう。
| メトリクス | 意味 | 注目すべき値 |
|---|---|---|
http_req_duration |
リクエスト送信〜レスポンス完了の時間 | p(95), avg |
http_req_failed |
4xx/5xxの割合 | 0に近いほど良い |
http_req_waiting |
TTFB(サーバーの処理時間) | サーバー側のボトルネック指標 |
http_req_blocked |
TCP接続待ちの時間 | コネクションプール不足の兆候 |
checks |
check() の成功率 |
100%が理想 |
Step 4: 段階的負荷テスト(Load Test)に進化させる
Smokeテストが通ったら、実際の利用に近い負荷をかけるLoadテストを書きます。
load-tests/load.js を作成します。
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
// 段階的に負荷を変化させる
stages: [
{ duration: '1m', target: 10 }, // 1分かけて10VUまで増加
{ duration: '3m', target: 10 }, // 3分間10VUを維持
{ duration: '1m', target: 0 }, // 1分かけて0VUに減少
],
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<500', 'p(99)<1000'],
},
};
export default function () {
const res = http.get('https://test-api.k6.io/public/crocodiles/');
check(res, {
'ステータスが200': (r) => r.status === 200,
'レスポンスボディが存在': (r) => r.body && r.body.length > 0,
});
// ユーザーのページ閲覧時間を模倣(1〜3秒)
sleep(Math.random() * 2 + 1);
}
stages を使うと「徐々にユーザーが増えて、一定時間利用して、徐々にいなくなる」という現実に近いパターンを再現できます。これは台形のような負荷曲線になります。
しきい値の設計指針
テストパターンによって適切なしきい値は異なります。高負荷テストでエラーや遅延が出るのは当然なので、しきい値を厳しくしすぎるとテストの目的を見失います。
| テストパターン | エラー率 | p(95) | 考え方 |
|---|---|---|---|
| Load Test |
<0.05(5%) |
<500ms |
プロダクトの非機能要件そのもの |
| Stress Test |
<0.1(10%) |
<2000ms |
高負荷なので緩め。回復できるかが重要 |
| Spike Test |
<0.15(15%) |
<5000ms |
急増時のエラーは許容。回復速度を見る |
GitHub Actionsで自動実行する
ここからが本記事の核心です。PRを出すたびにk6が自動実行される仕組みを作ります。
Step 5: ワークフローファイルの作成
.github/workflows/k6-load-test.yml を作成します。
name: k6 Load Tests
on:
pull_request:
branches: [main]
jobs:
smoke-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run k6 Smoke Test
uses: grafana/k6-action@v0.3.0
with:
filename: load-tests/smoke.js
env:
BASE_URL: ${{ secrets.STAGING_API_URL }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results/
retention-days: 30
このワークフローのポイントは以下の通りです。
-
grafana/k6-action: Grafana公式のGitHub Actionで、k6のインストールと実行を1ステップで行えます -
on: pull_request: PRが作成・更新されたタイミングで自動実行されます -
secrets.STAGING_API_URL: テスト対象のURLをGitHubのSecretsに登録しておくことで、リポジトリに直接URLを書かずに済みます - k6の
thresholdsを超えた場合、k6はゼロ以外の終了コードを返し、ワークフローが自動的にFailになります
Step 6: 環境変数をスクリプトで使う
テスト対象のURLをハードコードせず、環境変数で切り替えられるようにします。
// load-tests/smoke.js(環境変数対応版)
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const options = {
vus: 1,
iterations: 10,
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<500'],
},
};
export default function () {
const res = http.get(`${BASE_URL}/api/health`);
check(res, {
'ステータスが200': (r) => r.status === 200,
});
sleep(1);
}
__ENV.BASE_URL で環境変数を参照できます。ローカル開発時は http://localhost:3000、CI環境では secrets.STAGING_API_URL が使われる仕組みです。
Step 7: ブランチ保護ルールで品質を強制する
GitHub Actionsが動くだけでは「テストが落ちてもマージできてしまう」状態です。これを防ぐために、ブランチ保護ルールを設定します。
- GitHubリポジトリの Settings → Branches → Branch protection rules を開く
-
mainブランチに対してルールを追加 - Require status checks to pass before merging にチェック
-
Status checks に
smoke-test(ジョブ名)を追加
これで、k6のしきい値を満たさないPRはマージできなくなります。
よくある落とし穴と対処法
k6を導入する際にハマりやすいポイントをまとめます。
1. sleep() を入れ忘れて非現実的な負荷になる
sleep() なしだと、VUはレスポンスを受け取った瞬間に次のリクエストを送ります。実際のユーザーはページを読んだり考えたりする時間があるので、ページ閲覧なら1〜3秒、フォーム入力なら5〜10秒の待機を入れましょう。
2. check() の失敗でテストが止まると思い込む
check() は検証結果を記録するだけで、テストは続行されます。テストを失敗させるには thresholds で checks: ['rate>0.99'] のように設定します。
3. setup() の戻り値に関数を入れてしまう
setup() から default function へのデータ受け渡しはJSONシリアライズ経由で行われます。関数やDBコネクションは渡せません。トークン文字列や設定値などのプリミティブ値・オブジェクトのみ使用できます。
4. Docker実行時に --network=host を忘れる
ローカルのサーバーに対してDocker内のk6からテストする場合、--network=host を付けないと localhost にアクセスできません。
docker run --rm --network=host -i grafana/k6 run - < load-tests/smoke.js
5. CIでの負荷テストが本番に影響する
CI環境からはステージング環境にのみ負荷をかけてください。 本番環境に対してCIから負荷テストを実行すると、実ユーザーに影響を与えます。ステージング環境のURLを secrets で管理し、本番URLが間違って使われないようにしましょう。
まとめ
本記事では、k6によるの負荷テストの基礎から、GitHub Actionsでの自動化までをハンズオン形式で解説しました。
導入のステップをまとめます。
- Smokeテストから始める: 1〜2VUで「そもそも動くか」を確認するテストをCIに組み込む
-
しきい値を設定する:
thresholdsでp(95)やエラー率の基準を定義する - GitHub Actionsで自動実行: PRごとにテストが走り、基準を満たさなければマージできない状態にする
- 段階的にテストを拡充する: Load Test → Stress Test と、必要に応じてテストパターンを増やす
負荷テストは「やらなくても動く」ものですが、「やっておけば本番障害を防げた」ケースは数え切れません。まずは最小のSmokeテスト1本をCIに組み込むところから始めてみてください。