Search
⚙️

k8s workload의 안정성, 효율성 동시 확보에 관하여 2/2

Category
as S/W 엔지니어
Tags
k8s
stability
efficiency
OOMKilled
Linux
cgroup
kubelet
eviction
Created time
2026/01/01
Kubernetes 환경에서의 cgroup과 global OOM과 cgroup OOM. 단일 통합 root는 cgroup V2에서부터 도입되었다고(…cgroup v2 has only a single process hierarchy…)

Introduction

k8s workload의 안정성, 효율성 동시 확보에 관하여 1/2에서는 Pod 강제 종료 Overview와 Kubernetes에 의한 강제 종료인 pod eviction를 다뤘다. 여기서는 Linux에 의한 강제 종료인 OOMKilled 및 OOM Killer, 특히 cgroup OOM Killer를 다룬 이후, 안정성과 효율성 동시 확보를 위한 방안 제시로 전체를 정리한다.
전편과 마찬가지로, 아이디어 출처 대부분은 AI이지만 전부 직접 작성했다. 논리 구조 확인, AI 간 cross check 뿐 아니라 의심스럽다 싶은 부분은 죄다 공식 문서 참조 링크를 달았다. 그럼에도 간간히 공식 문서 뒷바침이 없는 부분이 있는데, 그건 지쳐서 그런 것이니…
틀린 부분이 보인다 → 코멘트를 남겨주시면 그저 감사할 뿐.

Summary

global OOM killer는 프로세스의 cgroup 소속에 무관하게 메모리 할당 실패가 발생하면 동작한다. 보통 메모리 할당 실패는 node 메모리가 고갈되는 시점으로, kill 대상에는 k8s 관리 프로세스, 시스템 프로세스도 포함된다(따라서 무섭다).
Kubernetes에서 사실 상 모든 프로세스는 cgroup의 통제를 받아 cgroup의 memory controller(memcg)가 cgroup OOM killer이다. 해당 cgroup의 limit 초과 여부가 OOM 발생의 기준이다(limit 설정은 걍 필수라 생각하자).
어짜피 완벽한 안정성이란 없다. 최선의 안정성과 효율성 trade-off만 있을 뿐이며 resources, PriorityClass, HPA 설정 뿐 아니라 app 종류 별 우선 순위를 두어 각기 다르게 이들 값을 다르게 가져가는 게 정신 건강에 좋아 보인다. 그리고 cgroup V2는 그냥 얼렁 도입하자.
효율성은 Burstable QoS 활용에서 나온다. request ~ limit 사이의 공유 메모리 영역 활용이 관건이고 systemReserved, kubeReserved 조절을 통해 해당 영역 조절이 가능하다.

Linux OOM killers

OOM killer는 cgroup과 global로 나뉜다. 앞선 그림에 보이듯 cgroup OOM은 container, pod에 각각 대응하여 cgroup 단위로 kill을 수행하고, global OOM은 cgroup OOM 발생 조건을 만족하지 않는 경우의 프로세스에 대해 kill을 수행하여, kill 대상에 root cgroup (global)에 위치한 k8s, 시스템 프로세스가 포함된다.

global OOM killer

상대적으로 다룰 내용이 적어 global OOM killer 먼저 다룬다.
Global OOM killer의 kill 대상 단위는 프로세스로서 node 수준에서 동작한다. 메모리 할당 실패 시 프로세스 별로 점수(oom_score )를 매겨 가장 높은 프로세스를 kill한다. 여기서 중요한 점은 이 프로세스 범위 내에 kubelet, containerd 등의 k8s 관리 프로세스와 system 프로세스가 포함되어 node 회복 불가를 야기할 수 있다는 점이다.
oom_score 는 사실 상 메모리 사용률 + QoS이기에 꽤나 직관적이다. 메모리 사용률 계산 시 분모로 (당연스럽게도) 가용 메모리, 즉 노드 전체 RAM과 swap 공간이 분모가 된다. oom_score_adj 란 값으로 표현되는 QoS의 경우 Guaranteed는 -997을, Burstable은 2 ~ 999, BestEffort는 1000을 가져, oom_score가 0 ~ 1000 범위라는 점을 고려했을 때 QoS가 매우 큰 영향을 미침을 알 수 있다.
참고로, k8s는 swap을 지원하지만 Linux에서는 별도 설정해야 동작한다(default는 swap이 있는 node에서 kubelet이 동작하지 않음).

cgroup OOM (killer)

일단 cgroup이 뭔지를 간단히 알아야 하겠는데, cgroup은 프로세스를 계층적으로 구성하고(cgroup core), 해당 계층 구조에 따라 리소스를 분배(cgroup controller)하는 놈으로 정의된다. version 1(V1)과 version 2(V2)가 있는데, V2는 나온지 꽤 오래되었음에도 여전히 V1은 많이 사용되는 듯하다. 어쨌건 cgroup의 가장 중요한 기능은 격리(isolation)로서, container 및 k8s pod는 이를 활용한 일종의 application이 되겠다(앞선 그림 참조).
cgroup OOM은 memcg(memory controller)라 불리는 cgroup controller에 의해 수행되어, memory limit을 넘어서면 OOM을 일으킨다. memory limit은 k8s의 resources.limits.memory 에 대응하는 값으로, V1에서 memory.limit_in_bytes , V2에서 memory.max 로 지정된다.
유의할 점으로, 일시적으로 사용량이 이 limit을 넘어설 수 있거니와(the usage may go over the limit temporarily), 이에 잡히지 않는 예외 경로가 존재하기에 완전히 잡아내는 것은 아니며, 무엇보다 limit 지정을 안하면 cgroup OOM은 동작하지 않아 결국에는 global OOM으로 넘어간다.
아래 표는 Linux의 메모리를 회수 가능 여부(reclaimable; OOM 없이 회수 가능 영역)와 cgroup version 별로 OOM 관리 수준을 나눈 것으로, 보다시피 cgroup V2가 V1에 비해 많은 부분, 특히 TCP socket buffer를 포함한 kernel memory 대부분을 커버하기에 global OOM 발생을 막는데 상당히 유리하다. 참고로 표 내용 중 RSS(Resident Set Size)란 User space 중 실제 물리 메모리 사용 영역을 뜻한다.
memory 분류
Space
cgroup v1 OOM 관리
cgroup v2 OOM 관리
설명
Anonymous RSS
User
- 대표적 회수 불가능 메모리 - heap, stack, anon shm(공유 메모리)을 포함. - OOM의 대표적 원인
File-backed RSS
User
 부분
- 파일을 메모리처럼 접근하기 위한 매핑으로 실행 파일 - 실행 파일, shared lib, file mmap 포함 - memory pressure 시 회수 지연으로 OOM 발생 가능
Reclaimable (회수 가능)
Kernel
 부분
- dentry, inode, page cache 등이 포함 - memory pressure 시 회수 지연으로 OOM 발생 가능
Unreclaimable (회수 불가능)
Kernel
대부분
- TCP socket buffer, kernel stack 포함 - cgroup V2에서는 대부분 측정(account) - Spike 시 TCP socket buffer 급증이 대표적 OOM 원인
참고: Working Set 이란
보통 실제 메모리 사용량을 보려면 container_memory_working_set_bytes cadvisor metric 을 사용하라 하는데(...usage... 가 아니라), working set이 뭔지 싶어 함께 남긴다. 참고로, working set은 뇌과학에서도 거의 유사한 의미로 사용된다.
회수하면 당장 문제가 될 가능성이 높은 사용 중인 메모리
RSS – (즉시 회수 가능한 file cache) 또는 anonymous RSS + active file-backed RSS
한편, Kubernetes는 cgroup V2의 메모리 관리 구조와 정렬하려는 움직임이 있으나 2023년 기준 alpha 버전이 2년이 훌쩍 넘어간 현 시점에도 GA가 안되었다. 따라서 global OOM 발생을 막는데 상당히 유효해보이는 V2의 soft limit 기능은 언감생심.

안정성과 효율성의 동시 확보를 위한 전략

이제 마지막에 다 왔다(아이구 힘들다). 해결안 제시에 앞서 강조해야 할 것은, Kubernetes 위에 올라간 workload는 원래 언제든 k8s 및 OS에 의해 죽을 수도 있다는 점, 그리고 k8s가 Guaranteed QoS와 Priority 등 안정성을 위한 다양한 장치를 마련함에도 완벽히 보장은 없다는 점이다. 이를 언제나 전제를 깔고 app 개발을 해야지, k8s 위에 올라가고 나서 app 로직에 문제 없는데 왜 OOMKilled 가 나느냐… 라고 해봐야 아무런 소용 없다.
따라서, 아래 내용은 해결안이라기 보다는 안정성과 효율성 동시 확보를 위한 최선안이다.

global OOM 발생 최소화

global OOM을 최소화해야하는 이유는 앞서 설명했다(k8s, system 프로세스 kill 위험 완화). 이 말은 결국 global OOM 대신 Eviction이나 cgroup OOM이 발생하도록 최대한 유도하는 것이다.
k8s의 resources.limits.memory 사용: limit이 없으면 global OOM으로 빠진다.
cgroup V2 지원 OS 사용: V1보다 관리 범위가 넓기에, 특히 spike에 취약한 TCP socket buffer를 다루기에 cgroup V2 지원 OS를 사용하는 것이 좋겠다. 메이저 업그레이드인데 V2를 쓰면 좋을 다른 이유도 많을 듯.
Eviction이 최대한 우선하도록 여유 공간을 마련: Kubernetes default 값은 soft가 없고 hard의 memory.available<100Mi 뿐인데, 절대치라 node 크기에 따른 유연한 대응이 어렵다. 아래 예와 같이 soft와 hard 동시 운용하되, %를 사용하여 유연한 대응을 한다.
evictionSoft: memory.available: "15%" evictionSoftGracePeriod: memory.available: "30s" evictionHard: memory.available: "10%"
YAML
복사

resources , HPA, headroom 설정

효율적 memory 사용을 위한 Burstable QoS 활용 전략. request ~ limit 영역은 사실 상 공유 pool로 일시적 요구에 대응하고, kubeReserved, systemReserved 조절을 통해 pool 크기를 조절한다.
지금 껏 논의는 죄다 안정성에 관한 것이었는데 이제야 효율성을 고려할 수 있겠다. 효율성 확보의 핵심은 burst 공간의 공유(공유 메모리 pool; headroom)를 통한 유휴 메모리 최소화로서, 이 아이디어는 burst 공간이 pod별 전담 메모리가 불필요하다는 점에서 출발한다.

서비스 담당자 TODO

HPA(averageUtilization): 아래에서 논할 목표 사용률로 설정한다. 이러한 의미로 사용하는 것이 averageUtilization 명칭과도 잘 맞는다.
resources.requests (request)
상시 사용량과 목표 사용률을 기준 설정하되, 작게 설정하여 효율과 scaling, allocation 가능성을 높이다.
request 는 kube-scheduler에 의한 pod 스케줄링 기준이자 node-pressure eviction 우선순위 판단 및 에 참조되는 값이기에 app이 보장받아야 할 안정 구간 즉, 상시 사용량의 의미로 사용한다.
일단 목표 사용률(used / request; utilization)을 정한다. 사용량 편차가 클수록 작게 잡고 작을 수록 크게 잡으면 될 듯 한데 일반적 웹 서비스의 경우 80~90% 정도가 적당하지 않나 싶다.
이제 남은건 request 갯수와 pod 갯수를 정하는 것 뿐이다. 목표 사용률은 위에서 정했고 상시 사용량은 실제 traffic 또는 부하 테스트 통해 얻을 수 있으므로 우변항은 상수가 된다.
requestpod×pod  갯수=상시 사용total목표  사용률\displaystyle request_{pod}\times pod\;갯수 = \frac{상시\space사용량_{total}}{목표\;사용률}
request 는 작게 잡는게 좋은데(즉, pod 갯수는 많게), 아래는 그 이유다.
Bin-packing 효율 증가: 작은 request일수록 노드에 촘촘히 배치 가능 → 단편화 감소
Autoscaling 해상도 증가: pod 하나가 전체 용량에서 차지하는 비중이 작아져 스케일링이 더 부드럽고 세밀해짐.
Allocation 가능성 증가: request 가 크면 당연스럽게도 allocation 역시 어렵다.
AI가 제시하는 request 하한
request를 너무 작게 잡으면 pod당 overhead와 CPU의 context switching 비용 등으로 배보다 배꼽이 더 커지는 사태가 발생한다(thrashing 위험). 이에 AI는 아래와 같은 하한선을 추천하는데 좀 크다 싶기도.
CPU: 100m ~ 300m
Memory: 256Mi ~ 512Mi
VPA(Vertical Pod Autoscaler)를 사용하면 request 결정에 용이하겠는데, recommendation 기능도 있어 직접 산정한 값과 비교하기도 좋겠다. 하지만 운영에 VPA, HPA 동시 상시 사용은 부적절하다고.
resources.limits (limit)
burst 상한으로 클러스터 관리자가 지정한 값으로 설정한다.
limit 는 cgroup OOM이 동작하는 기준 값으로,
아래에서 논하는 node의 burstable 영역 크기에 제한을 받는다(해당 크기보다 limit 이 커지면 limit 이 무의미해진다). 이에 따라, 즉 클러스터 관리자의 가이드를 받아 설정한다.

클러스터 관리자 TODO

Pod의 공유 burstable 영역 확보를 위해 headroom을 사전 지정한다. 이를 지정하지 않으면 pod의 burst 불능, 나아가 node까지 죽일 수 있는 global OOM이 유발될 수 있다.
kube-scheduler는 limit 을 전혀 고려하지 않고 오직 request 만 사용하기에, 극단적인 경우 node에는 pod의 request 총합만으로 남는 메모리가 없는 ‘꽉 찬 상태’로 pod를 스케줄링할 수 있다.
이 경우 limit 은 사실 상 무용지물이 되어 해당 node의 모든 pod는 burst 불능의 상태로 빠지는 것은 물론이고, 더욱 나쁜 것은 burst 시 k8s, OS 프로세스에게 필요한 공간이 모자라 node crash까지 발생 가능하다는 점이다.
kube-scheduler의 pod 할당 구조
pod의 allocatable 영역 크기는 전체 메모리에서 OS와 kubelet 등의 k8s 시스템 프로세스용으로 할당된 영역(systemReserved, kubeReserved)의 크기를 뺀 값으로,
kube-scheduler는 이 값에 타 pod용으로 이미 할당되고 남는 공간이 대상 pod의 request보다 클 때만 pod를 스케줄링한다.
위 문제를 방지하기 위해  Burst용 공간, 즉 headroom이 필요하고, node의 kubeReserved, systemReserved 를 키우는 것도 하나의 방법이다. 다만 이 경우 enforceNodeAllocatable 설정에서 pods 를 제외해야 하는 단점이 있다.
이제 headroom 크기를 얼마로 설정하느냐이다. 다음은 크기 산정 방법 중 하나로, limit 배수(mm; multiplier)와 pods의 동시 burst 비율(pp; proportion)을 사용하는 방법이다(동시 burst 비율은 실사용 모니터링 등을 통해 구한다). 이를 통해 클러스터 담당자는 pod에 사용할 limit 값을 서비스 담당자에게 가이드 가능하다.
headroom=111+p(m1)\displaystyle headroom = 1 - \frac{1}{1 + p(m - 1)}
예컨데, limitrequest 의 3배로 하고, burst 비율을 0.1로 하면 headroom은 16.7%(p=0.1,m=3 → 1 − 1/1.2 = 16.7%)이 나오고, 여기에 물리 메모리량을 곱하면 headroom 크기가 된다.
중요한건, 이 headroom에는 kubeReserved, systemReserved 의 보호 대상인 k8s, OS 시스템 프로세스용 공간도 포함된다는 점이다. 이를 고려해서 최종 headroom 크기를 설정할 필요가 있다.
물리 메모리량은 node의 크기마다 달라지므로, node를 띄울 때 자동으로 위 headroom 설정에 맞게 동적으로 설정되도록 하는 것이 좋겠다.

서비스 종류 별 QoS, Priority 설정 전략

하기 전략은 노드 메모리 관리를 중요도 순으로 계층화하여, 노드의 관리 프로세스 생존을 최대화하고(안정성), Burstable QoS를 통해 노드 자원의 초과 할당을 허용하여 자원 효율성을 높이는 것을 목표로 한다.
순위
대상 워크로드
목표
설정
설정 사유
1
클러스터 관리 프로세스 (e.g. Kubelet, Containerd, OS 데몬)
무조건 생존 보장 (노드 자체 회복력)
- kubeReserved, systemReserved 적정량 설정 - oom_score_adj 낮게설정
- 관리 프로세스 리소스 보장 - Burstable 영역(공유 풀) 확보 - 최대한 후순위로 global OOM 발생하도록
2
Guaranteed App
최대 생존 보장
- QoS: Guaranteed - PriorityClass: medium
- oom_score 이 낮아 OOM, eviction에 대해 유리 - 1순위 및 control plane 서비스보다는 낮지만 타 순위 대비 할당 가능성 높임
3
DaemonSet (OTel Agent 등)
생존 및 유연성 확보 (다양한 노드 크기 대응)
- QoS: Burstable - PriorityClass: highest
- 효율적인 리소스 운용. 다만 상대적으로 안정성 낮음(1,2 순위 대비). 다양한 리소스 크기별 노드 타입에 효율적 대응 - 죽었을 경우 최대한 빠르게 노드에 진입하도록
4
Burstable Deployment App (일반 워크로드)
최대 효율성 (자원 초과 할당 허용)
- QoS: Burstable - PriorityClass: low/default
- 안정성보다 효율성 중심. - 리소스 이슈 발생을 pod에 격리하기 위해 limit 사용(따라서 BestEffort 사용하지 않음) - 되도록이면 request도 사용하여 예측력을 높이는 것이 좋을 듯

기타 사항

다음은 본 주제 관련한 기타 AI 및 외부 자료가 전하는 사항이다. 난 설득되었다.
Local rate limiting은 spike에 상당히 도움 된다.
kernel 네트워크 메모리 spike를 직접 차단하지는 못하지만, socket buffer 회전율을 높여 노드가 global OOM 또는 hard lockup에 도달하기까지의 시간을 지연시키며, 이 지연 구간은 대부분의 일시적 traffic spike가 소멸되거나 HPA가 개입할 수 있는 유일한 생존 창이 된다.
TCP socket buffer 급증 문제는 HTTP/2(or gRPC)에서 상당부분 완화 가능하다.
client는 server 측 WINDOW를 sync 받고 상황이 안좋으면 안보내는 구조임(flow control 기반 backpressure).
그림 코드 백업