Bomfather cuts eBPF security agent CPU cost by about 90% with inode caching

The developers behind Bomfather, an open source eBPF security agent (github.com/bomfather/agent), profiled their code and found that the costly part of enforcing a file access policy was not the allow or deny decision itself but figuring out which policy applies to a given file open. Their policies are path based, enforced through an LSM hook that fires on file open: the agent reconstructs the file's path, walks up its parent dentries, and checks each level for a matching policy before combining the results into a final decision. That work repeats every time the same file, or another file in the same subtree, is opened again. Their own example: a policy that lets only the postgres executable read /var/lib/postgres. When Postgres opens /var/lib/postgres/data/base/123, then /data/base/234, then /data/base/345, each open walks the full dentry path from scratch, which the authors call 'the slow path'.

Their fix is a cache, but not one keyed on the dentries themselves: dentries are pointers and cannot be used directly as eBPF map keys, and wrapping their contents in a struct would make a heavy map key. Instead the cache key has three fields: the mount namespace ID, the mount ID, and the inode number. Inode numbers alone are not enough because they are only unique within a single mount tree, so a policy spanning multiple mount trees could see overlapping inode numbers; the mount ID identifies which mounted tree a file was seen through, and the mount namespace ID stops cached entries from one namespace being reused in another. The cache value stores an access_index (the bit position of the matching policy, since policies are stored as bitmasks for space efficiency) plus a cache state, held in a BPF_MAP_TYPE_LRU_HASH map with max_entries set to 10000. With the cache in place, a file open builds the key, looks it up in the LRU hash, enforces the cached result on a hit, and falls back to the slow path and stores the result on a miss.

In a benchmark that opened the same file 200,000 times, the cache dropped kernel cycles from 28 billion to 3.03 billion, matching the roughly 90% CPU reduction. Profiling with perf's cycles:k event showed that, without the cache, three functions dominated the kernel-side stack: tail_call_security_check at 89.2%, is_restricted_filepath at 81.9%, and path_check_callback at 63.7%. With the cache, is_restricted_filepath and path_check_callback each shrank to roughly 0.02% of the profile, small enough to effectively vanish from the flamegraph.

One edge case forced a trade-off: multiple file paths, such as hardlinks, can point to the same inode, so a single cached policy per inode could apply the wrong policy to a hardlinked file. The agent reads the inode's link count (i_nlink) and, whenever it is not exactly 1, skips the cache and falls back to the slow path rather than risk an incorrect enforcement decision. The authors accept the resulting loss of cache coverage because getting the policy right matters more than the cache's hit rate. The change is entirely internal to the agent: no existing user policy needs to change to benefit from the speedup.

Key facts

  • Profiling showed that determining which path based access policy applies to a file open, not enforcing that policy, was the CPU-expensive part of the Bomfather eBPF security agent.
  • An inode based memoization cache, keyed on mount namespace ID, mount ID and inode number, replaced repeated dentry path walks and cut kernel CPU cost by about 90%.
  • In a benchmark of 200,000 opens of the same file, kernel cycles fell from 28 billion to 3.03 billion; without the cache, tail_call_security_check, is_restricted_filepath and path_check_callback took up 89.2%, 81.9% and 63.7% of the profile, and with the cache the latter two shrank to roughly 0.02% each.
  • Because multiple paths, such as hardlinks, can share one inode, the agent checks the inode's link count and skips the cache whenever it is not exactly 1, trading some cache coverage for correct policy enforcement.
  • Bomfather is open source at github.com/bomfather/agent, and the cache is entirely internal, so no user facing policy configuration had to change to get the speedup.

Why it matters

eBPF security agents that enforce policy on every file open sit directly on a system's hot path, so their CPU overhead decides whether they are usable in production at all. This post is a concrete, benchmarked demonstration that the expensive part of such an agent was policy lookup rather than policy enforcement, and that a straightforward inode based cache removed most of that cost without touching the enforcement logic itself.

Who it affects

Developers building eBPF based security or observability agents that check path based policies on every file open, especially for workloads like databases that repeatedly reopen files within a small directory subtree, plus anyone already running or evaluating Bomfather's open source agent.

How to use it

The code is open source at github.com/bomfather/agent. The specific technique, an LRU hash map keyed on mount namespace ID, mount ID and inode number, storing a bitmask access index per inode, is a pattern other eBPF policy engines with the same dentry-walk bottleneck could adopt directly; no pricing or licensing terms are given in the source.

How solid is it

The claim rests on a described benchmark: opening the same file 200,000 times while measuring kernel-side CPU with perf's cycles:k event, with before and after cycle counts (28 billion to 3.03 billion) and stack-sampling percentages for the three hottest functions, backed by flamegraphs. The method and numbers are stated directly, though it is a synthetic repeated-open benchmark rather than a measurement from a production workload.

Risks and caveats

The cache trades coverage for correctness: any file whose inode has more than one hardlink is excluded from caching entirely and always takes the slow path, so hardlinked files see none of the speedup. The source gives no hardware or kernel version details, and the headline 90% figure comes from a best-case repeated-open microbenchmark rather than an end-to-end production latency measurement.

“This is a trade off since we are giving up some cache coverage, but I don’t think it is too big a deal because having an accurate cache is most important.”

— the author