接続失敗時のリトライと、バックオフを入れない設計がフリート規模で牙を剥く理由

サーキットブレーカーの核心は「壊れていると分かったバックエンドには、次から試行すらせず即座に遮断する」ことです。これはパッシブヘルスチェック(連続失敗でunhealthyに落とす=遮断)とアクティブヘルスチェック(定期プローブでの復帰確認)の組み合わせで、名前こそ付けていませんでしたがすでに実現していました。今回追加するのは、その上に乗るリトライです。今まではTcpStream::connectが1回失敗したら、そのままクライアントにエラーを返していました。

リトライ対象は接続確立の失敗だけ

// src/proxy.rs
pub async fn handle_connection(
    mut inbound: TcpStream,
    peer_addr: SocketAddr,
    lb: Arc<dyn LoadBalancer>,
    backends: Arc<Backends>,
    pools: Arc<Vec<Arc<Pool>>>,
    failure_threshold: usize,
    max_connection_attempts: usize,
) -> std::io::Result<()> {
    let mut last_err = std::io::Error::other("no healthy backend available");

    for _ in 0..max_connection_attempts {
        let Some(backend_addr) = lb.next_backend() else {
            return Err(last_err);
        };

        let idx = backends.index_of(backend_addr);

        let mut outbound = if let Some(stream) = idx.and_then(|idx| pools[idx].try_get()) {
            stream
        } else {
            match TcpStream::connect(backend_addr).await {
                Ok(stream) => {
                    if let Some(idx) = idx {
                        backends.record_success(idx);
                    }
                    stream
                }
                Err(e) => {
                    if let Some(idx) = idx {
                        backends.record_failure(idx, failure_threshold);
                    }
                    lb.release(backend_addr);
                    last_err = e;
                    continue;
                }
            }
        };

        let result = copy_bidirectional(&mut inbound, &mut outbound).await;
        lb.release(backend_addr);

        let (from_client, from_backend) = result?;
        println!(
            "{peer_addr} -> {backend_addr}: {from_client} bytes client->backend, {from_backend} bytes backend->client"
        );
        return Ok(());
    }

    Err(last_err)
}

リトライするのは接続確立(TcpStream::connect)が失敗したときだけです。copy_bidirectionalが始まった後(クライアントのデータをすでに転送し始めた後)のエラーはリトライしません。一度送ったバイト列を別のバックエンドに送り直すことはできないためです。この境界線は、Cloudflare pingoraのProxyHttpトレイトが接続確立のエラー用にfail_to_connect、確立後のエラー用にerror_while_proxyという別々のコールバックを持っているのと同じ考え方です。

lb.next_backend()が呼ばれるたびにロードバランサーの選択ロジックが動くので、Round Robinであれば大抵は前回とは別のバックエンドが選ばれます。同じ壊れたバックエンドを選び続けた場合も、選ばれるたびにrecord_failureが呼ばれるので、閾値回失敗すればそのバックエンド自身がその後のリトライ対象からも除外されます。

バックオフを入れなかった判断

リトライの間に待ち時間(バックオフ)は入れていません。理由は単純で、リトライのたびに別のバックエンドが選ばれるのが通常だからです。古典的にバックオフが必要とされるのは「同じ相手にすぐリトライして、回復していない/輻輳している状態に追い打ちをかける」ケースであり、毎回選び直しているこの実装ではその状況になりにくいと判断しました。

この判断がフリート規模で意味を変える

ただし、これはあくまで「個々のバックエンドが独立してランダムに落ちる」場合の話です。バックエンドの不調が独立ではなく相関して同時に起きる場合(不具合のあるデプロイが全台に配信された、共有の依存先が落ちた、トラフィック急増など)は事情が変わります。

小規模なフリートでは「台数が多いほど、たまたま数台落ちても残りの健全な台数に余裕がある」という理屈が成り立ちますが、大規模なフリートでは逆に、台数が多いということは同時に受けているクライアント接続の数もそれだけ多いということです。フリート全体がわずかに弱った瞬間に、桁違いの数のリトライが一斉に発生し、部分的な不調を全断に変えてしまいかねません。これは「リトライ増幅(retry amplification)」と呼ばれる、大規模障害の典型的な原因の一つで、Google SREの文献でも「cascading failure(連鎖障害)」として章立てで扱われています。Envoyのような大規模インフラ向けプロキシソフトウェアが、フリート全体でのリトライ総量に上限をかける「retry budget」という仕組みを持っているのは、まさにこの「台数が多いほど危険になる」側面への対策です。台数の多さは免罪符にはならず、むしろ対策の必要性を高める要因になります。

今回の学習プロジェクトの規模ではバックオフ・retry budgetのどちらも作り込んでいませんが、その判断が成立するのは「フリートが小さいうちだけ」という前提付きです。