Proxy Protocol v1/v2でクライアントIPを伝播する設計と、プールが「使い捨て」だから両立できた理由

L4のTCPプロキシを経由すると、バックエンドから見た接続元IPは常にプロキシ自身のIPになります。TCP/IPヘッダの範囲だけでは、本来のクライアントIPを下流に伝える手段がありません。Proxy Protocolは、実データを転送する前にプロキシが小さなヘッダをバックエンド側へ書き込み、そこにクライアントの本来のIP/ポートを載せる、という規約です。v1(テキスト)とv2(バイナリ)の2形式を実装しました。

v1: テキスト形式

// src/proxy_protocol.rs
pub fn v1_header(client_addr: SocketAddr, proxy_addr: SocketAddr) -> String {
    let inet = match (client_addr, proxy_addr) {
        (SocketAddr::V4(_), SocketAddr::V4(_)) => "TCP4",
        _ => "TCP6",
    };
    format!(
        "PROXY {inet} {} {} {} {}\r\n",
        client_addr.ip(),
        proxy_addr.ip(),
        client_addr.port(),
        proxy_addr.port(),
    )
}

PROXY TCP4 203.0.113.7 198.51.100.1 54321 8000\r\nのような1行のASCIIテキストを、copy_bidirectionalで実データを流し始める前にバックエンドへ書き込みます。

v2: バイナリ形式

pub fn v2_header(client_addr: SocketAddr, proxy_addr: SocketAddr) -> Vec<u8> {
    const SIGNATURE: [u8; 12] = *b"\r\n\r\n\x00\r\nQUIT\n";
    const VER_CMD: u8 = 0x21; // version 2, command PROXY

    let (fam_proto, addr_bytes): (u8, Vec<u8>) = match (client_addr, proxy_addr) {
        (SocketAddr::V4(client), SocketAddr::V4(proxy)) => {
            let mut bytes = Vec::with_capacity(12);
            bytes.extend_from_slice(&client.ip().octets());
            bytes.extend_from_slice(&proxy.ip().octets());
            bytes.extend_from_slice(&client.port().to_be_bytes());
            bytes.extend_from_slice(&proxy.port().to_be_bytes());
            (0x11, bytes) // AF_INET(1) << 4 | TCP(1)
        }
        (SocketAddr::V6(client), SocketAddr::V6(proxy)) => {
            let mut bytes = Vec::with_capacity(36);
            bytes.extend_from_slice(&client.ip().octets());
            bytes.extend_from_slice(&proxy.ip().octets());
            bytes.extend_from_slice(&client.port().to_be_bytes());
            bytes.extend_from_slice(&proxy.port().to_be_bytes());
            (0x21, bytes) // AF_INET6(2) << 4 | TCP(1)
        }
        _ => unreachable!("client and proxy addresses must share the same IP version"),
    };

    let mut header = Vec::with_capacity(16 + addr_bytes.len());
    header.extend_from_slice(&SIGNATURE);
    header.push(VER_CMD);
    header.push(fam_proto);
    header.extend_from_slice(&(addr_bytes.len() as u16).to_be_bytes());
    header.extend_from_slice(&addr_bytes);
    header
}

v1がその場で文字列を組み立てるだけなのに対し、v2は12バイトの固定シグネチャ+1バイトのバージョン/コマンド+1バイトのアドレスファミリ/プロトコル+2バイトの長さ+アドレス本体、という固定レイアウトのバイナリです。数値フィールド(長さ、ポート番号)はto_be_bytes()でビッグエンディアン(ネットワークバイトオーダー)に変換しています。HAProxyやEnvoyなど実運用のプロキシがv2をデフォルトにしているのは、この固定レイアウトのおかげでパース側が文字列分割や数値パースをせずに済み、テキスト形式より速く・曖昧さなく読めるためです。シグネチャの\r\n\r\n\x00\r\nQUIT\nという奇妙なバイト列も、v1のテキストヘッダとして誤読されないように意図的に選ばれた値です。

最終的にsrc/proxy.rsでは、copy_bidirectionalの直前でv2_headerを書き込む形にしました。

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

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

v1_headerは使われなくなりましたが、削除せずproxy_protocol.rsに残しています。v1とv2はどちらも同じプロトコルの独立したワイヤーフォーマット実装であり、片方がもう片方に依存する関係ではないため、将来v1へ切り替え可能にする余地として残すコストは小さいと判断しました。

この仕組みにネゴシエーションは存在しない

Proxy Protocolには「バックエンドが対応しているか事前に確認する」手順がありません。プロキシは常にヘッダを送りつけ、バックエンドがProxy Protocol対応であることを前提にしています。もしバックエンドが対応していなければ、このヘッダはただのゴミバイト列としてアプリケーションデータの先頭に混入し、パースエラーやプロトコル違反を引き起こします。TLSのClientHelloのように相手の対応状況を検出して分岐する仕組みは無く、両端で設定を合わせて使うことが前提の、いわば「紳士協定」に近いプロトコルです。

コネクションプールが「使い捨て」だったから、何も壊れなかった

Proxy Protocolの仕様では、ヘッダは1つの物理TCP接続につき接続の先頭で1回だけ送るものとされています。その接続を流れるトラフィックはずっと同じクライアントのものだ、という前提があるためです。

ここで気になるのが、コネクションプーリングで実装した、バックエンドとの接続を使い回す仕組みとの整合性です。もし本当に「1つの物理接続を複数クライアントで使い回す」実装だったら、2人目のクライアントの接続時にもヘッダを送る必要があり、それは「接続の先頭で1回だけ」という仕様に反することになります。

実際にsrc/pool.rsを確認すると、この問題は起きません。Pool::try_get()で取り出した接続は、copy_bidirectionalが終わった後にプールへ返却されることがなく(pool.put()を呼んでいるのは、事前に新規接続を補充し続けるrun_pool_fillerだけです)、1回使われたらそのまま破棄されます。つまりこの「プール」は接続を使い回す仕組みではなく、connect()のレイテンシを先読みして隠すための事前ウォームアップの仕組みでしかなく、物理接続とクライアントセッションは常に1対1です。だからこそ、「取り出すたびに毎回ヘッダを送る」という今回の実装がProxy Protocolの仕様と矛盾せずに成立しています。もし今後、本当の意味での接続の使い回し(複数クライアントで1本の物理接続を再利用する)を実装するなら、Proxy Protocolとは design上両立しない、あるいはクライアントが切り替わるたびに接続を切り直す必要がある、という制約を意識する必要があります。