Post

eBPF란

What is eBPF and how it works?

eBPF란

개요

커널 함수의 성능 측정을 위해 eBPF를 활용할 일이 있어 간단하게 사용해 본 적이 있다. 그런데 이 eBPF라는 녀석의 정확한 정체가 무엇인지 궁금해서 공부해보게 되었다. eBPF 외에도 커널 함수 trace를 위한 여러 방법이 존재한다. 주로 CPU 성능 및 이벤트 추적을 위한 perf, sysfs를 활용하는 ftrace, 이 외에도 LTTngSystemTap 이라는 도구도 존재한다. 그 중 eBPF는 정확히 무엇인지, 정확히 어떤 매커니즘으로 돌아가는지 알아보자.

eBPF란 무엇인가

ebpf.io의 설명에 따르면 커널의 소스코드 변경 및 재빌드 없이 privileged context에서 sandboxed program을 실행할 수 있는 기술이라고 한다. 쉽게 말해 커널의 소스 코드를 변경하여 재빌드하지 않고도 커널의 동작을 수정하거나 추가할 수 있게 해주는 도구이다.

전통적으로 커널의 동작을 수정하고 싶으면 1) 커널 소스 코드를 직접 고치거나 2) 커널 모듈을 개발해서 사용해야 한다. 하지만 1번 옵션은 수정 사항이 리눅스 커널 upstream에 반영되기까지 iteration cycle이 너무 길고, 2번 옵션은 커널 버전이 업데이트되어 사용하는 자료구조의 형태가 달라지는 경우 호환되지 않기 때문에 매번 새로 작성해야 한다는 문제가 있다. 반면 eBPF는 커널 코드를 고치거나 커널 모듈을 개발하지 않더라도 리눅스 커널의 동작을 수정할 수 있는 방법을 제공한다. 또한 CO-RE 기술을 통해 여러 버전들에 호환 가능성을 제공한다.

eBPF의 동작 방식으로 넘어가기 전에 내가 개인적으로 궁금했던 eBPF의 정체애 대해 설명하고 넘어가자면, eBPF는 RISC ISA를 갖는 독립된 아키텍쳐이다. 11개의 64bit 레지스터와 program counter, 512 byte 크기의 스택이 존재한다. 커널은 bpf 라는 syscall을 노출하고, 사용자로부터 실행시킬 프로그램을 받는다. 커널은 받은 프로그램이 안전한지 검사하고 안전하다면 등록된 트리거가 발생할 때 마다 해당 함수를 실행시켜준다.

어떻게 돌아가는가

eBPF가 동작하는 방식에 대해 high level에서 설명한다.

1. eBPF 프로그램 작성

eBPF 튜토리얼에 나와있는 예시 프로그램 코드 중 일부이다.

1
2
3
4
5
6
7
8
9
10
// sample.c
SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
  pid_t pid = bpf_get_current_pid_tgid() >> 32;
  if (pid_filter && pid != pid_filter)
    return 0;
  bpf_printk("BPF triggered sys_enter_write from PID %d.\n", pid);
  return 0;
}

SEC(..) 구문은 이 프로그램의 트리거를 결정하는 부분이다. 지금의 예시에선 sys_enter_write 라는 tracepoint를 트리거로 설정했다. 이 외에도 다양한 종류의 트리거를 설정할 수 있는데 이 트리거의 종류에 따라 프로그램 타입이 달라진다. 다양한 종류의 프로그램 타입들이 존재하는데 예시로 몇가지만 소개하자면 이런 것들이 있다.

  • BPF_PROG_TYPE_XDP: 네트워크 드라이버 수준의 패킷 처리
  • BPF_PROG_TYPE_TRACEPIONT/KPROBE: 커널 함수/이벤트 추적 (위의 예시는 이 경우에 해당한다)
  • BPF_PROG_TYPE_SK_MSG: 소켓 메시지 필터링

eBPF 프로그램은 임의의 커널 함수를 호출할 수 없다. 그 이유는 특정 버전의 커널에 바인딩되고 호환성이 깨질 수 있기 때문이다. 대신 bpf_get_currnet_pid_tgidbpf_printk 처럼 bpf에서 제공하는 다양한 helper function이 존재한다.

2. eBPF program load

프로그램 작성을 마쳤으면 clang을 통해 BPF bytecode로 컴파일할 수 있다. 이 bytecode는 앞서 설명한 eBPF 아키텍쳐의 레지스터와 스텍을 사용하는 IR이 될 것이다.

1
$ clang -O2 -target bpf -c sample.c -o sample.o

그리고 컴파일된 bytecode를 bpf라는 syscall을 통해 커널에 넘겨준다. 실제로 bpftool을 사용해보며 strace로 syscall 호출 로그를 살펴보면 아래와 같은 로그를 볼 수 있다.

bpf syscall을 쉽게 활용하기 위한 libbpf라는 library가 있고, 이를 활용한 CLI 툴이 bpftool 이다.

1
2
$ strace -e trace=bpf bpftool prog load sample.o /sys/fs/bpf/sample
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP ...}, 128) = 3

3. Program verify & JIT compile

커널은 넘겨받은 eBPF 프로그램이 안전한지 검사한다.

  • 프로그램이 항상 종료되는지, 무한루프에 빠지거나 블락되지 않는지 검사한다.
  • 초기화되지 않은 변수를 사용하지 않는지 검사한다.
  • 앞서 정의된 eBPF 아키텍쳐의 리소스를 초과해서 사용하지 않는지 검사한다.
  • verify가 정해진 리소스 안에서 분석을 마치지 못할정도로 너무 복잡하지 않은지 검사한다.

검사를 통과한다면 커널 내부에서 이 bytecode를 machine code로 JIT compile하여 실행시킨다. 실행 시에도 보안상 문제가 있을 수 있는데 다음과 같은 Hardening 과정이 존재한다.

  • eBPF 코드를 read-only로 설정하여 실행될 코드가 변조되는 일을 방지한다.
  • Spectre 취약점 약용을 막기 위해 메모리 접근을 마스킹하거나, Retpoline을 사용하거나, 검증기가 speculative execution path까지 검증한다.
  • JIT spraying attack을 방지하기 위해 상수 난독화를 한다.

또 다른 runtime 제약으로는 eBPF 프로그램은 임의의 커널 메모리에 직접 접근할 수 없다. 예를 들어 kprobe를 통해 함수의 시작을 trigger로 설정하고, 함수 인자로 넘어온 구조체의 특정 field의 값을 읽어보고 싶다면 eBPF helper function을 사용해서 해당 메모리를 읽어야 한다.

4. Trigger

이제 sys_enter_write 트리거를 발생시키는 (write syscall을 호출하는) 아무 프로그램이나 실행시면 아래와 같이 내가 등록한 eBPF 프로그램이 실행되었음을 알 수 있다.

1
2
3
$ cat /sys/kernel/debug/tracing/trace_pipe | grep "BPF triggered sys_enter_write"
        <...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: write system call from PID 3840345.
        <...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: write system call from PID 3840345.

여기서 /sys/kernel/debug/tracing/trace_pipe 로 결과가 출력되는 것은 bpf_printk의 출력 결과가 해당 pipe로 연결되어 있기 때문이다. 꼭 해당 pipe로 결과를 내보내지 않더라도 BPF_PERF_OUTPUT 를 사용해 perf Ring Buffer로 결과를 내보낸다거나 뒤에 후술할 eBPF MAP을 통해 user space application과 데이터를 공유할 수도 있다.

eBPF Map

eBPF 프로그램은 eBPF Map을 통해 user space에서 돌아가는 application과 데이터를 공유할 수 있다. 이 Map이라는 녀석은 HashTable, LRU, Ring Buffer, Stack Trace 등등의 타입이 될 수 있다. 나의 경우 Ring Buffer를 이용하여 eBPF 프로그램에서 함수의 시작과 끝에서 인자 중 몇가지 정보를 Ring Buffer에 기록하고, Rust로 작성한 user application에서 Ring Buffer로부터 해당 데이터를 읽어 함수의 시작과 끝을 매칭해 하나의 span으로 만들어 성능 측정을 했었다.

정리

나는 커널 함수의 성능 측정을 위해 eBPF를 사용했지만 eBPF라는 녀석은 더 강력하고 범용적으로 사용될 수 있는 도구임을 알았다. 예를 들어 네트워크에서 로드 벨런싱이나 패킷 필터링을 수행할 수도 있고, 커널 함수 추적을 통해 성능 관찰 및 모니터링을 하거나, 런타임 보안을 강화하는데 사용될 수 있다.

또한 여기서 자세히 다루지는 않았지만 eBPF 프로그램의 높은 이식성을 위한 CO-RE 및 BTF에 관한 내용도 흥미롭다.

참고

This post is licensed under CC BY 4.0 by the author.