如何使用 Node.js 和 Docker 構建高質量的微服務
微服務的服務范圍越來越廣泛,尤其是在構建復雜應用中,下面我主要從以下幾點分享如何使用 Node.js 和 Docker 構建高質量的微服務,以及使用 Kong 構建 API gateway。
1. 什么是微服務
微服務架構是一種構造應用程序的替代性方法。應用程序被分解為更小、完全獨立的組件,這使得它們擁有更高的敏捷性、可伸縮性和可用性。一個復雜的應用被拆分為若干微服務,微服務更需要一種成熟的交付能力。持續集成、部署和全自動測試都必不可少。編寫代碼的開發人員必須負責代碼的生產部署。
在實際基于 Docker 構建微服務架構中,我們主要解決了以下5個問題:
-
編寫高質量的微服務
-
微服務的持續集成與快速部署
-
客戶端到服務端以及微服務之間的高效通信
-
服務器快速配置
-
完善的運維與監控體系
微服務架構是由一個個微小的應用程序組成的,一個高質量的微服務是構建微服務架構的前提;在實際開發中還需要一個一體化的 DevOps 平臺,這樣才可以解決微服務的持續集成與快速部署;微服務多了之后,還需要解決客戶端到服務端以及微服務之間的高效通信,我們通過 Kong 構建微服務的 API gateway,為客戶端提供一個統一的 Rest API,微服務之間也通過 Rest API 進行通信。今天我們主要討論前三個問題。
2. Node.js 異步流程控制及異常處理
Node.js 是構建微服務的利器,為啥這么說呢,請往下看:
-
Node.js 采用事件驅動、異步編程,為網絡服務而設計
-
Node.js 非阻塞模式的IO處理給 Node.js 帶來在相對低系統資源耗用下的高性能與出眾的負載能力,非常適合用作依賴其它IO資源的中間層服務
-
Node.js輕量高效,可以認為是數據密集型分布式部署環境下的實時應用系統的完美解決方案
這些優勢正好與微服務的優勢:敏捷性、可伸縮性和可用性相契合(捂臉笑)。
但是 Node.js 的異步特性也帶來了一些問題,比如 Callback 回調地獄以及“脆弱”的異常處理,當然我們可以通過使用 ES2015 的特性來控制異步流程,解決回調地獄,也可以加強異常處理機制規避一些未處理異常引起的程序崩潰,最終在實際部署中,通過多實例以及 Kubernetes 的負載均衡特性保證程序的高可用。
目前 Node.js 的 LTS 版本早就支持了 Generator , Promise 這兩個特性,也有許多優秀的第三方庫 Bluebird、Q 這樣的模塊支持的也非常好,性能甚至比原生的還好,可以用 Bluebird 替換 Node.js 原生的 Promise:
global.Promise = require('bluebird')
Bluebird 的性能是 V8 里內置的 Promise 3 倍左右(Bluebird 的優化方式)。
2.1 Node.js 異步流程控制
2.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. — ctionhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function
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 函數的定義,是通過 function *(){} 實現的
-
對 Generator 函數的調用返回的實際是一個遍歷器,隨后代碼通過使用遍歷器的 next() 方法來獲得函數的輸出
-
通過使用 yield 語句來中斷 generator 函數的運行,并且可以返回一個中間結果
-
每次調用 next() 方法,generator 函數將執行到下一個 yield 語句或者是 return 語句。
下面我們就對上面代碼的每次next調用進行一個詳細的解釋:
-
第1次調用 next() 方法的時候,函數執行到第一次循環的 yield index++ 語句停了下來,并且返回了 0 這個 value ,隨同 value 返回的 done 屬性表明 Generator 函數的運行還沒有結束
-
第2次調用 next() 方法的時候,函數執行到第二循環的 yield index++ 語句停了下來,并且返回了 1 這個 value ,隨同 value 返回的 done 屬性表明 Generator 函數的運行還沒有結束
-
… …
-
第4次調用 next() 方法的時候,由于循環已經結束了,所以函數調用立即返回, done 屬性表明 Generator 函數已經結束運行, value 是 undefined 的,因為這次調用并沒有執行任何語句
2.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/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
所謂 Promise,就是一個對象,用來傳遞異步操作的消息。它代表了某個未來才會知道結果的事件(通常是一個異步操作),并且這個事件提供統一的 API,可供進一步處理。
一個 Promise 一般有3種狀態:
-
pending :初始狀態, 不是 fulfilled ,也不是 rejected
-
fulfilled :操作成功完成
-
rejected :操作失敗
一個 Promise 的生命周期如下圖:
下面我們看一段具體代碼:
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 中設置的回調函數就會被執行。
小結
如果是編寫一個 SDK 或 API,推薦使用傳統的 Callback 或者 Promise,不使用 Generator 的原因是:
-
Generator 的出現不是為了解決異步問題
-
使用 Generator 是會傳染的,當你嘗試 yield 一下的時候,它要求你也必須在一個 generator function 內
《如何用 Node.js 編寫一個 API 客戶端》@leizongmin(https://cnodejs.org/topic/572d68b1afd3b34a17ff40f0)
由此看來學習 Promise 是水到渠成的事情。
2.2 Node.js 異常處理
一個友好的錯誤處理機制應該滿足三個條件:
-
對于引發異常的用戶,返回 500 頁面
-
其他用戶不受影響,可以正常訪問
-
不影響整個進程的正常運行
下面我們就以這三個條件為原則,具體介紹下 Express、Koa 中的異常處理:
2.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.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.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.使用 Kong 構建 API gateway
Kong 是一個基于 Nginx 開發的開源 API gateway,下面主要從以下3個方面介紹 Kong:
-
Docker 中運行 Kong
-
Kong 高可用
-
Kong Plugin 使用舉例
3.1 Docker 中運行 Kong
-
啟動數據庫容器,以 postgres 為例
-
docker run -d --name kong-database \ -p 5432:5432 \ -e "POSTGRES_USER=kong" \ -e "POSTGRES_DB=kong" \ postgres:9.4
-
啟動 Kong
-
docker run -d --name kong \ --link kong-database:kong-database \ -e "KONG_DATABASE=postgres" \ -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \ -e "KONG_PG_HOST=kong-database" \ -p 8000:8000 \ -p 8443:8443 \ -p 8001:8001 \ -p 7946:7946 \ -p 7946:7946/udp \ kong
-
檢查 Kong 是否運行正常
Kong 啟動以后,會監聽 8000 和 8001 兩個端口。其中 8001 作為 Admin API Server
-
curl http://127.0.0.1:8001
3.2 Kong 高可用
可以通過 Nginx 或者 Kubernetes 實現 Kong 高可用,開啟高可用后,系統的典型構架如下:
使用 Nginx 實現高可用可參考以下腳本:
upstream backend { ip_hash; server 192.168.1.1:8000; server 192.168.1.2:8000; server 192.168.1.3:8000; } server { listen 8000; # ssl on; # ssl_certificate /etc/nginx/conf.d/server.cert; # ssl_certificate_key /etc/nginx/conf.d/server.key; location / { #設置主機頭和客戶端真實地址,以便服務器獲取客戶端真實IP proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; #禁用緩存 proxy_buffering off; #反向代理的地址 proxy_pass https://backend; } location /status { stub_status on; auth_basic "NginxStatus"; } }
3.3 Kong Plugin 使用舉例
Kong 依賴于 Cassandra 或 PostgreSQL,但 Kong Server 自己維護 cache,只有 Plugin 需要使用額外的數據庫,下面以 key-authentication 為例說明如何給 API 增加 Authentication:
準備
創建一個 hello world 服務(監聽端口 8889),假設宿主機 IP 為 192.168.1.4。
docker run -d --name=hello -p 8889:80 index.tenxcloud.com/tenxcloud/hello-world
將 hello world API 添加到 Kong:(由于Kong運行在容器中,upstream_url 不能使用 localhost)。
curl -i -X POST \ --url http://localhost:8001/apis/ \ --data 'name=hello' \ --data 'upstream_url=http://192.168.1.4:8889' \ --data 'request_host=hello-world'
如果運行成功, 執行以下腳本,能夠看到 Kong 的 Response 頭信息和 hello world 頁面。
curl -i -X GET \ --url http://localhost:8000/ \ --header 'Host: hello-world'
使用 APIKey 進行身份認證
-
為特定API 添加權限驗證:
-
curl -i -X GET \ --url http://localhost:8000/ \ --header 'Host: hello-world'
這里 {api} 必須是 API 的 ID 或 Name,這里以 hello 為例。
添加成功以后,可以執行以下腳本查看該 API 的插件:
-
curl -X GET http://localhost:8001/apis/hello/plugins
此時,我們再次運行以下腳本 ,會得到 401 Unauthorized 的結果:
-
root@ubuntu:~# curl -i -X GET \ --url http://localhost:8000/ \ --header 'Host: hello-world' HTTP/1.1 401 Unauthorized Date: Mon, 10 Oct 2016 07:56:54 GMT Content-Type: application/json; charset=utf-8 Transfer-Encoding: chunked Connection: keep-alive WWW-Authenticate: Key realm="kong" Server: kong/0.9.2 {"message":"No API key found in headers or querystring"}
-
增加 Consumer,并為Consumer 增加 apiToken
不管使用 Key 進行身份認證,還是實現 Client 粒度的限速,都需要一個 Consumer ID 。
Consumer 本質上與 用戶 是一個概念。Consumer ID 與 API Token 配合使用,實現身份認證的功能;還可以與 Rate limiting 配合使用,實現對特定用戶限速的功能。
Consumer 的相關操作參考 API 文檔 Consumer Object(https://getkong.org/docs/0.9.x/admin-api/#consumer-object)。
增加名為 test 的 Consumer:
-
root@ubuntu:~# # add consumer "test" root@ubuntu:~# curl -X POST http://localhost:8001/consumers/ \ --data "username=test" \ --data "custom_id=id_test" {"custom_id":"id_test","username":"test","created_at":1478075521563,"id":"2bbd5f40-f4f3-456d-9695-2e1466633615"}
為 consumer “test” 增加一個 api key:
-
root@ubuntu:~# # add api key for consumer "test" root@ubuntu:~# curl -X POST http://localhost:8001/consumers/test/key-auth \ --data 'key=my-customized-key' {"created_at":1478075713267,"consumer_id":"2bbd5f40-f4f3-456d-9695-2e1466633615","key":"my-customized-key","id":"b9e4db78-2e1a-43d0-8a22-01b889b47952"}
測試生成的 api key 是否正確:
-
root@ubuntu:~# # check whether api key is valid or not root@ubuntu:~# curl -i -X GET \ --url http://localhost:8000/ \ --header "Host: hello-world" \ --header "apiKey: b9e4db78-2e1a-43d0-8a22-01b889b47952" HTTP/1.1 200 OK ... ... ...
4. 微服務持續集成與快速部署
由于我們是基于 Docker 構建的微服務架構,所以在部署時,首先我們要為每個應用程序都寫一個 Dockerfile 。
4.1 如何編寫 Dockerfile
4.1.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/guides/nodejs-docker-webapp/
我們事先寫好了兩個文件 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 libc 和 busybox 。
下面我們使用 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倍,運行兩個鏡像均測試通過。
4.1.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 的前面部分,并保持前面部分不變,這樣就可以利用緩存提升構建速度。
最后,在構建生產環境鏡像時可以設置 NODE_ENV 環境變量為 production ,可以提升應用的性能。
小結
我們構建的三個鏡像大小對比見上圖,鏡像的大小越小,發布的時候越快捷,而且可以提高安全性,因為更少的代碼和程序在容器中意味著更小的攻擊面。使用 node:argon 作為基礎鏡像構建出的鏡像(tag 為 argon)壓縮后的大小大概為 254 MB ,也不是很大,如果對 Alpine Linux 心存顧慮的童鞋可以選用 Node.js 官方推薦的 node:argon 作為基礎鏡像構建微服務。
4.2 持續集成與快速部署
我們內部基于 Kubernetes 和 Docker 開發了一套 DevOps 解決方案,可以實現持續集成與快速部署,流程大體如下圖:
開發團隊提交應用程序代碼后,會觸發代碼倉庫的 Webhook,從而觸發構建節點的自動構建,然后構建節點構建成功后,將應用程序的鏡像 push 到私有鏡像的倉庫,最后當 push 完成后觸發自動部署。當然這只是最簡單的場景,還有一些復雜的應用場景也是支持的。
5.Q&A
Q:請問 Kubernetes 的網絡用的什么?
A:這個要看具體業務場景,簡單的 Flannel、iptables 就夠了。
Q:請問外部訪問服務和內部訪問微服務方式是一樣的嗎?都是通過API Gateway的話,是否有性能壓力?另外,對外暴露的服務要分配外網地址,純內部服務只要內網地址,怎么區分?
A:內部用或者微服務之間訪問可以通過 內網地址 訪問,外部用綁定一個外網地址就可以了,考慮性能的話,可以通過 Nginx 等實現 Kong 的高可用。
Q:我想問一下容器網絡對微服務的影響,需要自定義網絡嗎?還是用 Kubernetes 就可以了?有更好的方案嗎?
A:在我們實踐過程中,是沒有自定義網絡的,微服務之間通過 rest api 進行交互,對客戶端通過 Kong 提供統一入口,然后用 Kubernetes 的負載均衡就差不多了。
Q:Node.js和Vue.js如何選擇?
A:童鞋,這兩個是完全不同的東西,Node.js 是后端,Vue.js 是一個前端庫,如果你非要選擇,我選擇 react。
來自:http://mp.weixin.qq.com/s?__biz=MzA5OTAyNzQ2OA==&mid=2649692459&idx=1&sn=5f7bae5f64e8f8156b5b4f1f9bdd2426&chksm=88932648bfe4af5ef87070f59c907d060b1e209d1a962b54a01c8704bbf2fa2aac2941cafbef&mpshare=1&scene=1&srcid=11080QbTnZObaDNYO4Wo9RUI#rd