Post

ebpf 알아보기

리눅스에서 혁신적인 기술로 불리는 ebpf 알아보기

💡 KANS 스터디
CloudNet에서 주관하는 KANS(Kubernetes Advanced Networking Study)으로 쿠버네티스 네트워킹 스터디입니다. 아래의 글은 스터디의 내용을 기반으로 작성했습니다.

스터디에 관심이 있으신 분은 CloudNet Blog를 참고해주세요.

kernel 수정 방식

기존의 커널에서 사용자 코드를 넣는 방식은 아래와 같다.

  • kernel module: kernel위에 붙이는 것으로 커널의 컴파일없이 로드할 수 있다. 장치 드라이버 또한 모듈 형태로 존재한다. 하지만 모듈이 안정적일지 모른다.
  • kernel source code 수정: 커널 컴파일이 필요하다. 과거 관련된 프로젝트 진행할 때 가벼운 커널이었어도 2~3시간 걸렸으며, 수정한 코드가 안정적일지 모름
  • kernel hooks: kernel의 일부분에서 hooks을 제공한다. 훅은 통해 custom logic을 붙일 수 있으며 kernel 내부를 수정할 수 있다. ex) netfilter
    • 기본적인 한계 존재(대부분의 영역에서 hook을 제공하는 건 아님)

위에 해당 하는 방식은 여러 단점이 존재한다. eBPF는 안전하게 커널 내부에 프로그램을 넣을 수 있는 방식으로 kernel 소스를 수정하지 않는다.

ebpf란

ebpf(extended berkeley packet fillter)으로 BPF를 확장한 모델이다. BPF는 이벤트기반으로 동작하며 이벤트 발생시 해당 kernel을 해킹한다. 커널 내부 소스코드를 건드리는 것이 아닌 sandboxed 된 환경으로 커널에 영향을 주지 않는다. BPF는 어떻게 kernel을 해킹하고, 샌드박스된 환경을 제공할 수 있을까?

이제 ebpf의 동작 방식을 확인하여 이유를 알아본다.

ebpf 동작 방식

image.png

출처: https://ebpf.io/ko-kr/what-is-ebpf/

  • bpf() 시스템 콜을 통해 코드를 커널 공간에서 넣는다. 참고 자료
    • 직접 커널 호출이나 메모리 수정이 불가능하며, kernel helper를 통해 제한된 동작만 진행하여 안전하다.
  • 검증기와 JIT 컴파일러를 통해 코드를 검사하고 JIT 컴파일러를 통해 코드를 실행한다.
    • 검증기 검사를 통해 안전하며 네이티브 컴파일러처럼 빠른 실행이 가능하다.(vs 인터프리터)
  • BPF의 코드는 커널의 각 함수에 존재하는 훅과 연결되거나 probe를 통해 커널 이벤트와 연결된다. 특정 이벤트 발생시 훅 혹은 프로브를 통해 해당 코드가 실행된다.
    • 만약, 훅이 없을시 kprobe와 같은 동적프로브를 활용하여 특정 커널 함수를 모니터링하여 커널함수가 실행될때 프로브를 통해 원하는 코드가 실행될 수 있다.
  • Data로는 BPF maps, Ring Buffers를 사용한다.

이제 여기서 나오는 주요 요소에 대해 자세히 살펴본다.

동적프로브(kprobe, kretprobe)

kprobe는 커널 코드 대부분에 넣을 수 있고, 프로브가 존재하면 커널 실행을 멈추고 프로브로 제어권이 넘어간다. kretprobe는 커널 함수가 종료될 때 실행되는 프로브이다.

“A kprobe can be inserted on virtually any instruction in the kernel. A return probe fires when a specified function returns.”

참고: https://docs.kernel.org/trace/kprobes.html

컴파일러

JIT (Just-In-Time) 컴파일러는 바이트코드를 실행시점에 머신코드로 변환한다. 이런 과정 덕분에 eBPF 프로그램도 기존의 컴파일러를 통해 컴파일된 프로그램과 유사한 속도로 실행가능하다.

helper function

미리 커널에 작성된 bpf helper를 통해 다양한 동작이 가능하다.

  • map과 관련된 동작(read, update, delete)
    • bpf_map_lookup_percpu_elem, bpf_map_update_percpu_elem: cpu별 데이터 조작 가능
  • packet 관련동작(패킷 데이터 읽어오기, 저장, 리다이렉트 등)
  • tracing, logging
    • trace_printk: 로그 출력 가능하나 성능에 영향을 줄 수 있음
      • For passing values to user space, perf events should be preferred.
    • stackid, bpf_ktime_get_ns
  • 성능 모니터링
    • bpf_perf_event_output
  • cgroup 관련
    • bpf_get_current_cgroup_id, bpf_skb_cgroup_id
  • 프로세스 관련
    • bpf_get_smp_processor_id: Get the SMP (symmetric multiprocessing) processor id.
    • bpf_get_current_pid_tgid: 현재프로세스의 PID + TGID 반환, 이후 실습에서 사용되는 함수이다.
    • bpf_send_signal: 특정 프로세스에게 signal
  • 네트워크 관련
    • bpf_sk_lookup_tcp: 소켓검색
    • bpf_msg_redirect_hash: 다른 소켓으로 redirect
    • bpf_ntohs, bpf_ntohl, bpf_htons, bpf_htonl : 네트우커ㅡ 바이트 순서, 호스트 바이트 순서 간의 변환 진행
    • bpf_csum_diff: 체크썸의 차이 계산
  • memory
    • bpf_ringbuf_output: 링 버퍼를 통해 사용자 공간으로 데이터 전송

참고: https://man7.org/linux/man-pages/man7/bpf-helpers.7.html

정리해보면, 아래와 같은 이유로 커널의 수정없이 안전하게 커널을 커스터마이징할 수 있다.

  • 훅과 동적프로브를 통해 커널 소스코드 변경없이 사용자 로직을 실행할 수 있다.
  • 검증기와 사전에 커널에 작성된 helper 함수를 통해 안전하게 실행가능하다.

실습

golang으로 진행한다. python으로 실행할 수 있는 bpf 관련 샘플 코드는 https://github.com/iovisor/bcc에서 확인할 수 있다.

환경은 ubuntu 22.04로 진행, go version 1.21로 진행한다.

여기서는 간단하게 시스템 콜 호출을 로깅하여 eBPF를 맛본다.

환경 구성

  • 컴파일 도구 및 linux 헤더 설치
1
2
3
4
5
6
7
8
9
10
sudo apt update
sudo apt install -y \
    build-essential \
    clang \
    llvm \
    libbpf-dev \
    libelf-dev \
    linux-headers-$(uname -r) \
    git \
    pkg-config
  • Golang 설치
1
2
3
4
5
6
7
8
sudo apt-get update
wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz
sudo tar -xvf go1.21.0.linux-amd64.tar.gz
sudo mv go /usr/local
export GOROOT=/usr/local/go
export GOPATH=$HOME/go
export PATH=$GOPATH/bin:$GOROOT/bin:$PATH
source ~/.profile
  • 샘플 코드를 다운받는다. 참고한 포스팅은 여기에서 확인할 수 있습니다.
1
2
3
4
5
vi ~/.bashrc
export PATH=$PATH:/usr/local/go/bin
source ~/.bashrc
git clone https://github.com/ozansz/intro-ebpf-with-go.git
cd intro-ebpf-with-go/0x01-helloworld

코드 확인(c언어 bpf 설정)

간단한 설명: 해당 코드는 sys_execve 시스템 콜이 발생할 때마다 해당 프로세스의 ID와 이름을 가져오는 코드이다.

이제 자세하게 내용을 살펴보면 다음과 같다.

  • 커널에 코드 삽입

kprobe를 통해 sys_execve 시스템 콜이 발생할 때마다 해당 아래의 hello_execve 함수가 호출되도록 한다.

1
2
3
4
5
6
7
8
SEC("kprobe/sys_execve")
int hello_execve(struct pt_regs *ctx) {
    u64 id = bpf_get_current_pid_tgid();
    pid_t pid = id >> 32;
    pid_t tid = (u32)id;

    if (pid != tid)
        return 0;
  • 링 버퍼 전송

BPF에서 프로세스 ID와 Name을 받아 이를 event 구조체에 작성한 후 링 버퍼로 전송한다.

1
2
3
4
5
6
7
8
9
  struct event *e;
  e = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
  if (!e) {
      return 0;
  }

  e->pid = pid;
  bpf_get_current_comm(&e->comm, TASK_COMM_SIZE);
  bpf_ringbuf_submit(e, 0);

💡 세부 내용

  • execve()

경로명을 참조하는 프로그램을 실행할 때 호출되는 함수이다. 관련 문서

  • bpf_get_current_comm의 자세한 내용은 bcc에서 확인할 수 있다.

Syntax: bpf_get_current_comm(char *buf, int size_of_buf)

“Populates the first argument address with the current process name. It should be a pointer to a char array of at least size TASK_COMM_LEN, which is defined in linux/sched.h.”

  • 전체코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// +build ignore

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

#define TASK_COMM_SIZE 100

char __license[] SEC("license") = "Dual MIT/GPL";

struct event {
    u32 pid;
    u8  comm[TASK_COMM_SIZE];
};

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 1 << 24);
} events SEC(".maps");

// Force emitting struct event into the ELF.
const struct event *unused __attribute__((unused));

SEC("kprobe/sys_execve")
int hello_execve(struct pt_regs *ctx) {
    u64 id = bpf_get_current_pid_tgid();
    pid_t pid = id >> 32;
    pid_t tid = (u32)id;

    if (pid != tid)
        return 0;

    struct event *e;

	e = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
	if (!e) {
		return 0;
	}

	e->pid = pid;
	bpf_get_current_comm(&e->comm, TASK_COMM_SIZE);

	bpf_ringbuf_submit(e, 0);

	return 0;
}

코드 확인(golang)

위에서 c로 작성한 eBPF 프로그램을 연결한다.

1
2
3
4
5
objs := ebpfObjects{}
if err := loadEbpfObjects(&objs, nil); err != nil {
    log.Fatalf("loading objects: %v", err)
}
defer objs.Close()

kprobe를 통해 sys_execve 시스템 콜이 발생하는 위치에 eBPF 프로그램(HelloExecve)이 실행되도록 한다.

1
2
3
4
5
kp, err := link.Kprobe(kprobeFunc, objs.HelloExecve, nil)
if err != nil {
    log.Fatalf("opening kprobe: %s", err)
}
defer kp.Close()

링 버퍼를 설정한다. 버퍼를 통해 커널에서 발생한 이벤트를 UserSpace에서 읽을 수 있다.

1
2
3
4
5
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
    log.Fatalf("opening ringbuf reader: %s", err)
}
defer rd.Close()

버퍼에서 이벤트를 수신하고, 이를 이벤트 구조체로 변환하여 로그를 출력한다. (PID, Process Name)

1
2
3
4
5
6
7
8
9
10
var event ebpfEvent
	for {
		record, err := rd.Read()
		if err != nil {
			...
		}
		...

		log.Printf("pid: %d\tcomm: %s\n", event.Pid, unix.ByteSliceToString(event.Comm[:]))
	}
  • 전체 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package main

import (
	"bytes"
	"encoding/binary"
	"errors"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/ringbuf"
	"github.com/cilium/ebpf/rlimit"
	"golang.org/x/sys/unix"
)

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event ebpf hello_ebpf.c

const (
	kprobeFunc = "sys_execve"
)

func main() {
	log.SetPrefix("hello_ebpf: ")
	log.SetFlags(log.Ltime)

	// Subscribe to signals for terminating the program.
	stopper := make(chan os.Signal, 1)
	signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

	// Allow the current process to lock memory for eBPF resources.
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal(err)
	}

	// Load pre-compiled programs and maps into the kernel.
	objs := ebpfObjects{}
	if err := loadEbpfObjects(&objs, nil); err != nil {
		log.Fatalf("loading objects: %v", err)
	}
	defer objs.Close()

	// Open a Kprobe at the entry point of the kernel function and attach the
	// pre-compiled program. Each time the kernel function enters, the program
	// will emit an event containing pid and command of the execved task.
	kp, err := link.Kprobe(kprobeFunc, objs.HelloExecve, nil)
	if err != nil {
		log.Fatalf("opening kprobe: %s", err)
	}
	defer kp.Close()

	// Open a ringbuf reader from userspace RINGBUF map described in the
	// eBPF C program.
	rd, err := ringbuf.NewReader(objs.Events)
	if err != nil {
		log.Fatalf("opening ringbuf reader: %s", err)
	}
	defer rd.Close()

	// Close the reader when the process receives a signal, which will exit
	// the read loop.
	go func() {
		<-stopper

		if err := rd.Close(); err != nil {
			log.Fatalf("closing ringbuf reader: %s", err)
		}
	}()

	log.Println("Waiting for events..")

	// bpfEvent is generated by bpf2go.
	var event ebpfEvent
	for {
		record, err := rd.Read()
		if err != nil {
			if errors.Is(err, ringbuf.ErrClosed) {
				log.Println("Received signal, exiting..")
				return
			}
			log.Printf("reading from reader: %s", err)
			continue
		}

		// Parse the ringbuf event entry into a bpfEvent structure.
		if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
			log.Printf("parsing ringbuf event: %s", err)
			continue
		}

		log.Printf("pid: %d\tcomm: %s\n", event.Pid, unix.ByteSliceToString(event.Comm[:]))
	}
}

빌드

이제 코드를 빌드한다.

1
2
go generate
go build -o hello_ebpf

실행

아래의 명령어를 통해 코드를 실행하고 테스트를 진행한다. (추가 터미널 필요)

1
sudo ./hello_ebpf

실험을 위해 sudo su - 및 여러 명렁어를 입력했다.

1
2
3
sudo su -
cd ~
go version

bash 기반으로 작동하는 건 bash로 나오고, su와 go도 보이는 것을 확인할 수 있다.

image.png

ebpf for Cilium

그렇다면 Cilium은 ebpf를 사용하는 CNI이다. CIlium에서는 ebpf를 어떻게 사용할까?

기존의 iptables는 클러스터의 규모가 커질수록 여러 단점이 존재한다. 관련된 내용은 블로그에 잘정리되어있다.

  1. 패킷마다 iptables의 규칙에 일치할 때까지 모든 규칙을 평가한다.
  2. incremental update를 지원하지 않는다. (새로운 서비스가 생성되면, 전체를 교체해야 한다.)

위와 같은 단점을 해결하기위해 eBPF는 최대한 리눅스 네트워크 스택을 타지 않고 패킷을 처리하려고 한다.

아래의 그림과 같이 패킷은 NIC을 통해 들어오고 XDP > TC > Routing Rule > .. > User Space로 향한다.

ebpf를 통해 XDP에서 바로 처리한다면 성능은 매우 향상될 것이다.

image.png

NIC에서 만약 XDP를 지원한다면 더 최적화할 수 있다.

image.png

결과를 보면 아래와 같다. Iptables 방식은 기존 이더넷의 속도의 10%의 성능만 보였지만 xdp와 xdp-offload는 성능저하가 미세한 것을 확인할 수 있다.

image.png

간단하게 Packet Drop과 관련된 예시를 봤다. Cilium은 eBPF를 통해 커널을 커스텀하여 성능과 보안 그리고 커널 수준에서 모니터링을 확보했다. 자세한 내용은 다음 Cilium 편에서 다룰 예정이다.

image.png

참고자료

ebpf: https://ebpf.io/ko-kr/what-is-ebpf/

bpg helpers: https://man7.org/linux/man-pages/man7/bpf-helpers.7.html

bpf header:

go ebpf example: https://github.com/cilium/ebpf/tree/main/examples

bcc(bpf sample): https://github.com/iovisor/bcc

bpf 실습코드: https://royzsec.medium.com/install-go-1-21-0-in-ubuntu-22-04-2-in-5-minutes-468a5330c64e

datadog ebpf: https://www.datadoghq.com/knowledge-center/ebpf/

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