Node.js記憶體洩漏實用指南 – Arbaz Siddiqui

banq發表於2020-03-29

記憶體洩漏就像應用程式的寄生蟲一樣,會不經意地蔓延到您的系統中,並且最初不會造成任何危害,但是一旦洩漏足夠嚴重,它們就會對您的應用程式造成災難性問題,例如高延遲和崩潰。在本文中,我們將研究什麼是記憶體洩漏,javascript如何管理記憶體,如何在現實情況下識別洩漏以及最終如何解決它們。

記憶體洩漏可以廣義地定義為應用程式不再需要的記憶體塊,但作業系統無法將其用於進一步使用。換句話說,記憶體塊正在佔用您的應用程式,而無意在將來使用它。

記憶體管理

記憶體管理是一種將記憶體從計算機記憶體分配給應用程式,然後在不再使用時將其釋放回計算機的方法。記憶體管理有多種方式,這取決於您使用的程式語言。以下是幾種記憶體管理方式:

  • 手動記憶體管理:在這種記憶體管理模式中,程式設計師負責分配和釋放記憶體。預設情況下,該語言不會為您提供任何自動化工具。雖然它為您提供了極大的靈活性,但也增加了開銷。C並C++使用這種方法來管理記憶體,並提供類似方法malloc並free與機器記憶體協調。
  • 垃圾收集:垃圾收集的語言為您提供了開箱即用的記憶體管理功能。程式設計師不必擔心釋放記憶體,因為內建的垃圾收集器將為您完成此任務。對於開發人員來說,它的工作方式以及何時觸發釋放未使用的記憶體將是一個黑匣子。像大多數現代程式語言Javascript,JVM based languages (Java, Scala, Kotlin),Golang,Python,Ruby等都是垃圾回收的語言。
  • 所有權:在這種記憶體管理方法中,每個變數必須具有其所有者,並且一旦所有者超出範圍,該變數中的值就會被丟棄,從而釋放記憶體。Rust使用這種記憶體管理方法。

還有許多其他管理語言使用的記憶體的方法,但這超出了本文的範圍。這些方法中每種方法的優缺點和比較要求有其自己的文章。由於Web開發人員的寵愛js語言以及本文範圍內的語言是“垃圾收集”,因此我們將更深入地研究Javascript中垃圾收集的工作方式

Javascript中的垃圾收集

如上一節所述,javascript是一種垃圾收集語言,因此,名為Garbage Collector的引擎會定期執行,並檢查您的應用程式程式碼仍可以訪問分配的記憶體,即哪些變數您仍然具有引用。如果發現應用程式未引用某些記憶體,它將釋放它。上述方法有兩種主要演算法。首先 是:JS的標記和清除演算法Mark and Sweep;另外一種是Python和PHP使用的Reference counting。

Node.js記憶體洩漏實用指南 – Arbaz Siddiqui

標記和清除演算法首先建立一個根列表,這些根是環境中的全域性變數(window瀏覽器中的物件),然後從根到葉節點遍歷樹並標記它遇到的所有物件。堆中未被標記物件佔用的所有記憶體都標記為空閒。

Node應用中的記憶體洩漏

現在,我們對記憶體洩漏和垃圾回收有了足夠的瞭解,可以深入到實際應用中。在本節中,我們將編寫一個有洩漏的node伺服器,嘗試使用其他工具識別該洩漏,然後最終對其進行修復。

為了演示起見,我構建了一個其中包含洩漏路由的Express伺服器。我們將使用此API伺服器進行除錯。

const express = require('express')

const app = express();
const port = 3000;

const leaks = [];

app.get('/bloatMyServer', (req, res) => {
    const redundantObj = {
        memory: "leaked",
        joke: "meta"
    };

    [...Array(10000)].map(i => leaks.push(redundantObj));

    res.status(200).send({size: leaks.length})
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

在這裡,我們有一個leaks陣列超出了我們API的範圍,因此,每次呼叫該陣列時,它將一直將資料推送到該陣列,而無需清除它。由於它將始終被引用,因此GC將永遠不會釋放它佔用的記憶體。

網上有很多文章介紹如何除錯伺服器中的記憶體洩漏,方法是先用大炮artillery等工具多次擊中它,然後再使用進行除錯,node --inspect但是這種方法存在一個主要問題。想象一下,如果您有一個具有數百個API的API伺服器,每個API包含多個引數,這些引數會觸發不同的程式碼路徑。因此,在現實環境中,您根本不知道洩漏所在的位置,如果要脹滿記憶體以除錯洩漏,您將必須多次呼叫每個具有每種可能引數的API。對我來說,這聽起來很棘手,除非您擁有goreplay之類的工具,使您可以在測試伺服器上記錄和重放實際流量。

為了解決這個問題,我們將在生產環境中進行除錯,即,我們將允許伺服器記憶體在生產環境中膨脹(因為它將獲得各種api請求),一旦發現記憶體使用量上升,我們將開始對其進行除錯。

堆轉儲

要了解什麼是堆轉儲,我們首先需要了解什麼是堆。用最簡單的術語來說,堆是所有東西扔到那裡的地方,它一直呆在那裡,直到GC刪除了應該是垃圾的東西。堆轉儲是您當前堆的快照。它將包含堆中當前存在的所有內部和使用者定義的變數及分配。

因此,如果我們能夠以某種方式比較新伺服器的堆轉儲與執行時間較長的ated腫伺服器的堆轉儲,那麼我們應該能夠通過檢視差異來識別未被GC拾取的物件。

但是首先讓我們看一下如何進行堆轉儲。我們將使用一個npm庫heapdump,它允許我們以程式設計方式獲取伺服器的heapdump。要安裝,請執行以下操作:

npm i heapdump

我們將在express伺服器中進行一些更改以使用此軟體包。

const express = require('express');
const heapdump = require("heapdump");

const app = express();
const port = 3000;

const leaks = [];

app.get('/bloatMyServer', (req, res) => {
    const redundantObj = {
        memory: "leaked",
        joke: "meta"
    };

    [...Array(10000)].map(i => leaks.push(redundantObj));

    res.status(200).send({size: leaks.length})
});

app.get('/heapdump', (req, res) => {
    heapdump.writeSnapshot(`heapDump-${Date.now()}.heapsnapshot`, (err, filename) => {
        console.log("Heap dump of a bloated server written to", filename);

        res.status(200).send({msg: "successfully took a heap dump"})
    });
});

app.listen(port, () => {
    heapdump.writeSnapshot(`heapDumpAtServerStart.heapsnapshot`, (err, filename) => {
        console.log("Heap dump of a fresh server written to", filename);
    });
});

伺服器啟動後,我們已使用該軟體包進行堆轉儲,並在呼叫API /heapdump進行堆轉儲。當我們意識到記憶體消耗增加時,將呼叫此API。

如果您在kube叢集中執行應用程式,則將無法使用所需的高消耗Pod。為此,您可以使用埠轉發來命中群集中的該特定容器。另外,由於您將無法訪問檔案系統來下載這些大型轉儲,因此您可以將這些大型轉儲上傳到雲(s3)。

識別洩漏

因此,現在我們的伺服器已部署並且已經執行了好幾天。它受到許多請求的攻擊(在我們的例子中只有一個),並且我們注意到伺服器的記憶體消耗已經激增(可以使用Express Status MonitorClinicPrometheus等監視工具來實現)。現在,我們將進行API呼叫以進行堆轉儲。該堆轉儲將包含GC無法收集的所有物件。

curl --location --request GET 'http://localhost:3000/heapdump'

採取堆轉儲會強制GC觸發,因此我們不必擔心將來GC收集後的記憶體,而是關注當前位於堆內的記憶體分配,即非洩漏物件。進行堆轉儲是佔用大量記憶體並且會阻塞的操作,應該謹慎進行。閱讀此警告以獲取更多資訊。

一旦您掌握了兩個堆轉儲(新的和執行時間長的伺服器),我們就可以開始進行比較。

開啟chrome並按F12鍵。這將開啟chrome控制檯,轉到Memory選項卡,然後開啟Load兩個快照。

Node.js記憶體洩漏實用指南 – Arbaz Siddiqui

載入完兩個快照後,將perspective改為Comparison,然後單擊長時間執行的伺服器的快照

Node.js記憶體洩漏實用指南 – Arbaz Siddiqui我們可以通過Constructor 瀏覽GC未掃描的所有物件。它們中的大多數將是NodeJS使用的內部引用,一個巧妙的技巧是通過Alloc. Size對它們進行排序,以檢查我們擁有的最繁重的記憶體分配。如果我們進行擴充套件array,然後進行擴充套件,(object elements)我們將能夠看到leaks其中包含瘋狂數量的物件的陣列,而該陣列沒有被GC拾取。

Node.js記憶體洩漏實用指南 – Arbaz Siddiqui現在,我們可以將指向leaks陣列的原因歸結為高記憶體消耗的原因。

解決洩漏

現在我們知道陣列leaks會引起麻煩,我們可以檢視程式碼並很容易地對其進行除錯,因為陣列不在請求週期的範圍內,因此永遠不會刪除其引用。我們可以通過以下操作輕鬆修復它:

app.get('/bloatMyServer', (req, res) => {
    const redundantObj = {
        memory: "leaked",
        joke: "meta"
    };
const leaks = [];

    [...Array(10000)].map(i => leaks.push(redundantObj));

    res.status(200).send({size: leaks.length})
});

我們可以通過重複上述步驟並再次比較快照來驗證此修復程式。

結論

記憶體洩漏勢必會在垃圾收集語言(如javascript)中發生。修復記憶體洩漏很容易,儘管識別它們確實是很痛苦的。在本文中,我們瞭解了記憶體管理的基礎知識以及如何通過各種語言完成記憶體管理。我們模擬了一個真實的場景,並嘗試除錯其記憶體洩漏並最終對其進行了修復。

相關文章