Node.js 應用故障排查手冊 —— 雪崩型內存泄漏問題

摘要: 還有一些問題場景下下應用的內存泄漏非常嚴重和迅速,甚至於在我們的告警系統感知之前就已經造成應用的 OOM 了,這時我們來不及或者說根本沒辦法獲取到堆快照,因此就沒有辦法藉助於之前的辦法來分析爲什麼進程會內存泄漏到溢出進而 Crash 的原因了。

楔子

實踐篇一中我們也看到了一個比較典型的由於開發者不當使用第三方庫,而且在配置信息中攜帶了三方庫本身使用不到的信息,導致了內存泄漏的案例,實際上類似這種相對緩慢的 Node.js 應用內存泄漏問題我們總是可以在合適的機會抓取堆快照進行分析,而且堆快照一般來說確實是分析內存泄漏問題的最佳手段。

但是還有一些問題場景下下應用的內存泄漏非常嚴重和迅速,甚至於在我們的告警系統感知之前就已經造成應用的 OOM 了,這時我們來不及或者說根本沒辦法獲取到堆快照,因此就沒有辦法藉助於之前的辦法來分析爲什麼進程會內存泄漏到溢出進而 Crash 的原因了。這種問題場景實際上屬於線上 Node.js 應用內存問題的一個極端狀況,本節將同樣從源自真實生產的一個案例來來給大家講解下如何處理這類極端內存異常。

本書首發在 Github,倉庫地址:https://github.com/aliyun-node/Node.js-Troubleshooting-Guide,雲棲社區會同步更新。

最小化復現代碼

同樣我們因爲例子的特殊性,我們需要首先給出到大家生產案例的最小化復現代碼,建議讀者自行運行一番此代碼,這樣結合起來看下面的排查分析過程會更有收穫。最小復現代碼還是基於 Egg.js,如下所示:

'use strict';
const Controller = require('egg').Controller;
const fs = require('fs');
const path = require('path');
const util = require('util');
const readFile = util.promisify(fs.readFile);

class DatabaseError extends Error {
  constructor(message, stack, sql) {
    super();
    this.name = 'SequelizeDatabaseError';
    this.message = message;
    this.stack = stack;
    this.sql = sql;
  }
}

class MemoryController extends Controller {
  async oom() {
    const { ctx } = this;
    let bigErrorMessage = await readFile(path.join(__dirname, 'resource/error.txt'));
    bigErrorMessage = bigErrorMessage.toString();
    const error = new DatabaseError(bigErrorMessage, bigErrorMessage, bigErrorMessage);
    ctx.logger.error(error);
    ctx.body = { ok: false };
  }
}

module.exports = MemoryController;

這裏我們還需要在 app/controller/ 目錄下創建一個 resource 文件夾,並且在這個文件夾中添加一個 error.txt,這個 TXT 內容隨意,只要是一個能超過 100M 的很大的字符串即可。 

值得注意的是,其實這裏的問題已經在 egg-logger >= 1.7.1 的版本中修復了,所以要復現當時的狀況,你還需要在 Demo 的根目錄執行以下的三條命令以恢復當時的版本狀況:

rm -rf package-lock.json 
rm -rf node_modules/egg/egg-logger
npm install [email protected]

最後使用 npm run dev 啓動這個問題最小化復現的 Demo。

感知進程出現問題

這個案例下,實際上我們的線上 Node.js 應用幾乎是觸發了這個 Bug 後瞬間內存溢出然後 Crash 的,而平臺預設的內存閾值告警,實際上是由一個定時上報的邏輯構成,因此存在延時,也導致了這個案例下我們無法像 冗餘配置傳遞引發的內存溢出 問題那樣獲取到 Node.js 進程級別的內存超過預設閾值的告警。

那麼我們如何來感知到這裏的錯誤的呢?這裏我們的服務器配置過了 ulimit -c unlimited ,因此 Node.js 應用 Crash 的時候內核會自動生成核心轉儲文件,而且性能平臺目前也支持核心轉儲文件的生成預警,這一條規則目前也被放入了預設的快速添加告警規則中,可以參考工具篇中 Node.js 性能平臺使用指南 - 配置合適的告警 一節,詳細的規則內容如下所示:

這裏需要注意的是,核心轉儲文件告警需要我們在服務器上安裝的 Agenthub/Agentx 依賴的 Commandx 模塊的版本在 1.5.2 之上(包含),這一塊更詳細的信息也可以看官方文檔 核心轉儲分析能力 一節。

問題排查過程

I. 分析棧信息

依靠上面提到的平臺提供的核心轉儲文件生成時給出的告警,我們在收到報警短信時登錄控制檯,可以看到 Coredump 文件列表出現了新生成的核心轉儲文件,繼續參照工具篇中 Node.js 性能平臺使用指南 - 核心轉儲分析 中給出的轉儲和 AliNode 定製分析的過程,我們可以看到如下的分析結果展示信息:

同樣我們直接展開 JavaScript 棧信息查看應用 Crash 那一刻的棧信息:

截圖中忽略掉了 Native C/C++ 代碼的棧信息,這裏其實僅僅看 JavaScript 棧信息就能得到結論了,通過翻閱比對出問題的 [email protected] 中 lib/utils.js 的代碼內容:

function formatError(err) {
  // ...
  // 這裏對 Error 對象的 key 和 value 調用 inspect 方法進行序列化
  const errProperties = Object.keys(err).map(key => inspect(key, err[key])).join('\n');
  // ...
}

// inspect 方法實際上是調用 require('util').inspect 來對錯誤對象的 value 進行序列化
function inspect(key, value) {
  return `${key}: ${util.inspect(value, { breakLength: Infinity })}`;
}

這樣我們就知道了線上 Node.js 應用在 Crash 的那一刻正在使用 require('util').inspect 對某個字符串進行序列化操作。

II. 可疑字符串

那麼這個序列化的動作究竟是不是造成進程 Crash 的元兇呢?我們接着來點擊 inspect 函數的參數來展開查看這個可疑的字符串的詳細信息,如下圖所示:

點擊紅框中的參數,得到字符串的詳情頁面鏈接,如下圖所示:

再次點擊這裏的 detail 鏈接,既可在彈出的新頁面中看到這個可疑字符串的全部信息:

這裏可以看到,這個正在被 util.inspect 的字符串大小高達 186.94 兆,顯然正是在序列化這麼大的字符串的時候,造成了線上 Node.js 應用的堆內存雪崩,幾乎在瞬間就內存溢出導致 Crash。

值得一提的是,我們還可以點擊上圖中的 + 號來在當前頁面展示更多的問題字符串內容:

也可以在頁面上點擊 一鍵導出 按鈕下載問題完整的字符串:

畢竟對於這樣的問題來說,如果能抓到產生問題的元兇參數,起碼能更方便地進行本地復現。

III. 修復問題

那麼知道了原因,其實修復此問題就比較簡單了,Egg-logger 官方是使用 circular-json 來替換掉原生的 util.inspect 序列化動作,並且增加序列化後的字符串最大隻保留 10000 個字符的限制,這樣就解決這種包含大字符串的錯誤對象在 Egg-logger 模塊中的序列化問題。

結尾

本節的給大家展現了對於線上 Node.js 應用出現瞬間 Crash 問題時的排查思路,而在最小化復現 Demo 對應的那個真實線上故障中,實際上是拼接的 SQL 語句非常大,大小約爲 120M,因此首先導致數據庫操作失敗,接着數據庫操作失敗後輸出的 DatabaseError 對象實例上則原封不動地將問題 SQL 語句設置到屬性上,從而導致了 ctx.logger.error(error) 時堆內存的雪崩。

在 Node.js 性能平臺 提供的 核心轉儲告警 + 在線分析能力 的幫助下,此類無法獲取到常規 CPU Profile 和堆快照等信息的進程無故崩潰問題也變得有跡可循了,實際上它作爲一種兜底分析手段,在很大程度上提升了開發者真正將 Node.js 運用到服務端生產環境中的信心。



本文作者:奕鈞

閱讀原文

本文爲雲棲社區原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章