본문 바로가기
Infra2026년 9월 17일12분 읽기

eBPF 프로그램 성능 튜닝 — 맵 조회 비용을 줄이는 실전 기법

YS
김영삼
조회 154
eBPF 프로그램 성능 튜닝 — 맵 조회 비용을 줄이는 실전 기법

eBPF 프로그램은 커널의 훅 지점에서 실행된다. 네트워크 패킷마다, 시스템 콜마다, 스케줄링마다 돌 수 있다는 뜻이다. 호출 빈도가 초당 수백만 번인 지점에서는 프로그램 한 번의 비용이 수십 나노초만 늘어도 전체 CPU 사용률에 반영된다.

오버헤드의 대부분은 두 곳에서 나온다. 맵(map) 조회와 유저 공간으로의 이벤트 전달. 이 둘을 줄이는 것이 eBPF 튜닝의 핵심이다.

관측성 도구를 붙였더니 관측 대상보다 도구가 CPU를 더 쓰는 상황을 본 적이 있다. 초당 수백만 번 호출되는 훅에서 해시맵을 세 번 조회하고 있었다. 문제를 찾고 나니 해결은 단순했다. 조회 결과를 재사용하고, 일부를 per-CPU 구조로 바꾸는 것으로 CPU 사용률이 크게 떨어졌다.

비용이 어디서 나오는지 이해하기

요소상대 비용줄이는 방법
해시맵 조회높음 (해시 계산 + 락/RCU)per-CPU 맵, 배열 맵, 캐싱
배열 맵 조회낮음 (인덱스 접근)가능하면 배열로 설계
맵 갱신높음배치 갱신, per-CPU 누적 후 합산
유저 공간 이벤트 전달매우 높음집계 후 전달, 샘플링, 링버퍼
문자열·경로 처리높음커널에서 최소화, 유저 공간에서 해석
헬퍼 호출중간필요한 것만, 루프 밖으로
먼저 측정하라 bpftool prog show로 프로그램별 실행 횟수와 누적 실행 시간을 볼 수 있다. 통계 수집을 켜야 하며(sysctl kernel.bpf_stats_enabled=1), 이 자체에도 약간의 오버헤드가 있으니 측정 후 끄는 것이 좋다.
# BPF 프로그램 통계 켜기 (측정 시에만)
sudo sysctl -w kernel.bpf_stats_enabled=1

# 프로그램별 실행 횟수와 누적 시간 확인
sudo bpftool prog show
#  12: kprobe  name trace_sys_enter  tag abc...
#      run_time_ns 4821330012 run_cnt 91234567
#      → 호출당 평균 약 52ns

sudo sysctl -w kernel.bpf_stats_enabled=0

# 맵 사용 현황
sudo bpftool map show
sudo bpftool map dump id 7 | head

기법 1 — per-CPU 맵으로 경합 제거

여러 CPU가 같은 맵 항목을 갱신하면 동기화 비용이 든다. 카운터처럼 합산만 하면 되는 값은 per-CPU 맵에 각 CPU가 독립적으로 쌓고, 읽을 때 합치면 된다.

// 경합이 생기는 방식
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 1024);
} counts SEC(".maps");
// 여러 CPU가 같은 키를 갱신 → 원자 연산 비용

// per-CPU 방식: 각 CPU가 자기 슬롯에만 쓴다
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_HASH);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 1024);
} counts SEC(".maps");

// 유저 공간에서 읽을 때 CPU 수만큼 값을 받아 합산한다.
// 트레이드오프: 메모리 사용이 CPU 수만큼 늘어난다.

기법 2 — 조회 결과 메모이제이션

같은 키에 대한 조회가 반복된다면 결과를 재사용할 수 있다. 특히 프로세스 정보나 컨테이너 식별자처럼 짧은 시간 동안 변하지 않는 값이 대상이다.

// per-CPU 배열을 1칸짜리 캐시로 사용
struct cache_entry { u64 key; u64 value; u64 ts; };

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, u32);
    __type(value, struct cache_entry);
    __uint(max_entries, 1);
} last SEC(".maps");

static __always_inline u64 lookup_cached(u64 key) {
    u32 zero = 0;
    struct cache_entry *c = bpf_map_lookup_elem(&last, &zero);
    if (c && c->key == key && (bpf_ktime_get_ns() - c->ts) < 1000000000ULL) {
        return c->value;                  // 캐시 적중 — 비싼 조회 생략
    }
    u64 v = expensive_lookup(key);        // 실제 조회
    if (c) { c->key = key; c->value = v; c->ts = bpf_ktime_get_ns(); }
    return v;
}

작업 부하에 시간적 지역성이 있으면(같은 프로세스의 이벤트가 연속으로 오면) 적중률이 매우 높다. 반대로 키가 매번 달라지는 워크로드에서는 캐시 검사 비용만 추가되므로 실측이 필요하다.

기법 3 — 커널에서 집계하고 적게 내보내기

가장 큰 절감은 유저 공간으로 보내는 이벤트를 줄이는 것에서 나온다. 이벤트 하나를 전달하는 비용은 맵 조회 수십 번에 맞먹는다.

집계 후 전달 방식
  • 이벤트 전달 횟수가 수천분의 1로 감소
  • 유저 공간 처리 부담 감소
  • 버퍼 오버플로로 인한 손실 감소
개별 이벤트가 필요한 경우
  • 인과 관계 추적(특정 요청의 경로)
  • 희귀 이벤트 디버깅
  • 정확한 타임라인이 필요한 분석
// 히스토그램을 커널에서 만들고 주기적으로만 읽는다
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 64);          // log2 버킷
} hist SEC(".maps");

static __always_inline void record(u64 delta_ns) {
    u32 slot = log2l(delta_ns);
    if (slot >= 64) slot = 63;
    u64 *cnt = bpf_map_lookup_elem(&hist, &slot);
    if (cnt) (*cnt)++;                // 이벤트 전달 없음
}
// 유저 공간은 1초에 한 번 맵 전체를 읽어 집계한다.

기법 4 — 샘플링과 조기 반환

모든 이벤트가 필요하지 않은 경우가 많다. 훅 진입 직후 필터링해서 빠르게 반환하면, 나머지 로직 비용을 통째로 아낄 수 있다.

SEC("tracepoint/syscalls/sys_enter_read")
int on_read(void *ctx) {
    // 1) 가장 싼 조건부터 검사하고 즉시 반환
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (pid == self_pid) return 0;             // 자기 자신 제외

    // 2) 샘플링 — 전수 수집이 필요 없다면
    if ((bpf_get_prandom_u32() & 0x3F) != 0)   // 1/64 확률
        return 0;

    // 3) 비싼 작업은 여기서부터
    do_expensive_work(ctx);
    return 0;
}
필터는 커널에서 유저 공간에서 걸러 낼 거라면 애초에 커널에서 보내지 말아야 한다. "일단 다 보내고 나중에 필터"는 eBPF에서 최악의 패턴이다.

기법 5 — 링버퍼와 perf 버퍼 선택

개별 이벤트를 꼭 보내야 한다면 전달 수단도 성능에 영향을 준다. 링버퍼는 CPU별 버퍼를 따로 두지 않고 공유하며, 예약-커밋 방식으로 복사를 줄일 수 있다.

수단특징적합한 경우
perf 버퍼CPU별 버퍼, 오래된 방식CPU별 순서가 중요한 경우
링버퍼공유 버퍼, 이벤트 순서 보장, 예약-커밋 지원대부분의 신규 구현
맵 폴링주기적으로 맵을 읽음집계·히스토그램
// 링버퍼: 예약 후 직접 쓰기 (중간 복사 제거)
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 22);          // 4MB
} events SEC(".maps");

struct event { u64 ts; u32 pid; u32 len; };

SEC("kprobe/...")
int probe(void *ctx) {
    struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e) return 0;                       // 버퍼 가득 — 드롭 카운터 증가 권장
    e->ts = bpf_ktime_get_ns();
    e->pid = bpf_get_current_pid_tgid() >> 32;
    e->len = 0;
    bpf_ringbuf_submit(e, 0);
    return 0;
}

검증 — 개선을 어떻게 확인하나

튜닝 전후 비교
bpftool prog show의 호출당 평균 실행 시간(run_time_ns / run_cnt)
시스템 전체 CPU 사용률 — 특히 %sys 비중
대상 워크로드의 처리량·지연 변화 (관측 대상에 주는 영향)
이벤트 드롭 카운터 — 버퍼가 넘쳐 데이터를 잃고 있지 않은가
맵 메모리 사용량 — per-CPU 전환 시 CPU 수만큼 증가한다
verifier 통과 시간과 명령 수 — 복잡도가 과해지지 않았는가

마지막 항목은 성능이 아니라 운영 문제다. 프로그램이 복잡해지면 verifier 검증이 오래 걸리고, 커널 버전에 따라 거부될 수 있다. 최적화가 이식성을 깎지 않는지도 함께 봐야 한다.

자주 묻는 질문

eBPF 프로그램의 오버헤드를 어떻게 측정하나요?

kernel.bpf_stats_enabled를 켠 뒤 bpftool prog show로 실행 횟수와 누적 실행 시간을 확인하면 호출당 평균 비용을 계산할 수 있습니다. 측정 자체에 약간의 오버헤드가 있으므로 확인 후에는 끄는 것이 좋습니다.

per-CPU 맵은 언제 쓰나요?

여러 CPU가 같은 키를 갱신하는 카운터나 누적값에 적합합니다. 각 CPU가 자기 슬롯에만 쓰므로 경합이 사라지고, 유저 공간에서 읽을 때 합산합니다. 대신 메모리 사용량이 CPU 수만큼 늘어납니다.

맵 조회를 캐싱해도 되나요?

짧은 시간 동안 변하지 않는 값이고 접근에 시간적 지역성이 있다면 효과적입니다. per-CPU 배열에 최근 조회 결과를 두고 유효 시간을 짧게 잡는 방식이 일반적입니다. 키가 매번 달라지는 워크로드에서는 오히려 손해일 수 있으니 실측하세요.

가장 큰 절감은 어디서 나오나요?

유저 공간으로 보내는 이벤트를 줄이는 것입니다. 커널에서 히스토그램이나 카운터로 집계한 뒤 주기적으로 읽어 가면 전달 횟수가 수천분의 1로 줄어듭니다. 개별 이벤트는 인과 추적이 필요한 경우에만 사용하세요.

샘플링을 하면 데이터가 부정확해지지 않나요?

통계적 분포를 보는 목적에는 충분한 경우가 많습니다. 다만 희귀 이벤트 탐지나 인과 추적에는 적합하지 않으므로, 목적에 따라 전수 수집 구간과 샘플링 구간을 나누는 설계가 필요합니다.

링버퍼와 perf 버퍼 중 무엇을 쓸까요?

신규 구현이라면 링버퍼가 기본 선택지입니다. 공유 버퍼로 메모리 효율이 좋고 이벤트 순서가 보장되며, 예약-커밋 방식으로 중간 복사를 줄일 수 있습니다. CPU별 독립 처리가 꼭 필요한 경우에만 perf 버퍼를 고려하세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록