高質量 Node.js 微服務的編寫和部署

前幾天在微信羣作的一次分享,整理出來分享給你們,相關代碼請戳 https://github.com/Carrotzpc/docker_web_appjavascript

node.js+docker

微服務架構是一種構造應用程序的替代性方法。應用程序被分解爲更小、徹底獨立的組件,這使得它們擁有更高的敏捷性、可伸縮性和可用性。一個複雜的應用被拆分爲若干微服務,微服務更須要一種成熟的交付能力。持續集成、部署和全自動測試都必不可少。編寫代碼的開發人員必須負責代碼的生產部署。構建和部署鏈須要重大更改,以便爲微服務環境提供正確的關注點分離。後續咱們會聊一下如何在時速雲平臺上集成 DevOps。html

microservice

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient. Node.js' package ecosystem, npm, is the largest ecosystem of open source libraries in the world. --- https://nodejs.orgjava

Node.js 是構建微服務的利器,爲何這麼說呢,咱們先看下 Node.js 有哪些優點:node

  1. Node.js 採用事件驅動、異步編程,爲網絡服務而設計python

  2. Node.js 非阻塞模式的IO處理給 Node.js 帶來在相對低系統資源耗用下的高性能與出衆的負載能力,很是適合用做依賴其它IO資源的中間層服務linux

  3. Node.js輕量高效,能夠認爲是數據密集型分佈式部署環境下的實時應用系統的完美解決方案。git

這些優點正好與微服務的優點:敏捷性、可伸縮性和可用性相契合(捂臉笑),再看下 Node.js 的缺點:github

  1. 單進程,單線程,只支持單核CPU,不能充分的利用多核CPU服務器。一旦這個進程 down 了,那麼整個 web 服務就 down 了web

  2. 異步編程,callback 回調地獄docker

第一個缺點能夠經過啓動多個實例來實現CPU充分利用以及負載均衡,話說這不是 K8s 的原生功能嗎。第二個缺點更不是事兒,如今能夠經過 generatorpromise等來寫同步代碼,爽的不要不要的。

下面咱們主要從 Docker 和 Node.js 出發聊一下高質量Node.js微服務的編寫和部署:

  1. Node.js 異步流程控制:generator 與 promise

  2. Express、Koa 的異常處理

  3. 如何編寫 Dockerfile

  4. 微服務部署及 DevOps 集成

1. Node.js 異步流程控制:Generator 與 Promise

Node.js 的設計初衷爲了性能而異步,如今已經能夠寫同步的代碼了,你造嗎?目前 Node.js 的LTS版本早就支持了Generator, Promise這兩個特性,也有許多優秀的第三方庫 bluebird、q 這樣的模塊支持的也很是好,性能甚至比原生的還好,能夠用 bluebird 替換 Node.js 原生的 Promise:

global.Promise = require('bluebird')

blurbird 的性能是 V8 裏內置的 Promise 3 倍左右(bluebird 的優化方式見 https://github.com/petkaanton... )。

1.1 ES2015 Generator

Generators are functions which can be exited and later re-entered. Their context (variable bindings) will be saved across re-entrances. --- https://developer.mozilla.org...*

generator 就像一個取號機,你能夠經過取一張票來向機器請求一個號碼。你接收了你的號碼,可是機器不會自動爲你提供下一個。換句話說,取票機「暫停」直到有人請求另外一個號碼(next()),此時它纔會向後運行。下面咱們看一個簡單的示例:

function* idMaker(){
  var index = 0
  while(index < 3)
    yield index++
}

var gen = idMaker()

gen.next() // {value: 0, done: false}
gen.next() // {value: 1, done: false}
gen.next() // {value: 2, done: false}
gen.next() // {value: undefined, done: true}
// ...

generator_sample

從上面的代碼的輸出能夠看出:

  1. generator 函數的定義,是經過 function *(){} 實現的

  2. 對 generator 函數的調用返回的實際是一個遍歷器,隨後代碼經過使用遍歷器的 next() 方法來得到函數的輸出

  3. 經過使用yield語句來中斷 generator 函數的運行,而且能夠返回一箇中間結果

  4. 每次調用next()方法,generator 函數將執行到下一個yield語句或者是return語句。

下面咱們就對上面代碼的每次next調用進行一個詳細的解釋:

  1. 第1次調用next()方法的時候,函數執行到第一次循環的yield index++語句停了下來,而且返回了0這個value,隨同value返回的done屬性代表 generator 函數的運行尚未結束

  2. 第2次調用next()方法的時候,函數執行到第二循環的yield index++語句停了下來,而且返回了1這個value,隨同value返回的done屬性代表 generator 函數的運行尚未結束

  3. ... ...

  4. 第4次調用next()方法的時候,因爲循環已經結束了,因此函數調用當即返回,done屬性代表 generator 函數已經結束運行,valueundefined的,由於此次調用並無執行任何語句

PS:若是在 generator 函數內部須要調用另一個 generator 函數,那麼對目標函數的調用就須要使用yield*

1.2 ES2015 Promise

The Promise object is used for asynchronous computations. A Promise represents an operation that hasn't completed yet, but is expected in the future. --- https://developer.mozilla.org...

所謂 Promise,就是一個對象,用來傳遞異步操做的消息。它表明了某個將來纔會知道結果的事件(一般是一個異步操做),而且這個事件提供統一的 API,可供進一步處理。

promise

一個 Promise 通常有3種狀態:

  1. pending: 初始狀態,不是fulfilled,也不是rejected.

  2. fulfilled: 操做成功完成.

  3. rejected: 操做失敗.

一個 Promise 的生命週期以下圖:

promises_full

下面咱們看一段具體代碼:

function asyncFunction() {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve('Async Hello world')
    }, 16)
  })
}

asyncFunction().then(function (value) {
  console.log(value)  // => 'Async Hello world'
}).catch(function (error) {
  console.log(error)
})

asyncFunction 這個函數會返回 Promise 對象, 對於這個 Promise 對象,咱們調用它的then 方法來設置resolve後的回調函數,catch方法來設置發生錯誤時的回調函數。

該 Promise 對象會在setTimeout以後的16ms時被resolve, 這時then的回調函數會被調用,並輸出 'Async Hello world'。

在這種狀況下catch的回調函數並不會被執行(由於 Promise 返回了resolve), 不過若是運行環境沒有提供 setTimeout 函數的話,那麼上面代碼在執行中就會產生異常,在 catch 中設置的回調函數就會被執行。

promises_reject

小結

若是是編寫一個 SDK 或 API,推薦使用傳統的 callback 或者 Promise,不使用 generator 的緣由是:

  • generator 的出現不是爲了解決異步問題

  • 使用 generator 是會傳染的,當你嘗試yield一下的時候,它要求你也必須在一個 generator function 內

《如何用 Node.js 編寫一個 API 客戶端》@leizongmin)

由此看來學習 Promise 是水到渠成的事情。

2. Express、Koa 的異常處理

exception_handle

一個友好的錯誤處理機制應該知足三個條件:

  1. 對於引起異常的用戶,返回 500 頁面

  2. 其餘用戶不受影響,能夠正常訪問

  3. 不影響整個進程的正常運行

下面咱們就以這三個條件爲原則,具體介紹下 Express、Koa 中的異常處理:

2.1 Express 異常處理

在 Express 中有一個內置的錯誤處理中間件,這個中間件會處理任何遇到的錯誤。若是你在 Express 中傳遞了一個錯誤給next(),而沒有本身定義的錯誤處理函數處理這個錯誤,這個錯誤就會被 Express 默認的錯誤處理函數捕獲並處理,並且會把錯誤的堆棧信息返回到客戶端,這樣的錯誤處理是很是不友好的,還好咱們能夠經過設置NODE_ENV環境變量爲production,這樣 Express 就會在生產環境模式下運行應用,生產環境模式下 Express 不會把錯誤的堆棧信息返回到客戶端。

在 Express 項目中能夠定義一個錯誤處理的中間件用來替換 Express 默認的錯誤處理函數:

app.use(errorHandler)
function errorHandler(err, req, res, next) {
  if (res.headersSent) {
    return next(err)
  }
  res.status(500)
  switch(req.accepts(['html', 'json'])) {
    case 'html':
      res.render('error', { error: err })
      break
    default:
      res.send('500 Internal Server Error')
  }
}

在全部其餘app.use()以及路由以後引入以上代碼,能夠知足以上三個友好錯誤處理條件,是一種很是友好的錯誤處理機制。

2.2 Koa 異常處理

咱們以Koa 1.x爲例,看代碼:

app.use(function *(next) {
  try {
    yield next
  } catch (err) {
    this.status = err.status || 500
    this.body = err
    this.app.emit('error', err, this)
  }
})

把上面的代碼放在全部app.use()函數前面,這樣基本上全部的同步錯誤均會被 try{} catch(err){} 捕獲到了,具體原理你們能夠了解下 Koa 中間件的機制。

2.3 未捕獲的異常 uncaughtException

上面的兩種異常處理方法,只能捕獲同步錯誤,而異步代碼產生的錯誤纔是致命的,uncaughtException錯誤會致使當前的全部用戶鏈接都被中斷,甚至不能返回一個正常的HTTP 錯誤碼,用戶只能等到瀏覽器超時才能看到一個no data received錯誤。

這是一種很是野蠻粗暴的異常處理機制,任何線上服務都不該該由於uncaughtException 致使服務器崩潰。在Node.js 咱們能夠經過如下代碼捕獲 uncaughtException錯誤:

process.on('uncaughtException', function (err) {
  console.error('Unexpected exception: ' + err)
  console.error('Unexpected exception stack: ' + err.stack)
  // Do something here: 
  // Such as send a email to admin
  // process.exit(1)
})

捕獲uncaughtException後,Node.js 的進程就不會退出,可是當 Node.js 拋出 uncaughtException 異常時就會丟失當前環境的堆棧,致使 Node.js 不能正常進行內存回收。也就是說,每一次、uncaughtException 都有可能致使內存泄露。既然如此,退而求其次,咱們能夠在知足前兩個條件的狀況下退出進程以便重啓服務。固然還能夠利用domain模塊作更細緻的異常處理,這裏就不作介紹了。

3. 如何編寫 Dockerfile

3.1 基礎鏡像選擇

咱們先選用 Node.js 官方推薦的node:argon官方LTS版本最新鏡像,鏡像大小爲656.9 MB(解壓後大小,下文提到的鏡像大小沒有特殊說明的均指解壓後的大小)

The first thing we need to do is define from what image we want to build from. Here we will use the latest LTS (long term support) version argon of node available from the Docker Hub --- https://nodejs.org/en/docs/gu...

咱們事先寫好了兩個文件package.json, app.js

{
  "name": "docker_web_app",
  "version": "1.0.0",
  "description": "Node.js on Docker",
  "author": "Zhangpc <zhangpc@tenxcloud.com>",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.13.3"
  }
}
// app.js
'use strict';

const express = require('express')

// Constants
const PORT = 8080

// App
const app = express()
app.get('/', function (req, res) {
  res.send('Hello world\n')
})

app.listen(PORT)
console.log('Running on http://localhost:' + PORT)

下面開始編寫 Dockerfile,因爲直接從 Dockerhub 拉取鏡像速度較慢,咱們選用時速雲的docker官方鏡像 docker_library/node,這些官方鏡像都是與 Dockerhub 實時同步的:

# Dockerfile.argon
FROM index.tenxcloud.com/docker_library/node:argon

# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# Install app dependencies
COPY package.json /usr/src/app/
RUN npm install

# Bundle app source
COPY . /usr/src/app

# Expose port
EXPOSE 8080
CMD [ "npm", "start" ]

執行如下命令進行構建:

docker build -t zhangpc/docker_web_app:argon .

最終獲得的鏡像大小是660.3 MB,體積略大,Docker 容器的優點是輕量和可移植,因此承載它的操做系統即基礎鏡像也應該迎合這個特性,因而我想到了Alpine Linux,一個面向安全的,輕量的 Linux 發行版,基於 musl libcbusybox

下面咱們使用alpine:edge做爲基礎鏡像,鏡像大小爲4.799 MB

# Dockerfile.alpine
FROM index.tenxcloud.com/docker_library/alpine:edge

# Install node.js by apk
RUN echo '@edge http://nl.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories
RUN apk update && apk upgrade
RUN apk add --no-cache nodejs-lts@edge

# If you have native dependencies, you'll need extra tools
# RUN apk add --no-cache make gcc g++ python

# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# If your project depends on many package, you can use cnpm instead of npm
# RUN npm install cnpm -g --registry=https://registry.npm.taobao.org
# RUN cnpm install

# Install app dependencies
COPY package.json /usr/src/app/
RUN npm install

# Bundle app source
COPY . /usr/src/app

# Expose port
EXPOSE 8080

CMD [ "npm", "start" ]

執行如下命令進行構建:

docker build -t zhangpc/docker_web_app:alpine .

最終獲得的鏡像大小是31.51 MB,足足縮小了20倍,運行兩個鏡像均測試經過。

3.2 還有優化的空間嗎?

首先,大小上仍是能夠優化的,咱們知道 Dockerfile 的每條指令都會將結果提交爲新的鏡像,下一條指令將會基於上一步指令的鏡像的基礎上構建,因此若是咱們要想清除構建過程當中產生的緩存,就得保證產生緩存的命令和清除緩存的命令在同一條 Dockerfile 指令中,所以修改 Dockerfile 以下:

# Dockerfile.alpine-mini
FROM index.tenxcloud.com/docker_library/alpine:edge

# Create app directory and bundle app source
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY . /usr/src/app

# Install node.js and app dependencies
RUN echo '@edge http://nl.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories \
  && apk update && apk upgrade \
  && apk add --no-cache nodejs-lts@edge \
  && npm install \
  && npm uninstall -g npm \
  && rm -rf /tmp/* \
  && rm -rf /root/.npm/
  
# Expose port
EXPOSE 8080

CMD [ "node", "app.js" ]

執行如下命令進行構建:

docker build -t zhangpc/docker_web_app:alpine .

最終獲得的鏡像大小是21.47 MB,縮小了10M。

其次,咱們發如今構建過程當中有一些依賴是基本不變的,例如安裝 Node.js 以及項目依賴,咱們能夠把這些不變的依賴集成在基礎鏡像中,這樣能夠大幅提高構建速度,基本上是秒級構建。固然也能夠把這些基本不變的指令集中在 Dockerfile 的前面部分,並保持前面部分不變,這樣就能夠利用緩存提高構建速度。

最後,若是使用了 Express 框架,在構建生產環境鏡像時能夠設置NODE_ENV環境變量爲production,能夠大幅提高應用的性能,還有其餘諸多好處,下面會有介紹。

小結

docker_web_app_size

咱們構建的三個鏡像大小對比見上圖,鏡像的大小越小,發佈的時候越快捷,並且能夠提升安全性,由於更少的代碼和程序在容器中意味着更小的攻擊面。使用node:argon做爲基礎鏡像構建出的鏡像(tag 爲 argon)壓縮後的大小大概爲254 MB,也不是很大,若是對Alpine Linux心存顧慮的童鞋能夠選用 Node.js 官方推薦的node:argon做爲基礎鏡像構建微服務。

4. 微服務部署及 devops 集成

部署微服務時有一個原則:一個容器中只放一個服務,可使用stack 編排把各個微服務組合成一個完整的應用:

devops_stack

4.1 Dokcer 環境微服務部署

安裝好 Docker 環境後,直接運行咱們構建好的容器便可:

docker run -d --restart=always -p 8080:8080 --name docker_web_app_alpine zhangpc/docker_web_app:alpine

4.2 使用時速雲平臺集成 DevOps

時速雲目前支持github、gitlab、bitbucket、coding 等代碼倉庫,並已實現徹底由API接入受權、webhook等,只要你開發時使用的是這些代碼倉庫,均可以接入時速雲的 CI/CD 服務:

tenxcloud_devpos

下面咱們簡單介紹下接入流程:

  1. 建立項目,參考文檔 http://doc.tenxcloud.com/doc/...

  2. 開啓CI
    devops_ci

  3. 更改代碼並提交,項目自動構建
    devops_ci_build

  4. 用構建出來的鏡像(tagmaster)建立一個容器
    devops_imagedevops_deploy

  5. 開啓CD,並綁定剛剛建立的容器
    devops_cd

  6. 更改代碼,測試 DevOps
    devops_edit_codedevops_ci_build_2devops_ci_build_2devops_deploy_2

咱們能夠看到代碼更改已經通過構建(CI)、部署(CD)體如今了容器上。

參考資料

相關文章
相關標籤/搜索