쿠버네티스 리소스 설정 — OOMKilled와 CPU Throttling 잡는 최적값

목차

쿠버네티스 리소스 설정을 다시 본 건 컨테이너가 자꾸 OOMKilled로 죽어서였다. 프론트엔드만 만지다 백엔드 인프라로 넘어온 지 2년, 가장 늦게 깨달은 영역이 이 부분이다. requests와 limits를 분리해서 본 뒤로 같은 클러스터, 같은 이미지인데 결과가 달라졌다.

:::stats Before — exit code 137, RESTARTS 12회, p99 응답 1.2s, CPU throttling 78% After — RESTARTS 0회(3일 운영), p99 응답 220ms, CPU throttling 4% :::

이 차이를 만든 건 노드 사양도, 코드 변경도 아니다. requests와 limits의 역할을 분리해서 본 게 전부였다. FastAPI 서비스를 EKS 1.30에 올렸다가 메모리 limit을 512Mi에서 1Gi로 올리는 첫 반응을 했더니, 노드가 자원 부족으로 비명을 질렀다. 거꾸로 갔다. limit을 줄이고 request를 키웠더니 이상하게 더 안정적이었다. 왜 그런지 메모로 남긴다.

오늘 한 것 — requests와 limits를 다르게 본 뒤 일어난 일

그런데, 기존엔 두 값을 거의 같게 뒀다. requests: 500m, limits: 500m. 깔끔해 보였고, 노드 할당량 계산하기도 편했다. 다만 이 방식이 문제를 키운다는 걸 늦게 알았다.

예를 들어, requests는 스케줄러가 이 파드를 어디에 둘지 결정할 때 보는 값이다. 실제 사용량이 아니라 노드 자원에서 예약해두는 양이다. 노드 메모리가 16Gi인데 모든 파드의 requests 합이 14Gi라면, 사용량이 5Gi뿐이어도 새 파드는 못 들어온다. 실제 부하와 무관하다.

limits는 다르다. 컨테이너가 이 값을 넘으면 메모리는 OOMKilled, CPU는 throttling이 발생한다. requests는 스케줄링 기준, limits는 런타임 강제값이다. 두 개를 같은 값으로 두면 의도와 다르게 두 메커니즘이 충돌한다.

한편, VPA(Vertical Pod Autoscaler) Recommender 모드로 일주일 돌려서 받은 추천값은 CPU 250m / Memory 300Mi였다. 그동안 500m / 500Mi로 뒀던 건 과할당이었고, 동시에 limit이 너무 낮아 피크 트래픽에 OOMKilled가 났다. 같은 값에서 양쪽이 다 어긋난 이상한 조합이었다.

새로 알게 된 것 — 두 죽음의 메커니즘은 다르다

OOMKilled와 CPU Throttling은 자주 같이 언급되지만 작동 방식이 다르다. 처음엔 둘 다 "limit을 올리면 해결된다"라고 생각했다. 메모리는 그게 맞다. CPU는 아니다. 이걸 구분하지 못해서 오래 헤맸다.

OOMKilled — 메모리는 압축이 안 된다

따라서, 리눅스 커널은 메모리가 부족하면 OOM Killer를 호출한다. 컨테이너에 memory limit 512Mi를 걸면, cgroup 안에서 메모리 사용량이 그 값을 넘는 순간 즉시 종료된다. exit code는 137(128 + SIGKILL). 협상도 유예도 없다.

kubectl describe podLast State: Terminated / Reason: OOMKilled로 찍히면 두 가지 가능성이 있다. limit이 부족했거나, 메모리 누수가 있다는 뜻이다. 두 경우의 대처가 다른데도 둘 다 limit을 올려 가리는 경우가 많다. 누수는 그래프로 봐야 한다. 시간에 따라 메모리 사용량이 단조 증가하면 누수 쪽이다. limit을 키워봤자 죽는 주기만 늘어난다.

게다가, memory request와 limit을 같게 두는 게 안전하다는 권장이 공식 문서에 나온다(출처: Kubernetes 공식 문서, Resource Management for Pods and Containers). QoS class가 Guaranteed로 잡혀서 노드 메모리 압박 시 우선순위 보호를 받는다. 다만 모든 워크로드를 Guaranteed로 줄 수는 없다. 배치 작업이나 캐시처럼 사용량 편차가 큰 워크로드는 burstable로 두는 게 노드 활용률에 낫다.

CPU Throttling — limit이 오히려 발목을 잡는다

CPU는 메모리와 다르게 압축 가능한 자원(compressible resource)이다. limit을 넘어도 죽지 않는다. 대신 100ms 단위 시간 슬라이스 안에서 강제로 멈춘다. 이걸 throttling이라고 부른다.

이처럼, 문제는 이 throttling이 p99 응답 시간을 망친다는 점이다. CPU limit 500m을 걸면 100ms 중 50ms만 쓸 수 있다. burst를 못 낸다. FastAPI 서비스에서 평균 CPU가 200m이어도, 요청 폭주 순간에 800m이 필요한데 500m에서 잘린다. 응답이 늘어진다. 평균값만 보면 여유가 있어 보여서 더 까다롭다.

kubectl top으로는 throttling이 안 보인다. cAdvisor의 container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total 비율을 봐야 한다. 내 경우 이 비율이 78%였다. limit을 빼고 request만 350m으로 두니 4%로 떨어졌다. 같은 트래픽, 같은 코드인데 응답 시간이 5배 빨라진 게 인상적이었다.

CPU limit을 빼는 게 위험해 보이지만, 한 컨테이너가 CPU를 다 잡아먹어도 다른 컨테이너는 자기 request만큼 보장받는다. 이게 CFS의 핵심 동작이다. 그래서 프로덕션에서 CPU limit을 의도적으로 제거하는 패턴이 늘고 있다(출처: Tim Hockin, KubeCon 2023 발표 — "For the Love of God, Stop Using CPU Limits"). 멀티테넌트 클러스터에서는 다른 보호 장치(ResourceQuota, LimitRange)와 함께 검토해야 한다.

QoS Class — 매니페스트가 결정하는 운명

또한, requests와 limits를 어떻게 두느냐에 따라 파드의 QoS class가 결정된다. 노드가 압박받을 때 누구를 먼저 죽일지 정하는 기준이다.

  • Guaranteed: 모든 컨테이너에서 CPU·메모리 requests = limits. 가장 늦게 evict.
  • Burstable: requests < limits 이거나 일부만 설정. 중간.
  • BestEffort: requests와 limits 둘 다 미설정. 가장 먼저 evict.

한편, 코드 변경 없이 QoS를 바꿀 수 있다는 게 흥미롭다. critical 서비스는 Guaranteed로, 비동기 worker는 Burstable로, 일회성 분석 잡은 BestEffort로. 이 구분 없이 그냥 limit만 박아두면, 정작 중요한 서비스가 먼저 죽는 일이 생긴다. 매니페스트 한 줄이 운명을 가른다.

코드 — 워크로드 타입별 설정값

내가 쓰는 4가지 워크로드 타입별 설정이다. 정답은 아니고, FastAPI + PostgreSQL 기반 서비스 운영 기준으로 정리한 값이다.

# FastAPI 웹 서버 — 짧은 요청, burst가 필요한 경우
resources:
  requests:
    cpu: "350m"        # VPA 추천값 + 20% 마진
    memory: "400Mi"    # 평소 사용량 + 30% 마진
  limits:
    # CPU limit 의도적으로 미설정 (throttling 회피)
    memory: "600Mi"    # request의 1.5배. 누수 발생 시 종료 보장
# 배치 잡 — 한 번에 무거운 일, 오래 안 걸림
resources:
  requests:
    cpu: "500m"
    memory: "1Gi"
  limits:
    cpu: "2000m"       # 짧은 시간 burst 허용
    memory: "1Gi"      # request와 동일. Guaranteed QoS

그래서, 웹 서버와 배치 잡의 가장 큰 차이는 메모리 limit이 request와 같은가 아닌가다. 웹 서버는 누수가 의심되면 빠르게 재시작되어야 하니 limit이 더 높고, 배치 잡은 사용량 패턴이 예측되니 같게 둔다. CPU 쪽은 정반대다. 웹 서버는 burst가 필요해서 limit이 없고, 배치 잡은 노드를 잡아먹지 않도록 limit으로 막는다.

워크로드 CPU limit Memory req:limit QoS Class 핵심 의도
웹 서버(burst 필요) 없음 1 : 1.5 Burstable p99 보호
배치 잡 있음(burst용) 1 : 1 Guaranteed 노드 보호
데몬셋 있음 1 : 2 Burstable 노드 안정성
사이드카(log forwarder) 없음 1 : 2 Burstable 본체 우선

핵심은 CPU limit과 memory limit의 역할이 다르다는 점이다. CPU limit은 "옵션"에 가깝고, memory limit은 거의 필수다. 이걸 같은 무게로 보는 게 잘못된 출발이었다.

메모 — 언제 쓰고 언제 안 쓰는지

그런데, 판단 기준만 짧게 남긴다.

requests = limits로 두는 게 맞는 상황 — 메모리 누수 가능성이 있는 워크로드, 노드 압박 시 보호받아야 하는 critical 서비스, DB나 캐시처럼 사용량이 일정한 워크로드. QoS Guaranteed가 필요할 때 이 조합이 맞다.

CPU limit을 빼는 게 맞는 상황 — p99 응답 시간이 SLO에 들어가는 웹 서비스. 트래픽이 burst성이고 평균과 피크 차이가 큰 경우. 클러스터 운영자가 같은 팀이고, 노드 자원 보호를 ResourceQuota로 따로 거는 환경.

CPU limit을 반드시 거는 게 맞는 상황 — 멀티테넌트 클러스터에서 다른 팀 워크로드를 보호해야 할 때. 비용 예측이 중요해서 청구서가 burst로 튀면 안 될 때. 배치 잡 중 무한 루프 가능성이 있는 코드.

당장 해볼 만한 액션 세 가지.

  1. VPA를 Recommender 모드(updateMode: Off)로 deploy한다. 실제 파드는 안 건드린다. 7일치 추천값만 본다(참고: kubernetes/autoscaler GitHub, vertical-pod-autoscaler).
  2. Prometheus에서 rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m])를 본다. 10%를 넘는 컨테이너는 CPU limit을 의심한다.
  3. kubectl get pod <name> -o jsonpath='{.status.qosClass}'로 QoS class를 확인한다. critical 서비스가 Burstable로 떠 있으면 설정이 잘못된 거다.

프론트엔드 시절엔 브라우저 메모리를 거의 안 봤다. 백엔드 인프라는 결이 다르더라. limits를 잘못 잡으면 코드는 멀쩡한데 서비스가 죽는다. 같은 컨테이너, 같은 이미지를 두고도 QoS class 하나로 운명이 갈리는 게 처음엔 황당했다. 지금은 모든 매니페스트에서 가장 먼저 보는 줄이 됐다.

관련 글