TT Lab
블로그 시작하기
배우기 러닝패스 코스

리눅스 커널과 eBPF

캐시는 왜 OOM 을 내지 않는가

TT Lab 에서 이어서 보기

이 실습은 VM 에서 돕니다

우분투 24.04 VM 한 대입니다(메모리 2GiB, 스왑 없음). 페이지 캐시를 비우고, cgroup 에 메모리 한도를 걸고, 일부러 OOM 을 일으키는 일은 커널 권한이 필요합니다. 실습 파드에서는 할 수 없습니다.

목표

free 와 /proc/meminfo 의 숫자를 바르게 읽습니다. 파일을 읽으면 페이지 캐시가 늘고, 그 캐시가 읽은 프로세스의 cgroup 에 청구된다는 것을 확인합니다. 그리고 같은 64MiB 한도에서 익명 메모리는 OOM 으로 죽고 페이지 캐시는 회수될 뿐이라는 차이를 memory.events 로 봅니다. 마지막으로 memory.oom.group 이 무엇을 함께 죽이는지 봅니다.

왜 중요한가

"메모리가 꽉 찼다" 는 경보의 절반은 페이지 캐시입니다. 캐시는 필요하면 언제든 회수되는 메모리라 MemFree 가 작다고 위험한 것이 아닙니다. 반대로 컨테이너의 메모리 그래프에는 캐시가 함께 잡혀서, 로그를 많이 읽는 파드가 한도에 붙어 있는 것처럼 보이기도 합니다. 무엇이 회수되고 무엇이 죽는지를 알아야 한도를 올릴지, 코드를 고칠지 판단할 수 있습니다. 쿠버네티스는 cgroup v2 에서 컨테이너 그룹에 memory.oom.group 을 켜기 때문에, 한 프로세스의 OOM 이 컨테이너 전체를 끝낸다는 것도 알아 두어야 합니다.

단계

  1. /proc/meminfo 에서 다섯 항목을 골라 /root/kx/mem/01-meminfo.txt 에 저장하세요.
  2. 캐시를 비운 뒤 /var/lib/kx/data.bin 을 읽어 Cached 의 변화를 /root/kx/mem/02-cache.txt 에 적으세요.
  3. kx-mem/reader 그룹 안에서 같은 파일을 읽어, 캐시가 그 그룹에 청구되게 하세요.
  4. kx-mem/hog 그룹에 메모리 한도 64MiB 를 거세요.
  5. hog 그룹 안에서 kx-hog 200 을 돌려 OOM 을 일으키세요.
  6. kx-mem/cache 그룹에 같은 한도를 걸고 그 안에서 파일을 읽어, OOM 없이 회수만 일어나게 하세요.
  7. kx-mem/group 그룹에 memory.oom.group 을 켜고, 두 프로세스가 함께 끝나는 것을 확인하세요.
  8. 정리를 /root/kx/mem/08-report.md 에 쓰세요.

참고

free 가 말하지 않는 것

/proc/meminfo 에서 MemTotal·MemFree·MemAvailable·Buffers·Cached 다섯 줄을 골라 /root/kx/mem/01-meminfo.txt 에 그대로 저장하세요.

free 는 이 파일을 읽어 다시 계산한 것입니다. 줄 머리의 이름으로 고르면 됩니다(grep -E '^(이름|이름):'). MemFree 와 MemAvailable 이 왜 다른지 생각해 보세요.

읽으면 캐시가 는다

페이지 캐시를 비운 직후의 Cached(kB)를 재고, /var/lib/kx/data.bin(160MiB)을 끝까지 읽은 뒤 다시 재서 /root/kx/mem/02-cache.txt 에 before_kb= 와 after_kb= 두 줄로 적으세요. 차이가 120000 kB 이상이어야 합니다.

이 파일은 VM 이 뜰 때 만들어져서 이미 캐시에 올라 있습니다. 비우지 않고 재면 차이가 거의 없습니다. 읽기는 cat 파일 > /dev/null 이면 충분합니다.

캐시는 읽은 쪽 몫이다

/sys/fs/cgroup/kx-mem 을 만들어 자식에게 메모리 컨트롤러를 내려 주고, kx-mem/reader 그룹을 만드세요. 캐시를 비운 뒤 그 그룹 안에서 data.bin 을 읽어, reader 의 memory.stat 에서 file 이 오르고 memory.peak 이 100MiB 이상이 되게 하세요.

페이지 캐시는 그 페이지를 처음 메모리에 올린 프로세스의 cgroup 에 청구됩니다. 그래서 그룹에 들어간 뒤에 읽어야 하고, 이미 캐시에 있으면 새로 청구되지 않으니 먼저 비웁니다. 캐시는 나중에 회수될 수 있어서 채점은 최고치(memory.peak)로 합니다. 값은 바이트입니다.

한도를 건다

kx-mem/hog 그룹을 만들고 memory.max 를 64M 로, memory.swap.max 를 0 으로 거세요.

cgroup 파일에는 64M 처럼 단위를 붙여 써도 되고, 읽으면 바이트로 나옵니다. 스왑이 없는 VM 이지만 스왑 한도도 0 으로 적어 두는 습관을 들이세요. 스왑이 있는 노드에서는 그것이 OOM 대신 느려짐으로 바뀝니다.

익명 메모리는 죽는다

kx-mem/hog 그룹 안에서 kx-hog 200 을 실행해 OOM 을 일으키세요. 끝난 뒤 그 그룹의 memory.events 에서 oom_kill 이 1 이상이어야 합니다.

kx-hog 은 16MiB 씩 잡고 실제로 페이지를 건드립니다. 한도 64MiB 를 넘는 순간 커널이 그 그룹 안에서 희생자를 고릅니다. 종료 코드 137(128+9)이 SIGKILL 을 뜻합니다. dmesg 에도 기록이 남습니다.

캐시는 회수된다

kx-mem/cache 그룹에 memory.max 를 64M 로 걸고, 캐시를 비운 뒤 그 그룹 안에서 data.bin 을 끝까지 읽으세요. 그 그룹의 memory.events 에서 max 는 0 보다 크고 oom_kill 은 0 이어야 합니다.

160MiB 파일을 64MiB 한도 안에서 읽어도 죽지 않습니다. 커널이 한도에 닿을 때마다 오래된 캐시 페이지를 버리기 때문입니다. 그 '한도에 닿은 횟수' 가 max 이벤트입니다. memory.stat 의 file 이 한도 아래에 머무는지도 보세요.

함께 죽는 그룹

kx-mem/group 그룹에 memory.max 를 64M, memory.oom.group 을 1 로 걸고, 그 안에 sleep 600 을 백그라운드로 하나 넣은 뒤 같은 그룹에서 kx-hog 200 을 돌리세요. memory.events 의 oom_group_kill 이 1 이상이고 그룹에 남은 프로세스가 없어야 합니다.

memory.oom.group 이 1 이면 커널은 희생자 하나가 아니라 그 그룹의 프로세스 전부를 함께 죽입니다. 반쯤 죽은 서비스를 남기지 않으려는 장치입니다. sleep 은 메모리를 거의 안 쓰는데도 함께 끝납니다.

무엇이 죽고 무엇이 버티는가

/root/kx/mem/08-report.md 에 ## 페이지 캐시는 누구 몫인가·## OOM 이 나는 경우와 안 나는 경우·## 쿠버네티스에서 세 절을 쓰세요. 본문에 memory.events 와 oom_group_kill 이 나오고, 6단계 cache 그룹의 max 이벤트 횟수를 그대로 인용해야 합니다.

첫 절은 2·3단계, 둘째 절은 5·6단계의 차이, 셋째 절은 7단계와 컨테이너의 working set 이야기입니다. 숫자는 cat /sys/fs/cgroup/kx-mem/cache/memory.events 에서 가져오세요.