Docker에서 Node.js가 메모리 문제로 종료됐을 때 Exit Code만 보지 않게 된 이유

Docker 환경에서 Node.js 프로젝트를 빌드하다가 프로세스가 갑자기 종료된 적이 있었다.

당시에는 Nest 기반 프로젝트를 빌드하는 과정에서 문제가 나타났고, 로그를 따라가다 보면 결국 눈에 들어오는 문구는 메모리와 관련된 것이었다.

(아래와 같은 오류.. 수도 없이 봤을 것이다…)

JavaScript heap out of memory

그리고 프로세스는 정상적으로 끝나지 않았고 Exit Code 134가 남았다.

처음에는 이 상황을 아주 단순하게 받아들였다.

Docker 안에서 실행 중이던 Node.js가 메모리 문제로 죽었으니 컨테이너에 메모리가 부족했던 것이라고 생각하면 되지 않을까 싶었다.

당시 서버에는 다른 컨테이너도 실행되고 있었고, Docker라는 환경 자체가 프로세스의 자원을 제한할 수 있다는 것도 알고 있었기 때문에 더 그렇게 생각했다.

그래서 처음 머릿속에 떠오른 해결 방법도 자연스러웠다.

Docker에 메모리를 더 주거나 서버 메모리를 확보하면 해결되지 않을까?

그런데 로그를 다시 보면서 조금 이상한 점이 생겼다.

에러 메시지가 단순히 운영체제에서 프로세스를 강제로 죽였다는 내용이 아니라 JavaScript heap 자체를 명확하게 이야기하고 있었기 때문이다.

Node.js 공식 문서에도 V8이 heap 한계에 접근하면 garbage collection에 더 많은 시간을 사용하게 되고, 끝내 메모리를 확보하지 못하면 JavaScript heap out of memory 형태의 치명적인 오류로 프로세스가 종료될 수 있는 사례가 나온다. 또한 --max-old-space-size는 V8 old memory 영역의 최대 크기를 조절하는 옵션으로 제공된다.

이 내용을 보고 나니 내가 처음부터 서로 다른 두 종류의 메모리 문제를 하나로 보고 있었던 것은 아닐까라는 생각이 들었다.


처음에는 “메모리 부족”이면 전부 같은 문제라고 생각했다

애플리케이션을 운영하면서 메모리가 부족하다는 표현을 자주 사용한다.

그래서 다음과 같은 상황도 모두 비슷하게 느껴진다.

메모리가 부족하다
→ 프로세스가 죽는다

하지만 Node.js를 Docker에서 실행하고 있다면 실제로는 적어도 두 개의 서로 다른 한계를 생각해야 했다.

하나는 Node.js/V8이 JavaScript 객체를 관리하는 heap의 한계이고, 다른 하나는 컨테이너 또는 호스트 운영체제가 사용할 수 있도록 허용한 실제 메모리의 한계다.

둘 다 최종적으로는 “프로세스가 죽었다”라는 결과를 만들 수 있기 때문에 로그를 자세히 보지 않으면 같은 문제처럼 느껴진다.

그런데 원인이 다르면 해결 방법도 달라진다.

Node.js의 JavaScript heap 한계에 도달한 것이라면 V8의 heap 설정이나 애플리케이션의 메모리 사용 패턴을 확인해야 한다.

반대로 컨테이너가 허용된 실제 메모리 한계를 넘어서 운영체제의 OOM 처리 대상이 된 것이라면 Docker의 memory limit과 컨테이너의 실제 사용량을 확인해야 한다.

Docker 공식 문서에서도 컨테이너가 사용 가능한 메모리보다 많이 사용해 OOM 상황이 발생하면 기본적으로 커널이 컨테이너 내부 프로세스를 종료할 수 있다고 설명한다. Docker는 컨테이너에 --memory를 이용한 hard limit도 설정할 수 있다.

이 차이를 알고 나니 “메모리 때문에 죽었다”라는 설명만으로는 원인을 거의 설명하지 못한다는 생각이 들었다.

정말 알아야 하는 것은 누가 어떤 한계를 넘겼다고 판단해서 프로세스를 종료했는가였다.

그리고 Docker 컨테이너 일부가 좀비로 남는 경우도.. 간혹 있었다.


내가 본 JavaScript heap out of memory는 무엇을 말하고 있었을까

Node.js는 JavaScript 객체를 저장하고 관리하기 위해 V8 heap을 사용한다.

애플리케이션이 객체를 계속 만들면 heap 사용량이 증가하고, V8은 더 이상 사용하지 않는 객체를 정리하기 위해 garbage collection을 수행한다.

여기까지는 일반적인 동작이다.

문제가 되는 것은 garbage collection을 반복해도 필요한 공간을 확보하지 못하고 heap이 허용된 한계에 가까워질 때다.

Node.js 공식 진단 문서에서도 메모리 부족 상황에서는 memory usage가 증가하다 프로세스가 crash하고, garbage collection 활동이 증가하면서 CPU 사용량과 응답시간에도 영향을 줄 수 있다고 설명한다.

Node 공식 예제를 보면 heap limit에 도달했을 때 다음과 유사한 로그가 나타난다.

FATAL ERROR:
Reached heap limit
Allocation failed
JavaScript heap out of memory

그리고 그 전에 여러 번의 GC 기록이 남을 수도 있다.

이 부분을 보고 나서 내가 당시 봤던 오류를 조금 다르게 생각할 수 있었다.

Docker가 먼저:

너는 컨테이너 메모리를 너무 많이 썼으니 강제로 종료한다.

라고 판단한 상황과,

Node.js/V8이:

JavaScript heap에서 더 이상 필요한 메모리를 확보하지 못한다.

고 판단하면서 자체적으로 치명적인 오류를 발생시키는 상황은 관찰되는 원인이 다를 수 있다.

그래서 JavaScript heap out of memory가 로그에 명확하게 보인다면 적어도 V8 heap 상태를 확인할 이유가 생긴다.

Docker 자체만 바라보고 끝내면 안 되는 이유였다.


반대로 Docker OOM Kill이라면 무엇을 봐야 할까

그렇다고 로그에 메모리라는 말이 나왔다는 이유만으로 Docker 쪽을 무시해서도 안 된다.

Node.js heap을 크게 늘려놓았는데 컨테이너 자체의 memory limit보다 더 많은 실제 메모리를 필요로 한다면 결국 다른 한계에 부딪힐 수 있기 때문이다.

Docker에서는 컨테이너의 상태 정보 안에 OOMKilled 여부와 ExitCode가 따로 존재한다. Docker Engine의 공식 Container Inspect 응답에도 State.OOMKilledState.ExitCode가 별도의 값으로 정의되어 있다.

그래서 지금 같은 문제가 다시 발생한다면 로그만 보고 추측하기보다 바로 상태를 확인하게 되었다.

예를 들면:

docker inspect \
  --format '{{.State.ExitCode}} {{.State.OOMKilled}}' \
  container_name

여기에서 중요한 것은 Exit Code 하나만 보는 것이 아니다.

Docker가 실제로 이 컨테이너를 OOM Kill로 기록하고 있는지를 같이 보는 것이다.

Docker 공식 문서에서는 상태 코드 137SIGKILL(9)로 종료된 컨테이너를 찾을 때 사용할 수 있다고 명시하고 있고, OOM 상황에서는 커널이 컨테이너 프로세스를 종료할 수 있다고 설명한다.

이 내용을 보고 나니 내가 겪었던 Exit 134에 대해서도 “메모리라는 단어가 있었으니 Docker OOM Kill이었다”라고 단정하면 안 된다는 생각이 들었다.

당시 함께 나타난 Node.js의 JavaScript heap out of memory 로그가 오히려 더 중요한 단서였다.

Exit Code는 결과를 좁혀가는 하나의 정보이고, 실제 종료 원인을 판단하려면 애플리케이션 로그와 Docker 상태를 함께 봐야 했다.


처음 해결책으로 생각한 것은 --max-old-space-size였다

Node.js heap 문제를 검색하다 보면 상당히 빠르게 만나게 되는 옵션이 있다.

node --max-old-space-size=4096 app.js

Node.js 공식 문서에서 --max-old-space-size는 V8 old memory 영역의 최대 크기를 MiB 단위로 설정한다. heap 사용량이 이 한계에 접근하면 V8은 사용하지 않는 메모리를 확보하기 위해 garbage collection에 더 많은 시간을 사용한다.

그래서 JavaScript heap out of memory를 보면 이 값을 높이는 것이 아주 자연스러운 해결 방법처럼 보인다.

나도 처음에는 그렇게 봤다.

현재 JavaScript heap이 부족하다.

그렇다면 heap을 더 크게 허용하면 된다.

논리 자체는 틀리지 않는다.

실제로 정상적인 작업이 현재 V8 heap 한계보다 많은 메모리를 필요로 하는 경우라면 heap limit을 늘리는 것이 필요한 조치일 수 있다.

그런데 여기서 또 하나의 질문이 생겼다.

왜 이 빌드가 그렇게 많은 JavaScript heap을 필요로 하고 있는가?

이 질문 없이 숫자만 늘리면 PostgreSQL에서 max_connections를 계속 올리던 문제와 비슷해질 수 있다는 생각이 들었다.

1GB가 부족하면 2GB.

2GB가 부족하면 4GB.

4GB가 부족하면 8GB.

이런 식으로 계속 늘렸는데 실제 원인이 메모리 누수나 비정상적으로 큰 빌드 작업에 있다면 한계선을 뒤로 미루는 것에 불과할 수 있다.

그래서 --max-old-space-size를 사용하더라도 이 값이 해결인지, 단순한 증상 완화인지 구분할 필요가 있었다.


특히 “서버 RAM이 32GB니까 Node에 16GB 줘도 되겠지”라고 생각하면 부족했다

처음에는 물리적인 서버 메모리만 보면 여유가 충분해 보일 수 있다.

예를 들어 서버에 RAM이 많이 남아 있다면 Node.js heap limit 역시 크게 줘도 괜찮을 것 같다.

그런데 Docker 안에서 실행한다면 다시 한 단계가 추가된다.

물리 서버 RAM
      ↓
Docker가 컨테이너에 허용한 메모리
      ↓
Node.js 프로세스 전체 메모리
      ↓
그 안의 V8 JavaScript Heap

이 숫자들은 전부 같은 것이 아니다.

Node.js의 --max-old-space-sizeNode 프로세스 전체 메모리 상한을 설정하는 옵션이 아니라 V8 old space의 최대 크기를 조정하는 것이다. Node.js 공식 문서도 이 옵션을 V8 old memory section의 최대 크기로 정의한다.

Node 프로세스에는 heap 이외에도 다른 메모리가 필요할 수 있다.

여기에 컨테이너 자체에는 Node 프로세스 외의 자원도 존재할 수 있다.

그런데 container memory limit을 4GB로 설정해 놓고 Node의 old-space 한계 역시 거의 4GB 가까이 사용하도록 만든다면 다른 메모리가 사용할 여유를 생각하지 못한 설정이 될 수 있다.

Node.js 문서에서도 2GiB 메모리 환경을 예로 들면서 old-space를 1536MiB 정도로 설정해 다른 용도를 위한 메모리를 남기는 예를 제시한다.

이 예시를 보고 나니 숫자를 정할 때 “얼마까지 줄 수 있는가”보다 “다른 메모리가 사용할 공간을 얼마나 남겨야 하는가”도 같이 생각해야 한다는 점이 더 명확해졌다.


빌드 중에만 죽는다면 또 다르게 봐야 했다

내가 겪었던 문제에서 특히 신경 쓰였던 부분은 애플리케이션을 단순 실행할 때와 빌드할 때 메모리 사용 패턴이 다를 수 있다는 점이었다.

Nest나 Node 기반 프로젝트를 빌드하면 TypeScript 변환, 모듈 분석, 번들링이나 기타 빌드 과정이 한꺼번에 진행될 수 있다.

평소 API 서버는 잘 실행되고 있는데:

npm run build

또는 Nest 빌드 과정에서만 JavaScript heap out of memory가 나타난다면,

애플리케이션 운영 자체가 메모리를 계속 누수하고 있다.

라는 결론과는 구분해서 생각해야 한다.

문제가 발생하는 시점이 단서가 될 수 있기 때문이다.

항상 일정 시간 운영한 뒤 죽는다면 장기적으로 메모리 사용량이 증가하는 문제를 의심해볼 수 있다.

반대로 특정 빌드 명령을 실행하는 순간에만 메모리가 급격히 증가한다면 빌드 작업이 순간적으로 필요로 하는 heap 크기나 빌드 구조를 먼저 확인할 수 있다.

Node.js 공식 메모리 진단 문서에서도 메모리 문제를 분석할 때 heap profiler, heap snapshot, GC trace 등을 이용해 객체의 크기와 allocation pattern을 확인하는 방법을 안내하고 있다.

이걸 보고 나서 “메모리 부족”이라는 한 문장보다 언제부터, 어떤 작업을 할 때, 어떤 형태로 증가했는가가 더 중요한 정보라고 생각하게 됐다.


그래서 지금이라면 먼저 메모리가 증가하는 모습을 보려고 할 것 같다

오류가 이미 발생한 뒤의 로그만 가지고 보면 그 순간의 결과만 알 수 있다.

나는 지금 다시 같은 문제를 만난다면 프로세스가 죽기 전에 메모리가 어떻게 변하는지부터 보고 싶다.

Docker에서는 docker stats를 이용해 실행 중인 컨테이너의 CPU와 memory usage, memory limit 등의 runtime metrics를 볼 수 있다.

예를 들어:

docker stats

를 켜놓고 빌드를 실행한다.

그리고 생각한다.

빌드를 시작하기 전에는 어느 정도였는가.

빌드가 시작되면 서서히 증가하는가.

특정 시점에 급격하게 증가하는가.

컨테이너 한계에 거의 도달하는가.

아니면 Docker가 허용한 메모리에는 여유가 있는데 Node.js가 먼저 heap out of memory로 종료되는가.

두 번째 상황이라면 상당히 중요한 단서가 된다.

Docker Memory Limit
4GB

실제 컨테이너 사용량
2GB 부근

그런데

Node.js
JavaScript heap out of memory

와 같은 패턴이 실제로 관찰된다면 컨테이너 전체 memory limit이 첫 번째 의심 대상이 아닐 수 있다.

반대로:

Docker Memory Limit
2GB

Memory Usage
2GB에 매우 근접

그 직후 종료

되고 OOMKilled도 true로 기록된다면 Docker/커널 쪽 메모리 한계를 먼저 볼 근거가 강해진다.

위 숫자는 설명을 위한 예시지만, 이런 방식으로 실제 측정값을 남겨야 두 상황을 구분할 수 있다는 점이 중요하다.


--max-old-space-size를 올린 뒤 잘 됐다고 해서 원인이 완전히 해결됐다고 보기도 어려웠다

가령 old-space 값을 높였더니 빌드가 성공했다고 생각해보자.

그러면 다음과 같이 결론을 내리기 쉽다.

원인: Node heap이 작았음
해결: max-old-space-size 증가
끝

하지만 나는 지금이라면 한 번 더 질문할 것 같다.

왜 기존 heap으로는 빌드가 불가능했을까?

프로젝트 규모가 실제로 커져서 정상적인 빌드에 더 많은 메모리가 필요한 것이라면 큰 문제가 아닐 수 있다.

그렇지만 이전에는 같은 설정으로 빌드됐는데 어느 시점부터 갑자기 실패하기 시작했다면 그 사이 무엇이 변했는지를 확인할 이유가 생긴다.

Dependency가 크게 늘었는지.

빌드 대상에 불필요한 파일이 들어갔는지.

코드 생성이나 변환 작업이 늘어났는지.

특정 버전 변경 이후 메모리 사용 패턴이 달라졌는지.

CI나 Docker 빌드 환경의 memory limit이 달라졌는지.

이런 질문이 필요하다.

--max-old-space-size로 성공했다는 것은 더 많은 heap을 주면 현재 작업을 끝낼 수 있다는 사실은 보여준다.

하지만 메모리를 많이 사용하게 된 이유까지 설명해주는 것은 아니다.

이 차이를 구분하지 않으면 몇 달 뒤 프로젝트가 더 커졌을 때 같은 문제를 다시 만날 수 있다.


메모리 누수와 단순히 큰 작업도 구분해야 했다

heap out of memory라는 말을 들으면 메모리 누수가 바로 떠오를 수 있다.

하지만 heap이 부족했다고 해서 항상 leak이라고 말할 수는 없다.

예를 들어 한 번의 작업에서 실제로 3GB의 객체가 동시에 필요하고 heap 한계가 2GB라면 메모리가 정상적으로 해제되고 있어도 해당 작업은 끝낼 수 없다.

반대로 처리한 작업이 끝났는데도 사용했던 객체가 계속 참조되어 garbage collection 대상이 되지 않고 시간이 갈수록 heap이 증가한다면 leak을 의심할 수 있다.

두 상황 모두 마지막에는:

JavaScript heap out of memory

로 끝날 수 있다.

그래서 Node.js 공식 메모리 진단 문서에서도 단순히 heap 크기를 조절하는 방법뿐 아니라 heap snapshot, profiler, GC trace 등을 이용해 어떤 객체가 공간을 차지하고 있고 무엇이 garbage collection을 막는지 분석하도록 안내한다.

이 차이가 중요한 이유는 해결 방법이 완전히 다르기 때문이다.

정상적인 대형 빌드라면 더 큰 heap을 제공하는 것이 합리적일 수 있다.

메모리 누수라면 heap을 늘리는 것은 프로세스가 죽는 시간을 늦출 뿐이다.


그렇다고 운영 서버에서 무턱대고 Heap Snapshot을 찍는 것도 조심해야 했다

메모리 문제가 있으니 Heap Snapshot을 찍으면 되지 않을까 생각할 수 있다.

하지만 이 작업 자체도 메모리를 사용한다.

Node.js 공식 문서는 heap snapshot을 생성할 때 main thread의 다른 작업이 멈출 수 있고, snapshot 생성 과정에서 현재 heap 크기의 약 두 배 수준 메모리가 필요할 수 있어 애플리케이션이 crash할 위험도 있다고 경고한다.

이 내용을 보면 운영 서버에서 메모리가 거의 바닥난 순간에:

지금 바로 heap snapshot부터 찍어보자.

라고 하는 것도 위험할 수 있다는 것을 알 수 있다.

이미 메모리 여유가 거의 없는 상태라면 진단 작업이 마지막 남은 메모리를 사용하는 상황도 생각해야 한다.

그래서 실제 운영 환경에서는 재현 가능한 테스트 환경을 만들거나, 장애 영향이 없는 인스턴스에서 진단하는 방법까지 같이 고려해야 한다.

이런 부분은 단순히 heapdump 사용법만 알고 있을 때는 잘 보이지 않았다.


내가 지금 다시 이 오류를 만나면 보는 순서가 완전히 달라질 것 같다

예전에는 로그에서 heap out of memory를 보고 바로 메모리를 늘리는 방향으로 생각했다.

지금은 먼저 로그 자체를 자세히 볼 것 같다.

Node.js가 JavaScript heap out of memory를 출력했는지 확인한다.

그다음 Docker 상태를 본다.

docker inspect \
  --format '{{.State.ExitCode}} {{.State.OOMKilled}}' \
  container_name

그리고 실행 중 재현이 가능하다면:

docker stats

로 컨테이너의 실제 메모리 증가를 본다.

Node.js heap limit도 별도로 확인할 수 있다. Node.js 공식 메모리 문서는 v8.getHeapStatistics()heap_size_limit을 이용해 현재 heap limit을 확인하는 방법을 제공한다.

예를 들면:

const v8 = require('node:v8');

const { heap_size_limit } = v8.getHeapStatistics();

console.log(
  heap_size_limit / 1024 / 1024,
  'MB'
);

이렇게 보면 최소한 세 개의 숫자를 서로 비교할 수 있다.

Node.js V8 Heap Limit

Docker Memory Limit

실제 Container Memory Usage

예전에는 이 셋을 거의 같은 “메모리 크기”로 생각했다.

지금은 각각 다른 계층의 한계라고 본다.


이 세 숫자를 같이 보면 해결 방향도 달라진다

예를 들어 실제 측정 결과가 다음과 같다고 생각해보자.

Node.js heap limit 근처까지 계속 올라가는데 Docker 컨테이너의 memory limit에는 충분한 여유가 있다.

그리고 로그에는 명확하게 JavaScript heap out of memory가 있다.

그렇다면 V8 heap 설정과 Node 애플리케이션의 heap 사용을 먼저 볼 근거가 생긴다.

반대로 Node heap limit보다 앞서 컨테이너가 Docker memory limit에 도달하고 OOMKilled=true로 종료된다면 컨테이너 자원 제한을 먼저 확인해야 한다.

그리고 heap과 Docker limit 모두 여유가 있는데 프로세스가 계속 비정상 종료된다면 메모리 문제라고 고집하지 말고 다른 원인으로 범위를 넓혀야 한다.

이것이 내가 Exit Code 하나에 너무 큰 의미를 부여하지 않게 된 이유다.

Exit Code는 조사 시작점이지 항상 원인 자체는 아니었다.


특히 Docker에서는 빌드 단계와 실행 단계를 구분해서 보고 싶다

Node 프로젝트를 Docker로 배포하면 빌드와 런타임을 같은 컨테이너 과정에서 처리할 수도 있고 multi-stage build처럼 분리할 수도 있다.

내가 다시 구조를 만든다면 빌드 중에만 큰 메모리가 필요한지, 실제 서비스 실행 중에도 계속 큰 메모리가 필요한지를 구분하고 싶다.

예를 들어 빌드 시점에만 메모리 요구량이 크다면:

Build Stage
→ 큰 메모리 필요

Runtime Stage
→ 상대적으로 작은 메모리

일 수 있다.

이 상황에서 런타임 컨테이너의 Node heap까지 무조건 크게 설정할 이유는 없을 수 있다.

반대로 서비스가 실행된 뒤 요청을 받을수록 메모리가 계속 증가한다면 빌드 문제가 아니라 런타임 메모리 사용 패턴을 분석해야 한다.

같은 heap out of memory라도 언제 발생했는지에 따라 조사할 코드와 설정이 달라진다.

이 부분을 기록해두는 것이 중요하다고 느꼈다.


당시에는 해결하는 것이 우선이었지만 지금이라면 실패 당시의 수치를 남길 것 같다

이런 문제를 실제로 만나면 블로그 글을 쓰기 위해 차분하게 데이터를 모으고 있을 여유가 없는 경우가 많다.

나 역시 당시에는 우선 빌드가 정상적으로 되도록 만드는 것이 중요했다.

그래서 지금 이 글에서:

당시 컨테이너 메모리가 정확히 몇 GB였고 Node heap이 정확히 몇 MB에서 실패했다.

같은 값을 만들어 적고 싶지는 않다.

그 숫자를 별도로 기록해두지 않았다면 모른다고 하는 것이 맞다.

다만 그 경험 때문에 다음에 같은 문제가 생기면 반드시 기록하고 싶은 항목은 분명해졌다.

Node 버전.

빌드 명령.

Docker memory limit.

오류 직전 container memory usage.

Node heap limit.

docker inspect의 ExitCode와 OOMKilled.

JavaScript heap out of memory 로그 직전 GC 내용.

--max-old-space-size 변경 전후 결과.

이 값들이 있다면 다음에는 단순히:

메모리를 올리니까 해결됐다.

가 아니라,

어느 한계에 먼저 도달했고, 어떤 설정을 바꾸자 그 한계가 어떻게 변했는지

를 설명할 수 있다.

나는 지금은 이 차이가 훨씬 중요하다고 생각한다.


결국 내가 잘못 묻고 있었던 질문은 “메모리를 얼마나 더 줘야 하지?”였다

처음 오류를 만났을 때 내가 가장 궁금했던 것은 이것이었다.

몇 GB를 더 주면 빌드가 될까?

지금은 이 질문부터 시작하지 않을 것 같다.

먼저 이렇게 묻는다.

지금 부족하다고 말하는 메모리는 정확히 어느 메모리인가?

V8의 JavaScript heap인가.

Docker가 컨테이너에 허용한 memory limit인가.

호스트 전체의 실제 RAM인가.

또는 메모리가 아니라 다른 이유로 프로세스가 비정상 종료된 것인가.

이 구분을 먼저 해야 해결 방법도 결정할 수 있다.

V8 heap 문제라면 --max-old-space-size를 검토하면서 왜 heap 사용량이 그렇게 큰지 본다.

Docker OOM Kill이라면 container memory limit과 실제 사용량을 확인한다.

장기적으로 memory usage가 계속 증가한다면 heap profiling이나 snapshot을 통해 무엇이 남아 있는지 분석한다.

빌드 시점에만 발생한다면 빌드 과정과 런타임을 분리해서 본다.

결국 모두 “메모리 문제”이지만 같은 문제는 아니다.


마무리

Docker에서 Node.js 프로세스가 갑자기 종료되고 로그에 메모리 관련 오류가 나타나면 처음에는 모든 원인이 한곳에 있는 것처럼 느껴진다.

나도 Exit Code 134JavaScript heap out of memory를 보면서 가장 먼저 Docker에 메모리를 더 주는 방향을 생각했다.

하지만 다시 구조를 살펴보면서 Node.js의 V8 heap과 Docker의 container memory limit은 서로 다른 계층이라는 것을 구분해서 보기 시작했다.

Node.js는 V8 old-space의 최대 크기를 --max-old-space-size로 조절할 수 있고, heap limit에 접근하면 GC 활동이 증가하며 결국 메모리를 확보하지 못하면 JavaScript heap out of memory로 종료될 수 있다.

Docker에서는 별도로 컨테이너의 memory limit을 설정할 수 있고, OOM 상황에서 커널이 프로세스를 종료할 수 있으며, Container Inspect 상태에는 OOMKilledExitCode가 별도로 기록된다.

이 둘을 구분하고 나니 Exit Code를 보는 방식도 달라졌다.

예전에는:

Exit Code
+
메모리 오류

→ Docker 메모리 부족

처럼 바로 연결하려고 했다.

지금은:

애플리케이션 로그 확인

↓

Node/V8 Heap 오류인가?

↓

Docker OOMKilled인가?

↓

실제 Container Memory는 어디까지 올라갔는가?

↓

Node Heap Limit은 얼마인가?

↓

문제가 Build에서만 발생하는가,
Runtime에서도 발생하는가?

순서로 생각한다.

그리고 이 과정을 거친 뒤에야 메모리를 늘리는 것이 정말 해결책인지 판단한다.

결국 이 문제를 겪으면서 가장 크게 바뀐 생각은 메모리가 부족하다는 메시지를 해결책으로 연결하기 전에, 누가 어떤 메모리의 한계를 이야기하고 있는지부터 확인해야 한다는 것이었다.

그 한계를 구분할 수 있어야 --max-old-space-size를 올릴지, Docker memory limit을 수정할지, 애플리케이션의 메모리 사용 자체를 분석할지 결정할 수 있다.

단순히 숫자를 크게 만드는 것보다 먼저 해야 할 일은 프로세스가 어디에서 한계에 부딪혔는지를 설명할 수 있는 상태를 만드는 것이었다.