Node.js + Puppeteer Memory Leak Handling

2022. 11. 20. 14:51Frontend

Overview

콴다 팀에서는 수학 문제를 이미지가 아닌 Latex String의 형태로 저장합니다. 용량 측면에서도 그렇지만, 문제의 "유사도"를 측정하거나, 문제의 컨텐츠를 기반으로 사용자에게 알맞은 문제를 추천하는 데에 있어서는 이미지보다 스트링의 형태가 더 효율적이기 때문입니다. 하지만 결국 이 컨텐츠를 사용하는 학생들의 입장에서는 이 스트링이 올바르게 렌더 된 수식이 필요하기 때문에 이를 렌더 해주는 작업이 필요합니다. 

 

Latex String이 올바르게 렌더된 형태(좌) Latex String(우)

 

이 수식을 라이브러리를 사용해서 user-side에서 사용자의 화면에 그대로 그려주는 방법도 있겠지만, 이는 클라이언트 사이드의 많은 리소스를 소모하게 됩니다. 게다가 웹뷰가 아닌 Native 환경에서는 수식을 렌더 하기 위해 엄청난 양의 폰트를 로드해야 하는데, 이는 결국 애플리케이션의 번들 사이즈를 필요 이상으로 크게 만들게 됩니다. (통계적으로 애플리케이션 사이즈가 커질수록 다운로드 수가 줄어듭니다) 이 문제를 해결하기 위해 문제 자체는 Database에 "Latex String"의 형태로 저장하되, 사용자에게 문제를 보여줄 때는 "이미지"의 형태로 내려주기로 결정하였으며, Chromium 기반의 Puppeteer를 사용한 Node.js 서버를 만들어서 이 수식을 이미지로 변환하도록 했습니다.

 

이번 포스팅은 이 렌더러 서버에 지속적으로 들어오는 수만~수십만 개의 요청을 처리하면서 발생했던 Memory Leak의 원인을 탐구하고 이를 해결하는 과정에 대한 기록입니다.

The Problem

JavaScript는 기본적으로 Garbage Collection(이하 GC)가 매우 잘 되어 있는 언어입니다. 따라서 로컬에서 렌더러 서버를 개발하고 테스트할 때는 Memory Leak에 대한 내용을 전혀 생각하지 못한 채, 다음과 같이 문제들을 렌더하기 위한 타입을 정의하고, 이 정의된 형태의 문제가 아래와 같이 적당한 시간 안에 빠르게 렌더 되는지에 대해서만 테스트를 했습니다. 의도한 대로 Image와 PDF가 잘 생성되는 것을 확인한 후 큰 문제가 없을 것 같아, Dev 환경에 해당 서버를 올리고, 동시에 3000개 정도의 요청을 받을 수 있도록 Vertical / Horizontal Scaling을 적절히 설정한 뒤에 배치를 돌려 실제로 요청을 보냈습니다.

 

 

처음에는 요청을 잘 처리하는가 싶더니 수천개의 요청을 처리한 이후, 이내 이어지는 요청들이 모조리 실패하면서 계속해서 Connection Timeout에러가 발생하는 상황이 생겼습니다. 문제를 확인하기 위해 GCP상의 여러 지표들을 확인해보니 아래와 같이 memory 사용량이 시간이 지남에 따라 계단형으로 증가하면서 100%를 초과하고 있었고, 이에 따라 이후의 요청들이 계속 실패하고 있었음을 확인할 수 있었습니다. Memory Leak이 발생한 것입니다.

 

vs

Careful Inspection. 

 

Memory Leak이 발생하는 것을 확인한 후, 이를 해결하기 위해 node의 공식문서에서 가이드하고 있는 대로 inspect 옵션을 두고 Heap Snapshot을 찍어보기로 했습니다. node application을 실행할 때 위와 같이 "--inspect" 옵션을 주면 chrome://inspect 탭에서 해당 노드 서버의 콘솔과 메모리, 네트워크 정보를 크롬 디버깅 툴을 사용해서 확인할 수 있습니다.

 

chrome://inspect

 

아래 사진은 node application이 처음 실행되었을 때의 Heap Snapshot(15.3MB)이며, 그 다음 사진은 node application이 3번의 이미지 생성 요청을 수행하고, 사진 왼쪽 상단의 Trash Icon을 눌러서 Force Garbage Collection을 수행한 뒤의 Heap Snapshot(15.5MB)입니다. 두 Snapshot 사이에는 이미 요청이 끝나서 Response(png)가 클라이언트에게 반환되었음에도 불구하고, screenshotTaskQueue와 같은 Promise, Socket 등의 정보가 아직 남아있는 것을 발견할 수 있습니다. 기대대로라면 요청이 끝난 후 메모리에서 제거되어야 하는 리소스들이 제거되지 않고 Heap에 남아 있고, 이들이 수만 건의 요청을 처리하면서 누적되어 Memory Leak을 발생시켜, Service Unavailable 상태를 만들게 된 것입니다. 

 

 

 

Is this a Reaping problem?

위 Node 서버는 수식을 렌더하고, 이를 Screenshot으로 남기기 위해서 Puppeteer를 사용합니다. Puppeteer Process는 아래 htop Snapshot에서 확인할 수 있는 것처럼, 하나의 Parent Process 아래에 2개의 Chrome Helper Process가 fork 되어 child process로 존재하고, 이미지 생성을 위해 서버에 요청을 보내면, 1개의 Chrome Helper Process가 추가로 생성되어 요청을 처리하고 종료됩니다.

 

항상 이렇게 3개의 프로세스(1 Parent, 2 Child)가 Terminate되지 않고 살아있는 상태에서 요청마다 새로운 프로세스가 생성되고 종료되기 때문에 해당 프로세스가 종료된 후 Parent Process가 이를 reaping 하지 않았다면, 요청을 처리하고 종료된 프로세스는 좀비 프로세스로 남아 시스템 자원을 소모하게 됩니다. 따라서 Memory Leak가 이러한 Reaping Problem에 기인한 것인지를 알아보기 위해 "defunct" 상태인 puppeteer 관련 프로세스가 있는지를 살펴보았습니다.

하지만 zombie 프로세스, 혹은 defunct 상태인 puppeteer 관련 프로세스는 별도로 남아있지 않았고, 따라서 puppeteer가 요청을 처리한 뒤에 이를 reap하지 않아서 생기는 memory leak 문제는 아닌 것으로 판단했습니다.

Nah, maybe chromium memory leak?

Puppeteer 프로세스는 이미지 생성을 받으면 새로운 프로세스를 하나 띄워 요청을 처리한 후(수식을 렌더 해서 스크린샷을 찍은 후) 성공적으로 해당 프로세스를 종료하고 Reaping 합니다. 따라서 위에서 발생한 Memory Leak 이슈는 Puppeteer 자체적인 이슈이거나, Puppeteer가 사용하는 Chromium 브라우저 내부적인 이슈, 특히 네트워크를 처리하는 과정에서 발생하는 이슈일 가능성이 높다고 생각했습니다.

 

비슷한 사례들이 있는지를 더 찾아보기 위해 puppeteer github repository에 등록된 이슈들을 찾아보니 다음과 같은 이슈들이 있는 것을 확인할 수 있었습니다. (아래 Comment의 @OrKoN은 puppeteer의 contributer입니다.)

지금 겪고 있는 이유가 정확히 이 이슈와 매칭되는지는 조금 더 확인해 보아야겠지만, 확실한 건 Puppeteer / Chromium에서 비슷한 이유로서의 Memory Leak가 자주 발생했기 때문에, 지금 겪고 있는 문제 또한 Puppeteer / Chromium에서 발생한 이슈일 가능성이 있으며, 이 문제를 빠르게 해결하기 위해서는 조금 다른 방향에서의 접근방법이 필요하다는 것입니다. (지금 당장 Chromium에 Contribute 할 수는 없을 것 같았기 때문이기도 합니다)

How Can We Solve this Leak?

Memory Leak 문제가 Puppeteer에서 발생한 것이든 Chromium에서 발생한 것이든 간에, 사용하지 않는 메모리가 GC에 의해 수거되지 않고 남아있다는 것은 사용하지 않는 메모리가, 어딘가로부터 "도달가능하기 때문"일 것입니다.(Node가 사용하는 V8 엔진은 GC Algorithm으로 Mark & Sweep을 사용하는 것으로 알려져 있습니다.) 따라서 확실하게 이 참조를 끊어주기 위해서 "주기적으로 Puppeteer의 Parent Process를 제거하고 재생성한다면" 해당 프로세스와 해당 프로세스의 Child Process에 할당되는 메모리가 모두 GC의 수거대상이 될 것이며, Memory Leak을 일으켰던 메모리 Chunk들도 모두 함께 수거될 것입니다. 즉, "Child Process에서의 Memory Leak를 지금 당장 해결할 수 없으니 Parent Process를 주기적으로 Kill 하고 재생성해서 강제로 GC를 시키겠다"는 것입니다.

 

따라서 기존에 Browser Process를 생성해서 반환했던 Factory Method를 조금 수정해서 다음과 같은 Wrapping Object를 반환하도록 Application Code를 수정했습니다. 이 Wrapping Object는 Browser Process를 획득하기 위한. get() 메서드를 제공하며, 이 Wrapping Object는 자체적으로 life라는 Counter를 가지고 있어서 지정한 숫자(300) 만큼 사용되고 나면 기존 Browser Process를 destroy 하고 다시 생성한 후에 리턴합니다.

// pseudo code
const factory = {
  create: () =>
    Promise.resolve({
      instance: puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS),
      life: 300,
      get() {
        if (this.life > 0) {
          this.life -= 1;

          return this.instance;
        }

        this.life = 300;
        const oldInstance = this.instance;
        oldInstance.then((browser) => browser.close());
        this.instance = puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS);

        return this.instance;
      },
      close() {
        this.instance.then((browser) => browser.close());
      },
    }),
  destroy: (refresher) => Promise.resolve(refresher.close()),
};

Result & Conclusion

위와 같이 Application Code를 수정한 뒤에, 다시 Dev 환경에 배포해서 수만개의 요청을 처리하도록 했더니 다음과 같이 Memory Leak가 더 이상 발생하지 않고(정확하게는 문제 되지 않을 정도로 발생하다가 특정 시점마다 모조리 수거되는 것입니다.) 20% 선의 Memory Utilization을 보여주는 것을 확인할 수 있습니다.

 

일반적인 경우 JS에서 Memory Leak을 걱정할 필요는 거의 없지만, 이렇게 외부 라이브러리를 사용하거나 수많은 트래픽을 받는 경우 예상하지 못한 곳에서 Memory Leak을 맞닥뜨릴 수 있으므로, GC와 Process, Monitoring에 대한 준비를 해두는 것이 좋습니다.

Reference

https://nodejs.org/en/docs/guides/debugging-getting-started/

 

Debugging - Getting Started | Node.js

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

https://github.com/puppeteer/puppeteer/issues/5893

 

Chrome memory leak · Issue #5893 · puppeteer/puppeteer

Tell us about your environment: Puppeteer version: 2.1.1 Platform / OS version: Docker / node:12.16.1-alpine URLs (if applicable): None Node.js version: v12.16.1 What steps will reproduce the probl...

github.com

https://man7.org/linux/man-pages/man2/fork.2.html

 

fork(2) - Linux manual page

fork(2) — Linux manual page FORK(2) Linux Programmer's Manual FORK(2) NAME         top fork - create a child process SYNOPSIS         top #include pid_t fork(void); DESCRIPTION         top fork() creates a new process by duplicating the calli

man7.org

 

반응형

'Frontend' 카테고리의 다른 글

Concept of React Scheduler  (0) 2023.01.14
React Mount System Deep Dive (Sync Mode)  (5) 2023.01.08
Suspense SSR Architecture in React 18  (1) 2022.09.18
Algebraic Effects of React Suspense  (1) 2022.09.12
Conceptual Model of React Suspense  (0) 2022.09.12