8.2 리소스 요청과 제한
컨테이너 spec의 resources.requests와 resources.limits는 나란히 적지만 읽는 주체와 작동 시점이 다르다. requests는 스케줄러가 읽는 배치 시점의 입력이고, limits는 커널 cgroup에 내려가 런타임을 제약하는 상한이다. 이 구분을 이해하면 “requests만 적은 Pod가 왜 노드 자원을 통째로 쓰는가”, “limits를 넘겼는데 왜 CPU는 살고 메모리는 죽는가” 같은 질문이 한 번에 풀린다.
requests, 스케줄링의 입력
스케줄러의 filtering 단계는 노드의 할당 가능량(allocatable)과 그 노드에 이미 배치된 Pod들의 requests 합계를 비교한다. allocatable은 노드 전체 용량(capacity)에서 시스템 데몬 몫(system-reserved, kube-reserved)과 eviction 여유분을 뺀 값이다. 비교 대상이 실제 사용량이 아니라 requests의 합이라는 점이 중요하다. 노드의 실사용률이 20%라도 배치된 Pod들의 requests 합이 allocatable에 닿았으면 새 Pod는 들어가지 못하고, 반대로 requests를 실제 사용량보다 훨씬 작게 적으면 스케줄러는 과밀 배치를 허용하게 된다.
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
containers:
- name: app
image: nginx:1.27
resources:
requests:
cpu: "500m"
memory: "256Mi"
limits:
cpu: "1"
memory: "512Mi"노드별 잔여량은 kubectl describe node <노드>의 Allocatable과 Allocated resources 항목으로 확인한다. Allocated resources에 표시되는 것도 사용량이 아니라 requests의 합계다.
limits와 cgroup, 초과의 비대칭
kubelet은 컨테이너를 만들 때 requests와 limits를 컨테이너 런타임에 전달하고, 런타임이 이 값을 cgroup 파라미터로 기록한다. 대응 관계는 다음과 같다.
| 필드 | cgroup v2 | cgroup v1 | 성격 |
|---|---|---|---|
requests.cpu | cpu.weight | cpu.shares | 경합 시 상대 가중치 |
limits.cpu | cpu.max | cpu.cfs_quota_us | period당 사용 시간 상한 |
limits.memory | memory.max | memory.limit_in_bytes | 메모리 절대 상한 |
requests.memory가 표에 없는 것은 실수가 아니다. 기본 구성에서 메모리 requests는 cgroup 값으로 직접 내려가지 않고, 스케줄링 계산과 eviction 우선순위, 그리고 뒤에 나오는 oom_score_adj 계산에 쓰인다.
같은 limits라도 초과했을 때의 결과는 자원에 따라 비대칭이다. CPU는 압축 가능한(compressible) 자원이다. cpu.max의 quota를 다 쓰면 커널이 다음 period까지 실행을 지연시키는 throttling이 걸릴 뿐 프로세스는 죽지 않는다. 증상은 응답 지연으로 나타나고, container_cpu_cfs_throttled_periods_total 같은 메트릭으로 관측된다. 메모리는 압축이 불가능하다. 사용량이 memory.max에 닿으면 커널이 페이지 회수를 시도하고, 회수로 해결되지 않으면 OOM killer가 컨테이너 프로세스를 종료한다. 컨테이너는 OOMKilled 상태를 남기고 restartPolicy에 따라 재시작된다. 같은 “limits 초과"라는 사건이 CPU에서는 성능 저하로, 메모리에서는 프로세스 종료로 끝나는 이유가 이 압축 가능성의 차이다.
QoS 클래스 판정과 eviction 순서
Kubernetes는 requests와 limits의 조합에서 Pod마다 QoS 클래스를 판정한다. 직접 지정하는 값이 아니라 파생 값이며, kubectl get pod -o jsonpath='{.status.qosClass}'로 확인한다.
| 클래스 | 판정 조건 |
|---|---|
| Guaranteed | 모든 컨테이너가 CPU와 메모리의 requests, limits를 지정했고 두 값이 같음 |
| Burstable | Guaranteed는 아니지만 최소 한 컨테이너에 requests나 limits가 있음 |
| BestEffort | 어떤 컨테이너에도 requests와 limits가 전혀 없음 |
limits만 적으면 requests가 limits와 같은 값으로 자동 지정되므로 결과는 Guaranteed다.
QoS는 노드가 궁지에 몰렸을 때의 생존 순서와 연결된다. kubelet의 node-pressure eviction은 requests를 초과해 사용 중인 Pod부터 회수 대상으로 삼는데, requests가 0인 BestEffort는 항상 초과 상태이므로 사실상 가장 먼저 내려간다. requests 이내로 쓰는 Burstable과 Guaranteed는 마지막까지 남는다. 커널 OOM killer 쪽에도 같은 방향의 힌트가 전달된다. kubelet은 Guaranteed 컨테이너에 oom_score_adj -997, BestEffort에 1000, Burstable에는 메모리 requests가 클수록 낮아지는 중간값을 설정해, 커널이 종료 대상 프로세스를 고를 때도 requests를 많이 선언한 쪽이 늦게 죽도록 만든다. eviction의 임계값과 절차 전체는 8.5에서 다룬다.
requests 없는 클러스터의 위험
requests를 아무도 적지 않은 클러스터에서는 세 가지가 동시에 무너진다. 스케줄러는 모든 Pod의 자원 요구를 0으로 보므로 노드마다 Pod가 제한 없이 몰리고, 모든 Pod가 BestEffort로 판정되어 메모리 압박이 왔을 때 무엇부터 내려갈지에 대한 통제가 사라지며, CPU 경합 시에는 cpu.weight가 전부 기본값이라 중요한 워크로드를 보호할 가중치도 없다. 이 과밀은 평소에는 드러나지 않다가 트래픽이 몰리는 시점에 노드 단위 연쇄 장애로 나타난다.
정리
requests는 스케줄러가 노드를 고를 때 쓰는 약속이고, limits는 cgroup(cpu.max, memory.max)이 집행하는 상한이다. CPU 초과는 throttling으로 느려지는 데서 끝나지만 메모리 초과는 OOM kill로 이어진다는 비대칭을 기억해 두면, 장애의 겉모습만 보고도 어느 층의 문제인지 판별하기 쉬워진다. 두 값의 조합이 QoS 클래스를 결정하고, 그 클래스가 자원 부족 시의 생존 순서를 정한다.