eBPFのCPUコストをメモ化により約90%削減(AI生成ではありません)
Dropping eBPF CPU Cost by About 90% with Memoization (Not AI Gen) (nathannaveen.dev)
要約
eBPFセキュリティエージェントは、ファイルオープン時のポリシー適用処理に時間がかかっていました。これは、パスを再構築し、親ディレクトリを辿りながらポリシーをチェックする処理が繰り返し行われていたためです。この問題を解決するため、inodeベースのキャッシュを導入し、inodeごとに適用されるポリシーを記録するようにしました。これにより、カーネルCPUコストが約90%削減され、パフォーマンスが大幅に向上しました。
全文翻訳
メモ化(AI生成ではない)によりeBPFのCPUコストを約90%削減
兄弟と一緒に、eBPFセキュリティエージェントをゼロから非常に高速に設計することに多くの時間を費やしましたが、最近、メモ化を使うことでさらに高速化できることを発見しました!
数週間前、eBPFコードのプロファイリングを行ったところ、保護機能で最もコストがかかる部分は、実際にはポリシーの適用(許可/拒否)ではなく、特定のファイルオープンにどのポリシーが適用されるかを判断することであることがわかりました。
私たちのポリシーはパスベースであるため、eBPFはファイルオープン時にトリガーされるLSMフックを利用します。その後、パスを再構築し、親のdentryを辿り、ファイルまたはその祖先ディレクトリのいずれかに一致するポリシーがあるかを確認します。これは機能しますが、パフォーマンスは良くなく、すでに見たことのあるファイル(例えば、繰り返しファイルパスにアクセスするデータベースアクセスなど)に対して多くの作業を繰り返すことになります。
そのため、各inodeに適用されるポリシーをキャッシュします。これにより、カーネルCPUコストが約90%削減されました。
さらに、最近リポジトリをオープンソース化したため、この記事のすべては https://github.com/bomfather/agent で見つけることができます。
問題点
キャッシュ導入前は、すべてのファイルオープンでパス全体を辿っていました。そのため、フローは以下のようになります。
- ファイルパスを取得する。
- dentryを使ってファイルパスを辿る。
- 各レベルで、そのパスにポリシーが存在するかどうかを確認する。
- 結果を組み合わせて、許可または拒否を決定するために使用できる最終ポリシーを取得する。
これは機能しますが、同じファイルが複数回開かれたり、同じサブツリー内の複数のファイルが開かれたりした場合、これらのステップをファイルごとに繰り返す必要があります。
例えば、Postgresで、Postgresのみが/var/lib/postgresにアクセスできるようにしたい場合、次のようなポリシーを設定できます。
policies:
- executable: "filepath = /usr/lib/postgresql/16/bin/postgres"
can_access_dirs:
- "/var/lib/postgres:read"
すると、Postgresはvar/lib/postgres/data/base/123、var/lib/postgres/data/base/234、var/lib/postgres/data/base/345からファイルを取得します。これらのファイルアクセスごとにdentryのパス全体を辿る必要があり、これは非常に非効率的です。
この記事の残りの部分では、この非効率的なパスウォークを「スローパス」と呼びます。
キャッシュに含まれるもの
私たちの解決策はキャッシュを使用することです。しかし、キャッシュが重くなく、キャッシュされたアイテムを安全に再利用できることを確認する必要があります。
dentryを使用することも考えましたが、dentryはポインタであり、ポインタはeBPFマップ内に格納できません。dentryを使用したい場合、dentryの内容を構造体に格納し、その構造体をマップキーとして使用できますが、かなり重い構造体になります。
そのため、代わりにinodeベースのキャッシュを使用することにしました。キャッシュキーは3つのフィールドで構成されます:マウント名前空間ID、マウントID、inode番号。
inode番号は特定のマウントツリーに対して一意であるため(ポリシーが複数のマウントツリーをカバーする場合、inodeが重複する可能性がある)、inode自体でキャッシュすることはできません。マウントIDは、どのマウントツリーを通してファイルを見たかを識別するのに役立ちます。マウント名前空間IDは、異なる名前空間でキャッシュされたエントリを使用しないようにするためにも使用されます。
キャッシュ値は2つの部分で構成されます:access_indexとキャッシュ状態。スペース効率のためにポリシーをビットマスクとして格納しており、access_indexはパスポリシーのビット位置です([https://nathannaveen.dev/posts/optimizing-ebpf-policies-for-speed-and-space/](https://nathannaveen.dev/posts/optimizing-ebpf-policies-for-speed-and-space/))。
したがって、キャッシュはキーと値とともに、次のようになります。
#define INODE_POLICY_CACHE_NO_POLICY 0
#define INODE_POLICY_CACHE_ACCESS_INDEX 1
#define INODE_POLICY_CACHE_GLOBAL_READ_ONLY 2
#define INODE_POLICY_CACHE_ACCESS_INDEX_AND_GLOBAL_RO 3
struct inode_cache_key {
u64 mntns_id;
u64 mount_id;
u64 inode;
};
struct inode_policy_cache_value {
u32 access_index;
u8 state;
};
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, 10000);
__type(key, struct inode_cache_key);
__type(value, struct inode_policy_cache_value);
} bomfather_inode_policy_cache SEC(".maps");
これでキャッシュが導入されたフローは次のようになります。
- キャッシュキーを構築する必要があります。
- LRUハッシュマップでキーを検索できます。
- ヒットした場合、キャッシュされた結果に基づいてファイルオープンを強制できます。
- ミスした場合、スローパスを実行し、結果をキャッシュに保存します。
[画像: キャッシュヒット時、ファイルオープンはinodeキャッシュキーを構築し、直接許可または拒否に進みます。ミス時、親dentryを辿り、ポリシーをマージし、結果を保存してから、許可または拒否します。]
パフォーマンスの変化
ベンチマークテストでは、パフォーマンスを分析するために同じファイルを200,000回開きました。キャッシュにより、カーネルサイクルは280億から30.3億に削減されました。キャッシュなしでは、スタック上にtail_call_security_checkが89.2%、is_restricted_filepathが81.9%、path_check_callbackが63.7%の時間出現していました。
以下のフレイムグラフを見ると、キャッシュを使用した場合、最初のルックアップ以降、パスウォークのコストはほぼ消滅していることがわかります。例えば、is_restricted_filepathとpath_check_callbackはそれぞれ約0.02%に縮小し、グラフから実質的に消えるほど小さい値になりました。
導入前(キャッシュなし):
[画像: inodeキャッシュなしのカーネルフレイムグラフ。tail_call_security_check、is_restricted_filepath、path_check_callbackがスタックを支配しています。]
導入後(キャッシュあり):
[画像: inodeキャッシュありのカーネルフレイムグラフ。パスウォークのコストは、最初のルックアップ後にほとんど消滅しています。]
perfを使用してcycles:kイベントでカーネルCPUをプロファイリングしました。これは、ファイルオープン中のカーネル側のCPUコストを測定します。
エッジケース
このキャッシュで考慮しなければならなかったことの1つは、複数のパスが単一のinodeを共有できることです。ハードリンクが最も簡単な例です。ハードリンクを使用すると、2つの異なるパスが同じinodeを共有できます。これは大きな問題です。なぜなら、正確な結果はキャッシュパフォーマンスよりも重要だからです。
私たちの解決策は、実際の解決策というよりも回避策です。inodeにはリンクカウント(i_nlink)があり、いくつのパスがinodeを指しているかを示します。それを読み取ることができ、1より大きい場合はそのキャッシュエントリを使用せず、スローパスにフォールバックします。
if (BPF_CORE_READ_INTO(&nlink, inode, i_nlink)) {
return false;
}
if (nlink != 1) {
inode_cache_stats_inc(INODE_CACHE_STATS_SKIPS_NLINK);
return false;
}
これは、キャッシュカバレッジの一部を犠牲にすることになりますが、正確なキャッシュが最も重要であるため、それほど大きな問題ではないと考えています。
最終的な考察
最終的に、これは取り組むのが非常に楽しいものでした。なぜなら、キャッシュのさまざまなアイデアを検討し、このアイデアにたどり着くまで試行錯誤する必要があったからです。
また、キャッシュが完全に内部的なものであることも非常に満足しています。そのため、エージェントが高速化するためにユーザーのポリシーを変更する必要はありません!