Docker and OOM(Out Of Memory) Killer

이야기는 이렇게 시작된다.

  1. Docker를 쓰다가
  2. Container위에서 돌던 Java 프로세스가 OOM(Out Of Memory) Error를 내고 죽은 로그를 발견!
  3. Docker Container의 OOM 문제에 대해서 리서치를 시작!
  4. 언제나 그렇듯 나는 삼천포로 빠져들어…
  5. Linux Kernel의 OOM Killer와의 예정되지 않았던 만남!
  6. 더듬더듬 흔적을 따라가다 보니
  7. Host의 OOM Killer가 Docker Container내의 프로세스를 죽이는 또 하나의 원인이 될 수 있다는 점을 발견!
  8. 가급적 중요한 역할을 수행하는 컨테이너를 Host의 OOM Killer로 부터 보호할 수 있는 방법을 조사하게 되었고
  9. 이거 정리해 놓지않으면 무조건 까먹겠구나! 생각이 들어 블로그에 글을 남기게 되었다.

작년 말부터 Docker를 쓰기 시작해서 아직 엉금엉금 배우는 단계라, 다소 두서없는 글이 되겠지만, Docker와 OOM Killer에 대해서 조사했던 기록을 블로그에 정리해본다. 혹시 틀린 부분이 있다면, 주저없이 댓글을 달아주시길 바랍니다! (그러려고 쓰는 블로그가 되겠다.)

처음엔 Docker에 할당한 메모리의 문제인 줄 알았다.

OOM Error를 처음 만났을 때, Docker에 할당한 메모리를 초과할 경우 발생하는 문제점으로 추측하고, Docker의 memory관련 파라미터를 먼저 체크했다.

Docker Container를 run할때 사용하는 resource관련 옵션은 아래 URL에서 확인할 수 있다.
Docker run reference
Memory – Limit a container’s resources

간단히 살펴보면 다음과 같다.

옵션 설명
-m,--memory="" 사용 가능한 메모리 b,k,m,g 로 구분. 최소값은 4m
--memory-swap="" total memory limit (memory + swap) 예를 들어 --memory-swap이 600m 이고 --memory가 300m이면 container가 사용하는 swap은 300m이 된다. default는 -1(무제한) 이다.
--memory-reservation="" Memory soft limit --memory옵션보다 작은 값으로 설정되어야 하며, 메모리 경합이 있거나 호스트의 메모리가 부족하다고 판단될 때 최소 메모리로 우선적으로 할당하게 된다.
--kernel-memory="" Kernel memory limit. Kernel 메모리는 swap되지 않는 물리 메모리다. 이 값을 설정할 경우 호스트의 리소스를 block할 수 있고, 호스트 및 다른 컨테이너에 영향을 미칠 수 있다. 자세한 설명은 여기를 참고. 최소값은 4m
--memory-swappiness="" Tune container memory swappiness. default는 -1 (호스트의 vm.swappiness설정을 따름)이다. 0 부터 100까지 container내에서의 swappiness를 설정할 수 있다.
혹시 swappiness를 처음 만났다면
  • vm.swappiness = 0 : 스왑 사용안함 (실제로 swap이 disable되지는 않는다. 참고)
  • vm.swappiness = 1 : 스왑 사용 최적화
  • vm.swappiness = 60 : 기본값
  • vm.swappiness = 100 : 적극적으로 스왑 사용

규칙으로 설정되며, 현재 서버의 swappiness 값은 다음 명령으로 확인할 수 있다.

$ sudo sysctl -n vm.swappiness
60

그리고 운명적으로 다음 파라미터를 발견하게 된다.

옵션 설명
–oom-kill-disable default: false, Disable OOM Killer 참고
–oom-score-adj default: 0, Tune hosts’ OOM preferences (-1000 to 1000)
oom-kill-disable??? oom-score-adj??? OOM Killer????

여기서부터 나는 OOM Killer의 마성(이라 쓰고 삼천포라 읽는다) 에 빠지고 마는데…

여기서 숨을 크게 들이쉬고 시작해보자!

OOM Killer에 대해 알아보기 전에

먼저 Linux Kernel의 Memory Overcommit에 대한 이해가 필요하다.
malloc() 등의 시스템 콜을 통해서 Linux Kernel에 메모리 할당을 요청하게 되면, Kernel이 바로 물리 메모리 상의 실제영역을 할당(바인딩)하는것이 아니라, 요청한 메모리 영역의 주소값만 반환 하게 되는데 이를 memory commit이라고 한다. [중요] 아직 실제 물리 메모리에는 할당이 되지 않은 상태

나는 malloc을 배우고, 와! 개발자가 되어야겠다! 하고 결심을 했었더랬다.
dynamic memory allocation이라니 얼마나 멋진말인가!

이 memory commit이 이루어 질때, Linux 가상 메모리 시스템은 현재 사용가능한 메모리영역을 초과한 영역의 반환을 허락해주는데 이를 Memory Overcommit이라고 한다.

메모리를 요청만하고 실제로 사용하지 않을 수도 있고, 요청 후 바로 사용하지 않고 한참 있다가 사용할 수도 있고, 요청할 땐 부족했던 가용메모리가 메모리 반납을 통해서, 사용을 시작할 땐 충분히 확보될수도 있기 때문에 메모리 할당요청 시점과 실제사용 시점 사이의 비어있는 시간에 다른 프로세스가 이 메모리 영역을 사용할 수 있도록, 주소값만 리턴하는 방법으로 메모리 사용을 극대화하는 것이다. (숨이차다.)

좀 더 궁금한 분은 vm.overcommit에 대한 짧은 이야기 – 강진우님의 블로그를 읽어보길 바란다. 엄청 해매고 있던 내용이었는데 강진우님 블로그 글을 읽고 머리가 상쾌해지는 기분! 을 느낄 수 있었다.

OOM Killer(Out of Memory Killer)

이렇게 memory commit에서는 먼저 주소값만 리턴하고, 해당 주소값에 쓰기 요청이 들어왔을 때 비로서 해당 주소값을 실제 물리 메모리에 바인딩하게 되는데, 이 때 실제 메모리와 가상 메모리공간(Swap memory)을 전부 활용해도 더 이상 메모리 확보가 불가능한 경우, 쓸모없다고 판단되는 프로세스를 강제 종료시켜 여유 메모리를 확보하는 Linux Kernel의 메커니즘을 OOM(Out Of Memory) Killer라고 한다.

메모리를 최대한 넓게 쓰라고 아량을 배풀었던 over commit이 OOM Killer에 의해서, 되려 독이 되어 다가올 때가 있는 것이다.

OOM Killer가 프로세스를 죽이는 알고리즘이 Heuristic(체험적, 발견적)에 기반하기 때문에 때에따라 중요한 Server Process가 죽는 경우가 발생할 수 있다.
자료를 조사하는 과정에서는, mysqld가 OOM Killer에 의해 강제종료되서 당혹스러워 하는 사람들을 종종 만날 수 있었다.

물리 메모리가 주소값에 바인딩 되는 순간

여유 물리 메모리가 부족하고, 사용률이 낮은 물리 메모리를 swap할 디스크 공간도, shrink(수축)할 cache공간도 없다고 판단 했을 때 실행 중인 모든 task에 대해 badness()를 계산하고, 이 중 가장 나쁜(badness()가 가장큰) task를 kill하게 되는데, 이 가장 나쁜녀석으로 우리가 중요하게 생각하는 프로세스가 선정되기도 하는 것이다.

프로세스 마다 커널에 의해 -1000 에서 1000(구버전 커널에서는 -16 ~ 15)사이의 score가 매겨지게 되는데
Kernel이 판단하기에, -1000에 가까울수록 착한 프로세스로 1000에 가까울수록 나쁜 프로세스로 점수를 매기고, 이 score가 높은 프로세스부터 OOM Killer의 대상으로 선정된다.
(-1000은 OOM Killer에 의해 종료되지 않도록 함을 의미한다.)

score 값은 아래와 같이 /proc/{process_id}/oom_score로 확인할 수 있다.

$ cat /proc/29285/oom_score
0

다시 긴숨을 들이쉬기 전으로 돌아가서

Docker에서는 다음의 파라미터로 oom-killer에 의한 프로세스 종료를 예방할 수 있다.

옵션 설명
--oom-kill-disable default: false, Disable OOM Killer 참고
--oom-score-adj default: 0, Tune hosts’ OOM preferences (-1000 to 1000)

--oom-kill-disable의 값을 true로 하면, host의 OOM Killer를 disable할 수 있고
--oom-score-adj의 값을 설정하면 /proc/{process_id}/oom_score_adj에 값이 할당되어(default는 0) Kernel이 설정한 badness() score(oom_score)의 값보다, 사용자가 설정한 oom_score_adj값을 우선시 하도록 설정 가능해지는 것이다.

참고로 Container가 Host의 OOM Killer에 의해 중지된 경우 STATUS를 Exited (137) xxx ago와 같이 표시한다.

Error 137
from https://bobcares.com/blog/error-137-docker/

OOM Error가 발생했던 Container를 docker stats 명령으로 확인해보니 이 프로세스가 해당 서버내에서 가장 많은 메모리를 사용하고 있었고, Kernel은 이 프로세스를 쓸모없는 프로세스 로 판단했던 듯 하다.

이제 OOM Killer에 의한 Docker Container 내부 프로세스의 종료를 방지해보자.

--oom-kill-disable=true 파라미터는 왜 때문인지 운영하는 서버(Ubuntu 16.04기반)에서 정상적으로 동작하지 않았다.
--oom-score-adj 파라미터는 정상적으로 작동했다. --oom-score-adj 값을 -1000을 주었을 때 --oom-kill-disable=true와 같은 효과를 주기 때문에, 큰 문제는 아닌 것 같다.

문제가 됐던 Process를 Host의 Process id 기반으로 추적해보니 /proc/{process_id}/oom_score 값이 600 ~ 760 사이를 왔다갔다 했다.

$ cat /proc/29285/oom_score
766
$ cat /proc/29285/oom_score_adj
0

$ echo -500 > /proc/29285/oom_score_adj 명령 등으로 소중한 프로세스의 oom_score_adj 값을 조정해주면, OOM Killer에 의해서 이 프로세스가 가장 나쁜 프로세스로 선정되는 것은 방지할 수 있을 것 으로 생각된다.

혹은 docker run 할 때 --oom_score_adj=-500 과 같이 파라미터로 전달할 수도 있다.

이 프로세스 말고 시스템의 모든 프로세스의 oom_score 값을 조사해 보니, 대부분의 프로세스는 oom_score를 0으로 가지고 있었고 음수 값으로 설정된 프로세스도 다수 조회됐다. 아마도 시스템에서 중요한 역할을 하는 프로세스에는 음수값으로 자동 세팅이 되는 듯하다.

Docker 컨테이너 자체의 프로세스는 서버 마다 다른 oom_score로 측정되었는데 0으로 되어있는 서버도 있고 -500으로 되어 있는 서버도 있었다.

docker-compose.yml 파일에 oom-score-adj 파라미터를 적용하기 위해서는

다음과 같이 version: ‘2’ 를 명시 해줘야만 사용이 가능했다.
version: '2'가 아닌 경우 oom_score_adj는 지원하지 않는다는 에러가 발생한다.

version: '2'
services:
   db:
     image: mysql:5.7
     restart: always
     ...
     oom_score_adj: -1000

vm.overcommit_memory

앞서, malloc() 에서 가용영역을 넘어선 메모리영역을 리턴하는 것이 overcommit이라 설명했는데,
vm.overcommit_memory Kernel Parameter를 통해서 다음과 같이 overcommit rule을 설정할 수 있다.

vm.overcommit 설명
0 (디폴트) heuristic에 따라 overcommit여부를 판단. 메모리 확보가 불가능한 경우, 실행중인 프로세스를 강제 종료해서 메모리를 확보
1 무조건 overcommit 허용. 메모리 확보가 불가능한 경우 OOM Killer 동작(0과 같다)
2 overcommit 불가. 메모리가 부족할 경우 에러 발생 [Swap size] + ([RAM size] * vm.overcommit_ratio/100)

대부분의 서버는 다음과 같이 기본 값으로 설정되어 있다.

$ sudo sysctl -n vm.overcommit_memory
0
$ sudo sysctl -n vm.overcommit_ratio
50 

의도하지 않게 프로세스가 OOM Killer에 의해 지속적으로 종료된다면, 메모리 관리를 Kernel heuristic에 맡기기보다 vm.overcommit을 2로 설정하고, overcommit_ratio를 적당한 수치로 조정해볼 필요가 있다.

앞으로 여러 번의 시도와 경험축적이 필요하겠지만

docker run의 oom-score-adj optionvm.overcommit Kernel 파라미터의 적절한 조합으로 조금 더 안전하게 프로세스를 유지할 수 있지 않을까? 하는 생각을 한다.

oom-xxx 의 옵션외에도

Save Yourself From The OOM Killer 글에서는 vm.min_free_kbytes의 값을 설정함으로서, 시스템이 지속적으로 유지할 수 있는 최소 free memory를 물리 메모리의 5~10% 값으로 상향조정하는 방법을 권유하고 있다.

$ sysctl -n vm.min_free_kbytes
10978

# 영구적으로 이 값을 수정하려면 /etc/sysctl.conf 파일을 수정해야함
$ sysctl -w vm.min_free_kbytes=20978

vm.min_free_kbytes와 Linux Kernel의 메모리 할당에 대해서 강진우님의 블로그 메모리 재할당과 커널 파라미터를 읽어보면 큰 도움이 된다. 감사합니다. 강진우님!

덧붙여 Docker내에서 Java프로세스를 실행한다면,

Analyzing java memory usage in a Docker container 이 블로그를 참고하면 Docker내에서의 Java 프로세스 메모리 사용을 좀 더 정확하게 추적할 수 있을 것 같다.
Xmx로 설정한 memory limit외에도 시스템이 사용하는 메모리 RSS(Heap size + MetaSpace + OffHeap size) 등에 대해서 사용량을 예측, 메모리를 할당, 관리할 필요가 있다는 것을 알게 되었다.

이렇게 Docker와 OOM-Killer에 대해 조사한 내용을 정리해보았다.

처음에 글을 쓸때는, 사내위키에 정리했던 글이라 블로그에 옮기기 무척 수월할 것으로 생각했는데, 막상 글을 적기 시작하니, 마무리를 지을때까지 짧지 않은 시간이 소요됐다. 부디 이 글이 다 까먹을 미래의 나를 비롯해서 Docker를 공부하는 누군가에게 도움이 되었으면 좋겠다.

아직 블로그 두번째 글이라 훈련하는 것이라 생각하고 더 많이 열심히 글을 써나갈 수 있으면 좋겠다. 이제 막 도커 컨테이너를 배워가는 입장에서 쓰는 글이라, 틀린 내용이 수두룩할것 같은데, 편하게 댓글로 의견을 적어주시면 공부하는데 더더 도움이 되고 다음 글을 쓰는데 응원도 될 것 같다.

참고자료 (감사합니다!)

vm.overcommit에 대한 짧은 이야기
메모리 재할당과 커널 파라미터
OOM Killer & Overcommit
Overcommit and OOM, The Linux Kernel
메모리 overcommit
How to Prevent Docker containers from crashing with error 137
Limit a container’s resources
OOM Killer
Save Yourself From the OOM Killer
Analyzing java memory usage in a Docker container