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

리눅스 커널과 eBPF

MemFree 가 작은 것은 경보가 아니다

TT Lab 에서 이어서 보기

한 줄 요약

리눅스는 남는 메모리를 페이지 캐시로 씁니다. 캐시는 필요하면 언제든 회수되므로 MemFree 가 작아도 위험하지 않습니다. 위험한 것은 버릴 곳이 없는 익명 메모리이고, 한도를 넘으면 OOM 이 납니다.

왜 헷갈리나

free 를 처음 본 사람은 free 열이 작으면 놀랍니다. 그런데 커널 입장에서 쓰지 않는 메모리는 낭비라서, 한 번 읽은 파일 내용을 메모리에 남겨 두고 다음 읽기를 빠르게 합니다. 이 페이지 캐시는 buff/cache 열에 잡히고, 다른 누군가 메모리가 필요하면 커널이 깨끗한 캐시부터 버려 내줍니다. 그래서 "지금 새 프로그램이 쓸 수 있는 양" 은 MemFree 가 아니라 MemAvailable 입니다.

컨테이너 환경에서는 한 겹 더 헷갈립니다. cgroup 은 페이지 캐시도 그룹의 메모리 사용량에 넣습니다. 로그를 많이 읽는 파드는 실제로 쓰는 것보다 훨씬 많은 메모리를 쓰는 것처럼 보이고, 한도 근처에 붙어 있는데도 죽지 않습니다.

어떻게 동작하나

메모리 페이지는 크게 두 종류입니다. 파일 페이지는 디스크의 파일과 짝이 있어서, 깨끗하면 그냥 버리고 다시 읽으면 됩니다. 익명 페이지는 malloc 으로 잡은 힙처럼 짝이 되는 파일이 없어서, 버리려면 스왑에 써야 합니다. 스왑이 없으면 버릴 방법이 없습니다.

페이지 캐시는 그 페이지를 처음 메모리에 올린 프로세스의 cgroup 에 청구됩니다. 나중에 다른 그룹이 같은 파일을 읽어도 이미 캐시에 있으면 새로 청구되지 않습니다. 그룹마다 memory.stat 의 anon·file 이 그 내역입니다.

cgroup v2 의 memory.max 는 넘을 수 없는 한도입니다. 그룹이 한도에 닿으면 커널은 먼저 그 그룹 안에서 회수를 시도합니다. 캐시는 버리고, 익명 페이지는 스왑할 수 있으면 스왑합니다. 그렇게 해서 자리가 나면 아무도 죽지 않고 memory.events 의 max 횟수만 오릅니다. 회수로도 자리가 안 나면 그 그룹 안에서 OOM killer 가 희생자를 골라 SIGKILL 을 보내고 oom_kill 이 오릅니다. memory.peak 은 그룹이 찍은 최고 사용량입니다.

memory.events 의 줄은 각각 다른 사건을 셉니다. high 는 memory.high 를 넘어 회수 압박과 지연을 받은 횟수, max 는 memory.max 에 닿아 회수를 시도한 횟수, oom 은 회수에 실패해 OOM 상황에 들어간 횟수, oom_kill 은 실제로 프로세스를 죽인 횟수입니다. max 만 오르고 oom_kill 이 0 이면 한도가 빠듯하지만 버티고 있다는 뜻이고, oom_kill 이 오르기 시작하면 한도나 코드를 바꿔야 할 때입니다.

memory.oom.group 을 1 로 두면 OOM 이 났을 때 희생자 하나가 아니라 그룹의 프로세스 전부를 함께 죽이고 oom_group_kill 을 올립니다. 일부만 죽어 반쯤 망가진 서비스가 남는 것을 막는 장치입니다.

현장에서 만나는 모습

쿠버네티스가 컨테이너의 메모리를 판단할 때 쓰는 working set 은 사용량에서 비활성 파일 캐시를 뺀 값입니다. 대시보드의 메모리 사용량이 한도에 붙어 있어도 working set 이 낮으면 캐시가 차 있는 것뿐입니다. 반대로 working set 이 한도로 다가가면 곧 OOM 입니다.

kubelet 은 cgroup v2 노드에서 컨테이너 그룹에 memory.oom.group 을 켭니다. 그래서 사이드카 없이 프로세스 여럿을 띄운 컨테이너는 그중 하나가 한도를 넘으면 통째로 재시작합니다. 파드 이벤트의 OOMKilled 와 그룹의 oom_group_kill 이 같은 사건입니다.

그리고 "메모리를 비우려고" 운영 서버에서 drop_caches 를 쓰는 습관은 버려야 합니다. 비운 캐시만큼 디스크 읽기가 한꺼번에 몰려 오히려 느려집니다. 실습에서 쓰는 것은 측정을 깨끗하게 하려는 것뿐입니다.

English corner

다음 실습에서 할 것

/proc/meminfo 를 읽고, 캐시를 비운 뒤 파일을 읽어 Cached 가 느는 것을 봅니다. cgroup 안에서 읽어 캐시가 그 그룹에 청구되는 것을 확인하고, 같은 64MiB 한도에서 익명 메모리는 OOM 으로 죽고 캐시는 회수만 되는 차이를 memory.events 로 비교합니다. 마지막으로 memory.oom.group 이 무엇을 함께 끝내는지 봅니다.