컨테이너 환경에서의 NodeJS 메모리 관리

00_8_3·2022년 3월 27일
0

간단 Node

목록 보기
25/27

시작

로컬환경에서 개발을 할 경우 별 생각없이 도커 환경에서 작업을 많이들 합니다.
도커와 노드를 더 이해하기 위해 이 글을 작성합니다.

도커 컨테이너 내에 노드를 실행하는 경우 전형적인 메모리 튜닝은 기대처럼 항상 동작하지는 않을 것 입니다.
그 이유를 알아보고 컨테이너 환경에서의 노드실행의 best practices와 권고사항을 알아 보겠습니다.

권고사항 요약

노드를 컨테이너에서 실행 할 때
--max-old-space-size를 사용 하는 경우 컨테이너의 limit 보다 작게 설정해야 합니다.

도커 메모리 limit

기본적으로 컨테이너는 자원에 제한이 없고 호스트 OS가 허락하는 한도 내의 메모리 자원을 사용 할 수 있습니다.
다음 명령어로 도커는 메모리 또는 cpu 한도를 설정 할 수 있습니다.

docker run --memory <x><y> --interactive --tty <imagename> bash

docker run --memory 1000000b --interactive --tty <imagename> bash

컨테이너 내에서 다음 명령어로 메모리 한도를 확인 할 수 있습니다.

cat /sys/fs/cgroup/memory/memory.limit_in_bytes

node의 --max_old_space_size 플래그를 사용하여 컨테이너를 살펴볼 수 있습니다.

Old space란 Node의 v8엔진에 의해 관리되는 heap으로 --max_old_space_size를 사용하여 최대 heap 사이즈를 제어 할 수 있습니다.

보통 컨테이너 보다 더 큰 메모리를 Node가 사용하게 된다면 Node는 강제로 종료가 됩니다.

다음 코드는 0.1초 마다 record를 push 하여 heap을 성장 시키게 합니다.

'use strict';
const list = [];
setInterval(()=> {
        const record = new MyRecord();
        list.push(record);
},10);
function MyRecord() {
        var x='hii';
        this.name = x.repeat(10000000);
        this.id = x.repeat(10000000);
        this.account = x.repeat(10000000);
}
setInterval(()=> {
        console.log(process.memoryUsage())
},100);
docker run --memory 512m --interactive --tty node-image bash

node --max_old_space_size=1024 test-fatal-error.js
//위 명령어는 node-image에 명시해야 합니다.

예상되는 결과

기본으로는 Node v11의 경우 32bit에서는 700mb 64bit에서는 1400mb의 최대 heap 사이즈를 갖습니다.

최신 버전에서는 1.7gb를 갖는걸로 알고있습니다.

노드가 컨테이너 메모리 limit보다 2배를 설정 했기 때문에 OOM에 의해 예상한 대로라면 종료가 되어야 합니다.

실제 결과

--max-old-space-size로 최대 heap을 설정 하였다 하더라도 처음부터 최대로 설정은 되지 않습니다.
대신 늘어나는 수요에 맞춰 점차적으로 heap을 키워나갑니다.

최대 heap에 도달하더라도 Node가 종료가 되지 않았습니다.

왜?

컨테이너가 호스트된 애플리케이션에서 추적하는 중요한 것은 RSS입니다.

RSS란 애플리케이션의 가상 메모리의 일부를 나타냅니다.
게다가 애플리케이션에 할당된 메모리의 일부도 나타내고 현재 활성중인 애플리케이션의 할당된 메모리의 일부로 표현됩니다.

애플리케이션 내에서 할당된 모든 메모리가 활성 상태인 것은 아닙니다. 할당된 메모리는 프로세스가 실제로 그것을 사용하기 시작할 때까지 할당되지 않기 때문입니다.

메모리로 할당된 버퍼 - 예1

다음 코드는 버퍼를 메모리로 할당한 것 처럼 보입니다.

const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024)
console.log(Math.round(buf.length / (1024 * 1024)))
console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)))

하지만 결과는 종료되지 않습니다.

$ node buffer_example 2000
2000
16

그 이유는 메모리가 끝까지 차지 않았기 때문입니다.
rss 값이 매우 낮아 컨테이너 메모리 limit을 넘지 못했기 때문입니다.

data로 버퍼가 채워졌을 경우 - 예2

// 'x'가 데이터
const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024, 'x')
console.log(Math.round(buf.length / (1024 * 1024)))
console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)))
$ node buffer_example_fill.js 2000
2000
984

하지만 이래도 종료가 되지 않네요 왜 그럴가요?
도커는 기본적으로 --momory플래그로 설정된 메모리 만큼의 스왑 메모리를 갖기 때문입니다.
즉, 위의 1gb의 메모리 설정으로 인해 1gb의 스왑 메모리가 추가로 생겨, 총 2gb의 메모리를 갖게 되기 때문입니다.

스왑 사용을 제한한 컨테이너 - 예3

예제2의 js 코드를 그대로 사용하여 다음 도커 명령어를 사용합니다.

docker run --memory 1024m --memory-swap=1024m --memory-swappiness=0 --interactive --tty ravali1906/dockermemory bash
$ node buffer_example_fill.js 2000
Killed

결과는 원하는 대로 Node가 종료가 되었습니다.
--memory-swap의 값이 --memory 값과 같으면 추가적인 스왑공간을 컨테이너가 사용하지 못하게 됩니다.

또한 기본적으로 호스트 커널은 컨테이너에서 사용하는 익명 페이지의 일정 비율을 교체할 수 있으므로 값 0을 --memory-swappiness 플래그에 전달하여 비활성화합니다.

따라서 컨테이너 내부에서 스와핑이 발생하지 않기 때문에 rss가 컨테이너 제한을 초과하여 프로세스가 종료됩니다.

요약

Node의 --max-old-space-size가 컨테이너 limit보다 높아도 종료가 되지않는 것을 보면 컨테이너를 무시하는게 아닌 JS의 heap이 최대에 도달하지 않았기 때문인 것을 알 수 있었습니다.

Node는 제어가능하지만 컨테이너는 불가능한 경우

  • 컨테이너에 비어있는 Node를 실행 후 rss 사용량을 측정합니다.(예를 들어 20mb)
  • 노드의 heap에는 다른 메모리 영역도 있으므로 추가적인 메모리도 추정해줍니다. (추가로 20mb)
  • 컨테이너에서 사용가능한 메모리에서 40mb를 빼줍니다. 빼고 남은 값은 JS의 Heap old space size에 대해 안전한 값이어야 합니다.

출처

https://developer.ibm.com/articles/nodejs-memory-management-in-container-environments/

0개의 댓글