実障害と見分けがつかない障害注入の設計と、ラウンドロビンが健全なバックエンドだけを対象にすることで起きる注入の偏り

これまでのフェーズで、サーキットブレーカー/リトライパッシブヘルスチェックといった耐障害の仕組みを積み上げてきました。これらが本当に機能しているかを確かめるには、本来バックエンドを実際に落とす必要がありますが、毎回それをやるのは手間です。今回は、確率に基づいて遅延・切断を注入するFaultInjectorを実装し、バックエンドを落とさずに耐障害機構を検証できるようにします。

確率で発火する遅延・切断

// src/fault_injection.rs
use rand::RngExt;
use std::time::Duration;

pub struct FaultInjector {
    delay_probability: f64,
    delay_duration: Duration,
    disconnect_probability: f64,
}

impl FaultInjector {
    pub fn new(
        delay_probability: f64,
        delay_duration: Duration,
        disconnect_probability: f64,
    ) -> Self {
        Self {
            delay_probability,
            delay_duration,
            disconnect_probability,
        }
    }

    pub async fn maybe_delay(&self) {
        if rand::rng().random::<f64>() < self.delay_probability {
            tokio::time::sleep(self.delay_duration).await;
        }
    }

    pub fn should_disconnect(&self) -> bool {
        rand::rng().random::<f64>() < self.disconnect_probability
    }
}

確率はmain.rsの定数で渡します。デフォルトは両方とも0.0(無効)にしてあり、検証したいときだけ値を上げて使う運用です。

注入ポイントはバックエンド接続を確保した直後の1箇所だけ

// src/proxy.rs(抜粋)
let local_addr = outbound.local_addr()?;

fault_injector.maybe_delay().await;
if fault_injector.should_disconnect() {
    drop(outbound);
    if let Some(idx) = idx {
        backends.record_failure(idx, failure_threshold);
        metrics.inc_backend_failure(idx);
    }
    if sni_backend.is_none() {
        lb.release(backend_addr);
    }
    last_err = std::io::Error::other("fault injection: forced disconnect");
    continue;
}

let header = v2_header(peer_addr, local_addr);
outbound.write_all(&header).await?;

outboundコネクションプール由来か新規ダイヤルか)を確保した直後、PROXY protocolヘッダーを書き込む前に挟んでいます。この位置にしたのは、プール経由・新規ダイヤル経由のどちらの接続もここを必ず通るため、1箇所の注入で両方をカバーできるからです。

ポイントは、切断発火時の後処理を本物の接続失敗と全く同じ形で書いていることです。backends.record_failuremetrics.inc_backend_failurelb.release、そしてリトライループへのcontinue。実際にTcpStream::connectが失敗したときの処理(すぐ上のコード)と一字一句同じ構造をなぞっています。この結果、注入された障害と本物の障害は、システムのどの層から見ても区別がつきません。サーキットブレーカーもパッシブヘルスチェックも、原因が「注入」か「本物」かを気にせず、失敗の事実にだけ反応します。

ラウンドロビンは「健全なバックエンドだけ」を対象に剰余を取る

FAULT_DISCONNECT_PROBABILITY1.0にして全接続を強制切断させたところ、tcpproxy_backend_failures_totalが特定の1台だけに積み上がる現象が起きました。原因はロードバランサー側のロジックにあります。

// src/load_balancer/round_robin.rs
fn next_backend(&self) -> Option<SocketAddr> {
    let healthy_indices: Vec<usize> = (0..self.backends.addrs().len())
        .filter(|&idx| self.backends.is_healthy(idx))
        .collect();
    if healthy_indices.is_empty() {
        return None;
    }
    let pick = self.counter.fetch_add(1, Ordering::Relaxed) % healthy_indices.len();
    Some(self.backends.addrs()[healthy_indices[pick]])
}

counterを剰余する対象は、設定されたバックエンド全体の数ではなく、その時点で健全と判定されているバックエンドの数です。このときたまたま検証用のダミーバックエンドを2台落としたままにしていたため、健全なバックエンドは実質1台しか残っておらず、healthy_indices.len()1になっていました。counterの値が何であっても% 1は常に0なので、リトライのたびに同じ1台が選ばれ続け、そこに注入した切断がすべて積み重なる形になりました。ラウンドロビンの「均等に割り振る」という性質は、あくまで現在健全な集合の中で均等という意味であり、設定上のバックエンド全体に対して均等ではない、という点が実際の挙動として確認できました。

アクティブヘルスチェックは失敗回数の累積を見ていない

落としていたダミーバックエンドを復旧させると、tcpproxy_backend_upは次のヘルスチェック周期(3秒)以内に自動で1へ戻りました。一方tcpproxy_backend_failures_totalはモノトニックなカウンタなので、それまでに積み上がった値はそのまま残ります。

アクティブヘルスチェックは、直近の接続試行が成功したかどうかだけを見て健全性を上書きする、累積とは無関係な仕組みです。一方サーキットブレーカーのconsecutive_failuresは連続失敗数で、成功すればrecord_successでリセットされます。「これまで何回失敗したか(監視用の累積値)」「今現在生きているか(ルーティング用の直近の判定)」「連続で何回失敗しているか(遮断用のリセット可能な値)」という3つの指標が、それぞれ別のライフサイクルで動いていることが、意図的に障害を起こしたことで改めて確認できました。

既知の限界

遅延・切断のどちらも、注入ポイントはバックエンド接続を確保した直後、実データの転送が始まるの1箇所だけです。転送の途中(copy_bidirectional実行中)でバックエンドが応答を止める、あるいは接続を切るといった、より現実に近い障害パターンは注入できません。これを実現するには、TcpStreamを直接渡すのではなく、読み書きの途中に介入できるラッパー(AsyncRead/AsyncWriteを実装するストリーム)を挟む必要があり、今回のスコープには含めていません。