Pod 강제 종료 구조. 크게 Kubernetes에 의한 eviction과 Linux에 의한 OOMKiled로 나눌 수 있다.
Introduction
Kubernetes 운영 시 접하는 흔한 이슈 중 하나인 OOMKilled (Out Of Memory의 메모리 부족으로 인한 pod의 비정상 종료), 그리고 이를 포함한 pod 안정성과 효율성의 동시 처리에 관한 논의이다. OOMKilled 는 메모리를 과다하게 할당하면 해결은 되겠지만 돈이 남아나지 않고서야... 달리 말하자면 효율성에 문제가 발생한다. 결국 안정성과 효율성을 동시에 취하는 방법이 필요하다는 의미로, 이는 pod 리소스와 autoscaling 설정을 어찌할 것인가의 문제로 귀결된다.
참고로 CPU 역시 resources 의 설정 대상임에도 주로 memory만 논하는데, Linux에서 CPU는 memory와는 달리 선점 자원(preemtible resource), 즉 OS가 강제로 빼앗아 다른 작업에 재할당 가능하여 사용 중에도 중단, 전환이 가능하기에 프로세스 종료를 야기하지 않기 때문이다. 단지 느려질 뿐이지.
한방에 정리해서 끝내려했는데 왠걸, 파면 팔수록 산이라서 나눠서 간다(사실 좀 지쳤다). 종착역은 일반적 web service용 Kubernetes workload의 resources , PriorityClass, QoS, HPA 설정 전략이 될 것이다. 꽤나 Kubernetes 환경에 익숙하다 생각했는데, 익숙하기만 하지 상당히 어설펐다.
참고로, 아이디어 출처 대부분은 AI이다(그렇다고 AI가 쓴 글은 전혀 아니고). Hallucination이 찜찜해서 내 나름의 논리구조 확인은 물론이고 AI 간 cross check에, 나아가 중요하다 싶은 부분은 공식 문서까지 달아놨지만… 그래도 남은 찜찜함은 몰겠다. 뭐 논문 쓰는 것도 아니고.
Summary
•
안정성 고려하려면 단순히 OOMKilled 만 걱정하면 될게 아니다. Eviction은 물론이요 Kubernetes 자체적으로 복구 불가능한 NotReady , 시스템 crash 역시 걱정해야 한다.
•
OOM는 Linux에서 구조적으로 완전히 피하는 건 불가능하다. 따라서 안정성을 사수하려면 보안 마냥 다층 방어 전략이 필요하다. Linux kernel 마저 그렇게 한다.
•
정상적 pod 강제 종료인 eviction은 preemption, node-pressure가 원인으로 동작한다. 특히 node-pressure는 polling 방식이기에 OOMKilled 이 발생 가능한거다.
•
OOMKilled 발생 주체는 cgroup, global OOM killer로 나뉘는데, 일반적으로 cgroup이 먼저 동작한다.
Pod eviction, OOM killers
일단 안정성을 팔려면 Kubernetes 관리 상 예외 경우에 속하는 OOMKilled 뿐 아니라, 일반적인 경우에 어떤 경로로 (app 의지와는 상관 없이) pod가 강제 종료될 수 있는지 알아야 하고 그게 pod 축출(eviction)이다. 축출 주체는 Kubernetes이고. 맨 앞 그림은 eviction과 OOMKilled 발생 주체와 조건을 나타낸다.
또한, OOMKilled를 발생시키는 주체는 Kubernetes가 아니라 Linux란 점도 중요하다. OOMKilled 란 이 예외적인 상황이 발생하는 이유는 kubelet에 의한 eviction(상기 그림의 1번)은 polling 기반이라 축출에 앞서 Linux의 OOM killer가 동작 가능하기 때문(검사 주기가 오기 전에 memory 폭발).
그나마 cgroup OOM killer에 의해 OOMKilled 가 발생하면 해당 pod로 이슈가 격리되겠지만, 문제는 (global) OOM killer의 kill 대상에 kubelet, containerd 등의 Kubernetes 관리 프로세스나 host의 시스템 프로세스마저 포함하여 NodeNotReady 발생 또는 (재수 없으면) node를 회복 불가 상태로 만들 수 있다는 점이다. 무시무시(!)한 일이다.
이게 끝이 아니다. 사실 선할당 → 후검출이란 Linux의 memory 관리 구조로 인해 memory를 ‘완벽하게’ 관리할 방법은 없다(Heuristic overcommit). 이 말은 global OOM마저 뚫고 node를 죽일 수 있다는 것인데 아래는 한 예로, 트래픽 spike는 꽤나 흔한 이슈이기도 하다.
1.
트래픽 spike → TCP socket buffer 등 커널 메모리 급증 → 가용 메모리 고갈
2.
cgroup OOM은 이를 감지 못할 수 있음 → (global) OOM killer 발동
3.
user space 프로세스의 반복적 kill. 그러나 OOM killer는 프로세스에 포함 안되는 unreclaimable kernel memory에 무효 → 가용 메모리 회복 실패
4.
반복 OOM → 결국 kubelet, systemd 등 k8s, 시스템 프로세스까지 종료 → node 회복 불가
아래 표는 memory pressure로 인한 관리 주체 별 정리로서, 일반적으로 나열된 순서대로 발생한다. 상세 내용은 그 이후로 이어진다.
주체 | 관리 범위 | 동작 결과 | 동작 방식 | 한계 |
kube-cheduler (trigger) | cluster | pod 축출(eviction) | Event 방식 (더 높은 PriorityClass pod scheduling 시) | memory pressure와 무관, preemption 시에 동작 |
kubelet | node | pod 축출(eviction) | Polling 방식 (노드 가용 메모리가 eviction threshold에 도달 시) | polling으로 인해 node pressure 신호는 늦게 감지될 수 있음. 따라서, OOMKilled 의 완전한 차단은 불가. |
cgroup OOM killer | pod | container OOMKilled | Event 방식 (limit 초과) | cgroup V1은 kernel memory, 특히 spike로 문제되는 TCP socket buffer를 다루는데 제약이 크고, V2 및 global 조차 한계가 있음. |
global OOM killer | node | process OOMKilled | Event 방식 (메모리 할당 실패 시)) | kill 대상에 k8s, 시스템 프로세스까지 포함. → node 회복 불가 |
Pod eviction
먼저 정상(?) 강제 종료인 pod eviction부터 간다.
Pod Eviction 발생 구조. Preemption과 node-pressure의 경우로 나뉜다.
Pod eviction을 발생시키는 경우는 여럿이지만 preemption과 node pressure 상황이 대표적으로, 전자는 신규 pod를 위한 남은 node resource가 모자라 기존 pod가 eviction되는 경우를, 후자는 pods의 resource 과다 사용으로 인해 OS가 할당 가능 resource를 확보하려는 경우를 의미한다.
각각 kube-scheduler와 kubelet이 담당하며, 전자의 경우 triggering 역할만 담당한다(trigger되면 kube-apiserver를 거쳐 kubelet으로 전해지고, kubelet이 eviction을 실행한다).
Preemption
Preemption에 관여하는 값은 PriorityClass 뿐으로 동일 PriorityClass 내에선 선착순이다. 이에 따라 eviction은 PriorityClass가 낮은 pod에서 먼저 발생한다. 따라서 PriorityClass를 높게 설정하면, 먼저 scheduling될 뿐 아니라 preemption에 의한 eviction 역시 낮은 PriorityClass의 타 pod에 비해 늦게 발생한다. 아래의 Node-pressure 상황과는 달리 requests.memory, QoS와는 무관하다.
당연스럽게도 해당 node에 반드시 schedule 되어야하는 조건(e.g. DaemonSet)이 아니라면, 여유 있는 타 node에 schedule되어 preemption은 발생하지 않는다. 문제는 node에 여유가 없어 Cluster Autoscaler나 Karpenter 등이 신규 node를 생성하는 경우 preemption 없이 해당 신규 node에 scheduling되느냐인데, preemption이 신규 node 준비보다 먼저 발생 가능하기에 preemption을 막을 방법은 사실 상 예비 node 상시 준비가 최선일 듯.
참고로, preemption도 조건이 있어 발생 안할 수도 있다. 안한 대표적 상태가 Pending 이다.
Node-pressure
Node pressure는 memory, disk, PID 부족 상황을 의미하는데, 이 경우 kubelet은 pod를 축출(eviction)하는데, 사실 상 주로 발생되는 원인은 memory 부족일 것이다.
•
Soft / Hard threshold: node에 사전 지정된 eviction threshold를 리소스가 넘어서면 eviction이 발동한다. Soft는 hard보다 threshold가 낮아 먼저 동작하며 일정 시간이 지나면 eviction을 시도하고, hard의 경우 바로 시도한다.
•
Polling 기반: default 10초마다 threshold를 넘는지를 검사한다(housekeeping-interval). polling 방식이기에 eviction보다 먼저 OOM killer에 의한 OOMKilled 발생이 가능하다.
•
Eviction 대상 pod 선택 규칙
◦
node 내 전체 pod를 대상으로,
◦
현 사용량이 request 를 넘어섰는지 여부 → PriorityClass → 사용량의 request 를 넘은 비율 순으로 선택 된다. 후순위 규칙은 선순위 규칙으로 선택이 불가능할 경우(draw) 적용된다. QoS는 직접적 사용하지는 않지만 request 설정 방식에 따라 결과적으로 QoS와 유사한 우선순위 효과를 낸다.
◦
request가 PriorityClass 보다 순위가 높은 이유는 request 를 ‘계약’으로, PriorityClass 를 ‘계급’으로 생각해보면 자연스럽게 이해된다.
정리
정리하자면, Preemption 방지를 위해서는 PriorityClass 를 높이고, Node-pressure eviction 방지를 위해서는 resources.requests 를 늘리는 것이다. 당연스럽게도 후자는 효율성에 직결되어 늘리는 만큼 돈 더 많이 나가므로, ‘적당히’ 늘리는게 중요하다. 결국 ‘적당히’가 얼마냐, 그 기준이 뭐냐란 질문으로 이어지는데, 결론만 먼저 전하자면 pod의 평균 사용량이 아닐까 싶다. Spike는 resources.limits 으로 대응하고.
k8s workload의 안정성, 효율성 확보에 관하여 #2으로 계속됩니다.
그림 코드 백업




