🔀

eBPF宛先リダイレクト — connect() syscallをカーネルで書き換える原理

Keploy、Cilium、Istioが使う透過プロキシの核心メカニズム

Keployの「コード変更ゼロ」を実現する核心トリックがeBPF宛先リダイレクトだ。Keploy固有の技術ではない。Cilium(Kubernetes CNI)やIstio(サービスメッシュ)も同じ原理を使っている。

なぜ可能か — syscallの構造

アプリがネットワーク通信する時、カーネルへのリクエストは決まったパターンに従う:

  1. socket() — ソケット生成
  2. connect(fd, addr, addrlen) — 宛先アドレスに接続
  3. send()/recv() — データ送受信

connect()の第2引数addrに宛先IPとポートが入る。postgres:5432なら{AF_INET, 5432, 10.0.0.5}のような構造体になる。

eBPFはこのconnect() syscallが実行されるまさにその瞬間にフックを掛けられる。カーネルがTCPハンドシェイクを開始する前に、addr構造体を直接変更できる。

Keployの実装 — 3つのeBPFマップ

Keployのソース(pkg/agent/hooks/linux/)を見ると、eBPFプログラムが3つの共有マップを使用:

clientRegistrationMap — Keployが監視するプロセス(アプリ)を登録。PIDベースで「このプロセスのネットワークだけ傍受する」と伝える。

agentRegistrationMap — Keployエージェント自身を登録。「このプロセス(プロキシ)のトラフィックは触るな」— 無限ループ防止。

redirectProxyMap — 核心マップ。Keyはソースポート、Valueは元の宛先情報(DestInfo構造体)。eBPFが宛先を書き換える時に元の情報をここに保存。

リダイレクト手順

1. アプリ: connect(postgres:5432)呼び出し
2. eBPF connect4フック発動
3. clientRegistrationMap確認 → このPIDは監視対象? Yes
4. redirectProxyMapに保存: {srcPort: 54321} → {ip: 10.0.0.5, port: 5432}
5. addr構造体を修正: {10.0.0.5:5432} → {127.0.0.1:16789}
6. カーネルが修正されたaddrでTCPハンドシェイク開始
7. アプリはpostgresに接続したつもりだが、実際にはローカルプロキシに接続

アプリプロセスのメモリには元の宛先がそのまま残っている。カーネルレベルでのみ書き換えが起きるのでアプリは絶対に知り得ない。

プロキシが元の宛先を復元する方法

Keployの透過プロキシ(pkg/agent/proxy/)が接続を受けると:

  1. クライアントのソースポートを確認(例: 54321)
  2. GetDestinationInfo(54321) — eBPFマップから元の宛先を照会
  3. 結果: {ip: 10.0.0.5, port: 5432} — postgresサーバー
  4. プロキシが実際のpostgresに接続
  5. アプリ ↔ プロキシ ↔ postgres間のトラフィックを中継しながら録画

「透過プロキシ」と呼ばれる理由がこれだ。アプリもサーバーもプロキシの存在を知らない。

SockOps — cgroupレベルソケット監視

connect4/6だけでは不十分。SockOps eBPFプログラムはcgroupv2に付着してコンテナ内の全ソケットイベントを監視する。

Dockerコンテナでアプリを実行すると、コンテナのcgroupにSockOpsが自動的に付着。コンテナ内で起きる全てのconnectacceptclose等のソケットイベントがeBPFを通過する。

無限ループ防止

ここで一つ問題がある。プロキシもconnect()を呼んで実際のpostgresに接続する。これもeBPFに引っかかったら?プロキシ→プロキシ→プロキシ→…無限ループ。

agentRegistrationMapがこれを防ぐ。Keployエージェント(プロキシ)プロセスのPIDがここに登録されていれば、eBPFはそのプロセスのconnect()をスキップする。

従来のプロキシ方式との違い

mitmproxy、Charles、VCR等の従来ツールは:

  • HTTP_PROXY環境変数の設定が必要

  • またはアプリにSDK/ライブラリのインストールが必要

  • またはDNS操作でトラフィックを迂回

全てアプリか環境の変更が必要。eBPF方式はカーネルレベルで動作するのでアプリも環境も触らない。keploy recordなしで実行すればeBPFフックが付着せずプロキシも起動しない。完全に透明。

Linux専用の理由

eBPFはLinuxカーネルの機能。Linux 3.15で導入され5.xで大幅拡張されたカーネル内仮想マシン。macOSのXNUカーネル、WindowsのNTカーネルには存在しない。

macOSではDocker Desktopを使えばいい。内部でLinux VMを動かし、その中でeBPFが動作する。ただしDockerでもう一段ラップする負担が生じる。CI/CD(GitHub Actions、Jenkinsなど)は大半がLinuxランナーなので問題ない。

RubyからGoへ

1

eBPF connect4フックがconnect() syscall実行時に発動 — addr構造体の宛先IP:portをプロキシアドレスに上書き

2

元の宛先はredirectProxyMap(eBPF共有マップ)に{ソースポート→元IP:port}で保存

3

プロキシがGetDestinationInfo(srcPort)で元の宛先を復元→実サーバーに転送しつつ録画

4

agentRegistrationMapでプロキシ自身のconnect()はeBPFがスキップ — 無限ループ防止

メリット

  • アプリコード/設定/環境変数の変更ゼロ — カーネルで動作するのでユーザースペースを触らない
  • 言語/フレームワーク非依存 — 全プロセスがconnect() syscallを使うから

デメリット

  • Linuxカーネル5.15+必須 — macOS(XNU)、Windows(NT)にはeBPFがない
  • ネットワークソケット通信のみ傍受可能 — SQLite(ファイルI/O)、Unixドメインソケット(一部)は対象外

ユースケース

Keploy — APIテスト自動生成。アプリトラフィックを全録画してテストケース+Mockに変換 Cilium — Kubernetesネットワークポリシー適用。Pod間トラフィックをeBPFで制御 Istio — サービスメッシュのサイドカーなしにeBPFでトラフィックルーティング