메뉴
HN
Hacker News • 11일 전

메모이제이션으로 eBPF CPU 비용 약 90% 절감

IMP
6/10
핵심 요약

형제 개발자가 만든 eBPF 기반 보안 에이전트에서 파일 경로 정책 매칭 비용이 병목임을 프로파일링으로 확인하고, inode 기반 캐시(메모이제이션)를 도입해 커널 CPU 비용을 약 90% 줄였습니다. 캐시 키는 마운트 네임스페이스 ID, 마운트 ID, inode 번호로 구성되며, 소스코드는 GitHub에 오픈소스로 공개되었습니다.

번역된 본문

메모이제이션으로 eBPF CPU 비용 약 90% 절감하기 (AI 생성 아님)

내 형과 나는 eBPF 보안 에이전트를 처음부터 정말 빠르게 설계하는 데 많은 시간을 썼지만, 최근 메모이제이션을 활용하면 훨씬 더 빠르게 만들 수 있다는 것을 발견했습니다!

몇 주 전, eBPF 코드를 프로파일링한 결과 보호 기능에서 가장 비용이 큰 부분은 실제로 정책을 적용(허용/거부)하는 것이 아니라, 주어진 파일 열기에 어떤 정책이 적용되는지 파악하는 것이라는 걸 알게 됐습니다. 우리의 정책은 경로 기반이므로, eBPF는 파일 열기 시 트리거되는 LSM 훅을 활용합니다. 그런 다음 경로를 재구성하고, 부모 dentry들을 거슬러 올라가며 해당 파일이나 조상 디렉터리에 매칭되는 정책이 있는지 확인합니다. 이 방식은 동작하지만 성능이 좋지 않으며, 이미 본 파일에 대해(예: 파일 경로를 반복적으로 다시 여는 데이터베이스 접근) 상당 부분의 작업을 반복하게 됩니다.

그래서 각 inode에 어떤 정책이 적용되는지 캐싱했습니다. 이를 통해 커널 CPU 비용이 약 90% 감소했습니다. 또한 최근 저장소를 오픈소스로 공개했으므로, 이 글의 모든 내용은 https://github.com/bomfather/agent 에서 확인할 수 있습니다.

문제

캐시 도입 전에는 매번 파일을 열 때마다 전체 경로를 순회했습니다. 흐름은 다음과 같습니다:

  1. 파일 경로를 얻습니다.
  2. dentry들을 따라 파일 경로를 거슬러 올라갑니다.
  3. 각 단계에서 해당 경로에 정책이 존재하는지 확인합니다.
  4. 그런 다음 결과를 결합해 최종 정책을 얻고, 이를 통해 허용 여부를 결정합니다.

이 방식은 동작하지만, 같은 파일이 여러 번 열리거나 같은 하위 트리의 여러 파일이 열리면 파일마다 이 단계들을 반복해야 합니다.

예를 들어, 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 경로를 순회해야 하므로 매우 비효율적입니다. 이 글에서는 이 비효율적인 경로 순회를 "느린 경로(slow path)"라고 부르겠습니다.

캐시에는 무엇이 들어있나?

우리의 해결책은 캐시를 사용하는 것입니다. 하지만 캐시가 무겁지 않아야 하고, 캐시된 항목을 안전하게 재사용할 수 있어야 합니다. dentry를 사용할까 했지만, dentry는 포인터이고 포인터는 eBPF 맵에 저장할 수 없습니다. dentry를 쓰고 싶다면 dentry의 내용을 구조체에 담아 맵 키로 사용할 수 있지만, 꽤 무거운 구조체가 될 것입니다. 그래서 대신 inode 기반 캐시를 사용하기로 했습니다.

캐시 키는 세 필드로 구성됩니다: 마운트 네임스페이스 ID, 마운트 ID, inode 번호. inode 번호는 특정 마운트 트리 내에서만 고유하기 때문에(정책이 여러 마운트 트리를 커버하면 inode가 겹칠 수 있음) inode만으로는 캐싱할 수 없습니다. 마운트 ID는 파일을 어떤 마운트 트리에서 관찰했는지 식별하는 데 도움을 주고, 마운트 네임스페이스 ID는 다른 네임스페이스에서 캐시된 항목을 사용하는 것을 방지합니다.

캐시 값은 두 부분으로 구성됩니다: access_index와 캐시 상태입니다. 정책은 공간 효율성을 위해 비트마스크로 저장하며, access_index는 경로 정책의 비트 위치입니다(참고: https://nathannavein.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");

이제 캐시가 있으니 흐름은 대략 다음과 같습니다...

원문 보기
원문 보기 (영어)
Dropping eBPF CPU Cost by About 90% With Memoization (Not AI Gen) My brother and I spent a lot of time designing our eBPF security agent to be really fast from the ground up, but recently we discovered we could make it much faster using memoization! A couple of weeks ago, I profiled the eBPF code and found that the most expensive part of the protection isn’t actually enforcing a policy (allow/deny), but figuring out which policy applies to a given file open. Our policies are path based, so our eBPF leverages an LSM hook that triggers on file open. We then reconstruct the path, walk up parent dentries, and check whether the file or any ancestor directory has a matching policy. While this works, it isn’t performant, and we end up repeating much of the work for files we have already seen (for example, database accesses that repeatedly reaccess file paths). So, we cache which policy applies for each inode. This dropped our kernel CPU cost by about 90%. Additionally, we recently open sourced our repo, so everything in this blog post can be found at https://github.com/bomfather/agent . The Problem Before the cache, every file open would walk through the entire path. So the flow would look like this: Get the file path. Walk up the file path with dentries. At each level, check whether a policy exists for the path. Then combine the results to get a final policy, which we can use to decide whether to allow or deny. This works, but if the same file is opened multiple times or multiple files in the same subtree are opened, we have to repeat these steps for each file. For example: In Postgres, if we only want Postgres to be able to touch /var/lib/postgres , we can have this example policy: policies: - executable: "filepath = /usr/lib/postgresql/16/bin/postgres" can_access_dirs: - "/var/lib/postgres:read" Then Postgres retrieves files from var/lib/postgres/data/base/123 , var/lib/postgres/data/base/234 , and var/lib/postgres/data/base/345 . We would have to walk the entire path of dentries for each of these file accesses, which is really inefficient. For the rest of this blog post, I’ll call this inefficient path walk “the slow path.” What’s in the Cache? Our solution is to use a cache. But we need to make sure the cache isn’t heavy and that it’s safe to reuse cached items. We were thinking of using dentries, but dentries are pointers, and pointers can’t be stored inside eBPF maps. If we wanted to use dentries, we could store the dentries’ contents in a struct and use that struct as the map key, but it would be a pretty heavy struct. So instead, we decided to use an inode based cache. Our cache key has three fields: the mount namespace ID, the mount ID, and the inode number. We can’t cache the inode by itself because inode numbers are unique to a specific mount tree (so if a policy covers multiple mount trees, inodes could overlap). The mount ID helps us identify which mounted tree we observed the file through. The mount namespace ID also prevents us from using cached entries in a different namespace. The cache value has two parts: an access_index and a cache state. We store our policies as bitmasks for space efficiency, and the access_index is the bit position for the path policy ( https://nathannaveen.dev/posts/optimizing-ebpf-policies-for-speed-and-space/) . So, our cache, along with the keys and values, looks something like this: #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"); Now with the cache, our flow looks something like this: We need to build the cache key. We can look up the key in the LRU hash map. If there is a hit, we can enforce the file open based on the cached result. If there is a miss, we can do the slow path and store the result in the cache. Performance Changes In our benchmark tests, we opened the same file 200,000 times to analyze performance; the cache reduced kernel cycles from 28 billion to 3.03 billion. Without the cache, our tail_call_security_check appeared on the stack 89.2%, is_restricted_filepath 81.9%, and path_check_callback 63.7% of the time. In the flamegraphs below, we can see that with the cache, the expense from path traversal pretty much disappears after the first lookup. For example, is_restricted_filepath and path_check_callback each shrink to roughly 0.02%, which is small enough to effectively disappear from the graph. Before (without cache): After (with cache): We profiled the kernel CPU with perf using the cycles:k event. This measures kernel side CPU cost during file opens. Edge Cases One thing we had to account for with this cache is that multiple paths can share a single inode. Hardlinks are the easiest example; with a hardlink, two different paths can share the same inode. This is a big problem because accurate results matter more than cache performance. Our solution is more of a workaround than a real solution. Inodes have a link count ( i_nlink ) that tells us how many paths point to the inode; we can read it, and if it is greater than 1, we don’t use that cache entry and fall back to the slow path. 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; } 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. Final Thoughts In the end, this was a really fun thing to work on since I had to work through multiple different ideas for the cache until I landed on this. I am also pretty happy the cache is entirely internal, so a user’s policy doesn’t need to change for the agent to speed up!