余裕を持たせたタイムアウトが障害を広げる
一言でいうと
タイムアウトは、呼び出しごとに別々に決める数字ではなく、上位のリクエスト1つに与えられた予算を、下へ分け与えることです。予算が足りなければ、呼び出さずにすぐ失敗させるのが、最も安い選択です。
なぜ必要なのか
「タイムアウトは少し余裕を持たせておこう」という言葉は、安全に見えますが、実際は逆です。相手が遅くなったときに何が起きるかをたどってみれば、わかります。
相手の応答が、普段は200msなのに、ある日30秒になりました。こちらのタイムアウトが60秒なら、こちらのワーカーは、30秒間その呼び出しに縛られます。ワーカーが50個あれば、1秒あたりの処理量が急激に落ち、こちらを呼ぶ側から見れば、遅くなったのはこちらです。その側もタイムアウトに余裕があれば、同じことが1階層上でまた起きます。1つのシステムの遅延が上へ広がっていくこの現象のため、タイムアウトは「相手を待ってあげる思いやり」ではなく、こちらを守るブレーカーです。
ここでよくある2つ目のミスが、タイムアウトを1つの値だけで設定することです。Python requestsの高度な使い方のドキュメントには、タイムアウトを(connect, read)の2つの値で渡せると書かれています。2つに分けるのは、意味がまったく違うからです。
- 接続の上限は、相手に届くまでにかかる時間です。同じデータセンターなら、ミリ秒単位で済むのが正常で、数秒かかるなら、それは遅いのではなくできていないのです。そのため、接続の上限は短く設定します。
- 読み取りの上限は、相手が仕事をする時間です。これは業務によって違い、余裕が必要な場合もあります。
接続の失敗の形も分かれます。Connection refusedは、パケットが宛先まで行って戻ってきたものなので、ほぼ即座に終わり、誰も応答しない場合は、上限まで待ってからタイムアウトになります。実験には、RFC 5737がドキュメント用に予約している192.0.2.0/24のようなアドレスを使います。どこにもルーティングされないので、パケットが静かに消え、接続タイムアウトがどんな形になるかを、安全に見られます。
どう動くのか
予算として考えると、ルールは3行に縮まります。
1つ目、上位のリクエストに総予算を決めます。画面が2秒以内に描画されなければならないなら、そのリクエストの予算は2000msです。
2つ目、各下位呼び出しの読み取り上限は、その時点で残っている予算です。最初の呼び出しに2000ms、それが300ms使えば、次の呼び出しには1700ms。こうすれば、どの呼び出しも、総予算を超えることができません。
3つ目、残りの予算が下限より小さければ、呼び出しません。200ms残っているのに、普段400msかかる呼び出しをかけるのは、失敗を200ms先に延ばすだけです。その場で失敗すれば、その200msで、部分応答だけでも作れます。
再試行は、予算を食います。この計算を抜かしている実装が、非常に多いです。読み取り上限2秒で再試行3回なら、最悪の場合は6秒に、待機時間まで加わります。待機は指数で伸ばし、ランダムを混ぜるのが定石ですが(同じ瞬間に失敗したクライアントたちが、同じ瞬間にまた押し寄せることを防ぎます)、その待機も予算から出ていきます。そのため、再試行の直前に「今待ってもう一度かけると、予算に収まるか」と問い、収まらなければ止めます。
残りの予算は、下へ渡します。こちらに1200msしか残っていないのに、下位のサービスが自分の基準で5秒待つと、その4秒は、誰も見ない答えを作るために使われます。そこで、残りの予算をヘッダーに載せて送り、受け取る側が自分の上限にするようにします。ステータスコードのほうでは、RFC 9110が定義する504(Gateway Timeout)が「上位が待って切った」を、RFC 6585の429(Too Many Requests)が「速度を落とせ」を意味します。2つは違う信号で、対応も違います。
예산 2000ms
├─ 호출 A 남은 2000 → 300ms 사용
├─ 호출 B 남은 1700 → 250ms 사용
├─ 재시도 남은 1450 → 대기 200 + 호출 400
└─ 호출 C 남은 850 → 하한 1000 미만이면 걸지 않고 즉시 실패
このコードブロックの韓国語は、予算、各呼び出しの残りと使用量、再試行の待機と呼び出し、下限(1000)未満なら呼び出さず即座に失敗、という意味のラベルです。
現場での姿
1つ目、設定したタイムアウトが、実際には効いていないことがよくあります。アプリの設定は30秒なのに、失敗が127秒で起きたなら、その値が適用されず、カーネルのSYN再送が尽きたのです。設定を直す前に、実際に何秒で切れるのかを測ってみます。
2つ目、タイムアウトとコネクションプールは、一緒に動きます。プールの待機時間にも上限がなければ、呼び出し自体のタイムアウトが短くても、プールで待つ間に全体の時間が長くなります。
3つ目、予算を超えたときに何をするかを、事前に決めていません。選択肢は、たいてい3つです。部分応答を返す、キャッシュの古い値を返す、失敗を返す。何を選んでもかまいませんが、選ばなければ、コードが最悪のものを選びます。最後まで待つことです。
4つ目、数字の根拠を残していません。「読み取り3秒」がどこから来たのかが書かれていなければ、誰も変えられません。相手の観測された99パーセンタイルに余裕を掛けて決めた、と1行書いておけば十分です。
次のラボですること
遅くなるパートナーサーバーを起動して、接続拒否・接続タイムアウト・読み取りタイムアウトの3つの失敗の形を、自分で測ります。接続と読み取りの上限を分けて設定する呼び出し器を作り、その上に、総予算を守る呼び出し器を重ねて、残りの予算がそのまま次の呼び出しの上限になるようにします。予算が下限に届かなければ、かけずに飛ばすようにし、再試行が予算を食う計算まで入れます。最後に、残りの予算をヘッダーで下に渡し、画面1つの予算表を、数字で書きます。