이 글에서는 Node.js에서 높은 메모리 사용량을 추적하고 수정하는 접근 방식을 공유하겠습니다.
최근에 '라이브러리 x의 메모리 누수 문제 해결'이라는 제목의 티켓을 받았습니다. 설명에는 높은 메모리 사용량으로 인해 결국 OOM(메모리 부족) 오류로 인해 충돌이 발생하는 12개의 서비스를 보여주는 Datadog 대시보드가 포함되어 있으며, 모두 x 라이브러리가 공통적으로 있었습니다.
아주 최근에 코드베이스(<2주)를 소개받았는데, 이 덕분에 작업이 어렵고 공유할 가치도 생겼습니다.
저는 두 가지 정보로 작업을 시작했습니다.
아래는 티켓과 연동된 대시보드입니다.
서비스는 Kubernetes에서 실행 중이었고 서비스가 메모리 제한에 도달하고 충돌(메모리 회수)하고 다시 시작될 때까지 시간이 지남에 따라 메모리를 축적하는 것이 분명했습니다.
이 섹션에서는 높은 메모리 사용량의 원인을 식별하고 나중에 수정하는 등 당면한 작업에 어떻게 접근했는지 공유하겠습니다.
저는 코드베이스를 처음 접했기 때문에 먼저 코드, 문제의 라이브러리가 수행하는 작업 및 사용 방법을 이해하고 싶었고, 이 프로세스를 통해 문제를 더 쉽게 식별할 수 있기를 바랐습니다. 아쉽게도 적절한 문서가 없었지만 코드를 읽고 서비스가 라이브러리를 어떻게 활용하고 있는지 검색하면서 코드의 요지를 이해할 수 있었습니다. Redis 스트림을 감싸고 이벤트 생산 및 소비를 위한 편리한 인터페이스를 노출하는 라이브러리였습니다. 하루 반 동안 코드를 읽어보니 코드 구조와 복잡성(많은 클래스 상속과 익숙하지 않은 rxjs)으로 인해 모든 세부 사항과 데이터가 어떻게 흐르는지 파악하지 못했습니다.
그래서 읽기를 잠시 멈추고 코드 작동을 관찰하고 원격 측정 데이터를 수집하면서 문제를 찾아보기로 했습니다.
추가 조사에 도움이 되는 프로파일링 데이터(예: 연속 프로파일링)가 없었기 때문에 문제를 로컬에서 재현하고 메모리 프로필을 캡처하기로 결정했습니다.
Node.js에서 메모리 프로필을 캡처하는 몇 가지 방법을 찾았습니다.
어디를 봐야할지 단서가 없어서 라이브러리에서 가장 "데이터 집약적"이라고 생각되는 부분인 Redis 스트림 생산자 및 소비자를 실행하기로 결정했습니다. Redis 스트림에서 데이터를 생성하고 소비하는 두 가지 간단한 서비스를 구축하고 메모리 프로필을 캡처하고 시간이 지남에 따라 결과를 비교했습니다. 불행하게도 두 시간 동안 서비스에 로드를 생성하고 프로필을 비교한 후에도 두 서비스 모두에서 메모리 소비의 차이를 발견할 수 없었으며 모든 것이 정상적으로 보였습니다. 라이브러리는 Redis 스트림과 상호 작용하는 다양한 인터페이스와 방법을 노출하고 있었습니다. 특히 실제 서비스에 대한 도메인 관련 지식이 제한되어 있기 때문에 문제를 재현하는 것이 예상했던 것보다 더 복잡할 것이라는 것이 분명해졌습니다.
그래서 질문은 메모리 누수를 포착할 적절한 순간과 조건을 어떻게 찾을 수 있느냐는 것이었습니다.
앞서 언급했듯이 메모리 프로필을 캡처하는 가장 쉽고 편리한 방법은 영향을 받는 실제 서비스에 대해 지속적인 프로파일링을 수행하는 것인데, 나에게는 이 옵션이 없었습니다. 저는 최소한 추가 노력 없이 필요한 데이터를 캡처할 수 있도록 스테이징 서비스(이 서비스도 마찬가지로 높은 메모리 소비에 직면하고 있음)를 활용하는 방법을 조사하기 시작했습니다.
저는 Chrome DevTools를 실행 중인 Pod 중 하나에 연결하고 시간 경과에 따른 힙 스냅샷을 캡처하는 방법을 찾기 시작했습니다. 저는 스테이징에서 메모리 누수가 발생하고 있다는 것을 알고 있었기 때문에 해당 데이터를 캡처할 수 있다면 적어도 일부 핫스팟을 발견할 수 있기를 바랐습니다. 놀랍게도 그렇게 할 수 있는 방법이 있습니다.
이를 수행하는 과정
kubectl exec -it <nodejs-pod-name> -- kill -SIGUSR1 <node-process-id> </p> <p><em>Signal Events에서 Node.js 신호에 대해 자세히 알아보세요</em></p> <p>성공하면 서비스 로그가 표시됩니다.<br> </p> <pre class="brush:php;toolbar:false">Debugger listening on ws://127.0.0.1:9229/.... For help, see: https://nodejs.org/en/docs/inspector
kubectl port-forward <nodejs-pod-name> 9229
그렇지 않다면 대상 검색 설정이 제대로 설정되었는지 확인하세요
이제 초과 시간 동안 스냅샷 캡처를 시작하고(기간은 메모리 누수가 발생하는 데 필요한 시간에 따라 다름) 비교할 수 있습니다. Chrome DevTools는 이를 수행하는 매우 편리한 방법을 제공합니다.
기록 힙 스냅샷에서 메모리 스냅샷 및 Chrome 개발자 도구에 대한 자세한 내용을 확인할 수 있습니다
스냅샷을 생성하면 메인 스레드의 다른 모든 작업이 중지됩니다. 힙 내용에 따라 1분 이상 걸릴 수도 있습니다. 스냅샷은 메모리에 내장되어 있으므로 힙 크기가 두 배로 늘어나 전체 메모리가 가득 차고 앱이 충돌할 수 있습니다.
프로덕션에서 힙 스냅샷을 찍으려면 애플리케이션 가용성에 영향을 주지 않고 스냅샷을 찍는 프로세스가 중단될 수 있는지 확인하세요.
Node.js 문서에서
제 사례로 돌아가서 델타를 기준으로 비교하고 정렬하기 위해 두 개의 스냅샷을 선택하면 아래에서 볼 수 있는 결과를 얻을 수 있습니다.
가장 큰 긍정적인 델타가 문자열 생성자에서 발생한 것을 볼 수 있습니다. 이는 서비스가 두 스냅샷 사이에 많은 문자열을 생성했지만 여전히 사용 중임을 의미합니다. 이제 문제는 해당 항목이 어디서 생성되었으며 누가 참조하는지였습니다. 캡처한 스냅샷에 리테이너라는 정보도 포함되어 있어서 다행입니다.
스냅샷과 결코 줄어들지 않는 문자열 목록을 파헤치는 동안 ID와 유사한 문자열 패턴을 발견했습니다. 이를 클릭하면 이를 참조하는 체인 개체, 즉 리테이너(Retainer)를 볼 수 있습니다. 라이브러리 코드에서 알아볼 수 있는 클래스 이름의 sentEvents라는 배열이었습니다. 짜잔, 범인이 생겼습니다. 이 시점에서 제가 추측했던 ID 목록은 절대 공개되지 않을 것입니다. 초과근무로 여러 장의 스냅샷을 촬영했는데, 이곳이 큰 양의 델타를 지닌 핫스팟으로 계속해서 다시 나타나는 단일 장소였습니다.
이 정보를 통해 코드 전체를 이해하려고 하기보다는 배열의 목적, 언제 채워지고 지워지는지에 집중해야 했습니다. 코드가 항목을 배열로 푸시하는 단일 위치와 코드가 항목을 팝업하는 또 다른 위치가 있어 수정 범위가 좁아졌습니다.
배열이 비어 있어야 할 때 비어 있지 않았다고 가정하는 것이 안전합니다. 코드의 세부 사항을 건너뛰면 기본적으로 다음과 같은 일이 발생합니다.
이게 어디로 가는지 알 수 있나요? ? 서비스가 이벤트 생성을 위해서만 라이브러리를 사용하는 경우 sentEvents는 여전히 모든 이벤트로 채워지지만 이를 지우기 위한 코드 경로(소비자)는 없습니다.
생산자, 소비자 모드에서만 이벤트를 추적하도록 코드를 패치하고 스테이징에 배포했습니다. 스테이징 로드에도 불구하고 패치가 높은 메모리 사용량을 줄이는 데 도움이 되었으며 어떤 회귀도 발생하지 않았다는 것이 분명했습니다.
패치가 프로덕션에 배포되었을 때 메모리 사용량이 대폭 감소하고 서비스 안정성이 향상되었습니다(OOM이 더 이상 없음).
좋은 부작용은 동일한 트래픽을 처리하는 데 필요한 Pod 수가 50% 감소했다는 것입니다.
이것은 Node.js의 메모리 문제를 추적하고 사용 가능한 도구에 더욱 익숙해지는 것과 관련하여 저에게 훌륭한 학습 기회였습니다.
각 도구에 대해 자세히 설명하려면 별도의 게시물을 작성해야 하므로 자세한 내용을 다루지 않는 것이 최선이라고 생각했습니다. 하지만 이것이 이 주제에 대해 더 자세히 알아보고 싶거나 유사한 문제에 직면하고 있는 모든 사람에게 좋은 시작점이 되기를 바랍니다.
위 내용은 Node.js의 높은 메모리 사용량 추적의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!