메모이제이션으로 eBPF CPU 비용 약 90% 절감
형제 개발자가 만든 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 에서 확인할 수 있습니다.
문제
캐시 도입 전에는 매번 파일을 열 때마다 전체 경로를 순회했습니다. 흐름은 다음과 같습니다:
- 파일 경로를 얻습니다.
- dentry들을 따라 파일 경로를 거슬러 올라갑니다.
- 각 단계에서 해당 경로에 정책이 존재하는지 확인합니다.
- 그런 다음 결과를 결합해 최종 정책을 얻고, 이를 통해 허용 여부를 결정합니다.
이 방식은 동작하지만, 같은 파일이 여러 번 열리거나 같은 하위 트리의 여러 파일이 열리면 파일마다 이 단계들을 반복해야 합니다.
예를 들어, 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");
이제 캐시가 있으니 흐름은 대략 다음과 같습니다...