プログラミング
eBPFのCPUコストをメモ化により約90%削減(AI生成ではありません)
Dropping eBPF CPU Cost by About 90% with Memoization (Not AI Gen) (nathannaveen.dev)
要約
eBPFセキュリティエージェントは、ファイルオープン時のポリシー適用処理に時間がかかっていました。これは、パスを再構築し、親ディレクトリを辿りながらポリシーをチェックする処理が繰り返し行われていたためです。この問題を解決するため、inodeベースのキャッシュを導入し、inodeごとに適用されるポリシーを記録するようにしました。これにより、カーネルCPUコストが約90%削減され、パフォーマンスが大幅に向上しました。
全文翻訳
メモ化によりeBPFのCPUコストを約90%削減(AI生成ではありません)
兄と私は、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とcache state。ポリシーはスペース効率のためにビットマスクとして格納されており、access_indexはパスポリシーのビット位置です(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ハッシュマップでキーをルックアップします。
ヒットした場合、キャッシュされた結果に基づいてファイルオープンを強制できます。
ミスした場合、遅いパスを実行し、結果をキャッシュに格納します。
パフォーマンスの変化
ベンチマークテストでは、パフォーマンスを分析するために同じファイルを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%に縮小し、グラフから実質的に消えるほど小さくなっています。
キャッシュ導入前(キャッシュなし):
[フレイムグラフの画像]
キャッシュ導入後(キャッシュあり):
[フレイムグラフの画像]
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;
}
これはトレードオフです。キャッシュカバレッジの一部を犠牲にしていますが、正確なキャッシュが最も重要であるため、それほど大きな問題ではないと思います。
最終的な考え
最終的に、これは非常に楽しい作業でした。キャッシュについて複数の異なるアイデアを検討し、この解決策にたどり着きました。
ユーザーのポリシーを変更することなく、エージェントが高速化できるキャッシュが完全に内部的であることも非常に満足しています!