컨테이너 환경에서 Node.js 메모리 관리

June·2024년 1월 18일
0
post-thumbnail

이 포스트는 Node.js memory management in container environments 를 번역 및 일부 설명을 추가한 글입니다.
예제 코드는 이 곳 에서 확인하실 수 있습니다.

권장사항 요약

메모리 제한이 설정된 컨테이너 내에서 Node.js 애플리케이션이 실행될 때 (Docker의 --memory 또는 다른 오케스트레이션 시스템의 다른 플래그 옵션 사용 시), Node가 컨테이너의 메모리 설정 제한 값 보다 작은 값을 사용하도록 하기 위해 --max-old-space-size 옵션을 사용합니다.

컨테이너 안에서 Node.js 애플리케이션이 실행될 때, 애플리케이션의 최대 활성 메모리 사용량에 따라 메모리 용량을 설정합니다 (컨테이너 메모리를 조정할 수 있는 경우)

Docker 메모리 제한

기본적으로 컨테이너는 리소스 제약이 없으며 Host OS가 허용하는 만큼 사용 가능한 메모리 리소스를 사용할 수 있습니다. docker run 명령어는 컨테이너가 사용할 수 있는 메모리나 CPU의 제한을 설정하는 명령줄 옵션이 있습니다.

docker run 
  --memory <x><y> # x: 메모리 크기 y: 단위
  --interactive 
  --tty <imagename>

Example

  • 컨테이너 실행
docker run --memory 100M --interactive --tty node:20 bash
  • 메모리 제한 확인
cat /sys/fs/cgroup/memory/memory.limit_in_bytes #104857600 (100Mb)

이제 --max_old_space_size 옵션을 사용하여 컨테이너의 동작을 살펴보겠습니다.

Old spaceV8 의 managed heap (i.e. javascript object 가 존재하는 곳) 의 일반 heap 섹션을 의미하며 --max-old-space-size 는 그 최대 크기를 제어합니다.

메모리 사용량이 제한에 가까워지면, V8은 사용하지 않는 메모리를 해제하기 위해 가비지 컬렉션에 더 많은 시간을 소비합니다.

일반적으로 애플리케이션이 컨테이너의 메모리보다 더 많은 메모리를 사용하면, 애플리케이션이 종료됩니다.

다음 샘플 애플리케이션은 10 millseconds 간격으로 레코드를 추가합니다. 이 빠른 간격은 heap을 무한정 증가시켜 메모리 누수를 시뮬레이션합니다.

  • test-fatal-error.js
"use strict";
const list = [];

class MyRecord {
  constructor() {
    const x = "hi there?";
    this.name = x.repeat(10000000);
    this.id = x.repeat(10000000);
    this.account = x.repeat(10000000);
  }
}

setInterval(() => {
  const record = new MyRecord();
  list.push(record);
}, 10);

setInterval(() => {
  console.log(process.memoryUsage());
}, 100);

위 애플리케이션을 dockerize하고 메모리 제한과 함께 실행할 수 있습니다.

이 때, node의 --max_old_space_size 옵션을 docker의 --memory 옵션 보다 큰 메모리를 설정하여 실행해봅시다.

  • Dockerfile
FROM node:20

COPY test-fatal-error.js index.js

# 컨테이너 제한보다 더 큰 메모리로 애플리케이션 실행
CMD ["node", "--max_old_space_size=1024", "index.js"]
  • run.sh
docker build -t test-fatal-error .

docker run --memory 512m --interactive --tty --rm test-fatal-error

실행결과

{ 
  rss: 550498304,
  heapTotal: 1090719744,
  heapUsed: 1030627104,
  external: 1427035,
  arrayBuffers: 10467
}

Killed

애플리케이션은 메모리 사용량이 어떤 임계값을 초과하면 종료됩니다 그런데 애플리케이션의 메모리 사용량이 docker의 --memory 설정 값을 초과해도 종료되지 않았습니다.

컨테이너 제약 조건 내에서 --max-old-space-size 를 사용한 예상 동작

기본적으로, Node.js(v20), 64bit 플랫폼에서 최대 힙 사이즈는 약 4GB로 설정되어 있습니다

const v8 = require("v8");
console.log(v8.getHeapStatistics());

// log
// {
//   total_heap_size: 4079616,
//   total_heap_size_executable: 262144,
//   total_physical_size: 3674112,
//   total_available_size: 4342934864,
//   used_heap_size: 3307304,
//   heap_size_limit: 4345298944, // 약 4GB
//   malloced_memory: 262312,
//   peak_malloced_memory: 107808,
//   does_zap_garbage: 0,
//   number_of_native_contexts: 1,
//   number_of_detached_contexts: 0,
//   total_global_handles_size: 8192,
//   used_global_handles_size: 2208,
//   external_memory: 1250668
// }

이론적으로, 컨테이너 메모리보다 큰 메모리 제한을 가진 --max-old-space-size 를 설정하면, 애플리케이션은 OOM killer 에 의해 종료될 것으로 예상합니다.

그러나 실제로는 종료되지 않을 수도 있습니다.

컨테이너 제약 조건 내에서 --max-old-space-size 를 사용한 실제 동작

--max-old-space-size에 지정된 모든 메모리가 프로그램에 미리 할당되는 것은 아닙니다.

대신 Javascript heap 은 점차적으로 증가하는 요구에 대응하여 증가합니다.

애플리케이션에 의해 소비되는 실제 메모리(Javascript heap 내의 객체 구조로)는 process.memoryUsage() API의 heapUsed 필드에 의해 표현됩니다.

그러므로 수정된 예상은 실제 힙 크기 (상주중인 객체 크기) 가 OOM Killer 의 임계값 (--memory 플래그가 있는 컨테이너 내부)을 넘어서면, 컨테이너가 애플리케이션을 종료합니다.

그러나 이것도 실제로 발생하지 않을 수 있습니다.

컨테이너 제약이 있는 환경에서, 메모리 집약적인 Node.js 애플리케이션을 프로파일링 할 때, 두 가지 패턴을 보게 됩니다.

  1. OOM Killer는 힙의 총 크기와 사용된 힙 크기가 컨테이너 제약 조건을 초과했을 때 훨씬 늦게 작동합니다.
  2. OOM Killer가 전혀 작동하지 않습니다.

컨테이너 환경에서 Node.js 동작

컨테이너가 호스팅된 애플리케이선에서 추적하는 중요 지표는 resident set size(RSS) 입니다.

RSS는 애플리케이션의 가상 메모리 일부를 나타냅니다. 또한 현재 활성 상태인 애플리케이션에 할당된 메모리의 일부를 나타냅니다.

애플리케이션에 할당된 모든 메모리가 활성화되지는 않을 수 있습니다. 할당된 메모리 가 프로세스가 실제로 사용 시작 전까지 필수적으로 할당되지는 않기 때문입니다. 또한 다른 프로세스로부터 메모리 요구가 있으면, OS는 비활성 상태이거나 휴면 상태인 애플리케이션의 메모리의 일부를, 메모리가 필요한 다른 프로세스가 사용하도록 swap out 하고, 현재 애플리케이션이 다시 메모리를 필요로 할때 다시 swaped in 할 수 있습니다.

RSS 메모리는 애플리케이션의 주소 공간에서 현재 사용가능하고 활성화 된 메모리의 양을 반영합니다.

Example 1. 메모리에 버퍼를 할당하는 애플리케이션

  • buffer_example.js
const buf = Buffer.alloc(2000 * 1024 * 1024);
console.log(Math.round(buf.length / (1024 * 1024))); 
console.log(Math.round(process.memoryUsage().rss / (1024 * 1024))); 
  • Dockerfile
FROM node:20

COPY buffer-example.js index.js

CMD ["node", "--max_old_space_size=1024", "index.js"]
  • run.sh
#!/bin/bash

docker build -t buffer-example .

docker run --memory 1024m --interactive --tty --rm buffer-example

실행결과

2000
39

메모리가 컨테이너 한도보다 커도 애플리케이션이 종료되지 않습니다. 할당된 메모리가 완전히 액세스되지 않았기 때문입니다. RSS 값이 매우 낮고 컨테이너 메모리 제한을 초과하지 않았습니다.

Example 2. 버퍼가 데이터로 채워지는 애플리케이션

  • buffer-example-fill.js
const buf = Buffer.alloc(2000 * 1024 * 1024, "x");
console.log(Math.round(buf.length / (1024 * 1024)));
console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)));
  • Dockerfile
FROM node:20

COPY buffer-example-fill.js index.js

CMD ["node", "--max_old_space_size=1024", "index.js"]
  • run.sh
#!/bin/bash

docker build -t buffer-example-fill .

docker run --memory 1024m --interactive --tty --rm buffer-example-fill

실행결과

2000
1019

여기서도 애플리케이션은 종료되지 않습니다! 활성 메모리가 컨테이너의 제한에 도달하고, swap 공간에 있을 때, 일부 오래된 메모리 페이지는 swap 공간으로 푸시되어 동일한 프로세스에서 사용 가능하게 됩니다. 기본적으로 docker는 --memory 플래그를 통해 설정된 메모리 제한과 동일한 크기의 swap 공간을 할당합니다. 이 배치를 통해 프로세스는 이제 실질적으로 활성 메모리에 1GB, swap 공간에 1GB의 총 2GB를 사용할 수 있습니다. 즉 자체 메모리의 swap 으로 인해, 전체 RSS 는 여전히 컨테이너의 제한 내에 있고, 애플리케이션이 살아있을 수 있습니다.

Example 3. 버퍼가 데이터로 채워지고, 컨테이너가 swap을 사용할 수 없는 애플리케이션

  • buffer-example-fill.js
const buf = Buffer.alloc(2000 * 1024 * 1024, "x");
console.log(Math.round(buf.length / (1024 * 1024)));
console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)));
  • Dockerfile
FROM node:20

COPY buffer-example-fill-killed.js index.js

CMD ["node", "--max_old_space_size=1024", "index.js"]
  • run.sh
#!/bin/bash

docker build -t buffer-example-fill-killed .

docker run --memory 1024m \
           --memory-swap=1024m \
           --memory-swappiness=0 \
           --interactive \
           --tty \
           --rm buffer-example-fill-killed

종료되지 않나요? --memory-swap 값이 --memory 의 값과 같으면 컨테이너에 여분의 swap 공간을 사용하지 말라는 의미입니다. 또한 기본적으로 호스트 커널은 컨테이너가 사용하는 익명 페이지(anonymous page)의 일정 비율을 swap 할 수 있으므로 --memory-swappiness 플래그에 값 0 을 전달하여 이를 비활성화 합니다. 따라서 컨테이너 내부에 swap이 발생하지 않으므로 RSS가 컨테이너 제한을 초과하여 프로세스가 바로 종료됩니다.

요약 및 권장사항

--max-old-space-size 가 컨테이너 제한보다 크게 설정된 상태에서 Node.js 애플리케이션을 실행할 때, Node.js가 컨테이너의 강제 제한을 존중(respecting) 하지 않는 것처럼 보일 수 있습니다. 그러나 위의 예제에서 확인할 수 있는 것처럼, 진짜 이유는 애플리케이션이 해당 플래그를 사용하는 javascript heap 세트의 전체 크기에 도달하지 못하고 있을 수 있기 때문입니다.

컨테이너에서 사용할 수 있는 메모리보다 더 많은 메모리르 사용할 때 애플리케이션이 항상 동일하게 동작하기를 기대할 수는 없다는 점을 염두에 두세요. 프로세스의 활성 메모리(RSS)는 애플리케이션의 제어 범위 밖에 있는 여러 요인의 영향을 받고, 워크로드 자체, 시스템의 동시성 수준, OS의 스케줄러, GC 속도 등과 같이 부하 및 환경에 크게 의존할 수 있기 때문입니다. 또한 이러한 요인들은 실행할 때 마다 변경될 수 있습니다.

Node.js heap 크기 권장 사항 (컨테이너 크기를 제어할 수 없는 경우)

  • 컨테이너 내부에 빈 Node.js 애플리케이션을 실행하고 정적 RSS 사용량을 측정합니다.
  • Node.js는 heap의 다른 메모리 영역 (new_space, code_space등)이 있으므로, 기본 구성을 가정할 때 추가로 메모리를 차지합니다. 기본값을 변경할 경우 이 값을 그에 맞게 조정하세요.
  • 컨테이너에서 사용가능한 메모리에서 위에서 계산한 값을 뺍니다. 남은 크기는 javascript의 heap의 old space size에 대해 상당히 안전한 값이어야 합니다.

Container 크기 권장 사항 (Node.js memory 사용량을 제어할 수 없는 경우)

  • 최대 워크로드 시나리오를 다루는 애플리케이션을 실행합니다.
  • RSS 필드의 증가량을 측정합니다. top 명령어와 process.memoryUsage() api를 사용하면 됩니다.
  • 컨테이너에 다른 활성 프로세스가 없다면, 이 값을 container의 제한으로 사용하면 됩니다. 그 수에 10% 이상을 더해야 더 안전할 수 있습니다.

참고

0개의 댓글

관련 채용 정보