実トラフィックの失敗を数えるパッシブヘルスチェックと、接続失敗と転送エラーの切り分け

アクティブヘルスチェックは、定期的なTCP接続確認でバックエンドの生死を判定します。トラフィックがなくても動く一方、次の確認タイミングまではバックエンドの状態変化に気づけません。実際に転送したコネクションの成否からも健全性を判定し、確認間隔を待たずに死んでいるバックエンドを切り離すパッシブヘルスチェックを実装します。

実トラフィックの失敗を数える

Backendsに、バックエンドごとの連続失敗回数を追加します。生死フラグ(healthy: Vec<AtomicBool>)と同じ発想で、AtomicUsizeの配列を並べるだけです。

// src/health.rs
pub struct Backends {
    addrs: Vec<SocketAddr>,
    healthy: Vec<AtomicBool>,
    consecutive_failures: Vec<AtomicUsize>,
}

impl Backends {
    pub fn record_success(&self, idx: usize) {
        self.consecutive_failures[idx].store(0, Ordering::Relaxed);
    }

    pub fn record_failure(&self, idx: usize, threshold: usize) {
        let failures = self.consecutive_failures[idx].fetch_add(1, Ordering::Relaxed) + 1;
        if failures >= threshold {
            self.set_healthy(idx, false);
        }
    }

    pub fn index_of(&self, addr: SocketAddr) -> Option<usize> {
        self.addrs.iter().position(|&a| a == addr)
    }
}

thresholdBackends自身には持たせず、呼び出し側から渡す形にしています。run_health_checks(backends, interval, timeout)と同じく、間隔やしきい値などの設定は呼び出し側が持つという既存の方針に合わせました。Ordering::Relaxedhealthyフラグと同じ理由で問題ありません。これらのアトミック変数は互いに「この値が見えているなら、あの値もこう見えているはず」という同期を必要としない、独立したベストエフォートのフラグだからです。

復帰判定はこの仕組みでは行いません。バックエンドが息を吹き返したかどうかはアクティブヘルスチェックのTCP接続確認に任せ、パッシブ側は「実トラフィックの失敗で素早く切り離す」役割に専念させています。

接続失敗だけをバックエンドの責任にする

forwardは元々、バックエンドへの接続とcopy_bidirectionalによるデータ転送をひとつの関数にまとめていました。ここには見過ごしやすい問題があります。copy_bidirectionalが返すio::Errorは、inbound(クライアント側)とoutbound(バックエンド側)のどちらの読み書きが失敗しても発生します。つまり、クライアントが途中で回線を切っただけでもバックエンドの失敗としてカウントされてしまい、正常なバックエンドが実際とは無関係な理由でパッシブヘルスチェックによって誤って切り離されるおそれがあります。

そこで、バックエンドへの接続確立という「明確にバックエンド側の問題と言える失敗」だけをパッシブヘルスチェックの対象にし、確立後の転送エラーは対象から外しました。

// src/proxy.rs
pub async fn handle_connection(
    mut inbound: TcpStream,
    peer_addr: SocketAddr,
    lb: Arc<dyn LoadBalancer>,
    backends: Arc<Backends>,
    failure_threshold: usize,
) -> std::io::Result<()> {
    let Some(backend_addr) = lb.next_backend() else {
        return Err(std::io::Error::other("no health backend available"));
    };

    let idx = backends.index_of(backend_addr);

    // 接続確立の失敗は明確にバックエンド側の問題なので、パッシブ失敗カウンタに反映する。
    let mut outbound = 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);
            return Err(e);
        }
    };

    // ここから先のエラーはクライアント側が原因の場合もあるため、バックエンドの責任にはしない。
    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"
    );

    Ok(())
}

lb.releaseはLeast Connectionsの処理中コネクション数を減らすための呼び出しなので、接続失敗・転送エラーのどちらの経路でも呼ぶ必要があります。両方のmatchアームおよびその後の行で漏れなく呼んでいます。

アクティブとパッシブの役割分担

結果として、2つの仕組みは非対称な役割分担になりました。アクティブチェックはトラフィックがなくても動き、復帰を検知する役目を担います。パッシブチェックは実トラフィックが流れて初めて動き、次の確認タイミングを待たずに即座に切り離す役目を担います。「落とすのは速く、戻すのは確認してから」という設計です。

この設計判断は、Cloudflareのpingoraの実装とも整合します。pingoraのProxyHttpトレイトには、上流への接続確立で起きたエラーを扱うfail_to_connectと、接続確立後に起きたエラーを扱うerror_while_proxyという、明確に分離された2つのコールバックがあります。ドキュメントコメントにも「fail_to_connectは接続確立の過程でのエラー」「error_while_proxyは接続確立のエラー」と明記されており、今回行った接続失敗と転送エラーの切り分けと同じ区別です。

またpingoraのアクティブヘルスチェック(TcpHealthCheck)は、consecutive_successconsecutive_failureを別々の閾値として持ち、healthyフラグと連続カウンタを1つの構造体にまとめてArcSwapで丸ごと差し替える実装になっています。これは「カウンタを読む→更新する→閾値到達ならフラグも更新する」という一連の操作を、他スレッドから中間状態が見えないアトミックな単位にするためです。今回の実装はAtomicBoolAtomicUsizeを別々に持たせているため、同じ強度の保証はありません。もっとも、この一連の操作の途中に別コネクションの失敗が割り込んだ場合の最悪の結果は「同じset_healthy(false)が二重に呼ばれる」程度で実害はなく、学習目的のスコープでは今の実装で十分と判断しています。