Node.js 程序、執行緒除錯和診斷的設計和實現

語言: CN / TW / HK

前言:本文介紹 Node.js 中,關於程序、執行緒除錯和診斷的相關內容。程序和執行緒的方案類似,但是也有一些不一樣的地方,本文將會分開介紹,另外本文介紹的是對業務程式碼無侵入的方案,通過命令列開啟 Inspector 埠或者在程式碼裡通過 Inspector 模組開啟埠在很多場景下並不適用,我們需要的是一種動態控制的能力。

1. 背景

隨著前端的快速發展,Node.js 在業務中的使用場景也越來越多,如何保證 Node.js 服務的穩定也逐漸成為一個非常重要事情,傳統的伺服器架構大多數基於多程序、多執行緒的,任務的執行是隔離的,一個任務出現問題通常不會影響其他任務,比如在一個請求中執行一個死迴圈,伺服器還能處理其他的請求。

但是 Node.js 不一樣,從整體來看,Node.js 是單執行緒的,單個任務出現問題有可能會影響其他任務,比如在一個請求中執行了死迴圈,那麼整個服務就沒法繼續工作了。所以在 Node.js 中,我們更加需要方便的除錯和診斷工具,以便遇到問題時可以快速找到問題,解決問題,另外,工具不僅可以幫我們排查問題,還可以找出我們服務中的效能瓶頸,方便我們進行效能優化。

2. 目標

我們基於 Node.js 本身提供的除錯和診斷能力,提供一個除錯和診斷平臺,使用方只需要引入 SDK,然後通過除錯和診斷平臺就可以對服務的程序和執行緒進行除錯和診斷。

3. 實現

目前支援了多程序和多執行緒的除錯和診斷,下面按照程序和執行緒兩個方面介紹一下原理和具體實現。

3.1. 單程序

3.1.1 除錯和診斷基礎

在 Node.js 中,可以通過以下方式收集程序的資料。

const inspector = require('inspector');
const session = new inspector.Session();
session.connect();
// 傳送命令
session.post('Profiler.enable', () => {});

使用方式很簡單,通過新建一個和 V8 Inspector 通訊的 Session 就可以對程序進行資料的收集,比如抓取程序的堆快照和 Profile 資料。有了這個基礎後,我們就可以封裝這個能力。

const http = require('http');
const inspector = require('inspector');
const fs = require('fs');

// 開啟一個和 V8 Inspector 的會話
const session = new inspector.Session();
session.connect();

function getCpuprofile(req, res) {
// 向V8 Inspector 提交命令,開啟 CPU Profile 並收集資料
session.post('Profiler.enable', () = >{
session.post('Profiler.start', () = >{
// 收集一段時間後提交停止收集命令
setTimeout(() = >{
session.post('Profiler.stop', (err, { profile }) = >{
// 把資料寫入檔案
if (!err && profile) {
fs.writeFileSync('./profile.cpuprofile', JSON.stringify(profile));
}
// 回覆客戶端
res.end('ok');
});
},
3000);
})
});
}

http.createServer((req, res) = >{
if (req.url == '/debug/getCpuprofile') {
getCpuprofile(req, res);
} else {
res.end('ok');
}
}).listen(80);

但是這種方式不能除錯程序,除錯程序需要使用另外的 API,可以通過以下方式啟動除錯程序的服務。

const inspector = require('inspector');
inspector.open();
console.log(inspector.url());

這時候 Node.js 程序中就會啟動一個 WebSocket Server,我們可以通過 Chrome Dev Tools 連上這個 Server 進行除錯,我們看看如何封裝。

const inspector = require('inspector');
const http = require('http');
let isOpend = false;

function getHTML() {
return `<html>
<meta charset="utf-8" />
<body>
複製到新 Tab 開啟該 URL 開始除錯 devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=${inspector.url().replace("ws://", '')}
</body>
</html>`
;
}

http.createServer((req, res) = >{
if (req.url == '/debug/open') {
// 還沒開啟則開啟
if (!isOpend) {
isOpend = true;
// 開啟偵錯程式
inspector.open();
}
// 返回給前端的內容
const html = getHTML();
res.end(html);
} else if (req.url == '/debug/close') {
// 如果開啟了則關閉
if (isOpend) {
inspector.close();
isOpend = false;
}
res.end('ok');
} else {
res.end('ok');
}

}).listen(80);

我們以 API 的方式對外提供動態控制程序除錯和診斷的能力,具體的實現可以根據場景去修改,比如給前端返回一個不帶 Inspector 埠的 URL,前端再通過 URL 訪問服務,服務代理請求 Websocket 請求到 Inspector 對應的 WebSocket 服務。比如把收集的資料上傳到雲上,給前端返回一個 URL。

3.1.2 具體實現

我們通過 API 的方式提供功能,設計上採用外掛化的思想,主框架負責接收請求和路由處理,具體的邏輯交給具體的外掛去做,結構如下所示。

資料收集的實現和上面的例子中類似,收到請求路由到對應的外掛,外掛通過 Session 和 V8 Inspector 通訊完成資料的收集。除錯的實現就稍微複雜些,主要的原因是我們不能把埠返回給前端,讓前端直接連線該埠。這個不是因為安全問題,因為除錯的 URL 是一個帶有一個複雜隨機值的字串,就算埠暴露了,攻擊者也很難猜對隨機值,相比來說,通過提供 API 的方式更加不安全,因為只要知道服務的地址,就可以通過 API 去除錯程序了,所以嚴格來說,這裡還需要加一些校驗機制。言歸正傳,不暴露埠的原因是通常前端無法直接連線到這個埠,原因可能有很多,比如我們的服務執行在容器中,容器只對外暴露有限的埠,我們不能期待在程序中隨便起一個埠,在前端就可以直接訪問,但是有一個可以肯定的是,服務至少會對外提供一個埠,那就意味著我們可以通過某個對外的埠把非業務相關的請求傳遞到程序內,基於上面的情況,當我們開啟 Inspector 埠時,我們只會告訴前端開啟成功或者失敗,當前端通過除錯 API 訪問伺服器時,我們會判斷埠是否已經開啟,是的話代理請求到 WebSocket Server。結構設計如下:

大致實現如下:

const client = connect(WebSocket Server地址);
client.on('connect', () = >{
// 轉發協議升級的 HTTP 請求給 WebSocket Server
client.write(`GET ${req.path} HTTP/1.1\r\n` + buildHeaders(req.headers) + '\r\n');
// 透傳
socket.pipe(client);
client.pipe(socket);
});

收到客戶的的請求後,首先連線到 WebSocket Server,然後透傳客戶端的請求,接著通過管道讓 WebSocket Server 和客戶端通訊就行。

3.2 多程序

為了利用多核,Node.js 服務通常會啟動多個程序,所以支援多程序的除錯和診斷也是非常必要的。但是單程序的除錯診斷方案無法通過橫行拓展來支援多程序的場景。

3.2.1 單程序方案的限制

前面提到的方式看起來工作得不錯,但是如果服務是單例項上多程序部署,就會存在一些限制。我們來看看這時候的結構:

假如我們只有一個對外埠:

  1. 基於 Node.js Cluster 模組的多程序管理機制,多個程序監聽同一個埠是沒問題的,但是請求的分發上會存在問題,比如請求 1 被分發到程序 1,打開了程序 1 的 Inspector 埠,接著請求 2 想關閉這個埠,但是請求被分發到了程序 2,但是程序 2 並沒有開啟 Inspector 埠。

  2. 基於 child_process 的 fork 建立多程序,則在重複監聽埠時會報錯,導致只有一個程序可以使用提供的功能。

3.2.1 Agent 程序

一種解決方案是每個程序監聽不同的埠,這樣又回到了前面討論到問題,但是這種方案也不是完全不可行,只需要基於這個方案做一下改進,那就是引入 Agent 程序,這時候結構如下:

Agent 程序負責收集和管理工作程序的資訊(如 pid、監聽地址),並接管所有除錯和診斷相關的請求,收到請求後根據引數進行請求分發。具體流程如下:

  1. Agent 啟動一個伺服器。

  2. 子程序啟動後,把自己的 pid 和監聽的隨機服務地址註冊到 Agent。

  3. 客戶端通過 Agent 獲取程序的 pid 列表,並選擇需要操作的程序。

  4. Agent 收到客戶的請求,根據入參中的 pid 把請求傳送給對應的子程序。

  5. 子程序處理完畢後返回給 Agent,Agent返回給客戶端。

3.2.2 如何建立 Agent 程序

確定了 Agent 程序的方案後,如何建立 Agent 程序成為一個需要解決的問題。在 Node.js 裡啟動多個伺服器的方式是通過 Cluster 或者直接通過 child_process 模組 fork 出多個子程序,Node.js 框架/工具通常都會封裝這些邏輯,但是框架不一定會提供建立 Agent 程序的方式。為了通用,我們不能假設執行在某種框架/工具中,所以我們只能尋找一種獨立於框架/工具的方案。我們在每個 Worker 程序裡都建立一個 Agent 程序,然後多個 Agent 程序競爭監聽一個埠,監聽成功的程序繼續執行,監聽失敗的退出,最終剩下一個 Agent 程序。

3.3 多執行緒

執行緒和程序的除錯、診斷類似,下面主要講一下不一樣的地方。

3.3.1 除錯和診斷基礎

可以通過以下方式收集執行緒的資料。

const { Worker, workerData } = require('worker_threads');
const { Session } = require('inspector');

const session = new Session();
session.connect();
let id = 1;

// 給子執行緒傳送訊息
function post(sessionId, method, params, callback) {
session.post('NodeWorker.sendMessageToWorker', {
sessionId,
message: JSON.stringify({
id: id++,
method,
params
})
},
callback);
}

// 子執行緒連線上 V8 Inspector 後觸發
session.on('NodeWorker.attachedToWorker', (data) = >{
post(data.params.sessionId, 'Profiler.enable');
post(data.params.sessionId, 'Profiler.start');
// 收集一段時間後提交停止收集命令
setTimeout(() = >{
post(data.params.sessionId, 'Profiler.stop');
},
10000)
});

// 收到子執行緒訊息時觸發
session.on('NodeWorker.receivedMessageFromWorker', ({ params: { message } }) = >{});

const worker = new Worker('./httpServer.js', { workerData: { port: 80 } });

worker.on('online', () = >{
session.post("NodeWorker.enable", { waitForDebuggerOnStart: false }, (err) = >{
console.log(err, "NodeWorker.enable");
});
});

setInterval(() = >{},100000);

類似通過 Agent 程序管理多個 Worker 程序一樣,因為一個程序中可能存在多個執行緒,所以需要對多個執行緒進行管理。首先通過 NodeWorker.enable 命令開啟子執行緒的 Inspector 能力,然後通過 NodeWorker.attachedToWorker 事件拿到執行緒對應的 sessionId,後續通過 sessionId 和執行緒進行通訊。接著看一下除錯的實現:

const { Worker, workerData } = require('worker_threads');
const { Session } = require('inspector');

const session = new Session();
session.connect();
let workerSessionId;
let id = 1;

function post(method, params) {
session.post('NodeWorker.sendMessageToWorker', {
sessionId: workerSessionId,
message: JSON.stringify({
id: id++,
method,
params
})
});
}

session.on('NodeWorker.receivedMessageFromWorker', ({ params: { message } }) = >{
const data = JSON.parse(message);
console.log(data);
});

session.on('NodeWorker.attachedToWorker', (data) = >{
workerSessionId = data.params.sessionId;
post("Runtime.evaluate", {
includeCommandLineAPI: true,
expression: `const inspector = process.binding('inspector');
inspector.open();
inspector.url();
`

});
});

const worker = new Worker('./httpServer.js', { workerData: { port: 80 } });
worker.on('online', () = >{
session.post("NodeWorker.enable", { waitForDebuggerOnStart: false }, (err) = >{
err && console.log("NodeWorker.enable", err);
});
});

setInterval(() = >{}, 100000);

執行緒的除錯主要利用 Runtime.evaluate 在子執行緒裡動態執行程式碼來開啟子執行緒的 Inspector 埠。瞭解了基礎使用後,我們看一下具體實現。

3.3.2 具體實現

首先我們提供一個 API 獲取執行緒列表,這樣我們後續就可以選擇操作某個執行緒,後續的每個請求都需要帶上 執行緒對應的 id,這裡以獲取 Profile 為例講一下處理過程。

const {
sessionId,
interval = INTERVAL,
duration = DURATION
} = req.query;
// 向V8 Inspector 提交命令,開啟 CPU Profile 並收集資料
this.post(sessionId, { method: 'Profiler.enable' }, (err) = >{
this.post(sessionId, {
method: 'Profiler.setSamplingInterval',
params: { interval }
});
this.post(sessionId, { method: 'Profiler.start' }, (err) = >{
// 收集一段時間後提交停止收集命令
setTimeout(() = >{
this.post(sessionId, { method: 'Profiler.stop' }, (err, { profile }) => {});
}, duration);
});
})

我們看到每一個操作都需要 sessionId。通過 sessionId,我們把請求轉發到對應的執行緒。但是和程序不一樣,程序傳送一個請求時傳入一個回撥,請求成功後就會執行對應的回撥,我們不需要儲存請求上下文,Node.js 會幫我們處理,但是執行緒不一樣,存在一個巢狀的過程,因為 Inspector 命令的執行模式是一個請求命令對應一個回撥,但是和執行緒通訊時,是首選通過 NodeWorker.sendMessageToWorker 命令和主執行緒通訊,主執行緒會解析出 NodeWorker.sendMessageToWorker 的引數,引數裡包含了給子執行緒傳送的命令,接著主執行緒通過 sessionId 把請求轉發到子執行緒,然後這時候 NodeWorker.sendMessageToWorker 就會返回並執行對應的回撥,這時候意味著 NodeWorker.sendMessageToWorker 執行結束了,但是我們請求子執行緒的命令還沒有完成,也就是說我們需要自己維護請求子執行緒對應的回撥。我們看看 post 的具體實現:

post(sessionId, message, callback ? ) {
// 請求對應的 id
const requestId = ++this.id;
this.session.post('NodeWorker.sendMessageToWorker', {
sessionId,
message: JSON.stringify({ ...message, id: requestId })
},
(err) = >{
/*
回撥說明 NodeWorker.sendMessageToWorker 請求完成
err非空說明請求失敗,直接執行回撥
err為空說明請求成功,記錄 post 呼叫方的請求回撥,通過 id 關聯
*/
if (typeof callback === 'function') {
// 傳送失敗則直接執行回撥,成功則記錄回撥
if (err) {
callback(err);
} else {
this.sessionMap[sessionId]['requests'][requestId] = callback;
}
}
});
}

我們看到在 NodeWorker.sendMessageToWorker 回撥裡儲存了請求子執行緒的回撥。接下來我們看一下執行緒執行完命令後的回撥。

this.session.on('NodeWorker.receivedMessageFromWorker', ({
params: {
sessionId,
message
}
}) = >{
const ctx = this.sessionMap[sessionId];
try {
const data = JSON.parse(message);
/**
* data 的內容格式如下:
* {
* method: string,
* params: Object
* }
* 或者
* {
* id: number,
* result: { result: Object }
* }
*/

const {
id,
method,
result
} = data;
// 有 id 說明是請求對應的響應,沒有 id 說明是 Inspector 非同步觸發的事件
if (id) {
if (typeof ctx.requests[id] === 'function') {
const fn = ctx.requests[id];
delete ctx.requests[id];
fn(null, result);
}
} else {
ctx.emit(method, data);
}
} catch(e) {
console.warn(e);
}
});

通過 NodeWorker.receivedMessageFromWorker 事件可以接收到執行緒返回的請求結果,從響應的資料中我們可以知道這個響應來自的執行緒和請求 id,根據這些資訊我們就可以從維護的上下文中找到對應的回撥(某些請求在收到響應前會觸發一些事件,這種情況下響應裡是沒有請求 id 的)。

接著看一下如何除錯子執行緒,除錯埠預設是 9229,因為存在多執行緒,如果我們要同時除錯多個執行緒的話,則會失敗,所以我們要允許前端來控制開啟的埠,接著給子執行緒傳送一個命令。

this.post(query.sessionId, {
method: "Runtime.evaluate",
params: {
includeCommandLineAPI: true,
expression: `let inspector;
try {
inspector = require('inspector');
inspector.open(${port}, ${host});
} catch(e) {
inspector = process.binding('inspector');
inspector.open(${port}, ${host});
}
inspector.url();`

}
},
(err, result) = >{

});

我們通過在子執行緒裡動態執行程式碼來開啟 Inspector 埠,這裡需要處理一下不同 Node.js 版本的相容問題,高版本(比如 16)中增加了一個判斷邏輯,如果存在 session 就無法動態開啟 Inspector 埠了,比如以下程式碼在 16 中會報錯(換一下 connect 和 open 的位置就可以執行)。

const inspector = require('inspector');
const session = new inspector.Session();
session.connect();
inspector.open()

這裡需要繞過 JS 層的判斷,通過 C++ 模組提供的介面直接開啟 Inspector 埠,這樣就可以保證任何時候我們都可以動態開啟 Inspector 埠。最後通過  inspector.url() 讓子執行緒返回除錯的 URL 並儲存到上下文中,和程序一樣,前端也是通過 API 的方式連線子執行緒的 WebSocket Server。最後形成的結構如下。

4. 使用方式

目前支援了多個子程序和多個執行緒的除錯、獲取 CPU Profile、獲取 Heap Profile、獲取 Heap Snapshot、獲取記憶體資訊(RSS、堆外記憶體、ArrayBuffer等資訊)能力。使用方首先在業務程式碼里加載 SDK,部署服務後,進入除錯診斷平臺頁面,按照以下步驟操作:

  1. 選擇除錯程序還是執行緒

  2. 輸入服務地址(Agent 程序監聽的地址)和選擇對應的操作型別,如果是收集資料則還需要輸入收集的持續時間。

  3. 獲取程序列表,並從中選擇你想操作的程序,每個選項 hover 時會提示程序對應的資訊,比如檔案路徑。

  4. 如果操作執行緒的話,在選擇程序後,還需要獲取該程序下的執行緒列表,並選擇你想操作的執行緒。

  5. 點選執行就可以獲得你想收集的資料或者線上除錯的 URL。

程序:

執行緒:

5. 總結

程序、執行緒的除錯和診斷在 Node.js 中的實現非常複雜,瞭解了 Node.js 的實現和使用方式後,具體應用到業務裡也不容易,主要是要考慮到不同的業務場景,需要設計出通用的方案,另外除錯是一個比較有用但是也比較危險的操作,在安全方面也需要多多考慮。除錯、診斷和安全一樣,平時用不上,但是有問題的時候,能幫助我們更好地解決問題。

更多內容參考:

  1. 深入理解 Node.js 的 Inspector:https://mp.weixin.qq.com/s/GLIlhURSrCYQ-8Bqg7i1kA

  2. Node.js子執行緒除錯和診斷指南:https://zhuanlan.zhihu.com/p/402855448

- END -