インフラ・DevOps
ポート8125では誰も聞いていない
Nobody Is Listening on Port 8125 (yeet.cx)
要約
この記事では、StatsDエクスポーターがUDPポート8125でリッスンする従来の仕組みに代わる、eBPFプログラムを用いた新しいオブザーバビリティ手法を紹介しています。この手法では、カーネルレベルでパケットを直接キャプチャし、ユーザー空間のデーモンを不要にすることで、より効率的で堅牢なメトリクス収集を実現します。
全文翻訳
ポート8125では誰も聞いていない
Jacob Pradels · 2026年9月30日 · 7分読書
yeetの創業エンジニア。カーネルサイドのオブザーバビリティとその周辺ツールに取り組んでいます。eBPF、Linux内部、そしてテレメトリ料金がなぜそのようになるのかについて書いています。
Jacobにデモを予約する →
Jacobからの他の記事 →
私のデスクにあるFedoraボックスの名前はstickです。その上のアプリが約1週間、アプリが2011年から行ってきたように、1秒間に10回127.0.0.1:8125にStatsDデータグラムを送信しています。8125では何も聞いていません。
$ ss -lun | grep 8125
$ Grafanaはそれでもリクエストレートを取得しています。
これは、Prometheusの隣で誰もが実行しているエクスポーターのリストを、一つずつyeetスクリプトで置き換えていくシリーズの最初の投稿です。このシリーズのピッチは短いです。6つの言語で50個のエクスポーター、それぞれ独自のポート、独自の構成フォーマット、独自のベンダー意見を持つ必要はありません。カーネルを読み取れる1つのバイナリが必要です。StatsDエクスポーターから始めています。なぜなら、置き換えが同じものではなくなり、何か少し良くなるものに変わるからです。
TL;DR StatsDエクスポーターはトランスレーターです。アプリはUDP経由でStatsDラインをそれにプッシュし、それはPrometheusメトリクスとして保持され、Prometheusはプルします。これはPrometheusプロジェクト自体によって保守されているため、私たちが取り出しているスタックの公式の一部です。リスナーを、すべてのインターフェイスのトラフィック制御レイヤーでポート8125を監視するeBPFプログラムと、ラインをPrometheusファミリーに変換するJavaScriptデコーダーに置き換えました。アプリはファイア&フォーゲットUDPを続けます。アプリに変更はありません。yeetを除いてホストに何もインストールされません。そして、データグラムを受信する人がいなくてもメトリクスが表示されます。
以下を学びます。
- なぜStatsDレシーバーが単なるパケットスニファであり、その前にソケットがあるのか。
- 配線を読み取る165行のCコード、そしてなぜ同じメカニズムが、それを指し示す任意のラインプロトコルに機能するのか。Redis、memcached、Graphiteは後の投稿で、このプローブをそのまま再利用します。
- yeet:telemetryがスクリプトをソケットに一切触れることなくPrometheusターゲットに変換する方法。
- 何かを失うこと、なぜならいくつかのことを失うからです。
全体はオープンソースです。
自分のボックスで実行するには:
StatsDエクスポーターは実際には何をするのか?
あなたが思うより少ないです。UDPデータグラムにバインドし、データグラムを読み取り、改行で分割し、次のようなラインを解析します。
app.requests:1|c
app.queue.depth:22|g
app.request.time:143|ms
app.users:20|s
app.errors:1|c|@0.1|#service:api,region:us
そして、名前ごとにカウンター、ゲージ、ヒストグラム、またはセットを保持し、名前をYAMLマッピングファイルで処理してドットをラベルに変換し、それらすべてを:9102/metricsで提供します。それだけです。それがエクスポーターです。それは完全に良いエクスポーターであり、私は何年もそれを一度も考えずに実行してきました。これはインフラストラクチャについて言える最も良いことでしょう。
しかし、それが消費するものを調べてください。UDPデータグラム。よく知られたポートで。プレーンテキストのラインフォーマットで。通常、アプリと同じホスト上で。そのソケットがバイトを取得するまでに、カーネルはすでにそれらを持っており、それらを見て、どこに行くかを決定しています。リスナーは各パケットの2番目のリーダーです。そこで、最初のリーダーに尋ねました。
165行のC
カーネル部分は、アップしているすべてのインターフェイスのイングレスとエグレスフックにクリップされたtcxプログラムであり、ループバックも含まれます。それは3つのことを行います:トランスポートヘッダーを見つけ、どちらかのポートが私たちが気にするものかどうかを確認し、ペイロードの境界付きチャンクをリングバッファにコピーします。完了。
/* キャプチャする価値のあるポート。スクリプトによって埋められます。 */
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 64);
__type(key, __u16);
__type(value, __u8);
} ports SEC(".maps");
static __always_inline int wanted(__u16 sport, __u16 dport) {
return bpf_map_lookup_elem(&ports, &sport) != NULL || bpf_map_lookup_elem(&ports, &dport) != NULL;
}
static __always_inline int handle(struct __sk_buff *skb, __u8 dir) {
/* ... Ethernetまたはraw IP、次にIPv4またはIPv6、L4ヘッダーまで歩く... */
__u16 sport = 0, dport = 0;
bpf_skb_load_bytes(skb, l4, &sport, 2);
bpf_skb_load_bytes(skb, l4 + 2, &dport, 2);
sport = bpf_ntohs(sport);
dport = bpf_ntohs(dport);
if (!wanted(sport, dport)) return TCX_NEXT;
/* ... TCPデータオフセットまたは8バイトUDPヘッダー、次にペイロード... */
struct wire_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) return TCX_NEXT;
e->ts = bpf_ktime_get_ns();
e->sport = sport;
e->dport = dport;
e->ifindex = skb->ifindex;
e->dir = dir;
e->total_len = plen;
e->captured = cap;
if (bpf_skb_load_bytes(skb, poff, e->data, cap) < 0) {
bpf_ringbuf_discard(e, 0);
return TCX_NEXT;
}
bpf_ringbuf_submit(e, 0);
return TCX_NEXT;
}
そのifindexには理由があります。ループバック上のパケットはloのイングレスフックを通過し、次にカーネルがそれを自身に渡すため、loのイングレスフックを通過します。同じバイト、2つのイベント。TCPはシーケンス番号で重複排除できます。UDPにはシーケンス番号がありません。したがって、ループバック上ではコレクターはイングレスコピーを保持し、もう一方をドロップし、各データグラムは正確に1回カウントされます。
if (ev.dir === DIR_INGRESS && ev.ifindex === lo) return;
portsマップも見てください。Cにはポート番号はどこにもありません。スクリプトは起動時に引数からマップを埋め、8125はStatsDが使用していたものですが、それ以外であってはならない理由は何もありません。カーネル側はStatsDが何であるかを知らないため、同じメカニズムが既知のポートでラインプロトコルを話す他のものすべてをカバーします。ポートと最大512バイトのコピーを一致させます。解析はJavaScriptの問題であり、JavaScriptは安価です。stick上のコレクターは、この1つのプローブからRedis、memcached、Graphiteデコーダーをそれぞれフラグ付きで実行しており、それらは独自の投稿になります。次のプロトコルはポート番号とデコーダーを必要とします。他はすべてカーネル内に残ります。ACK、TLS、Zoomコール、それらはユーザー空間に決してクロスしません。そしてプログラムはすべてのパスでTCX_NEXTを返すため、パケットをドロップしたり書き換えたりすることはありません。読み取ります。それが仕事全体です。
yeetはyeet:bpfでスクリプトからそれをロードします。アタッチターゲットは、システムグラフがアップと述べているものです。
import { BpfObject, HashMap, RingBuf } from "yeet:bpf";
const { data } = await yeet.graph.query(`{
network_interfaces {
index
is_up
}
}`);
const tcx = {
kind: "tcx",
ifindex: data.network_interfaces.filter((i) => i.is_up).map((i) => i.index)
};
const control = await new BpfObject({
exe: "../bpf/bin/wire.bpf.o",
base: import.meta.dirname
})
.bind("events", { kind: "ringbuf", btf_struct: "wire_event" })
.bind("ports", { kind: "hash" })
.attach("on_ingress", tcx)
.attach("on_egress", tcx)
.start();
const ports = new HashMap(control, "ports");
for (const port of [8125, 8126]) await ports.update(port, 1); // from yeet.args, really
await new RingBuf(control, "events").subscribe(onEvent, () => dropped.inc());
btf_struct: "wire_event" は、デシリアライザー全体です。デーモンはオブジェクトのBTFから構造体のレイアウトを読み取り、onEventにsport、dport、dataなどをすでに持つプレーンオブジェクトを渡します。マップについても同様のトリックです。update(8125, 1)は、Cがそう言ったため、__u16キーと__u8値です。バイトパーサーを書きませんでした。バイトパーサーを書くつもりはありません。
デコーダーは意図的に退屈です。
配管を切り取ったStatsD側は次のとおりです。
request(ev, bytes) {
this.datagrams.inc();
for (const line of latin1(bytes).split("\n")) {
const m = /^([^:]+):([^|]+)\|([a-z]+)(?:\|@([0-9.]+))?(?:\|(.*))?$/.exec(line.trim());
if (!m) {
this.lines.labels({ status: "invalid" }).inc();
continue;
}
const [, rawName, rawValue, type, rate, tags] = m;
const name = rawName.replace(/\./g, "_");
const labels = parseTags(tags); // #service:api,region:us
const sample = 1 / (Number(rate) || 1); // @0.1 counts for ten
this.#apply(name, type, rawValue, Number(rawValue), labels, sample);
}
}
#applyはタイプ文字によるスイッチです。cは値*サンプルでカウンターを増やします。gはゲージを設定するか、値がcaの場合にそれを調整します。