相手が遅くなったのに、こちらが先に倒れた
目標
上位のリクエスト1つに与えられた時間を予算として捉え、その予算を、接続・読み取り・再試行・下位呼び出しに分け与えるクライアントを、自分で作ります。予算が足りなければ、かけずに早く失敗し、残りの予算を、ヘッダーで下に渡します。
なぜ重要なのか
タイムアウトに余裕を持たせる習慣は、安全に見えますが、実際は逆です。相手が30秒に遅くなったとき、こちらの上限が60秒なら、こちらのワーカーは30秒間縛られ、こちらを呼ぶ側から見れば、遅くなったのはこちらです。その遅延は、1階層上にもまた広がります。 そのため、タイムアウトは、相手を待ってあげる思いやりではなく、こちらを守るブレーカーです。そして、1つの値ではなく2つの値です。接続は届くまでの時間なので、数秒かかるなら、遅いのではなくできていないのであり、読み取りは相手が働く時間なので、業務によって違います。 予算として考えると、ルールは3行です。上位のリクエストに総予算を決め、各呼び出しの読み取り上限は、その時点の残りの予算にし、残りの予算が下限より小さければ、かけません。再試行とその待機時間も、同じ予算から出ていきます。 採点ツールは、作成した文章を信じません。遅くなるパートナーを、採点ツールが選んだポートで直接起動し、作成した呼び出し器を実際に動かして、かかった時間と、出力の区間の名前を、一緒に測ります。
ステップ
- /root/budget/partner.pyを作成してポート8014で起動し、失敗の3つの形を測って、/root/budget/probe.jsonに書いてください。
- /root/budget/call.pyを作成して、接続と読み取りの上限を別々に設定し、どの区間で切れたかを答えるようにしてください。
- /root/budget/deadline.pyを作成して、総予算1つで複数の呼び出しを行い、残りの予算が、次の呼び出しの読み取り上限になるようにしてください。
- deadline.pyに下限(
--min-ms)を付けて、残りの予算が下限より小さければ、かけずにno_budgetとして飛ばすようにしてください。 - /root/budget/retry.pyを作成して、再試行の待機を指数で伸ばしてランダムに散らしつつ、その待機まで予算から引くようにしてください。
- /root/budget/fanout.pyを作成して、そのときの残りの予算を、
X-Budget-Msヘッダーで下位呼び出しに渡してください。 - /root/budget/budget_plan.jsonに、画面1つの予算表を、数字で書いてください。
- /root/budget/budget_report.mdに、4つの節で報告してください。
参考
- パートナーの実行契約:
python3 /root/budget/partner.py --port <포트>(プレースホルダーはポートです)。/healthは{"ok": true}、/call?ms=<n>&fail=<k>&key=<s>は、nミリ秒後に応答し、同じkeyの最初のk回は503です。/budget?ms=<n>は、nミリ秒後に{"received": <X-Budget-Ms 헤더 값>}を出力します(プレースホルダーはX-Budget-Msヘッダーの値です)。 - 失敗の3つの形: 閉じたポート(
http://127.0.0.1:9/)は、ほぼ即座に拒否され、どこにもルーティングされないアドレス(http://192.0.2.1/。RFC 5737がドキュメント用に予約している帯域)は、接続の上限まで待ってから切れ、遅い応答は、読み取りの上限まで待ってから切れます。probe.jsonは、{"kind": "refused"|"connect_timeout"|"read_timeout", "target": ..., "elapsed_ms": ..., "error": ...}の3行のリストです。 - 呼び出し器の実行契約:
python3 call.py --url <U> --connect <초> --read <초>(プレースホルダーは秒です)は、{"ok": ..., "status": ..., "phase": "done"|"connect"|"read", "elapsed_ms": ..., "error": ...}を出力します。接続拒否のように、相手に届く前に終わった失敗も、connectの区間です。4xx・5xxの応答は、受け取ったものなので、phaseはdoneで、okだけがfalseです。 - 予算呼び出し器の実行契約:
python3 deadline.py --budget-ms <n> --url <U> [--url <U> ...] [--connect <초>] [--min-ms <n>](プレースホルダーは秒です)。出力は{"budget_ms": ..., "calls": [...], "skipped": k, "total_ms": ..., "exceeded": ...}で、各callには、url、ok、phase、elapsed_ms、read_timeout_msがあります。read_timeout_msは、その呼び出しをかけたときに残っていた予算です。--min-msは、ステップ3でも引数として受け取っておき(既定は0)、下限の判定は、ステップ4で付けます。exceededは、予算が足りずに飛ばした呼び出しがある場合や、読み取りが予算で切れた場合に、trueです。 - 再試行器の実行契約:
python3 retry.py --url <U> --budget-ms <n> --attempts <k> [--base-ms <b>] [--connect <초>] [--min-ms <n>](プレースホルダーは秒です)は、{"ok": ..., "attempts": ..., "elapsed_ms": ..., "waits_ms": [...], "stopped_reason": "ok"|"attempts"|"budget"}を出力します。待機の上限はbase-ms * 2^(시도-1)で(プレースホルダーは試行回数です)、実際の待機は、0からその上限の間で選びます。 - ファンアウトの実行契約:
python3 fanout.py --base <BASE_URL> --budget-ms <n> --n <횟수> [--connect <초>](プレースホルダーは順に回数、秒です)は、毎回<BASE>/budget?ms=200を呼び、X-Budget-Msヘッダーに、そのときの残りの予算を載せて送ります。出力の各callは、sent_budget_ms、received、elapsed_msです。 - 予算表の形式:
{"total_ms": ..., "reserve_ms": ..., "worst_case_ms": ..., "calls": [{"name": ..., "budget_ms": ..., "max_attempts": ...}]}。呼び出しは3つ以上、reserve_msは100以上、worst_case_msは、呼び出しの予算の合計に予備を加えた値で、total_msを超えてはいけません。 - よくあるミス: タイムアウトを1つの値だけで渡すこと、再試行の待機を予算から引かないこと、残りの予算がほとんどないのに呼び出しをかけること、下位呼び出しにこちらの予算を知らせないこと。
- 負荷テストを作らないでください。採点1回の予算は、60秒です。
失敗の3つの形を測ってみる
/root/budget/partner.pyを作成してポート8014で起動し、接続拒否・接続タイムアウト・読み取りタイムアウトの3つを自分で測って、/root/budget/probe.jsonに、kind・target・elapsed_ms・errorの3行で書いてください。
遅い応答は、パートナーがsleepで真似ます。接続拒否は、誰も待ち受けていない127.0.0.1のポートで、接続タイムアウトは、どこにもルーティングされないアドレスで作ります。3つの場合にかかった時間が、互いにどう違うかが、このステップの核心です。
接続と読み取りを分けて設定する
/root/budget/call.pyを作成して、--connectと--readを別々に受け取り、失敗したときに、phaseにconnectかreadかを書くようにしてください。応答を受け取ったなら、ステータスコードが4xx・5xxでも、phaseはdoneです。
requestsのtimeoutは、2つの値を入れたペアを受け取ります。例外も種類が分かれているので、接続側と読み取り側を区別できます。ただし、接続拒否はタイムアウトの例外ではないので、別に捕まえる必要があり、それも相手に届く前に終わったことなので、connectの区間です。
残りの予算が、次の呼び出しの上限になる
/root/budget/deadline.pyを作成して、--budget-ms1つで、複数の--urlを順に呼ぶようにしてください。各呼び出しの読み取り上限は、その時点の残りの予算である必要があり、その値をread_timeout_msに書く必要があります。
前のステップのcall.pyをモジュールとして読み込んで使えば、同じコードを2回書かずに済みます。開始時刻を1度決めておき、呼び出しの直前ごとに「予算引くこれまでに使った時間」を計算してください。こうすれば、呼び出しがいくつあっても、全体が予算を超えることができません。
見込みのない呼び出しは、かけない
deadline.pyに--min-msを付けて、残りの予算がその値より小さければ、呼び出しをかけずに、phaseをno_budgetとして記録し、skippedを増やすようにしてください。飛ばした呼び出しのelapsed_msは、0です。
200ms残っているのに、普段400msかかる呼び出しをかけるのは、失敗を200ms先に延ばすことです。その場で失敗すれば、残りの時間で、部分応答だけでも作れます。下限は引数で受け取り、状況ごとに異なる値を渡せる必要があります。
再試行も、予算から出ていく
/root/budget/retry.pyを作成して、失敗した呼び出しをもう一度かけつつ、待機の上限をbase-ms * 2^(시도-1)(プレースホルダーは試行回数です)で伸ばし、実際の待機は、0からその上限の間で選ぶようにしてください。待ったあとにもう一度かける余地が予算にないなら、stopped_reasonをbudgetにして止めます。
ランダムを混ぜなければ、同じ瞬間に失敗したクライアントたちが、同じ瞬間にまた押し寄せます。そして、待機時間も予算から出ていくので、寝て起きてから呼び出しをかける余地が残っているかを、寝る前に問う必要があります。止まった理由を3つに分けて書いておけば、あとでログだけを見て、原因がわかります。
残りの予算を下に渡す
/root/budget/fanout.pyを作成して、<BASE>/budget?ms=200を--n回呼びつつ、毎回X-Budget-Msヘッダーに、そのときの残りの予算を載せて送ってください。出力の各callに、sent_budget_ms・received・elapsed_msを書きます。
こちらに1200msしか残っていないのに、下位のサービスが自分の基準で5秒待つと、その4秒は、誰も見ない答えを作るために使われます。ヘッダーの名前は、こちらが決める約束で、受け取る側は、その値を自分の上限にします。パートナーの/budgetは、受け取ったヘッダーの値をそのまま返すので、実際に伝わったかを確認できます。
画面1つの予算表
/root/budget/budget_plan.jsonに、予算表を書いてください。total_ms・reserve_ms・worst_case_msと、calls(名前・budget_ms・max_attempts)が3つ以上、必要です。reserve_msは100以上、worst_case_msは、呼び出しの予算の合計に予備を加えた値で、total_msを超えてはいけません。
予備(reserve)は、こちら側のシリアライズやテンプレートのレンダリングのような、呼び出しではない時間です。これを抜かして呼び出しに予算をすべて分け与えると、画面は、いつも上限ぎりぎりです。再試行がある呼び出しは、その呼び出しの予算の中で、再試行まで終える必要があるという意味です。
予算の点検報告書
/root/budget/budget_report.mdに、## 지금 무엇이 시간을 먹는가、## 구간을 어떻게 나눴나、## 재시도가 먹는 몫、## 예산을 넘겼을 때 무엇을 하나(韓国語の見出しで、順に今何が時間を食っているか、区間をどう分けたか、再試行が食う分、予算を超えたときに何をするか、を意味します)の4つの節で書いてください。probe.jsonとbudget_plan.jsonの数字が、本文に入っている必要があります。
読む人は、「タイムアウトを少し延ばしてください」と言った人です。延ばすと何が悪くなるかを、数字で見せてください。再試行の分は、試行回数と待機の上限を掛けて、最悪の場合を計算して書きます。