AtomicとRAIIガードで集計するPrometheusメトリクスと、closeで受信バッファが残っているとRSTが飛ぶ理由

これまでのフェーズでは、このプロキシは一貫してL4に留まり、HTTPを含むアプリケーション層には触れてきませんでした。しかしメトリクスをPrometheus形式で公開するには、/metricsにHTTPで応答するエンドポイントが必要です。今回はこのプロジェクトで最初のL7領域として、必要最小限のHTTPレスポンダを自前で書きました。

集計する項目は次の6種類です。

  • tcpproxy_connections_total / tcpproxy_connections_active(接続数・同時接続数)
  • tcpproxy_rejected_total{reason}(IPレート制限・接続数上限による拒否)
  • tcpproxy_bytes_total{direction}(転送バイト数)
  • tcpproxy_backend_up{backend}(ヘルスチェック連動の健全性)
  • tcpproxy_backend_failures_total{backend}(バックエンド接続失敗の累積)
  • tcpproxy_pool_idle_connections{backend}(プールの空き接続数)

Atomicで集計し、Orderingは全部Relaxedで済ませる

// src/metrics.rs
pub struct Metrics {
    connections_total: AtomicU64,
    connections_active: AtomicU64,
    rejected_ip_rate_limit_total: AtomicU64,
    rejected_connection_limit_total: AtomicU64,
    bytes_client_to_backend_total: AtomicU64,
    bytes_backend_to_client_total: AtomicU64,
    backend_addrs: Vec<SocketAddr>,
    backend_failures_total: Vec<AtomicU64>,
}

各カウンタ・ゲージは他の変数と因果関係を持たない、独立した値です。「このカウンタをこう見たら、あの変数もこう見えているべき」という順序保証を必要としないので、Orderingは全箇所Relaxedにしています。Relaxedが保証するのは、その1個のアトミック変数への操作が最終的に正しく反映されること(fetch_addを100回呼べば必ず+100される)だけで、他のメモリ操作との前後関係は一切保証しません。逆に、あるフラグの書き込みを境に「それより前の別の書き込みも必ず見える」という保証が要る場面(ロックの実装など)ではAcquire/Releaseが必要になりますが、今回のような単純な集計にはオーバースペックです。

activeなゲージをRAIIガードで管理する

同時接続数(connections_active)は、接続開始時に+1、終了時に-1する必要があります。ただし終了経路は「正常に転送完了」「バックエンド全滅でエラー終了」など複数あり、デクリメントを毎回手動で書くと漏れが起きやすくなります。

// src/metrics.rs(続き)
pub fn track_connection(&self) -> ActiveGuard<'_> {
    self.connections_total.fetch_add(1, Ordering::Relaxed);
    self.connections_active.fetch_add(1, Ordering::Relaxed);
    ActiveGuard { metrics: self }
}

pub struct ActiveGuard<'a> {
    metrics: &'a Metrics,
}

impl Drop for ActiveGuard<'_> {
    fn drop(&mut self) {
        self.metrics.connections_active.fetch_sub(1, Ordering::Relaxed);
    }
}

呼び出し側はlet _active = metrics.track_connection();と束縛するだけで、関数を抜けるあらゆる経路(return Ok(())でも?によるエラー伝播でも)でDropが確実にデクリメントします。接続数上限OwnedSemaphorePermitで管理したときと同じ、スコープに紐づけて後始末を保証するパターンです。

状態を二重管理しない

バックエンドの健全性(backend_up)とプールの空き接続数(pool_idle_connections)は、Metrics構造体側に専用のフィールドを持たせていません。render()が呼ばれるたびに、既存のBackends::is_healthy()Pool::len()をそのまま読みに行きます。

// src/metrics.rs(render関数の抜粋)
for (idx, addr) in self.backend_addrs.iter().enumerate() {
    out.push_str(&format!(
        "tcpproxy_backend_up{{backend=\"{addr}\"}} {}\n",
        backends.is_healthy(idx) as u8
    ));
}

健全性の判定ロジックはアクティブヘルスチェック側に既にあり、メトリクス側でこれを別のAtomicとして複製すると、2箇所の状態がずれる余地が生まれます。一方backend_failures_total(接続失敗の累積)はメトリクス側で独自に持っています。Backendsが持つ失敗カウンタは連続失敗数(成功で0にリセットされる、サーキットブレーカー用の値)であり、「過去何回失敗したか」という累積値とは意味が違うため、これは複製ではなく別の指標として扱っています。

/metrics用の最小なHTTPレスポンダ

// src/metrics_server.rs
async fn serve(
    stream: TcpStream,
    metrics: &Metrics,
    backends: &Backends,
    pools: &[Arc<Pool>],
) -> std::io::Result<()> {
    let mut reader = BufReader::new(stream);

    let mut line = String::new();
    loop {
        line.clear();
        if reader.read_line(&mut line).await? == 0 || line == "\r\n" {
            break;
        }
    }

    let body = metrics.render(backends, pools);
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
        body.len(),
        body
    );

    let mut stream = reader.into_inner();
    stream.write_all(response.as_bytes()).await?;
    stream.shutdown().await
}

メソッドやパスは見ません。リクエストヘッダを空行まで読み捨てて、常に/metricsのボディを返すだけです。PrometheusのスクレイパーはGETしか送らないため、これで実用上足りています。

検証で分かったTCPクローズの挙動

実際にダミーバックエンドへ向けて動作確認したところ、tcpproxy_bytes_totalがいつまでも0のままになる現象に遭遇しました。原因は次の2点が重なったことです。

1つ目は、bytes_totalの加算箇所がcopy_bidirectional成功時にしか実行されないことです。

// src/proxy.rs
let result = copy_bidirectional(&mut inbound, &mut outbound).await;
...
let (from_client, from_backend) = result?;
metrics.add_bytes(Direction::ClientToBackend, from_client);
metrics.add_bytes(Direction::BackendToClient, from_backend);

copy_bidirectionalはエラー時に転送済みバイト数を返しません(Result<(u64, u64), io::Error>のErr側に情報がない)。そのため、レスポンス本体は既にクライアントへ届いた後でも、転送の終わり際にエラーが起きればadd_bytesはまるごとスキップされ、そのリクエストはbytes_totalに一切反映されません。

2つ目が、そのエラーがなぜ起きたかです。検証用に立てた素朴なTCPバックエンド(レスポンスを送ったら即close()するだけの実装)が、クライアント側のリクエストを読み切る前に閉じていました。Linuxは、受信バッファに未読のデータが残った状態でclose()が呼ばれると、正常終了を意味するFINではなくRSTを送出します。これがcopy_bidirectional側でエラーとして観測され、?で早期returnし、成功時のログ出力もメトリクスの加算も両方すり抜けていました。送信済みのレスポンス自体はTCPバッファに乗って先にクライアントへ渡っているため、curlから見ると何の問題もなく成功したように見える、という点も観測を厄介にしていました。修正は、バックエンド側でshutdown(SHUT_WR)した後にクライアントからのEOFを読み切ってからclose()する、片方向シャットダウンの手順に直すことでした。

もう一つ、コネクションプーリングの記事で「既知の限界」として書いていた「プール内の接続が死んでいた場合、copy_bidirectional側のエラーとして扱われる」という挙動も、今回まさにそのままの形で再現しました。動作確認中にダミーバックエンドのプロセスを何度も立て直したところ、プロキシ内部のプールには古いバックエンドへの生きていない接続が残ったままになり、Broken pipeが発生しました。プロキシ自体を再起動してプールを空の状態から作り直すまで、この状態は解消しませんでした。

既知の限界

bytes_totalcopy_bidirectionalが正常終了したリクエストのみを集計します。転送の終盤でエラーが起きた接続は、実際にはほぼ全データが届いていても、この指標には一切現れません。エラー時の部分転送量をcopy_bidirectionalから取り出す手段がない以上、この抜け漏れは現状の実装では解消できていません。