Node.js 애플리케이션의 메모리 누수 디버깅

게시 됨: 2022-03-11

한 번은 V8 트윈 터보 엔진을 장착한 아우디를 몰고 왔는데, 그 성능은 믿을 수 없었습니다. 나는 도로에 아무도 없을 때 오전 3시에 시카고 근처의 IL-80 고속도로에서 약 140MPH로 운전하고 있었습니다. 그 이후로 "V8"이라는 용어는 저에게 고성능과 연결되었습니다.

Node.js는 빠르고 확장 가능한 네트워크 애플리케이션을 쉽게 구축할 수 있도록 Chrome의 V8 JavaScript 엔진을 기반으로 하는 플랫폼입니다.

Audi의 V8은 매우 강력하지만 연료 탱크의 용량은 여전히 ​​제한적입니다. Node.js 뒤에 있는 JavaScript 엔진인 Google V8도 마찬가지입니다. 그 성능은 놀랍고 Node.js가 많은 사용 사례에서 잘 작동하는 데는 여러 가지 이유가 있지만 항상 힙 크기의 제약을 받습니다. Node.js 애플리케이션에서 더 많은 요청을 처리해야 하는 경우 수직 확장 또는 수평 확장의 두 가지 선택이 있습니다. 수평적 확장은 더 많은 동시 애플리케이션 인스턴스를 실행해야 함을 의미합니다. 올바르게 완료되면 더 많은 요청을 처리할 수 있게 됩니다. 수직적 확장이란 애플리케이션의 메모리 사용량과 성능을 개선하거나 애플리케이션 인스턴스에 사용할 수 있는 리소스를 늘려야 함을 의미합니다.

Node.js 애플리케이션의 메모리 누수 디버깅

Node.js 애플리케이션의 메모리 누수 디버깅
트위터

최근에 저는 메모리 누수 문제를 해결하기 위해 Toptal 클라이언트 중 하나에 대한 Node.js 애플리케이션 작업을 요청받았습니다. API 서버인 애플리케이션은 1분에 수십만 건의 요청을 처리할 수 있도록 설계되었습니다. 원래 애플리케이션은 거의 600MB의 RAM을 차지했기 때문에 핫 API 엔드포인트를 가져와 다시 구현하기로 결정했습니다. 많은 요청을 처리해야 하는 경우 오버헤드가 매우 비쌉니다.

새 API의 경우 기본 MongoDB 드라이버와 백그라운드 작업을 위한 Kue로 restify를 선택했습니다. 아주 가벼운 스택 같죠? 좀 빠지는. 최대 로드 동안 새 애플리케이션 인스턴스는 최대 270MB의 RAM을 사용할 수 있습니다. 따라서 1X Heroku Dyno당 2개의 애플리케이션 인스턴스를 갖는 꿈이 사라졌습니다.

Node.js 메모리 누수 디버깅 아스날

멤워치

"노드에서 누수를 찾는 방법"을 검색하면 가장 먼저 찾을 수 있는 도구는 memwatch 입니다. 원래 패키지는 오래 전에 폐기되었으며 더 이상 유지되지 않습니다. 그러나 저장소에 대한 GitHub의 포크 목록에서 최신 버전을 쉽게 찾을 수 있습니다. 이 모듈은 힙이 5개의 연속적인 가비지 수집을 통해 증가하는 것을 볼 경우 누수 이벤트를 방출할 수 있기 때문에 유용합니다.

힙 덤프

Node.js 개발자가 힙 스냅샷을 찍고 나중에 Chrome 개발자 도구로 검사할 수 있는 훌륭한 도구입니다.

노드 검사기

힙 덤프에 대한 더 유용한 대안은 실행 중인 애플리케이션에 연결할 수 있고 힙 덤프를 가져와 즉시 디버그 및 재컴파일할 수 있기 때문입니다.

스핀을 위한 "노드 검사기" 사용

불행히도 Heroku에서 실행 중인 프로덕션 응용 프로그램에 연결할 수 없습니다. 실행 중인 프로세스에 신호를 보낼 수 없기 때문입니다. 그러나 Heroku가 유일한 호스팅 플랫폼은 아닙니다.

node-inspector의 작동을 경험하기 위해 restify를 사용하여 간단한 Node.js 애플리케이션을 작성하고 그 안에 약간의 메모리 누수 소스를 넣을 것입니다. 여기의 모든 실험은 V8 v3.28.71.19에 대해 컴파일된 Node.js v0.12.7로 이루어졌습니다.

 var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });

여기의 응용 프로그램은 매우 간단하고 매우 명백한 누출이 있습니다. 어레이 작업 은 애플리케이션 수명 동안 증가하여 속도가 느려지고 결국 충돌합니다. 문제는 클로저뿐만 아니라 전체 요청 객체도 누출하고 있다는 것입니다.

V8의 GC는 stop-world 전략을 사용하므로 메모리에 더 많은 객체가 있다는 것은 가비지를 수집하는 데 더 오래 걸립니다. 아래 로그에서 애플리케이션 수명 초기에는 쓰레기를 수집하는 데 평균 20ms가 걸리지만 수십만 요청 후에는 약 230ms가 걸린다는 것을 분명히 알 수 있습니다. 우리 애플리케이션에 액세스하려는 사람들은 GC 때문에 지금 230ms 더 기다려야 합니다. 또한 GC가 몇 초마다 호출된다는 것을 알 수 있습니다. 즉, 몇 초마다 사용자가 응용 프로그램에 액세스하는 데 문제가 발생합니다. 그리고 응용 프로그램이 충돌할 때까지 지연이 늘어납니다.

 [28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

다음 로그 라인은 Node.js 애플리케이션이 –trace_gc 플래그로 시작될 때 인쇄됩니다.

 node --trace_gc app.js

이 플래그를 사용하여 Node.js 애플리케이션을 이미 시작했다고 가정해 보겠습니다. 응용 프로그램을 node-inspector와 연결하기 전에 실행 중인 프로세스에 SIGUSR1 신호를 보내야 합니다. 클러스터에서 Node.js를 실행하는 경우 슬레이브 프로세스 중 하나에 연결해야 합니다.

 kill -SIGUSR1 $pid # Replace $pid with the actual process ID

이를 통해 Node.js 애플리케이션(정확히 말하면 V8)을 디버깅 모드로 전환합니다. 이 모드에서 애플리케이션은 V8 디버깅 프로토콜을 사용하여 포트 5858을 자동으로 엽니다.

다음 단계는 실행 중인 애플리케이션의 디버깅 인터페이스에 연결하고 포트 8080에서 다른 웹 인터페이스를 여는 node-inspector를 실행하는 것입니다.

 $ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

애플리케이션이 프로덕션에서 실행 중이고 방화벽이 있는 경우 원격 포트 8080을 localhost로 터널링할 수 있습니다.

 ssh -L 8080:localhost:8080 [email protected]

이제 Chrome 웹 브라우저를 열고 원격 프로덕션 애플리케이션에 연결된 Chrome 개발 도구에 대한 전체 액세스 권한을 얻을 수 있습니다. 불행히도 Chrome 개발자 도구는 다른 브라우저에서 작동하지 않습니다.

누수를 찾자!

V8의 메모리 누수는 우리가 C/C++ 애플리케이션에서 알고 있는 실제 메모리 누수가 아닙니다. JavaScript에서 변수는 공백으로 사라지지 않고 "잊혀지는" 것입니다. 우리의 목표는 이러한 잊혀진 변수를 찾아 Dobby가 무료임을 상기시키는 것입니다.

Chrome 개발자 도구 내에서 여러 프로파일러에 액세스할 수 있습니다. 우리는 특히 시간이 지남에 따라 여러 힙 스냅샷을 실행하고 생성하는 힙 할당 기록 에 관심이 있습니다. 이를 통해 어떤 물체가 누출되고 있는지 명확하게 볼 수 있습니다.

힙 할당 기록을 시작하고 Apache Benchmark를 사용하여 홈 페이지에서 50명의 동시 사용자를 시뮬레이션해 보겠습니다.

스크린샷

 ab -c 50 -n 1000000 -k http://example.com/

새 스냅샷을 찍기 전에 V8은 마크 스윕 가비지 수집을 수행하므로 스냅샷에 오래된 가비지가 없다는 것을 확실히 알고 있습니다.

즉석에서 누출 수정

3분 동안 힙 할당 스냅샷을 수집한 후 다음과 같은 결과가 나타납니다.

스크린샷

힙에 거대한 배열, 많은 IncomingMessage, ReadableState, ServerResponse 및 Domain 개체가 있음을 분명히 알 수 있습니다. 누출의 원인을 분석해 보겠습니다.

차트에서 힙 차이를 20~40으로 선택하면 프로파일러를 시작한 이후 20초 이후에 추가된 개체만 볼 수 있습니다. 이런 식으로 모든 일반 데이터를 제외할 수 있습니다.

시스템에 있는 각 유형의 객체 수를 기록하면서 필터를 20초에서 1분으로 확장합니다. 우리는 이미 상당히 거대한 어레이가 계속 성장하고 있음을 알 수 있습니다. "(array)" 아래에 동일한 거리를 가진 "(object properties)" 객체가 많이 있음을 알 수 있습니다. 이러한 개체는 메모리 누수의 원인입니다.

또한 "(클로저)" 객체도 빠르게 성장하는 것을 볼 수 있습니다.

문자열을 보는 것도 편리할 수 있습니다. 문자열 목록 아래에는 "Hi Leaky Master" 문구가 많이 있습니다. 그것들은 우리에게 단서를 줄 수도 있습니다.

우리의 경우 "Hi Leaky Master" 문자열은 "GET /" 경로에서만 조합될 수 있다는 것을 알고 있습니다.

리테이너 경로를 열면 이 문자열이 req 를 통해 어떻게든 참조되는 것을 볼 수 있으며 컨텍스트가 생성되고 이 모든 것이 클로저의 거대한 배열에 추가됩니다.

스크린샷

따라서 이 시점에서 우리는 일종의 거대한 클로저 배열이 있다는 것을 압니다. 실제로 소스 탭에서 실시간으로 모든 클로저에 이름을 지정해 보겠습니다.

스크린샷

코드 편집이 끝나면 CTRL+S를 눌러 즉시 코드를 저장하고 다시 컴파일할 수 있습니다!

이제 또 다른 힙 할당 스냅샷 을 기록하고 어떤 클로저가 메모리를 차지하는지 살펴보겠습니다.

SomeKindOfClojure() 가 우리의 악당임이 분명합니다. 이제 SomeKindOfClojure() 클로저가 전역 공간의 task 라는 배열에 추가되고 있음을 알 수 있습니다.

이 배열이 쓸모가 없다는 것을 쉽게 알 수 있습니다. 우리는 그것을 주석 처리할 수 있습니다. 그러나 이미 점유된 메모리를 어떻게 해제합니까? 매우 쉽습니다. 빈 배열을 작업 에 할당하면 다음 요청으로 재정의되고 다음 GC 이벤트 후에 메모리가 해제됩니다.

스크린샷

도비는 자유 다!

V8의 쓰레기 수명

글쎄, V8 JS에는 메모리 누수가 없고 잊어버린 변수만 있습니다.

글쎄, V8 JS에는 메모리 누수가 없고 잊어버린 변수만 있습니다.
트위터

V8 힙은 여러 공간으로 나뉩니다.

  • New Space : 비교적 작은 공간으로 1MB ~ 8MB 정도의 크기를 가지고 있습니다. 대부분의 개체가 여기에 할당됩니다.
  • 이전 포인터 공간 : 다른 개체에 대한 포인터를 가질 수 있는 개체가 있습니다. 개체가 New Space에서 충분히 오래 생존하면 Old Pointer Space로 승격됩니다.
  • 이전 데이터 공간 : 문자열, boxed 숫자 및 unboxed double 배열과 같은 원시 데이터만 포함합니다. New Space에서 충분히 오랫동안 GC에서 살아남은 개체도 여기로 이동됩니다.
  • Large Object Space : 너무 커서 다른 공간에 맞지 않는 오브제가 이 공간에 생성됩니다. 각 객체에는 메모리에 자체 mmap 영역이 있습니다.
  • 코드 공간 : JIT 컴파일러에서 생성된 어셈블리 코드를 포함합니다.
  • 셀 공간, 속성 셀 공간, 맵 공간 : 이 공간에는 Cell s, PropertyCell s 및 Map s가 포함됩니다. 이것은 가비지 수집을 단순화하는 데 사용됩니다.

각 공간은 페이지로 구성됩니다. 페이지는 mmap을 사용하여 운영 체제에서 할당된 메모리 영역입니다. 각 페이지의 크기는 큰 개체 공간의 페이지를 제외하고 항상 1MB입니다.

V8에는 Scavenge, Mark-Sweep 및 Mark-Compact의 두 가지 내장 가비지 수집 메커니즘이 있습니다.

Scavenge는 매우 빠른 가비지 수집 기술이며 New Space 의 개체와 함께 작동합니다. Scavenge는 Cheney 알고리즘의 구현입니다. 아이디어는 매우 간단합니다. New Space 는 두 개의 동일한 반 공간인 To-Space와 From-Space로 나뉩니다. 스캐빈지 GC는 To-Space가 가득 차면 발생합니다. 그것은 단순히 To와 From 공간을 교환하고 모든 살아있는 객체를 To-Space로 복사하거나 두 개의 청소부에서 살아남은 경우 이전 공간 중 하나로 승격시킨 다음 공간에서 완전히 지워집니다. 청소는 매우 빠르지만 두 배 크기의 힙을 유지하고 메모리에 개체를 지속적으로 복사하는 오버헤드가 있습니다. 청소기를 사용하는 이유는 대부분의 개체가 젊어서 죽기 때문입니다.

Mark-Sweep & Mark-Compact는 V8에서 사용되는 또 다른 유형의 가비지 수집기입니다. 다른 이름은 전체 가비지 수집기입니다. 모든 라이브 노드를 표시한 다음 모든 데드 노드를 청소하고 메모리를 조각 모음합니다.

GC 성능 및 디버깅 팁

웹 응용 프로그램의 경우 고성능이 그렇게 큰 문제가 아닐 수도 있지만 어떤 경우에도 누출을 피하고 싶을 것입니다. 전체 GC의 표시 단계에서 응용 프로그램은 가비지 수집이 완료될 때까지 실제로 일시 중지됩니다. 이것은 힙에 더 많은 개체가 있을수록 GC를 수행하는 데 더 오래 걸리고 사용자가 더 오래 기다려야 함을 의미합니다.

항상 클로저와 함수에 이름을 지정하십시오.

모든 클로저와 함수에 이름이 있으면 스택 추적과 힙을 검사하는 것이 훨씬 쉽습니다.

 db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })

핫 기능에서 큰 개체 피하기

이상적으로는 모든 데이터가 New Space 에 맞도록 핫 함수 내부의 큰 개체를 피하고 싶습니다. 모든 CPU 및 메모리 바인딩 작업은 백그라운드에서 실행되어야 합니다. 또한 핫 함수에 대한 역최적화 트리거를 피하십시오. 최적화된 핫 함수는 최적화되지 않은 함수보다 메모리를 덜 사용합니다.

핫 기능을 최적화해야 합니다.

더 빠르게 실행되지만 메모리를 덜 소모하는 핫 함수는 GC가 덜 자주 실행되도록 합니다. V8은 최적화되지 않은 기능이나 최적화되지 않은 기능을 찾아내는 데 유용한 디버깅 도구를 제공합니다.

핫 기능에서 IC에 대한 다형성 방지

인라인 캐시(IC)는 객체 속성 액세스 obj.key 또는 일부 간단한 기능을 캐싱하여 일부 코드 청크의 실행 속도를 높이는 데 사용됩니다.

 function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3

x(a,b) 가 처음 실행될 때 V8은 모노모픽 IC를 생성합니다. x 를 두 번째로 호출하면 V8은 이전 IC를 지우고 두 유형의 피연산자 정수 및 문자열을 모두 지원하는 새로운 다형성 IC를 생성합니다. IC를 세 번째로 호출하면 V8은 동일한 절차를 반복하고 레벨 3의 또 다른 다형성 IC를 생성합니다.

그러나 한계가 있습니다. IC 레벨이 5에 도달하면( –max_inlining_levels 플래그로 변경 가능) 기능이 메가모픽이 되어 더 이상 최적화 가능한 것으로 간주되지 않습니다.

모노모픽 함수가 가장 빠르게 실행되고 메모리 공간도 더 작다는 것은 직관적으로 이해할 수 있습니다.

메모리에 대용량 파일을 추가하지 마십시오.

이것은 명백하고 잘 알려져 있습니다. 큰 CSV 파일과 같이 처리할 큰 파일이 있는 경우 전체 파일을 메모리에 로드하는 대신 줄 단위로 읽고 작은 청크로 처리합니다. csv의 한 줄이 1mb보다 커서 New Space 에 맞출 수 있는 드문 경우가 있습니다.

주 서버 스레드를 차단하지 마십시오

이미지 크기를 조정하는 API와 같이 처리하는 데 시간이 걸리는 핫 API가 있는 경우 별도의 스레드로 이동하거나 백그라운드 작업으로 전환합니다. CPU 집약적인 작업은 다른 모든 고객이 대기하고 요청을 계속 보내도록 강제하는 메인 스레드를 차단합니다. 처리되지 않은 요청 데이터는 메모리에 쌓이기 때문에 전체 GC를 완료하는 데 더 오랜 시간이 걸립니다.

불필요한 데이터를 생성하지 마십시오

나는 한 번 restify와 함께 이상한 경험을 했다. 잘못된 URL로 수십만 개의 요청을 보내면 애플리케이션 메모리는 몇 초 후 전체 GC가 시작될 때까지 최대 수백 메가바이트까지 빠르게 증가합니다. 잘못된 각 URL에 대해 restify는 긴 스택 추적을 포함하는 새 오류 개체를 생성합니다. 이로 인해 새로 생성된 개체는 New Space 가 아닌 Large Object Space 에 할당되었습니다.

이러한 데이터에 액세스하는 것은 개발 중에 매우 유용할 수 있지만 분명히 프로덕션에서는 필요하지 않습니다. 따라서 규칙은 간단합니다. 꼭 필요한 경우가 아니면 데이터를 생성하지 마십시오.

도구 알아보기

마지막으로 가장 중요한 것은 도구를 아는 것입니다. 다양한 디버거, 누출 캐더 및 사용 그래프 생성기가 있습니다. 이러한 모든 도구는 소프트웨어를 더 빠르고 효율적으로 만드는 데 도움이 될 수 있습니다.

결론

V8의 가비지 수집 및 코드 최적화 프로그램이 작동하는 방식을 이해하는 것은 애플리케이션 성능의 핵심입니다. V8은 JavaScript를 네이티브 어셈블리로 컴파일하고 경우에 따라 잘 작성된 코드는 GCC 컴파일된 애플리케이션과 비슷한 성능을 달성할 수 있습니다.

개선의 여지가 있지만 내 Toptal 클라이언트를 위한 새로운 API 애플리케이션이 매우 잘 작동하는지 궁금하신가요?

Joyent는 최근 V8의 최신 버전 중 하나를 사용하는 Node.js의 새 버전을 출시했습니다. Node.js v0.12.x용으로 작성된 일부 애플리케이션은 새로운 v4.x 릴리스와 호환되지 않을 수 있습니다. 그러나 애플리케이션은 새 버전의 Node.js 내에서 엄청난 성능과 메모리 사용 개선을 경험할 것입니다.