CloudNet@ Team Study/EKS Workshop 4th Cohort

Sharing actual EKS troubleshooting details

"Everything about infra" 2026. 4. 19. 02:08
포스팅은 CloudNet@팀 서종호(Gasida)님이 진행하시는
AWS EKS Workshop Study 내용을 참고하여 작성합니다.

 

이번 강의는 AWS 정영준님께서

EKS 트러블 슈팅 가이드를 열강해주셨는데요,

 

5주차 과제로 실제 트러블 슈팅 내용을 공유드리고

어떻게 해결했는지 내용을 한번 담아보도록 하겠습니다.

 

 

EKS 운영에서 가장 자주 마주치는 트러블슈팅 TOP 3

환경 기준: EKS 1.32+, kubectl 1.30+, AWS CLI v2
스터디: CloudNet@ AEWS 5주차


실제 EKS 클러스터를 운영하다 보면 반복적으로 마주치는 문제 패턴들이 있습니다. 이 글에서는 빈도가 높고, 서비스 장애로 직결되며, 원인-진단-해결 흐름이 단계로 3가지 트러블슈팅 시나리오를 정리해보려 합니다. 명령어와 함께 "왜 이 오류가 발생하는가" 에 집중해서 읽으면 훨씬 도움이 될 것같습니다.


Case 1. Pod CrashLoopBackOff / OOMKilled

1-1. CrashLoopBackOff란 ?

CrashLoopBackOff는 Pod 상태(Status)가 아니라 Kubernetes가 컨테이너를 반복 재시작하는 과정에서

붙이는 이유(Reason) 입니다. 흐름은 아래와 같습니다.

컨테이너 시작 → 비정상 종료(Exit Code != 0) → kubelet이 재시작 시도
→ 또 비정상 종료 → 재시작 간격을 점점 늘림 (Exponential Backoff)
→ 대기 시간이 길어진 상태를 CrashLoopBackOff로 표시

재시작 간격은 10s → 20s → 40s → 80s → 160s → 최대 300s 순으로 증가합니다.

즉, CrashLoopBackOff 자체가 오류 원인이 아니고, 컨테이너가 왜 종료됐는지를 찾아야 합니다.

컨테이너가 비정상 종료되는 원인은 크게 다음 4가지로 정리를 해보았습니다.

 

원인 설명

OOMKilled 컨테이너에 설정된 메모리 limit을 초과해 커널이 강제 Kill
앱 내부 오류 컨테이너 entrypoint 프로세스가 비정상 종료 (Exit Code 1 등)
Liveness Probe 실패 kubelet이 앱이 죽었다고 판단하여 강제 재시작
ConfigMap / Secret 마운트 실패 의존 리소스가 없어 컨테이너 자체가 시작 불가

 

OOMKilled: 왜 발생하는가?

📌 메모리 Limit과 cgroup의 관계

Kubernetes에서 resources.limits.memory를 설정하면, kubelet은 해당 컨테이너를 위한

Linux cgroup(Control Group) 에 메모리 상한선을 설정합니다. 컨테이너 내부 프로세스가 이 상한을 넘어서는

순간, Linux 커널의 OOM Killer가 해당 프로세스에 SIGKILL(Signal 9)을 보내 강제 종료해요.

 

이때 Exit Code는 128 + 9 = 137 이 됩니다. (128 + signal number)

프로세스의 메모리 사용량 > cgroup limits.memory
→ 커널 OOM Killer 발동
→ SIGKILL 전송 (Exit Code 137)
→ kubelet이 컨테이너 재시작
→ 반복되면 CrashLoopBackOff

 

왜 limits를 넘기는가?

단순히 limits를 낮게 잡은 것 외에도 다음 이유들이 있습니다.

  • JVM Heap 설정 미스: Java 앱은 -Xmx로 Heap을 제한해도 Off-Heap(메타스페이스, 네이티브 메모리, 스레드 스택)이 추가로 사용됩니다. -Xmx512m을 설정해도 실제 프로세스 메모리는 훨씬 클 수 있다.
  • 메모리 누수: 앱 코드의 버그로 메모리가 점점 증가
  • 캐시 설정 미스: Redis 클라이언트, 커넥션 풀 등이 메모리를 과다하게 선점
  • limits 미설정: limits를 설정하지 않으면 노드 전체 메모리를 쓸 수 있어 노드 전체가 불안정해진다.

 

Liveness Probe 실패로 인한 재시작: 왜 발생하는가?

Probe는 kubelet이 컨테이너의 상태를 주기적으로 체크하는 메커니즘입니다.

3가지 종류가 있고 각각 역할이 다르며, Probe 종류 역할 실패 시 결과를 표로 정리해보았습니다.

startupProbe 앱이 최초 시작 완료됐는지 확인 실패 시 컨테이너 재시작. 성공 전까지 liveness/readiness
비활성화
livenessProbe 앱이 살아있는지(데드락 감지) 확인 실패 시 컨테이너 재시작
readinessProbe 앱이 트래픽을 받을 준비가 됐는지 확인 실패 시 Service Endpoints에서 제거 (재시작 없음)

 

CrashLoopBackOff를 유발하는 Probe 오류 패턴

패턴 1: livenessProbe의 initialDelaySeconds가 앱 부팅 시간보다 짧은 경우

Spring Boot 같은 JVM 앱은 부팅에 30~60초가 걸리는 경우가 있습니다.

initialDelaySeconds: 5 로 설정하면, 앱이 아직 시작 중인 5초 시점에 kubelet이 /health 를 찔러요

 

당연히 404 또는 Connection Refused가 나고, kubelet은 앱이 죽었다고 판단해 재시작합니다.

앱이 부팅될 새도 없이 계속 재시작되는 무한루프가 돌게돼요.

앱 부팅 시작 (0초)
→ livenessProbe 실행 (5초) → 아직 부팅 중 → 실패
→ failureThreshold(3회) 도달
→ kubelet이 컨테이너 Kill → 재시작
→ 반복 → CrashLoopBackOff

 

패턴 2: livenessProbe에 외부 의존성(DB 등)을 포함한 경우

/health 엔드포인트 구현 시 DB 연결을 체크하는 로직을 넣으면, DB가 잠깐 느려지거나

네트워크 지연이 생길 때 멀쩡한 앱 Pod 전체가 재시작됩니다.

DB 일시적 응답 지연 (3초)
→ livenessProbe timeout (3초) → 실패
→ failureThreshold 도달 → 모든 Pod 재시작
→ 재시작된 Pod들도 DB에 연결 시도 → DB 부하 폭증
→ 더 많은 Pod 재시작 → Cascading Failure

이게 바로 Cascading Failure(연쇄 장애)다. livenessProbe는 반드시

앱 자체의 상태(데드락, 무한루프)만 체크해야 합니다.

 

1-2 진단: 원인 찾기

# STEP 1: 비정상 Pod 목록 확인
kubectl get pods -n <namespace> | grep -v Running | grep -v Completed

# STEP 2: 이전 컨테이너 종료 원인 확인 (가장 중요)
kubectl describe pod <pod-name> -n <namespace>
# 아래 필드를 집중해서 본다:
# - Last State.Reason: OOMKilled 또는 Error
# - Last State.Exit Code: 137(OOM), 1(앱 오류), 143(SIGTERM)
# - Events: Warning Unhealthy → Probe 실패 메시지

# STEP 3: 이전 크래시 로그 확인
# CrashLoopBackOff 상태에서는 컨테이너가 이미 죽었으므로 --previous 플래그 필수
kubectl logs <pod-name> -n <namespace> --previous

# 멀티 컨테이너 Pod의 경우
kubectl logs <pod-name> -n <namespace> -c <container-name> --previous

# STEP 4: 현재 리소스 사용량 확인
kubectl top pod <pod-name> -n <namespace>

# STEP 5: 설정된 resource limits 확인
kubectl get pod <pod-name> -n <namespace> \
  -o jsonpath='{.spec.containers[*].resources}'

 

📌 Exit Code 해석표

Exit Code Signal 원인

0 - 정상 종료 (Job 완료 등)
1 - 앱 내부 오류 (코드 버그, 설정 오류)
2 - 쉘 명령어 오류
137 SIGKILL (9) OOMKilled 또는 docker kill
143 SIGTERM (15) Graceful Termination (정상적인 종료 시도)
255 - 컨테이너 런타임 오류

kubectl describe pod 출력 예시 : OOMKilled:

Containers:
  app:
    Last State:   Terminated
      Reason:     OOMKilled
      Exit Code:  137
      Started:    Sun, 19 Apr 2026 10:00:00 +0900
      Finished:   Sun, 19 Apr 2026 10:00:45 +0900

kubectl describe pod 출력 예시 : Probe 실패:

Events:
  Warning  Unhealthy  3m  kubelet
    Liveness probe failed: HTTP probe failed with statuscode: 503
  Warning  Killing    3m  kubelet
    Container app failed liveness probe, will be restarted

 

1-3. 해결

OOMKilled 해결 : resource limits 조정

# deployment.yaml
containers:
- name: app
  resources:
    requests:
      memory: "256Mi"   # 스케줄링 기준 (이 메모리가 있는 노드에 배치)
      cpu: "250m"
    limits:
      memory: "512Mi"   # 이 값을 초과하면 OOMKilled 발생
      cpu: "500m"
# 파일로 적용
kubectl apply -f deployment.yaml

# 즉시 patch (긴급 대응 시)
kubectl patch deployment <deployment-name> -n <namespace> \
  --type='json' \
  -p='[{"op":"replace","path":"/spec/template/spec/containers/0/resources/limits/memory","value":"512Mi"}]'

주의: limits를 늘리는 건 임시 조치입니다.

근본 원인(메모리 누수, JVM Heap 설정)은 반드시 앱 레벨에서 분석해야 해요!

JVM 앱이라면 컨테이너 메모리 limit에 맞게 -XX:MaxRAMPercentage=75 옵션 사용을 권장합니다.

Probe 설정 교정 : 3단계 Probe 패턴

containers:
- name: app
  image: my-spring-app:latest

  # [1단계] startupProbe: 앱이 완전히 기동될 때까지 기다린다
  # failureThreshold(30) × periodSeconds(10) = 최대 300초 대기
  # 이 Probe가 성공하기 전까지 liveness/readiness는 실행되지 않는다
  startupProbe:
    httpGet:
      path: /actuator/health
      port: 8080
    failureThreshold: 30
    periodSeconds: 10

  # [2단계] readinessProbe: 트래픽 수신 준비 여부 확인
  # 실패하면 재시작이 아니라 Service Endpoints에서 제거됨
  readinessProbe:
    httpGet:
      path: /actuator/health/readiness
      port: 8080
    initialDelaySeconds: 10
    periodSeconds: 5
    timeoutSeconds: 3
    failureThreshold: 3

  # [3단계] livenessProbe: 데드락 감지 전용
  # 절대로 DB 연결, 외부 API 체크를 넣지 않는다
  livenessProbe:
    httpGet:
      path: /actuator/health/liveness
      port: 8080
    initialDelaySeconds: 60
    periodSeconds: 10
    timeoutSeconds: 5
    failureThreshold: 3

실행 중인 Pod 내부 디버깅 : Ephemeral Container

컨테이너가 distroless 이미지처럼 쉘이 없거나, CrashLoopBackOff 상태라서 exec가 안 될 때:

# 실행 중인 Pod에 디버깅 컨테이너 붙이기
# nicolaka/netshoot: curl, dig, tcpdump 등 네트워크 도구 모음
kubectl debug <pod-name> -it \
  --image=nicolaka/netshoot \
  --target=<container-name>

# Pod 복제 후 이미지 교체 (원본 Pod 유지)
kubectl debug <pod-name> \
  --copy-to=debug-pod \
  --image=ubuntu

# 노드 자체에 접근 (호스트 파일시스템은 /host 에 마운트됨)
kubectl debug node/<node-name> -it --image=ubuntu

 

📌 요약 체크리스트

□ kubectl describe pod → Last State.Reason 확인
□ Exit Code 137 → OOMKilled → memory limits 상향
□ kubectl logs --previous → 이전 컨테이너 로그 확인
□ READY 0/1 이면 readinessProbe 실패 → 재시작 없음, Endpoint에서 제거된 것
□ RESTARTS 증가 + Running → livenessProbe 실패로 재시작 중
□ JVM 앱 → startupProbe 필수, initialDelaySeconds 충분히 확보
□ livenessProbe에 DB 연결 체크 포함 여부 확인 → 즉시 제거

 


Case 2. Service Endpoint 없음 / Pod 간 통신 불가

📌 Kubernetes Service가 트래픽을 전달하는 원리

Service가 실제로 어떻게 동작하는지 이해해야 Endpoint가 왜 비어있는지 알 수 있습니다.

[클라이언트 Pod]
      ↓ (1) DNS 조회: web-service.default.svc.cluster.local
[CoreDNS] → ClusterIP 반환 (예: 10.100.50.10)
      ↓ (2) ClusterIP로 패킷 전송
[노드 커널 iptables / ipvs]
      ↓ (3) kube-proxy가 설정한 규칙으로 실제 Pod IP로 DNAT
[백엔드 Pod IP] (예: 192.168.1.5)

여기서 핵심은 (3) kube-proxy가 어떤 Pod IP로 DNAT할지를 결정하는 데이터가 바로 Endpoints 라는 것!

Kubernetes의 Endpoints Controller는 다음 조건을 모두 만족하는 Pod만 Endpoints에 등록합니다.

  1. Pod의 label이 Service의 selector와 정확히 일치
  2. Pod이 Ready 상태 (readinessProbe 성공)
  3. Pod이 Running 상태

따라서 Endpoints가 <none> 이면 위 3가지 중 하나 이상이 어긋난 것이다.

 

2-1. Label Selector 불일치

왜 발생하는가?

Service는 selector 필드에 지정한 label key-value를 모두 가진 Pod에만 트래픽을 보냅니다.

label은 문자열 완전 일치이기 때문에 오타 한 글자, 대소문자 차이, 키 이름 불일치로 Endpoints가 비어버립니다.

 

자주 발생하는 오타 패턴:

Service selector: version=v1
Pod label:        ver=v1          ← 키 이름이 "ver" vs "version"

Service selector: app=web-server
Pod label:        app=webserver   ← 하이픈 유무

Service selector: env=production
Pod label:        env=prod        ← 값 축약

이런 불일치는 팀원이 각자 Deployment와 Service를 만들거나,

Helm chart 값을 일부만 오버라이드할 때 자주 발생합니다.

 

📌 진단

# Service selector 확인
kubectl get svc <service-name> -n <namespace> \
  -o jsonpath='{.spec.selector}'
# 출력: {"app":"web","version":"v1"}

# 실제 Pod label 확인 (selector 값과 직접 비교)
kubectl get pods -n <namespace> --show-labels
# NAME       READY  STATUS   LABELS
# web-abc    1/1    Running  app=web,ver=v1   ← "version" 아닌 "ver"

# selector 기준으로 매칭되는 Pod이 있는지 직접 확인
kubectl get pods -n <namespace> -l app=web,version=v1
# No resources found   ← 없으면 selector 문제

 

📌 해결

# Service selector를 Pod label에 맞춰 수정
kubectl patch svc <service-name> -n <namespace> \
  -p '{"spec":{"selector":{"app":"web","ver":"v1"}}}'

# 또는 Deployment의 label을 Service selector에 맞춰 수정
# spec.template.metadata.labels 를 수정하고 apply
# (이 경우 Rolling Update가 트리거됨)

 

 

2-2 port / targetPort 불일치

왜 발생하는가?

Service에는 두 가지 포트 개념이 있습니다.

  • port: Service가 클라이언트에게 노출하는 포트 (ClusterIP:port)
  • targetPort: 실제 Pod 컨테이너가 리스닝하는 포트 (트래픽이 최종 도달하는 곳)

targetPort가 실제 컨테이너 포트와 다르면, kube-proxy는 올바른 Pod IP로 패킷을 전달하지만

Pod 내 프로세스가 해당 포트를 듣고 있지 않아 Connection Refused가 발생합니다.

Endpoints는 정상적으로 채워져 있어서 DNS/Service 설정 문제로 오해하기 쉬워요 !

# 현재 Service 포트 설정 확인
kubectl get svc <service-name> -n <namespace> -o yaml | grep -A10 ports
# ports:
#   - port: 80
#     targetPort: 8080   ← 이게 실제 컨테이너 포트와 다르면 Connection Refused

# Pod이 실제로 리스닝하는 포트 확인
kubectl get pod <pod-name> \
  -o jsonpath='{.spec.containers[*].ports[*].containerPort}'
# 9090   ← 실제는 9090인데 Service는 8080으로 설정됨

# 또는 Pod 내부에서 직접 확인
kubectl exec <pod-name> -- ss -tlnp
# LISTEN   0   128   *:9090   *:*

 

📌 해결

kubectl patch svc <service-name> -n <namespace> \
  -p '{"spec":{"ports":[{"port":80,"targetPort":9090,"protocol":"TCP"}]}}'

 

 

2-3. Pod가 NotReady 상태

왜 발생하는가?

앞서 설명했듯, Endpoints Controller는 readinessProbe를 통과한 Pod만 Endpoints에 등록합니다.

Pod가 Running 상태여도 READY 컬럼이 0/1 이면 그 Pod은 Endpoints에 없습니다.

 

이 설계는 의도적이에요. 아직 초기화 중이거나, 일시적으로 과부하 상태인 Pod에게 트래픽을 보내지 않기 위해서다.

# Endpoints에 등록된 Pod IP 확인
kubectl get endpoints <service-name> -n <namespace>
# NAME          ENDPOINTS
# web-service   192.168.1.5:8080, 192.168.2.3:8080   ← 정상
# web-service   <none>                                 ← 모든 Pod이 NotReady

# Pod의 READY 상태 확인
kubectl get pods -n <namespace> -o wide
# NAME      READY   STATUS    NODE
# web-abc   0/1     Running   ip-10-0-1-1  ← 0/1: Running이지만 NotReady

# readinessProbe 실패 원인 확인
kubectl describe pod <pod-name> -n <namespace> | grep -A5 "Readiness"
# Readiness probe failed: HTTP probe failed with statuscode: 404

 

 

2-4. LoadBalancer가 Pending인 경우

왜 발생하는가?

EKS에서 type: LoadBalancer 또는 Ingress를 생성하면 실제 AWS ELB/ALB를 프로비저닝해야 한다.

이 작업은 AWS Load Balancer Controller가 담당합니다.

 

이 Controller가 없거나, AWS API를 호출할 권한(IRSA)이 부족하면 LoadBalancer는 영원히 Pending 상태다.

자주 발생하는 원인을 아래와 같이 정리해보았습니다.

 

📌 원인 증상

AWS LB Controller 미설치 EXTERNAL-IP가 <pending>으로 고정
IRSA 권한 부족 LB Controller 로그에 AccessDenied
서브넷 태그 누락 Controller가 어느 서브넷에 LB를 만들지 모름
Annotation 오타 ALB 대신 NLB가 만들어지거나 아무것도 안 됨
# Service 상태 확인
kubectl get svc -n <namespace>
# TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)
# LoadBalancer   10.100.50.10   <pending>     80:30001/TCP

# LB Controller Pod 확인
kubectl get pods -n kube-system \
  -l app.kubernetes.io/name=aws-load-balancer-controller

# LB Controller 로그에서 오류 확인
kubectl logs -n kube-system \
  -l app.kubernetes.io/name=aws-load-balancer-controller \
  --tail=100 | grep -i "error\|denied\|failed"

# 서브넷 태그 확인 (외부 LB용 태그)
aws ec2 describe-subnets \
  --subnet-ids <subnet-id> \
  --query 'Subnets[].Tags[?Key==`kubernetes.io/role/elb`]'
# 값이 없으면 LB Controller가 서브넷을 인식 못 함

# 필요 시 서브넷 태그 추가
aws ec2 create-tags \
  --resources <subnet-id> \
  --tags Key=kubernetes.io/role/elb,Value=1

 

 

2-5. NetworkPolicy가 트래픽 차단

왜 발생하는가?

Kubernetes NetworkPolicy는 Pod 간 트래픽을 L3/L4 레벨에서 제어합니다.

한 번 Default Deny 정책이 적용되면, 명시적으로 허용하지 않은 모든 트래픽은 차단됩니다 !

문제는 AND / OR 로직을 YAML indent로 구분하기 때문에 작성 실수가 잦다는 것이에요.

# 네임스페이스에 적용된 NetworkPolicy 확인
kubectl get networkpolicy -n <namespace>

# 특정 Policy 내용 확인
kubectl describe networkpolicy <policy-name> -n <namespace>

AND vs OR 혼동 — indent 한 칸 차이가 완전히 다른 정책

# [AND 로직]: "alice 네임스페이스" AND "role=client 라벨" 둘 다 만족해야 허용
# namespaceSelector와 podSelector가 같은 블록(-)에 있다
ingress:
- from:
  - namespaceSelector:
      matchLabels:
        user: alice
    podSelector:          # ← 하이픈(-) 없이 같은 레벨 = AND
      matchLabels:
        role: client
# [OR 로직]: "alice 네임스페이스" OR "role=client 라벨" 하나만 만족해도 허용
# 각 selector가 별도 블록(-)으로 분리되어 있다
ingress:
- from:
  - namespaceSelector:
      matchLabels:
        user: alice
  - podSelector:          # ← 하이픈(-) 있음 = OR (별도 항목)
      matchLabels:
        role: client

의도한 것보다 넓은 OR 정책이 적용되거나, 반대로 좁은 AND 정책으로 인해

트래픽이 차단되는 경우 모두 발생합니다.

# NetworkPolicy 테스트: netshoot으로 연결 직접 확인
kubectl run tmp-shell --rm -i --tty \
  --image nicolaka/netshoot -- bash

# 내부에서:
# DNS 해석 확인
dig web-service.default.svc.cluster.local

# HTTP 연결 테스트
curl -v http://web-service.default.svc.cluster.local:80/health

# TCP 포트 연결 확인
nc -zv web-service.default.svc.cluster.local 80

# 패킷 캡처 (특정 Pod IP로의 트래픽)
tcpdump -i any host  -n

 

 

📌 요약 체크리스트

□ kubectl get endpoints → <none> 확인
□ Service selector vs Pod label 직접 비교 (오타, 키 이름 불일치)
□ kubectl get pods -l <selector> → 매칭 Pod 직접 조회
□ Pod READY 상태 확인 (0/1 이면 Endpoints에서 제외됨)
□ Service targetPort vs 실제 containerPort 비교
□ LoadBalancer Pending → LB Controller 설치 및 로그 확인
□ 서브넷 태그 (kubernetes.io/role/elb: 1) 존재 여부
□ NetworkPolicy AND/OR 로직 indent 확인
□ netshoot으로 내부에서 직접 curl / dig 테스트

 

 


Case 3. CoreDNS 장애 / DNS 해석 실패

📌 EKS DNS 아키텍처 이해

DNS가 왜 장애나는지 알려면 EKS에서 DNS가 어떻게 동작하는지 먼저 알아야 한다.

[Pod 내부 앱]
      ↓ DNS 쿼리 (예: web-service.default.svc.cluster.local)
[Pod의 /etc/resolv.conf]
  nameserver: 10.100.0.10   ← kube-dns Service IP (CoreDNS)
      ↓
[CoreDNS Pod (kube-system)]
  - 클러스터 내부 도메인 → 직접 응답
  - 외부 도메인 → VPC DNS Resolver로 forward
      ↓ (외부 도메인인 경우)
[VPC DNS Resolver] (169.254.169.253 또는 VPC+2 주소)
      ↓
[외부 DNS / Route53]

여기서 장애 포인트는 3곳입니다.

  1. CoreDNS Pod 자체가 죽는 경우 → 클러스터 전체 DNS 해석 불가
  2. ndots:5로 인한 쿼리 폭증 → CoreDNS / VPC DNS 과부하
  3. VPC DNS throttling → ENI 한도 초과로 DNS 응답 지연

 

3-1. CoreDNS OOMKilled

왜 발생하는가?

CoreDNS는 클러스터 모든 Pod의 DNS 쿼리를 처리합니다. 클러스터 규모가 커질수록

쿼리 수가 비례해서 증가하고, 기본 메모리 limits(170Mi)로는 감당이 안 되는 시점이 오겠죠 ?

 

특히 아래 상황에서 CoreDNS OOM이 자주 발생한다:

  • Pod 수가 급격히 증가하는 경우 (오토스케일링, 배포 러시)
  • ndots:5 설정으로 불필요한 쿼리가 수배 증폭되는 경우
  • DNS TTL이 너무 짧아 캐싱이 안 되는 경우

CoreDNS가 OOMKilled 되면 잠시 재시작하는 동안 클러스터 전체의 DNS 해석이 실패합니다.

nslookup, curl 이 모두 실패하고, 앱에서 dial tcp: lookup ... no such host 오류가 대량 발생해요.

# CoreDNS 상태 확인
kubectl get pods -n kube-system -l k8s-app=kube-dns
# NAME                       READY   STATUS      RESTARTS
# coredns-5d78c9869d-abc12   0/1     OOMKilled   5

# OOMKilled 확인
kubectl describe pods -n kube-system -l k8s-app=kube-dns \
  | grep -A5 "Last State"
# Last State:   Terminated
#   Reason:     OOMKilled

# 현재 메모리 사용량 확인
kubectl top pods -n kube-system -l k8s-app=kube-dns
# NAME                     CPU   MEMORY
# coredns-xxx              50m   160Mi   ← limits(170Mi)에 근접

# CoreDNS 로그에서 오류 확인
kubectl logs -n kube-system -l k8s-app=kube-dns --tail=50

 

📌 해결

# CoreDNS 메모리 limits 증가
kubectl set resources deployment coredns -n kube-system \
  --limits=memory=300Mi \
  --requests=memory=100Mi

# 즉시 Rolling Restart
kubectl rollout restart deployment coredns -n kube-system

# 재시작 완료 확인
kubectl rollout status deployment coredns -n kube-system
# deployment "coredns" successfully rolled out

 

 

3-2. ndots:5로 인한 불필요한 DNS 쿼리 폭증

ndots:5는 왜 존재하는가?

ndots:5는 Kubernetes 클러스터 내부 서비스 이름 해석을 편리하게 하기 위해 존재합니다.

web-service 처럼 짧은 이름을 쿼리하면, /etc/resolv.conf의

search domain을 붙여서 FQDN을 만들어 시도해요.

web-service
  → web-service.default.svc.cluster.local   ← 성공! (클러스터 내부 서비스)

.이 5개 미만인 도메인은 search domain을 먼저 시도하도록 ndots:5로 설정하면,

web-service.default.svc.cluster.local 처럼 .이 5개인 FQDN도 한 번에 해석할 수 있다.

문제: 외부 도메인을 조회할 때

외부 API (api.example.com, .이 2개)를 조회하면:

1. api.example.com.default.svc.cluster.local  → NXDOMAIN (실패)
2. api.example.com.svc.cluster.local          → NXDOMAIN (실패)
3. api.example.com.cluster.local              → NXDOMAIN (실패)
4. api.example.com.                           → 성공 (외부 DNS)

실제로 필요한 쿼리 1번에 3번의 불필요한 쿼리가 선행됩니다. Pod가 1,000개라고 가정해보겠습니다.

각 Pod가 초당 1회씩 외부 API를 호출한다면, 초당 3,000개의 쓸모없는 DNS 쿼리가 발생해요.

 

이게 CoreDNS 과부하와 VPC DNS throttling의 주범이다.

# Pod 내부의 resolv.conf 확인
kubectl exec <pod-name> -- cat /etc/resolv.conf
# nameserver 10.100.0.10
# search default.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5

# dig로 실제 쿼리 순서 확인 (netshoot에서 실행)
kubectl run tmp-shell --rm -i --tty --image nicolaka/netshoot -- bash
# 내부에서:
dig api.example.com +search +showsearch
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN
# ;api.example.com.default.svc.cluster.local. ← 첫 번째 시도

해결 방법 1: Pod에서 ndots 값 낮추기

# Deployment spec.template.spec 에 추가
spec:
  template:
    spec:
      dnsConfig:
        options:
        - name: ndots
          value: "2"    # 5 → 2로 감소
                        # .이 2개 미만인 도메인만 search domain 시도
      containers:
      - name: app
        image: my-app:latest

ndots:2 로 설정하면 api.example.com (.이 2개)은 search domain 없이 바로 외부 DNS를 조회합니다.

클러스터 내부 서비스 (web-service.default) 는 여전히 search domain을 통해 해석된다.

해결 방법 2: FQDN + trailing dot 사용

# 앱 설정이나 코드에서 외부 도메인 호출 시 끝에 . 추가
# trailing dot이 있으면 ndots 설정과 무관하게 즉시 외부 DNS 조회
curl https://api.example.com.

해결 방법 3: NodeLocal DNSCache (대규모 클러스터 필수)

Pod → CoreDNS 로 직접 쿼리하는 대신, 각 노드에 DNS 캐시 데몬을 띄워 로컬에서 응답한다.

[Pod]
  ↓ DNS 쿼리
[NodeLocal DNSCache] (노드 로컬, 169.254.20.10)
  ├─ 캐시 히트 → 즉시 응답 (CoreDNS 거치지 않음)
  └─ 캐시 미스 → CoreDNS로 전달

CoreDNS로 가는 쿼리 수가 대폭 줄고, VPC DNS로 나가는 쿼리도 줄어든다.

# NodeLocal DNSCache 설치
kubectl apply -f https://raw.githubusercontent.com/kubernetes/kubernetes/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml

# 설치 확인
kubectl get pods -n kube-system -l k8s-app=node-local-dns
# NAME                   READY   STATUS    NODE
# node-local-dns-abc12   1/1     Running   ip-10-0-1-1
# node-local-dns-def34   1/1     Running   ip-10-0-1-2

 

 

3-3. VPC DNS Throttling

왜 발생하는가?

AWS VPC DNS Resolver는 ENI(Elastic Network Interface) 당 초당 1,024개의 DNS 패킷

이라는 제한이 있습니다. EC2 인스턴스는 ENI를 1개 이상 가지므로 실제 한도는 노드당 ENI 수 × 1,024이지만,

대규모 클러스터에서 ndots:5로 쿼리가 증폭되면 이 한도를 초과하기 쉬워요.

 

throttling이 발생하면 DNS 응답이 지연되거나 누락되고,

앱에서는 간헐적인 connection timeout 또는 host not found 오류가 나타납니다.

# VPC DNS Throttling 여부 확인 (CloudWatch 메트릭)
aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 \
  --metric-name DNSQueriesExceedLimit \
  --dimensions Name=InstanceId,Value=<instance-id> \
  --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --period 60 \
  --statistics Sum

# CoreDNS 메트릭으로 외부 쿼리 비율 확인 (Prometheus 연동 시)
# coredns_dns_requests_total{type="forward"} → 외부로 나가는 쿼리 수
# coredns_forward_requests_duration_seconds → 외부 쿼리 응답 시간

대응은 앞서 설명한 NodeLocal DNSCache 설치가 가장 효과적입니다.

 

3-4. CoreDNS 설정 점검

# CoreDNS 설정 확인
kubectl get configmap coredns -n kube-system -o yaml

기본 Corefile:

.:53 {
    errors
    health { lameduck 5s }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
        ttl 30
    }
    prometheus :9153
    forward . /etc/resolv.conf {
        max_concurrent 1000     ← 동시 외부 쿼리 한도
    }
    cache 30                    ← 캐시 TTL (초)
    loop
    reload
    loadbalance
}

자주 수정하는 부분:

  • cache 30 → cache 300 : 캐시 TTL을 늘려 반복 쿼리 감소
  • max_concurrent 1000 → max_concurrent 5000 : 대규모 클러스터에서 동시 외부 쿼리 증가
# ConfigMap 수정
kubectl edit configmap coredns -n kube-system

# 수정 후 CoreDNS 재시작 (reload 플러그인으로 자동 반영되지만 확실하게)
kubectl rollout restart deployment coredns -n kube-system

 

 

3-5. DNS 디버깅 명령어 모음

# DNS 기본 테스트 (Pod 내부에서 실행)
kubectl exec <pod-name> -- nslookup kubernetes.default
kubectl exec <pod-name> -- nslookup <service-name>.<namespace>.svc.cluster.local
kubectl exec <pod-name> -- cat /etc/resolv.conf

# netshoot으로 상세 분석
kubectl run dns-debug --rm -i --tty \
  --image nicolaka/netshoot -- bash

# 내부에서:

# DNS 응답 시간 및 쿼리 경로 확인
dig kubernetes.default.svc.cluster.local +stats

# search domain 포함 실제 쿼리 순서 확인
dig api.example.com +search +showsearch

# CoreDNS Pod에 직접 쿼리 (CoreDNS IP 확인 후 사용)
kubectl get svc kube-dns -n kube-system
dig @<coredns-clusterip> kubernetes.default.svc.cluster.local

# DNS 쿼리 추적
dig +trace api.example.com

# 소켓 레벨 DNS 확인
ss -tulnp | grep :53

 

 

📌 요약 체크리스트

□ kubectl get pods -n kube-system -l k8s-app=kube-dns → Running 여부
□ kubectl top pods → CoreDNS 메모리 limits(기본 170Mi) 근접 여부
□ OOMKilled → memory limits 증가 후 rollout restart
□ 외부 API 호출이 많은 앱 → ndots:2 설정 또는 FQDN trailing dot
□ Pod resolv.conf 확인 → options ndots 값 점검
□ 클러스터 규모 크고 DNS 간헐적 실패 → VPC DNS throttling 의심
   → NodeLocal DNSCache 설치 검토
□ CoreDNS ConfigMap cache TTL / max_concurrent 값 점검
□ netshoot으로 dig +stats / +search +showsearch 테스트

 

 


📌 전체 First 5 Minutes 체크리스트

장애 발생 시 가장 먼저 실행할 명령어 세트를 아래와 같이 정리해보았습니다.

# [30초] 클러스터 전체 상태
aws eks describe-cluster --name <cluster-name> \
  --query 'cluster.status' --output text

kubectl get nodes
kubectl get pods --all-namespaces | grep -v Running | grep -v Completed

# [2분] 이벤트 스코프 파악
kubectl get events --all-namespaces \
  --sort-by='.lastTimestamp' | tail -20

kubectl get pods -n <namespace> --no-headers \
  | awk '{print $3}' | sort | uniq -c | sort -rn

# 노드별 비정상 Pod 분포
kubectl get pods --all-namespaces -o wide \
  --field-selector=status.phase!=Running \
  | awk 'NR>1 {print $8}' | sort | uniq -c | sort -rn

# [5분] 문제 Pod 상세 분석
kubectl describe pod <pod-name> -n <namespace>
kubectl logs <pod-name> -n <namespace> --previous
kubectl top nodes
kubectl top pods -n <namespace> --sort-by=cpu

 


긴 글 읽어주셔서 감사합니다.

다음 포스팅 내용은 CI/CD 및 GitOps 내용으로 찾아뵙겠습니다.

참고 자료