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

기초·개념7분조회 –

서버에서 특정 프로그램이 실행되면 알림을 받아야 할 일이 생겼는데, 지금 돌고 있는지가 아니라 언제 실행됐는지가 필요한 거라, 잠깐 떴다 사라진 것도 빠짐없이 잡혀야 합니다.

먼저 떠오르는 건 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.h 는 bpftool 로 만드는데, 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

이게 없으면 맵을 잡는 단계에서 operation not permitted 로 떨어집니다. 코드 문제로 보이지만 한도 문제입니다.

링버퍼 드롭을 안 세고 있음

아직 링버퍼 드롭을 세지 않는 게 걸립니다. 프로세스가 폭주하면 유저 공간이 읽어가는 속도보다 커널이 넣는 속도가 빨라져서 이벤트가 버려지고, 지금 구조로는 버려졌다는 사실 자체를 알 수 없습니다. 감시 도구에서 알림이 안 오면 아무 일도 없는 건지 놓치고 있는 건지 구분이 안 되니 맵에 드롭 카운터를 하나 두는 게 먼저입니다.

  1. 불러오는 중