K8s 트러블슈팅을 관통하는 3가지 멘탈 모델
Declarative Control, Pod Lifecycle, Networking — 대부분의 K8s 문제가 3가지 멘탈 모델로 수렴하는 이유.
도입
트러블슈팅 가이드는 “CrashLoopBackOff이면 이렇게 하세요”라고 말합니다. 그런데 처음 보는 에러가 뜨면? 또 다른 가이드를 찾아야 합니다.
원리를 알면 다릅니다. K8s에서 발생하는 대부분의 문제는 3가지 멘탈 모델 중 하나(또는 교차)로 수렴합니다.
Declarative Control — 누가, 무엇으로 관리하는가Pod Lifecycle — Pod가 왜 이 상태인가Networking — 트래픽이 어디서 끊기는가이 3가지를 이해하면 처음 보는 문제 앞에서도 “첫 번째 질문”을 던질 수 있습니다.
Declarative Control — “누가, 무엇으로 관리하는가”
개념
K8s는 level-triggered 시스템입니다.
- edge-triggered: 이벤트(변화)에 반응 → 이벤트를 놓치면 상태 불일치
- level-triggered: 현재 상태를 주기적으로 확인 → 놓쳐도 다음 루프에서 보정
모든 컨트롤러는 같은 루프를 무한 반복합니다:
flowchart LR A[Desired State] -->|비교| B[Current State] B -->|차이 발견| C[조정 Action] C -->|반영| B이 루프가 K8s의 자가치유(self-healing)를 만듭니다. 컨트롤러가 살아있는 한, 상태는 결국 수렴합니다.
리소스는 ownerReferences로 부모-자식 계층을 형성합니다.
Deployment → ReplicaSet → PodStatefulSet → Pod + PVCCronJob → Job → Pod컨트롤러는 자신이 소유한 자식 리소스를 관리합니다. 자식을 수정하면 부모가 되돌리고, 자식을 삭제하면 부모가 다시 만듭니다.
반대로 부모를 삭제하면? Garbage Collection이 자식을 정리합니다:
| 삭제 모드 | 동작 |
|---|---|
| Background (기본) | 부모 즉시 삭제, 자식은 GC가 비동기 정리 |
| Foreground | 자식 먼저 삭제, 완료 후 부모 삭제 |
| Orphan | 부모만 삭제, 자식은 남김 |
단, StatefulSet의 PVC는 의도적으로 보존됩니다 — 데이터 유실 방지 설계입니다.
삭제가 즉시 완료되지 않는 경우도 있습니다. 리소스에 Finalizer가 등록되어 있으면, 해당 컨트롤러가 정리 작업을 완료할 때까지 리소스는 Terminating 상태에 머뭅니다.
레버리지
K8s에서 가장 흔한 세 가지 혼란이 있습니다:
- “설정을 바꿨는데 되돌아간다”
- “리소스를 삭제했는데 다시 뜬다”
- “삭제했는데 Terminating에서 안 끝난다”
세 증상 모두 같은 모델로 설명됩니다:
- 되돌아간다 → 수정한 곳이 Source of Truth가 아닙니다. 컨트롤러는 자기가 아는 Desired State로 되돌립니다.
- 재생성된다 → 소유자(부모 컨트롤러)가 desired state를 맞추고 있습니다. ownerReferences를 확인하면 소유자를 알 수 있습니다.
- 안 지워진다 → Finalizer가 등록된 컨트롤러가 정리 작업을 완료하지 못한 것입니다.
소유자 확인:
kubectl get <resource> -o yaml | grep -A5 ownerReferences
Source of Truth 체인
“되돌아간다”와 “재생성된다” 모두, 수정해야 할 곳은 체인의 최상위입니다.
단순한 경우:
Deployment → ReplicaSet → Pod실전 복잡도:
Git Repo → ArgoCD → Helm Release → Deployment → RS → PodEnvoyProxy CRD → GatewayClass → Controller → Service → CCM → Cloud LB체인의 어디를 수정하든, 그 위에 컨트롤러가 있으면 되돌아갑니다. 가장 위(upstream)를 찾아서 수정하는 것이 핵심입니다.
적용 범위
| 증상 | 원인 / 확인 |
|---|---|
| Cloud 콘솔에서 LB 설정 변경 → 되돌아감 | K8s Service 어노테이션 (또는 그 위의 CRD)이 Source of Truth |
| HPA가 늘린 replica를 ArgoCD가 원복 | Git manifest에서 replicas 필드 제거 → HPA에 소유권 이전 |
| ConfigMap을 kubectl edit → 원복됨 | GitOps 컨트롤러가 Git 상태로 되돌림 |
| cert-manager 꺼진 후 인증서 만료 | 컨트롤러 미실행 = 조정 중단 → drift 누적 |
| Pod 삭제해도 즉시 재생성 | ownerReferences 확인 — RS/STS/DaemonSet이 소유 중 |
| Deployment 삭제했는데 Pod가 남음 | GC 지연 또는 orphan 모드로 삭제됨 |
| StatefulSet 삭제 후 PVC 남음 | 정상 (by design) — 데이터 보존 |
| Namespace가 Terminating에 멈춤 | 하위 리소스의 Finalizer 처리 미완료 |
| CRD 삭제 후 CR이 Terminating | CRD 먼저 삭제 → 컨트롤러가 Finalizer 처리 불가 |
| PVC가 Terminating에 멈춤 | pvc-protection Finalizer — Pod가 아직 사용 중 |
진단 공식
"되돌아간다" or "재생성된다" or "안 지워진다" → 되돌아감/재생성: 이 리소스를 관리하는 컨트롤러는? (ownerReferences 확인) → 그 컨트롤러의 Source of Truth는? → 체인의 최상위를 수정 → 삭제 안 됨: 어떤 Finalizer가 남아있는가? (kubectl get -o yaml) → 그 Finalizer를 처리하는 컨트롤러가 실행 중인가? → 컨트롤러 복구 또는 (이해한 뒤) Finalizer 수동 제거Pod Lifecycle — “Pod가 왜 이 상태인가”
개념
Pod는 명확한 상태 머신을 따릅니다:
flowchart LR A[Pending] -->|스케줄링| B[PVC Binding /<br>Volume Attach] B -->|마운트| C[ContainerCreating] C --> D[Running] D --> E[Succeeded / Failed] A --> F[Unschedulable] B --> G[Multi-Attach /<br>Mount Error] C --> H[ImagePullBackOff] D --> I[CrashLoopBackOff]각 상태가 실패할 수 있는 지점이 다르고, 진단 방법도 다릅니다.
레버리지
Pod의 상태를 읽으면 문제가 어느 단계에서 발생했는지 즉시 알 수 있습니다. “안 된다”라는 막연한 상황이 “Pending이다” → “스케줄링 실패다”로 구체화됩니다.
단계별 진단
Pending — 스케줄러가 노드를 찾지 못함
스케줄러 파이프라인: Filter(배제) → Score(순위) → Bind(확정)
Filter에서 모든 노드가 탈락하면 Pending이 됩니다. 주요 카테고리:
| 카테고리 | Events 메시지 | 원인 |
|---|---|---|
| Resources | Insufficient cpu/memory | 요청량 > 노드 여유 (requests 기준, 실사용량 아님) |
| Affinity | didn't match pod anti-affinity rules | 모든 노드에 충돌 Pod 존재 |
| Taints | had taint that pod didn't tolerate | 유지보수 후 taint 미제거 등 |
| Volume | volume node affinity conflict | PV가 특정 zone에 묶임 |
실전 함정:
- Request vs 실사용량: 스케줄러는 requests만 봅니다. 클러스터가 한가해 보여도 requests 합계가 allocatable을 넘으면 Pending이 됩니다
- Resource fragmentation: 5노드 x 2CPU, 각 1.5CPU 사용 → 총 2.5CPU 여유지만 1CPU Pod 스케줄 불가 (단일 노드에 1CPU 없음)
- DaemonSet의 숨은 소비: 모니터링 DaemonSet 노드당 500m → 4CPU 노드의 12.5% 선점
- StorageClass Immediate: PVC 먼저 생성 → zone-A에 볼륨 → Pod가 zone-B 필요 → 영원히 Pending.
WaitForFirstConsumer로 해결할 수 있습니다
ContainerCreating에서 멈춤 — 볼륨 단계
스케줄링 성공 후, 컨테이너가 시작되기 전에 볼륨 단계를 거칩니다:
PVC Binding (PVC ↔ PV 매칭) → Volume Attach (PV를 노드에 연결) → Volume Mount (컨테이너 경로에 마운트)각 단계에서 실패할 수 있습니다:
| Events 메시지 | 원인 | 단계 |
|---|---|---|
persistentvolumeclaim not found | PVC가 존재하지 않음 | Binding |
unbound immediate PersistentVolumeClaims | PVC가 Pending — 매칭 PV 없거나 프로비저너 실패 | Binding |
Multi-Attach error for volume | RWO 볼륨이 이전 노드에서 detach 안 됨 | Attach |
FailedAttachVolume | 노드당 볼륨 수 제한 초과 (클라우드별 상이) | Attach |
FailedMount / MountVolume.SetUp failed | 마운트 타임아웃, 파일시스템 손상 | Mount |
흔한 시나리오:
- 노드 이동 후 Multi-Attach: Pod가 새 노드에 뜨는데, RWO 볼륨이 이전 노드에서 detach되지 않는 경우입니다. 이전 노드가 NotReady면 6분(기본
attachDetachReconcilerSyncDuration) 동안 대기합니다. 급하면 이전 노드의 VolumeAttachment를 수동 삭제할 수 있습니다 - PVC Pending:
kubectl describe pvc로 Events를 확인합니다. 프로비저너 Pod가 살아있는지, StorageClass가 존재하는지 점검합니다 - 볼륨 Terminating:
kubernetes.io/pvc-protection파이널라이저가 아직 사용 중인 PVC 삭제를 차단합니다. Pod가 PVC를 사용 중이면 삭제할 수 없습니다
ImagePullBackOff — 이미지를 못 가져옴
| 원인 | 확인 |
|---|---|
| 이미지명/태그 오타 | spec.containers[].image 확인 |
| Private registry 인증 실패 | imagePullSecrets 설정 확인 |
| 네트워크 차단 | 노드에서 registry 접근 가능한지 |
CrashLoopBackOff — 컨테이너가 시작 후 즉시 종료를 반복
kubelet이 재시작 시도 → 실패 → 대기(10s, 20s, 40s… 최대 5분) → 반복합니다.
| 원인 | 확인 |
|---|---|
| 앱 에러 | kubectl logs <pod> (이전 로그: --previous) |
| OOMKilled | kubectl describe pod → Last State: OOMKilled → memory limit 증가 |
| 잘못된 command/args | Dockerfile ENTRYPOINT와 Pod spec 충돌 |
| 의존성 미충족 | DB 연결 실패 등 — 환경변수, Service 이름 확인 |
Running이지만 트래픽이 안 옴 — Readiness Probe 실패
Pod는 Running이지만 Ready 상태가 아니면 Service Endpoint에서 제외됩니다. 공식 문서에서도 이 패턴을 다루고 있습니다.
kubectl get pod <name> -o wide# READY 열이 0/1이면 readiness probe 실패- Probe path/port 오타
- 앱 startup 시간 > initialDelaySeconds
- 리소스 부족으로 응답 지연 > timeoutSeconds
진단 공식
Pod 문제 → kubectl get pod <name> → STATUS 컬럼 확인 → Pending → describe pod → Events (스케줄링) → ContainerCreating → describe pod → Events (볼륨 attach/mount) → kubectl get pvc (PVC Pending 여부) → ImagePullBack → describe pod → Events (이미지) → CrashLoopBack → logs --previous (앱 로그) → Running/NotReady → describe pod → Conditions (probe)Networking — “어디서 끊기는가”
개념
K8s 네트워킹은 4개 계층으로 나뉘고, 각 계층마다 실패 모드가 다릅니다:
┌──────────────────────────────────────────────┐│ Layer 4: External → Cluster ││ Gateway API / LoadBalancer / NodePort │├──────────────────────────────────────────────┤│ Layer 3: Pod → Service ││ kube-proxy (iptables/IPVS) + CoreDNS │├──────────────────────────────────────────────┤│ Layer 2: Pod → Pod (across nodes) ││ CNI plugin (Calico, Cilium, Flannel) │├──────────────────────────────────────────────┤│ Layer 1: Container → Container (same Pod) ││ Shared localhost │└──────────────────────────────────────────────┘레버리지
“서비스에 접근이 안 된다”는 4개 계층 중 어디든 원인일 수 있습니다. 계층을 모르면 DNS 문제인데 CNI를 의심하고, Gateway 문제인데 Service를 뒤지게 됩니다.
진단 방향은 아래에서 위로 (bottom-up)입니다. Pod가 살아있는지 먼저 확인하고, 그다음 Service, 그다음 Gateway/LB 순으로 봅니다.
계층별 진단
Layer 1: Container → Container
같은 Pod 내 컨테이너는 localhost를 공유합니다. 거의 깨지지 않습니다. 깨진다면 Pod spec 자체가 잘못된 것입니다.
Layer 2: Pod → Pod
CNI 플러그인이 Pod 간 통신을 담당합니다.
| 증상 | 원인 |
|---|---|
| Pod 간 ping 불가 | CNI 미설치/장애, 노드 라우팅 문제 |
| 특정 Pod만 통신 불가 | NetworkPolicy가 차단 |
# Pod에서 다른 Pod IP로 직접 통신 테스트kubectl exec <pod> -- curl <other-pod-ip>:<port>Layer 3: Pod → Service
Service는 가상 IP(ClusterIP)입니다. 실체가 없고, iptables/IPVS 규칙으로만 존재합니다. kube-proxy가 이 규칙을 관리하고, CoreDNS가 이름 → ClusterIP를 해석합니다.
| 증상 | 원인 | 확인 |
|---|---|---|
| Service에 접근 불가 | Endpoint가 없음 | kubectl get endpoints <svc> |
| Endpoint가 비어있음 | selector ↔ Pod label 불일치 | selector와 Pod labels 대조 |
| DNS 해석 실패 | CoreDNS Pod 장애 | kubectl get pods -n kube-system -l k8s-app=kube-dns |
| 간헐적 연결 끊김 | conntrack 테이블 포화 | 노드의 conntrack_max 확인 |
가장 흔한 실수는 Service selector와 Pod label 불일치, 그리고 port와 targetPort 혼동입니다. Debug Services 가이드에서 이 진단 과정을 상세히 다루고 있습니다.
Service: selector: app=web ←─┐ port: 80 │ 이 3개가 모두 맞아야 트래픽이 흐릅니다 targetPort: 8080 ←─┐│ ││Pod: ││ labels: app=web ────┘│ containerPort: 8080 ─────┘Layer 4: External → Cluster
외부 트래픽이 클러스터에 진입하는 경로: Gateway API / LoadBalancer / NodePort.
Gateway API는 Ingress의 후속으로, GatewayClass → Gateway → HTTPRoute 3계층 구조입니다. 각 계층마다 독립적으로 실패할 수 있습니다:
| 증상 | 원인 |
|---|---|
Gateway Programmed: False | GatewayClass 미존재 또는 컨트롤러(Envoy, Istio 등) 미실행 |
HTTPRoute Accepted: False | parentRefs의 Gateway 이름/namespace 불일치, listener 미매칭 |
| Route 연결됐는데 트래픽 안 옴 | backendRefs의 Service 이름/포트 오류 |
| LB가 Pending | CCM 미실행 또는 cloud 인증 만료 |
| 502/504 에러 | Backend Pod not ready, health check 실패 |
| TLS 에러 | 인증서 만료, Secret 미연결 |
| 특정 노드에서만 접근 불가 | externalTrafficPolicy: Local + 해당 노드에 Pod 없음 |
진단 공식
"접근이 안 된다" → Pod는 살아있는가? (Layer 1-2) → Service에 Endpoint가 있는가? (Layer 3) → 외부에서 Gateway/LB까지 도달하는가? (Layer 4) → 아래에서 위로, 각 계층의 연결을 확인모델 교차: 복합 문제
까다로운 문제는 단일 모델이 아니라 교차점에서 발생합니다. 각 모델 하나씩만 보면 정상인데, 두 조건이 동시에 걸리면서 장애가 됩니다. 한 모델로 설명이 안 되면, 두 번째 모델을 겹쳐보는 것이 포인트입니다.
Rolling Update 데드락 (Declarative Control + Pod Lifecycle)
- Declarative Control 관점: 새 RS가 Pod를 만들고, 이전 RS가 줄이면 끝납니다. 정상.
- Pod Lifecycle 관점: 노드에 자리가 있으면 스케줄링 성공입니다. 정상.
- 교차하면: 이전 Pod가 노드를 점유 중이라 새 Pod를 띄울 곳이 없고, maxUnavailable: 0이라 이전 Pod를 내릴 수도 없습니다.
Deployment├── old RS (replica: 2) — 기존 Pod가 노드를 점유└── new RS (replica: 2) — 새 Pod 생성 시도데드락 조건: required podAntiAffinity(노드당 1개) + maxUnavailable: 0 + 노드 수 = replica 수
새 Pod를 띄울 노드가 없고, 이전 Pod를 내릴 수도 없습니다. kubernetes/kubernetes#116891 — K8s에서 공식 미해결 이슈입니다.
해소법:
maxUnavailable: 1(순간 용량 감소 허용)preferredanti-affinity (임시 같은 노드 허용)- 이전 RS를 수동 scale 0 (긴급 대응)
LB Health Check 실패 (Pod Lifecycle + Networking)
- Pod Lifecycle 관점: Pod가 node-1에 배치되어 정상 Running입니다. 문제 없습니다.
- Networking 관점: LB가 각 노드에 health check를 보냅니다. 정상 동작입니다.
- 교차하면:
externalTrafficPolicy: Local은 Pod가 있는 노드에서만 응답합니다. node-2에 Pod가 없으면 health check 실패 → 503이 발생합니다.
노드 이동 후 Multi-Attach (Pod Lifecycle + Declarative Control)
- Pod Lifecycle 관점: 새 노드에 Pod가 스케줄링됩니다. 정상.
- Declarative Control 관점: AD Controller가 볼륨 attach/detach를 조정합니다. 정상.
- 교차하면: RWO 볼륨은 동시에 하나의 노드에만 attach 가능합니다. 이전 노드가 NotReady면 detach 완료까지 6분(기본값) 대기하게 되고, 그 사이 새 Pod는 ContainerCreating에 멈춥니다.
설정 변경이 5단계 뒤에 반영 (Declarative Control 다단계)
단일 모델이지만, 체인이 길어서 복합적으로 느껴지는 경우입니다:
EnvoyProxy CRD → GatewayClass → Controller → Service → CCM → Cloud LB중간 어디를 수정하든 그 위의 컨트롤러가 되돌립니다. 최상위(upstream)를 찾아야 합니다.
진단 알고리즘
모든 K8s 문제에 적용할 수 있는 4단계입니다:
Step 1: 어떤 모델인가? "되돌아간다/재생성/안 지워진다" → Declarative Control → "되돌아감/재생성" → Source of Truth 체인 역추적 → "삭제 안 됨" → Finalizer 확인 "Pending/Crash/NotReady" → Pod Lifecycle "접근이 안 된다" → Networking
Step 2: Desired vs Current 갭은? spec(원하는 상태)과 status(현재 상태)를 비교합니다
Step 3: 무엇이 수렴을 막는가? 컨트롤러가 안 돌고 있는가? 잘못된 레벨을 수정하고 있는가? (Declarative Control) 어떤 Filter에 걸리는가? (Pod Lifecycle) 어떤 계층이 끊겼는가? (Networking)
Step 4: Events를 확인합니다 kubectl describe <resource> → Events kubectl get events --field-selector type!=Normal핵심 정리
| 증상 | 멘탈 모델 | 첫 질문 |
|---|---|---|
| 되돌아간다 / 재생성 / 안 지워진다 | Declarative Control | Source of Truth는? 소유자는? Finalizer는? |
| Pod가 뜨지 않거나 죽는다 | Pod Lifecycle | STATUS가 뭐라고? Events에 뭐라고? |
| 트래픽이 도달하지 않는다 | Networking | 어떤 계층에서 끊기는가? |
| 복합 문제 | 모델 교차 | 어떤 모델 2개가 충돌하는가? |
증상별 해결법을 외우는 것보다, 모델을 익히는 것이 효과적입니다. 3가지면 대부분의 K8s 트러블슈팅이 수렴합니다.
관련 콘텐츠
Gateway API, Ingress를 대체하는 Kubernetes 표준
SIG-Network이 4년에 걸쳐 만든 Gateway API의 핵심 리소스 3가지와 그 관계를 이해하고, Ingress와 무엇이 달라졌는지 정리한다.
KubernetesKubernetes 노드 메모리 리밸런싱 — OOMKilled 해결과 워크로드 분산
Control Plane 노드에 메모리 대식가 워크로드가 몰려 OOMKilled가 반복됐다. fork()와 Copy-on-Write 메커니즘을 이해하고 affinity/toleration으로 워크로드를 분산한 과정.
KubernetesNGINX Ingress EOL 대응: OCI에서 Envoy Gateway로 마이그레이션
NGINX Ingress Controller 지원 종료에 대비해 OCI Always Free 클러스터에서 Envoy Gateway로 마이그레이션한 경험을 공유합니다.