사전 OOM 킬러
개념

OOM 킬러

gabury1고친 사람 github-actions[bot]

OOM 킬러는 메모리가 바닥났을 때 프로세스 하나를 골라 강제로 끝냅니다. 그렇게 되찾은 메모리로 나머지 프로그램이 계속 돌아갑니다. 리눅스 커널에 들어 있는 기능입니다. 서버에서 멀쩡하던 프로세스가 로그 한 줄 없이 사라졌다면 먼저 의심해 볼 대상입니다.

쉽고 빠른 이해

OOM 킬러는 메모리가 모자라 기계 전체가 멈추기 직전에 나서는 커널 기능입니다. 메모리를 가장 많이 쥔 프로세스를 끝내고 그 메모리를 돌려받습니다.

리눅스는 프로그램이 달라는 메모리를 먼저 약속해 줍니다. 기계에 꽂힌 실제 메모리는 프로그램이 그 메모리를 처음 쓸 때 내줍니다. 그래서 약속한 양이 가진 양보다 많아질 수 있습니다.

모두가 한꺼번에 쓰러 오면 내줄 메모리가 없습니다. 누군가를 끝내지 않으면 기계 전체가 멈춥니다.

어떻게 도는가:

  1. 프로그램이 메모리를 쓰려는데 내줄 메모리가 없습니다
  2. 커널이 캐시를 비우고 안 쓰는 메모리를 디스크로 내보내 메모리를 되찾아 봅니다
  3. 그래도 모자라면 메모리를 가장 많이 쥔 프로세스를 골라 곧바로 끝냅니다

대가는 끝나는 쪽이 치릅니다. 정리할 틈이 없어서 마지막 로그도 못 남깁니다. 고른 대상이 하필 데이터베이스처럼 중요한 프로세스일 수도 있습니다.

상세

짐을 너무 많이 실은 화물선이 가라앉기 시작합니다. 선장은 가장 큰 짐 상자 하나를 골라 바다에 던집니다. 짐 주인에게 묻지 않고 기다려 주지도 않습니다. 배가 떠 있어야 나머지 짐도 무사하기 때문입니다.

OOM 킬러는 그 선장 노릇을 하는 기능입니다. 이름의 OOM(Out Of Memory, 메모리 부족)은 메모리가 바닥난 상태를 가리킵니다. 이 기능은 커널 안에 있습니다. 커널은 운영체제의 핵심부로, 프로그램들에 메모리를 나눠 주는 일을 맡습니다. 더 나눠 줄 메모리가 없을 때 이 기능이 깨어납니다.

서버에서 이 이름을 들으면 대개 리눅스 커널의 것을 가리킵니다. 이 문서도 리눅스를 기준으로 씁니다.

메모리가 바닥나는 까닭

메모리가 어떻게 바닥나는지를 알아야 이 기능이 왜 있는지가 보입니다. 열쇠는 커널이 메모리를 약속하는 때와 물리 메모리를 내주는 때가 다르다는 점입니다. 물리 메모리는 기계에 꽂힌 실제 메모리입니다.

프로그램이 메모리를 달라고 하면 커널은 곧바로 「줬다」고 답합니다. 이때는 주소 범위만 약속해 둡니다. 물리 메모리는 프로그램이 그 주소에 처음 값을 쓸 때 붙입니다. 이 방식을 요구 페이징이라고 합니다.

약속만 먼저 하니 약속한 양의 합이 가진 메모리보다 커질 수 있습니다. 이것을 오버커밋이라고 부릅니다.

대부분의 프로그램은 받은 메모리를 다 쓰지 않습니다. 약속한 만큼 물리 메모리를 미리 떼어 두면 아무도 안 쓰는 메모리가 묶입니다. 오버커밋은 그 낭비를 줄이려는 선택입니다.

문제는 여럿이 약속받은 양을 한꺼번에 쓰러 올 때입니다. 프로그램이 새 주소에 값을 쓰는 순간 커널은 물리 메모리를 붙여야 합니다. 이 순간은 함수 호출이 아니라 평범한 메모리 쓰기라서, 프로그램에 「실패」를 돌려줄 길이 없습니다.

그래서 커널은 어디선가 메모리를 되찾아 옵니다. 가장 쉽게 버릴 수 있는 것은 페이지 캐시입니다. 페이지 캐시는 디스크에서 읽은 파일 내용을 메모리에 담아 둔 것이라, 버려도 나중에 다시 읽으면 됩니다.

오래 안 쓴 메모리를 디스크로 내보내 물리 메모리를 비우는 방법도 있습니다. 내보낸 메모리를 담는 디스크 공간을 스왑이라 하고, 내보내는 일을 스와핑이라고 합니다. 이 둘로도 모자랄 때 커널은 OOM 킬러를 부릅니다.

flowchart TD
    A["프로그램이 새 주소에 값을 쓴다"] --> B{"빈 메모리가 있나"}
    B -->|"있다"| OK["메모리를 붙이고 계속한다"]
    B -->|"없다"| C["페이지 캐시를 비우고 디스크로 내보낸다"]
    C --> D{"메모리를 되찾았나"}
    D -->|"그렇다"| OK
    D -->|"아니다"| E["OOM 킬러가 프로세스 하나를 끝낸다"]
    E --> F["남은 프로세스는 되찾은 메모리로 계속한다"]

OOM 킬러가 없다면 메모리를 기다리는 프로그램들이 하나씩 멈춥니다. 커널도 일을 이어 갈 메모리가 없어 기계 전체가 응답을 잃습니다. 프로세스 하나를 잃는 대신 나머지를 살리는 것이 이 기능의 존재 이유입니다.

약속의 크기를 정하는 설정

커널이 얼마나 넉넉하게 약속할지는 설정으로 바꿀 수 있습니다. vm.overcommit_memory 라는 커널 설정값이 아래 세 방식 가운데 하나를 고릅니다.

값 약속하는 방식 메모리가 모자랄 때
0 기본값입니다. 한 번에 물리 메모리와 스왑을 합한 것보다 큰 요청처럼 누가 봐도 지나친 요청만 거절합니다 나중에 쓰는 순간 OOM 킬러가 나설 수 있습니다
1 언제나 약속합니다 나중에 쓰는 순간 OOM 킬러가 나설 수 있습니다
2 스왑 크기에 물리 메모리의 일정 비율(기본 50%)을 더한 만큼까지만 약속합니다 대부분의 경우 요청하는 순간 할당이 실패합니다

2 로 두면 모자람이 할당 호출의 오류로 드러납니다. 프로그램이 그 오류를 받아 스스로 대처할 수 있습니다. 대신 약속할 수 있는 총량이 줄어서, 메모리를 넉넉히 잡아 두는 프로그램은 할당부터 실패하기 쉽습니다.

끝낼 프로세스를 고르는 점수

OOM 킬러가 하는 일은 둘입니다. 끝낼 프로세스를 고르고, 끝냅니다. 먼저 고르는 쪽입니다. 고르는 기준은 점수 하나입니다.

커널은 프로세스마다 0 에서 1000 사이의 점수를 매깁니다. 점수는 그 프로세스가 쓸 수 있는 메모리 가운데 얼마만큼을 쥐고 있나를 나타냅니다. 기계 전체의 메모리가 바닥난 경우라면 쓸 수 있는 메모리는 물리 메모리와 스왑을 합한 양입니다.

쓸 수 있는 메모리를 다 쥐었으면 1000, 절반이면 500 쯤입니다. 쓸 수 있는 메모리에 스왑이 들어가므로 쥔 양에도 스왑으로 내보낸 몫이 들어갑니다.

점수가 가장 높은 프로세스가 끝납니다. 점수의 바탕은 메모리 양입니다. 오래 돌았는지는 보지 않습니다.

사람이 이 점수를 기울일 수도 있습니다. 프로세스마다 oom_score_adj 라는 조정값이 있습니다. 이 값은 -1000 에서 +1000 사이를 받습니다. 커널은 이 값을 점수에 더한 뒤 견줍니다. -1000 을 주면 점수가 늘 0 이 되어 그 프로세스는 대상에서 빠집니다.

두 값은 /proc 아래에 파일로 있습니다. /proc 은 커널이 프로세스 정보를 파일처럼 보여 주는 폴더입니다. 프로세스 번호마다 하위 폴더가 하나씩 있습니다. 점수는 그 안의 oom_score 파일에, 조정값은 oom_score_adj 파일에 있습니다.

아래는 쓸 수 있는 메모리의 절반을 쥔 프로세스 1234 의 점수를 읽고 조정하는 예입니다.

터미널
cat /proc/1234/oom_score      # 500
echo 300 > /proc/1234/oom_score_adj
cat /proc/1234/oom_score      # 800
echo -1000 > /proc/1234/oom_score_adj
cat /proc/1234/oom_score      # 0

조정값을 300 올리면 점수도 300 오릅니다. -1000 을 주면 메모리를 얼마나 쥐었든 0 으로 읽힙니다.

끝내는 방식과 남는 흔적

고른 뒤에는 곧바로 끝냅니다. 끝내는 방식을 알면 애플리케이션 로그에 왜 아무것도 안 남는지가 풀립니다.

끝낼 때는 시그널을 씁니다. 시그널은 커널이 프로세스에 보내는 짧은 알림입니다. 터미널에서 Ctrl+C 를 누르면 실행 중인 프로그램에 시그널이 가서 끝내라고 알립니다. 프로그램은 대부분의 시그널을 받아 하던 일을 정리하고 끝날 수 있습니다.

OOM 킬러가 보내는 것은 SIGKILL 입니다. 이 시그널은 프로그램이 받아 챙길 수 없습니다. 커널이 그 프로세스를 바로 끝내므로 종료 훅(끝나기 직전에 도는 정리 코드)도, 마지막 로그 한 줄도 돌 틈이 없습니다.

그래서 흔적은 커널 쪽에 남습니다. 커널 로그에 Out of memory 로 시작하는 줄이 찍힙니다. 끝낸 프로세스의 번호와 이름도 그 줄에 적힙니다. 커널 로그는 dmesg 명령이나 시스템 로그에서 읽습니다. 애플리케이션 로그만 보면 프로세스가 까닭 없이 사라진 것처럼 보입니다.

프로세스를 띄운 쪽에는 종료 코드 137 이 돌아옵니다. 띄운 쪽은 셸일 수도 있고 컨테이너 런타임일 수도 있습니다. 둘 다 시그널로 끝난 프로세스의 종료 코드를 128 에 시그널 번호를 더해 적습니다. SIGKILL 의 번호가 9 라서 128 + 9 = 137 입니다.

137 은 누가 SIGKILL 을 보내도 똑같이 나옵니다. OOM 킬러가 끝냈는지는 커널 로그로 확인합니다.

컨테이너 안의 OOM 킬러

지금까지는 기계 전체의 메모리가 바닥나는 경우였습니다. 컨테이너에서는 기계에 메모리가 남아 있는데도 프로세스가 끝납니다. 컨테이너마다 걸리는 메모리 상한 때문입니다.

컨테이너는 한 기계 위에서 프로그램을 따로 떼어 돌리는 방식입니다. 떼어 놓는 데 쓰는 커널 기능 가운데 cgroup(control group, 컨트롤 그룹)이 있습니다. cgroup 은 프로세스들을 한 묶음으로 묶어 그 묶음이 쓸 자원에 상한을 겁니다. 컨테이너에 메모리 2GB 를 주면 그 묶음에 2GB 상한이 걸립니다.

묶음의 메모리가 상한에 닿으면 커널은 먼저 그 묶음 안에서 메모리를 되찾아 봅니다. 되찾지 못하면 OOM 킬러가 그 묶음 안에서 움직입니다. 대상도 묶음 안에서만 고릅니다. 밖의 프로세스는 건드리지 않습니다. 점수의 「쓸 수 있는 메모리」도 이때는 기계 전체가 아니라 그 묶음의 상한입니다.

flowchart TD
    subgraph HOST["기계 · 메모리에 여유가 있다"]
        subgraph A["컨테이너 A · 상한 2GB · 상한에 닿았다"]
            A1["프로세스 · OOM 킬러가 끝낸다"]
        end
        subgraph B["컨테이너 B · 상한 4GB · 여유가 있다"]
            B1["프로세스 · 계속 돈다"]
        end
    end

컨테이너가 종료 코드 137 로 끝났다면 가장 먼저 이 경우를 의심합니다.

힙 상한과 컨테이너 상한의 어긋남

JVM(Java Virtual Machine, 자바 가상 머신) 위의 서비스가 컨테이너에서 자주 겪는 일입니다. JVM 은 객체를 담는 힙의 최대 크기를 설정으로 받습니다. 그런데 프로세스가 쓰는 메모리는 힙만이 아닙니다. 스레드마다 딸린 스택, 클래스 정보, 힙 밖에 잡는 버퍼가 더해집니다.

힙 최대 크기를 컨테이너 상한과 거의 같게 잡으면 힙 밖의 몫이 상한을 넘깁니다. 그러면 JVM 이 스스로 오류를 내기도 전에 커널이 프로세스를 끝냅니다.

이것은 OutOfMemoryError 와 다른 사건입니다. OutOfMemoryError 는 JVM 이 제 힙이 찼다고 던지는 예외라 로그와 스택 트레이스가 남습니다. OOM 킬러는 커널이 밖에서 끝내는 것이라 JVM 쪽에는 아무것도 안 남습니다.

메모리가 조금씩 불어나는 프로세스

메모리 누수가 있는 프로세스는 쥔 메모리가 조금씩 늘어납니다. 며칠 돌다가 상한에 닿으면 OOM 킬러가 끝냅니다. 그러면 systemd 나 쿠버네티스 같은 감시 도구가 다시 띄웁니다. 다시 뜬 프로세스는 메모리를 적게 쥐고 있어서 한동안 멀쩡해 보입니다.

코드의 실수가 아니어도 메모리는 불어납니다. 프로메테우스 같은 메트릭 수집 서버는 값의 종류가 늘어나는 만큼 메모리를 더 씁니다. 종류가 걷잡을 수 없이 느는 것을 카디널리티 폭발이라고 합니다. 그 끝도 OOM 킬러입니다. 일정한 간격으로 프로세스가 재시작된다면 이 두 모양을 의심해 볼 만합니다.

엉뚱한 프로세스가 끝나는 경우

점수는 메모리 양만 봅니다. 그래서 메모리를 갑자기 늘린 프로세스가 아니라 가장 많이 쥔 프로세스가 끝납니다. 원래 메모리를 크게 쥐고 도는 MySQL 같은 데이터베이스나 Redis 같은 캐시 서버가 대신 끝나기 쉽습니다.

막으려면 지켜야 할 프로세스의 oom_score_adj 를 낮춥니다. 그러면 다음으로 점수가 높은 프로세스가 대상이 됩니다.

모든 프로세스를 -1000 으로 막으면 끝낼 대상이 하나도 남지 않습니다. 그러면 커널은 메모리를 되찾을 방법이 없어 커널 패닉으로 기계를 멈춥니다. 커널 패닉은 커널이 더 진행할 수 없다고 보고 기계를 세우는 것입니다.

관련 항목

OOM 킬러를 부르는 메모리 약속 방식

메모리 오버커밋 · 요구 페이징 · 가상 메모리 · 페이지 폴트 · 물리 메모리

OOM 킬러보다 먼저 메모리를 되찾는 수단

페이지 캐시 · 스와핑 · 메모리 회수 · 페이지 교체 알고리즘 · 스래싱

OOM 킬러가 대상을 고르고 끝내는 데 쓰는 값과 신호

oom_score · oom_score_adj · procfs · 시그널 · SIGKILL · 종료 코드

OOM 킬러의 범위를 한 묶음으로 좁히는 격리 기능

cgroup · 컨테이너 · 메모리 제한 · 컨테이너 런타임 · Kubernetes · OOMKilled

OOM 킬러를 불러들이는 메모리 문제

메모리 누수 · 카디널리티 폭발 · 메모리 단편화 · 메모리 부족 · OOM

OOM 킬러와 헷갈리는 오류

OutOfMemoryError · 커널 패닉 · 세그멘테이션 폴트

OOM 킬러가 자주 끝내는 JVM 의 메모리 구성

JVM · 힙 · 가비지 컬렉션 · 메타스페이스 · 다이렉트 버퍼

OOM 킬러의 흔적을 읽는 도구

커널 로그 · dmesg · journalctl · 시스템 로그

OOM 킬러보다 먼저 움직이는 사용자 공간 도구

earlyoom · systemd-oomd · PSI · 메모리 압박

OOM 킬러가 속하는 상위 분류

커널 · Linux · 운영체제 · 메모리 관리 · MMU · 성능

다른 이름: OOM killer · Out Of Memory killer · oom-killer · OOM 킬 · 메모리 부족 킬러