基於Unix Socket的可靠Node.js HTTP代理實現(支援WebSocket協議)

  • 2020 年 3 月 10 日
  • 筆記

實現代理服務,最常見的便是代理伺服器代理相應的協議體請求源站,並將響應從源站轉發給客戶端。而在本文的場景中,代理服務及源服務採用相同技術棧(Node.js),源服務是由代理服務fork出的業務服務(如下圖),代理服務不僅負責請求反向代理及轉發規則設定,同時也負責業務服務伸縮擴容、日誌輸出與相關資源監控報警。下文稱源服務為業務服務。
enter image description here

最初筆者採用上圖的架構,業務服務為真正的HTTP服務或WebSocket服務,其偵聽伺服器的某個埠並處理代理服務的轉發請求。可這有一些問題會困擾我們:

  • 業務服務需要偵聽埠,而埠是有上限的且有可能衝突(儘管可以避免衝突)
  • 代理服務轉發請求時,又在內核走了一次TCP/IP協議棧解析,且存在性能損耗(TCP的慢啟動、ack機制等可靠性保證導致傳輸性能降低)
  • 轉發策略需要與埠耦合,業務移植時存在風險

因此,筆者嘗試尋找更優的解決方案。

基於Unix Socket協議的HTTP Server

老實說,之前學習linux網路編程的時候從沒有嘗試基於域套接字的HTTP Server,不過從協議上說,HTTP協議並沒有嚴格要求傳輸層協議必須為TCP,因此如果底層採用基於位元組流的Unix Socket傳輸,應該也是可以實現要求的。

同時相比較TCP協議實現的可靠傳輸,Unix Socket作為IPC有些優點:

  • Unix Socket僅僅複製數據,並不執行協議處理,不需要添加或刪除網路報頭,無需計算校驗和,不產生順序號,也不需要發送確認報文
  • 僅依賴命名管道,不佔用埠

Unix Socket並不是一種協議,它是進程間通訊(IPC)的一種方式,解決本機的兩個進程通訊

在Node.js的http模組和net模組,都提供了相關介面 「listen(path, cb)」,不同的是http模組在Unix Socket之上封裝了HTTP的協議解析及相關規範,因此這是可以無縫兼容基於TCP實現的HTTP服務的。

下為基於Unix Socket的HTTP Server與Client 樣例:

const  http  =  require('http');  const  path  =  require('path');  const  fs  =  require('fs');  const  p  =  path.join(__dirname,'tt.sock');    fs.unlinkSync(p);  let  s  =  http.createServer((req, res)=> {  req.setEncoding('utf8')  req.on('data',(d)=>{      console.log('server get:', d)  });  res.end('helloworld!!!');  });    s.listen(p);    setTimeout(()=>{      let  c  =  http.request( {          method:  'post',          socketPath:  p,          path:  '/test'      }, (res) => {          res.setEncoding('utf8');          res.on('data', (chunk) => {              console.log(`響應主體: ${chunk}`);          });            res.on('end', () => {          });      });      c.write(JSON.stringify({abc:  '12312312312'}));      c.end();  },2000)

代理服務與業務服務進程的創建

代理服務不僅僅是代理請求,同時也負責業務服務進程的創建。在更為高級的需求下,代理服務同時也擔負業務服務進程的擴容與伸縮,當業務流量上來時,為了提高業務服務的吞吐量,代理服務需要創建更多的業務服務進程,流量洪峰消散後回收適當的進程資源。透過這個角度會發現這種需求與cluster和child_process模組息息相關,因此下文會介紹業務服務集群的具體實現。

本文中的代理為了實現具有粘性session功能的WebSocket服務,因此採用了child_process模組創建業務進程。這裡的粘性session主要指的是Socket.IO的握手報文需要始終與固定的進程進行協商,否則無法建立Socket.IO連接(此處Socket.IO連接特指Socket.IO成功運行之上的連接),具體可見我的文章 socket.io搭配pm2(cluster)集群解決方案 。不過,在fork業務進程的時候,會通過pre_hook腳本重寫子進程的 http.Server.listen() 從而實現基於Unix Socket的底層可靠傳輸,這種方式則是參考了 cluster 模組對子進程的相關處理,關於cluster模組覆寫子進程的listen,可參考我的另一篇文章 Nodejs cluster模組深入探究 的「多個子進程與埠復用」一節。

// 子進程pre_hook腳本,實現基於Unix Socket可靠傳輸的HTTP Server  function  setupEnvironment() {      process.title  =  'ProxyNodeApp: '  +  process['env']['APPNAME'];      http.Server.prototype.originalListen  =  http.Server.prototype.listen;      http.Server.prototype.listen  =  installServer;      loadApplication();  }  function  installServer() {      var  server  =  this;      var  listenTries  =  0;      doListen(server, listenTries, extractCallback(arguments));      return  server;  }    function  doListen(server, listenTries, callback) {      function  errorHandler(error) {          // error handle      }      // 生成pipe      var  socketPath  =  domainPath  =  generateServerSocketPath();      server.once('error', errorHandler);      server.originalListen(socketPath, function() {          server.removeListener('error', errorHandler);          doneListening(server, callback);          process.nextTick(finalizeStartup);      });        process.send({          type:  'path',          path:  socketPath      });  }

這樣就完成了業務服務的底層基礎設施,到了業務服務的編碼階段無需關注傳輸層的具體實現,仍然使用 http.Server.listen(${any_port})即可。此時業務服務偵聽任何埠都可以,因為在傳輸層根本沒有使用該埠,這樣就避免了系統埠的浪費。

流量轉發

流量轉發包括了HTTP請求和WebSocket握手報文,雖然WebSocket握手報文仍然是基於HTTP協議實現,但需要不同的處理,因此這裡分開來說。

HTTP流量轉發

此節可參考 「基於Unix Socket的HTTP Server與Client」的示例,在代理服務中新創建基於Unix Socket的HTTP client請求業務服務,同時將響應pipe給客戶端。

class  Client  extends  EventEmitter{      constructor(options) {          super();          options  =  options  || {};          this.originHttpSocket  =  options.originHttpSocket;          this.res  =  options.res;          this.rej  =  options.rej;          if (options.socket) {              this.socket  =  options.socket;          } else {              let  self  =  this;              this.socket  =  http.request({                  method:  self.originHttpSocket.method,                  socketPath:  options.sockPath,                  path:  self.originHttpSocket.url,                  headers:  self.originHttpSocket.headers              }, (res) => {                  self.originHttpSocket.set(res.headers);                  self.originHttpSocket.res.writeHead(res.statusCode);                  // 代理響應                  res.pipe(self.originHttpSocket.res)                  self.res();              });          }      }      send() {          // 代理請求          this.originHttpSocket.req.pipe(this.socket);      }  }  // proxy server  const  app  =  new  koa();  app.use(async  ctx  => {      await  new  Promise((res,rej) => {          // 代理請求          let  client  =  new  Client({              originHttpSocket:  ctx,              sockPath:  domainPath,              res,              rej          });          client.send();      });  });    let  server  =  app.listen(8000);

WebSocket報文處理

如果不做WebSocket報文處理,到此為止採用Socket.IO僅僅可以使用 「polling」 模式,即通過XHR輪詢的形式實現假的長連接,WebSocket連接無法建立。因此,如果為了更好性能體驗,需要處理WebSocket報文。這裡主要參考了「http-proxy」的實現,針對報文做了一些操作:

  1. 頭部協議升級欄位檢查
  2. 基於Unix Socket的協議升級代理請求

報文處理的核心在於第2點:創建一個代理服務與業務服務進程之間的「長連接」(該連接時基於Unix Socket管道的,而非TCP長連接),並使用此連接overlay的HTTP升級請求進行協議升級。

此處實現較為複雜,因此只呈現代理服務的處理,關於WebSocket報文處理的詳細過程,可參考 proxy-based-unixsocket。

// 初始化ws模組  wsHandler  =  new  WsHandler({      target: {          socketPath:  domainPath      }  }, (err, req, socket) => {      console.error(`代理wsHandler出錯`, err);  });    // 代理ws協議握手升級  server.on('upgrade',(req, socket, head) =>{      wsHandler.ws(req, socket, head);  });

回顧與總結

大家都知道,在Node.js範疇實現HTTP服務集群,應該使用cluster模組而不是「child_process」模組,這是因為採用child_process實現的HTTP服務集群會出現調度上不均勻的問題(內核為了節省上下文切換開銷做出來的「優化之舉」,詳情可參考 Nodejs cluster模組深入探究「請求分發策略」一節)。可為何在本文的實現中仍採用child_process模組呢?

答案是:場景不同。作為代理服務,它可以使用cluster模組實現代理服務的集群;而針對業務服務,在session的場景中需要由代理服實現對應的轉發策略,其他情況則採用RoundRobin策略即可,因此child_process模組更為合適。

本文並未實現代理服務的負載均衡策略,其實現仍然在 Nodejs cluster模組深入探究 中講述,因此可參閱此文。

最終,在保持進程模型穩定的前提下,變更了底層協議可實現更高性能的代理服務。
本文程式碼proxy-based-unixsocket。
enter image description here

Tags: