cgroup v2 전환 후 OOMKill 동작이 바뀐 이유
cgroup v2 전환 후 OOMKill 동작이 바뀐 이유
올해 초 우리 팀 클러스터를 EKS 1.28에서 1.30으로 올리면서 cgroup v2 노드 비율이 100%가 됐다. 그 직후부터 묘하게 신경 쓰이는 현상이 생겼다. 예전 같으면 사이드카 하나만 OOM으로 죽고 메인 컨테이너는 살아 있던 케이스가, 이제는 컨테이너 안의 모든 프로세스가 한꺼번에 같이 죽고 있었다. 처음에는 "그래, 이게 더 깔끔하긴 한데" 정도로 넘겼는데, 일부 멀티 프로세스 워크로드가 갑자기 불안정해지는 걸 보고 한참을 파봤다. 결론부터 말하면 이건 버그가 아니라 의도된 변경이고, 알고 쓰면 오히려 운영이 단순해진다. 다만 모르고 쓰면 황당한 장애를 만난다.
이 글은 그 동작이 왜, 어디서, 어떻게 바뀌었는지를 cgroup, kubelet, kernel 세 레이어로 나눠서 풀어본 정리다.
memory.oom.group, 이게 뭔데
cgroup v2가 v1과 결정적으로 다른 부분 중 하나가 memory.oom.group이라는 컨트롤 파일이다. v1 시절에는 OOM Killer가 메모리 cgroup 단위로 동작하긴 했지만, 죽일 프로세스를 고르는 건 결국 커널의 oom_score 기반 휴리스틱이었다. cgroup에 프로세스가 10개 있으면 그중 점수가 가장 안 좋은 1개만 죽었다. 사이드카로 띄운 작은 보조 프로세스가 메모리를 거의 안 쓰는 메인 프로세스보다 먼저 죽는 일이 자주 일어났다. 더 큰 문제는, 죽은 프로세스가 컨테이너 PID 1이 아닌 자식 프로세스인 경우다. 컨테이너는 살아 있고, 안에 핵심 워커 하나만 사라진 상태. 헬스체크는 통과한다. 그런데 일은 안 한다. 좀비 컨테이너의 전형이다.
cgroup v2의 memory.oom.group은 이 문제를 정면으로 해결한다. 이 파일에 1을 쓰면, OOM 이벤트가 발생했을 때 그 cgroup에 속한 모든 태스크가 한꺼번에 SIGKILL을 받는다. 일부만 죽지 않는다. 전부 죽거나, 전부 산다. 그리고 자식 cgroup으로도 전파된다.
# v2 노드의 어떤 컨테이너 cgroup에서
$ cat /sys/fs/cgroup/kubepods.slice/.../memory.oom.group
1
쿠버네티스 1.28부터 kubelet은 컨테이너 단위 cgroup에 이걸 1로 설정한다. 그래서 위에서 말한 "사이드카만 죽고 메인은 사는" 패턴이 사라진 거다.
왜 이걸 강제로 켰을까
PR #117793에서 메인테이너들이 정리한 이유는 단순했다. 절반만 죽은 컨테이너가 만들어내는 운영 비용이 너무 컸다. 헬스체크를 잘 짜놓은 워크로드면 문제없지만, 현실 워크로드의 절반쯤은 그렇지 못하다. 특히 Python uWSGI, Node.js cluster, nginx worker 같이 워커 프로세스를 여럿 띄우는 패턴은 자식 하나가 OOM으로 사라져도 마스터가 새로 fork해서 계속 굴러간다. 새 자식도 같은 이유로 또 죽는다. 무한 fork-kill 루프가 도는 동안 메모리는 계속 출렁이고, kubelet은 컨테이너가 "정상"이라고 본다. 결국 사람이 알아챌 때쯤이면 노드 전체가 흔들린다.
memory.oom.group=1은 이걸 끊는다. 어차피 한 프로세스가 못 버틴 메모리 상황이면 같은 컨테이너의 다른 프로세스도 곧 마주칠 가능성이 크니, 깔끔하게 컨테이너째 OOMKilled로 만들고 kubelet이 재시작 정책에 따라 새 컨테이너를 띄우게 하자는 거다. 운영 모델이 단순해진다.
그런데 멀티 프로세스 워크로드는 어쩌라고
여기서 끝났으면 좋았겠지만, 세상에는 자식 프로세스 OOM을 의도적으로 처리하도록 설계된 소프트웨어가 많다. 대표적으로 Postgres가 그렇다. Postgres의 백엔드 워커 하나가 OOM으로 죽으면 postmaster가 그걸 잡아서 깔끔하게 정리하고 다른 연결은 영향을 받지 않는다. 이런 워크로드한테 cgroup 통째로 죽이는 동작은 오히려 가용성을 떨어뜨린다.
그래서 kubernetes/kubernetes#126096에서 singleProcessOOMKill이라는 kubelet config 플래그가 추가됐다. 이걸 true로 설정하면 v2 노드에서도 v1처럼 단일 프로세스만 죽는 동작으로 돌아간다.
# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
singleProcessOOMKill: true
이걸 켜면 kubelet은 memory.oom.group을 1로 쓰지 않는다. 노드 단위 설정이라 파드 단위로 섞어 쓸 수는 없다. 이게 좀 아쉬운데, 사실 이슈 #124253에서는 파드 단위 어노테이션으로 제어하자는 제안이 있었다. 아직 머지는 안 됐다.
우리 팀은 결국 노드 풀을 두 개로 나눴다. 일반 워크로드는 기본값(group kill)으로, DB 인스턴스가 도는 노드 풀은 singleProcessOOMKill: true로. 이게 깔끔한 해법인지는 아직 확신이 없다. 노드 풀이 늘어나는 건 항상 운영 비용이니까.
진단할 때 보는 신호
cgroup v2 노드에서 OOM이 발생했을 때 어디를 봐야 하는지도 정리해두자. dmesg를 보면 v1 시절과 약간 다른 메시지가 찍힌다.
Memory cgroup out of memory: Killed process 12345 (myapp) ...
oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=...,cpuset=...,...
Tasks state (memory values in pages):
[ 12345] 0 12345 65432 32100 ... myapp
[ 12347] 0 12347 1234 567 ... sidecar
oom_reaper: reaped process 12345 (myapp), ...
oom_reaper: reaped process 12347 (sidecar), ...
여러 프로세스가 줄줄이 oom_reaper: reaped로 찍히면 그게 group kill이다. 한 줄만 보이면 single process kill. 컨테이너 cgroup의 memory.events를 봐도 단서가 나온다.
$ cat /sys/fs/cgroup/.../memory.events
low 0
high 12
max 145
oom 3
oom_kill 5
oom_group_kill 1
oom_group_kill 카운터가 v2에서 새로 생긴 항목이다. 이 값이 0보다 크면 그 컨테이너에서 group kill이 한 번 이상 발생했다는 뜻이다. Prometheus의 cAdvisor 메트릭에서는 아직 이 카운터를 직접 노출하진 않는다. node_exporter의 node_pressure_* 시리즈와 같이 보면 도움이 된다.
마이그레이션 시 점검 리스트
cgroup v1 → v2로 넘어가는 클러스터에서 우리가 실제로 챙겼던 것들을 적어둔다. 도움이 될지 모르겠지만 비슷한 작업을 앞두고 있다면 한번 훑어보길 권한다.
첫째, 모든 멀티 프로세스 컨테이너의 PID 1을 점검한다. PID 1이 자식 시그널을 제대로 전달하지 않는 구조라면 group kill 동작이 오히려 잔여 자식을 만들 수 있다. tini나 dumb-init 같은 init wrapper를 쓰고 있는지 확인.
둘째, JVM 같은 런타임은 자체 메모리 관리와 cgroup 시그널이 충돌할 수 있다. 특히 MaxRAMPercentage를 너무 높게 잡아둔 워크로드는 group kill로 더 자주 죽는다. 한도를 좀 낮춰주는 게 안전하다.
셋째, 가능하면 Pod의 resources.limits.memory를 좀 더 보수적으로 잡는다. v2에서 OOM이 발생하면 영향 범위가 컨테이너 전체이므로, 자주 limit에 닿는 워크로드는 그만큼 자주 통째로 죽는다.
넷째, 모니터링에 OOMKilled 알림이 들어가 있는지 다시 본다. v1 시절에는 자식만 죽고 컨테이너는 살아 있어서 알림이 안 떴던 케이스가, v2에서는 컨테이너 재시작으로 잡힌다. 알림이 늘어나면 노이즈처럼 보일 수 있지만 이건 사실 v1에서 감춰져 있던 진짜 신호다.
마무리
이 변화는 어떤 의미로 보면 "쿠버네티스가 컨테이너를 좀 더 컨테이너답게 다루기 시작했다"는 신호다. 하나의 운명 공동체로 묶고, 메모리 압박을 받으면 함께 죽고 함께 다시 태어나게. 우리 팀처럼 노드 풀을 나눠야 하는 케이스가 분명히 있긴 하지만, 전체 클러스터의 운영 비용은 v2로 오면서 줄었다고 본다. 좀비 컨테이너 디버깅에 쓰던 시간이 사실 생각보다 컸다.
다음에 기회가 되면 cgroup v2의 memory.high / memory.low / memory.min 계층 쪽을 한번 정리해보고 싶다. Kubernetes에서는 limits/requests 추상화 뒤에 가려져 있는데, 직접 만져보면 swap-less 환경에서 OOM 전 단계의 throttling 동작이 꽤 다르게 동작한다.
참고: Preferred Networks 기술 블로그 · kubernetes#124253 · kubernetes#126096