resources.limits.memory: 512Mi 한 줄. 이게 사실 얼마나 복잡한 일을 시키는 건지, 최근에 팀 내부에서 OOM 관련 장애를 하나 겪고 나서야 제대로 파봤다. 대충 "커널이 알아서 죽여준다" 수준으로만 알고 있었는데, cgroup v2로 넘어오면서 동작이 꽤 달라졌다는 걸 그제서야 알았다.
이 글은 그때 정리한 노트에 가깝다. Kubernetes에서 메모리 limit이 실제로 커널까지 내려가서 어떤 파일에 어떤 값이 쓰이고, OOM이 터질 때 어떤 순서로 뭐가 일어나는지 — 이게 왜 최근 몇 년 사이 근본적으로 바뀌었는지 순서대로 짚어본다.
메모리 limit이 커널로 내려가는 경로
파드 매니페스트에 limits.memory: 512Mi를 적었다고 하자. 이 값은 kubelet → CRI(containerd 기준) → runc(또는 crun) → 커널의 cgroup 파일시스템, 이 순서로 내려간다.
최종적으로 값이 쓰이는 파일은 아래 두 경로 중 하나다.
- cgroup v1:
/sys/fs/cgroup/memory/kubepods.slice/.../memory.limit_in_bytes - cgroup v2:
/sys/fs/cgroup/kubepods.slice/.../memory.max
이름부터 다르다. v1은 "limit_in_bytes", v2는 "max". 그냥 이름만 바뀐 게 아니라 파일 하나에 해당하는 커널 로직이 통째로 갈렸다. v2에서는 memory.max 외에도 memory.high, memory.low, memory.min 같은 여러 계층의 임계값이 존재하고, 이 조합이 실제 회수(reclaim)와 kill 동작을 결정한다.
Kubernetes 1.25부터 cgroup v2가 GA가 됐고, 요즘 배포되는 대부분 배포판(Ubuntu 22.04+, AL2023, Bottlerocket, Talos 등)은 기본이 v2다. 그래서 실무에서 만나는 cgroup은 사실상 v2라고 봐도 된다.
memory.max에 값이 쓰인 뒤, 커널이 하는 일
memory.max에 512M이 쓰이면, 그 순간부터 해당 cgroup 안의 모든 프로세스의 메모리 사용량 합계가 커널의 메모리 컨트롤러에 의해 실시간 추적된다. 사용량은 memory.current 파일로 노출된다.
여기서 중요한 건, "사용량"에 뭐가 포함되냐다. 실무자 입장에서 자주 헶갈리는 지점이다.
memory.current에 카운트되는 항목:
- RSS (Resident Set Size) — 익명 매핑, 프로세스 힙/스택
- Page cache — 파일을 읽었을 때 커널이 캐싱한 페이지
- 커널 메모리 일부 — slab, kernel stacks 등 (v2에서 통합됨)
- tmpfs, shmem
여기서 놓치기 쉬운 게 page cache다. 컨테이너 안에서 어플리케이션이 큰 로그 파일을 tail만 해도, 파일이 페이지 캐시로 올라오면 그게 memory.current에 잡힌다. top의 RSS만 보다가 "왜 512Mi 넘지도 않았는데 OOM이 나지?" 하고 당황하는 케이스가 대부분 이거다.
사실 페이지 캐시는 회수 가뉥한 메모리라 커널이 리밋에 근접하면 알아서 던다. 다만 던지는 과정에도 CPU를 쓰기 때문에, 어플리케이션이 메모리 압박을 받고 있다는 신호가 P99 레이턴시 상승으로 먼저 나타나는 경우가 종종 있다.
OOM이 터지는 순간: v1과 v2의 결정적 차이
여기가 이 글에서 가장 중요한 부분이다.
cgroup v1 시절의 동작:
cgroup의 사용량이 limit에 도달하면, 커널의 OOM killer가 해당 cgroup 안에서 oom_score가 가장 높은 단일 프로세스를 골라 SIGKILL을 보낸다. 이게 문제였다. 컨테이너 안에 부모 프로세스 + 워커 여러 개가 돌고 있을 때, 워커 하나만 죽고 부모는 살아있는 상황이 발생한다. 컨테이너 상태는 "Running"인데 실제로는 반쪽짜리로 돌아가는 좀비 상태가 되는 것이다.
cgroup v2 + Kubernetes 1.28+의 동작:
kubelet이 컨테이너 cgroup을 만들 때 memory.oom.group = 1을 설정한다. 이 플래그가 켜져 있으면 OOM 발생 시 cgroup 안의 모든 프로세스가 원자적으로 함께 kill된다. 결과적으로 컨테이너 전체가 죽고, kubelet이 이를 감지해서 재시작한다.
이게 왜 중요하냐면, 이전에 v1에서 겪던 "OOMKilled 로그는 없는데 어플리케이션이 이상하게 동작하는" 유형의 미스터리한 장애가 사라지기 때문이다. 대신 대가는, "덜 중요한 워커 하나만 죽고 살아남는" 우아한 저하(graceful degradation) 시나리오도 함께 사라졌다는 것.
우리 팀에서는 이 차이 때문에 노드를 v2로 갈아치우고 나서 특정 워커 기반 서비스의 OOM 재시작 빈도가 눈에 띄게 늘었다. 알고 보니 원래는 워커 하나 죽고 부모가 새 워커 fork해서 조용히 넘어가던 케이스가, v2에서는 컨테이너 전체 재시작으로 승격된 것이었다. 진짜 문제는 원래부터 메모리 leak이 있었다는 거였고, v2가 그걸 가시화시킨 셈이다.
memory.high와 압박 신호
v2에는 memory.max 말고도 memory.high라는 게 있다. 이건 하드 리밋이 아니라 소프트 리밋에 가깝다. 사용량이 memory.high를 넘어가면 커널이 해당 cgroup의 프로세스들을 스로틀링하면서 메모리 회수를 강제로 돌린다. OOM은 안 나지만, 어플리케이션은 눈에 띄게 느려진다.
Kubernetes의 MemoryQoS 알파 피처(feature gate: MemoryQoS)를 켜면, kubelet이 다음 매핑을 자동으로 세팅한다:
memory.min = requests.memory
memory.high = limits.memory * 0.8 (기본 스로틀링 팩터)
memory.max = limits.memory
즉, limit의 80% 지점부터 커널이 압박을 걸고, 100% 지점에서 OOM으로 넘어간다. 이론적으로는 우아하다. 실제로는 이 피처가 alpha 상태로 꽤 오래 머물러 있고, 스로틀링이 걸린 상태에서 어플리케이션이 어떻게 반응하는지가 워크로드마다 너무 달라서 프로덕션 도입은 신중해야 한다.
관측 관점에서는 memory.pressure 파일이 유용하다. PSI(Pressure Stall Information)라고 부르는 지표인데, 해당 cgroup의 프로세스들이 메모리 회수 때문에 지연된 시간을 마이크로초 단위로 노출한다.
some avg10=12.34 avg60=5.67 avg300=2.10 total=1234567
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
some은 일부 프로세스가 스톨된 시간, full은 전체가 스톨된 시간의 비율이다. Prometheus의 node_exporter 최신 버전이면 node_pressure_memory_waiting_seconds_total로 노출된다. 이 값이 조금씩이라도 계속 증가한다는 건 컨테이너가 메모리 회수 압박을 받고 있다는 뜻이다. OOM 터지기 전에 미리 경보를 걸 수 있는 유일한 신호에 가깝다.
OOM 이벤트를 실무에서 잡는 법
OOMKilled가 났을 때 원인을 추적하는 순서는 대개 이렇다.
첫째, kubectl describe pod으로 Container Status의 lastState.terminated.reason과 exitCode(137이면 SIGKILL) 확인. 이건 다들 하는 것.
둘째, 노드에 들어가서 dmesg를 본다. OOM killer는 로그에 상세한 메모리 상태를 남긴다. dmesg -T | grep -i "oom\|memory cgroup"로 걸러본다. 어느 cgroup에서 터졌고, 그 순간의 anon/file/slab 각각 얼마나 썼는지가 다 찍혀있다.
셋째, /sys/fs/cgroup/kubepods.slice/.../memory.events 파일. 여기에 누적 카운터가 있다:
low 0
high 42
max 3
oom 3
oom_kill 3
high는 memory.high를 넘어간 횟수, max는 memory.max에 도달한 횟수, oom_kill은 실제로 kill이 실행된 횟수. 세 값이 다 늘어나고 있다면 진짜 메모리가 부족한 것이고, high만 늘어난다면 스로틀링에 계속 걸리고 있지만 OOM까지는 안 간 상태다.
사실 이 파일을 kubelet이나 metrics-server가 직접 노출해주면 좋을 텐데, 아직 그런 파이프라인이 표준으로 자리잡지는 않았다. 우리는 사이드카에서 파일을 읽어 Prometheus로 밀어넣는 방식으로 뽑아 쓴다.
그래서 실무자는 뭘 해야 하나
정리하자면 이렇다. cgroup v2 환경에서 메모리 리밋은 예전보다 훨씬 결정적으로 동작한다. OOM은 프로세스 하나가 아니라 컨테이너 전체를 죽인다. 이건 대체로 개선이지만, v1에서 우연히 살아남던 워크로드가 v2에서는 재시작 루프에 빠질 수 있다는 뜻이기도 하다.
메모리 leak을 진단할 때 RSS만 보지 말고 memory.current와 memory.events, memory.pressure를 같이 봐야 한다. 페이지 캐시 때문에 왜곡되는 경우가 많다.
MemoryQoS는 매력적이지만 alpha 상태고 워크로드별 튜닝이 필요하니, 프로덕션은 아직 이르다는 게 개인적인 의견이다. 대신 PSI 기반 알람은 지금 당장 세팅할 만한 가치가 있다. OOM보다 먼저 신호가 잡힌다는 것만으로도 야간 온콜 몇 번을 줄여준다.
다음에는 CPU cgroup 쪽, 특히 CFS quota와 CPU throttling 문제를 다뤄보려고 한다. 메모리 쪽만큼이나 오해가 많은 영역이다.
'IT > 컨테이너' 카테고리의 다른 글
| BuildKit cache mount 하나 잘못 붙였다가 CI가 두 배 느려진 이야기 (0) | 2026.07.14 |
|---|---|
| distroless 파드 디버깅, kubectl debug --profile 만 알면 된다 (0) | 2026.07.06 |
| Docker BuildKit 캐시, 사실 내부적으로는 이렇게 돌아간다 (1) | 2026.07.04 |
| Docker BuildKit cache mount, 이거 모르면 CI에서 매번 손해 본다 (0) | 2026.07.01 |
| Dockerfile COPY --link, 다들 쓴다는데 진짜 좋을까 (0) | 2026.06.28 |