requests는 스케줄러가 Pod를 어디에 얹을지 정하는 "예약", limits는 런타임이 강제하는 "상한"이다. 메모리가 limit을 넘으면 컨테이너는 OOMKill(137)로 즉사하고, CPU는 limit을 넘으면 죽는 대신 스로틀링으로 느려진다.
이 둘의 차이를 모르면 노드는 텅 비었는데 Pending이 쌓이고, 멀쩡하던 컨테이너가 새벽에 Exit Code 137로 재시작한다. 여기서는 requests/limits가 실제로 무엇을 바꾸는지, QoS 클래스가 어떻게 결정되고 왜 중요한지, HPA/VPA는 언제 쓰는지, 그리고 실무에서 값을 어떻게 잡는지를 정리한다.
쿠버네티스를 몇 년 운영하다 보면 결국 장애의 절반은 리소스 오설정에서 나온다는 걸 알게 된다. 배포는 잘 됐는데 트래픽만 몰리면 응답이 느려지는 서비스, 노드 자원은 남는데 스케줄이 안 되는 Deployment, 그리고 그 유명한 OOMKilled. 나도 처음엔 limit을 넉넉하게 주면 안전한 줄 알았다. 실제로는 정반대인 경우가 많았다.
| 항목 | requests | limits |
|---|---|---|
| 누가 사용 | 스케줄러(kube-scheduler) | 노드 런타임(kubelet/cgroup) |
| 역할 | 배치 결정 + 최소 보장 | 런타임 상한 강제 |
| CPU 초과 시 | 경쟁 시 비율대로 분배 | 스로틀링(느려짐, 안 죽음) |
| 메모리 초과 시 | 노드 압박 시 축출 우선순위 | OOMKill(137) |
| 생략하면 | limit 값이 request로 복사(설정 시) | 무제한(노드 전체를 잡아먹을 수 있음) |
스케줄링은 오직 requests만 본다
Pod가 어느 노드로 갈지는 requests 합계로만 결정된다. limits는 스케줄링 계산에 전혀 들어가지 않는다. 이게 "노드에 자원이 남는데 왜 Pending이지?"의 핵심 원인이다. 스케줄러는 각 노드의 Allocatable에서 이미 스케줄된 Pod들의 requests를 뺀 뒤, 새 Pod의 requests가 들어갈 자리가 있는지만 확인한다. 실제 사용량은 보지 않는다.
그래서 request를 실사용보다 훨씬 크게 잡으면, 노드 CPU 사용률이 20%인데도 스케줄러 입장에선 "예약이 꽉 찼다"며 새 Pod를 못 얹는다. 반대로 request를 너무 작게 잡으면 한 노드에 Pod가 과밀하게 몰려 실제 부하가 터질 때 서로 자원을 뺏는다. 오버커밋과 안정성 사이의 균형이 여기서 갈린다.
# Pending 원인 확인 — 대부분 여기서 답이 나온다
kubectl describe pod my-app-7d9f-abc | grep -A5 Events
# 흔한 메시지:
# 0/5 nodes are available: 5 Insufficient cpu.
# 0/5 nodes are available: 5 Insufficient memory.
# 노드별 requests 예약 현황 (실사용 아님, "예약" 기준)
kubectl describe node ip-10-0-1-23 | grep -A10 "Allocated resources"
Allocatable은 노드의 물리 용량 전체가 아니다. kubelet이 시스템 데몬과 kube 컴포넌트 몫(--kube-reserved, --system-reserved)과 축출 임계치(--eviction-hard)를 뺀 나머지다. m5.large(2 vCPU, 8Gi)라도 실제 Allocatable은 CPU 1930m, 메모리 7Gi 남짓인 경우가 흔하다. "8Gi 노드니까 8Gi request 주면 되겠지"가 안 먹히는 이유다.CPU limit과 스로틀링 — 죽지 않지만 느려진다
CPU limit을 넘으면 컨테이너가 죽는 게 아니라 스로틀링된다. 리눅스 CFS(Completely Fair Scheduler)는 100ms 주기(cpu.cfs_period_us=100000)마다 컨테이너가 쓸 수 있는 CPU 시간을 quota로 배분하는데, limit이 500m이면 100ms당 50ms만 실행할 수 있다. quota를 다 쓰면 남은 기간 동안 그냥 멈춰 세워진다. 다음 주기가 오기 전까지.
문제는 이게 p99 지연으로 나타난다는 것이다. 평균 CPU 사용률은 30%로 여유로워 보이는데 순간적으로 quota를 소진하는 버스트성 워크로드(요청 처리, GC, JIT 컴파일)는 100ms 단위로 강제로 멈춘다. Java 앱에서 특히 악명 높다. 스레드가 많은 JVM이 짧은 quota에 걸려 톱니처럼 끊긴다.
# 스로틀링이 실제로 일어나는지 확인 (cgroup v2)
kubectl exec my-app-pod -- cat /sys/fs/cgroup/cpu.stat
# nr_periods 12043
# nr_throttled 8971 <- 74%가 스로틀링됨. 심각
# throttled_usec 41230000 <- 41초나 멈춰 있었다는 뜻
# nr_throttled / nr_periods 비율이 높으면 CPU limit이 병목
개인적으로는 지연에 민감한 서비스에는 CPU limit을 아예 안 거는 쪽을 선호한다. request로 최소 보장을 확보하고, limit은 비워서 노드에 여유가 있을 때 버스트를 허용하는 것이다. 물론 이건 노드 오버커밋 정책과 멀티테넌시 상황에 따라 다르다. 남의 워크로드와 노드를 공유하는 환경이라면 limit 없는 컨테이너가 이웃을 굶길 수 있으니, 이땐 넉넉한 limit이 낫다. 정답은 없고 트레이드오프만 있다.
cpu.cfs_burst 지원 환경에서 많이 개선됐지만, "CPU 사용률이 낮은데 왜 느리지?"의 범인이 여전히 이 스로틀링인 경우가 많다. 먼저 cpu.stat부터 보자.메모리 limit과 OOMKill — 137의 정체
메모리는 CPU와 달리 압축 불가능한 자원이다. limit을 넘는 순간 커널의 cgroup OOM killer가 그 컨테이너 프로세스를 즉시 SIGKILL한다. 종료 코드는 137(128 + 9). 유예도 정리도 없다. 그래서 CPU는 limit을 넘겨도 그냥 느려지지만 메모리는 넘기면 죽는다. 이 비대칭이 리소스 관리의 핵심 감각이다.
헷갈리는 지점: 노드 레벨 OOM과 cgroup(컨테이너) 레벨 OOM은 다르다. 컨테이너가 자기 limit을 넘겨 죽으면 kubectl describe pod의 Last State: Terminated, Reason: OOMKilled로 명확히 찍힌다. 반면 노드 전체 메모리가 고갈되면 kubelet의 eviction이 먼저 개입하거나, 그보다 급하면 커널 OOM killer가 oom_score가 높은 프로세스를 잡는데, 이땐 엉뚱한 컨테이너가 희생될 수도 있다. 여기서 QoS 클래스가 결정적 역할을 한다.
# OOMKill 사후 분석
kubectl describe pod my-app-pod | grep -A6 "Last State"
# Last State: Terminated
# Reason: OOMKilled
# Exit Code: 137
# Started/Finished ...
# 재시작 횟수가 늘고 있으면 OOM 루프 의심
kubectl get pod my-app-pod -o jsonpath='{.status.containerStatuses[0].restartCount}'
실무에서 데인 적이 있다. JVM 앱에 메모리 limit을 2Gi로 주고 -Xmx1800m을 걸었는데 계속 OOMKilled가 났다. 힙은 1.8Gi 안쪽인데 왜? 컨테이너 메모리는 힙만이 아니다. 메타스페이스, 스레드 스택, JIT 코드 캐시, 네이티브 버퍼(Netty의 direct memory 특히)까지 다 합쳐진 RSS가 limit을 넘긴 거였다. 힙 상한만 보고 limit을 잡으면 이 함정에 빠진다. JDK 10+ 부터는 -XX:MaxRAMPercentage로 컨테이너 limit 기준 비율을 주는 게 훨씬 안전하다.
QoS 클래스 — 노드가 압박받을 때 누가 먼저 죽나
QoS(Quality of Service) 클래스는 노드 메모리가 부족할 때 어떤 Pod를 먼저 축출/종료할지 정하는 등급이다. 우리가 직접 지정하는 게 아니라 requests/limits 설정 방식에 따라 kubelet이 자동으로 부여한다. 세 등급이 있다.
| QoS 클래스 | 조건 | 축출 우선순위 | 쓰는 곳 |
|---|---|---|---|
| Guaranteed | 모든 컨테이너가 requests=limits (CPU+메모리 둘 다) | 가장 나중에 축출 | DB, 결제, 핵심 API |
| Burstable | requests 존재하나 limits와 불일치(또는 일부만) | 중간 | 대부분의 일반 워크로드 |
| BestEffort | requests/limits 아무것도 없음 | 가장 먼저 축출 | 배치잡, 비핵심 로그 수집 |
노드에 메모리 압박이 오면 kubelet은 BestEffort → Burstable(requests 초과분 큰 순) → Guaranteed 순으로 Pod를 축출한다. Guaranteed라고 절대 안 죽는 건 아니지만, 다른 등급이 다 정리될 때까지는 보호된다. 그래서 진짜 죽으면 안 되는 워크로드는 requests와 limits를 같게 줘서 Guaranteed로 만드는 게 정석이다.
# QoS 클래스 확인
kubectl get pod my-app-pod -o jsonpath='{.status.qosClass}'
# Guaranteed / Burstable / BestEffort
# Guaranteed로 만드는 스펙 — requests와 limits가 정확히 일치해야 한다
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "500m" # request와 동일
memory: "512Mi" # request와 동일
HPA와 VPA — 수평이냐 수직이냐
HPA(Horizontal Pod Autoscaler)는 Pod 개수를 늘리고, VPA(Vertical Pod Autoscaler)는 개별 Pod의 requests/limits를 조정한다. 결이 완전히 다르다. HPA는 부하 분산이 가능한 무상태 서비스(웹 API 등)에 맞고, VPA는 스케일아웃이 어려운 워크로드나 requests 값 자체를 모를 때 적정값을 찾아주는 용도다.
HPA는 기본적으로 CPU 사용률을 requests 대비 비율로 계산한다. 여기서 함정 하나. HPA 타깃을 CPU 70%로 잡았는데 request를 너무 크게 설정해두면, 실사용이 request의 70%에 도달하기 전에 이미 노드가 버겁다. 반대로 request가 너무 작으면 조금만 부하가 와도 즉시 max replica까지 튀어 오른다. HPA를 쓴다면 request 값이 곧 스케일링의 기준점이 된다는 걸 기억해야 한다.
# HPA — CPU 사용률 기준 (사용률은 requests 대비 %)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # requests의 70% 넘으면 scale-out
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 급격한 축소 방지 (플래핑 억제)
중요한 주의: HPA와 VPA를 같은 지표(CPU/메모리)로 동시에 쓰면 안 된다. VPA가 request를 올리면 HPA의 사용률 계산 기준이 바뀌어 둘이 서로 싸운다. VPA는 updateMode: "Off"로 추천값만 받아 보고, 실제 스케일링은 HPA에 맡기는 조합을 나는 자주 쓴다. VPA의 리포트를 requests 초기값 잡는 데이터로만 활용하는 것이다.
# VPA를 "추천 전용"으로 — 자동 적용 없이 적정값만 관찰
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: web-api-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: web-api
updatePolicy:
updateMode: "Off" # Auto로 하면 Pod를 재생성함. 우선 Off로 관찰
# 추천값 확인
# kubectl describe vpa web-api-vpa
# Target: cpu: 340m, memory: 610Mi <- 이 값을 requests 기준으로 삼는다
실전 사이징 — 값을 어떻게 잡을까
이론은 됐고 실제로 어떤 숫자를 넣느냐. 내가 쓰는 대략의 절차는 이렇다. 완벽한 공식은 없지만 감으로 찍는 것보다는 훨씬 낫다.
- 메모리 request = 실제 사용량 p95 + 여유. 메모리는 초과하면 죽으니 보수적으로. 며칠간
container_memory_working_set_bytes를 관찰해 p95~p99를 잡고 15~30% 버퍼를 더한다. - 메모리 limit = request와 같게(Guaranteed) 또는 request의 1.2~1.5배. limit을 request보다 훨씬 크게 주면 "가끔 터지는 메모리 누수"를 노드가 흡수하다가 이웃 Pod까지 말려든다. 차라리 낮은 limit으로 빨리 죽고 재시작하는 게 나을 때도 많다.
- CPU request = 정상 부하의 평균~p90. CPU는 압축 가능하니 메모리만큼 빡빡하게 안 해도 된다.
- CPU limit = 지연 민감 서비스는 비우거나 넉넉히, 배치성은 명시. 스로틀링이 관측되면(
nr_throttled비율이 25% 넘으면) limit을 올리거나 없앤다.
| 워크로드 유형 | QoS 목표 | CPU limit | 메모리 limit |
|---|---|---|---|
| 지연 민감 API | Burstable | 비우거나 크게 | request의 1.2~1.5배 |
| DB/스테이트풀 | Guaranteed | request와 동일 | request와 동일 |
| 배치/크론잡 | Burstable | 명시(노드 독점 방지) | 명시 |
| 비핵심 사이드카 | Burstable | 작게 명시 | 작게 명시 |
그리고 팀 단위로는 LimitRange와 ResourceQuota를 걸어두는 걸 권한다. 개발자가 resources를 아예 안 쓰면 default를 주입하고(BestEffort 방지), 말도 안 되게 큰 request를 요청하면 막아준다. 네임스페이스 전체 총량도 ResourceQuota로 상한을 건다. 이거 없으면 누군가 실수로 memory: 100Gi 하나 배포해서 클러스터가 마비되는 걸 겪게 된다.
# 네임스페이스 기본값/상한 강제
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
spec:
limits:
- type: Container
default: # limits 미지정 시 주입
cpu: "500m"
memory: "512Mi"
defaultRequest: # requests 미지정 시 주입
cpu: "100m"
memory: "128Mi"
max: # 개별 컨테이너 상한
cpu: "4"
memory: "8Gi"
흔한 오설정 다섯 가지
리뷰하면서 반복적으로 마주친 실수들이다. 하나라도 짚이면 오늘 고치는 게 좋다.
- limit만 있고 request가 없다. 이 경우 쿠버네티스가 request를 limit과 같게 설정해버려서, 의도치 않게 큰 request로 스케줄링돼 노드가 금방 "예약 만석"이 된다.
- 메모리 request는 작은데 limit이 너무 크다. Burstable로 여러 Pod가 한 노드에 몰리고, 동시에 다들 limit 쪽으로 메모리를 밀어붙이면 노드 OOM이 나면서 엉뚱한 Pod가 축출된다.
- CPU limit을 request와 똑같이 아주 낮게(예: 100m) 준다. Guaranteed는 됐는데 버스트를 전혀 못 해서 상시 스로틀링. 짧은 요청 하나 처리하는 데도 quota가 모자란다.
- 모든 워크로드에 같은 값을 복붙한다. Helm values에
cpu: 1, memory: 1Gi하나 만들어놓고 20개 서비스에 그대로 쓴다. 어떤 건 낭비, 어떤 건 부족. - JVM/Node 힙 설정과 컨테이너 limit이 따로 논다. 런타임이 컨테이너 cgroup limit을 인식하도록 최신 버전을 쓰고, 힙은 limit의 절대치가 아니라 비율(
MaxRAMPercentage등)로 설정한다.
자주 묻는 질문
requests와 limits 중 하나만 설정해야 한다면 무엇을 먼저 잡나요?
requests를 먼저 잡으세요. 스케줄링과 최소 자원 보장, QoS 결정의 기준이 전부 requests입니다. 특히 메모리 limit은 신중히 정해야 하니, 잘 모를 때는 requests만 설정하고 메모리 사용량을 며칠 관찰한 뒤 limit을 붙이는 순서가 안전합니다.
Exit Code 137이 뜨는데 항상 메모리 부족인가요?
137은 SIGKILL(128+9)을 받았다는 뜻이고, 컨테이너 환경에서 가장 흔한 원인이 OOMKill입니다. 다만 kubectl describe pod의 Reason이 OOMKilled인지 꼭 확인하세요. 배포 중 강제 종료, liveness 프로브 실패 후 강제 kill 등 다른 이유로도 137이 나올 수 있습니다.
CPU limit을 아예 설정하지 않아도 괜찮나요?
지연에 민감한 서비스이고 노드 오버커밋을 관리할 수 있다면 오히려 권장됩니다. CPU는 압축 가능한 자원이라 경쟁이 생겨도 requests 비율대로 나눠 쓰고, limit이 없으면 스로틀링 없이 여유 CPU를 버스트로 활용할 수 있습니다. 단, 신뢰할 수 없는 워크로드와 노드를 공유하는 멀티테넌트 환경이라면 이웃 보호를 위해 limit을 두는 편이 낫습니다.
노드에 자원이 남는데 Pod가 Pending인 이유는?
스케줄러는 실사용량이 아니라 requests 예약 합계로 판단하기 때문입니다. 실제 CPU/메모리는 놀고 있어도 이미 스케줄된 Pod들의 requests가 노드 Allocatable을 채웠다면 새 Pod가 못 들어갑니다. kubectl describe node의 Allocated resources와 각 워크로드의 requests가 실사용 대비 과하게 크지 않은지 점검하세요.
HPA와 VPA를 같이 써도 되나요?
같은 지표(CPU 또는 메모리)로는 동시에 쓰지 마세요. VPA가 requests를 바꾸면 HPA의 사용률 계산 기준이 흔들려 둘이 충돌합니다. 실무에서는 VPA를 updateMode: "Off"로 두고 requests 추천값만 참고하면서, 실제 스케일링은 HPA에 맡기는 조합을 많이 씁니다.
Guaranteed QoS로 만들면 절대 죽지 않나요?
아닙니다. 노드 메모리 압박 시 BestEffort와 Burstable Pod가 먼저 축출되므로 상대적으로 가장 늦게까지 보호될 뿐입니다. 자기 limit을 초과하면 Guaranteed라도 OOMKill됩니다. "가장 안전한 등급"이지 "불멸"은 아닙니다.
CPU 사용률이 낮은데 응답이 느린 이유는?
CPU 스로틀링일 가능성이 높습니다. 평균 사용률은 낮아도 100ms quota 안에서 버스트가 잘려 p99 지연이 튑니다. /sys/fs/cgroup/cpu.stat의 nr_throttled와 throttled_usec를 확인하고, 비율이 높으면 CPU limit을 올리거나 제거하는 것을 검토하세요.

댓글 0