파드의 컴퓨팅 리소스 관리
- 14.1 컨테이너 리소스 요청(requests)
- 14.2 리소스 제한(limits)과 OOMKilled
- 14.3 QoS 클래스 — BestEffort/Burstable/Guaranteed
- 14.4 LimitRange로 네임스페이스 기본값 강제
- 14.5 ResourceQuota로 네임스페이스 총량 제한
- 14.6 리소스 사용량 모니터링
지금까지는 파드를 만들 때 "이거 얼마나 먹는지"는 신경 안 썼어. 14장은 그걸 어떻게 신고하고, 어떻게 막고, 누가 먼저 죽어야 하는지를 다뤄. 멀티 테넌트 클러스터에선 이게 곧 비용과 안정성의 문제야 — 모르고 넘어가면 노드가 주기적으로 폭사하거든.
먼저 리소스 요청(requests). resources.requests에 cpu: 200m(200 millicores = 0.2 코어), memory: 10Mi 같은 식으로 적어. 요청은 컨테이너 단위로 적고, 파드 전체의 요청은 컨테이너들의 합이야. 핵심은 스케줄러는 노드에서 실제로 얼마나 쓰고 있는지 보지 않아. 이미 배치된 파드들의 requests 합계만 봐. 노드가 80% requests를 받았으면 실제 사용률이 30%여도 새 파드는 남은 20%에만 들어갈 수 있어. 이유는 간단해 — 기존 파드한테 한 약속을 깰 수 없으니까. 지금은 놀고 있어도 갑자기 쓸 수 있거든.
스케줄러는 둘 중 하나로 노드 점수를 매겨. LeastRequestedPriority는 덜 찬 노드 선호 — 부하 분산 목적이고, MostRequestedPriority는 더 찬 노드 선호 — 클라우드에서 노드 수를 최소화해서 비용 절약하는 목적이야 (cluster autoscaler가 빈 노드를 제거할 수 있게). kubectl describe node에서 보이는 두 값이 헷갈리지 말아야 할 게, Capacity는 노드의 전체 자원이고 Allocatable은 시스템 컴포넌트(kubelet, kube-proxy 같은 거)가 reserve한 걸 뺀 값이야. 스케줄러는 이 Allocatable을 봐. kubectl describe node의 "Non-terminated Pods" 섹션과 "Allocated resources"를 보면 누가 얼마를 잡고 있는지 정확히 보여. 파드가 Pending에 머물면 여기부터 봐야 해.
requests는 유휴 CPU 시간을 나누는 비율도 결정해. 200m와 1000m 두 파드가 둘 다 CPU를 풀로 쓰려 들면 1:5로 나뉘어. 한쪽이 놀고 있으면 다른 쪽이 다 가져가고 — 안 쓰는 자원은 낭비할 필요 없으니까. 그리고 GPU 같은 **커스텀 리소스(Extended Resources)**도 가능해. Node 객체의 capacity에 PATCH로 추가한 다음, 파드 requests에 그 이름으로 적으면 스케줄러가 가용량을 검사해서 배치해. kubernetes.io/ 접두사는 안 되고 정수만 가능해.
이제 limits. resources.limits에 cpu: 1, memory: 20Mi 같은 식으로 적어. limits는 안 적으면 requests와 같은 값으로 default 돼. CPU와 메모리는 거동이 완전히 달라. CPU는 한도 초과 시 throttle만 돼. 프로세스가 죽진 않아. 메모리는 한도 초과 시 OOMKill이야. 즉시 죽여. restart policy에 따라 재시작되지만 반복되면 CrashLoopBackOff 상태로 진입해 (10초 → 20초 → 40초 → ... → 5분 backoff). kubectl describe pod에서 Last State.Reason: OOMKilled, Exit Code: 137 보이면 메모리 부족이야. requests와 달리 모든 limits 합이 노드 capacity를 넘어도 돼 — overcommit이 가능하다는 거지. 이 때문에 노드가 진짜로 자원이 모자랄 때 누군가는 죽어야 해. 그게 14.3의 QoS야.
여기서 가장 큰 함정 하나 — 컨테이너는 자기 limit을 몰라. 컨테이너 안에서 top을 치면 노드 전체의 메모리/CPU가 보여. limit은 안 보여. 문제가 터지는 곳은 두 군데야. 첫째 Java JVM — -Xmx 안 주면 호스트 전체 메모리 기준으로 heap을 결정해. 운영 노드(메모리 큰 곳)에 띄우면 limit 넘겨서 OOMKill되거든. -Xmx 줘도 off-heap은 별개라 완전한 해결책이 아니야. 최신 JVM은 cgroup limit을 인식하긴 해. 둘째 CPU 코어 수 기반으로 worker thread를 결정하는 앱(Go의 GOMAXPROCS, 일부 Node.js 라이브러리) — 64코어 노드 보고 64개 워커를 띄워서 메모리가 폭발해. 해결은 Downward API로 limit을 환경변수로 주입하거나, cgroup 파일을 직접 읽는 거야 (/sys/fs/cgroup/cpu/cpu.cfs_quota_us).
QoS 클래스는 별도 필드가 아니야. requests/limits 조합에서 자동 도출돼. requests/limits 둘 다 없으면 BestEffort, 모든 컨테이너에서 모든 리소스의 requests = limits면 Guaranteed, 그 외 전부는 Burstable이야. Guaranteed 조건이 까다로워 — CPU와 메모리 둘 다, 모든 컨테이너에, requests = limits여야 해. 하나라도 어긋나면 Burstable이야. OOM 상황에서 죽는 순서는 BestEffort → Burstable → Guaranteed고, Guaranteed는 시스템 프로세스가 메모리 필요할 때만 죽어. 같은 클래스 안에서는 OOM score로 결정되는데, 핵심은 "requested 메모리 대비 현재 사용 비율"이 높은 쪽이 먼저 죽어. 절대 사용량이 아니라 비율이야. 즉 1GB 요청해서 900MB 쓰는 파드(90%)가 2GB 요청해서 1.4GB 쓰는 파드(70%)보다 먼저 죽어. 교훈 — requests를 실제 사용량과 가깝게 잡아야 해. 너무 낮게 잡으면 오버사용으로 인식돼서 먼저 죽어.
파드마다 requests/limits를 적어야 한다는 걸 강제하는 방법이 LimitRange야. LimitRanger admission plugin이 처리하고. 기능은 네 가지 — min/max로 범위 검증해서 위반 시 API 서버가 파드를 거부하고, defaultRequest/default로 requests/limits 안 적은 컨테이너에 자동 주입하고, maxLimitRequestRatio로 limit이 request의 몇 배까지 허용되는지 정해(4로 잡으면 200m request에 800m limit까지만 OK), 그리고 PVC도 최소·최대 storage 지정 가능해. LimitRange는 개별 파드·컨테이너 단위야. 네임스페이스 전체 합계는 못 막아. 그건 ResourceQuota 일이야. 검증은 admission 시점에만 일어나니까 LimitRange를 나중에 바꿔도 이미 떠 있는 파드엔 영향 없고.
ResourceQuota는 네임스페이스 전체 합계를 봐. CPU/메모리 quota는 hard에 requests.cpu, requests.memory, limits.cpu, limits.memory를 적어. kubectl describe quota로 현재 사용량과 한도를 확인할 수 있어. 함정이 하나 있는데 — ResourceQuota를 만들면 그 네임스페이스의 모든 파드는 반드시 requests/limits를 명시해야 해. 안 그러면 API 서버가 거부해. 그래서 ResourceQuota와 LimitRange(default 주입용)는 거의 항상 같이 만들어. Storage quota는 StorageClass별로도 따로 제한 가능해. 비싼 SSD는 제한, 일반 HDD는 넉넉히 같은 식으로. 객체 개수 quota도 가능한데 pods, services, services.loadbalancers, services.nodeports, persistentvolumeclaims, configmaps, secrets 같은 걸 제한할 수 있어. LoadBalancer 개수 제한이 특히 유용해 — 클라우드에서 비용에 직결되거든. 그리고 scope로 특정 파드만 대상으로 할 수도 있어. 스코프 4종은 BestEffort, NotBestEffort, Terminating(activeDeadlineSeconds 있음), NotTerminating이야.
마지막으로 모니터링. requests를 잘 잡으려면 실제 사용량을 봐야 해. cAdvisor는 Kubelet 안에 내장돼서 노드별로 컨테이너 메트릭을 수집하고, Heapster가 클러스터 전체 cAdvisor를 수집하는 게 책 시점이야. 참고로 Heapster는 deprecated되고 metrics-server로 대체됐어. 지금 kubectl top은 metrics-server가 백엔드야. kubectl top node로 노드 실제 CPU/메모리를, kubectl top pod --all-namespaces로 파드별 사용량을 볼 수 있어. kubectl describe node는 requests/limits를 보여주고 kubectl top은 실제 사용량을 보여줘 — 둘 다 봐야 sweet spot이 잡혀. 운영에서 본격 모니터링은 Prometheus + Grafana로 가고.
정리
14장 읽고 기억할 거 세 가지:
- 스케줄러는 requests만 본다, limits는 노드 capacity를 넘어서도 된다(overcommit). 그래서 requests는 "최소 보장"이고 limits는 "방어선"이다. requests를 너무 낮게 잡으면 OOM 상황에서 같은 QoS 안에서도 먼저 죽고(사용 비율로 판정), 너무 높게 잡으면 노드가 비어있어도 새 파드가 못 들어온다.
- QoS는 자동 도출된다. Guaranteed가 되려면 모든 컨테이너에서 CPU/메모리 둘 다 requests = limits여야 한다. 한 글자라도 다르면 Burstable. 운영 critical 파드는 Guaranteed로, 배치 작업은 BestEffort로 — OOM 시 죽는 순서가 BestEffort → Burstable → Guaranteed라는 걸 활용.
- 컨테이너는 자기 limit을 모른다.
top으로 노드 전체가 보인다. JVM-Xmx, GOMAXPROCS, worker pool 자동 산정 같은 거 쓰는 앱은 반드시 Downward API나 cgroup 파일로 limit을 명시적으로 주입해야 한다. 운영에서 Java 컨테이너가 OOMKill로 죽는 가장 흔한 원인.