eBPF 프로세스 exec 감시 — 실제로 걸린 것들

기초·개념6분조회

특정 프로그램이 서버에서 실행되면 알림을 받고 싶었습니다. ps 를 주기적으로 돌리는 방법이 먼저 떠오르지만, 1초 만에 떴다 사라지는 프로세스는 폴링 사이에 끼어서 안 보입니다.

결국 실행되는 순간을 잡아야 해서 eBPF 로 갔습니다.


eBPF — 커널에 검증된 코드를 붙이는 구조

커널에 작은 프로그램을 안전하게 끼워 넣고, 특정 사건이 일어날 때마다 그걸 실행시키는 기술입니다.

예전에는 커널 안에서 뭘 하려면 커널 모듈을 짜는 수밖에 없었는데, 모듈에 버그가 있으면 커널째로 죽어서 서버가 그대로 멈춥니다.

eBPF 는 이 위험을 검증기(verifier)로 막습니다. 프로그램을 커널에 올리기 전에 무한 루프가 없는지, 접근하면 안 되는 메모리를 건드리는지 전부 검사하고, 하나라도 걸리면 로드를 거부합니다. 통과한 프로그램만 커널에서 돌기 때문에 프로덕션 서버에도 붙일 수 있습니다.

대신 제약이 많아서 반복 횟수가 제한되고, 스택도 512바이트뿐이고, 쓸 수 있는 함수도 커널이 허용한 목록으로 정해져 있습니다.

sched_process_exec — 프로그램이 실행되는 순간

eBPF 프로그램은 혼자 돌지 않고, “언제 실행할지” 를 커널의 어떤 지점에 붙여줘야 합니다. 그 지점 중 하나가 tracepoint 입니다.

tracepoint 는 커널 개발자들이 관측용으로 미리 뚫어둔 자리입니다. 커널 코드 곳곳에 “여기서 이런 일이 일어났다”고 알려주는 표시가 박혀 있고, 평소에는 꺼져 있다가 누가 붙으면 켜집니다.

sched_process_exec 은 프로세스가 새 실행 파일로 전환될 때, 즉 execve 가 일어날 때 발생합니다. 여기에 붙여 두면 서버에서 뭐가 실행되든 그 순간에 eBPF 프로그램이 불리니, 폴링처럼 사이에 끼어 놓치는 일이 없습니다.

커널 → 유저 공간 — 링버퍼로 전달

커널 안에서는 알림을 보내거나 파일을 쓰는 것 같은 일을 할 수 없어서, “이런 일이 있었다” 는 사실만 밖으로 내보내고 실제 처리는 유저 공간 프로그램이 맡습니다.

그 통로로 쓰는 게 링버퍼인데, 커널이 이벤트를 넣으면 유저 공간에서 읽어 가는 구조라 읽는 쪽을 Go 로 만들었습니다.

CO-RE — 커널마다 재빌드 회피

eBPF 프로그램이 커널 구조체를 읽으려면 그 구조체가 메모리에 어떻게 놓여 있는지를 알아야 하는데, 이 배치가 커널 버전마다 달라집니다. 필드 하나가 추가되면 뒤쪽 필드 위치가 전부 밀리니, 예전에는 대상 서버마다 그 커널 헤더로 다시 컴파일하는 수밖에 없었습니다.

CO-RE(Compile Once – Run Everywhere)는 이걸 없앱니다. 요즘 커널은 자기 타입 정보를 BTF 라는 형식으로 들고 있습니다. eBPF 프로그램에 “이 필드가 필요하다”고만 적어두면, 로드하는 시점에 그 커널의 BTF 를 보고 실제 위치로 자동 교정됩니다.

vmlinux.h 는 그 BTF 를 C 헤더로 뽑아낸 파일이고, 빌드할 때 이게 필요합니다.

여기까지가 재료라, bpf2go 로 빌드하고 워치리스트에 있는 프로세스가 뜨면 스크립트를 실행하게 붙였습니다. 동작하는 상태까지 가면서 걸린 것들이 아래 다섯 가지입니다.


프로세스 이름 — 15자에서 잘림

커널이 들고 있는 프로세스 이름은 comm 이고, 길이가 TASK_COMM_LEN(16)으로 고정이라 NUL 을 빼면 쓸 수 있는 건 15자입니다.

그래서 워치리스트에 15자 넘는 이름을 적으면 커널이 보내주는 값과 문자열 비교가 계속 어긋나는데, 에러는 안 나고 이벤트만 안 옵니다.

로더에서 자르고 경고를 남기게 했습니다.

comm := filepath.Base(raw) // 경로가 들어와도 basename만 사용
if len(comm) > 15 {
    log.Printf("warn: line %d comm '%s' truncated to 15 chars (TASK_COMM_LEN)", lineNo, comm)
    comm = comm[:15]
}

comm 은 경로가 아니라 이름이라는 것도 같이 걸립니다. 워치리스트에 경로를 적어도 basename 만 쓰이므로 같은 이름의 다른 경로를 구분할 수 없습니다.

커널은 comm만 전달 — 경로는 유저 공간에서

tracepoint 인자에 실행 파일 경로가 있어서 커널에서 바로 읽으면 될 것 같지만 이렇게 하면 컴파일이 깨집니다.

error: no member named 'filename' in 'struct trace_event_raw_sched_process_exec'

tracepoint 컨텍스트 구조체는 커널 버전에 따라 필드가 다릅니다.

그래서 커널에서는 comm 으로 1차 필터링만 하고 이벤트를 링버퍼에 넣습니다. filename 필드는 구조체에 두되 커널에서 채우지 않습니다.

struct event {
    __u64 ts_ns;
    __u32 pid;
    __u32 uid;
    char  comm[TASK_COMM_LEN];      // 16 bytes
    char  filename[MAX_FILENAME];   // 커널에선 채우지 않음
};

SEC("tracepoint/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
    struct comm_key key = {};
    bpf_get_current_comm(&key.comm, sizeof(key.comm));

    // watchlist에 없으면 드랍
    if (!bpf_map_lookup_elem(&watchlist, &key))
        return 0;

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

    e->ts_ns = bpf_ktime_get_ns();
    e->pid   = bpf_get_current_pid_tgid() >> 32;
    e->uid   = bpf_get_current_uid_gid();
    __builtin_memcpy(e->comm, key.comm, sizeof(e->comm));

    bpf_ringbuf_submit(e, 0);
    return 0;
}

경로는 유저 공간에서 /proc/<pid>/exe 를 readlink 해서 채웁니다. 실행 중인 프로세스의 /proc/<pid>/exe 는 실제 실행 파일을 가리키는 심볼릭 링크입니다.

초단명 프로세스 — 경로 못 읽음

다만 이 방법에는 구멍이 하나 있는데, 이벤트를 받아 처리하는 동안 그 프로세스가 이미 끝나 있으면 /proc/<pid> 가 통째로 사라져서 읽을 게 없습니다.

짧게 여러 번 다시 시도하게 했습니다.

func getExePathWithRetry(pid uint32) string {
    for i := 0; i < 6; i++ { // ~48ms
        if p := fallbackExePath(pid); p != "" {
            return p
        }
        time.Sleep(8 * time.Millisecond)
    }
    return ""
}

경합 자체를 없애는 방법은 아니라서, 아주 짧게 살았다 죽는 프로세스는 여전히 경로가 빈 값으로 남습니다.

타임스탬프가 1970년 — 부팅 이후 경과 시간

bpf_ktime_get_ns() 가 돌려주는 값은 부팅 이후 경과 시간입니다. 유닉스 시각이 아닙니다. 부팅한 지 3시간 된 서버면 “3시간”에 해당하는 숫자가 나오고, 그걸 그대로 날짜로 해석하면 1970년 1월 1일 오전 3시가 됩니다.

유저 공간에서 오프셋을 한 번 구해 더해줘야 벽시계가 됩니다.

var wallOffsetNs int64

func initWallOffset() {
    var ts unix.Timespec
    if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts); err != nil {
        log.Printf("warn: CLOCK_MONOTONIC read failed: %v", err)
        return
    }
    wallOffsetNs = time.Now().UnixNano() - ts.Nano()
}

func toWallTime(tsNs uint64) time.Time {
    return time.Unix(0, int64(tsNs)+wallOffsetNs)
}

bpftool — 별도 패키지 아님

CO-RE 빌드에 필요한 vmlinux.hbpftool 로 만듭니다. 그런데 Ubuntu 에서 패키지 이름으로 찾으면 없습니다.

E: Package 'bpftool' has no installation candidate

커널 툴 묶음에 들어 있습니다.

$ sudo apt install linux-tools-$(uname -r)
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

vmlinux.h 는 커널 타입 정보라 대상 시스템마다 새로 뜨는 게 가장 안전합니다.

root 권한과 memlock

eBPF 프로그램 로드와 tracepoint 부착에는 root 가 필요합니다. memlock 제한도 걸립니다. systemd 유닛에 이렇게 넣었습니다.

[Service]
LimitMEMLOCK=infinity

남은 것

아직 링버퍼 드롭을 안 세고 있는 게 걸립니다. 프로세스가 폭주하면 유저 공간이 읽어가는 속도보다 커널이 넣는 속도가 빨라져 이벤트가 버려지는데, 지금 구조로는 버려졌다는 사실 자체를 알 수 없습니다. 맵에 드롭 카운터를 하나 두면 최소한 얼마나 버려졌는지는 남습니다.

  1. 불러오는 중