40줄짜리 내 gadget이 Pod 이름을 받기까지

bocopile·2026년 8월 8일

Learning-eBPF

목록 보기
7/14
post-thumbnail

커널은 mount namespace 번호만 아는데, 그 숫자가 exec-loop이 되는 경로를 따라간다

1편에서 gadget 42개를 봤다. 카탈로그에 없는 걸 보려면 직접 만들어야 하는데, 그 예제로 chdir을 골랐다.
이유는 셋이다 — 42개 어디에도 없고, 인자가 경로 하나뿐이라 40줄로 끝나고,
그러면서도 exec·open과 똑같이 프로세스 컨텍스트를 타므로 이 글이 따라갈 이름 붙는 경로를 그대로 지난다.
관심사가 chdir 자체가 아니라 그 경로라서, 예제는 작을수록 좋았다.

그 40줄을 이미지로 구워, 번들 gadget과 같은 이미지 구조를 갖고 같은 이름 enrichment를 받는지 확인한다.
받는다면 그 "대우"가 정확히 무엇이고 누가 해주는지도 따라간다.
1편은 질문 셋을 남겼다. 이름의 출처, 0건이 만들어지는 자리, 직접 만들 수 있는지.
셋 다 이 글에서 답하는데, 앞의 둘은 같은 자리에서 풀린다.

이 글의 범위

1편을 읽었거나 ig run으로 gadget을 한 번 돌려본 사람을 상정했다. C를 읽을 수 있으면 좋지만 못 써도 된다 —
예제는 40줄이다. 다 읽고 나면 gadget을 만들어 이미지로 굽고 ig 단독으로 실행할 수 있고,
enrichment가 어디서 어떻게 붙는지 말할 수 있다. eBPF 프로그램 작성법 자체는 다루지 않는다.

확인 범위를 미리 그어둔다. 직접 만든 gadget은 ig 단독 실행까지만 돌려봤다.
레지스트리에 push해 kubectl gadget으로 돌리는 데까지는 가지 못했고, 그래서 클러스터 절의 출력은
전부 번들 gadget의 것이다. 그 경계는 「어디까지가 CRI이고 어디부터 API인가」에서 정확히 긋는다.
근거를 세 층으로 나눠 어미로 구분하는 규칙은 1편과 같다.

확인한 환경

1편과 같다. lima VM 1대(Ubuntu 26.04, aarch64) 단일 노드, ig v0.54.0으로 확인했다. 확인일 2026-08-03.
번들 gadget은 1편과 마찬가지로 확인일의 :latest(v0.54.1 빌드)이고, 지금 같은 명령을 치면 다른 이미지가 온다.
따로 밝히지 않으면 k3s가 떠 있는 상태의 출력이다.
관측 대상 Pod exec-loop·net-loop도 1편에서 띄운 그대로다
(kubectl run 두 줄은 1편 「찍힌 한 줄을 뜯어본다 — trace_exec」에 있다).
아래 ig run은 전부 별도 터미널에 띄워둔 채 다른 터미널에서 kubectl exec을 쳤다.

1편은 --containerd-socketpath를 빼면 k3s Pod가 아무리 바빠도 화면이 빈다고만 하고 넘어갔다.
왜 오류도 경고도 없이 0건이 되는지는 「빈 화면은 왜 조용한가」에서 답한다 —
이 글의 중심 질문과 같은 자리에서 풀리기 때문이다.
그때까지 아래 ig run에는 전부 이 플래그가 붙어 있다.

40줄로 gadget 하나를 만든다

번들 gadget 중 trace_signal을 본떴다. 전체가 이것뿐이다.

// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>

#include <gadget/buffer.h>
#include <gadget/common.h>
#include <gadget/filter.h>
#include <gadget/macros.h>
#include <gadget/types.h>

#define GADGET_PATH_MAX 512

struct event {
	gadget_timestamp timestamp_raw;
	struct gadget_process proc;
	char path[GADGET_PATH_MAX];
};

GADGET_TRACER_MAP(events, 1024 * 256);
GADGET_TRACER(chdir, events, event);

SEC("tracepoint/syscalls/sys_enter_chdir")
int ig_chdir_e(struct syscall_trace_enter *ctx)
{
	struct event *eventp;
	const char *pathname = (const char *)ctx->args[0];

	if (gadget_should_discard_data_current())
		return 0;

	eventp = gadget_reserve_buf(&events, sizeof(*eventp));
	if (!eventp)
		return 0;

	eventp->timestamp_raw = bpf_ktime_get_boot_ns();
	gadget_process_populate(&eventp->proc);
	bpf_probe_read_user_str(&eventp->path, sizeof(eventp->path), pathname);

	gadget_submit_buf(ctx, &events, eventp, sizeof(*eventp));
	return 0;
}

char LICENSE[] SEC("license") = "GPL";

SEC("tracepoint/syscalls/sys_enter_chdir")는 3장에서 본 관례 그대로다.
gadget이라고 별도 문법이 있는 게 아니다.

프레임워크가 얹은 건 매크로 둘과 헬퍼 셋이다.

이름하는 일
GADGET_TRACER_MAP(events, …)이벤트를 내보낼 ring buffer를 만든다
GADGET_TRACER(chdir, events, event)이 map이 chdir이라는 datasource이고 타입이 struct event임을 선언한다
gadget_reserve_buf / gadget_submit_bufring buffer에 자리를 잡고 채워서 보낸다
gadget_process_populatepid·comm과 mount namespace 번호를 한 번에 채운다
gadget_should_discard_data_current이 이벤트를 버려야 하는지 묻는다

마지막 둘이 이 글의 축이다. gadget_process_populate가 채우는 번호가 나중에 컨테이너 이름으로 풀릴 재료이고,
gadget_should_discard_data_current가 1편의 "기본은 컨테이너만"을 내 프로그램에도 적용한다.
내가 짠 코드가 아니라 include한 헤더가 그 규칙을 가져온다.

표는 헤더를 읽고 만들었는데, 공식 eBPF API 문서에 같은 것이 정리돼 있다 — 「Container enrichment」와
「Event filtering」 절이 이 둘을 그대로 다룬다
(gadget-devel/gadget-ebpf-api).
처음부터 만들 거라면 공식 튜토리얼이
hello-world-gadget에 있다.

메타데이터는 코드에서 나온다

1편에서 출력 열을 정하는 gadget.yaml을 봤다. 직접 쓸 필요는 없다.

sudo ig image build . -t trace_chdir:v1 --local --update-metadata

--update-metadata가 eBPF 코드를 읽어 뼈대를 만든다.

name: 'TODO: Fill the gadget name'
datasources:
  chdir:
    fields:
      path:          { annotations: { description: 'TODO: Fill field description' } }
      proc:          { annotations: { description: 'TODO: Fill field description' } }
      timestamp_raw: { annotations: { description: 'TODO: Fill field description' } }
params:
  ebpf:
    targ_comm: { key: targ_comm, defaultValue: "" }
    targ_gid:  { key: targ_gid,  defaultValue: "" }
    targ_pid:  { key: targ_pid,  defaultValue: "" }
    targ_tid:  { key: targ_tid,  defaultValue: "" }
    targ_uid:  { key: targ_uid,  defaultValue: "" }

각 항목의 description 줄은 접었다. 전문은 실습 저장소에 있다 —
gadgets/trace_chdir/gadget.yaml.

datasources.chdir.fields는 내가 struct event에 쓴 셋을 그대로 옮겨 왔다. 설명만 채우면 되고,
열 이름·폭은 안 써도 기본값으로 돈다.

그중 timestamp_raw는 값이 하나 더 붙는다. 내가 넣은 건 bpf_ktime_get_boot_ns()가 준 정수 하나뿐인데,
타입을 gadget_timestamp로 선언했기 때문에 formatters가 사람이 읽는 timestamp 필드를 새로 만든다.
_raw 접미사를 떼서 새 필드 이름을 짓는 규칙까지 문서에 적혀 있다
(gadget-devel/gadget-ebpf-api의
gadget_timestamp 절에 -o json 예시가 있다).
타입 이름 하나가 필드 하나를 더 만든다. 아래 params도 같은 얘기다.

params는 내가 쓴 적 없는 것들이다. <gadget/filter.h>를 include한 것만으로
targ_pid·targ_tid·targ_uid·targ_gid·targ_comm 필터가 딸려왔다.
헤더 하나가 파라미터 다섯을 가져온다(include/gadget/filter.h:21-34의 GADGET_PARAM 다섯 개).
1편에서 본 --paths 같은 플래그도 이 경로로 만들어진다.

40줄이 싼 게 아니라 빌드 환경이 비싸다

C 코드가 40줄이면 굽는 것도 그만큼 간단하리라 봤다. 실제로 명령은 한 줄이다.
그런데 앞 절의 그 한 줄에는 --local이 붙어 있었고, 그게 편의 옵션이 아니다.

ig image build는 기본적으로 빌더 컨테이너를 띄우고
Docker 소켓을 찾는데, 이 VM에는 containerd만 있어서 표준 경로가 아예 막힌다.
--local은 대신 호스트의 툴체인으로 clang -target bpf …와 llvm-strip -g를 직접 돌린다
(cmd/common/image/build-step.go:67-92). 그러면 빌더 이미지가 들고 있던 것을 손으로 갖춰야 한다 —
clang·llvm, libbpf 헤더, gadget 헤더, 그리고 아키텍처별 vmlinux.h.
문서도 같은 목록을 요구사항으로 적어뒀다
(gadget-devel/building의 「Toolchain location」).

여기가 "40줄"이라는 숫자가 감추는 부분이다. C 코드는 40줄이 맞지만, 그게 이미지가 되려면
빌더 컨테이너 하나 또는 손으로 갖춘 크로스컴파일 툴체인 하나가 필요하다.
Docker가 있는 호스트라면 --local 없이 한 줄로 끝나고, 이 VM처럼 containerd만 있으면 준비가 따로 붙는다.
"gadget을 직접 만들 수 있는가"의 답은 코드 쪽이 아니라 이쪽에서 갈린다.

그때 딸려오는 /usr/include/gadget/amd64/vmlinux.h와 arm64/vmlinux.h가 아래에서 한 번 더 쓰인다 —
aarch64 노드인데 amd64 오브젝트까지 나오는 이유가 이것이다.

준비를 갖추고 나면 이미지는 나온다. ig가 찍어주는 이름이
ghcr.io/inspektor-gadget/gadget/trace_chdir:v1@sha256:c9e1ff7d…인데, 이 digest가 곧 다시 나온다.
레이어를 열어 번들 trace_exec과 나란히 놓으면 구조가 같다.

trace_exec (번들)trace_chdir (이 글에서 만든 것)
configvnd.gadget.config.v1+yaml 5,774 B같은 타입 1,122 B
eBPFvnd.gadget.ebpf.program.v1+binary 1,631,128 B같은 타입 48,880 B
WASMvnd.gadget.wasm.program.v1+binary 2,619,126 B없음
아키텍처amd64 + arm64amd64 + arm64
서명.sig manifest 동봉없음

media type이 같다. WASM 레이어는 선택 사항이다 — 스펙도 최대 1개라고만 적고
(docs/spec/oci.md), --update-metadata로 구운 이 이미지에는 붙지 않았다.
그 레이어가 맡는 사용자공간 후처리(필드 조작·가상 필드·이벤트 드롭)는 1편 「레이어 셋, 처리기 셋」에서 이미 봤다.
이 예제에는 그런 후처리가 없다. 다만 번들 trace_exec의 2.6 MB가 실제로 무엇을 하는지는 열어보지 않았으므로,
"후처리가 필요 없으면 안 넣는다"까지는 말하지 않는다.

eBPF 레이어가 33배 작은 건 trace_exec이 argv를 통째로 담고 경로를 해석하는 프로그램이 여섯 개인 데 비해
이쪽은 프로그램 하나에 필드 셋뿐이라 그렇다. 구조가 같으면 크기는 문제가 아니다 —
ig가 이 이미지를 다루는 방식은 번들과 같다.

로컬에만 있는 이미지에 왜 원격 조회가 걸리나

이미지는 --local로 구웠고 레지스트리에 올린 적이 없다. 그러니 네트워크를 탈 일도 없겠거니 했다.
만든 gadget을 그냥 돌리면 실행이 아니라 오류가 난다.

Error: verifying image: pulling gadget signature "trace_chdir:v1":
  GET "https://ghcr.io/v2/inspektor-gadget/gadget/trace_chdir/manifests/sha256-c9e1ff7d….sig":
  response status code 403: denied

정확히는 "서명이 틀렸다"가 아니라 "서명 manifest를 읽지 못했다"이다. ig가 기본 저장소 이름 규칙대로
ghcr.io에 가서 이 이미지의 .sig를 찾았는데 접근이 거부됐다.
결과는 같다 — 검증을 통과하지 못하면 실행하지 않는다.

메시지는 짚어둘 만하다. 이 이미지는 --local로 구웠고 레지스트리에 올린 적이 없다.
그런데 ig는 이름 규칙만 보고 ghcr.io에 조회를 걸었다. 로컬에만 있는 이미지에 원격 조회를 시도한 셈이고,
그래서 오류가 네트워크나 권한 문제처럼 읽힌다. 실제 상황은 "이 이미지에는 서명이 없다"인데
그 문장은 끝내 나오지 않는다. 직접 만든 gadget을 처음 돌려보는 사람이 여기서 한 번 헤맨다.

--verify-image     Verify image using the provided public key (default true)
--public-keys []   Public keys used to verify the gadgets with cosign (default -----BEGIN PUBLIC KEY-----…)

기본 설정에서 ig는 검증을 통과한 gadget만 실행한다. 기본 공개키가 프로젝트 것이므로,
따로 키를 넣지 않으면 사실상 프로젝트가 서명한 것만 통과한다. 끄려면 명시해야 하고, 끄면 경고가 뜬다.

sudo ig run trace_chdir:v1 --verify-image=false \
  --containerd-socketpath /run/k3s/containerd/containerd.sock -o columns
level=warning msg="gadget signature verification is disabled due to using corresponding option"

소켓 플래그가 또 붙어 있다. 빼면 ig가 k3s 컨테이너를 하나도 못 보므로
mount namespace 필터가 전부 걸러 헤더만 나온다 — 그 이유는 「필터도 같은 표에서 나온다」에서 본다.

직접 만든 gadget을 돌리는 경로는 결국 둘 중 하나다 — 검증을 끄거나, 자체 키로 서명하고
--public-keys에 넣거나. 이번에는 앞쪽으로 갔다. 뒤쪽은 실행해보지 않았지만 절차는 문서에 있다 —
내 키와 프로젝트 키를 쉼표로 함께 넣으면 둘 다 통과시킬 수 있다
(reference/verify-gadgets).

--verify-image=false는 이 글처럼 버려도 되는 실습 VM에서만 쓸 것을 전제한다. gadget 이미지는
커널에 eBPF 프로그램을 올린다. 검증을 끄면 그 자리에 무엇이 올라가는지 확인하는 단계가 통째로 빠지고,
ig가 경고를 찍는 것도 그래서다. 실제로 쓸 곳이라면 기본 경로는 뒤쪽 — 자체 서명이다.

내 코드엔 컨테이너 얘기가 한 줄도 없는데

앞의 40줄을 다시 보면 컨테이너라는 단어가 없다. chdir tracepoint에 붙어 pid·comm·경로를 담을 뿐이다.
번들 gadget이 컨테이너 이름을 내는 건 프레임워크가 그것들을 특별히 알기 때문이라고 보는 게 자연스럽다.
그렇다면 내 것에는 그 열이 비어야 한다.

검증을 끄고 돌린 뒤 Pod 안에서 디렉터리를 옮겨봤다.

kubectl exec exec-loop -- sh -c "cd /tmp; cd /var"
kubectl exec net-loop  -- sh -c "cd /etc"
RUNTIME.CONTAINERNAME   COMM             PID      PATH
exec-loop               runc:[2:INIT]    179117   /
exec-loop               sh               179117   /tmp
exec-loop               sh               179117   /var
net-loop                sh               179150   /etc

RUNTIME.CONTAINERNAME이 채워졌다. 내 코드에는 컨테이너에 관한 문장이 한 줄도 없다.

컨테이너 이름만이 아니다. -o json으로 보면 Pod 이름까지 들어 있다.

"k8s": {
"containerName": "exec-loop",
"namespace": "default",
"podName": "exec-loop",
"node": "",
"owner": { "kind": "", "name": "" },
"podLabels": ""
}

40줄짜리 내 gadget이 podName을 낸다. Kubernetes에 물어본 적도 없는데 그렇다.
비어 있는 node·owner가 뭘 뜻하는지는 뒤에서 본다.

덤으로 runc:[2:INIT]가 /로 chdir하는 게 보인다. kubectl exec이 기존 컨테이너에 붙을 때
runc의 stage-2 init이 컨테이너 루트로 들어가는 순간이다 — 바로 아래 sh와 PID가 179117로 같다.
그 init이 sh로 exec된 것이다.
카탈로그 42개에 없던 시야다.

여기까지가 ig 단독 실행에서 확인한 것이다. 이름이 어디서 왔는지가 다음 질문이다.

그 이름은 어디서 오는가

커널의 chdir tracepoint는 컨테이너를 모른다. 내 프로그램이 이벤트에 넣은 건
gadget_process_populate가 채운 값들 — PID·TID·UID·GID·comm, 그리고 mount namespace의 inode 번호 — 뿐이다.
그중 컨테이너 정체성으로 이어지는 키가 mount namespace 번호다.

이름을 붙이는 건 사용자 공간이다. ig는 컨테이너 목록을 유지하면서
mount namespace 번호로 색인한 표를 들고 있다.
이 환경에서 그 목록의 주 정보원은 CRI 소켓이고, ig는 그 밖에도 OCI config와
fanotify 기반 감지를 함께 쓴다(localmanager.go의 WithOCIConfigEnrichment, WithContainerFanotifyEbpf).

// pkg/container-collection/container-collection.go:269
func (cc *ContainerCollection) LookupContainerByMntns(mntnsid uint64) *Container {
	container, ok := cc.containersByMntNs.Load(mntnsid)
	...
}

이벤트가 올라오면 그 표를 찾아 값을 옮겨 담는다.

// pkg/container-collection/operator.go:21
func (cc *ContainerCollection) EnrichEventByMntNs(event operators.ContainerInfoFromMountNSID) bool {
	event.SetNode(cc.nodeName)

	mountNsId := event.GetMountNSID()
	container := cc.LookupContainerByMntns(mountNsId)
	if container == nil && cc.cachedContainers != nil {
		container = lookupContainerByMntns(cc.cachedContainers, mountNsId)
	}
	if container != nil {
		event.SetContainerMetadata(container)
	}
	return container != nil
}

이 함수가 이벤트에 붙는 지점은 localmanager의 PreStart다
(pkg/operators/localmanager/localmanager.go:623-630의 compat.Subscribe).
SetContainerMetadata가 실제로 필드를 채우는 코드는 pkg/datasource/compat/wrapper.go:366-427에 있다.

커널이 준 숫자 하나를 키로 삼아 사용자 공간의 표에서 나머지를 가져온다. 그게 전부다.
내 gadget이 특별한 대우를 받은 게 아니라, gadget_process_populate가 그 숫자를 넣어줬기 때문에
같은 표를 탈 수 있었던 것이다.

빈 화면은 왜 조용한가

1편이 남긴 숙제가 여기서 풀린다. 플래그 하나로 결과가 이렇게 갈렸다.
소켓을 잘못 골랐다면 어딘가 그 말이 나올 것이고, 안 나오더라도 -v를 켜면 보이리라 봤다.

실행이벤트
ig run trace_exec0건
ig run trace_exec --containerd-socketpath /run/k3s/containerd/containerd.sock30건

k3s는 자체 containerd를 /run/k3s/containerd/containerd.sock에 띄우는데
ig의 기본값은 /run/containerd/containerd.sock이다. 소켓이 틀리면 컨테이너 목록을 못 받고,
그러면 방금 본 그 표가 빈다. 표가 비면 색인도 비고, 다음 절에서 볼 필터 map도 빈다.
eBPF 프로그램은 정상이고 커널도 정상인데 이름 붙일 대상이 없는 상태다.

남은 건 왜 아무 말도 없느냐다. 오류도 경고도 뜨지 않는다.
이건 우연이 아니라 코드에 그렇게 쓰여 있다.

// pkg/operators/localmanager/localmanager.go:279
if !log.IsLevelEnabled(log.DebugLevel) && isDefaultContainerRuntimeConfig(rc) {
	warnings := []...{containercollection.WithDisableContainerRuntimeWarnings()}
	ccOpts = append(ccOpts, warnings...)
}

런타임 설정이 기본값이고 디버그 로그가 꺼져 있으면 컨테이너 런타임 경고를 끈다.
ig는 기본적으로 docker·containerd·CRI-O·podman 넷을 다 시도하는데,
어느 호스트든 대부분은 없으니 그 경고가 소음이 되기 때문이다.

-v를 붙이면 나온다.

level=debug msg="Ignoring runtime \"cri-o\" with non-existent socketPath \"/run/crio/crio.sock\""
level=debug msg="Ignoring runtime \"docker\" with non-existent socketPath \"/run/docker.sock\""
level=debug msg="Ignoring runtime \"podman\" with non-existent socketPath \"/run/podman/podman.sock\""

그런데 containerd는 이 목록에 없다. /run/containerd/containerd.sock이 실제로 있어서
연결에 성공했기 때문이다. 다만 그 containerd에는 k3s 컨테이너가 없다.
그래서 -v를 켜도 "소켓을 잘못 골랐다"는 말은 끝내 나오지 않는다.
ig가 볼 수 있는 건 "연결됐고 컨테이너가 0개"까지다.

빈 화면을 보면 -v부터 붙여보는 게 맞다. 다만 이 경우처럼 엉뚱한 런타임에 정상 연결된 상황은
-v로도 안 잡힌다. 그때 남는 건 헤더 한 줄뿐이고, 그게 컨테이너가 없는 정상 상태와 구분되지 않는다.

필터도 같은 표에서 나온다

1편에서 컨테이너가 없으면 이벤트가 0건이라고 했다. 프로그램은 올라가 있었다. 어디서 버려지는가.

커널이다. gadget 헤더에 필터가 들어 있다.

// include/gadget/mntns_filter.h
static __always_inline bool gadget_should_discard_mntns_id(gadget_mntns_id mntns_id)
{
	return gadget_filter_by_mntns &&
	       !bpf_map_lookup_elem(&gadget_mntns_filter_map, &mntns_id);
}

컨테이너의 mount namespace 번호가 map에 없으면 이벤트를 버린다.
내가 쓴 gadget_should_discard_data_current()가 이걸 부른다.

map을 채우는 게 아니라 갈아끼운다. ig는 실행할 때 mntnsset_<UUID>라는 map을 새로 만들어
gadget에 주입한다. 접두사는 tracer-collection.go:30의 MountMapPrefix이고 뒤에 붙는 건
localmanager.go:632의 uuid.New()다. ELF에 선언된 gadget_mntns_filter_map은 쓰이지 않는다.

갈아끼우는 코드는 eBPF operator에 있다. 주입된 map을 받아 ELF 쪽 같은 이름 map과 호환되는지 확인한 뒤
mapReplacements에 넣고(pkg/operators/ebpf/ebpf.go:795-800),
그 map으로 컬렉션을 로드한다(:760의 ebpf.CollectionOptions{MapReplacements: …}).

주입 여부는 --host가 가른다 — localmanager.go:638의 if !host가 그 map 자체를 안 만든다.
bpftool map list로 세어보면 그대로다. 기본에서는 mntnsset_*이 하나 뜨고 ELF가 선언한 쪽은 안 보이며,
--host에서는 반대다(gadget_mntns_fi로 잘려 보이는데, 오탈자가 아니라 커널이 map 이름을
15자로 자른 것이다). --host일 때 남는 그 하나는 선언대로 만들어졌을 뿐 필터로 쓰이지 않는다.

주입된 map 내용을 뜨면 「그 이름은 어디서 오는가」의 표와 같은 숫자가 들어 있다.

key: bc 02 00 f0 00 00 00 00  value: 01 00 00 00
Found 7 elements

리틀엔디언이라 0xf00002bc = 4026532540.
exec-loop 컨테이너 프로세스의 /proc/<pid>/ns/mnt가 mnt:[4026532540]으로 정확히 같다.
엔트리는 7개였다. 이 시점에 노드에서 돌던 컨테이너(k3s 시스템 Pod와 테스트 Pod)의 수와 같은 규모인데,
하나씩 대조해보지는 않았다.

같은 컨테이너 목록이 두 곳에 쓰인다. 사용자 공간에서는 이름을 붙이는 표로, 커널에서는 거르는 map으로.
1장에서 map을 "커널과 유저 공간이 데이터를 나누는 곳"으로 배웠는데,
여기서는 유저 공간이 커널에 정책을 주입하는 방향으로 쓴다.

세 절에 걸쳐 따라온 경로를 한 장으로 줄이면 이렇다.

CC 하나에서 실선은 이름을 붙이러 올라가고 점선은 커널로 내려가 거른다.
소켓이 틀리면 CC가 비고, 그러면 두 화살표가 동시에 죽는다. 앞 절의 "조용한 빈 화면"이 그것이다.

그 map은 ig 프로세스와 함께 사라진다. ig를 띄우기 전, 띄운 동안, 끝낸 뒤로 커널의 프로그램 수를 세면
57 → 68 → 57로 돌아온다. 늘어난 11개는 trace_exec 6개와 ig 자신의 컨테이너 감지 훅 5개다.
ig는 /sys/fs/bpf에 pin하지 않으므로 마지막 참조가 닫힐 때 커널이 회수한다
(4장의 FD 수명 그대로다).
정리 부담이 없어진 대신 ig가 살아 있어야만 관측이 유지된다.

클러스터에 올리면 무엇이 바뀌나

여기서부터는 번들 gadget으로 돌아간다. kubectl gadget은 DaemonSet을 배포한다.

$ kubectl gadget deploy
Creating Namespace/gadget...
Creating DaemonSet/gadget...
1/1 gadget pod(s) ready

올리기 전에 이 DaemonSet이 무엇을 요구하는지는 보고 가는 게 낫다. 매니페스트는 privileged가 아니다 —
hostPID·hostNetwork가 false, readOnlyRootFilesystem: true이고 capability도 drop: ALL 뒤에
일곱 개만 되돌린다(pkg/resources/manifests/deploy.yaml:171-172,221,231-286).
그것만 보면 오도된다. 같은 파일이 AppArmor를 unconfined로 두고(:164) SELinux 타입을 spc_t로 지정하며(:229)
/host/etc와 /host/opt를 쓰기 가능으로 마운트한다(:295,299). 결과는 주석이 직접 적어뒀다 —
gadget pod에서 touch /host/proc/1/root/foobar를 치면 호스트 파일시스템에 쓰인다(:310-316).
감사 결과가 아니라 매니페스트에 적힌 현행 동작이고, 클러스터에 올릴지 정할 때 실제로 물어야 할 쪽이다.

앞 절에서 gadget 이미지를 당기고 검증하던 주체가 여기서 바뀐다. kubectl gadget run으로 돌리면
이미지를 받는 쪽은 내 노트북이 아니라 노드의 gadget pod이다. 그래서 --verify-image 같은
실행 시점 플래그가 아니라 배포 시점의 --daemon-config(operator.oci.verify-image)가 그 자리를 맡는다.
DaemonSet 쪽 동작은 확인하지 못했다.

같은 trace_exec을 돌리면 열이 다르다.

$ kubectl gadget run trace_exec:latest -o columns
K8S.NODE    K8S.NAMESPACE   K8S.PODNAME   K8S.CONTAINERNAME   COMM       PID
bokhoshin   default         exec-loop     exec-loop           id         155457
bokhoshin   default         net-loop      net-loop            nslookup   155459

--containerd-socketpath를 안 붙였다. DaemonSet은 노드 위에서 돌면서 런타임을 찾는다.
이 환경에서는 그랬다 — 모든 배포판에서 자동 탐지가 되는지는 확인하지 않았다.

Pod와 네임스페이스로 좁히는 것도 여기서 된다.

kubectl gadget run trace_exec:latest -p net-loop
kubectl gadget run trace_dns:latest  -n kube-system

내릴 때는 kubectl gadget undeploy인데 네임스페이스는 남는다. --delete-namespace를 붙여야 지워진다
(cmd/kubectl-gadget/undeploy.go:78-79,242).

열만 바뀌는 게 아니다. trace_dns를 kubectl gadget으로 돌리면 두 편에서 가장 눈에 띄는 변화가 나온다.

두 방식을 동시에 띄워놓고 같은 질의 하나를 잡았다. 출발 포트가 51415로 같아서 같은 이벤트임을 알 수 있다.

ig 단독:

SRC 10.42.0.10:51415   DST 10.43.0.10:53

kubectl gadget:

SRC p/default/net-loop:51415   DST s/kube-system/kube-dns:53

IP가 사라지고 참조가 들어왔다. p/는 Pod, s/는 Service다.
10.43.0.10은 Pod가 아니라 kube-dns 서비스의 ClusterIP였고, 그래서 s/kube-system/kube-dns로 풀린다.
1편에서 이름만 언급한 kubeipresolver가 한 일이다.

앞서 본 mntns 표가 이벤트를 낸 쪽을 풀었다면, 이건 패킷 주소를 푼다. 정보원이 다르다.

DNS 문제를 볼 때 어느 Pod가 어느 서비스에 물었는지를 IP 표를 뒤지지 않고 읽는다.
Pod가 재시작하면 IP도 이름도 바뀌지만, 적어도 이 출력은 그 시점에 누가 누구에게 물었는지를 그대로 말해준다.

어디까지가 CRI이고 어디부터 API인가

앞에서 본 JSON을 다시 보면, podName과 namespace는 채워졌는데
node·owner·podLabels는 비어 있었다.

「그 이름은 어디서 오는가」의 EnrichEventByMntNs를 보면 이유가 보인다.
event.SetNode(cc.nodeName)은 조건 없이 실행된다 —
ig 단독에서는 cc.nodeName 자체가 비어 있어서 빈 값이 들어간 것이다.
podLabels도 코드상 복사 대상이지만 원본 Container에 없으면 빈 채로 남는다.

그래서 경계는 "ig 대 kubectl gadget"이 아니다. localmanager에도 스위치가 있다.

// pkg/operators/localmanager/localmanager.go:143
Key:          EnrichWithK8sApiserver,   // "enrich-with-k8s-apiserver"
DefaultValue: "false",
Description:  "Connect to the K8s API server to get further K8s enrichment",

정확한 경계는 정보원이다.

정보원이번 환경에서 채워진 필드
컨테이너 목록 (주로 CRI 소켓, OCI config·fanotify 병행)podName, namespace, containerName, containerId, 이미지 이름·digest
Kubernetes API 연결node, owner(Deployment 등), podLabels

공식 스펙도 같은 선을 긋는다. localmanager가 런타임에서 얻는 k8s 필드를 나열하면서
owner에만 (only when using enrich-with-k8s-apiserver)를 붙여뒀다
(spec/operators/localmanager).

podLabels만 두 줄에 걸쳐 있다 — API 경로에서도 채워지고, --runtime-protocol을 cri로 두면
CRI 소켓만으로도 채워진다. 이번에 비었던 건 그 값이 internal 기본값이라서다
(localmanager.go:138-139).

아래 칸이 비었다고 해서 ig 단독이 영영 못 얻는다는 뜻은 아니다.
localmanager에 스위치가 있고, 다만 조건이 붙는다.

// pkg/operators/localmanager/localmanager.go:301
if enrichWithK8s {
	nodeName := os.Getenv("NODE_NAME")
	if nodeName == "" {
		return errors.New("NODE_NAME environment variable is not set, cannot enrich with K8s API server")
	}
	...
}

--enrich-with-k8s-apiserver를 켜려면 NODE_NAME 환경변수가 있어야 하고, API 접근 권한도 필요하다.
kubectl gadget의 DaemonSet은 그 조건을 갖춘 구성이다.
ig 단독에서 이 플래그를 켜보지는 않았다 — 조건을 갖추면 될 것으로 보이나 확인 필요다.

이 trace_chdir을 kubectl gadget으로 돌리면 같은 enrichment를 받는지도 확인하지 못했다.
DaemonSet이 접근할 레지스트리에 push해야 하는데 거기까지 가지 않았다.
따라서 "번들과 같은 대우"라고 확인한 범위는 ig 단독의 컨테이너 이름까지다.

이벤트가 사라지는 자리가 하나 더 있다

여기까지 "이벤트가 안 보인다"의 원인은 줄곧 하나였다 — 필터 map에 없어서 커널에서 버려지는 것.
소켓을 잘못 골라 컨테이너 목록이 비는 경우도 결국 그 map이 비어서 같은 자리에서 끝났다.
자리가 하나 더 있다. 링버퍼다. 이쪽은 커널을 통과해 올라오다가 사라진다.

링버퍼 하나를 관측 대상 컨테이너들이 나눠 쓴다. 크기도 전부 같다 —
v0.54.0 번들의 gadgets/*/program.bpf.c를 세면 GADGET_TRACER_MAP 호출이 21개인데,
한 파일에 둘 이상 있는 경우가 없고 크기도 하나같이 1024 * 256, 즉 256 KB다.
프레임워크가 하나로 제한하는 건 아니고 번들이 그렇게 돼 있다.
위에서 만든 것도 그 줄을 그대로 베껴 썼다.

넘치면 어떻게 되는지는 소스에 답이 있다. pkg/operators/ebpf/tracer.go:296-301이 <map>_lost_samples map을
찾아 붙이고, 실제로 유실이 나면 :203-205가 경고 로그와 ds.ReportLostData(lost)를 낸다.
앞의 조용한 빈 화면과는 성격이 다르다 — 그쪽은 아무 말이 없었고, 이쪽은 말을 한다.

실제로 유실을 일으켜보지는 않았다. 소스로 확인한 건 버퍼가 공유된다는 구조와 유실 시 경고가 난다는 것까지다.

언제 쓰고 언제 bpftrace로 충분한가

두 편에서 확인한 구조에서 나오는 구분이다.

고를 이유:

  • Pod 단위로 좁혀야 할 때. 커널의 필터 map을 컨테이너 목록이 자동으로 채운다 — 앞에서 본 그 표다.
    bpftrace도 프로브 안에서 거를 수는 있지만, 무엇으로 거를지는 직접 이어붙여야 한다
    (IG 쪽도 프로그램은 일단 실행된 뒤 map을 조회해 버리는 방식이라, 훅 자체가 없어지는 건 아니다)
  • 노드 여러 대에 같은 것이 올라갔다고 말해야 할 때. digest로 고정하면 그 말이 성립한다
  • 관측 대상에 아무것도 설치할 수 없을 때. 컨테이너 안에는 손대지 않는다
  • IP를 Pod 이름으로 읽어야 할 때. 대안이 마땅치 않다
  • 카탈로그에 없는 걸 봐야 할 때. 이 글이 그 경우였다

과할 때:

  • 노드 한 대에서 한 번 확인하고 끝날 질문. bpftrace 한 줄이 간단하다
  • 컨테이너가 없는 서버. --host를 늘 붙이게 되고, 그러면 앞에서 본 대로 필터 map 자체가 안 만들어진다.
    enrichment는 --host와 무관하게 PreStart에서 늘 구독되지만(localmanager.go:623-630) 붙일 컨테이너가 없다

그리고 어느 쪽을 고르든 남는 한계가 하나 있다. gadget은 자기가 붙인 훅으로 들어오는 것만 본다.
trace_open은 open·openat 계열 tracepoint에 붙어 있고, 같은 일을 다른 syscall로 하면 그 줄은 안 남는다.
"파일이 열리지 않았다"가 아니라 "이 gadget이 보는 경로로는 열리지 않았다"가 정확한 문장이고,
어느 경로를 덮는지는 그 gadget의 SEC() 목록이 말해준다 — 위에서 이 gadget을 짤 때 직접 고른 그 줄이다.
빈 출력을 부재의 증거로 읽는 것이 이 글 앞부분의 "조용한 0건"과 같은 함정이다.

남는 한 문장

커널이 주는 값 중 컨테이너 정체성으로 이어지는 건 네임스페이스 inode 번호 하나뿐이고,
이름·네임스페이스·이미지는 전부 사용자 공간이 들고 있는 컨테이너 목록에서 나온다.

이 글이 따라간 chdir 경로에서는 그 번호가 mount namespace였다. 네트워크를 보는 gadget이면
network namespace 번호가 같은 자리를 맡는다 — 앞에서 인용한 compat.Subscribe가 EnrichEventByMntNs와
EnrichEventByNetNs를 나란히 넘기던 게 그것이고, 공식 스펙도 localmanager가
"mount or network namespace inode ID"를 쓴다고 적는다. 표는 하나, 키가 둘이다.

그 표가 사용자 공간의 컨테이너 목록이다. 40줄짜리 내 gadget이 번들과 같은 대우를 받은 것도
결국 거기에 올라탔기 때문이고, 내가 한 일은 gadget_process_populate 한 줄을 부른 것뿐이다.

확인하지 못한 것

본문에서 그때그때 밝혔지만, 한자리에 모으면 이렇다. 여기서 확인이 끊긴다는 표시이고,
대부분은 소스까지만 읽은 것이며 몇은 환경이 안 돼서 손도 못 댄 것이다.

  • 만든 gadget을 레지스트리에 push해 kubectl gadget으로 돌리기 — 이걸 해야 "같은 대우"가 Kubernetes까지 넓어진다.
    랩에 사설 레지스트리를 안 세운 게 이유이고, 셋 중 가장 아쉽다
  • --enrich-with-k8s-apiserver를 켠 ig 단독 실행, --runtime-protocol=cri에서 podLabels가 채워지는지 —
    NODE_NAME과 API 권한을 갖춘 구성을 따로 만들어야 해서 미뤘다
  • DaemonSet 안에서 도는 gadget 이미지 검증, 자체 키 서명 — 앞 항목과 같은 이유(레지스트리)
  • 링버퍼 유실 — 256 KB를 넘길 만큼 이벤트를 쏟아내는 부하를 만들지 않았다
  • WASM 레이어가 도는 것 — 설계 문서가 적은 용도는 읽었지만 실행은 보지 못했다
  • btfgen 경로 — BTF가 켜진 커널 하나로만 돌렸다
  • 멀티노드 — 노드가 하나뿐이었다. 이 글의 결론 중 "노드마다 같은 것이 올라간다"는 여기서 확인이 끊긴다

참고 자료

본문에서 이미 링크한 자료

더 읽을 자료

profile
DevOps Engineer

0개의 댓글