블로그 이미지
Cloud Engineer

클라우드 & 쿠버네티스 엔지니어의 기술 기록

쿠버네티스 기반 프라이빗 클라우드 구축·운영과 레거시 → 클라우드 전환 경험을 정리합니다. 직접 부딪힌 문제와 트러블슈팅 과정을 위주로 기록해요.

Kubernetes Private Cloud Legacy to Cloud
전체 방문자
오늘
어제

GitHub Activity

GitHub Stats Top Languages
GitHub Streak

Tech Stack

Container & Orchestration

OpenShift OpenShift Virtualization RKE2 Rancher Kubernetes Docker Helm

Middleware / WAS

JBoss Apache HTTPD Tomcat

Cloud

AWS Google Cloud

Auth / Identity

Keycloak Google OIDC OAuth2 Proxy

Automation / IaC

Ansible

카테고리 둘러보기

DevOps/Kubernetes 2026. 10. 29. 10:02

kubectl debug와 ephemeral container로 죽은 파드 들여다보기 - 쉘 없는 distroless 파드 디버깅과 --copy-to

728x90

쉘이 없는 distroless 이미지의 파드는 kubectl exec로 들어갈 수 없고, 죽어 있는 파드는 더더욱 들여다보기 어렵습니다. 이 글에서는 kubectl debug가 기존 파드에 임시 컨테이너(ephemeral container)를 붙이는 방법과 --target의 효과, CrashLoopBackOff 파드에서는 임시 컨테이너가 붙지 않아 --copy-to로 복제해야 하는 이유, 디버그 프로파일과 노드 디버깅, 필요한 RBAC 권한을 로컬 k3s 클러스터(Kubernetes v1.35.9, kubectl v1.34.1)에서 직접 실행해 정리합니다.

먼저 전체 그림입니다. 쉘이 없는 app 컨테이너에는 kubectl exec가 실패하지만, kubectl debug는 같은 파드에 도구가 든 임시 컨테이너를 붙이고, 이 컨테이너가 네트워크와 프로세스, 파일을 함께 들여다봅니다.

Pod web 안에 쉘이 없는 distroless app 컨테이너가 있을 때, kubectl exec web -- sh는 실행 파일을 찾지 못해 실패하고, kubectl debug로 붙인 busybox 임시 컨테이너 dbg는 app과 네트워크, 프로세스 공간, 파일을 공유해 들여다볼 수 있다는 그림

먼저 알아둘 용어

용어 뜻
파드 (Pod) 쿠버네티스에서 컨테이너를 실행하는 가장 작은 단위입니다. 한 파드 안의 컨테이너들은 같은 네트워크(localhost)를 씁니다.
distroless 이미지 앱 실행에 필요한 것만 담고 쉘과 ls 같은 기본 도구도 뺀 최소 이미지입니다. 공격 면적이 작은 대신 kubectl exec로 들어가도 쓸 명령이 없습니다. 만드는 방법은 Docker 멀티스테이지 빌드 글에서 다뤘습니다.
임시 컨테이너 (ephemeral container) 이미 떠 있는 파드에 나중에 끼워 넣는 디버깅용 컨테이너입니다. 자동으로 재시작되지 않으며 앱을 만드는 용도가 아닙니다. Kubernetes v1.25부터 stable입니다.
--target 임시 컨테이너가 지정한 컨테이너와 프로세스 공간을 공유하도록 하는 옵션입니다. 이것을 줘야 ps에 대상 앱의 프로세스가 보입니다.
CrashLoopBackOff 컨테이너가 시작하자마자 죽는 일이 반복되어, 쿠버네티스가 재시작 간격을 점점 늘리며 기다리는 상태입니다.
디버그 프로파일 (profile) kubectl debug가 만드는 컨테이너에 줄 권한 설정(securityContext)의 묶음입니다. general, baseline, restricted, netadmin, sysadmin이 있습니다.

왜 필요한가

운영 이미지를 distroless로 줄이면 보안상 이득이지만, 장애가 났을 때 파드 안에서 확인할 수단이 사라집니다. 쉘이 없으니 kubectl exec가 막히고, 도구를 넣은 디버그용 이미지로 파드를 다시 만들면 문제가 났던 상태 자체가 사라집니다. 그래서 파드는 그대로 두고 도구만 잠깐 붙이는 방법이 필요합니다. 쿠버네티스 공식 문서도 임시 컨테이너의 용도로 컨테이너가 죽었거나 이미지에 디버깅 도구가 없어 kubectl exec로는 부족한 경우를 듭니다.

실습 환경 만들기

이 Mac에는 클러스터가 없어서 Docker 안에서 k3s(경량 쿠버네티스)를 임시로 띄웠습니다. k3s의 kubeconfig를 꺼내 서버 주소만 호스트에서 접근할 포트(16443)로 바꿔 씁니다. kubectl은 1.34이므로 버전 차이가 한 단계인 k3s v1.35.9를 골랐습니다.

$ docker run -d --name k3s-demo --privileged -p 16443:6443 rancher/k3s:v1.35.9-k3s1 server --disable=traefik --disable=metrics-server --disable=servicelb --tls-san=127.0.0.1
$ docker exec k3s-demo cat /etc/rancher/k3s/k3s.yaml | sed 's#https://127.0.0.1:6443#https://127.0.0.1:16443#' > kubeconfig
$ export KUBECONFIG=$PWD/kubeconfig
$ kubectl version
Client Version: v1.34.1
Kustomize Version: v5.7.1
Server Version: v1.35.9+k3s1
$ kubectl get nodes -o wide
NAME           STATUS   ROLES           AGE   VERSION        INTERNAL-IP   EXTERNAL-IP   OS-IMAGE           KERNEL-VERSION     CONTAINER-RUNTIME
50473a59c9d0   Ready    control-plane   2s    v1.35.9+k3s1   172.17.0.2    <none>        K3s v1.35.9+k3s1   6.12.54-linuxkit   containerd://2.2.7-k3s1

여기서 봐야 할 부분: 서버가 v1.35.9이고 컨테이너 런타임은 containerd입니다. 아래 출력에서 노드 이름과 긴 ID는 <node>, <id>로 줄여 적었습니다.

실험에 쓸 파드 두 개입니다. web은 정상적으로 도는 파이썬 웹 서버이고, crash는 ConfigMap을 /etc/app에 마운트한 뒤 /etc/app/settings.json을 읽다가 죽습니다. 둘 다 쉘이 없는 distroless 파이썬 이미지입니다.

apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  containers:
  - name: app
    image: gcr.io/distroless/python3-debian12:latest
    args: ["-m", "http.server", "8000", "--directory", "/tmp"]
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  config.json: '{"mode": "prod"}'
---
apiVersion: v1
kind: Pod
metadata:
  name: crash
spec:
  containers:
  - name: app
    image: gcr.io/distroless/python3-debian12:latest
    args: ["-c", "import json; print(json.load(open('/etc/app/settings.json')))"]
    volumeMounts:
    - name: cfg
      mountPath: /etc/app
  volumes:
  - name: cfg
    configMap:
      name: app-config
$ kubectl apply -f web.yaml -f crash.yaml
pod/web created
configmap/app-config created
pod/crash created
$ kubectl get pods
NAME    READY   STATUS    RESTARTS      AGE
crash   0/1     Error     2 (19s ago)   39s
web     1/1     Running   0             39s

1. kubectl exec가 막히는 모습

$ kubectl exec web -- sh
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "<id>": OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
$ kubectl exec web -- ls /tmp
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "<id>": OCI runtime exec failed: exec failed: unable to start container process: exec: "ls": executable file not found in $PATH

여기서 봐야 할 부분: sh뿐 아니라 ls도 없습니다. 이 이미지에는 python3.11 실행 파일 외에 셸 도구가 없기 때문입니다.

2. 임시 컨테이너 붙이기와 --target의 효과

먼저 --target 없이 붙여 봅니다. -c는 새 컨테이너 이름이고, -- 뒤가 그 컨테이너에서 실행할 명령입니다. 임시 컨테이너는 실행이 끝나면 종료되므로 결과는 kubectl logs -c 이름으로 봅니다.

$ kubectl debug web -c dbg-noshare --image=busybox:1.36 -- sh -c 'ps; echo ---; ls /'
--profile=legacy is deprecated and will be removed in the future. It is recommended to explicitly specify a profile, for example "--profile=general".
$ kubectl logs web -c dbg-noshare
PID   USER     TIME  COMMAND
    1 root      0:00 sh -c ps; echo ---; ls /
    7 root      0:00 ps
---
bin
dev
etc
home
lib
lib64
proc
root
sys
tmp
usr
var

여기서 봐야 할 부분: ps 결과의 PID 1이 임시 컨테이너 자신의 sh이고 web의 파이썬 프로세스는 보이지 않습니다. 컨테이너마다 프로세스 공간이 분리되어 있기 때문입니다. 또 --profile을 주지 않으면 legacy 프로파일이 쓰이고 지원 중단 예정이라는 경고가 나옵니다. 이후에는 --profile=general을 명시합니다.

이번에는 --target=app을 줍니다.

$ kubectl debug web -c dbg --image=busybox:1.36 --target=app -- sh -c 'ps; echo ---; ls /proc/1/root/tmp; echo ---; ls /proc/1/root/usr/bin | head -3; echo ---; wget -qO- http://localhost:8000/ | head -5; echo ---; cat /proc/1/cmdline | tr "\0" " "; echo'
Targeting container "app". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
--profile=legacy is deprecated and will be removed in the future. It is recommended to explicitly specify a profile, for example "--profile=general".
$ kubectl logs web -c dbg
PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/python3.11 -m http.server 8000 --directory /tmp
   19 root      0:00 sh -c ps; echo ---; ls /proc/1/root/tmp; echo ---; ls /proc/1/root/usr/bin | head -3; echo ---; wget -qO- http://localhost:8000/ | head -5; echo ---; cat /proc/1/cmdline | tr "\0" " "; echo
   25 root      0:00 ps
---
---
python
python3
python3.11
---
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory listing for /</title>
---
/usr/bin/python3.11 -m http.server 8000 --directory /tmp

여기서 봐야 할 부분: 세 가지입니다.

  • PID 1이 /usr/bin/python3.11 -m http.server 8000, 즉 app의 프로세스로 바뀌었습니다. 임시 컨테이너가 app과 프로세스 공간을 공유하기 때문입니다.
  • /proc/1/root를 통해 app의 파일 시스템을 읽을 수 있습니다. /usr/bin의 python3.11이 보입니다.
  • 같은 파드라서 localhost:8000으로 app의 웹 서버에 바로 접속됩니다.

두 경우의 차이를 그림으로 비교하면 다음과 같습니다.

같은 파드에 busybox 임시 컨테이너를 붙여 ps를 실행한 결과 비교. --target이 없으면 PID 1이 임시 컨테이너 자신의 sh이고 app이 보이지 않지만, --target=app을 주면 PID 1이 app의 python3.11 http.server로 보이고 /proc/1/root로 파일을 읽고 localhost:8000에 접속할 수 있다는 그림

임시 컨테이너의 제약도 확인했습니다. 한 번 붙인 임시 컨테이너는 파드에 남고, 같은 이름으로는 다시 만들 수 없습니다.

$ kubectl get pod web -o jsonpath='{range .status.ephemeralContainerStatuses[*]}{.name}{"\t"}{.state.terminated.reason}{"\t"}exit={.state.terminated.exitCode}{"\n"}{end}'
dbg	Completed	exit=0
dbg-noshare	Completed	exit=0
$ kubectl get pod web -o jsonpath='{.spec.ephemeralContainers[*].name}'
dbg-noshare dbg
$ kubectl debug web -c dbg --image=busybox:1.36 --target=app --profile=general -- true
Targeting container "app". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
The Pod "web" is invalid: spec.ephemeralContainers: Forbidden: existing ephemeral containers "dbg" may not be changed

여기서 봐야 할 부분: 끝난 임시 컨테이너(Completed)도 파드의 spec.ephemeralContainers에 계속 남고, 같은 이름(dbg)으로 다시 만들면 may not be changed로 거부됩니다. 공식 문서도 임시 컨테이너는 추가한 뒤 바꾸거나 지울 수 없고, kubectl edit로 추가할 수도 없다고 설명합니다. 반복해서 디버깅한다면 이름을 매번 다르게 줍니다.

3. CrashLoopBackOff 파드는 복제해서 본다

이제 crash 파드입니다. 로그와 종료 상태는 다음과 같습니다.

$ kubectl logs crash
Traceback (most recent call last):
  File "<string>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/etc/app/settings.json'
$ kubectl get pod crash
NAME    READY   STATUS             RESTARTS      AGE
crash   0/1     CrashLoopBackOff   5 (13s ago)   3m18s
$ kubectl get pod crash -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'
{"containerID":"containerd://<id>","exitCode":1,"finishedAt":"2026-10-29T00:53:30Z","reason":"Error","startedAt":"2026-10-29T00:53:30Z"}

여기서 봐야 할 부분: 로그가 /etc/app/settings.json 파일이 없다는 것까지는 알려 주지만, 파일이 왜 없는지는 알려 주지 않습니다. 마지막 종료 상태의 종료 코드 1은 앱이 오류로 끝났다는 뜻입니다(종료 코드는 Docker 메모리·CPU 제한 직접 확인하기 글의 6번에 정리했습니다). 마운트한 디렉터리 안을 봐야 원인을 알 수 있으니 임시 컨테이너를 붙여 봅니다.

3-1. --target을 주면 임시 컨테이너가 시작되지 못합니다.

$ kubectl debug crash -c dbg --image=busybox:1.36 --target=app --profile=general -- sh -c 'ls -l /proc/1/root/etc/app 2>&1 | head; echo exit=$?'
$ kubectl get pod crash -o jsonpath='{.status.ephemeralContainerStatuses[?(@.name=="dbg")].state.waiting.reason}'
CreateContainerError
$ kubectl get pod crash -o jsonpath='{.status.ephemeralContainerStatuses[?(@.name=="dbg")].state.waiting.message}'
failed to generate container "<id>" spec: failed to generate spec opts: invalid target container: container "<id>" is not running - in state CONTAINER_EXITED

여기서 봐야 할 부분: 대상 컨테이너가 종료된 상태(CONTAINER_EXITED)여서 프로세스 공간을 공유할 수 없다는 invalid target container 오류로 CreateContainerError에 머뭅니다. 앱이 죽어 있는 동안에는 --target 방식이 통하지 않습니다.

3-2. --target 없이 붙이면 실행은 되지만 원하는 것이 보이지 않습니다.

$ kubectl debug crash -c dbg3 --image=busybox:1.36 --profile=general -- sh -c 'ps; echo ---; ls /etc/app 2>&1; echo exit=$?'
$ kubectl logs crash -c dbg3
PID   USER     TIME  COMMAND
    1 root      0:00 sh -c ps; echo ---; ls /etc/app 2>&1; echo exit=$?
    7 root      0:00 ps
---
ls: /etc/app: No such file or directory
exit=1

여기서 봐야 할 부분: 임시 컨테이너는 실행되었지만 /etc/app이 없습니다. kubectl debug가 만드는 임시 컨테이너에는 기본적으로 파드의 볼륨이 마운트되지 않기 때문입니다.

3-3. --copy-to로 파드를 복제합니다. 원본은 그대로 두고, 같은 설정의 새 파드를 만들되 이미지를 busybox로 바꾸고 명령을 sleep 3600으로 바꿔 죽지 않게 합니다. 복제본은 같은 ConfigMap 볼륨을 마운트합니다.

$ kubectl debug crash --copy-to=crash-debug -c app --set-image=app=busybox:1.36 --profile=general -- sleep 3600
$ kubectl get pods
NAME          READY   STATUS             RESTARTS      AGE
crash         0/1     CrashLoopBackOff   4 (58s ago)   2m42s
crash-debug   1/1     Running            0             2s
web           1/1     Running            0             2m42s
$ kubectl exec crash-debug -- ls -l /etc/app
total 0
lrwxrwxrwx    1 root     root            18 Oct 29 00:55 config.json -> ..data/config.json
$ kubectl exec crash-debug -- cat /etc/app/config.json
{"mode": "prod"}
$ kubectl get pod crash-debug -o jsonpath='{.spec.containers[0].image}{" | "}{.spec.containers[0].command}{" | "}{.spec.containers[0].args}{"\n"}'
busybox:1.36 | ["sleep","3600"] |

여기서 봐야 할 부분: 복제본 crash-debug는 Running 상태이고, /etc/app 안에 settings.json이 아니라 config.json이 있습니다. 앱은 settings.json을 읽는데 ConfigMap의 키 이름은 config.json이라서 파일이 없던 것입니다. 해결은 앱이 읽는 경로나 ConfigMap의 키 이름 중 하나를 맞추는 것입니다. 로그만으로는 알기 어려운 원인을 복제본에서 직접 찾았습니다.

세 가지 시도를 한 장으로 정리하면 다음과 같습니다.

CrashLoopBackOff 파드 crash에서 세 가지를 시도한 결과. --target을 준 임시 컨테이너는 대상이 실행 중이 아니어서 CreateContainerError로 실패하고, target 없는 임시 컨테이너는 실행되지만 볼륨이 없어 /etc/app이 보이지 않으며, --copy-to로 복제한 crash-debug는 Running 상태에서 config.json과 settings.json의 이름 불일치를 보여 준다는 그림

복제본은 트래픽을 받지 않습니다. 원본에 라벨을 붙이고 복제해 비교했습니다.

$ kubectl label pod crash app=demo tier=backend
$ kubectl debug crash --copy-to=crash-debug2 -c app --set-image=app=busybox:1.36 --profile=general -- sleep 3600
$ kubectl get pod crash crash-debug2 --show-labels
NAME           READY   STATUS             RESTARTS      AGE     LABELS
crash          0/1     CrashLoopBackOff   6 (92s ago)   7m23s   app=demo,tier=backend
crash-debug2   1/1     Running            0             4s      <none>

여기서 봐야 할 부분: 복제본에는 라벨이 하나도 없습니다. --keep-labels 옵션의 기본값이 false이기 때문입니다. 그래서 라벨로 파드를 고르는 Service의 대상에 들어가지 않아, 복제본이 실제 요청을 받을 걱정은 없습니다. 반대로 라벨을 유지하려면 --keep-labels를 줍니다. 확인이 끝나면 복제본은 kubectl delete pod crash-debug로 지웁니다.

4. 디버그 프로파일: 어떤 권한이 붙는가

임시 컨테이너에 줄 권한은 --profile로 고릅니다. 공식 문서의 설명은 다음과 같습니다.

프로파일 공식 문서 설명
legacy 1.22 동작과 호환되는 속성 (기본값이지만 지원 중단 예정)
general 디버깅 전반에 쓰는 무난한 속성
baseline Pod Security Standard의 baseline 정책과 호환되는 속성
restricted Pod Security Standard의 restricted 정책과 호환되는 속성
netadmin 네트워크 관리자 권한을 포함하는 속성
sysadmin 시스템 관리자(root) 권한을 포함하는 속성

프로파일마다 실제로 어떤 차이가 생기는지 같은 파드에 하나씩 붙여 컨테이너가 가진 권한(CapEff)을 읽어 봤습니다.

$ for p in general baseline restricted netadmin sysadmin; do kubectl debug web -c p-$p --image=busybox:1.36 --target=app --profile=$p --quiet -- sh -c 'grep CapEff /proc/self/status; id -u'; done
$ kubectl logs web -c p-general
CapEff:	00000000a80c25fb
0
$ kubectl logs web -c p-restricted
Error from server (BadRequest): container "p-restricted" in pod "web" is waiting to start: CreateContainerConfigError
$ kubectl get pod web -o jsonpath='{.spec.ephemeralContainers[?(@.name=="p-restricted")].securityContext}'
{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"runAsNonRoot":true,"seccompProfile":{"type":"RuntimeDefault"}}
$ kubectl get pod web -o jsonpath='{.status.ephemeralContainerStatuses[?(@.name=="p-restricted")].state.waiting.message}'
container has runAsNonRoot and image will run as root (pod: "web_default(<uid>)", container: p-restricted)
프로파일 CapEff (유효 권한) 생성된 securityContext 결과
general 00000000a80c25fb capabilities.add: [SYS_PTRACE] 실행됨
baseline 00000000a80425fb 비어 있음 (기본 권한) 실행됨
netadmin 00000000a80435fb capabilities.add: [NET_ADMIN, NET_RAW] 실행됨
sysadmin 000001ffffffffff privileged: true 실행됨 (모든 권한)
restricted - runAsNonRoot: true, capabilities.drop: [ALL] 등 시작 실패 (CreateContainerConfigError)

여기서 봐야 할 부분: 네 가지입니다.

  • general은 baseline보다 CapEff 값이 0x80000 커졌고, 이는 SYS_PTRACE(다른 프로세스를 추적하는 권한) 한 가지가 더해진 것입니다. 프로세스를 들여다보는 디버깅에 맞춘 구성입니다.
  • netadmin은 NET_ADMIN이 더해져 값이 0x1000 커졌습니다. NET_RAW는 기본 권한에 이미 포함되어 있었습니다.
  • sysadmin은 privileged: true라서 모든 권한 비트가 켜졌습니다. 꼭 필요할 때만 씁니다.
  • restricted는 root가 아닌 사용자로 실행하라고 요구하는데 busybox 이미지는 root로 실행되어 시작하지 못했습니다. restricted를 쓰려면 non-root로 실행되는 디버그 이미지가 필요합니다.

5. 노드를 디버깅하기

노드에 SSH로 들어갈 수 없을 때는 kubectl debug node/노드이름으로 그 노드에서 도는 파드를 만들 수 있습니다.

$ kubectl debug node/<node> --image=busybox:1.36 --profile=general -- sh -c 'echo hostname=$(hostname); ls /host | head -5; echo ---; grep PRETTY_NAME /host/etc/os-release; ls /host/etc/rancher/k3s | head -3; id -u'
Creating debugging pod node-debugger-<node>-4lmgl with container debugger on node <node>.
$ kubectl logs node-debugger-<node>-4lmgl
hostname=<node>
bin
dev
etc
lib
proc
---
PRETTY_NAME="K3s v1.35.9+k3s1"
k3s.yaml
0
$ kubectl get pod node-debugger-<node>-4lmgl -o jsonpath='hostPID={.spec.hostPID} hostNetwork={.spec.hostNetwork} hostIPC={.spec.hostIPC} volume={.spec.volumes[0].hostPath.path} mount={.spec.containers[0].volumeMounts[0].mountPath}'
hostPID=true hostNetwork=true hostIPC=true volume=/ mount=/host

여기서 봐야 할 부분: 이 파드는 hostPID, hostNetwork, hostIPC가 모두 true이고, 노드의 루트 디렉터리(/)가 컨테이너의 /host에 마운트됩니다. 그래서 /host/etc/os-release처럼 노드의 파일을 읽을 수 있고, hostname도 노드 이름이 나옵니다. 노드 전체가 보이므로 쓰고 나면 파드를 반드시 지웁니다.

6. 필요한 권한(RBAC)

kubectl debug로 임시 컨테이너를 붙이는 것은 파드의 ephemeralcontainers라는 하위 리소스를 수정하는 일입니다. 파드 조회와 exec 권한만 준 서비스 어카운트로 확인했습니다.

$ kubectl debug web -c v1 --image=busybox:1.36 --profile=general --as=system:serviceaccount:default:viewer -- true
Error from server (Forbidden): pods "web" is forbidden: User "system:serviceaccount:default:viewer" cannot patch resource "pods/ephemeralcontainers" in API group "" in the namespace "default"
$ kubectl create role pod-debug --verb=patch,update --resource=pods/ephemeralcontainers
$ kubectl create rolebinding viewer-debug --role=pod-debug --serviceaccount=default:viewer
$ kubectl debug web -c v1 --image=busybox:1.36 --profile=general --as=system:serviceaccount:default:viewer -- true
$ kubectl get pod web -o jsonpath='{.status.ephemeralContainerStatuses[?(@.name=="v1")].state.terminated.reason}'
Completed

여기서 봐야 할 부분: 권한이 없으면 cannot patch resource "pods/ephemeralcontainers"로 거부됩니다. 이 하위 리소스에 patch(와 update)를 허용하자 임시 컨테이너가 실행되어 Completed가 되었습니다. exec 권한과는 별개이므로, 디버깅 권한은 따로 주고 따로 감사해야 합니다.

상황별로 무엇을 쓰는가

상황 쓰는 방법
컨테이너에 쉘과 도구가 있다 kubectl exec -it 파드 -- sh
쉘이 없지만 앱은 실행 중이다 kubectl debug 파드 -it --image=busybox --target=컨테이너 --profile=general
앱이 죽었다 살아나기를 반복한다 (CrashLoopBackOff) kubectl debug 파드 --copy-to=복제이름 -c 컨테이너 --set-image=컨테이너=busybox -- sleep 3600 후 exec
노드 자체를 봐야 한다 kubectl debug node/노드이름 -it --image=busybox --profile=general

트레이드오프와 주의점

  • 임시 컨테이너는 지울 수 없습니다. 파드가 사라질 때까지 spec에 남고 이름도 다시 못 씁니다. 그래서 같은 파드를 오래 디버깅하면 기록이 쌓입니다.
  • 복제본은 원본과 완전히 같지 않습니다. 이 글의 실험에서는 라벨이 제거되었고, 명령과 이미지도 바꿨습니다. 증상이 원본의 런타임 상태에만 있는 문제는 재현되지 않을 수 있습니다. 복제본은 쓰고 나서 지웁니다.
  • 디버그 이미지를 받을 수 있어야 합니다. 인터넷이 막힌 환경에서는 사내 레지스트리에 디버그용 이미지를 미리 올려 두고 --image로 지정합니다.
  • 권한을 통제합니다. 임시 컨테이너는 대상 파드의 네트워크와 (--target이면) 프로세스, 파일에 접근하고, sysadmin 프로파일이나 노드 디버깅은 훨씬 넓은 권한을 줍니다. pods/ephemeralcontainers 권한은 필요한 사람에게만 줍니다.
  • 스태틱 파드에는 쓸 수 없습니다. 쿠버네티스 문서에 임시 컨테이너는 static pod를 지원하지 않는다고 적혀 있습니다.

정리

  • distroless 같은 쉘 없는 파드는 kubectl exec가 실패하지만, kubectl debug로 busybox 같은 임시 컨테이너를 붙이면 확인할 수 있습니다.
  • --target을 주면 ps에서 대상 앱이 PID 1로 보이고 /proc/1/root로 그 파일을 읽을 수 있었습니다.
  • CrashLoopBackOff 파드에는 --target 임시 컨테이너가 붙지 않았고(invalid target container), 임시 컨테이너에는 볼륨이 기본 마운트되지 않았습니다. 이 경우 --copy-to로 복제해 명령을 바꿔 살려 두고 확인했습니다.
  • 복제본은 기본으로 라벨이 제거되어 Service 트래픽을 받지 않았습니다.
  • --profile로 권한을 고르며 general은 SYS_PTRACE가 추가된 구성이고 sysadmin은 privileged입니다. restricted는 root 이미지로는 시작하지 못했습니다.
  • 임시 컨테이너는 pods/ephemeralcontainers 권한이 필요하고, 한 번 붙이면 지울 수 없습니다.

체크리스트

  • 디버그할 때는 --profile을 명시합니다. 생략하면 지원 중단 예정인 legacy가 쓰입니다.
  • 죽는 파드는 --copy-to로 복제해서 봅니다. 쓰고 나면 복제본을 지웁니다.
  • 임시 컨테이너 이름은 매번 다르게 줍니다. 같은 이름은 다시 쓸 수 없습니다.
  • 필요 이상으로 넓은 프로파일을 쓰지 않습니다. 대부분은 general이면 충분합니다.

요약

쉘이 없는 파드는 kubectl debug로 도구 컨테이너를 붙여 들여다봅니다. --target을 주면 대상 앱의 프로세스와 파일까지 보입니다. 다만 앱이 죽어 있는 CrashLoopBackOff 상태에서는 --target이 통하지 않고 임시 컨테이너에는 볼륨도 기본으로 붙지 않으므로, --copy-to로 파드를 복제해 명령을 바꿔 살려 놓고 확인하는 것이 방법입니다. 프로파일과 pods/ephemeralcontainers 권한으로 어디까지 허용할지 통제합니다.

참고사항

728x90