負荷の階段と飽和点
目標
同時実行数をラダーのように上げながら、スループットとレイテンシの関係を測定し、飽和する地点と、SLAを守れる最大の地点を数字で見つけます。そしてリトルの法則で、その測定自体が有効だったかを検証します。成果物は/root/lt2/の下に置きます。
なぜ重要なのか
負荷テストで最も静かな失敗は、「負荷ジェネレーターが目標の負荷を出せなかったこと」です。応答1つが2秒を食うと、同期式のジェネレーターは、その2秒のあいだに送るはずだったリクエストを送りません。システムが最も遅かった区間のサンプルがまるごと消え、ジェネレーターは、自分が作り出したバックプレッシャーに協調してしまいます。その結果、負荷を上げてもp99が動かず、プロダクションでだけ裾が悪くなります。そのため、すべての実行に1行の受け入れ検査を付けます。報告された実際のスループットが、設定した目標スループットと一致しているか。一致しなければ、その実行のレイテンシ分布は信頼できません。このラボのステップ6・7が、まさにその検査です。
準備: 負荷対象を起動する
このラボはUbuntu 24.04のVMで動き、Dockerも一緒にインストールされています。それでも、対象をコンテナではなくPythonのプロセスとして起動します。できないからではなく、そのほうが適切だからです。
負荷対象は、何でも起動してよいわけではありません。リクエスト1つにかかるコストがはっきりしている対象であって初めて、ラダーがラダーらしく見え、ステップ6のリトルの法則の検算(rps × 평균 지연 ≈ 동시성、プレースホルダーは平均レイテンシと同時実行数です)も成り立ちます。nginxで静的ファイルだけを返すと、1件が1msもかからないので、測っているものがサーバーではなく、負荷ジェネレーター自身になります。ラダーを上げてもニーが現れず、現れたとしても、それは対象の限界ではなく、heyの限界です。
以下の対象は、リクエスト1つに50msを使うように作ってあります。その50msがあって初めて、同時実行数を上げたときに、スループットが上がってから止まる地点が目に見えます。
mkdir -p /root/lt2
cat > /root/lt2/target.py <<'PY'
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BODY = b"labhub load-test target
"
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
disable_nagle_algorithm = True # 헤더와 본문이 따로 나가면 지연 ACK 로 40ms 가 얹힌다
def do_GET(self):
time.sleep(0.05) # 요청 하나의 처리 비용
self.send_response(200); self.send_header("Content-Length", str(len(BODY)))
self.end_headers(); self.wfile.write(BODY)
def log_message(self, *a): pass
ThreadingHTTPServer(("127.0.0.1", 8085), H).serve_forever()
PY
setsid nohup python3 /root/lt2/target.py >/dev/null 2>&1 &
curl -s -o /dev/null -w '%{http_code}
' http://127.0.0.1:8085/ # 200
どうしてもコンテナに移したいなら、docker run -d --name lt-web -p 127.0.0.1:8085:8080 -v /root/lt2:/app python:3.12-alpine python /app/target.pyのように、同じPythonの対象を入れる必要があります。上の段落の理由で、nginxに変えると、以降のステップの数字がすべて意味を失います。
ステップ
/root/lt2/plan.mdに、ラダーの計画を書きます。必ず含めるもの: 同時実行数のステップ1、2、5、10、20の5つ、各ステップの継続時間、ウォームアップの計画、そして成功基準(例: p95のしきい値)。/root/lt2/ramp.shを作成してchmod +xします。出力ディレクトリを第1引数($1)として受け取り、同時実行数1、2、5、10、20を順に実行し、結果を$1/c<동시성>.txt(例:c10.txt、プレースホルダーは同時実行数です)として保存する必要があります。- スクリプトを
/root/lt2/outに対して実行します。/root/lt2/out/c1.txt、c2.txt、c5.txt、c10.txt、c20.txtの5つが作られる必要があり、各ファイルの総レスポンスが100件以上で、Requests/secと95% inの行が残っている必要があります。 /root/lt2/ramp.csvを作成します。形式はconcurrency,rps,p95,errorsで、同時実行数1/2/5/10/20の5行を書きます。rpsとp95は、該当するcN.txtから読んだ値、errorsは、ステータスコード分布の2xx以外のレスポンス数(整数、正確に一致する必要があります)です。/root/lt2/knee.txtに、knee_concurrency=の1行を書きます。判定のルールは次のとおりです。同時実行数を1 → 2 → 5 → 10 → 20の順に見ながら、直前のステップに対するRPSの増加率が10%未満になる最初の地点を見つけて、その直前の同時実行数を答えとして書きます。最後まで10%以上増えていれば、答えは20です。/root/lt2/little.txtに4行を書きます。concurrency=10、rps=(out/c10.txtのRequests/sec)、latency_avg_s=(同じファイルのAverage)、product=(rps × 平均レイテンシ、小数第2位)です。この積が同時実行数10と大きく違えば、その実行は目標の負荷を出せなかったことになります。- 到着率を固定した(open)実行をもう1つ回して、
/root/lt2/open.txtに保存します。そして/root/lt2/model.mdに、次を書きます。closed(固定同時実行数)モデルの説明、open(固定到着率)モデルの説明、コーディネーテッドオミッション(coordinated omission)の説明、そして2行target_rps=(設定した目標スループット)とactual_rps=(open.txtから読んだ実際のスループット)です。 /root/lt2/max-safe.txtに2行を書きます。max_safe_concurrency=、max_safe_rps=です。ramp.csvから、p95が0.2秒以下でerrorsが0の行のうち最大の同時実行数と、そのときのRPSです。
参考
- openモデルの近似:
heyの-qは、ワーカーあたりの1秒あたりのリクエスト数を制限します。hey -z 30s -c 50 -q 4 http://127.0.0.1:8085/は目標200 RPS(50 × 4)を意味するので、target_rps=200と書いて、実際の値と比較すればよいのです。 - スクリプトの骨組み:
for c in 1 2 5 10 20; do hey -n 300 -c "$c" http://127.0.0.1:8085/ > "$1/c$c.txt" 2>&1; done。先頭にmkdir -p "$1"を入れてください。 errorsは、awk '/Status code distribution/{f=1;next} f && /responses/ {gsub(/[][]/," "); if($1<200||$1>=300) e+=$2} END{print e+0}'で数えます。エラーがなければ0を書きます。- よくある間違い1:
ramp.shの中に出力パスをハードコードしてしまうこと。第1引数を使わないと、採点で引っかかります。 - よくある間違い2: ステップ5で「最もRPSが高い同時実行数」を、ニーとして書いてしまうこと。ニーは最高点ではなく、伸びが止まり始める地点の直前です。
- よくある間違い3: 各ステップを短く回しすぎてしまうこと。100件未満だと、パーセンタイルが意味を持てません。
負荷ラダーの計画書を書く
/root/lt2/plan.mdに、ラダーの計画を書きます。必ず含めるもの: 同時実行数のステップ1、2、5、10、20の5つ、各ステップの継続時間、ウォームアップの計画、そして成功基準(例: p95のしきい値)。
/root/lt2/plan.mdに、5段階の同時実行数、各ステップの継続時間、ウォームアップ、成功基準を書きます。何が見えれば合格かを事前に決めておかないと、結果を見てから基準を作ることになります。
実行スクリプトを書く
/root/lt2/ramp.shを作成してchmod +xします。出力ディレクトリを第1引数($1)として受け取り、同時実行数1、2、5、10、20を順に実行し、結果を$1/c<동시성>.txt(例: c10.txt、プレースホルダーは同時実行数です)として保存する必要があります。
/root/lt2/ramp.shは、出力ディレクトリを第1引数として受け取って5つのステップを順に実行し、結果をc<同時実行数>.txtとして保存します。パスをハードコードすると、別のディレクトリで回し直せません。chmod +xを忘れないでください。
5つのステップを実行する
スクリプトを/root/lt2/outに対して実行します。/root/lt2/out/c1.txt、c2.txt、c5.txt、c10.txt、c20.txtの5つが作られる必要があり、各ファイルの総レスポンスが100件以上で、Requests/secと95% inの行が残っている必要があります。
/root/lt2/outの下に、c1 c2 c5 c10 c20の結果がすべてある必要があり、各ステップは100件以上である必要があります。各ファイルに、サマリーとレイテンシ分布のセクションがそのまま残っていて初めて、次のステップで値を読めます。
結果表を作る
/root/lt2/ramp.csvを作成します。形式はconcurrency,rps,p95,errorsで、同時実行数1/2/5/10/20の5行を書きます。rpsとp95は、該当するcN.txtから読んだ値、errorsは、ステータスコード分布の2xx以外のレスポンス数(整数、正確に一致する必要があります)です。
/root/lt2/ramp.csvに、同時実行数ごとのrps、p95、errorsをまとめます。値は元の出力から読んだものである必要があり、errorsは2xx以外のレスポンス数です。
飽和する地点を見つける
/root/lt2/knee.txtに、knee_concurrency=の1行を書きます。判定のルールは次のとおりです。同時実行数を1 → 2 → 5 → 10 → 20の順に見ながら、直前のステップに対するRPSの増加率が10%未満になる最初の地点を見つけて、その直前の同時実行数を答えとして書きます。最後まで10%以上増えていれば、答えは20です。
/root/lt2/knee.txtに、knee_concurrency=の1行を書きます。スループットが意味のある形では増えなくなり始める地点の直前の同時実行数が答えで、判定のルールは指示文に正確に書かれています。
リトルの法則で実行を検証する
/root/lt2/little.txtに4行を書きます。concurrency=10、rps=(out/c10.txtのRequests/sec)、latency_avg_s=(同じファイルのAverage)、product=(rps × 平均レイテンシ、小数第2位)です。この積が同時実行数10と大きく違えば、その実行は目標の負荷を出せなかったことになります。
/root/lt2/little.txtに、同時実行数10の区間のrps、平均レイテンシ、その積を書きます。積が同時実行数と大きく違えば、負荷ジェネレーターが目標の負荷を実際には出せなかったということです。
openモデルとclosedモデルの比較
到着率を固定した(open)実行をもう1つ回して、/root/lt2/open.txtに保存します。そして/root/lt2/model.mdに、次を書きます。closed(固定同時実行数)モデルの説明、open(固定到着率)モデルの説明、コーディネーテッドオミッション(coordinated omission)の説明、そして2行target_rps=(設定した目標スループット)とactual_rps=(open.txtから読んだ実際のスループット)です。
到着率を固定した実行を別に回して/root/lt2/open.txtに保存し、model.mdに、2つのモデルの違いとコーディネーテッドオミッションを説明します。目標スループットと実際のスループットを並べて書いて、比較してください。
SLAを守れる最大の地点を求める
/root/lt2/max-safe.txtに2行を書きます。max_safe_concurrency=、max_safe_rps=です。ramp.csvから、p95が0.2秒以下でerrorsが0の行のうち最大の同時実行数と、そのときのRPSです。
/root/lt2/max-safe.txtに、条件を満たす最大の同時実行数と、そのときのスループットを書きます。判定の条件は、指示文にある2つで、表から直接選ぶ必要があります。