Published on

K8s CrashLoopBackOff? OOMKilled 원인·제한값 튜닝

Authors

서버가 멀쩡해 보이는데 Pod가 계속 재시작하고, 상태가 CrashLoopBackOff로 굳어버리는 상황은 운영에서 가장 흔한 장애 패턴입니다. 특히 원인이 OOMKilled(메모리 부족)인 경우가 많습니다. 문제는 “메모리 늘리면 되지”로 끝나는 일이 거의 없다는 점입니다. 메모리 제한값(limits.memory)은 컨테이너 런타임과 커널 OOM 정책에 직접 영향을 주고, 애플리케이션 런타임(JVM, Go, Node 등)의 힙 전략과도 맞물립니다.

이 글에서는 다음을 목표로 합니다.

  • CrashLoopBackOffOOMKilled정확히 구분하고 증거를 수집하는 방법
  • Kubernetes 리소스 모델(requests/limits)을 기반으로 **제한값을 “튜닝”**하는 절차
  • JVM/Go/Node 등 런타임별로 컨테이너 메모리 한도와 힙/GC를 정렬하는 실무 팁
  • HPA/VPA, 프로브, 종료 시그널 등으로 재발을 줄이는 운영 설계

참고로, EKS 환경에서 네트워크/헬스체크로도 재시작이 발생할 수 있으니, 인프라 레벨 점검은 별도 글인 EKS에서 NLB 타겟 Unhealthy - 헬스체크·Pod·SG도 함께 참고하면 좋습니다.

CrashLoopBackOff의 의미부터 정확히

CrashLoopBackOff는 “컨테이너가 종료되었고(Exit), kubelet이 재시작을 반복하며, 재시작 간격을 점점 늘리고 있다(BackOff)”는 상태입니다. 즉, 원인은 항상 컨테이너의 종료 원인(Exit Reason)에 있습니다.

대표 케이스는 다음과 같습니다.

  • 애플리케이션 프로세스가 즉시 종료(설정 오류, 의존성 실패, 포트 바인딩 실패 등)
  • 프로브 실패로 kubelet이 컨테이너를 강제 재시작(livenessProbe)
  • 노드/커널/런타임에 의해 강제 종료(OOMKilled)

따라서 첫 단계는 “왜 종료되었는지”를 증거로 확정하는 것입니다.

1) OOMKilled인지 3분 안에 판별하기

이벤트와 상태 확인

아래 명령으로 Pod의 종료 이유를 가장 빠르게 확인할 수 있습니다.

kubectl describe pod -n <namespace> <pod-name>

MDX 빌드 에러 방지를 위해 위의 <namespace>, <pod-name> 같은 플레이스홀더는 반드시 인라인 코드로 봐야 합니다.

describe 출력에서 중점적으로 볼 곳은 다음입니다.

  • State: Terminated / Reason: OOMKilled
  • Last State에 있는 직전 종료 원인
  • Events 섹션의 Killing 메시지

추가로 JSONPath로 종료 이유만 뽑으면 더 빠릅니다.

kubectl get pod -n <namespace> <pod-name> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'

직전 로그 확인

OOM은 프로세스가 SIGKILL로 종료되므로, 애플리케이션이 정상적으로 “종료 로그”를 남기지 못하는 경우가 많습니다. 그래도 직전 로그는 단서가 됩니다.

kubectl logs -n <namespace> <pod-name> --previous
  • --previous는 “바로 이전에 죽은 컨테이너” 로그를 가져옵니다.
  • OOM 직전에는 대개 요청 폭증, 캐시 적재, 대용량 응답 생성, GC 과다 등의 징후가 보입니다.

노드 OOM과 컨테이너 OOM 구분

  • Reason: OOMKilled는 보통 컨테이너 메모리 제한을 초과해 cgroup에 의해 죽은 경우입니다.
  • 반면 노드 전체 메모리가 부족하면, Pod가 Evicted 되거나 노드에서 OOM이 발생해 다른 형태로 보일 수 있습니다.

노드 압박 여부는 아래로 확인합니다.

kubectl describe node <node-name>

ConditionsMemoryPressure=True가 보이면 노드 차원의 문제 가능성이 큽니다.

2) requests/limits가 OOM에 미치는 실제 영향

Kubernetes에서 메모리 관련 핵심은 다음 두 가지입니다.

  • requests.memory: 스케줄러가 노드에 배치할 때 “최소 필요량”으로 간주
  • limits.memory: cgroup 상한. 초과 시 컨테이너가 강제 종료될 수 있음(대개 OOMKilled)

CPU는 초과해도 쓰로틀링으로 버티는 경우가 많지만, 메모리는 초과 시 “죽음”으로 이어지기 쉽습니다. 그래서 메모리 limits는 단순히 “안전하게 낮게” 잡는 것이 아니라, 애플리케이션의 피크 사용량을 반영해 설계해야 합니다.

흔한 안티패턴

  • requests는 작게, limits는 크게: 노드에 과밀 배치되어 노드 메모리 압박이 발생
  • limits를 너무 작게: 정상 트래픽에서도 피크에 OOMKilled
  • requestslimits를 동일하게 고정: 안정적이지만 비용 증가, 확장 전략이 없으면 비효율

3) OOMKilled의 대표 원인 7가지

1) 애플리케이션 힙 상한이 컨테이너 상한과 불일치

  • JVM: -Xmx가 컨테이너 메모리 제한을 고려하지 않으면 OOMKilled
  • Node: V8 힙 기본 상한이 워크로드에 비해 낮거나, 반대로 컨테이너 한도보다 과도하게 잡힘
  • Go: GC 타깃이 트래픽/할당 패턴에 따라 급격히 흔들릴 수 있음

2) 메모리 누수 또는 캐시 무제한 적재

  • LRU 없는 in-memory cache
  • 요청당 객체를 전역에 누적
  • 큰 버퍼/슬라이스가 재사용되지 않고 유지

3) 대용량 응답/파일 처리에서 버퍼를 한 번에 잡음

  • 대용량 JSON 생성
  • 압축/암호화 처리에서 큰 버퍼 할당
  • 업로드 파일을 통째로 메모리에 적재

4) 동시성 폭증(큐/스레드/고루틴)으로 per-request 메모리 합산

  • 스레드 풀 과다
  • 고루틴 폭증
  • 요청 큐가 메모리에서만 쌓임

5) 프로브/재시작 루프가 부하를 증폭

  • livenessProbe가 너무 공격적이면, 회복 중인 프로세스를 계속 죽여 메모리/CPU가 더 불안정해집니다.

6) 사이드카(Envoy 등) 메모리 미고려

Pod 단위로 보면 앱은 괜찮은데, 사이드카가 메모리를 잡아먹어 전체가 불안정해지는 케이스가 많습니다.

7) 노드/커널 레벨 메모리 압박

  • 과밀 스케줄링
  • 페이지 캐시/커널 메모리 증가
  • 다른 Pod의 폭주

4) 제한값 튜닝 절차: “증거 기반”으로 조정하기

Step 1. 현재 사용량의 분포를 본다

가장 좋은 건 Prometheus/Grafana에서 다음을 보는 것입니다.

  • 컨테이너 메모리 사용량(Working Set)
  • 재시작 시점의 피크
  • 동시 요청 수/큐 길이와의 상관관계

Prometheus가 없다면 임시로 kubectl top도 가능합니다.

kubectl top pod -n <namespace>

주의: top은 순간값이므로 피크를 놓치기 쉽습니다. OOM은 피크에서 터집니다.

Step 2. 리밋을 “피크 + 여유분”으로 잡는다

실무에서 자주 쓰는 가이드(서비스 성격에 따라 조정):

  • limits.memory = 관측된 피크의 1.2배에서 1.5배
  • requests.memory = 관측된 P50~P70 수준(혹은 안정적 베이스라인)

단, “피크”가 트래픽 급증에 의해 무한히 커질 수 있다면, 단순히 리밋을 올리는 대신 동시성 제한, 큐 전략, 페이징/스트리밍 등으로 메모리 상한을 만들 필요가 있습니다.

Step 3. 애플리케이션 런타임 상한을 리밋보다 낮게 맞춘다

JVM(Spring Boot 등) 권장 패턴

  • 컨테이너 메모리 제한이 512Mi라면, 힙은 그보다 훨씬 낮아야 합니다.
  • 힙 외에도 Metaspace, Thread Stack, Direct Buffer, JIT, 네이티브 라이브러리 메모리가 필요합니다.

예시(보수적으로 시작):

resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
  limits:
    memory: "768Mi"
    cpu: "1"
env:
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:MaxRAMPercentage=60 -XX:InitialRAMPercentage=30 -XX:+ExitOnOutOfMemoryError"
  • MaxRAMPercentage로 컨테이너 메모리 기준 힙을 비율로 제한합니다.
  • ExitOnOutOfMemoryError는 JVM 내부 OOM을 명확히 실패로 처리해(로그 남기고 종료) 원인 파악을 쉽게 합니다.

DB 커넥션 풀, 스레드 풀도 메모리에 영향을 줍니다. 특히 Spring 계열은 커넥션/스레드 튜닝이 병목과 메모리 사용량에 직결되므로 Spring Boot HikariCP 커넥션 고갈 원인 8가지도 함께 보면 “동시성 폭증으로 메모리가 올라가는 구조”를 줄이는 데 도움이 됩니다.

Go 서비스 권장 패턴

Go는 컨테이너 메모리 제한을 완벽히 “자동”으로 맞춰주지 않는다고 가정하고 접근하는 편이 안전합니다.

  • GOMEMLIMIT로 소프트 상한을 두고
  • GOGC로 GC 공격성을 조정합니다.
env:
  - name: GOMEMLIMIT
    value: "500MiB"
  - name: GOGC
    value: "80"

또한 메모리 누수/할당 폭증이 의심되면 pprof로 힙을 떠서 “무엇이 남는지”를 확인해야 합니다. Go 빌드/프로파일링 관련 팁은 Go gcflags로 CPU 프로파일링·인라이닝 해제도 참고할 수 있습니다(힙 분석이 주 목적이라도, 재현 환경에서 인라이닝을 끄면 스택/프로파일 해석이 쉬워지는 경우가 있습니다).

Node.js 권장 패턴

Node는 V8 힙 상한을 컨테이너에 맞춰 지정하는 것이 안전합니다.

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=384"
  • 단위는 MiB에 가깝게 이해하면 됩니다.
  • 힙 외 메모리(버퍼, 네이티브 모듈 등)를 고려해 리밋보다 여유를 둡니다.

Step 4. 프로브와 종료 동작을 안정화한다

OOM 자체를 막는 것과 별개로, 재시작 루프가 장애를 증폭하지 않게 해야 합니다.

  • startupProbe를 도입해 초기 기동이 느린 앱이 liveness에 의해 조기 종료되지 않게 함
  • liveness는 “진짜로 죽었을 때만” 실패하도록 완만하게
  • terminationGracePeriodSeconds를 충분히 줘서 종료 시 정리(로그 flush, 커넥션 close) 시간 확보

예시:

startupProbe:
  httpGet:
    path: /healthz
    port: 8080
  failureThreshold: 30
  periodSeconds: 2
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  periodSeconds: 5

5) 실제 튜닝 예시: OOMKilled 재발 줄이는 리소스 설정

다음은 “메모리 피크가 450Mi 정도로 관측되는 Spring Boot 서비스”를 가정한 예시입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: api
          image: <image>
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "300m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "768Mi"
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError"

핵심 포인트:

  • requests는 스케줄링 안정성을 위해 베이스라인 이상으로
  • limits는 피크를 수용하되 무한정 키우지 않음
  • JVM 힙은 limits보다 낮게 자동 조정되도록 비율 기반 설정

6) HPA/VPA를 언제, 어떻게 쓰나

HPA(수평 확장)

메모리로 HPA를 거는 것도 가능하지만, 메모리는 “GC/캐시/버퍼”로 인해 지표가 매끄럽지 않을 수 있습니다. 그럼에도 다음 상황에선 유효합니다.

  • 요청량이 증가하면 메모리도 함께 증가하는 구조
  • 단일 Pod의 처리량 한계가 명확하고, 복제 확장으로 분산이 잘 되는 서비스

VPA(수직 확장)

VPA는 requests를 자동 조정해 과소/과대 요청을 줄이는 데 유리합니다. 다만 운영에서는 다음을 주의합니다.

  • VPA가 Pod 재시작을 유발할 수 있음(모드에 따라)
  • limits까지 자동 변경할지 정책 결정 필요
  • 워크로드 특성상 피크가 큰 서비스는 “평균 기반 추천”만 믿으면 위험

실무적으로는:

  • 초기에 VPA를 recommendation 용도로만 켜고
  • 관측치를 기반으로 사람이 limits를 설계
  • 이후 안정화되면 requests 자동화 범위를 확대

7) OOM을 “없애는” 게 아니라 “통제”하는 관점

OOMKilled는 단순히 메모리 숫자를 올려서 사라질 수도 있지만, 그 경우 비용과 불확실성이 같이 증가합니다. 재발을 줄이려면 다음 질문에 답해야 합니다.

  • 피크 메모리는 무엇이 만들었나(힙, 버퍼, 캐시, 동시성)
  • 피크가 트래픽에 비례하는가, 특정 입력에 의해 튀는가
  • 리밋을 올리는 것이 맞나, 아니면 메모리 상한을 만드는 코드/아키텍처 개선이 맞나

특히 “특정 요청에서만 터지는” 케이스는 리밋 튜닝보다 입력 제한, 스트리밍 처리, 페이지네이션, 압축 전략 변경 같은 제품/코드 개선이 정답인 경우가 많습니다.

체크리스트: 장애 대응 시 바로 쓰는 순서

  1. kubectl describe podReason 확인(정말 OOMKilled인지)
  2. kubectl logs --previous로 직전 로그 확보
  3. 메트릭으로 피크 메모리와 동시성/트래픽 상관관계 확인
  4. limits.memory를 피크 기반으로 재설계(여유분 포함)
  5. 런타임 힙/GC 상한을 컨테이너 리밋과 정렬(JVM MaxRAMPercentage, Go GOMEMLIMIT, Node --max-old-space-size)
  6. 프로브를 완만하게 조정하고 startupProbe 도입
  7. HPA/VPA로 재발 방지 구조화

위 순서대로 진행하면 CrashLoopBackOff를 “재시작만 반복하는 미스터리”가 아니라, 원인과 해결책이 명확한 운영 이슈로 바꿀 수 있습니다.