OOM Killerは誰を選ぶのか
한국어 원문으로 표시합니다.
한 줄 요약
OOM Killer 의 점수는 top 의 VIRT 가 아니라 RSS 기준이고, oom_score_adj 는 총 메모리의 1000분의 1을 단위로 하는 가감점이다. 그리고 종료 코드 137 은 "SIGKILL 로 죽었다"는 뜻일 뿐 OOM 을 뜻하지 않는다.
왜 이게 필요했나
"서비스가 죽었는데 애플리케이션 로그에 에러가 없습니다. 마지막 줄이 정상 요청 처리입니다."
이 신고에서 로그가 깨끗하다는 것 자체가 단서다. SIGKILL 은 잡을 수 없고 핸들러도 돌지 않으므로 애플리케이션은 아무것도 남길 기회가 없다. 흔적은 애플리케이션이 아니라 커널이 남긴다.
여기서 흔한 오해가 네 가지 있다.
- 137 = OOM 이 아니다. 128+9, 즉 SIGKILL 로 죽었다는 뜻일 뿐이다. liveness probe 실패 후 유예 만료도, 수동 종료도, 노드 종료도 전부 137 이다.
- 범인은 VIRT 가 아니다. 64GB 를 mmap 으로 예약만 하고 200MB 만 실제로 쓰는 프로세스는 후보조차 아니다.
- 스왑을 넣으면 OOM 이 안 난다 - 절반만 맞다. 막는 게 아니라 늦추고, 그 구간의 스래싱이 즉사보다 나쁠 수 있다.
- 컨테이너가 OOMKilled 인데 limit 을 안 넘었을 수 있다. 노드 전역 OOM 이 컨테이너 안 프로세스를 죽여도 같은 표시가 나온다. 이 경우 limit 을 올려도 재발한다.
어떻게 동작하나
점수 계산은 대략 이렇다.
points = rss + swapents + (pgtables / PAGE_SIZE) # 단위: 페이지
adj = oom_score_adj * (totalpages / 1000)
badness = points + adj
즉 oom_score_adj 200 은 64GiB 머신에서 약 12.8GiB 상당의 가점이다. RSS 2GB 프로세스가 이 가점으로 11GB JVM 을 제칠 수도 있다. 반대로 -1000 은 특별하다 - 점수 계산에서 아예 제외되어 절대 선택되지 않는다. 배포판이 sshd 에 이 값을 주는 이유다.
언제 발동하나. "메모리가 0 이 됐을 때"가 아니라 "회수(reclaim)를 시도했는데 진전이 없을 때" 다. 그래서 스왑이 있으면 회수가 진전을 만들어 OOM 이 미뤄지고, 그 구간에 스래싱이 생긴다.
전역 OOM 과 cgroup OOM 은 다른 사건이다. dmesg 리포트 마지막의 oom-kill: 줄에 있는 constraint 가 이를 가른다. CONSTRAINT_NONE 은 전역, CONSTRAINT_MEMCG 는 cgroup 이다. cgroup OOM 일 때만 memory: usage/limit/failcnt 줄이 함께 나온다. 이 한 줄이 대응을 완전히 가른다 - 전역이면 노드 전체 배치를 봐야 하고, cgroup 이면 그 컨테이너의 한도와 실사용량 문제다.
컨테이너의 진짜 한도는 free 가 아니다. 컨테이너 안에서 free -m 은 호스트 전체를 보여 준다. 실제 상한은 /sys/fs/cgroup/memory.max 이고, 현재 사용량은 memory.current 다. 그리고 memory.events 의 세 카운터가 상태를 알려 준다 - max 는 상한에 부딪혀 회수한 횟수, oom 은 회수 실패로 OOM 경로에 진입한 횟수, oom_kill 은 실제로 죽인 횟수. max 만 크고 oom_kill 이 0 이면 아직 버티는 중이라는 뜻이라, 사후 부검보다 이걸 잡는 편이 낫다.
현장에서 만나는 모습
MemFree 와 MemAvailable 은 다르다. 리눅스는 남는 메모리를 페이지 캐시로 쓴다. MemFree 가 작은 것은 정상이고, 판단 기준은 회수 가능한 캐시까지 계산에 넣은 MemAvailable 이다. free 명령에서도 free 열이 아니라 available 열을 본다.
RSS 를 합산하면 실제보다 커진다. 여러 프로세스가 공유하는 라이브러리 페이지를 RSS 는 각각 전부 센다. 워커 20개짜리 웹 서버에서 단순 합산하면 실제의 몇 배가 나온다. 용량 산정에는 공유 페이지를 참조 프로세스 수로 나눈 PSS(/proc/PID/smaps_rollup)를 쓴다.
쿠버네티스의 request 는 스케줄러를 속이는 숫자가 아니라 생존 순위다. Burstable 파드의 oom_score_adj 는 request 비율로 계산되므로, request 를 작게 적고 많이 쓰는 파드가 노드 압박 시 가장 먼저 죽는다.
죽은 이유를 나중에 알아내는 법
OOM 킬러는 조용하지 않다. 다만 찾아볼 자리를 알아야 보인다.
dmesg -T | grep -iE 'killed process|out of memory|oom-kill'
journalctl -k --since "1 hour ago" | grep -i oom
커널 메시지에는 죽은 프로세스뿐 아니라 그때의 후보 목록이 함께 남는다.
total-vm, anon-rss, oom_score_adj 열을 보면 누가 얼마나 쓰고 있었는지가
그대로 나온다.
컨테이너에서는 cgroup 이 먼저 죽인다. 호스트 전체는 여유가 있는데 그 파드만 죽는 경우가 여기다. 종료 코드 137(128+9)과 함께 나타난다.
cat /sys/fs/cgroup/<경로>/memory.events # oom_kill 이 0이 아니면 이미 죽었다
kubectl get pod <파드> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
OOMKilled 인데 컨테이너가 안 죽은 것처럼 보일 때가 있다. 파드 안의 자식
프로세스만 죽으면 PID 1 은 살아 있으므로 재시작이 안 된다. 그때는 서비스가
"살아 있는데 아무것도 처리하지 않는" 상태가 된다. 이것을 잡는 것이 readiness
프로브다.
캐시는 사용 중인 메모리가 아니다. free -h 의 available 을 보고 판단한다.
used 만 보면 캐시가 찬 정상 상태를 부족으로 오해한다. 반대로 available 이
작은데 buff/cache 가 크다면, 그 캐시는 회수할 수 없는 것(tmpfs, mlock,
dirty page)일 수 있다.
메모리가 아니라 예약이 문제인 경우도 있다. 오버커밋 설정에 따라 malloc 이
성공하고 나중에 실제로 쓸 때 죽는다. 그래서 "할당은 됐는데 나중에 죽는" 현상이
생긴다.
cat /proc/meminfo | grep -E 'MemAvailable|Committed_AS|CommitLimit'
sysctl vm.overcommit_memory vm.overcommit_ratio
우선순위를 조정할 수 있다. 절대 죽으면 안 되는 프로세스는
oom_score_adj 를 낮추고, 먼저 죽어도 되는 것은 올린다. 다만 근본은 한도를
정확히 잡는 것이고, 점수 조정은 그 사이의 임시 방편이다.
다음 실습에서 할 것
/proc/meminfo 에서 MemTotal 과 MemAvailable 을 직접 읽고 둘의 차이를 확인한다. 메모리를 실제로 붙잡는 프로세스를 띄워 VmSize 와 VmRSS 가 어떻게 다른지 보고, oom_score_adj 를 올려 점수가 실제로 변하는 것을 관찰한다. 마지막에는 RSS 상위 프로세스를 뽑는 도구와 임계 초과를 감시하는 스크립트를 만든다.