eBPF / bpf2go - (BPF_MAP_TYPE_HASH) Map을 이용한 전역 변수 구현
자, 이제 본격적으로 Map에 대한 이야기를 해볼까요? ^^
eBPF에서 Map이란 용어는 Dictionary의 의미가 아니라 "Memory Map"에서의 Map이라고 보시면 됩니다. 즉, Ring 0 레벨의 커널 측에서도 접근할 수 있고 Ring 3 레벨의 사용자 코드에서도 접근할 수 있는 메모리 영역을 의미합니다.
그렇다면, 1개의 항목만 담을 수 있는 Map을 정의한다면 그것이 곧 "전역 변수"와 다를 바가 없는데요,
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, uint32_t);
__type(value, uint32_t);
__uint(max_entries, 1);
} my_hash_map SEC(".maps");
위와 같이 max_entries == 1인 Map 정의로 전역 변수를 흉내 낼 수 있는 것입니다. 자, 그럼 정의한 구문을 천천히 뜯어볼까요? 우선, type == BPF_MAP_TYPE_HASH로 설정한 것은, 다양한 용도의 Map 구조가 정의돼 있지만 여기서는 "HASH" 성격을 갖는 Map을 정의하겠다는 것입니다.
현재 eBPF가 지원하는 모든 Map 타입은 아래의 문서에서 확인할 수 있습니다.
Map types (Linux)
; https://docs.ebpf.io/linux/map-type/
그다음, key와 value는 각각 uint32_t 타입으로 준 것이고, 이렇게 구성한 Map의 이름을 "my_hash_map"으로 ".maps" 섹션에 정의했습니다. (Map은 반드시 ".maps" 섹션에 있어야 합니다.)
이렇게 정의했으면, CRUD 연산을 할 수 있어야죠? ^^
eBPF에서 Map에 대해 지원하는 (연산) 함수는 아래의 문서에서 확인할 수 있습니다.
Map helpers
; https://docs.ebpf.io/linux/helper-function/
예를 들어, 위의 목록 중에 bpf_map_lookup_elem을 선택해 볼까요?
Helper function bpf_map_lookup_elem
; https://docs.ebpf.io/linux/helper-function/bpf_map_lookup_elem/
위의 문서에서 확인할 수 있듯이, 함수에 따라 사용할 수 있는 eBPF 프로그램 타입이 정의된 것을 알 수 있는데요, 아래는 bpf_map_lookup_elem을 사용할 수 있는 프로그램 유형입니다.
Program types
This helper call can be used in the following program types:
BPF_PROG_TYPE_CGROUP_DEVICE
BPF_PROG_TYPE_CGROUP_SKB
BPF_PROG_TYPE_CGROUP_SOCK
BPF_PROG_TYPE_CGROUP_SOCKOPT
BPF_PROG_TYPE_CGROUP_SOCK_ADDR
BPF_PROG_TYPE_CGROUP_SYSCTL
BPF_PROG_TYPE_FLOW_DISSECTOR
BPF_PROG_TYPE_KPROBE
BPF_PROG_TYPE_LIRC_MODE2
BPF_PROG_TYPE_LSM
BPF_PROG_TYPE_LWT_IN
BPF_PROG_TYPE_LWT_OUT
BPF_PROG_TYPE_LWT_SEG6LOCAL
BPF_PROG_TYPE_LWT_XMIT
BPF_PROG_TYPE_NETFILTER
BPF_PROG_TYPE_PERF_EVENT
BPF_PROG_TYPE_RAW_TRACEPOINT
BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE
BPF_PROG_TYPE_SCHED_ACT
BPF_PROG_TYPE_SCHED_CLS
BPF_PROG_TYPE_SK_LOOKUP
BPF_PROG_TYPE_SK_MSG
BPF_PROG_TYPE_SK_REUSEPORT
BPF_PROG_TYPE_SK_SKB
BPF_PROG_TYPE_SOCKET_FILTER
BPF_PROG_TYPE_SOCK_OPS
BPF_PROG_TYPE_STRUCT_OPS
BPF_PROG_TYPE_SYSCALL
BPF_PROG_TYPE_TRACEPOINT
BPF_PROG_TYPE_TRACING
BPF_PROG_TYPE_XDP
우리가 정의한 eBPF 코드는 (BPF_PROG_TYPE_TRACEPOINT에 해당하는) tracepoint이므로,
SEC("tracepoint/syscalls/sys_enter_close")
int sys_enter_close(struct trace_event_raw_sys_enter *ctx) {
// ...[생략]...
}
bpf_map_lookup_elem을 사용하는데 문제가 없습니다. 하지만, 또 하나의 제약이 있는데요, 즉 bpf_map_lookup_elem을 사용할 수 있는 Map의 타입도 정해져 있다는 점입니다. 역시 문서에 나오듯이,
Map types
This helper call can be used with the following map types:
BPF_MAP_TYPE_ARRAY
BPF_MAP_TYPE_ARRAY_OF_MAPS
BPF_MAP_TYPE_HASH
BPF_MAP_TYPE_HASH_OF_MAPS
BPF_MAP_TYPE_LPM_TRIE
BPF_MAP_TYPE_LRU_HASH
BPF_MAP_TYPE_LRU_PERCPU_HASH
BPF_MAP_TYPE_PERCPU_ARRAY
BPF_MAP_TYPE_PERCPU_HASH
BPF_MAP_TYPE_SOCKHASH
BPF_MAP_TYPE_SOCKMAP
BPF_MAP_TYPE_XSKMAP
bpf_map_lookup_elem은 우리가 만든 BPF_MAP_TYPE_HASH를 지원하므로 코드에서 사용이 가능합니다. 그렇게 확인이 끝났으면, 이제 문서에 따라 다음과 같은 식으로 코드를 작성하면 됩니다.
SEC("tracepoint/syscalls/sys_enter_close")
int sys_enter_close(struct trace_event_raw_sys_enter *ctx) {
uint32_t key = 0;
uint32_t *arg_from_userspace = bpf_map_lookup_elem(&my_hash_map, &key);
uint32_t arg_value = 0;
if (arg_from_userspace) {
arg_value = *arg_from_userspace;
}
bpf_printk("sys_enter_close called: %d", arg_value);
return 0;
}
위의 코드는, 나중에 go 측에서 my_hash_map에 값을 쓰게 되면 lookup이 성공해 그 값을 화면에 로그로 찍어주는 역할을 합니다.
go 측에서도 연동을 해야겠죠? ^^ bpf2go 덕분에, 해당 Map도 자동 생성된 코드 안에 포함돼 다음과 같이 아주 간단하게 처리할 수 있습니다.
// ... [생략]...
var bpfObj ebpf_basicObjects
err = spec.LoadAndAssign(&bpfObj, &opts)
// ... [생략]...
go UpdateMap(bpfObj)
func UpdateMap(bpfObj ebpf_basicObjects) {
value := uint32(0)
for {
_ = bpfObj.MyHashMap.Update(uint32(0), &value, ebpf.UpdateAny)
value++
time.Sleep(1 * time.Second)
}
}
테스트를 위해 1초마다 증가시키도록 했으므로, 이제 실행해 보면 화면에 이런 식의 출력을 볼 수 있습니다.
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
...[생략]...
systemd-oomd-782 [004] ...21 239675.678467: bpf_trace_printk: sys_enter_close called: 0
systemd-oomd-782 [004] ...21 239675.928435: bpf_trace_printk: sys_enter_close called: 1
...[생략]...
참고로, bpfObj.MyHashMap.Update는 eBPF에서는
bpf_map_update_elem에 해당하는 함수입니다.
결국 BPF_MAP_TYPE_HASH 타입에 대한 CRUD 연산을 bpf_map_lookup_elem, bpf_map_update_elem,
bpf_map_delete_elem 등의 함수를 통해 수행할 수 있고, 위의 예제에서는 user space에서 커널로 데이터를 전달했지만 반대로 커널에서 user space로 데이터를 전달할 때도 사용할 수 있습니다.
cilium github 코드를 보면,
examples/kprobe/kprobe.c
; https://github.com/cilium/ebpf/blob/main/examples/kprobe/kprobe.c
아래와 같은 사용 예제가 있는데요,
//go:build ignore
#include "common.h"
char __license[] SEC("license") = "Dual MIT/GPL";
struct bpf_map_def SEC("maps") kprobe_map = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(u32),
.value_size = sizeof(u64),
.max_entries = 1,
};
SEC("kprobe/sys_execve")
int kprobe_execve() {
u32 key = 0;
u64 initval = 1, *valp;
valp = bpf_map_lookup_elem(&kprobe_map, &key);
if (!valp) {
bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
return 0;
}
__sync_fetch_and_add(valp, 1);
return 0;
}
값을 atomic하게 증가시켜 주는 __sync_fetch_and_add 함수 사용법을 익혀 두면 좋겠습니다. ^^
(이하, 오류 상황에 따른 해결 방법입니다.)
아래의 예제에 따라,
ebpf/testdata/loader.c
; https://github.com/cilium/ebpf/blob/main/testdata/loader.c#L8
SEC 매크로가 아닌 __section을 직접 지정해 보면,
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, uint32_t);
__type(value, uint32_t);
__uint(max_entries, 1);
} my_hash_map __section(".maps");
go generate로 자동 생성 코드를 만들 때 이런 오류가 발생합니다.
$ go generate
/mnt/c/temp/ebpf_sample/basic.c:22:2: error: expected ';' after struct
22 | } my_hash_map __section(".maps");
| ^
/mnt/c/temp/ebpf_sample/basic.c:22:3: error: unknown type name 'my_hash_map'
22 | } my_hash_map __section(".maps");
| ^
/mnt/c/temp/ebpf_sample/basic.c:22:25: error: expected parameter declarator
22 | } my_hash_map __section(".maps");
| ^
/mnt/c/temp/ebpf_sample/basic.c:22:25: error: expected ')'
/mnt/c/temp/ebpf_sample/basic.c:22:24: note: to match this '('
22 | } my_hash_map __section(".maps");
| ^
4 errors generated.
Error: compile: exit status 1
exit status 1
main.go:15: running "go": exit status 1
검색해 보면,
SEC 매크로는 "__attribute__((section("name"), used))"로 펼쳐진다고 합니다. 실제로 아래와 같이 정의하면 아무 문제 없이 빌드가 잘됩니다.
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, uint32_t);
__type(value, uint32_t);
__uint(max_entries, 1);
} my_hash_map __attribute__((section(".maps"), used));
본문에서 정의한 맵을 go 측에서 bpf_map_lookup_elem으로 이런 식으로 값을 받아오려고 하면,
for {
valueOut := 0
err := bpfObj.MyHashMap.Lookup(0, valueOut)
if err != nil {
fmt.Printf("MyHashMap: %v\n", err)
} else {
fmt.Printf("MyHashMap: %v\n", valueOut)
}
time.Sleep(1 * time.Second)
}
오류가 발생합니다.
can't marshal key: binary.Write: some values are not fixed-sized in type int
왜냐하면, key로 넘긴 '0' 리터럴 값이 uint32_t 타입이 아니기 때문입니다. 따라서 정확히 uint32 타입으로 지정해 전달해야만 해당 오류가 없어집니다.
err := bpfObj.MyHashMap.Lookup(uint32(0), valueOut)
하지만, 그래도 다음과 같은 오류가 이어서 발생합니다.
binary.Read: invalid type int
이번엔 valueOut의 타입이 문제인 건데요, 이것도 (map의 정의에서 uint32_t라고 지정했으므로) uint32로 명시해야 하지만,
valueOut := uint32(0)
err := bpfObj.MyHashMap.Lookup(uint32(0), valueOut)
// 에러: binary.Read: invalid type uint32
그래도 오류가 발생합니다. 비록 Lookup이 value의 타입을 interface{}로 받긴 해도 eBPF의 스타일(?)에 따라 valueOut은 포인터로 전달해야 합니다.
valueOut := uint32(0)
err := bpfObj.MyHashMap.Lookup(uint32(0), &valueOut)
아래와 같은 식으로 eBPF 코드를 작성하면,
uint32_t key = 0;
uint32_t *arg_from_userspace = bpf_map_lookup_elem(&my_hash_map, &key);
bpf_printk("sys_enter_close called: %d", *arg_from_userspace);
go 측에서 로딩 시 이런 오류가 발생합니다.
program sys_enter_close: load program: permission denied: 36: (61) r4 = *(u32 *)(r0 +0): R0 invalid mem access 'map_value_or_null' (43 line(s) omitted)
오류 메시지("map_value_or_null")에 나오듯이 arg_from_userspace가 null인 경우가 발생할 수 있으므로 이를 체크하는 코드를 넣어야만 합니다.
uint32_t *arg_from_userspace = bpf_map_lookup_elem(&my_hash_map, &key);
uint32_t arg_value = 0;
if (arg_from_userspace) {
arg_value = *arg_from_userspace;
}
bpf_printk("sys_enter_close called: %d", arg_value);
사실 이런 문제들은 런타임에 발생할 수 있는 것들인데요, 단지 저 코드가 kernel에서 실행되기 때문에 실제로 발생한다면 시스템 crash가 발생하는 식의 문제로 이어지게 됩니다. 따라서, eBPF Verifier가 이런 문제를 미리 사전에 체크해 로딩 시점에 차단해 버리는 것입니다.
참고로, libbpf + C/C++로 Map이 어떻게 다뤄지는지 아래의 글을 보면 도움이 될 것입니다.
eBPF 맵
; https://wariua.github.io/man-pages-ko/bpf%282%29/#ebpf
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]