高質量Node.js微服務的編寫和部署
為促進Docker、Kubernetes等技術的交流傳播,同時幫助用戶更全面地了解時速云產品及其應用,時速云每兩周會進行一次技術分享,分享時間為 周四晚8:30-9:30 在用戶微信群、時速云技術分享群等進行產品、容器技術相關的技術直播分享和現場答疑。以下整理自 7月28日第十三期技術分享內容 ,由 時速云工程師 張鵬程 分享。
微服務架構是一種構造應用程序的替代性方法。應用程序被分解為更小、完全獨立的組件,這使得它們擁有更高的敏捷性、可伸縮性和可用性。
一個復雜的應用被拆分為若干微服務,微服務更需要一種成熟的交付能力。持續集成、部署和全自動測試都必不可少。編寫代碼的開發人員必須負責代碼的生產部署。構建和部署鏈需要重大更改,以便為微服務環境提供正確的關注點分離。
后續我們會聊一下如何在時速云平臺上集成 DevOps。
Node.js 是構建微服務的利器,為什么這么說呢,我們先看下 Node.js 有哪些優勢:
- Node.js 采用事件驅動、異步編程,為網絡服務而設計
- Node.js 非阻塞模式的IO處理給 Node.js 帶來在相對低系統資源耗用下的高性能與出眾的負載能力,非常適合用作依賴其它IO資源的中間層服務
- Node.js輕量高效,可以認為是數據密集型分布式部署環境下的實時應用系統的完美解決方案。
這些優勢正好與微服務的優勢:敏捷性、可伸縮性和可用性相契合(捂臉笑),再看下Node.js 的缺點:
- 單進程,單線程,只支持單核CPU,不能充分的利用多核CPU服務器。一旦這個進程 down 了,那么整個 web 服務就 down 了
- 異步編程,callback 回調地獄
第一個缺點可以通過啟動多個實例來實現CPU充分利用以及負載均衡,話說這不是 K8s 的原生功能嗎。
第二個缺點更不是事兒,現在可以通過 generator 、 promise 等來寫同步代碼,爽的不要不要的。
下面我們主要從 Docker 和 Node.js 出發聊一下高質量Node.js微服務的編寫和部署:
- Node.js 異步流程控制:generator 與 promise
- Express、Koa 的異常處理
- 如何編寫 Dockerfile
- 微服務部署及 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/petkaantonov/bluebird/wiki/Optimization-killers )。
1.1 ES2015 Generator
generator 就像一個取號機,你可以通過取一張票來向機器請求一個號碼。你接收了你的號碼,但是機器不會自動為你提供下一個。
換句話說,取票機“暫停”直到有人請求另一個號碼( next() ),此時它才會向后運行。下面我們看一個簡單的示例:
從上面的代碼的輸出可以看出:
- 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 的,因為這次調用并沒有執行任何語句
PS: 如果在 generator 函數內部需要調用另外一個 generator 函數,那么對目標函數的調用就需要使用 yield* 。
1.2 ES2015 Promise
所謂 Promise,就是一個對象,用來傳遞異步操作的消息。它代表了某個未來才會知道結果的事件(通常是一個異步操作),并且這個事件提供統一的 API,可供進一步處理。
一個 Promise 一般有3種狀態:
1.pending : 初始狀態, 不是 fulfilled ,也不是 rejected .
2.fulfilled : 操作成功完成.
3.rejected : 操作失敗.
一個 Promise 的生命周期如下圖:
下面我們看一段具體代碼:
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 內
看來學習 Promise 是水到渠成的事情。
2. Express、Koa 的異常處理
一個友好的錯誤處理機制應該滿足三個條件:
- 對于引發異常的用戶,返回 500 頁面
- 其他用戶不受影響,可以正常訪問
- 不影響整個進程的正常運行
下面我們就以這三個條件為原則,具體介紹下 Express、Koa 中的異常處理。
2.1 Express 異常處理
在 Express 中有一個內置的錯誤處理中間件,這個中間件會處理任何遇到的錯誤。
如果你在 Express 中傳遞了一個錯誤給 next() ,而沒有自己定義的錯誤處理函數處理這個錯誤,這個錯誤就會被 Express 默認的錯誤處理函數捕獲并處理,而且會把錯誤的堆棧信息返回到客戶端,這樣的錯誤處理是非常不友好的。
還好我沒可以通過設置 NODE_ENV 環境變量為 production ,這樣 Express 就會在生產環境模式下運行應用,生產環境模式下 Express 不會把錯誤的堆棧信息返回到客戶端。
在 Express 項目中可以定義一個錯誤處理的中間件用來替換 Express 默認的錯誤處理函數:
在所有其他 app.use() 以及路由之后引入以上代碼,可以滿足以上三個友好錯誤處理條件,是一種非常友好的錯誤處理機制。
2.2 Koa 異常處理
我們以 Koa 1.x 為例,看代碼:
把上面的代碼放在所有 app.use() 函數前面,這樣基本上所有的同步錯誤均會被 try{} catch(err){} 捕獲到了,具體原理大家可以了解下 Koa 中間件的機制。
2.3 未捕獲的異常 uncaughtException
上面的兩種異常處理方法,只能捕獲同步錯誤,而異步代碼產生的錯誤才是致命的, uncaughtException 錯誤會導致當前的所有用戶連接都被中斷,甚至不能返回一個正常的 HTTP 錯誤碼,用戶只能等到瀏覽器超時才能看到一個 no data received 錯誤。
這是一種非常野蠻粗暴的異常處理機制,任何線上服務都不應該因為 uncaughtException 導致服務器崩潰。
在Node.js 我們可以通過以下代碼捕獲 uncaughtException 錯誤:
捕獲 uncaughtException 后,Node.js 的進程就不會退出,但是當 Node.js 拋出uncaughtException 異常時就會丟失當前環境的堆棧,導致 Node.js 不能正常進行內存回收。
也就是說,每一次、 uncaughtException 都有可能導致內存泄露。既然如此,退而求其次,我們可以在滿足前兩個條件的情況下退出進程以便重啟服務。
當然還可以利用 domain 模塊做更細致的異常處理,這里就不做介紹了。
3. 如何編寫 Dockerfile
3.1 基礎鏡像選擇
我們先選用 Node.js 官方推薦的 node:argon 官方 LTS 版本最新鏡像,鏡像大小為 656.9 MB (解壓后大小,下文提到的鏡像大小沒有特殊說明的均指解壓后的大小)
我們事先寫好了兩個文件 package.json , app.js :
下面開始編寫 Dockerfile,由于直接從 Dockerhub 拉取鏡像速度較慢,我們選用時速云的docker官方鏡像docker_library/node(https://hub.tenxcloud.com/repos/docker_library/node),這些官方鏡像都是與 Dockerhub 實時同步的:
執行以下命令進行構建:
docker build -t zhangpc/docker_web_app:argon .
最終得到的鏡像大小是 660.3 MB ,體積略大,Docker 容器的優勢是輕量和可移植,所以承載它的操作系統即基礎鏡像也應該迎合這個特性,于是我想到了 Alpine Linux ,一個面向安全的,輕量的 Linux 發行版,基于 musllibc 和 busybox 。
下面我們使用 alpine:edge 作為基礎鏡像,鏡像大小為 4.799 MB :
執行以下命令進行構建:
docker build -t zhangpc/docker_web_app:alpine .
最終得到的鏡像大小是 31.51 MB ,足足縮小了20倍,運行兩個鏡像均測試通過。
3.2 還有優化的空間嗎?
首先,大小上還是可以優化的,我們知道 Dockerfile 的每條指令都會將結果提交為新的鏡像,下一條指令將會基于上一步指令的鏡像的基礎上構建。
所以如果我們要想清除構建過程中產生的緩存,就得保證產生緩存的命令和清除緩存的命令在同一條 Dockerfile 指令中,因此修改 Dockerfile 如下:
執行以下命令進行構建:
docker build -t zhangpc/docker_web_app:alpine .
最終得到的鏡像大小是 21.47 MB ,縮小了10M。
其次,我們發現在構建過程中有一些依賴是基本不變的,例如安裝 Node.js 以及項目依賴,我們可以把這些不變的依賴集成在基礎鏡像中,這樣可以大幅提升構建速度,基本上是秒級構建。
當然也可以把這些基本不變的指令集中在 Dockerfile 的前面部分,并保持前面部分不變,這樣就可以利用緩存提升構建速度。
最后,如果使用了 Express 框架,在構建生產環境鏡像時可以設置 NODE_ENV 環境變量為 production ,可以大幅提升應用的性能,還有其他諸多好處,下面會有介紹。
小結
我們構建的三個鏡像大小對比見上圖,鏡像的大小越小,發布的時候越快捷,而且可以提高安全性,因為更少的代碼和程序在容器中意味著更小的攻擊面。
使用 node:argon 作為基礎鏡像構建出的鏡像(tag 為 argon)壓縮后的大小大概為 254 MB ,也不是很大。
如果對 Alpine Linux 心存顧慮的童鞋可以選用 Node.js 官方推薦的 node:argon 作為基礎鏡像構建微服務。
4.微服務部署及 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 服務:
下面我們簡單介紹下接入流程:
- 創建項目,參考文檔 http://doc.tenxcloud.com/doc/v1/ci/project-add.html
- 開啟CI
- 更改代碼并提交,項目自動構建
- 用構建出來的鏡像( tag 為 master )創建一個容器
- 開啟CD,并綁定剛剛創建的容器
- 更改代碼,測試 DevOps
我們可以看到代碼更改已經經過構建(CI)、部署(CD)體現在了容器上。
參考資料:
- 《微服務、SOA 和 API:是敵是友?》 http://www.ibm.com/developerw…
- 《解析微服務架構(一):什么是微服務》 http://t.cn/RtXiKLS
- 《微服務選型之Modern Node.js》 https://github.com/i5ting/mod…
- 帥龍攻城獅《鏡像構建優化之路》 http://blog.tenxcloud.com/?p=…
- 《微容器:更小的,更輕便的Docker容器》 http://blog.tenxcloud.com/?p=…
- 黃鑫攻城獅的內部分享《Dockerfile技巧分享》
- 《Node 出現 uncaughtException 之后的優雅退出方案》 http://www.infoq.com/cn/artic…
- 《Express Error handling》 https://expressjs.com/en/guid…
- 《Promise 迷你書》 http://liubin.org/promises-book/
- 《如何把 Callback 接口包裝成 Promise 接口》 http://www.75team.com/post/ho…
Q&A:
1. 自動構建對程序的要求是什么?有dockerfile就可以了嗎?
答:自動構建對程序沒有要求,只要有Dockerfile就行。
2. 一個容器只放一個服務,這個成本有點高吧?
答:結合 stack 編排,成本還是可控的,一個容器對應一個服務比較符合微服務的理念
3. node是單進程的 容器中的node也是單進程部署的嗎?或者說容器的cpu需不需要配置多核?
答:容器的CPU一般是按時間片劃分的,容器中的 node 一般都是單進程部署,結合 k8s 可以建立多個實例,實現負載均衡。
4.以前沒有容器 node進程掛了 操作系統關心的是進程 現在容器中跑node k8s去關心容器掛不掛 容器中的node如果掛了 就是容器去把node再重啟嗎?
答:如果是單機部署的話 可以用 –restart=always 命令實現容器自動重啟; k8s可以支持對容器內服務定義探針,根據規則可以對服務進行重啟或者從前端路由摘除
5.docker daemon掛了的話那也沒辦法了?
答:docker daemon掛了的話,可以通過節點agent自動恢復docker daemon,或者自動把服務遷移到其他正常服務節點。
來自:http://blog.tenxcloud.com/?p=1566