如何建立一個可靠穩定的Web伺服器

光光同學發表於2018-12-10

延續上篇文章騷年,Koa和Webpack瞭解一下?

本篇文章主要講述的是如何通過Node建立一個穩定的web伺服器,如果你看到這裡想起了pm2等工具,那麼你可以先拋棄pm2,進來看看,如果有哪些不合適的地方,懇請您指出。

建立一個穩定的web伺服器需要解決什麼問題。

  • 如何利用多核CPU資源。
  • 多個工作程式的存活狀態管理。
  • 工作程式的平滑重啟。
  • 程式錯誤處理。
  • 工作程式限量重啟。

如何利用多核CPU資源

利用多核CPU資源有多種解決辦法。

  • 通過在單機上部署多個Node服務,然後監聽不同埠,通過一臺Nginx負載均衡。

    這種做法一般用於多臺機器,在伺服器叢集時,採用這種做法,這裡我們不採用。

  • 通過單機啟動一個master程式,然後fork多個子程式,master程式傳送控制程式碼給子程式後,關閉監聽埠,讓子程式來處理請求。

    這種做法也是Node單機叢集普遍的做法。

所幸的是,Node在v0.8版本新增的cluster模組,讓我們不必使用child_process一步一步的去處理Node叢集這麼多細節。

所以本篇文章講述的是基於cluster模組解決上述的問題。

首先建立一個Web伺服器,Node端採用的是Koa框架。沒有使用過的可以先去看下 ===> 傳送門

下面的程式碼是建立一個基本的web服務需要的配置,看過上篇文章的可以先直接過濾這塊程式碼,直接看後面。

const Koa = require('koa');
const app = new Koa();
const koaNunjucks = require('koa-nunjucks-2');
const koaStatic = require('koa-static');
const KoaRouter = require('koa-router');
const router = new KoaRouter();
const path = require('path');
const colors = require('colors');
const compress = require('koa-compress');
const AngelLogger = require('../angel-logger')
const cluster = require('cluster');
const http = require('http');

class AngelConfig {
  constructor(options) {
    this.config = require(options.configUrl);
    this.app = app;
    this.router = require(options.routerUrl);
    this.setDefaultConfig(); 
    this.setServerConfig();
    
  }

  setDefaultConfig() {
    //靜態檔案根目錄
    this.config.root = this.config.root ? this.config.root : path.join(process.cwd(), 'app/static');
    //預設靜態配置
    this.config.static = this.config.static ? this.config.static : {};
  }

  setServerConfig() {
    this.port = this.config.listen.port;

    //cookie簽名驗證
    this.app.keys = this.config.keys ? this.config.keys : this.app.keys;

  }
}

//啟動伺服器
class AngelServer extends AngelConfig {
  constructor(options) {
    super(options);
    this.startService();
  }

  startService() {
    //開啟gzip壓縮
    this.app.use(compress(this.config.compress));

      //模板語法
    this.app.use(koaNunjucks({
      ext: 'html',
      path: path.join(process.cwd(), 'app/views'),
      nunjucksConfig: {
        trimBlocks: true
      }
    }));
    this.app.use(async (ctx, next) => {
      ctx.logger = new AngelLogger().logger;
      await next();
    })
  
    //訪問日誌
    this.app.use(async (ctx, next) => {
      await next();
      // console.log(ctx.logger,'loggerloggerlogger');
      const rt = ctx.response.get('X-Response-Time');
      ctx.logger.info(`angel ${ctx.method}`.green,` ${ctx.url} - `,`${rt}`.green);
    });
    
    // 響應時間
    this.app.use(async (ctx, next) => {
      const start = Date.now();
      await next();
      const ms = Date.now() - start;
      ctx.set('X-Response-Time', `${ms}ms`);
    });

    this.app.use(router.routes())
      .use(router.allowedMethods());

    // 靜態資源
    this.app.use(koaStatic(this.config.root, this.config.static));
  
    // 啟動伺服器
    this.server = this.app.listen(this.port, () => {
      console.log(`當前伺服器已經啟動,請訪問`,`http://127.0.0.1:${this.port}`.green);
      this.router({
        router,
        config: this.config,
        app: this.app
      });
    });
  }
}

module.exports = AngelServer;

複製程式碼

在啟動伺服器之後,將this.app.listen賦值給this.server,後面會用到。

一般我們做單機叢集時,我們fork的程式數量是機器的CPU數量。當然更多也不限定,只是一般不推薦。

const cluster = require('cluster');
const { cpus } = require('os'); 
const AngelServer = require('../server/index.js');
const path = require('path');
let cpusNum = cpus().length;

//超時
let timeout = null;

//重啟次數
let limit = 10;
// 時間
let during = 60000;
let restart = [];

//master程式
if(cluster.isMaster) {
  //fork多個工作程式
  for(let i = 0; i < cpusNum; i++) {
    creatServer();
  }

} else {
  //worker程式
  let angelServer = new AngelServer({
    routerUrl: path.join(process.cwd(), 'app/router.js'),//路由地址
    configUrl: path.join(process.cwd(), 'config/config.default.js')  
    //預設讀取config/config.default.js
  });
}

// master.js
//建立服務程式  
function creatServer() {
  let worker = cluster.fork();
  console.log(`工作程式已經重啟pid: ${worker.process.pid}`);
}

複製程式碼

使用程式的方式,其實就是通過cluster.isMastercluster.isWorker來進行判斷的。

主從程式程式碼寫在一塊可能也不太好理解。這種寫法也是Node官方的寫法,當然也有更加清晰的寫法,藉助cluster.setupMaster實現,這裡不去詳細解釋。

通過Node執行程式碼,看看究竟發生了什麼。

如何建立一個可靠穩定的Web伺服器

首先判斷cluster.isMaster是否存在,然後迴圈呼叫createServer(),fork4個工作程式。列印工作程式pid

cluster啟動時,它會在內部啟動TCP服務,在cluster.fork()子程式時,將這個TCP服務端socket的檔案描述符傳送給工作程式。如果工作程式中存在listen()監聽網路埠的呼叫,它將拿到該檔案的檔案描述符,通過SO_REUSEADDR埠重用,從而實現多個子程式共享埠。

程式管理、平滑重啟、和錯誤處理。

一般來說,master程式比較穩定,工作程式並不是太穩定。

因為工作程式處理的是業務邏輯,因此,我們需要給工作程式新增自動重啟的功能,也就是如果子程式因為業務中不可控的原因報錯了,而且阻塞了,此時,我們應該停止該程式接收任何請求,然後優雅的關閉該工作程式。

//超時
let timeout = null;

//重啟次數
let limit = 10;
// 時間
let during = 60000;
let restart = [];

if(cluster.isMaster) {
  //fork多個工作程式
  for(let i = 0; i < cpusNum; i++) {
    creatServer();
  }

} else {
  //worker
  let angelServer = new AngelServer({
    routerUrl: path.join(process.cwd(), 'app/router.js'),//路由地址
    configUrl: path.join(process.cwd(), 'config/config.default.js') //預設讀取config/config.default.js
  });

  //伺服器優雅退出
  angelServer.app.on('error', err => {
    //傳送一個自殺訊號
    process.send({ act: 'suicide' });
    cluster.worker.disconnect();
    angelServer.server.close(() => {
      //所有已有連線斷開後,退出程式
      process.exit(1);
    });
    //5秒後退出程式
    timeout = setTimeout(() => {
      process.exit(1);
    },5000);
  });
}

// master.js
//建立服務程式  
function creatServer() {

  let worker = cluster.fork();
  console.log(`工作程式已經重啟pid: ${worker.process.pid}`);
  //監聽message事件,監聽自殺訊號,如果有子程式傳送自殺訊號,則立即重啟程式。
  //平滑重啟 重啟在前,自殺在後。
  worker.on('message', (msg) => {
    //msg為自殺訊號,則重啟程式
    if(msg.act == 'suicide') {
      creatServer();
    }
  });

  //清理定時器。
  worker.on('disconnect', () => {
    clearTimeout(timeout);
  });

}

複製程式碼

我們在例項化AngelServer後,得到angelServer,通過拿到angelServer.app拿到Koa的例項,從而監聽Koa的error事件。

當監聽到錯誤發生時,傳送一個自殺訊號process.send({ act: 'suicide' })。 呼叫cluster.worker.disconnect()方法,呼叫此方法會關閉所有的server,並等待這些server的 'close'事件執行,然後關閉IPC管道。

呼叫angelServer.server.close()方法,當所有連線都關閉後,通往該工作程式的IPC管道將會關閉,允許工作程式優雅地死掉。

如果5s的時間還沒有退出程式,此時,5s後將強制關閉該程式。

Koa的app.listenhttp.createServer(app.callback()).listen();的語法糖,因此可以呼叫close方法。

worker監聽message,如果是該訊號,此時先重啟新的程式。 同時監聽disconnect事件,清理定時器。

正常來說,我們應該監聽processuncaughtException事件,如果 Javascript 未捕獲的異常,沿著程式碼呼叫路徑反向傳遞迴事件迴圈,會觸發 'uncaughtException' 事件。

但是Koa已經在middleware外邊加了tryCatch。因此在uncaughtException捕獲不到。

在這裡,還得特別感謝下大深海老哥,深夜裡,在群裡給我指點迷津。

限量重啟

通過自殺訊號告知主程式可以使新連線總是有程式服務,但是依然還是有極端的情況。 工作程式不能無限制的被頻繁重啟。

因此在單位時間規定只能重啟多少次,超過限制就觸發giveup事件。

//檢查啟動次數是否太過頻繁,超過一定次數,重新啟動。
function isRestartNum() {

  //記錄重啟的時間
  let time = Date.now();
  let length = restart.push(time);
  if(length > limit) {
    //取出最後10個
    restart = restart.slice(limit * -1);
  }
  //1分鐘重啟的次數是否太過頻繁
  return restart.length >= limit && restart[restart.length - 1] - restart[0] < during;
}

複製程式碼

同時將createServer修改成

// master.js
//建立服務程式  
function creatServer() {
  //檢查啟動是否太過頻繁
  if(isRestartNum()) {
    process.emit('giveup', length, during);
    return;
  }
  let worker = cluster.fork();
  console.log(`工作程式已經重啟pid: ${worker.process.pid}`);
  //監聽message事件,監聽自殺訊號,如果有子程式傳送自殺訊號,則立即重啟程式。
  //平滑重啟 重啟在前,自殺在後。
  worker.on('message', (msg) => {
    //msg為自殺訊號,則重啟程式
    if(msg.act == 'suicide') {
      creatServer();
    }
  });
  //清理定時器。
  worker.on('disconnect', () => {
    clearTimeout(timeout);
  });

}

複製程式碼

更改負載均衡策略

預設的是作業系統搶佔式,就是在一堆工作程式中,閒著的程式對到來的請求進行爭搶,誰搶到誰服務。

對於是否繁忙是由CPU和I/O決定的,但是影響搶佔的是CPU。

對於不同的業務,會有的I/O繁忙,但CPU空閒的情況,這時會造成負載不均衡的情況。
因此我們使用node的另一種策略,名為輪叫制度。

cluster.schedulingPolicy = cluster.SCHED_RR;
複製程式碼

最後

當然建立一個穩定的web服務還需要注意很多地方,比如優化處理程式之間的通訊,資料共享等等。

本片文章只是給大家一個參考,如果有哪些地方寫的不合適的地方,懇請您指出。

完整程式碼請見Github

參考資料:深入淺出nodejs

相關文章