L4プロキシにおけるコネクションプーリングの再定義と、事前ウォームアップによる接続レイテンシの排除

コネクションプーリングと聞くと、HTTPのkeep-aliveのように「1本の接続を複数のリクエストで使い回す」仕組みを思い浮かべます。しかしL4プロキシはアプリケーション層のことを何も知らず、生のバイト列をクライアント接続の寿命いっぱい転送し続けるだけなので、この意味でのプーリングはそのままでは成立しません。何を「プーリング」と呼ぶべきかを定義し直すところから始めます。

「使い終わった接続の再利用」が成立しない理由

copy_bidirectionalは、クライアントとバックエンドの間で、片方が接続を閉じる(FINが伝播する)までバイト列を転送し続けます。つまり1本のバックエンド接続は、1つのクライアント接続の生存期間ときっちり同じだけ使われ、転送が終わった時点でその接続自体の役目も終わります。プロトコルの区切りを知らないL4のまま、終わった接続を別の見ず知らずのクライアントに渡すと、前のクライアントの残りデータと新しいクライアントのデータが同じTCPストリームに混ざる可能性があり、安全に再利用できません。

そこで今回のプーリングは、バックエンドへの接続をあらかじめ張っておき、新規クライアント接続が来た瞬間にはもう3-wayハンドシェイクを待たなくていい状態にしておく、という事前ウォームアップの意味で実装します。使い終わった接続はプールに戻さず、代わりにバックグラウンドで新しい接続を補充し続けます。効果は「クライアント接続を受けてからバックエンドへの転送が始まるまでのレイテンシ」をなくすことです。

待機中の接続を保持するプール

// src/pool.rs
pub struct Pool {
    addr: SocketAddr,
    idle: Mutex<VecDeque<TcpStream>>,
}

impl Pool {
    pub fn try_get(&self) -> Option<TcpStream> {
        self.idle.lock().unwrap().pop_front()
    }

    pub fn put(&self, stream: TcpStream) {
        self.idle.lock().unwrap().push_back(stream);
    }

    fn len(&self) -> usize {
        self.idle.lock().unwrap().len()
    }
}

std::sync::Mutexを使っているのは、VecDequeへの出し入れ自体が一瞬で終わる同期的な操作だからです。.awaitをまたいでロックを持ち続けることがないので、tokioの非同期版Mutexは不要です(WeightedロードバランサーのMutex<Vec<WeightedState>>と同じ判断です)。

バックグラウンドで補充するタスク

// src/pool.rs(続き)
pub async fn run_pool_filler(pool: Arc<Pool>, target_size: usize, refill_interval: Duration) {
    let mut ticker = tokio::time::interval(refill_interval);
    loop {
        ticker.tick().await;
        let deficit = target_size.saturating_sub(pool.len());
        for _ in 0..deficit {
            if let Ok(stream) = TcpStream::connect(pool.addr()).await {
                pool.put(stream);
            }
        }
    }
}

アクティブヘルスチェックrun_health_checksと同じ「tokio::time::intervalで定期実行するバックグラウンドタスク」という構造です。不足分(deficit)を毎回計算し直すことで、接続がプールから引き出された分だけ埋め戻す動きになります。tokio::time::intervalは最初のtickを即座に発行するため、プロキシの起動直後にプールが埋まり始めます。

呼び出し側: プールを優先し、無ければ即座にダイヤルする

// src/proxy.rs(抜粋)
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);
            return Err(e);
        }
    }
};

プールから取れた接続には、record_successを呼びません。パッシブヘルスチェックの失敗カウンタは「今まさにダイヤルして繋がったかどうか」だけを見る指標として設計しているため、プールヒット(今回ダイヤルしていない)はこの指標の対象外です。プールが空だった場合のみ、これまで通りTcpStream::connectにフォールバックし、成否の記録も従来通り行われます。

既知の限界

今回はスコープを絞り、2点は未対応のまま残しています。

  • プール内の接続はアイドル中にバックエンド側から切られる可能性があり、TCPはそれを能動的に通知しません。try_get()で取り出した接続が実は死んでいた場合、copy_bidirectional側のエラーとして扱われ、パッシブヘルスチェックの対象にはカウントされません。
  • 補充タスクはバックエンドの生死(backends.is_healthy)を見ていないため、死んでいるバックエンドにも定期的に接続を試み続けます。