Docker는 컨테이너의 일종으로써, 리눅스, MacOS, 윈도우즈와 같은 많은 OS에서 동작한다.
본 포스트는 리눅스 기반의 docker를 기준으로 작성된 포스트이므로, 다른 OS에는 해당사항이 있을 수도 있고 없을 수도 있다.
Docker도 결국 주어진 머신을 최대한 잘 활용하기 위해 사용되는 것이다.
최근 각광받는 기술이긴 하지만 Docker와 같은 컨테이너들은 역사 자체는 오래되었다.
가상화 얘기가 한창 나오던 십수년전부터 OS 수준 가상화(OS-level virtualization)라는 이름으로 연구되었던 영역이다.
이러한 카테고리에 속하는 가상화 기술의 주 목적은 다음과 같다.
- OS 및 런타임(JAVA, Python 등)과 같은 코드 기반들의 공유.
- CPU, 메모리와 같은 물리적 자원 및 네트워크 연결, 파일시스템 등의 논리적 자원의 분배 및 고립(isolation).
OS 및 런타임과 같은 요소들까지 서로 고립시키는 전가상화(full-virtualization) 내지 반가상화(para-virtualization) 기술에 비해 오버헤드가 상대적으로 덜한 기술이라 할 수 있다.
따라서, 유사한 시스템 및 런타임 기반을 가진 애플리케이션을 구동시킬 경우, Docker와 같은 컨테이너 서비스는 아주 좋은 선택이라 할 수 있다.
리눅스 환경에서 동작하는 Docker의 경우 메모리와 같은 자원을 분배하기 위해 cgroup이라는 매커니즘을 활용한다.
cgroup은 control group의 약자로써, 특정 프로세스 혹은 프로세스 그룹이 사용할 수 있는 자원의 상한을 정해두고, 그 이상으로 사용하지 못하도록 커널 수준에서 제어하는 매커니즘의 명칭이다.
cgroup은 여러 자원들에 대한 컨트롤러를 제공하는데, Linux 4.5 버전이 되며 덩달아 버전 업 된 cgroup v2에서는 CPU, Memory, I/O의 세 가지 컨트롤러를 지원한다.
상대적으로 역사가 오래된 cgroup v1은 이보다 많은 컨트롤러를 지원하는데, 이로 인해 아직까지 docker에서는 cgroup v1을 활용한다고 한다. (명시적으로 표기된 공식 문서는 발견하지 못했지만, cgroup v2로 커널을 설정할 경우 Docker가 cgroup을 인식하지 못한다.)
자 그럼 왜 이번 포스팅의 주제인 MongoDB와 Docker 얘기를 꺼내기전에 왜 이런 장황한 이야기를 늘어놨는지 밝히도록 하겠다.
cgroup은 결국 논리적으로 자원을 분배한다. 그렇기 때문에 docker 컨테이너를 생성한후, 컨테이너에 접속하여 cat /proc/meminfo와 같은 명령을 입력하게 되면 호스트 머신이 가진 자원이 그대로 보이는 것을 확인할 수 있을 것이다.
예를 들자면 이런 것이다.
16G의 메모리를 탑재한 머신에 네 개의 컨테이너를 생성한다고 가정하자.
메모리를 최대한으로 허용한다면, 각각의 컨테이너에게 4G씩의 메모리를 할당할 수 있을 것이다. (물론 호스트의 OS 및 서비스를 위해 가용 메모리를 남겨둬야 한다. 이것은 단순히 예시일 뿐이다.)
즉, 우리가 기대하는 것은 각 컨테이너가 최대 4G의 메모리를 사용하는 것이다.
그리고 그 컨테이너에서 MongoDB의 인스턴스를 구동시켜보자.
MongoDB의 WiredTiger 스토리지 엔진은 자체적으로 데이터베이스의 레코드들을 캐시해두기 위한 in-memory cache를 생성하게 된다.
이때 in-memory cache의 크기는 1GB 혹은 (memory size / 2) - 1G 중 큰 쪽으로 선택된다.
여기서 저 memory size가 각 컨테이너에게 할당된 메모리 크기가 된다면, (4G / 2) - 1G 이므로 1G 크기의 in-memory cache를 가질 것이라 예측할 수 있다.
그리하여 MongoDB를 구동시키고 시간이 지난다면... 짜잔! 무언가 잘못되었다는 것을 느낄 수 있을 것이다. (물론 운이 좋으면 혹은 나쁘다면 잘못되지 않고 계속 실행된다.)
그 이유는 단순한데, 컨테이너에서 구동되고 있는 MongoDB가 컨테이너에게 할당된 메모리가 아닌 호스트 머신이 가지고 있는 메모리를 인식하여 in-memory cache를 생성하기 때문이다.
MongoDB에 리퀘스트가 도달할 때마다 MongoDB는 I/O를 줄이기 위해 레코드들을 캐싱해둘 것이고, 캐싱된 데이터의 크기가 현재 할당된 in-memory cache 사이즈를 넘을 경우 MongoDB는 메모리를 추가로 요청하게 된다.
만약, 미리 설정된 메모리 크기를 넘어설 경우 (예를 들어, (memory size / 2) -1G의 크기를 넘어설 경우), MongoDB는 캐시에서 오래된 레코드들을 찾아 디스크에 저장하고 메모리 공간을 회수한 뒤 회수된 메모리 공간에 새로운 레코드를 캐싱하게 된다.
안넘어섰을 경우에는 미리 설정된 사이즈까지 메모리를 추가로 요청하게 된다.
그리고 바로 이 시점이 오류가 발생하는 시점이다.
컨테이너에 할당된 메모리 사이즈와 애플리케이션이 인식한 메모리 사이즈 사이의 정보 격차로 인해, 컨테이너에 할당된 메모리 사이즈 이상의 메모리 요청이 발생할 경우 해당 요청은 거부되고 애플리케이션이 오동작을 일으키는 것이다.
경우에 따라서 OOM killer가 발생할 수도 있고, 이것을 옵션으로 막아두었다면 컨테이너가 deadlock 상태에 빠지거나 그냥 꺼질 수 있다 (여기까지는 직접 발견한 증상이다).
이를 방지하기 위해서는 wiredtiger의 캐시 사이즈를 제한할 필요가 있다.
이는 --wiredTigerCacheSizeGB
라는 옵션을 통해 설정할 수 있다.
MongoD 인스턴스를 실행시킬 때, 다음과 같은 옵션을 덧붙이면 된다.
$mongod --wiredTigerCacheSizeGB
2G --config <config path>
그럼 in-memory cache의 크기는 2G로 제한이 되고, 위에서 발생가능한 오류들을 사전에 방지할 수 있다.
사실 이러한 semantic gap으로 인한 버그들은 굉장히 많을 것이다.
이때문에 verification이라던지, gap을 없애려는 노력들을 하는 것일 거고...
앞으로 이와 관련된 연구를 좀 해볼 수 있을려나...?