通過(guò)Node.js的Cluster模塊源碼,深入PM2原理
Node.js無(wú)疑是走向大前端、全棧工程師技術(shù)棧最快的捷徑(但是一定要會(huì)一門(mén)其他后臺(tái)語(yǔ)言,推薦Golang),雖然Node.js做很多事情都做不好,但是在某些方面還是有它的優(yōu)勢(shì)。
眾所周知,Node.js中的JavaScript代碼執(zhí)行在單線程中,非常脆弱,一旦出現(xiàn)了未捕獲的異常,那么整個(gè)應(yīng)用就會(huì)崩潰。
這在許多場(chǎng)景下,尤其是web應(yīng)用中,是無(wú)法忍受的。通常的解決方案,便是使用Node.js中自帶的cluster模塊,以master-worker模式啟動(dòng)多個(gè)應(yīng)用實(shí)例。然而大家在享受cluster模塊帶來(lái)的福祉的同時(shí),不少人也開(kāi)始好奇
1.為什么我的應(yīng)用代碼中明明有app.listen(port);,但cluter模塊在多次fork這份代碼時(shí),卻沒(méi)有報(bào)端口已被占用?
2.Master是如何將接收的請(qǐng)求傳遞至worker中進(jìn)行處理然后響應(yīng)的?
帶著這些疑問(wèn)我們開(kāi)始往下看
TIPS:
本文編寫(xiě)于2019年12月8日,是最新版本的Node.js源碼
Cluster源碼解析:
- 入口 :
- const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master';
- module.exports = require(`internal/cluster/${childOrMaster}`);
- 分析
會(huì)根據(jù)一個(gè)當(dāng)前的Node_UNIQUE_ID(后面會(huì)講)是否在環(huán)境變量中判斷是子進(jìn)程還是主進(jìn)程,然后引用不同的js代碼
NODE_UNIQUE_ID是一個(gè)唯一標(biāo)示,Node.js的Cluster多進(jìn)程模式,采用默認(rèn)的調(diào)度算法是round-robin,其實(shí)就是輪詢(xún).官方解釋是實(shí)踐效率非常高,穩(wěn)定
之前的問(wèn)題一: 為什么我的應(yīng)用代碼中明明有app.listen(port);,但cluter模塊在多次fork這份代碼時(shí),卻沒(méi)有報(bào)端口已被占用?
我在Node.js的官網(wǎng)找到了答案:
原來(lái)所有的net.Socket都被設(shè)置了SO_REUSEADDR
這個(gè)SO_REUSEADDR到底是什么呢?
為什么需要 SO_REUSEADDR 參數(shù)?
服務(wù)端主動(dòng)斷開(kāi)連接以后,需要等 2 個(gè) MSL 以后才最終釋放這個(gè)連接,重啟以后要綁定同一個(gè)端口,默認(rèn)情況下,操作系統(tǒng)的實(shí)現(xiàn)都會(huì)阻止新的監(jiān)聽(tīng)套接字綁定到這個(gè)端口上。
我們都知道 TCP 連接由四元組唯一確定。形式如下
- {local-ip-address:local-port , foreign-ip-address:foreign-port}
一個(gè)典型的例子如下圖
TCP 要求這樣的四元組必須是唯一的,但大多數(shù)操作系統(tǒng)的實(shí)現(xiàn)要求更加嚴(yán)格,只要還有連接在使用這個(gè)本地端口,則本地端口不能被重用(bind 調(diào)用失?。?/strong>
啟用 SO_REUSEADDR 套接字選項(xiàng)可以解除這個(gè)限制,默認(rèn)情況下這個(gè)值都為 0,表示關(guān)閉。在 Java 中,reuseAddress 不同的 JVM 有不同的實(shí)現(xiàn),在我本機(jī)上,這個(gè)值默認(rèn)為 1 允許端口重用。但是為了保險(xiǎn)起見(jiàn),寫(xiě) TCP、HTTP 服務(wù)一定要主動(dòng)設(shè)置這個(gè)參數(shù)為 1。
目前常見(jiàn)的網(wǎng)絡(luò)編程模型就是多進(jìn)程或多線程,根據(jù)accpet的位置,分為如下場(chǎng)景
2種場(chǎng)景
(1) 單進(jìn)程或線程創(chuàng)建socket,并進(jìn)行l(wèi)isten和accept,接收到連接后創(chuàng)建進(jìn)程和線程處理連接
(2) 單進(jìn)程或線程創(chuàng)建socket,并進(jìn)行l(wèi)isten,預(yù)先創(chuàng)建好多個(gè)工作進(jìn)程或線程accept()在同一個(gè)服務(wù)器套接字
這兩種模型解充分發(fā)揮了多核CPU的優(yōu)勢(shì),雖然可以做到線程和CPU核綁定,但都會(huì)存在:
1.單一listener工作進(jìn)程或線程在高速的連接接入處理時(shí)會(huì)成為瓶頸
2.多個(gè)線程之間競(jìng)爭(zhēng)獲取服務(wù)套接字
3.緩存行跳躍
4.很難做到CPU之間的負(fù)載均衡
5.隨著核數(shù)的擴(kuò)展,性能并沒(méi)有隨著提升
6.SO_REUSEPORT解決了什么問(wèn)題
7.SO_REUSEPORT支持多個(gè)進(jìn)程或者線程綁定到同一端口,提高服務(wù)器程序的性能
解決的問(wèn)題:
1.允許多個(gè)套接字 bind()/listen() 同一個(gè)TCP/UDP端口
2.每一個(gè)線程擁有自己的服務(wù)器套接字
3.在服務(wù)器套接字上沒(méi)有了鎖的競(jìng)爭(zhēng)
4.內(nèi)核層面實(shí)現(xiàn)負(fù)載均衡
5.安全層面,監(jiān)聽(tīng)同一個(gè)端口的套接字只能位于同一個(gè)用戶(hù)下面
其核心的實(shí)現(xiàn)主要有三點(diǎn):
1.擴(kuò)展 socket option,增加 SO_REUSEPORT 選項(xiàng),用來(lái)設(shè)置 reuseport
2.修改 bind 系統(tǒng)調(diào)用實(shí)現(xiàn),以便支持可以綁定到相同的 IP 和端口
3.修改處理新建連接的實(shí)現(xiàn),查找 listener 的時(shí)候,能夠支持在監(jiān)聽(tīng)相同 IP 4.和端口的多個(gè) sock 之間均衡選擇。
5.有了SO_RESUEPORT后,每個(gè)進(jìn)程可以自己創(chuàng)建socket、bind、listen、accept相同的地址和端口,各自是獨(dú)立平等的
讓多進(jìn)程監(jiān)聽(tīng)同一個(gè)端口,各個(gè)進(jìn)程中accept socket fd不一樣,有新連接建立時(shí),內(nèi)核只會(huì)喚醒一個(gè)進(jìn)程來(lái)accept,并且保證喚醒的均衡性。
總結(jié):原來(lái)端口被復(fù)用是因?yàn)樵O(shè)置了SO_REUSEADDR,當(dāng)然不止這一點(diǎn),下面會(huì)繼續(xù)描述
回到源碼第一行
NODE_UNIQUE_ID是什么?
下面給出介紹:
- function createWorkerProcess(id, env) {
- // ...
- workerEnv.NODE_UNIQUE_ID = '' + id;
- // ...
- return fork(cluster.settings.exec, cluster.settings.args, {
- env: workerEnv,
- silent: cluster.settings.silent,
- execArgv: execArgv,
- gid: cluster.settings.gid,
- uid: cluster.settings.uid
- });
- }
原來(lái),創(chuàng)建子進(jìn)程的時(shí)候,給了每個(gè)進(jìn)程一個(gè)唯一的自增標(biāo)示ID
隨后Node.js在初始化時(shí),會(huì)根據(jù)該環(huán)境變量,來(lái)判斷該進(jìn)程是否為cluster模塊fork出的工作進(jìn)程,若是,則執(zhí)行workerInit()函數(shù)來(lái)初始化環(huán)境,否則執(zhí)行masterInit()函數(shù)
就是這行入口的代碼~
- module.exports = require(`internal/cluster/${childOrMaster}`);
接下來(lái)我們需要看一下net模塊的listen函數(shù)源碼:
- // lib/net.js
- // ...
- function listen(self, address, port, addressType, backlog, fd, exclusive) {
- exclusive = !!exclusive;
- if (!cluster) cluster = require('cluster');
- if (cluster.isMaster || exclusive) {
- self._listen2(address, port, addressType, backlog, fd);
- return;
- }
- cluster._getServer(self, {
- address: address,
- port: port,
- addressType: addressType,
- fd: fd,
- flags: 0
- }, cb);
- function cb(err, handle) {
- // ...
- self._handle = handle;
- self._listen2(address, port, addressType, backlog, fd);
- }
- }
仔細(xì)一看,原來(lái)listen函數(shù)會(huì)根據(jù)是不是主進(jìn)程做不同的操作!
上面有提到SO_REUSEADDR選項(xiàng),在主進(jìn)程調(diào)用的_listen2中就有設(shè)置。
子進(jìn)程初始化的每個(gè)workerinit函數(shù)中,也有cluster._getServer這個(gè)方法,
你可能已經(jīng)猜到,問(wèn)題一的答案,就在這個(gè)cluster._getServer函數(shù)的代碼中。它主要干了兩件事:
- 向master進(jìn)程注冊(cè)該worker,若master進(jìn)程是第一次接收到監(jiān)聽(tīng)此端口/描述符下的worker,則起一個(gè)內(nèi)部TCP服務(wù)器,來(lái)承擔(dān)監(jiān)聽(tīng)該端口/描述符的職責(zé),隨后在master中記錄下該worker。
- Hack掉worker進(jìn)程中的net.Server實(shí)例的listen方法里監(jiān)聽(tīng)端口/描述符的部分,使其不再承擔(dān)該職責(zé)。
對(duì)于第一件事,由于master在接收,傳遞請(qǐng)求給worker時(shí),會(huì)符合一定的負(fù)載均衡規(guī)則(在非Windows平臺(tái)下默認(rèn)為輪詢(xún)),這些邏輯被封裝在RoundRobinHandle類(lèi)中。故,初始化內(nèi)部TCP服務(wù)器等操作也在此處:
- // lib/cluster.js
- // ...
- function RoundRobinHandle(key, address, port, addressType, backlog, fd) {
- // ...
- this.handles = [];
- this.handle = null;
- this.server = net.createServer(assert.fail);
- if (fd >= 0)
- this.server.listen({ fd: fd });
- else if (port >= 0)
- this.server.listen(port, address);
- else
- this.server.listen(address); // UNIX socket path.
- /// ...
- }
在子進(jìn)程中:
- function listen(backlog) {
- return 0;
- }
- function close() {
- // ...
- }
- function ref() {}
- function unref() {}
- var handle = {
- close: close,
- listen: listen,
- ref: ref,
- unref: unref,
- }
由于net.Server實(shí)例的listen方法,最終會(huì)調(diào)用自身_handle屬性下listen方法來(lái)完成監(jiān)聽(tīng)動(dòng)作,故在代碼中修改之:此時(shí)的listen方法已經(jīng)被hack ,每次調(diào)用只能發(fā)揮return 0 ,并不會(huì)監(jiān)聽(tīng)端口
- // lib/net.js
- // ...
- function listen(self, address, port, addressType, backlog, fd, exclusive) {
- // ...
- if (cluster.isMaster || exclusive) {
- self._listen2(address, port, addressType, backlog, fd);
- return; // 僅在worker環(huán)境下改變
- }
- cluster._getServer(self, {
- address: address,
- port: port,
- addressType: addressType,
- fd: fd,
- flags: 0
- }, cb);
- function cb(err, handle) {
- // ...
- self._handle = handle;
- // ...
- }
- }
這里可以看到,傳入的回調(diào)函數(shù)中的handle,已經(jīng)把listen方法重新定義,返回0,那么等子進(jìn)程調(diào)用listen方法時(shí)候,也是返回0,并不會(huì)去監(jiān)聽(tīng)端口,至此,煥然大悟,原來(lái)是這樣,真正監(jiān)聽(tīng)端口的始終只有主進(jìn)程!
上面通過(guò)將近3000字講解,把端口復(fù)用這個(gè)問(wèn)題講清楚了,下面把負(fù)載均衡這塊也講清楚。然后再講PM2的原理實(shí)現(xiàn),其實(shí)不過(guò)是對(duì)cluster模式進(jìn)行了封裝,多了很多功能而已~
首先畫(huà)了一個(gè)流程圖
核心實(shí)現(xiàn)源碼:
- function RoundRobinHandle(key, address, port, addressType, backlog, fd) {
- // ...
- this.server = net.createServer(assert.fail);
- // ...
- var self = this;
- this.server.once('listening', function() {
- // ...
- selfself.handle.onconnection = self.distribute.bind(self);
- });
- }
- RoundRobinHandle.prototype.distribute = function(err, handle) {
- this.handles.push(handle);
- var worker = this.free.shift();
- if (worker) this.handoff(worker);
- };
- RoundRobinHandle.prototype.handoff = function(worker) {
- // ...
- var message = { act: 'newconn', key: this.key };
- var self = this;
- sendHelper(worker.process, message, handle, function(reply) {
- // ...
- });
解析
定義好handle對(duì)象中的onconnection方法
觸發(fā)事件時(shí),取出一個(gè)子進(jìn)程通知,傳入句柄
子進(jìn)程接受到消息和句柄后,做相應(yīng)的業(yè)務(wù)處理:
- var accepted = server !== undefined;
- // ...
- if (accepted) server.onconnection(0, handle);// lib/cluster.js
- // ...
- // 該方法會(huì)在Node.js初始化時(shí)由 src/node.js 調(diào)用
- cluster._setupWorker = function() {
- // ...
- process.on('internalMessage', internal(worker, onmessage));
- // ...
- function onmessage(message, handle) {
- if (message.act === 'newconn')
- onconnection(message, handle);
- // ...
- }
- };
- function onconnection(message, handle) {
- // ...
- }
總結(jié)下來(lái),負(fù)載均衡大概流程:
1.所有請(qǐng)求先同一經(jīng)過(guò)內(nèi)部TCP服務(wù)器,真正監(jiān)聽(tīng)端口的只有主進(jìn)程。
2.在內(nèi)部TCP服務(wù)器的請(qǐng)求處理邏輯中,有負(fù)載均衡地挑選出一個(gè)worker進(jìn)程,將其發(fā)送一個(gè)newconn內(nèi)部消息,隨消息發(fā)送客戶(hù)端句柄。
3.Worker進(jìn)程接收到此內(nèi)部消息,根據(jù)客戶(hù)端句柄創(chuàng)建net.Socket實(shí)例,執(zhí)行具體業(yè)務(wù)邏輯,返回。
至此,Cluster多進(jìn)程模式,負(fù)載均衡講解完畢,下面講PM2的實(shí)現(xiàn)原理,它是基于Cluster模式的封裝
PM2的使用:
- npm i pm2 -g
- pm2 start app.js
- pm2 ls
這樣就可以啟動(dòng)你的Node.js服務(wù),并且根據(jù)你的電腦CPU個(gè)數(shù)去啟動(dòng)相應(yīng)的進(jìn)程數(shù),監(jiān)聽(tīng)到錯(cuò)誤事件,自帶重啟子進(jìn)程,即使更新了代碼,需要熱更新,也會(huì)逐個(gè)替換,號(hào)稱(chēng)永動(dòng)機(jī)。
它的功能:
1.內(nèi)建負(fù)載均衡(使用Node cluster 集群模塊)
2.后臺(tái)運(yùn)行
3.0秒停機(jī)重載,我理解大概意思是維護(hù)升級(jí)的時(shí)候不需要停機(jī).
4.具有Ubuntu和CentOS 的啟動(dòng)腳本
5.停止不穩(wěn)定的進(jìn)程(避免無(wú)限循環(huán))
6.控制臺(tái)檢測(cè)
7.提供 HTTP API
8.遠(yuǎn)程控制和實(shí)時(shí)的接口API ( Nodejs 模塊,允許和PM2進(jìn)程管理器交互 )
先來(lái)一張PM2的架構(gòu)圖:
pm2包括 Satan進(jìn)程、God Deamon守護(hù)進(jìn)程、進(jìn)程間的遠(yuǎn)程調(diào)用rpc、cluster等幾個(gè)概念
如果不知道點(diǎn)西方文化,還真搞不清他的文件名為啥是 Satan 和 God:
撒旦(Satan),主要指《圣經(jīng)》中的墮天使(也稱(chēng)墮天使撒旦),被看作與上帝的力量相對(duì)的邪惡、黑暗之源,是God的對(duì)立面。
1.Satan.js提供了程序的退出、殺死等方法,因此它是魔鬼;God.js 負(fù)責(zé)維護(hù)進(jìn)程的正常運(yùn)行,當(dāng)有異常退出時(shí)能保證重啟,所以它是上帝。作者這么命名,我只能說(shuō)一句:oh my god。
God進(jìn)程啟動(dòng)后一直運(yùn)行,它相當(dāng)于cluster中的Master進(jìn)程,守護(hù)者worker進(jìn)程的正常運(yùn)行。
2.rpc(Remote Procedure Call Protocol)是指遠(yuǎn)程過(guò)程調(diào)用,也就是說(shuō)兩臺(tái)服務(wù)器A,B,一個(gè)應(yīng)用部署在A服務(wù)器上,想要調(diào)用B服務(wù)器上應(yīng)用提供的函數(shù)/方法,由于不在一個(gè)內(nèi)存空間,不能直接調(diào)用,需要通過(guò)網(wǎng)絡(luò)來(lái)表達(dá)調(diào)用的語(yǔ)義和傳達(dá)調(diào)用的數(shù)據(jù)。同一機(jī)器不同進(jìn)程間的方法調(diào)用也屬于rpc的作用范疇。
3.代碼中采用了axon-rpc 和 axon 兩個(gè)庫(kù),基本原理是提供服務(wù)的server綁定到一個(gè)域名和端口下,調(diào)用服務(wù)的client連接端口實(shí)現(xiàn)rpc連接。后續(xù)新版本采用了pm2-axon-rpc 和 pm2-axon兩個(gè)庫(kù),綁定的方法也由端口變成.sock文件,因?yàn)椴捎胮ort可能會(huì)和現(xiàn)有進(jìn)程的端口產(chǎn)生沖突。
執(zhí)行流程
程序的執(zhí)行流程圖如下:
每次命令行的輸入都會(huì)執(zhí)行一次satan程序。如果God進(jìn)程不在運(yùn)行,首先需要啟動(dòng)God進(jìn)程。然后根據(jù)指令,satan通過(guò)rpc調(diào)用God中對(duì)應(yīng)的方法執(zhí)行相應(yīng)的邏輯。
以 pm2 start app.js -i 4為例,God在初次執(zhí)行時(shí)會(huì)配置cluster,同時(shí)監(jiān)聽(tīng)cluster中的事件:
- // 配置cluster
- cluster.setupMaster({
- exec : path.resolve(path.dirname(module.filename), 'ProcessContainer.js')
- });
- // 監(jiān)聽(tīng)cluster事件
- (function initEngine() {
- cluster.on('online', function(clu) {
- // worker進(jìn)程在執(zhí)行
- God.clusters_db[clu.pm_id].status = 'online';
- });
- // 命令行中 kill pid 會(huì)觸發(fā)exit事件,process.kill不會(huì)觸發(fā)exit
- cluster.on('exit', function(clu, code, signal) {
- // 重啟進(jìn)程 如果重啟次數(shù)過(guò)于頻繁直接標(biāo)注為stopped
- God.clusters_db[clu.pm_id].status = 'starting';
- // 邏輯
- ...
- });
- })();
在God啟動(dòng)后, 會(huì)建立Satan和God的rpc鏈接,然后調(diào)用prepare方法。prepare方法會(huì)調(diào)用cluster.fork,完成集群的啟動(dòng)
- God.prepare = function(opts, cb) {
- ...
- return execute(opts, cb);
- };
- function execute(env, cb) {
- ...
- var clu = cluster.fork(env);
- ...
- God.clusters_db[id] = clu;
- clu.once('online', function() {
- God.clusters_db[id].status = 'online';
- if (cb) return cb(null, clu);
- return true;
- });
- return clu;
- }
PM2的功能目前已經(jīng)特別多了,源碼閱讀非常耗時(shí),但是可以猜測(cè)到一些功能的實(shí)現(xiàn):
例如
如何檢測(cè)子進(jìn)程是否處于正?;钴S狀態(tài)?
采用心跳檢測(cè)
- 每隔數(shù)秒向子進(jìn)程發(fā)送心跳包,子進(jìn)程如果不回復(fù),那么調(diào)用kill殺死這個(gè)進(jìn)程
- 然后再重新cluster.fork()一個(gè)新的進(jìn)程
子進(jìn)程發(fā)出異常報(bào)錯(cuò),如何保證一直有一定數(shù)量子進(jìn)程?
- 子進(jìn)程可以監(jiān)聽(tīng)到錯(cuò)誤事件,這時(shí)候可以發(fā)送消息給主進(jìn)程,請(qǐng)求殺死自己
- 并且主進(jìn)程此時(shí)重新調(diào)用cluster.fork一個(gè)新的子進(jìn)程
目前不少Node.js的服務(wù),依賴(lài)Nginx+pm2+docker來(lái)實(shí)現(xiàn)自動(dòng)化+監(jiān)控部署,
pm2本身也是有監(jiān)聽(tīng)系統(tǒng)的,分免費(fèi)版和收費(fèi)版~
具體可以看官網(wǎng),以及搜索一些操作手冊(cè)等進(jìn)行監(jiān)控操作,配置起來(lái)比較簡(jiǎn)單,
這里就不做概述了。
- https://pm2.keymetrics.io/
如果感覺(jué)寫(xiě)得不錯(cuò),麻煩幫忙點(diǎn)個(gè)贊然后分享給你身邊多人,原創(chuàng)不易,需要支持~!