Dev Study
← SLI を数える — 分母と分子を決める
レッスン 2 / 10·約 8 分·第28期 SLI を数える

状態コードは、誰が断ったかを言わない — 同じ 429 を原因で分けるか

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口今ここ
  6. アプリ処理今ここ
  7. DB保存

SLI の値は測りますが、何を数えるかは決めます。今回は、同じ状態コードの行を分母から外すか、悪いに数えるかを、原因の列で決めます。

まず結論

nginx が利用者の割当超過で断った 429 と、アプリが自分の混雑で断った 429 は、状態コードの列では同じ 429 です。分かれるのは、誰が断ったかを書いた別の列だけです。

同じ 103 行のログから、4xx(400/404 と 429)の置き場所を変えると、SLI は 90.291% から 53.763% まで動きました。どれが正しいかではなく、どれを採るかを SLI 仕様に書きます。

? 読む前に予測

103 行のアクセスログがあり、5xx は 10 件で固定です。4xx(400/404 が 10 件、429 が 33 件)の置き場所を変えて可用性を4通り出すと、最大と最小はどれだけ離れるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

コードは「何が返ったか」で、「誰が断ったか」ではない

RFC 9110 §15.5 は 4xx を次のように書きます。

The 4xx (Client Error) class of status code indicates that the client seems to have erred.

seems(見える)です。4xx を返すと判断するのはサーバで、コードは結果を運びますが、判断の理由までは運びません。

規格の側には、2種の拒否に別々のコードがあります。429 は RFC 6585 §4 で the user has sent too many requests、503 は RFC 9110 §15.6.4 で temporary overload。SRE Book 第22章も、処理中の要求数が上限を超えたら return an HTTP 503 を例に挙げます。第21章は拒否の理由を "customer is out of quota" と "task overloaded" の2つに名付けていますが、これは RPC のエラー名で、21章は HTTP コードを対応づけていません。

ところが実装は、別の原因に同じコードを載せられます。第3期で見たとおり nginx の割当拒否は既定で 503 で、limit_req_status 429; で 429 にもできます。今回の実演では逆向きの混ぜ方として、アプリの混雑拒否をわざと 429 で返させました(原典の例は 503)。こうすると、割当超過と混雑が同じ 429 になります。

2つの拒否が、状態コードの列で1つになる
割当超過nginx の limit_req が断る
混雑nginx は通し、アプリが断る
状態コードの列
429
429
原因の列
limit_req=REJECTEDreason=-
limit_req=PASSEDreason=shed
  • ・状態コードだけで数えると、2つの 429 は同じ扱いしかできない
  • ・原因の列があれば、片方を分母から外し、片方を悪いに数えることを選べる

この回で決めることは、同じ 429 を原因で分けるかです。割当を超えた利用者への 429 は分母から外す、自分の混雑で断った 429 は悪いに数える、という決め方は、原因がログに書かれているときにだけ選べます。

具体実物

同じ path の 10 行を、2つの書式で見る

実演の環境は nginx:1.31.6-alpine(nginx 1.31.6)と python:3.14-slim(Python 3.14.7)、Docker 28.4.0 linux/arm64、2026-09-28 の実行です。nginx は limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s; と limit_req zone=perip burst=2 nodelay; で割当を課し、アプリは同時に2件を超えると 429 と X-Reason: shed を返します。同じ要求を、nginx 定義済みの combined と、原因の列を足した書式の2本のログに書きました。

nginx.conf の log_format(抜粋。末尾の1変数を省略)設定ファイル
log_format labeled '$remote_addr [$time_local] "$request" $status '
'limit_req=$limit_req_status reason=$upstream_http_x_reason';

/api/mixed に 10 件を同時に送った結果です。

plain.log(combined)の /api/mixed 全10行(run1)コマンド出力
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 5 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 169 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:53 +0000] "GET /api/mixed HTTP/1.1" 200 8 "-" "Python-urllib/3.14"
172.20.0.4 - - [28/Sep/2026:14:05:53 +0000] "GET /api/mixed HTTP/1.1" 200 8 "-" "Python-urllib/3.14"
labeled.log の同じ10要求(run1。各行末尾の1列を省略)コマンド出力
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=PASSED reason=shed
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:52 +0000] "GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
172.20.0.4 [28/Sep/2026:14:05:53 +0000] "GET /api/mixed HTTP/1.1" 200 limit_req=PASSED reason=-
172.20.0.4 [28/Sep/2026:14:05:53 +0000] "GET /api/mixed HTTP/1.1" 200 limit_req=PASSED reason=-

3行目だけが「nginx は通し(PASSED)、アプリが混雑で断った(shed)」429 で、他の7件は nginx が割当で断った 429 です。状態コードの列はどちらも 429。 combined には、誰が断ったかを書いた列がありません。3行目のバイト数が 5 なのはアプリの本文の長さで、原因の記録ではありません。

$upstream_http_x_reason は、アプリが付けた X-Reason ヘッダを nginx が読む変数です。なおこのヘッダはログ専用ではなく、利用者への応答にもそのまま返っていました。

✓ ここまでの確認

combined 形式の /api/mixed の 10 行で、nginx が割当で断った 429 と、アプリが混雑で断った 429 を分ける列はどれですか。

抽象原理

429 の置き場所は3つあり、原因で分けると4つ目ができる

The Art of SLOs 参加者ハンドブック(Google のワークショップ教材)は、事象を予算から外す方法を2つ挙げます。

Events can be prevented from counting against an error budget either by including them in the numerator or by excluding them from the denominator.

分子(良い)に入れるか、分母(有効)から外すかです。どちらにも入れなければ悪いに数えられます。429 の行には、置き場所が3つあることになります。

原典の例も分かれています。Workbook 付録A は Any HTTP status other than 500–599 is considered successful. と書き、429 も良いに入ります。Art of SLOs のスライドの例は成功を 2XX, 3XX or 4XX (excl. 429) と書き、4xx のうち 429 だけを成功から外しています。進行役ハンドブックは "bad password" or "unknown user" which shouldn't count against SLI と、4xx の扱いを聞くよう促します。

OpenTelemetry の HTTP span 規約(Stable)も、同じ 4xx を見る側で分けます。SpanKind.SERVER では MUST be left unset、SpanKind.CLIENT では SHOULD be set to Error です。

同じ 103 行に、4つの決め方を当てました(良い=2xx+3xx。有効は下の式)。

  • A: 5xx 以外 ÷ 全件(付録A の成功の定義。今回の要求はすべて /api/ 配下)
  • B: 2xx/3xx ÷ (全件 − 4xx)。429 は分母から消える
  • C: 2xx/3xx ÷ (全件 − 400 − 404)。429 は全部悪い
  • D: C から、limit_req=REJECTED の 429 だけを分母から外す。reason=shed の 429 は悪い
具体実物

同じ 103 行から出た4つの数字

python3 analyze.py labeled.log(run1 の出力から抜粋。内訳表と、各方式の見出しの括弧書きを省略)コマンド出力
rows=103
by status: {200: 50, 400: 4, 404: 6, 429: 33, 500: 3, 503: 7}
A: 5xx以外÷全件
good=93 valid=103 bad=10 SLI=90.291% budget@99%=(1-0.99)*103=1.03件
B: 2xx/3xx÷(全件−4xx)
good=50 valid=60 bad=10 SLI=83.333% budget@99%=(1-0.99)*60=0.60件
C: 2xx/3xx÷(全件−400/404)
good=50 valid=93 bad=43 SLI=53.763% budget@99%=(1-0.99)*93=0.93件
D: C + 原因列で分ける
good=50 valid=69 bad=19 SLI=72.464% budget@99%=(1-0.99)*69=0.69件
D': D と同じだが原因列が無い場合の近似 = C (429 を分けられない)
good=50 valid=93 bad=43 SLI=53.763% budget@99%=(1-0.99)*93=0.93件

5xx の 10 件は4通りとも同じです。A と B は悪いが同じ 10 件なのに、分母が 103 と 60 で 90.291% と 83.333% に分かれます。D の有効は 103 − 4 − 6 − 24(割当拒否の 429)= 69、悪いは 500 の3件 + 503 の7件 + shed の 429 の9件 = 19 です。バジェット件数 (1 − SLO) × 有効 は、この 103 行では1件前後なので、ここでは式だけを見てください。

D の規則は「429 かつ REJECTED」を外すと書いたので、既定 503 のまま返した /api/quota503 の割当拒否7件は悪いに残りました。A でも同じ7件は悪く、429 で返した /api/quota の拒否 17 件は良いに入っています。同じ割当拒否が、返したコードだけで逆に数えられました。

同じ構成で2回実行し、件数はすべて一致しました。ただし時刻に依存する設定なので、別の環境では1件前後ずれえます。

✓ ここまでの確認

原因の列を持たないログで、D の決め方(割当拒否の 429 だけを分母から外し、混雑の 429 は悪いに数える)を計算しようとするとどうなりますか。

⇄ 2つを見比べる
具体例 A

nginx が割当超過で断った 429

limit_req が REJECTED にし、要求はアプリに届いていません。RFC 6585 の 429 の説明どおり、利用者の側の量で断っています。

labeled.log(1行。末尾の1列を省略)コマンド出力
"GET /api/mixed HTTP/1.1" 429 limit_req=REJECTED reason=-
具体例 B

アプリが混雑で断った 429

nginx は PASSED で通し、アプリが同時処理数の上限で断りました。原典が例に挙げるのは 503 で、今回はわざと 429 で返させています。

labeled.log(1行。末尾の1列を省略)コマンド出力
"GET /api/mixed HTTP/1.1" 429 limit_req=PASSED reason=shed

状態コードの列は同じです。2つを SLI で違う扱いにするには、何がログに要るでしょうか。

combined の行と labeled の行で、増えた列が何を記録しているかを見てください。

この章のまとめ

  • RFC 9110 は 4xx を seems to have erred と書く。コードは結果で、断った理由ではない。
  • 規格では 429 は利用者の送りすぎ、503 は一時的な過負荷。今回はアプリの混雑拒否をわざと 429 で返させ、同じ 429 に2つの原因を載せた。
  • combined の行では2種の 429 を分ける列が無い。$limit_req_status と $upstream_http_x_reason を列にすると分かれた。
  • 同じ 103 行から A 90.291% / B 83.333% / C 53.763% / D 72.464%。5xx は4通りとも 10 件。
  • 原因の列が無ければ D は選べない。採った決め方は SLI 仕様に書く。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

RFC 9110 §15.5 は 4xx クラスをどう説明していますか。

用語集(3語)
$limit_req_status
nginx の変数(1.17.6 から)。その要求に対する limit_req の結果を PASSED・DELAYED・REJECTED・DELAYED_DRY_RUN・REJECTED_DRY_RUN のどれかで持つ。
$upstream_http_<name>
nginx の変数。上流サーバの応答ヘッダを持つ。名前はヘッダ名を小文字にしハイフンを下線にしたもので、X-Reason なら $upstream_http_x_reason。
原因の列
本教材の語。状態コードとは別に、誰がなぜ断ったかをログに書いた列。今回は上の2変数で作った。