パフォーマンステスト
ステップはそれぞれ、アクションにかかった時間を記録します。 テストでは、その時間に上限を設けられます。 このガイドでは、1回の応答に上限を設ける方法、同じ操作から複数のサンプルを取ってその集計に上限を設ける方法、負荷試験用のツールをワークフローから実行する方法を説明します。 Probeは負荷試験用のツールではありません。 サンプルを取るために低い一定のレートでリクエストを送ることはできますが、それ以上の負荷をかけない理由は最後の節で述べます。
ステップが測る時間
各ステップは、アクションにかかった時間をrtとして公開します。
| フィールド | 型 | 説明 |
|---|---|---|
rt.duration | String | "120ms"のような文字列表記 |
rt.sec | Float | 同じ時間の秒数 |
どこからどこまでを測るかはアクションごとに異なります。
各ステップはアクションを別々のプロセスで実行するため、接続はステップをまたいで再利用されません。
httpアクションは、リクエストを送り始めてから応答のヘッダーを受け取るまでを測ります。
接続と、httpsならTLSハンドシェイクの時間は含まれ、ボディを読む時間は含まれません。
dbアクションは、接続を開いてから最後の行を読むまでを測ります。
shellアクションは、コマンドの開始から終了までを測ります。
時間を比べるのは、同じアクションで測ったもの同士に限ってください。
1回の応答への上限
rt.secは、応答のほかの値と同じようにテストで上限を設けられます。
name: Search Latency
vars:
api_url: "{{API_URL ?? 'http://localhost:8080'}}"
jobs:
- name: Search
steps:
- name: Search by keyword
id: search
uses: http
with:
url: "{{vars.api_url}}"
get: /search?q=probe
test: res.code == 200 && rt.sec < 0.5
outputs:
elapsed_ms: rt.sec * 10001回のリクエストは1つのサンプルにすぎません。 キャッシュが冷えていた、ガベージコレクションが走った、ネットワークが混んでいたといった理由で、直前の何倍もかかるリクエストはよくあります。 そのため、普段の時間に近い上限を置くと、コードに原因がなくてもときどき失敗します。 上限は普段の時間より十分に大きく取り、インデックスが使われなくなったクエリのように操作を何倍も遅くする変更を検出する用途に使います。 1回のリクエストが上限を超えたかどうかではなく、どれくらいの頻度で超えるか、時間がどう散らばるかを見るには、複数のサンプルを取ります。
同じ操作の複数のサンプル
repeatはジョブを繰り返し実行し、各ステップのテストは実行のたびに評価されます。
name: Search Latency
vars:
api_url: "{{API_URL ?? 'http://localhost:8080'}}"
jobs:
- name: Search
repeat:
count: 50
interval: 100ms
steps:
- name: Search by keyword
uses: http
with:
url: "{{vars.api_url}}"
get: /search?q=probe
test: res.code == 200 && rt.sec < 0.3結果には、テストが成り立った実行の回数と、アクションにかかった時間の分布が表示されます。
⏺ Search (Completed in 6.42s)
⎿ 0. Search by keyword (repeating 50 times)
✓ 50/50 success (100.0%)
min 8.1ms, p50 12.4ms, p90 31.0ms, p95 44.2ms, p99 118ms, max 118msテストが成り立たなかった実行が1回でもあればジョブは失敗します。
つまり、ここでのrt.secのテストでも、上限を超えたサンプルが1つでもあればジョブは失敗します。
一部の回が上限を超えることを許す方法は、全回に対する上限で述べます。
繰り返して得られるのは回数です。
1回のリクエストでは成否しかわかりませんが、48/50 success (96.0%)からは、50回のうち48回でテストが成り立ったことがわかります。
この回数はテスト全体の成否を数えたものです。
ステータスが200以外だった回も、時間が上限を超えた回も、それぞれ別の理由でテストが成り立たず、どちらも失敗として数えられます。
回数からはどちらの理由かはわかりません。
最初に失敗した回の理由は、ステップの下に表示されます。
分布には、アクションが時間を返した回をすべて含めます。
テストが失敗した回も含めるので、遅かった回が分布から抜け落ちることはありません。
パーセンタイルは、指定した割合の回がその時間以内に収まった時間です。
p95 44.2msは、100回のうち95回が44.2ms以内だったことを表します。
値は必ず実際にかかった時間のどれかなので、実行が100回未満ならp99は最も遅かった回、つまりmaxと同じになります。
回数とは違い、分布からは、上限に収まったまま操作が数割遅くなる変更もわかります。
変更の前と後で実行し、p50とp95を比べてください。
時間にはその間のネットワークも含まれるので、同じマシンから同じ環境に対して実行したもの同士を比べます。
--report jsonではステップのlatencyとして、--report markdownでは繰り返したステップの表として記録されるので、結果と並べてプルリクエストに載せられます。
起動した直後や、しばらくリクエストのなかったサービスでは、最初の数回が遅くなりがちです。
キャッシュが空で、コネクションプールもまだ埋まっていないためです。
その数回はmaxとp99を押し上げ、テストを失敗させることもあります。
warmupを指定すると、その回数だけ先に実行し、成否の回数にも分布にも含めません。
repeat:
count: 50
interval: 100ms
warmup: 5warmupの回でテストが失敗しても、ジョブは失敗しません。
サービスが本当に応答していなければ、その後に数える回で失敗します。
温まるのはサービスの側だけです。
ステップは毎回接続し直すので、Probeの側で接続が再利用されることはありません。
既定では実行は1回ずつ順に進み、intervalは1回の実行が終わってから次を始めるまでの待ち時間です。
async: trueにすると、前の実行が終わったかどうかに関係なくintervalごとに次の実行を始めます。
互いの応答を待たないクライアントと同じように、一定のレートでリクエストが送られます。
name: Search Latency
vars:
api_url: "{{API_URL ?? 'http://localhost:8080'}}"
jobs:
- name: Search at 10 requests a second
repeat:
count: 100
interval: 100ms
async: true
steps:
- name: Search by keyword
uses: http
with:
url: "{{vars.api_url}}"
get: /search?q=probe
test: res.code == 200 && rt.sec < 0.3前の実行を待つ場合は、ほかにリクエストのないサービスで各リクエストを測ることになります。 一定のレートで送る場合は、遅い応答があっても後続のリクエストの送信は遅れません。 そのため、サービスの処理が追いつかなくなると、後続のリクエストの時間にそれが表れます。 レートは低く保ってください。 理由は負荷試験で述べます。
全回に対する上限
repeat.testは、各回ではなく、全回の集計に上限を設けます。
すべての回が終わった後に評価され、これだけでジョブの成否が決まります。
name: Search Latency
vars:
api_url: "{{API_URL ?? 'http://localhost:8080'}}"
jobs:
- name: Search
repeat:
count: 100
interval: 100ms
warmup: 5
test: steps.search.success_rate >= 0.99 && steps.search.rt.p95 < 0.3
steps:
- name: Search by keyword
id: search
uses: http
with:
url: "{{vars.api_url}}"
get: /search?q=probe
test: res.code == 200steps.searchは、idがsearchのステップの全回の集計です。
回数のruns、success、failure、skippedと、割合のsuccess_rateを持ちます。
rtの下には、rt.secと同じく秒単位の時間として、min、mean、p50、p90、p95、p99、maxを持ちます。
このジョブは、100回のうち99回以上でステップが成功し、かつ100回のうち95回が300ms未満だったときに成功します。
失敗した回は数えるだけで、それ自体では何も失敗させません。
各回を成功と数えるかどうかは引き続きステップのテストで決まるので、そちらではレスポンスを確かめます。
時間の上限はrepeat.testに置きます。
そうすると、上限は各回ではなく、全回のうちの割合に対するものになります。
結果では、ステップの下にテストが表示されます。 偽だった場合は、どの条件が偽だったかと、比べた値も表示されます。
⏺ Search (Failed in 11.20s)
⎿ 0. Search by keyword (repeating 100 times, after 5 to warm up)
△ 99/100 success (99.0%)
min 8.3ms, p50 12.1ms, p90 30.2ms, p95 312ms, p99 640ms, max 702ms
✗ repeat test: steps.search.success_rate >= 0.99 && steps.search.rt.p95 < 0.3
failed: steps.search.rt.p95 < 0.3
steps.search.rt.p95 = 0.312ジョブが失敗し、終了ステータスが1になるのは、テストが成り立たなかったときだけです。
失敗した回が何で失敗したかは関係しません。
steps.search.rt.p95 < vars.p95_limitのように上限をvarsから読めば、環境ごとに変えられます。
そのジョブにないステップや、ステップにないフィールドを読むrepeat.testは、probe checkが報告します。
すべてのフィールドは全回に対するテストを参照してください。
以前の実行との比較
CIで秒単位の上限を決めるのは難しいです。
ランナーの速さは実行ごとに違うので、上限を厳しくすると何も遅くしていない変更が失敗し、緩くすると遅くした変更も通ってしまいます。
プルリクエストで示したいのは、マージ先のブランチより遅くなっていないことです。
--baselineは、以前の実行が--report jsonで書き出したレポートと今回の時間を比べて、それを示します。
# main ブランチで
probe --report json=main.json search.yml
# 変更したブランチで
probe --report json,markdown --baseline main.json search.yml端末では、各ステップの時間の下に以前の実行の時間が表示されます。
⎿ 0. Search by keyword (repeating 100 times)
✓ 100/100 success (100.0%)
min 8.3ms, p50 13.0ms, p90 35.1ms, p95 41.5ms, p99 70.2ms, max 88.0ms
baseline p50 12.1ms (+7.4%), p95 30.2ms (+37.4%), p99 64.0ms (+9.7%)Markdownのレポートには、時間がどう変わったかの表が入ります。
## Compared to the Baseline
Compared to the run started at 2026-10-15T09:00:00Z (2f9159ffd442ca6c), before → now.
| Job | Step | Runs | p50 | p95 | p99 | Success |
|---|---|---|---|---|---|---|
| Search | 0. Search by keyword | 100 → 100 | 12.1ms → 13.0ms (+7.4%) | 30.2ms → 41.5ms (+37.4%) | 64.0ms → 70.2ms (+9.7%) | 100.0% → 100.0% |ステップは、idが同じジョブにある、idが同じステップと比べます。
そのため、ジョブとステップの両方にidを付けてください。
idのないステップはジョブ内の位置で識別されるので、前にステップを追加すると別のステップとして扱われます。
以前の実行にないステップにはno baselineと示します。
比較そのものでは、ジョブは失敗しません。
何も変えていなくても2回の実行の時間は違い、どれだけ違うかはサービスとランナーによるためです。
同じマシンで続けて実行した2回が最も差が小さいので、CIでは、mainブランチのレポートを別の実行から持ってくるより、変更と同じジョブでmainブランチも実行するほうが確かです。
--report github-summaryを指定すれば、表は実行のサマリーページに載ります。
JSONのレポートに記録される内容は--baselineを参照してください。
同じコードを2回実行したときに時間がどれだけ違うかがわかれば、repeat.baseline_testで、それより大きな変化があったときにジョブを失敗させられます。
このテストは、以前の実行のステップをbaseline.stepsの下で読みます。
時間はstepsと同じく秒単位です。
repeat:
count: 100
warmup: 5
test: steps.search.success_rate >= 0.99
baseline_test: steps.search.rt.p95 < baseline.steps.search.rt.p95 * 1.2このジョブは、p95が以前の実行より20%以上遅くなると失敗します。
✓ repeat test: steps.search.success_rate >= 0.99
✗ baseline test: steps.search.rt.p95 < baseline.steps.search.rt.p95 * 1.2
failed: steps.search.rt.p95 < baseline.steps.search.rt.p95 * 1.2
steps.search.rt.p95 = 0.0415
baseline.steps.search.rt.p95 * 1.2 = 0.03624mainブランチでの実行のように--baselineを指定しないときは、テストをスキップし、ジョブはこのテストでは失敗しません。
そのため、比べる元のレポートも同じワークフローで書き出せます。
変更で追加したステップのように、テストが読むステップが以前の実行にないときもスキップします。
フィールドの一覧は以前の実行と比べるテストを参照してください。
エンドポイントが実行するクエリ
エンドポイントが遅い原因は、そこで実行されるクエリにあることがよくあります。
dbアクションはそのクエリを単独で実行するので、時間がデータベースでかかっているかどうかを切り分けられます。
name: Order History
vars:
api_url: "{{API_URL ?? 'http://localhost:8080'}}"
db_dsn: "{{DB_DSN}}"
jobs:
- name: Order history of one user
steps:
- name: Endpoint
uses: http
with:
url: "{{vars.api_url}}"
get: /users/42/orders
test: res.code == 200 && rt.sec < 1
- name: Query behind it
uses: db
with:
dsn: "{{vars.db_dsn}}"
query: SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50
params: [42]
test: res.code == 0 && rt.sec < 0.2?はMySQLとSQLiteのプレースホルダーです。
PostgreSQLでは$1と書きます。
2つの時間は同じものを測っていないので、一方を他方と比べるのではなく、それぞれのステップに上限を設けます。
ステップが測る時間で述べたとおり、httpステップの時間は接続から応答のヘッダーを受け取るまで、dbステップの時間はデータベースへの接続から最後の行を読むまでです。
ステップは毎回接続し直しますが、サービスが自身のクエリで同じようにするとは限りません。
数ミリ秒で終わるクエリなら、ステップの時間のほとんどは接続の時間です。
クエリが上限に収まっているのにエンドポイントが上限を超えるなら、時間はサービスの別の箇所でかかっている可能性が高いです。
日単位で見る応答時間
1回の実行では、サービスが日を追って遅くなっていくことはわかりません。
cronやCIで毎日上限を確かめれば、それがわかります。
スケジュールでワークフローを実行する方法はモニタリングワークフローで説明しています。
終了ステータスで、上限が守られたかどうかがスケジューラーに伝わります。
レポートのファイルは、同じパスに書き出す次の実行で置き換えられます。
各回の記録を残すには、--report json=reports/$(date +%F).jsonのように実行ごとに別のパスを指定します。
負荷試験
Probeは、組み込みのものも含め、ステップごとにアクションのプロセスを起動します。
各実行のテンプレート、テスト、outputsは、プロセスを起動する側で評価します。
1回のリクエストを送るステップでも、Probe自身の処理に数十ミリ秒かかります。
repeatの回数は、PROBE_MAX_REPEAT_COUNTで上限を引き上げない限り10000回未満です。
Probeは、サービスが普通にさばけるレートで自身の限界に達してしまいます。
しかも記録された時間からは、限界に達したのがProbeとサービスのどちらなのかがわかりません。
負荷試験には、k6 のように、多数のリクエストを同時に送って時間を記録するために作られたツールが必要です。
ワークフローでは、そのツールをステップとして実行し、その前後でサービスを確かめられます。
name: Checkout under Load
vars:
api_url: "{{API_URL ?? 'http://localhost:8080'}}"
jobs:
- name: Checkout under load
steps:
- name: Service is up
uses: http
with:
url: "{{vars.api_url}}"
get: /health
test: res.code == 200
- name: Load with k6
uses: shell
timeout: 15m
with:
cmd: k6 run --env API_URL="$API_URL" load/checkout.js
env:
API_URL: "{{vars.api_url}}"
test: res.code == 0
- name: Orders were all written
uses: http
with:
url: "{{vars.api_url}}"
get: /orders/count
test: res.code == 200 && res.body.failed == 0k6は、スクリプトのしきい値のどれかが成り立たないと0以外のステータスで終了するので、そのステップは失敗します。
前後のステップでは、負荷をかける前と後のサービスとデータの状態を確かめます。
これはk6からは確かめられないことです。
しきい値を満たせなかったときこそデータの状態を知る必要があるので、後のステップはk6の成否にかかわらず実行します。
URLはコマンドに書き込まず、コマンドの環境変数としてk6に渡しています。
API_URLにどのような値が入っても、コマンドそのものは変わりません。
次のステップ
- モニタリングワークフロー - ワークフローをスケジュールで実行する
- 環境管理 - 同じワークフローを別の環境に向ける
- エラーハンドリング戦略 - リトライ、タイムアウト、ステップが失敗したときの対処