이미지 하나가 표 한 줄이 되기까지 — Inspektor Gadget

bocopile·2026년 8월 8일

Learning-eBPF

목록 보기
6/14
post-thumbnail

eBPF 프로그램을 컨테이너 이미지로 배포한다는 발상이 실제로 무엇을 바꾸는가

k3s 노드에서 도는 Pod 하나가 무엇을 만지는지 목록이 필요하다. 어떤 파일을 열고, DNS를 어디로 묻는지.
kubectl exec로 들어가 strace를 붙이려면 그 컨테이너에 strace가 있어야 하고, 없으면 이미지를 다시 만들어야 한다.
노드에 bpftrace를 깔면 볼 수는 있는데, 이번엔 어느 이벤트가 그 Pod의 것인지를 따로 이어붙여야 한다.
커널은 Pod를 모르기 때문이다.

Inspektor Gadget은 이 두 문제를 한꺼번에 처리한다. 관측 프로그램을 컨테이너 이미지로 배포하고,
커널이 뱉은 숫자에 컨테이너 이름을 붙여서 돌려준다.

sudo ig run ghcr.io/inspektor-gadget/gadget/trace_dns:latest \
  --containerd-socketpath /run/k3s/containerd/containerd.sock -o columns

열이 많아 이 글에서 다루는 것만 남겼다.

RUNTIME.CONTAINERNAME   SRC                DST                QR  QTYPE  NAME                  LATENCY_NS
net-loop                10.42.0.10:47629   10.43.0.10:53      Q   A      kubernetes.default.   0ns
coredns                 10.42.0.10:47629   10.42.0.5:53       Q   A      kubernetes.default.   0ns
coredns                 10.42.0.5:45151    192.168.5.2:53     Q   A      kubernetes.default.   0ns

세 줄이 한 질의의 세 구간이다. net-loop Pod가 kube-dns 서비스에 묻고, coredns가 받고, 다시 업스트림으로 나간다.
설치한 건 바이너리 하나뿐이고, 관측 대상 컨테이너에는 아무것도 넣지 않았다.

--containerd-socketpath가 붙은 이유는 「왜 컨테이너가 없으면 아무것도 안 나오는가」에서 설명한다.
k3s를 쓴다면 이게 없으면 화면이 비어 있다.

Learning eBPF 1~4장에서 익힌 맵·tracepoint·FD 수명이 실전 소스에서 어떻게 맞물리는지 확인할 대상이
필요했다. Inspektor Gadget의 오퍼레이터 체인과 enrichment 경로가 그 개념들과 가장 촘촘히 닿아 있어 골랐다.

이 글의 범위

Kubernetes를 운영하면서 eBPF가 커널에 프로그램을 붙이는 기술이라는 것 정도는 아는 사람을 상정했다.
kubectl과 컨테이너 이미지, 레지스트리를 알면 되고 eBPF 프로그램을 직접 짜본 적은 없어도 된다.
다 읽고 나면 gadget을 골라 실행할 수 있고, 이미지 안에 무엇이 들었고 그게 왜 그렇게 설계됐는지 말할 수 있다.
Kubernetes 배포와 gadget 직접 만들기, 커널에서 필터가 걸리는 방식은 전부 2편이다.

하지 않은 것도 적어둔다. 프로젝트 전반의 평가나 Falco·Cilium 같은 도구와의 비교는 직접 돌려본 적이 없어
쓰지 않는다. bpftrace와 BCC가 두 편에서 여러 번 나오지만 배포 방식과 적용 범위를 대조할 때만 쓰고,
나란히 돌려 성능을 견준 적은 없으므로 그 축의 문장은 없다. 다만 2편 끝에서 언제 이걸 고르고 언제
bpftrace 한 줄로 충분한지는 정리하는데, 성능 비교가 아니라 여기서 확인한 구조에서 나오는 구분이다.
두 편이 다루는 건 직접 확인한 출력 하나가 어떤 경로로 만들어지는가까지다.

근거는 세 종류를 섞어 쓰고, 어느 쪽인지 문장마다 어미로 구분했다.
설계 문서를 인용할 때는 "…라고 적는다 / 의도한 역할은 …이다"로 쓴다. 그건 의도이지 현재 동작이 아니다.
행 번호를 단 소스는 "…이다(파일:행)"로 단정한다. v0.54.0의 현행 동작이다.
출력이 붙은 것은 "…였다 / 이 환경에서는"으로 한정한다. 노드 하나에서 하루치를 본 것이다.
셋 중 마지막이 일반화가 가장 어렵지만, 앞의 둘이 낼 수 없는 것을 낸다 — 이 글에서 가장 뜻밖이었던 장면은
전부 거기서 나왔다. 안 해본 것은 "확인 필요"로 적어뒀다.

확인한 환경

lima VM 1대(Ubuntu 26.04, aarch64, BTF 활성)에서 containerd v2.3.3 + k3s v1.36.2+k3s1 단일 노드,
ig v0.54.0(소스는 tag v0.54.0)으로 확인했다. 확인일은 2026-08-03.

버전은 셋을 따로 세야 한다. 분석한 소스는 tag v0.54.0, 친 명령은 ig v0.54.0,
받아서 돌린 gadget 이미지는 확인일의 :latest였고 그게 v0.54.1 빌드였다.
앞의 둘은 고정했고 세 번째는 고정하지 못했다 — 그 차이가 다음 절의 소재다.
다음 릴리스 v0.55.0은 확인일 당일 오후에 나왔고 측정은 그 전에 끝났다.
노드가 하나뿐이라 멀티노드 동작은 확인하지 않았다.

gadget은 eBPF 프로그램을 담은 OCI 이미지다

한 문장으로 줄이면 eBPF 프로그램과 메타데이터를 컨테이너 이미지로 패키징해서 레지스트리로 배포하고 실행하는
도구이자 프레임워크
다. 그 이미지 하나를 gadget이라고 부른다.

CNCF Sandbox 프로젝트이고 2023년 3월 7일에 받아들여졌다.
v0.54.0의 gadgets/ 최상위 디렉터리 45개에서 gadget이 아닌 셋(ci·testing·zz_daemon)을 빼면
gadget.yaml을 갖춘 번들 gadget이 42개 남는다. 세는 규칙을 적어두는 이유는, ci/ 안에도
테스트용 gadget이 셋 더 있어서 재귀로 세면 45가 되기 때문이다 — 이 글의 42는 최상위 기준이다.

bpftrace나 BCC와 비교하면 차이가 분명해진다. 셋 다 eBPF 프로그램을 커널에 올린다.

  • bpftrace는 스크립트를 넘긴다. 노드마다 스크립트를 배포하고 버전을 맞추는 건 내 몫이다
  • BCC는 전통적으로 소스를 넘겨 실행 시점에 컴파일했다. 지금은 libbpf-tools 쪽으로 옮겨가 미리 컴파일된 바이너리를 쓰는 경로가 있다
  • gadget은 이미 컴파일된 이미지를 넘긴다. 태그 대신 digest로 고정하면 노드마다 같은 것이 올라갔다고 말할 수 있다

세 번째가 이 프로젝트의 출발점이다. 설계 문서가 의도를 그대로 적는다 — Docker가 컨테이너에 하는 것처럼
eBPF 프로그램을 돌리는 프레임워크
로 만들자는 것이고, 노린 것은 "사용자가 새 gadget을 받으려고
Inspektor Gadget 자체를 새로 깔지 않아도 되게"
떼어놓는 일이다.
bumblebee와 ebpf_exporter가 앞선 사례로 적혀 있다
(docs/design/002-containerized-gadgets.md).

ig는 릴리스에서 단일 바이너리로 받는다. eBPF 프로그램을 올리는 local 런타임이
시작할 때 root를 확인하므로(pkg/runtime/local/local.go:34) 이 경로에서는 root가 필요하고,
빼먹으면 ig must be run as root to be able to run eBPF programs로 끝난다.
이미지를 미리 받아두지 않아도 ig가 ghcr.io에서 pull한다.

같은 명령인데 다른 것이 올라간다

받아둔 것을 보면 이렇다.

$ sudo ig image list
REPOSITORY  TAG      DIGEST           CREATED
trace_exec  latest   sha256:a1b3b5c3… 2026-07-14T09:08:14Z
trace_dns   latest   sha256:8f2c11a7… 2026-07-14T09:08:31Z

docker images와 같은 모양이다. 비유가 아니라 실제로 OCI 이미지고, 뒤에서 열어본다.

그런데 방금 적은 주장 — digest로 고정하면 노드마다 같은 것이 올라갔다고 말할 수 있다 — 을 뒤집으면
고정하지 않으면 어떻게 되는지도 말할 수 있어야 한다. 이 글이 그 사례가 됐다.
trace_exec의 a1b3b5c3…은 v0.54.1 빌드다. 그런데 그 뒤 v0.55.0이 나오면서 :latest가 옮겨갔고,
지금 같은 명령을 치면 다른 이미지가 온다. 명령은 한 글자도 안 바뀌었는데 올라가는 것이 바뀐다.

서명 검증도 이걸 막지 못한다. 검증이 보증하는 건 누가 만들었는가이지 무엇이 올라가는가가 아니다.
:latest가 옮겨간 뒤의 새 이미지도 프로젝트 키로 똑같이 서명돼 있어서 아무 저항 없이 통과한다.
"노드마다 같은 것이 올라갔다"를 지키는 건 서명이 아니라 digest다.

고정하려면 태그 자리에 digest를 그대로 쓰면 된다. 본문 명령은 실제로 친 대로 :latest로 남긴다.

sudo ig run ghcr.io/inspektor-gadget/gadget/trace_exec@sha256:a1b3b5c3… \
  --containerd-socketpath /run/k3s/containerd/containerd.sock -o columns

a1b3b5c3…는 지면상 줄인 표기라 그대로 붙여넣으면 실행되지 않는다.
실행하려면 sudo ig image list --digests로 얻은 전체 64자리 digest를 넣어야 한다.

거꾸로, 같은 소스에서 같은 digest를 다시 만들 수도 있다 — SOURCE_DATE_EPOCH를 주면
ig image build가 두 번 구워도 같은 digest를 낸다
(gadget-devel/building).
받는 쪽은 digest로 고정하고 굽는 쪽은 digest를 재현한다 — 이 프로젝트가 OCI에서 빌려온 게
배포 경로만이 아니라는 뜻이다.

이름 앞부분이 출력 양식을 말해준다

42개를 다 볼 필요는 없다. 자주 쓰는 것들은 이름 앞부분에서 출력 양식이 드러난다.

접두사방식예대응하는 익숙한 도구
trace_이벤트가 날 때마다 한 줄trace_exec, trace_dns, trace_open, trace_tcpstrace
snapshot_지금 이 순간의 목록snapshot_process, snapshot_socketps, ss
top_주기적 집계, 상위 항목top_file, top_process, top_tcptop
profile_분포와 히스토그램profile_cpu, profile_blockioperf

익숙한 도구를 노드 전체에서, 컨테이너 이름을 붙여 돌린다고 보면 된다.

이게 42개 전체의 분류는 아니다. bpfstats, tcpdump, traceloop, audit_seccomp,
advise_networkpolicy처럼 이 네 접두사에 안 들어가는 것이 10개 있다.
자주 쓰는 것부터 잡을 때 쓸 지도 정도로 보면 된다.

찍힌 한 줄을 뜯어본다 — trace_exec

관측 대상은 busybox Pod 둘이다. exec-loop은 1초마다 id와 ls를, net-loop은 nslookup과 wget을 돌린다.
두 편의 출력이 전부 이 둘에서 나오므로 같이 따라오려면 먼저 만들어야 한다.

kubectl run exec-loop --image=busybox --restart=Never -- \
  sh -c 'while true; do id >/dev/null; ls / >/dev/null; sleep 1; done'
kubectl run net-loop --image=busybox --restart=Never -- \
  sh -c 'while true; do nslookup kubernetes.default >/dev/null 2>&1; \
         wget -q -O- -T3 http://10.43.0.1:443 >/dev/null 2>&1; \
         cat /etc/hostname >/dev/null; sleep 2; done'
kubectl wait --for=condition=Ready pod/exec-loop pod/net-loop

net-loop의 wget이 도메인이 아니라 10.43.0.1(kubernetes API 서비스의 ClusterIP)로 가는 게 일부러다.
DNS 질의를 nslookup 하나에서만 나게 하려는 것이고, 그래서 맨 앞의 trace_dns 출력이 깔끔하게 세 줄이었다.
도메인으로 바꾸면 질의가 하나 더 섞인다.

trace_exec을 붙이면 이렇게 나온다. ig run은 계속 떠 있어야 하므로 터미널을 하나 따로 쓴다.

RUNTIME.CONTAINERNAME   COMM       PID      ARGS
exec-loop               id         151996   id
exec-loop               ls         151997   ls /
net-loop                nslookup   154384   nslookup kubernetes.default

ARGS의 인자 구분자가 라는 문자열로 찍힌다. 저장소의 ArgsSeparator 상수가 그것이고,
일반 공백이 아니라 non-breaking space(U+00A0)다(gadgets/trace_exec/consts/consts.go:17).
정의 옆 주석이 이유를 적어뒀다 — 공백을 포함한 인자와 섞이지 않게 고른 값이다.
COMM도 ARGS도 관측 대상이 정하는 문자열이라, 구분자를 무엇으로 두느냐가 취향 문제가 아니다.

그리고 ARGS는 명령줄 전체다. 인자로 토큰이나 비밀번호를 넘기는 프로세스가 노드에 있으면 그 값이
그대로 이 표에 찍힌다. trace_dns가 조회 도메인을 전부 남기는 것도 같은 성질이다 —
이 도구의 출력 자체가 다루기에 따라 민감하다. 화면을 넘기거나 로그로 흘릴 곳을 정할 때 볼 것.

반대로 아직 이름이 안 붙는 열도 있다. 맨 앞 trace_dns 출력에서 SRC와 DST가 IP:port 그대로였다 —
10.43.0.10이 kube-dns 서비스라는 건 이 표가 말해주지 않는다. 컨테이너 이름은 붙는데 IP는 안 붙는다.
그 비대칭이 어디서 오는지, 그리고 2편의 kubeipresolver가 그걸 어떻게 메우는지는 뒤에서 본다.

왜 컨테이너가 없으면 아무것도 안 나오는가

도입부부터 지금까지 ig run에는 빠짐없이 --containerd-socketpath가 붙어 있었다. 그 이유가 여기 있다.

플래그 이름만 보면 예상이 하나 선다. --host가 있으니 기본은 "컨테이너 모드"이고 --host를 붙이면
"호스트 모드"로 바뀌겠거니. 그리고 노드에서 도는 ig가 커널에 프로그램을 붙이는 이상,
컨테이너가 있든 없든 커널에서 나는 일은 보이겠거니.

둘 다 틀렸다. 아래 실행은 k3s와 containerd를 멈추고 컨테이너가 하나도 없는 상태에서 받았다.
다음 한 줄은 노드 위의 워크로드를 전부 내린다 — 버려도 되는 실습 노드에서만 친다.

sudo systemctl stop k3s containerd

이 상태에서 trace_exec을 그냥 돌리면 헤더만 찍히고 이벤트가 0건이다.
다른 터미널에서 /bin/ls를 아무리 실행해도 그렇다. --host를 붙이면 같은 명령이 12건을 쏟아낸다.
프로그램은 내내 커널에 올라가 있었는데 보이는 것만 달라졌다.

플래그 정의를 보면 이름이 오해를 부른 쪽이었다.

// pkg/operators/localmanager/localmanager.go:158-163
&params.ParamDesc{
	Key:          Host,
	Title:        "Host Data",
	Description:  "Show data from both the host and containers",
	DefaultValue: "false",
	...

--host는 "호스트 모드로 전환"이 아니라 "호스트 데이터도 추가로 보여줘"다.
기본값이 false이므로 아무것도 안 붙이면 컨테이너에서 난 이벤트만 본다.

기본값이 컨테이너라는 게 이 도구의 성격을 규정한다. 컨테이너 목록이 비면 볼 게 없다.
그 목록은 소켓 하나로 비어버린다 — k3s는 자체 containerd를 /run/k3s/containerd/containerd.sock에
띄우는데 ig의 기본값은 /run/containerd/containerd.sock이다.
그래서 플래그를 빼면 k3s Pod가 아무리 바빠도 화면이 비어 있다.

오류도 경고도 나오지 않는다. 왜 아무 말이 없는지, 그리고 이벤트가 정확히 어디서 버려지는지는
2편에서 본다 — 목록이 비는 것과 화면이 비는 것 사이에 커널의 map 하나가 더 있다.

세 진입점과 거기 붙는 부품

저장소 cmd/ 아래 진입점이 셋 있다.

명령어디서 실행하나쓰는 상황
ig관측 대상 호스트에서 직접노드 한 대. Kubernetes 유무와 무관하다 — 이 글도 k3s 노드에서 쓴다
kubectl gadget클러스터 밖 클라이언트DaemonSet으로 배포된 Pod들에 gRPC로 지시 (2편)
gadgetctl원격 클라이언트 (macOS/Windows 포함)데몬 모드로 띄운 ig에 gRPC로 접속

공식 저장소에 셋을 각각 그린 개념도가 있다. 이 글의 기본인 ig 단독은 이렇게 단순하다 —
한 호스트 안에서 끝난다.

`ig` 단독 구조

gadgetctl이 붙는 데몬 모드는 그 사이다. ig를 데몬으로 띄워두고 원격에서 gRPC로 붙는다.

`ig` 데몬 모드 구조

Kubernetes 쪽은 클라이언트가 kube-api server를 거쳐 각 노드의 Pod에 gRPC로 닿는 모양이다.

Inspektor Gadget의 Kubernetes 구조

출처: 세 그림 모두 Inspektor Gadget 프로젝트(inspektor-gadget.io)의 것이다.
공식 문서 Architecture 페이지에 실린
그대로이고, 원본 파일은 저장소의
architecture-ig.svg·architecture-ig-daemon.svg·architecture-k8s.svg다.
라이선스는 Apache-2.0이고, 태그 v0.54.0으로 고정해 가져왔다 — 손대지 않은 원본이다.
다만 그 페이지는
work in progress 상태라, 아래 구조 서술의 근거는 그림이 아니라 소스다.

eBPF 프로그램을 커널에 올리는 코드는 셋 다 관측 대상 호스트 위에 있다.
다만 명령을 내리는 위치만 다른 게 아니라 붙는 부품도 다르다.
그리고 그 부품은 플래그로 고르는 게 아니라 빌드가 정한다.

부품(operator)은 대개 각 패키지의 init()에서 전역 레지스트리에 등록된다(pkg/operators/registry.go:40).
Go에서 init()은 그 패키지를 import해야 도니까, 진입점의 blank import 목록이 그 바이너리가 가진 부품의 대부분이다.
나머지는 실행 명령을 세울 때 코드에서 직접 얹는다.

여기까지가 가진 것이고, 켜지는 것은 한 층 더 있다. 공식 스펙이 이렇게 적는다.

Operators are enabled automatically based on the gadget that's being run, according to the layers
present on the OCI image, the types used by the eBPF programs, etc. It's currently not possible to
explicitly disable an operator.
— docs/spec/operators/index.mdx

즉 두 단계다. 빌드 때 집합이 정해지고, 실행 때 이미지 레이어와 eBPF 타입을 보고 그 부분집합이 켜진다.
쓰는 쪽이 개입할 수 있는 층은 어느 쪽에도 없다 — 끄는 플래그가 아예 없고,
그게 없다는 사실이 issue #1992로 열려 있다.

바이너리blank import명령을 세울 때 얹히는 것
ig15개 (cmd/ig/main.go:41-55)oci-handler(:108), cli·combiner·generate_networkpolicy
kubectl gadget0개cli·combiner·generate_networkpolicy
gadgetctl0개cli·combiner·generate_networkpolicy

클라이언트 둘의 blank import는 비어 있다. eBPF를 커널에 올리거나 컨텍스트를 붙이는 부품이 하나도 없다.
설정이 아니라 빌드의 성질이다 — 둘은 -tags withoutebpf로 컴파일돼 eBPF 경로가 통째로 잘려 나간다
(Makefile:186,216). 그래서 macOS·Windows에서도 돈다.

그렇다고 완전히 빈손은 아니다. 셋 다 같은 common.NewRunCommand를 쓰고, 그 안에서 출력 쪽 부품 셋이
목록에 직접 얹힌다(cmd/common/oci.go:188,308). 뒤에서 볼 cli — 표를 찍는 그 부품 — 이 그중 하나다.
kubectl gadget이 클러스터 밖에서 표를 그릴 수 있는 이유가 이것이다.
잘려 나간 건 커널에 닿는 쪽이지 화면에 닿는 쪽이 아니다.

ig가 가진 15개 중 하나가 localmanager — 바로 앞 절에서 컨테이너 목록을 들고 있던 그 부품이다.
Kubernetes 컨텍스트를 붙이는 kubemanager는 ig에 없다. 클러스터 쪽 부품은 클라이언트가 아니라
DaemonSet 안에서 도는 데몬이 갖는다(gadget-container/gadgettracermanager/main.go:51-70).
그래서 출력 열까지 달라진다. 2편에서 그 차이를 본다.

셋이 공통으로 받는 것은 gadget 이미지 하나다. 그 이미지를 아직 열어보지 않았다.

이미지 안에 무엇이 들었나

ig가 받은 이미지는 /var/lib/ig/oci-store에 표준 OCI layout으로 놓인다.

$ sudo cat /var/lib/ig/oci-store/oci-layout
{"imageLayoutVersion":"1.0.0"}
$ sudo ls /var/lib/ig/oci-store
blobs  index.json  ingest  oci-layout

index.json에서 manifest를 따라가면 trace_exec:latest는 amd64/arm64 멀티아치 index고,
arm64 manifest는 이렇게 생겼다.

config: application/vnd.gadget.config.v1+yaml            5,774 B
layer : application/vnd.gadget.ebpf.program.v1+binary  1,631,128 B
layer : application/vnd.gadget.wasm.program.v1+binary  2,619,126 B

media type이 전부 vnd.gadget.*이다. 레이아웃은 표준을 그대로 쓰되 내용물 타입만 자기 것으로 정의했다.
컨테이너 런타임이 이 이미지를 실행할 수는 없다. 루트 파일시스템 레이어가 아니기 때문이다. 배포 경로만 빌려온 셈이다.

빌려오면서 딸려온 것도 있다. index.json에는 sha256-a1b3b5c3….sig manifest가 함께 들어 있다.
cosign 서명이고, ig가 실행 전에 이걸 확인한다. 2편에서 이 검증에 막히는 장면을 본다.

eBPF 레이어를 꺼내 보면 평범한 오브젝트 파일이다.

$ file blobs/sha256/7d2e8af5c9c3…
ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), not stripped

Learning eBPF 3장에서 clang으로 만들어
bpftool prog load로 직접 올렸던 것과 같은 물건이다.
gadget은 별도 바이트코드 규격을 만든 게 아니라 그 .o를 이미지 레이어에 넣었을 뿐이다.

맨 위의 config는 OCI 용어로 레이어가 아니라 manifest의 config descriptor다.
내용은 YAML이고, 여기서 출력 열이 정해진다.

name: trace exec
datasources:
  exec:
    fields:
      cwd:
        annotations:
          description: The current working directory of the process (require --paths flag)
          columns.width: 64
          columns.hidden: "true"
params:
  ebpf:
    paths:
      key: paths
      defaultValue: "false"

eBPF 프로그램은 숫자를 만들고, 그걸 사람이 읽는 표로 바꾸는 규칙은 이미지 안의 YAML에 있다.
앞에서 본 열 이름과 폭이 전부 여기서 나왔다.

레이어 셋, 처리기 셋

레이어가 세 종류인 게 우연이 아니다. InstantiateImageOperator를 구현한 타입을 찾으면 정확히 셋이다.

ImageOperator맡는 레이어
ebpfeBPF 레이어를 커널에 올리고 attach한다
wasmWASM 레이어를 로드한다
btfgen커널별 BTF 레이어를 주입한다

셋 중 도는 것을 실제로 확인한 건 ebpf뿐이다. 나머지 둘의 설명은 소스와 스펙에서 읽은 것이다.
앞 절에서 열어본 trace_exec 이미지에 wasm 레이어가 2.6 MB 들어 있는데 그게 도는 걸 보지는 못했다.
용도는 설계 문서에 적혀 있다 — 필드 값과 속성을 바꾸고, 기존 필드를 조합한 가상 필드를 만들고,
이벤트를 버리고, 새 DataSource까지 만든다
(docs/design/003-wasm-support.md).
eBPF로 짜기 어려운 후처리를 사용자 공간으로 뺀 자리다. btfgen 레이어는 아예 없었다.

이미지를 열고 레이어를 하나씩 이 셋에게 넘기는 건 oci-handler다.

// pkg/operators/oci-handler/oci.go:485
opInst, err := op.InstantiateImageOperator(gadgetCtx, target, layer, ...)

oci-handler 자신은 ImageOperator가 아니다. 이미지를 여는 오케스트레이터이고,
config descriptor의 YAML을 읽어 필드·파라미터 정의를 세우는 것도 이쪽 일이다.
분류상으로는 아래에서 볼 DataOperator에 속한다.

레이어 종류를 하나 늘리려면 ImageOperator를 하나 더 쓰면 되고, oci-handler는 안 건드린다.
다만 그 "하나 더"를 지금은 남이 끼울 수 없다. operator는 전부 built-in이고 third-party operator
지원은 계획 단계다(docs/spec/operators/index.mdx — issue #2497). 구조가 열려 있다는 것과
확장을 사용자가 할 수 있다는 것은 다른 말이고, 여기서는 앞쪽만 참이다.
btfgen 레이어가 없었던 것도 그래서다 — ig image build --btfgen --btfhub-archive <경로>로
구울 때만 붙는다(cmd/common/image/build.go:216).

btfgen이 존재하는 이유가 곧 이 도구의 커널 경계다. 미리 컴파일된 .o 하나를 여러 노드에 뿌리려면
커널 구조체 배치를 실행 시점에 맞춰야 하고, 그 정보를 커널이 BTF로 내주면 그만이다.
이 글의 환경은 BTF가 켜져 있어서 아무것도 안 해도 됐다. 켜지지 않은 오래된 커널이 섞여 있으면
그 자리를 메우는 게 btfgen 레이어이고, 그래서 굽는 사람이 BTFHub 아카이브를 들고 있어야 한다.
그 경로는 확인하지 않았다 — BTF가 있는 커널 하나로만 돌렸다.

여기서 나온 데이터를 받는 게 두 번째 종류다. DataOperator는 정수 하나를 내고 그 순서대로 줄을 선다.
v0.54.0의 저장소 전체에 24개가 있다. 앞 표의 "15개"와는 다른 축이다 —
24는 구현이 존재하는 수이고, 15는 ig가 blank import한 패키지 수다.
그 15개 중 하나가 이 글의 출력에도 찍혀 있다 —
RUNTIME.CONTAINERNAME 열을 채운 localmanager다.
2편에서는 여기에 kubeipresolver가 더 붙어 IP가 Pod 이름으로 바뀐다.
둘 다 앞쪽에서 컨텍스트를 붙이는 쪽이고, 뒤에 거르고 정렬하고 찍는 것들이 따라온다.

부품들이 주고받는 것 — DataSource

지금까지 부품 이야기만 했다. 그 부품들이 무엇을 주고받는지가 빠져 있다.

DataSource다(pkg/datasource/datasource.go:110). 이름이 붙은 이벤트 흐름이고,
gadget YAML에서 이미 본 적 있다 — 앞의 trace_exec config에 datasources: exec:가 그것이다.
열 이름과 폭을 정하던 그 YAML 키가 실제 자료구조 이름이었다.

핵심은 셋이다.

// pkg/datasource/datasource.go — 발췌
AddStaticFields(totalSize uint32, fields []StaticField) (FieldAccessor, error)  // :119
EmitAndRelease(Packet) error                                                    // :140
Subscribe(fn DataFunc, priority int)                                            // :151 부근

AddStaticFields의 주석이 용도를 그대로 적는다 — "use it to directly map for example eBPF structs".
eBPF 프로그램이 링버퍼에 밀어넣은 C 구조체가 여기서 필드 목록이 된다.
그 다음 EmitAndRelease가 그 이벤트를 operator 체인으로 흘려보낸다(:137-140 주석:
"sends Packet through the operator chain").

그러니까 전체 그림은 이런데, 한 장으로 그리면 안 되는 이유가 하나 있다. 시간축이 둘이다.
ImageOperator는 실행 전에 한 번 돌아 파이프라인을 세우고,
DataOperator는 이벤트가 지나갈 때마다 값을 붙이고 거르고 찍는다. 그래서 도식도 둘로 나눴다.

줄 세우는 정수의 실제 값

"정수 하나를 내고 그 순서대로 줄을 선다"고 했는데, 그 값이 몇인지 보면 설계 의도가 읽힌다.

구간우선순위operator
이미지를 연다-1000oci-handler
컨텍스트를 붙인다-1000 / -900 / -500process / cgroup / combiner
컨텍스트를 붙인다-1localmanager, kubemanager
형식과 enrichment0 / 0 / 1formatters / ebpf(stats) / env
형식과 enrichment5 / 10 / 10 / 11uidgidresolver / socketenricher / kubeipresolver / kubenameresolver
사용자 스택을 푼다100ustack
거른다9000 / 9200filter / generate_networkpolicy
정렬·제한9500 / 9600sort / limiter
내보낸다9995 / 9998 / 9999 / 9999otel-metrics / logs / otel-logs / otel-profiles
내보낸다10000cli

23개다. 앞에서 센 24개 중 빠진 하나인 simple은 우선순위를 부르는 쪽이 정하는 헬퍼라 고정값이 없다.
값은 소스의 상수에서 읽었는데, 공식 스펙도 operator마다 Priority 절에 같은 수를 적어뒀다
(docs/spec/operators/).
정렬은 우선순위, 같으면 이름 순이다(pkg/gadget-context/run.go:38-44).

읽을 만한 지점이 둘 있다.

oci-handler가 -1000으로 가장 앞이다. 이미지를 열어 필드와 파라미터 정의를 세워야 나머지가
성립하므로 순서가 강제된다. 앞에서 "오케스트레이터"라고 부른 성격이 숫자에도 남아 있다.
다만 process도 같은 -1000이라, 앞선 건 방금 적은 이름 순 규칙 덕이다 — 우선순위만으로 갈린 게 아니다.

otel-metrics가 9995인 것은 우연이 아니다. 소스에 이유가 주석으로 붙어 있다 —
"slightly before CLI so we can reroute output there"(otel-metrics.go:54).
출력을 가로채려면 cli(10000)보다 앞이어야 한다. 저자가 이유를 남긴 드문 경우다.

정방향으로 세우고 역방향으로 접는다

줄을 세웠으면 그 줄을 어떻게 여닫는지가 남는다. pkg/gadget-context/run.go가 전부 담고 있다.

단계순서근거
InstantiateDataOperator정방향:74
PreStart정방향:124-128
Start정방향:137-142
PreStop역방향:155-161
Stop역방향:172-179
PostStop역방향:188-194
Close역방향:206-213

세울 때는 앞에서부터, 접을 때는 뒤에서부터다. 소스 주석도 // Stop in reverse order라고 적어뒀고,
공식 문서도 한 문장으로 같은 말을 한다 — "operator는 우선순위 순으로 초기화되고 역순으로 닫힌다"
(devel/operator/lifecycle).
뒤쪽 부품이 앞쪽 부품이 세운 것에 기대고 있으므로 해체가 역순이어야 한다 — 흔한 규칙이지만
정말 그렇게 돼 있는지는 읽어봐야 아는 것이고, 문서와 소스가 같은 말을 한다.

Start·Stop·Close 셋만 필수다. PreStart·PreStop·PostStop은 선택이라
타입 단언으로 있는 것만 부른다(:126 — if preStart, ok := opInst.(operators.PreStart); ok).

그리고 여기에 "부품이 켜지는" 실제 메커니즘이 있다. 앞에서 operator를 끄고 켤 수 없다고 했는데,
정확히는 부품이 스스로 빠진다.

// pkg/gadget-context/run.go:74-81
opInst, err := op.InstantiateDataOperator(c, opParamValues)
...
if opInst == nil {
	log.Debugf("> skipped %s", op.Name())
	continue
}

각 operator가 실행 맥락을 보고 nil을 반환하면 그 실행에서 빠진다. 이것도 문서에 규약으로 적혀 있다 —
"operator를 건너뛰어야 하면 nil을 반환할 수 있다". 바이너리가 가진 게 전부 도는 게 아니라,
이 gadget에 자기가 필요한지를 스스로 판단한다. 그래서 앞 절의 "이미지 레이어와 eBPF 타입에 따라
자동 활성화"가 코드에서는 이 한 줄이다. 그리고 -v를 붙이면 > skipped …로 보인다 —
어떤 부품이 빠졌는지 알아낼 방법이 하나 있는 셈이다.

한 번의 실행을 들고 있는 건 GadgetContext다(pkg/gadget-context/gadget-context.go:51).
DataSource 목록, DataOperator 목록, 파라미터, 그리고 Runtime을 한 구조체에 쥐고 있다.
그 Runtime이 local이냐 grpc냐가 앞 절에서 본 세 바이너리의 차이를 만드는 마지막 조각이다
(pkg/runtime/runtime.go:111). ig는 local을 써서 자기 호스트에서 돌리고,
kubectl gadget과 gadgetctl은 grpc를 써서 남에게 시킨다.
클라이언트가 커널 쪽 부품을 하나도 안 가져도 되는 이유가 이것이다 —
시키는 쪽은 파이프라인을 커널에 세울 필요가 없고, 돌아온 데이터를 찍을 cli만 있으면 된다.

표가 비었을 때 어디를 보나

여기까지 보면 예상이 하나 더 선다. 층이 이렇게 또렷하게 나뉘어 있으니 — 이미지를 푸는 쪽,
커널에 올리는 쪽, 값을 붙이는 쪽, 거르는 쪽, 찍는 쪽 — 어디서 막혔는지도 또렷하게 보이겠거니.
확장이 쉬워진 구조라면 진단도 쉬워야 할 것 같다.

그렇지 않다. 쓰는 쪽이 치르는 값이 둘 있다.

하나. 출력이 비었을 때 어느 층에서 걸렸는지 알 수 없다.
앞에서 --host 없이 0건이 나왔을 때, eBPF 프로그램은 커널에 멀쩡히 올라가 있었다.
이미지를 푸는 쪽은 제 할 일을 다 했고 데이터를 거르는 쪽에서 걸린 것인데, 화면에는 아무 차이가 없다.
k3s 소켓 경로가 안 맞을 때도 증상이 똑같다 —
빈 표 하나로 "설정이 틀렸다"와 "아무 일도 안 일어났다"가 구분되지 않는다.
층이 나뉜 건 만드는 쪽 사정이고, 쓰는 쪽에는 그 경계가 노출되지 않는다.

둘. 플래그가 어느 층에서 오는지 안 보인다. ig run --help를 이미지 없이 치면 --paths가 없다.

$ sudo ig run --help | grep -c paths
0

이미지를 지정해야 나타난다. gadget의 YAML에서 오는 파라미터이기 때문이다.

$ sudo ig run ghcr.io/…/trace_exec:latest --help
      --containerd-socketpath string   ...   ← ig의 부품 것
      --host                           ...   ← ig의 부품 것
      --ignore-failed                  ...   ← gadget 것
      --paths                          ...   ← gadget 것

--help 하나가 이미지를 당겨 파이프라인을 한 번 세운다. 공식 문서가 라이프사이클이 둘이라고 적어뒀다 —
gadget을 실제로 돌리는 RunGadget과, 무엇을 제공하는지만 묻는 GetGadgetInfo다.
후자는 Instantiate와 Close만 부른다
(docs/devel/operator/lifecycle).
소스도 그대로다 — PrepareGadgetInfo가 instantiateOperators() 다음 줄에서 바로 close()다
(pkg/gadget-context/run.go:220-226). --help는 이 경로를 타고(cmd/common/oci.go:237)
돌아온 파라미터 목록을 그때서야 플래그로 등록한다(:242-263).

그래서 목록이 늦게, 그리고 한 덩어리로 나온다. 섞여 나오고 구분 표시가 없다.
어떤 플래그가 gadget을 바꾸고 어떤 게 ig를 바꾸는지는 --help만 봐서는 모른다.

그래서 실무 순서는 이렇게 된다. 표가 비면 먼저 --containerd-socketpath를 의심하고,
그다음 -v를 붙여 어떤 런타임에 붙었는지 본다. 플래그의 출신을 알아야 할 때는
ig run --help와 ig run <이미지> --help를 나란히 놓고 차집합을 본다.
앞의 것이 2편에서 한 번 더 배신한다 — -v를 켜도 안 잡히는 경우가 있다.

남는 한 문장

맨 앞에서 본 세 줄짜리 DNS 표가 어디서 왔는지, 이제 전부 말할 수 있다.

ghcr.io의 이미지를 ig가 표준 OCI layout으로 내려받고, oci-handler가 열어
eBPF 레이어는 커널로 보내고 config YAML은 열 이름과 폭이 된다. 커널이 링버퍼로 뱉은 구조체가
DataSource의 필드가 되고, 정수 순서로 줄 선 operator들이 컨테이너 이름을 붙이고 거르고 정렬한 뒤,
맨 뒤 cli(10000)가 표를 찍는다.

docker images처럼 생긴 그 목록이 비유가 아니었다는 게 이 편의 답이다.
배포 경로를 통째로 빌려오되 내용물 타입만 자기 것으로 정의했고, 그래서 레이어 종류를 늘리는 일이
oci-handler를 건드리지 않고 끝난다. 대신 그 확장은 지금 프로젝트 안에서만 열려 있다.

본문에서 그때그때 밝혔지만, 이 편이 확인하지 못한 것을 한자리에 모으면 이렇다.
ImageOperator 셋 중 실제로 도는 걸 본 건 ebpf뿐이고 wasm·btfgen은 소스로만 읽었다.
cosign 서명은 이미지에 들어 있는 것까지만 봤고, 검증이 실제로 무엇을 막는지는 2편에서 본다.
노드가 하나라 멀티노드는 대상이 아니었다.
성능은 이 두 편의 축이 아니고, 보안도 감사가 아니라 매니페스트에 적힌 것과 실행하다 실제로 부딪히는 것까지만 다룬다.

정작 표의 첫 열은 아직 설명하지 못했다. 커널은 컨테이너를 모르는데 RUNTIME.CONTAINERNAME이 채워져 있다.

다음 편에서 볼 것

  • 그 첫 열 — 커널은 mount namespace 번호만 아는데 RUNTIME.CONTAINERNAME은 어디서 오는가
  • 컨테이너가 없을 때 0건이 되는 일은 사용자 공간인가 커널인가
  • 카탈로그에 없는 걸 보고 싶으면 gadget을 직접 만들 수 있는가

셋 다 2편에서 답한다.

참고 자료

본문에서 이미 링크한 자료

  • docs/design/002-containerized-gadgets.md — 왜 이미지로 배포하기로 했는지. 이 글의 출발점을 저자들 문장으로 볼 때
  • docs/spec/operators/ — operator별 공식 스펙. 우선순위 값과 파라미터가 여기 있다. 이 글의 표는 소스 상수에서 읽고 이쪽과 대조했다
  • docs/devel/operator/lifecycle — 정방향/역방향 규약과 nil 반환으로 빠지는 규약. GetGadgetInfo와 RunGadget이 다른 경로라는 것도 여기서 읽었다
  • Learning eBPF 3장 정리 — 이미지 속 .o가 어떤 물건인지 궁금하면

더 읽을 자료

profile
DevOps Engineer

0개의 댓글