Skip to content
Published on

eBPFがオブザーバビリティを飲み込む

Authors

はじめに — カーネルの中でコードを走らせるという発想

オブザーバビリティ(可観測性)のツールを導入したことがある人なら、おなじみの疲労感があるはずです。アプリごとにエージェントを入れ、言語ごとに計装ライブラリを付け、ポッドごとにサイドカーを立て、そうして付けたものが今度はCPUとメモリを食っていく。何かを観測するためにシステムをますます重く複雑にしていくという逆説です。

eBPFはこの問題をまったく別の角度から攻めます。アプリに手を入れる代わりに、カーネルの中まで降りていって、その下からすべてを見張るのです。ネットワークパケット、システムコール、関数呼び出し、ディスクI/Oはすべてカーネルを通るので、カーネルで一度観測すれば、その上で何が動いていても計装できます。アプリは自分が観測されていることすら知りません。

この記事では、eBPFが正確には何なのか、どうやってカーネルの中で任意のコードを安全に走らせられるのか、そしてなぜそれがオブザーバビリティのツールを次々と飲み込んでいるのかを扱います。同時に、eBPFが万能ではないこと、その限界がどこにあるのかも正直に指摘します。

eBPFとは何か — カーネルの中の小さな仮想マシン

eBPF(extended Berkeley Packet Filter)は、その名に反してもはやパケットフィルタに限りません。今のeBPFは、Linuxカーネルの中に組み込まれた小さな仮想マシンに近いものです。小さなプログラムを書くと、カーネルがそれを特定のイベントに結び付けておき、そのイベントが起きるたびにカーネルの文脈の中で実行します。

核心はこうです。従来、カーネルの動作を変えたり覗いたりするにはカーネルモジュールを書く必要がありました。カーネルモジュールは強力ですが危険です。バグ一つがカーネル全体をパニックに追い込み、書き方を誤ればシステムを止めてしまいます。だからプロダクションのカーネルに気軽にモジュールを載せるのは、大きな決心が要ることでした。

eBPFはこの危険を取り除きます。eBPFプログラムはカーネルの中で動きますが、カーネルを崩せないように厳しく制約されます。この制約を強制するのが、後で詳しく扱う検証器(verifier)です。おかげで私たちは、カーネルモジュールの力に近づきつつ、システムを止めるリスクなしに、プロダクションでプログラムを付けたり外したりできます。

動作の全体像は次のとおりです。

  ユーザー空間
  +------------------------------+
  | 1. C/RustなどでeBPFを記述     |
  | 2. LLVMがeBPFバイトコードに    |
  |    コンパイル                 |
  +--------------+---------------+
                 | bpf() システムコールでロード
                 v
  カーネル空間
  +------------------------------+
  | 3. 検証器が安全性をチェック   |
  | 4. JITがネイティブコードに変換 |
  | 5. フック地点に取り付け        |
  +--------------+---------------+
                 | イベント発生時に実行
                 v
  +------------------------------+
  | 6. マップ(map)に結果を書き込み |
  |    ユーザー空間がマップを読む  |
  +------------------------------+

プログラムはユーザー空間で記述・コンパイルされ、bpf()システムコールでカーネルにロードされます。検証器を通ればJITコンパイラがそれをネイティブの機械語に変え、指定したフック地点に取り付けます。プログラムが集めたデータは、マップという共有データ構造を通じてユーザー空間へ渡されます。

どこにフックを掛けられるか — kprobe、uprobe、tracepoint、XDP

eBPFの力は「どこに取り付けられるか」から生まれます。フック地点の種類が、そのまま観測できる対象の範囲になります。代表的な四つを見てみましょう。

  • kprobe(カーネルプローブ):カーネル関数の入口または戻り地点にプログラムを取り付けます。たとえばtcp_connectが呼ばれるたびに実行するよう掛けられます。事実上どのカーネル関数でもフックできるので最も柔軟ですが、カーネル内部の関数名に依存するため、カーネルのバージョンが変わると壊れることがあります。
  • uprobe(ユーザープローブ):ユーザー空間プログラムの関数に取り付けます。アプリのバイナリの特定の関数が呼ばれたときに実行されます。これが「コードを変えずに」アプリの内部を覗く中心的な手段です。たとえばSSLライブラリの暗号化関数にuprobeを掛けて平文のトラフィックを観測する、といった具合です。
  • tracepoint(トレースポイント):カーネル開発者があらかじめ埋め込んでおいた安定的な計装地点です。kprobeと違ってカーネルが公式に維持するAPIなので、カーネルのバージョンが上がっても壊れにくいです。安定性が重要ならkprobeよりtracepointが好まれます。
  • XDP(eXpress Data Path):ネットワークスタックの最前段、つまりパケットがドライバに到着した瞬間に実行されます。カーネルのネットワークスタックを通る前なので極めて高速です。毎秒数百万パケットを処理するDDoS防御やロードバランシングがここで行われます。

このリストを貫く洞察が一つあります。システムで興味深いことはほとんどすべてカーネルを通る、ということです。ファイルを開こうと、ソケットを作ろうと、プロセスをフォークしようと、パケットを送ろうと、その瞬間にカーネルの何らかの関数やトレースポイントが実行されます。eBPFはまさにその地点に座って、システム全体の活動を一か所から見張るのです。

検証器 — なぜカーネルは死なないのか

ここで自然な疑問が湧きます。任意のコードをカーネルの中で走らせるなら、無限ループに陥ったり不正なメモリに触れたりしてカーネルを崩せるのではないか? eBPFがプロダクションで信頼される理由は、まさにこの問いへの答え、検証器にあります。

検証器は、プログラムがカーネルにロードされる瞬間、実行される前に静的に解析します。プログラムが取りうるすべての実行経路をたどり、次のことを証明しようとします。

  • 停止の保証:プログラムは必ず終わらなければなりません。従来、無制限のループは禁止されており、今は検証器が上限を証明できる有界ループだけが許されます。決して止まらないプログラムはロードすらされません。
  • メモリ安全:プログラムは許可されたメモリ領域だけを読み書きできます。ポインタを参照する前に必ずnullチェックを経ているか、配列アクセスが境界を越えないかを検証器が追跡します。
  • 命令数の上限:プログラムの複雑度に上限があり、検証器が現実的な時間内に全体を解析できなければなりません。

この過程を通らなければ、カーネルはプログラムのロードを拒否します。つまり安全でないeBPFプログラムは、そもそも実行される機会を得られません。これがカーネルモジュールとの決定的な違いです。カーネルモジュールは「信じて載せる」ものですが、eBPFは「証明されてこそ載る」ものです。

もちろん検証器は完璧ではありません。実際には安全なプログラムなのに、検証器が安全性を証明できずに拒否することがあります。eBPF開発者が「検証器と戦う」と冗談を言うのはこのためです。検証器を満足させようとまともなコードをあちこち捻じ曲げる経験は、eBPFを真剣に扱ったことのある人なら誰もがしています。しかしこの厳しさこそ、カーネルが死なない代償なのです。

ゼロ計装トレーシング — コードを変えない

eBPFがオブザーバビリティで革命的な理由を一言でまとめると「ゼロ計装(zero-instrumentation)」です。従来のオブザーバビリティは、アプリのコードに計装を埋め込むところから始まります。トレースするには各リクエストにスパンを開くコードを入れ、メトリクスを取るにはカウンタを増やすコードを入れます。言語ごとにSDKが違い、ライブラリを更新するたびに手直しが必要です。

eBPFはこの前提をひっくり返します。アプリはそのままにして、カーネルのフックから必要な情報を観測します。HTTPリクエストをトレースしたいならソケット関連のカーネル関数に、gRPCのレイテンシを測りたいなら関連するuprobeにフックを掛ければよいのです。アプリは再コンパイルする必要も、再起動する必要も、そもそも自分が観測されていると知る必要もありません。

これが実務で持つ意味は大きいです。

  • 言語非依存:GoでもPythonでもRustでもJavaでも、カーネルから見れば同じシステムコールとネットワークイベントです。言語ごとのSDKを管理する必要がありません。
  • レガシーも含む:ソースコードがない、あるいは修正できないサードパーティのバイナリも観測できます。コードを変えられない状況で特に強力です。
  • 性能オーバーヘッドが最小:計装コードがアプリのホットパスに入らず、カーネルでJITコンパイルされて動くのでオーバーヘッドが小さいです。

ただし「ゼロ計装」という言葉は誤解を招きかねないので正確にしておきましょう。eBPFがカーネルレベルで自動的に見るのは、システムコール、ネットワークフロー、関数呼び出しといった低レベルの信号です。「このリクエストがどのビジネストランザクションに属するか」といったアプリ固有の意味論はカーネルには分かりません。だから高レベルの分散トレーシングにおけるコンテキスト伝播のようなものは、依然としてアプリの協力を要することが多いです。eBPFは計装の大部分をなくしてくれますが、すべてをなくしてくれるわけではありません。

ツールたち — Cilium、Falco、Pixie、bpftrace

理論を越えて、eBPFはすでにプロダクションで広く使われるツールのエンジンになっています。代表的な四つを見てみましょう。

  • Cilium:KubernetesのネットワーキングとセキュリティをeBPFで実装します。従来のKubernetesネットワーキングはiptablesのルールに依存しており、ポッドが数千に増えるとiptablesのルールも爆発的に増えて性能が崩れました。CiliumはこれをeBPFプログラムで置き換え、ルーティング・ポリシー適用・負荷分散をはるかに効率的に処理します。サービスメッシュ機能もサイドカープロキシなしでeBPFで実装する方向へ進んでいます。
  • Falco:ランタイムセキュリティの観測ツールです。システムコールをリアルタイムで監視し、怪しい振る舞い(たとえばコンテナ内でシェルが立つ、機密ファイルを読む、予期しないネットワーク接続が生じる)を検知して警報を鳴らします。カーネルで直接観測するので、攻撃者が回避しにくいです。
  • Pixie:Kubernetesのオブザーバビリティ・プラットフォームで、eBPFのゼロ計装を前面に押し出します。何の計装コードもなしにクラスタのHTTP・gRPC・データベースのトラフィックを自動でキャプチャし、サービス間のレイテンシとエラー率を見せます。「入れればすぐ見える」体験がeBPFのおかげで可能になった代表例です。
  • bpftrace:eBPFのスイスアーミーナイフです。一行のスクリプトでその場でカーネルを観測できる高レベルのトレーシング言語です。プロダクションで「今何が起きているか」に即座に踏み込むときに使われます。

たとえばbpftraceで、あるプロセスがどのシステムコールをどれだけ呼ぶかを数えるのはこれほど簡単です。

# プロセスごとのシステムコール呼び出し回数を集計
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

この一行がカーネルのトレースポイントにフックを掛け、システムコールが発生するたびにプロセス名(comm)ごとにカウンタを増やし、終了時に集計を出力します。別途のエージェントも、アプリの修正もありません。この即時性こそ、bpftraceがプロダクションのデバッグで愛される理由です。

これらのツールをブラウザで概念的に試してみたいなら、eBPFプレイグラウンドでeBPFの命令セットと検証器の動作をシミュレーションで学べますし、Kubernetesネットワーキングの文脈はKubernetesネットワークラボで見ることができます。

なぜサイドカーに勝つのか

ここ数年、クラウドネイティブの世界の既定のパターンはサイドカーでした。サービスメッシュはすべてのポッドの隣にプロキシコンテナを付けてトラフィックを傍受し観測しました。このモデルはうまく動きましたが、代償が大きかったのです。

サイドカーの問題を挙げてみましょう。

  • リソースの倍増:ポッドごとにプロキシが一つ付くので、ポッドが1000個ならプロキシも1000個です。各プロキシがCPUとメモリを食い、その合計は無視できない規模になります。
  • レイテンシの追加:すべてのリクエストがアプリ → サイドカー → ネットワーク → 相手のサイドカー → 相手のアプリを通ります。ホップごとにレイテンシが積み重なります。
  • 運用の複雑さ:サイドカーを注入し、バージョンを合わせ、アップグレードすること自体が管理の負担です。

eBPFはこの構造を根本的に変えます。プロキシを各ポッドの隣に置く代わりに、カーネルの中で一度観測してポリシーを適用します。ノードごとにプロキシ1000個ではなく、カーネルという共有地点一つで処理するのです。トラフィックがユーザー空間のプロキシへ迂回して戻る必要がないので、レイテンシも減ります。

もちろんこれは「サイドカーは死んだ」という意味ではありません。サイドカーは依然として、アプリレベルの複雑なロジック(高度なルーティングルール、プロトコル別の精緻な操作)を扱うのに有利です。eBPFは低レベルの速い経路で、サイドカーは高レベルの柔軟な経路で、それぞれの強みがあります。実際、業界は両者を組み合わせた「サイドカーなしのデータプレーン+必要な所だけプロキシ」というハイブリッドに収束しつつあります。

eBPFにできないこと — 限界と落とし穴

eBPFは強力ですが万能ではありません。導入する前に知っておくべき限界がはっきりあります。

  • Linux中心:eBPFは本質的にLinuxカーネルの技術です。Windows向けeBPFの移植が進んでいますが、成熟度とエコシステムはLinuxに大きく後れをとっています。Linux以外の環境では話がまったく変わります。
  • カーネルバージョン依存:kprobeのようにカーネル内部に依存するフックは、カーネルのバージョンが変わると壊れることがあります。CO-RE(Compile Once, Run Everywhere)のような技術がこの問題を大きく緩和しましたが、それでも古いカーネルでは特定の機能が使えないという制約があります。
  • 検証器の壁:前述のとおり、検証器は安全ですが気難しいです。複雑なロジックを実装しようとして検証器に阻まれることが多く、それを回避するためにコードが不自然になります。プログラムのサイズと複雑度の上限も実質的な制約です。
  • 高い権限が必要:eBPFプログラムをロードするには通常、強力な権限(CAP_BPFなど)が必要です。これはつまり、eBPF自体が攻撃対象領域になりうるということです。悪意ある、あるいは欠陥のあるeBPFプログラムをロードする権限を得た攻撃者は、システムを深く観測したり操作したりできます。
  • 意味論の限界:強調してきたとおり、カーネルは低レベルのイベントしか見ません。アプリのビジネス上の意味(このリクエストがどのユーザーのどの注文か)はカーネルには分かりません。高レベルの文脈が必要な観測は、依然としてアプリの協力を要します。

これらの限界を総合すると、eBPFは「Linuxの上で、低レベル・高性能・システム全般の観測とネットワーキング」に最適です。クロスプラットフォームが必要な場合、深いアプリの意味論が核心である場合、あるいは検証器が扱いきれないほど複雑なロジックが必要な場合には、別のアプローチのほうが良いです。

おわりに — オブザーバビリティの重心が下がる

eBPFが起こす変化の本質は、オブザーバビリティの重心がアプリからカーネルへ下がるということです。かつては観測のためにアプリごとに計装を埋め込み、ポッドごとにサイドカーを付けました。今はカーネルという共有地点に一度フックを掛けて、その上で何が動いていても見張ります。

この移行が魅力的な理由は明確です。言語に依存せず、コード修正が要らず、オーバーヘッドが小さく、回避しにくい。Ciliumがサービスメッシュを、Falcoがランタイムセキュリティを、Pixieが自動観測を、bpftraceがその場のデバッグを、それぞれのやり方で再定義しているのもこのためです。

同時に冷静さも必要です。eBPFはLinuxに縛られており、検証器は気難しく、カーネルが見られるものには意味論的な限界があります。「eBPFがオブザーバビリティを飲み込む」という言葉は誇張ではありませんが、それがすべてを飲み込むという意味ではありません。カーネルがよく見える低レベルの世界でeBPFは圧倒的で、その上の高レベルの世界では依然として他のツールと共存します。

次にオブザーバビリティのスタックを設計することがあれば、アプリに何かをもう一つ付ける前に、こう問いかけてみてください。「これはカーネルからすでに見えるものではないか?」 意外にも多くの場合、答えは「そうだ」です。

参考資料