목차
- 오늘 한 것 — requests와 limits를 다르게 본 뒤 일어난 일
- 새로 알게 된 것 — 두 죽음의 메커니즘은 다르다
- 코드 — 워크로드 타입별 설정값
- 메모 — 언제 쓰고 언제 안 쓰는지
쿠버네티스 리소스 설정을 다시 본 건 컨테이너가 자꾸 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 pod에 Last 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로 튀면 안 될 때. 배치 잡 중 무한 루프 가능성이 있는 코드.
당장 해볼 만한 액션 세 가지.
- VPA를 Recommender 모드(
updateMode: Off)로 deploy한다. 실제 파드는 안 건드린다. 7일치 추천값만 본다(참고: kubernetes/autoscaler GitHub, vertical-pod-autoscaler). - Prometheus에서
rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m])를 본다. 10%를 넘는 컨테이너는 CPU limit을 의심한다. kubectl get pod <name> -o jsonpath='{.status.qosClass}'로 QoS class를 확인한다. critical 서비스가 Burstable로 떠 있으면 설정이 잘못된 거다.
프론트엔드 시절엔 브라우저 메모리를 거의 안 봤다. 백엔드 인프라는 결이 다르더라. limits를 잘못 잡으면 코드는 멀쩡한데 서비스가 죽는다. 같은 컨테이너, 같은 이미지를 두고도 QoS class 하나로 운명이 갈리는 게 처음엔 황당했다. 지금은 모든 매니페스트에서 가장 먼저 보는 줄이 됐다.
관련 글
- ArgoCD GitOps 설정 실전: 설치부터 자동 Sync 운영까지 – ArgoCD GitOps 설정을 처음부터 끝까지 따라가며, Jenkins 기반 스크립트 배포에서 선언적 배포로 넘어갈 때 걸리는 지점을 짚…
- Kubernetes VPA 설정으로 OOMKilled 해결: EKS 실무 가이드와 47→3건 감소 사례 – FastAPI 워커에서 주간 47건씩 발생하던 OOMKilled를 Kubernetes VPA 설정으로 3건까지 줄였다. HPA·수동 튜닝과…
- TypeScript Node.js Kubernetes 배포에서 Alpine이 답이 아닌 이유 – TypeScript Express/Fastify 앱을 Kubernetes에 올릴 때 Alpine부터 잡는 게 흔한 선택이지만, native…