Distroless Pod에 ephemeral container를 붙일 때, 안에서 일어나는 일
Distroless 이미지는 좋다. CVE 스캔 결과가 깨끗하고, attack surface가 작고, 이미지 크기도 작다. 그런데 사고가 났을 때가 문제다. sh도 없고 curl도 없고 ls도 없다. 컨테이너 안에 들어가서 뭔가 보고 싶어도 들어갈 수단 자체가 없는 셈이다.
kubectl debug 한 줄이면 끝나는 일이긴 하다. -it --image=nicolaka/netshoot --target=app. 이렇게 치면 netshoot 컨테이너가 붙고, ps도 보이고, tcpdump도 된다. 신기하다. 분명히 distroless 컨테이너 안에는 셸이 없는데 어떻게 그 컨테이너의 프로세스를 들여다보고 있는 걸까. 이게 사실 처음 봤을 때 좀 헷갈렸다. 그래서 한번 파봤다.
표면적으로는 pod.spec.ephemeralContainers에 entry를 추가하는 것
kubectl debug가 하는 일은 의외로 단순하다. API server에 pods/ephemeralcontainers subresource로 patch를 보낸다. kubectl debug --v=8로 찍어보면 바로 보인다.
kubectl debug -it my-pod --image=nicolaka/netshoot --target=app --profile=general --v=8
요청 본문을 까보면 이런 식이다.
{
"spec": {
"ephemeralContainers": [{
"name": "debugger-abc12",
"image": "nicolaka/netshoot",
"targetContainerName": "app",
"stdin": true,
"tty": true,
"securityContext": { ... }
}]
}
}
여기서 핵심은 targetContainerName이다. 이게 app을 가리키면, kubelet이 새 컨테이너를 만들 때 app 컨테이너와 namespace를 일부 공유하도록 컨테이너 런타임에게 지시한다. 그냥 같은 Pod에 컨테이너 하나 추가하는 것과는 다르다.
pods/ephemeralcontainers는 일반 pods subresource가 아니라 별도 subresource로 분리돼 있다. RBAC도 별도다. 즉, "Pod를 수정할 수 있는 권한"과 "ephemeral container를 붙일 수 있는 권한"을 분리할 수 있다. 우리 팀은 이 부분을 활용해서, 개발자에겐 자기 네임스페이스의 ephemeral container 권한만 주고, SRE한테는 cluster-wide로 줬다. 감사 로그도 깔끔하게 남는다.
kubelet과 컨테이너 런타임 단에서 진짜 벌어지는 일
kubelet은 Pod spec의 변화를 감지하면, SyncPod 루프 안에서 ephemeralContainers를 일반 컨테이너처럼 처리한다. 단, 두 가지가 다르다.
첫째, restart policy가 강제로 Never다. 죽으면 끝이다. 다시 안 살린다.
둘째, targetContainerName이 지정돼 있으면 CRI 호출 시 NamespaceOption에 PID namespace 공유 옵션이 붙는다. containerd의 runtime/v2/runc/container.go 어딘가에서 이걸 처리하는데, 결국엔 runc가 새 컨테이너의 pid namespace를 기존 컨테이너의 namespace로 join 시키는 식이다. 마운트 namespace는 공유 안 한다 — 이게 중요하다.
여기서 헷갈리기 쉬운 게 있다. process namespace를 공유한다고 해서 파일시스템을 공유하는 게 아니다. netshoot 컨테이너 안에서 ls /app/config.yaml을 쳐도 안 보인다. 그건 distroless 컨테이너의 파일시스템이다. 그럼 distroless 컨테이너 안의 파일을 어떻게 보냐? 여기서 /proc/<PID>/root 트릭이 나온다.
# netshoot ephemeral container 안에서
ps aux # distroless 컨테이너의 PID도 보인다
# app 프로세스 PID가 17이라면
ls /proc/17/root/app/
cat /proc/17/root/etc/resolv.conf
이게 가능한 이유는 PID namespace를 공유하고 있어서 target 컨테이너의 프로세스 PID가 보이고, /proc/<PID>/root는 그 프로세스 입장에서의 root filesystem을 가리키기 때문이다. 리눅스 procfs의 동작이다. distroless에 셸이 없어도 /proc/<PID>/root/bin/somefile을 통해 그 안의 바이너리를 호출하거나, 파일을 읽거나 할 수 있다. 사실 distroless 디버깅의 90%는 이 트릭으로 해결된다.
profile 플래그가 실제로 바꾸는 것
kubectl debug --profile은 1.30에서 GA 됐다. 그 전엔 securityContext를 매번 손으로 패치해야 했다. 종류는 이렇다.
general은 기본값이다. 별도 권한 없음.
baseline은 PodSecurityStandard의 baseline에 맞춘다. 큰 차이는 없다.
restricted는 nonRoot, capabilities drop all, seccomp RuntimeDefault가 강제된다.
netadmin이 실무에서 제일 자주 쓴다. CAP_NET_ADMIN, CAP_NET_RAW가 추가되고 host network 옵션도 켤 수 있다. tcpdump로 패킷 캡처하려면 보통 이게 필요하다.
sysadmin은 CAP_SYS_PTRACE까지 붙는다. strace로 syscall 추적하거나, gdb로 붙으려면 이게 필요하다. 다만 권한이 세다는 점 잊지 말 것. PSP/PSA가 strict한 클러스터에서는 막힐 수 있다.
내부적으로 profile은 그냥 ephemeral container의 securityContext를 미리 정의된 형태로 채워주는 사전 설정일 뿐이다. patch 결과물을 보면 그냥 평범한 capabilities.add와 runAsNonRoot 같은 필드가 들어가 있다. 마법은 없다.
--copy-to는 인시던트 중에 쓰지 말기
kubectl debug엔 --copy-to=new-pod 옵션이 있다. 원본 Pod을 그대로 복사한 새 Pod을 만들어서 거기에 디버그 컨테이너를 붙인다. 원본 Pod을 건드리지 않고 디버깅하고 싶을 때 쓰라고 만들어진 옵션이다.
근데 솔직히 프로덕션 인시던트 중엔 이거 쓰면 안 된다. 이유가 두 가지다.
하나, 복사된 Pod은 새로 스케줄링된다. 노드가 다르면 노드 로컬 문제(예: 노드 디스크 압박, kernel param, kubelet config 차이)를 못 잡는다. 사고난 그 Pod이어야 의미가 있는데 다른 Pod이 떠버리는 거다.
둘, 부하가 더 걸린다. 실제로 작년 우리 팀에서 이걸로 한 번 더 사고를 만든 적이 있다. 메모리 압박 받는 노드에 새 Pod이 또 떠서 OOM이 전염됐다. 그 뒤로는 인시던트 중엔 무조건 --copy-to 없이 원본 Pod에 ephemeral container만 붙인다.
--copy-to는 사후 분석이나 로컬 reproduce용으로만 쓴다. 인시던트 한복판에서 쓰는 옵션은 아니다.
끝나고 청소 못 한다는 점
ephemeral container의 가장 큰 함정. 한번 붙이면 못 뗀다. pod.spec.ephemeralContainers 배열에서 항목을 지우는 게 API 레벨에서 막혀 있다. Pod 자체를 지우는 수밖에 없다.
그래서 stateful한 워크로드에 함부로 붙이면 안 된다. 예를 들어 데이터베이스 primary Pod에 디버그 컨테이너 한 번 붙이고 나서, 나중에 그 Pod이 ephemeral container 때문에 spec dirty 상태로 남아 있으면 GitOps drift detection이 시끄러워진다. Argo CD에서 OutOfSync 표시되는 거 다들 한 번씩 겪어봤을 것이다.
운영적으로는, 인시던트 끝나면 해당 Pod을 graceful하게 재시작하는 걸 표준 절차로 박아두는 게 낫다. 우리 팀은 이걸 runbook에 명시했다. "ephemeral container 사용 후엔 24시간 이내 Pod 재배포".
한 줄 요약
kubectl debug는 pods/ephemeralcontainers subresource에 patch를 보내고, kubelet은 PID namespace를 공유하는 새 컨테이너를 띄운다. distroless 안의 파일은 /proc/<PID>/root로 접근한다. 마법은 아니고 procfs와 namespace의 자연스러운 응용이다. 다만 한번 붙이면 못 떼고, --copy-to는 인시던트 중엔 위험하다는 점만 기억하면 된다.
다음에는 ephemeral container를 활용한 sidecar 패턴 디버깅 — 특히 Istio ambient 환경에서 ztunnel 트래픽을 어떻게 들여다봤는지를 정리해보려고 한다.