eBPF 스핀락과 커널 디버깅의 기술
eBPF 스핀락 버그가 리눅스 커널에서 나타나는 방식과 찾기 어려운 이유, 그리고 eBPF 안전성 모델에 주는 교훈을 살펴봅니다.

eBPF의 스핀락 버그는 스택 트레이스나 에러 메시지로 존재를 알리지 않습니다. 대신 머신이 응답을 멈추는 방식으로 드러납니다. 로그도, 경고도, 점진적인 성능 저하도 없습니다. 풀리지 않을 락을 기다리며 CPU 코어 하나가 영원히 돌고 있을 뿐입니다. 운이 좋으면 워치독 타이머가 작동해 트레이스와 함께 커널 패닉이 발생합니다. 운이 나쁘면 그냥 머신이 멈춰서 전원을 직접 껐다 켜야 합니다.
eBPF는 안전하도록 설계되어 있습니다. 커널 안에서 실행되지만, 검증기(verifier)를 거치면서 프로그램이 반드시 종료되고, 잘못된 메모리에 접근하지 않으며, 커널 상태를 망가뜨리지 않는다는 것이 증명됩니다. 그런데 스핀락 버그는 어떻게 이 검증을 통과할까요? 검증기는 개별 프로그램을 검사하지만, 스핀락 버그는 프로그램, 맵, 그리고 커널의 스케줄링 결정이 서로 얽히면서 생겨나기 때문입니다. 정적 검증기가 완전히 예측할 수 없는 영역이죠.
eBPF 스핀락은 무엇을 위한 것인가
eBPF 프로그램은 커널 컨텍스트에서 실행되며, 종종 여러 CPU에서 동시에 돌아갑니다. 두 eBPF 프로그램이 같은 맵 값(카운터, 자료구조, 상태 머신 등)을 갱신해야 한다면 동기화가 필요합니다. 이를 위해 eBPF는 bpf_spin_lock과 bpf_spin_unlock을 제공합니다.
스핀락은 가장 단순한 형태의 락입니다. 락을 얻으려고 시도하고, 누군가 쥐고 있으면 풀릴 때까지 타이트한 루프를 돌며 기다립니다. 잠들지도, 양보하지도, 큐에 줄을 서지도 않습니다. eBPF에 잘 맞는 이유는 eBPF 프로그램이 잠드는 것이 금지된 컨텍스트, 즉 인터럽트 핸들러, 소프트IRQ, 선점이 비활성화된 구간에서 실행되기 때문입니다. 호출자를 재우는 뮤텍스를 쓰면 커널이 죽습니다. 스핀락은 락이 풀릴 때까지 CPU 사이클을 태울 뿐입니다.
struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}
언뜻 보면 간단합니다. 락을 잡고, 값을 갱신하고, 풀면 끝이죠. 이렇게 단순한 경우에는 잘 동작합니다. 문제는 락 패턴이 복잡해지거나, eBPF 프로그램과 커널 자체의 락킹 사이에 예상치 못한 의존성이 생길 때 시작됩니다.
스핀락 버그가 나타나는 방식
고전적인 스핀락 버그는 데드락입니다. 프로그램 A가 락 1을 쥔 채 락 2를 얻으려 하고, 프로그램 B는 락 2를 쥔 채 락 1을 얻으려 합니다. 둘 다 상대가 풀기만을 기다리며 영원히 스핀합니다. 커널 컨텍스트에서 이건 해당 CPU 코어가 영영 쓸모없어진다는 뜻입니다. 아무 일도 할 수 없게 되는 거죠.
eBPF 검증기는 이런 경우 일부를 막습니다. 동시에 두 개 이상의 스핀락을 쥐는 프로그램을 거부하기 때문에 고전적인 AB-BA 데드락은 사라집니다. 하지만 모든 데드락을 막을 수는 없습니다. 일부는 eBPF 스핀락과 커널 자체 락 사이의 관계에서 발생하기 때문입니다.
이런 시나리오를 생각해 봅시다. 트레이스포인트에 연결된 eBPF 프로그램이 맵의 스핀락을 얻습니다. 락을 쥐고 있는 동안 같은 CPU에서 하드웨어 인터럽트가 발생합니다. 인터럽트 핸들러는 같은 스핀락을 얻으려는 다른 eBPF 프로그램을 실행합니다. 데드락입니다. 인터럽트는 락을 얻기 전까지 반환할 수 없고, 락은 인터럽트당한 프로그램이 재개되어야 풀리는데, 그 프로그램은 인터럽트가 반환되어야 재개되니까요.
Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck
커널의 자체 스핀락은 락을 쥐고 있는 동안 인터럽트를 비활성화(spin_lock_irqsave)해서 이 문제를 피합니다. eBPF 스핀락도 비슷하게 동작합니다. bpf_spin_lock은 선점을 비활성화하고, 5.1 이상 커널에서는 소프트IRQ도 비활성화합니다. 하지만 모든 인터럽트 컨텍스트가 커버되는 것은 아니고, 커널 버전마다 처리 방식도 다릅니다. 미묘한 버그가 숨어 있는 곳이 바로 여기입니다.
디버깅의 어려움
eBPF 스핀락 문제를 디버깅하는 일은 구체적인 이유들로 인해 어렵습니다.
재현이 비결정적입니다. 스핀락 버그는 타이밍에 달려 있습니다. 어떤 CPU가 어떤 프로그램을 실행하는지, 인터럽트가 언제 발생하는지, 크리티컬 섹션이 얼마나 걸리는지에 따라 달라집니다. 999번은 잘 돌던 테스트가 1000번째에 데드락을 일으킬 수 있습니다. 개발 머신은 프로덕션과 CPU 토폴로지, 인터럽트 빈도, 부하 패턴이 다르기 때문에 개발 환경에서 안정적으로 재현하기 어렵습니다.
실패 모드가 증거를 파괴합니다. 스핀락 데드락이 발생하면 영향받은 CPU는 아무것도 실행하지 못합니다. 무슨 일이 일어났는지 알려줄 로깅과 모니터링 인프라도 포함해서요. 데드락이 단일 CPU에서만 생기면 머신은 어떻게든 버틸 수도 있습니다. 하지만 여러 CPU가 데드락에 빠지거나, 락을 쥔 CPU가 다른 CPU가 필요로 하는 자원을 쥐고 있다면 머신 전체가 멈춥니다.
전통적인 디버깅 도구는 도움이 되지 않습니다. 데드락 상태를 확인하려고 커널에 디버거를 붙일 수는 없습니다(KGDB를 쓸 수는 있지만, 데드락 발생 전에 시리얼 연결을 미리 설정해 둬야 합니다). 아무것도 기록하지 않으니 dmesg도 쓸모가 없습니다. 머신이 완전히 죽기 전에 락업 디텍터가 작동해야만 믿을 만한 정보를 얻을 수 있습니다.
실제로 효과가 있는 전략들
이런 어려움에도 불구하고 eBPF 스핀락 버그를 찾아 고치는 사람들은 있습니다. 방법은 다음과 같습니다.
락 순서 분석. 무언가를 실행하기 전에 모든 eBPF 프로그램의 락 동작을 분석하세요. 각 프로그램은 어떤 맵에 접근하나요? 어떤 락을 얻나요? 같은 락을 건드리는 두 프로그램이 같은 CPU에서 실행될 수 있나요(같은 트레이스포인트이거나, 트레이스포인트와 인터럽트 조합)? 지루한 수작업이지만, 가장 흔한 데드락 패턴을 발생 전에 잡아낼 수 있습니다.
락 보유 시간 모니터링. eBPF 프로그램에 계측을 넣어 스핀락을 얼마나 오래 쥐고 있는지 측정하세요. 스핀락을 몇 마이크로초 이상 쥐고 있다면 위험 신호입니다. 인터럽트 기반 데드락이 생길 시간 창이 넓어지고, 다른 CPU들은 스핀하며 자원을 낭비합니다. 짧은 크리티컬 섹션은 빠르고 안전합니다.
// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) { // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}
CPU별 자료구조. 가장 좋은 스핀락 버그는 애초에 발생하지 않는 버그입니다. 각 CPU가 자기만의 데이터 복사본에서 작업하고 나중에 합치면, 경합도 없고 락도 필요 없습니다. eBPF의 BPF_MAP_TYPE_PERCPU_HASH와 BPF_MAP_TYPE_PERCPU_ARRAY가 바로 이런 용도로 설계되었습니다. 카운터, 히스토그램, 누적기는 공유 상태 스핀락이 필요한 경우가 거의 없습니다.
락 대신 원자 연산. 카운터 증가나 값 비교 후 교환(compare-and-swap) 같은 단순한 연산에는 __sync_fetch_and_add 같은 원자 연산이 더 빠르고 데드락도 없습니다. 카운터를 올리는 데 스핀락은 필요하지 않습니다. 여러 필드를 원자적으로 갱신해야 하는 연산에만 스핀락을 쓰세요.
이것이 eBPF 안전성 모델에 말해주는 것
eBPF의 안전성 이야기는 인상적이지만 단순하지 않습니다. 검증기는 개별 프로그램이 안전하다는 것을 보장합니다. 종료하고, 잘못된 메모리에 접근하지 않고, 커널 상태를 망가뜨리지 않습니다. 하지만 '안전한' 프로그램도 타이밍 의존성이 생기면 시스템 전체로는 안전하지 않을 수 있습니다. 검증기가 분석할 수 없는 영역입니다.
이것은 정적 분석의 근본적인 한계입니다. 검증기는 프로그램을 하나씩 따로 봅니다. 어떤 다른 프로그램이 로드되어 있는지, 어떤 맵을 공유하는지, 어떤 커널 컨텍스트에서 실행되는지 알지 못합니다. 스핀락을 얻고 올바르게 푸는 프로그램은 그 자체로는 '안전'합니다. 하지만 로드된 다른 모든 eBPF 프로그램과 조합했을 때도 안전한지는 런타임 조건에 달려 있습니다.
커널 커뮤니티는 이 문제를 점진적으로 다루고 있습니다. 최근 패치들은 어떤 eBPF 프로그램 타입이 스핀락을 쓸 수 있는지, 크리티컬 섹션이 얼마나 길 수 있는지, 스핀락을 쥔 동안 어떤 연산이 금지되는지에 제약을 추가했습니다. 제약 하나하나가 버그가 생길 수 있는 틈을 좁히지만, 동시에 eBPF 프로그램이 할 수 있는 일도 제한합니다.
eBPF 락킹을 위한 실용적인 규칙
스핀락 문제를 충분히 디버깅하다 보면 피하는 패턴이 보입니다.
- CPU별 맵을 우선 사용하세요. 데이터를 CPU 단위로 나누고 사용자 공간에서 합칠 수 있다면 그렇게 하세요. 락도, 경합도, 데드락도 없습니다. 카운터, 이벤트 로그, 히스토그램 등 대부분의 사용 사례(약 80%)를 이걸로 처리할 수 있습니다.
- 스핀락보다 원자 연산을 선호하세요. 공유 카운터나 compare-and-swap이 필요하다면 원자 연산을 쓰세요. 락프리(lock-free)이고 데드락이 생길 수 없습니다.
- 스핀락을 꼭 써야 한다면 크리티컬 섹션을 아주 작게 유지하세요. 카운터 증가, 타임스탬프 갱신, 포인터 교체 정도만 하세요. 그 이상은 하지 마세요. 락을 쥔 상태에서 헬퍼 함수를 호출하지 마세요. 내부에서 어떤 락을 잡는지 알 수 없기 때문입니다.
- 같은 락을 두 종류의 eBPF 프로그램 타입에서 잡지 마세요. kprobe 프로그램과 트레이스포인트 프로그램이 같은 맵 값에 락을 건다면, 인터럽트 한 번이면 데드락입니다. 맵을 따로 쓰거나 CPU별 데이터를 사용하세요.
- 부하 상태에서 테스트하세요. 스핀락 버그는 타이밍에 의존합니다. 경합이 있을 때, 즉 CPU 사용률이 높고 인터럽트가 잦으며 많은 eBPF 프로그램이 동시에 돌 때 드러납니다. 한가한 개발 머신이 아니라 실제 프로덕션과 비슷한 부하로 테스트하세요.
eBPF는 리눅스 커널과 상호작용하는 방식을 근본적으로 바꿨습니다. 사용자 공간 프로그램에 커널 수준의 관측성과 네트워킹을 안전하게 제공하니까요. 하지만 '안전하다'에도 경계가 있습니다. 검증기 덕분에 eBPF는 원시 커널 모듈을 작성하는 것보다 훨씬 안전합니다. 그렇다고 사용자 공간 프로그래밍만큼 안전해지는 것은 아닙니다. 특히 공유 상태와 락킹을 둘러싼 이 경계가 어디인지 이해하는 것이, 테스트에서만 돌아가는 eBPF 프로그램과 실제 프로덕션에서 제대로 돌아가는 프로그램을 가르는 기준입니다.


