基於阿里雲的 Node.js 穩定性實踐

前言

如果你看過 2018 Node.js 的用戶報告,你會發現 Node.js 的使用有了進一步的增長,同時也出現了一些新的趨勢。

Node.js 的開發者更多的開始使用容器並積極的擁抱 ServerlessNode.js 越來越多的開始服務於企業開發半數以上的 Node.js 應用都使用遠端服務前端開發者們開始越來越多的關心和參與到後端和全棧中去

可以看到越來越多的前端開發者們具備了全棧的能力,更多的核心應用開始基於 Node.js 開發,而其中,保障應用的穩定性是每一個開發者的“頭等大事”。

穩定性是什麼?一般來說,指的是應用持續提供可用服務的能力,一旦應用頻繁不可用或出現故障無法及時恢復,對用戶的使用體驗都是巨大的傷害,甚至會造成很多更嚴重的後果。穩定性保障不僅僅是開發階段的事情,它應該是貫穿應用的開發、測試、上線、監控等,覆蓋整個 DevOps 生命週期的事情。

本身阿里雲提供了豐富的產品和服務來支持整個 DevOps。

包括 Code 代碼託管、PTS 性能測試、SLS 日誌服務、雲效 等等。

本文也將圍繞整個 DevOps 生命週期,來介紹基於阿里雲的 Node.js 穩定性保障的實踐。

應用開發

穩定性的保障從應用開發階段就已經開始了,這部分也是相關資料文章最多的,相信有追求的開發者都會關注並且已經應用和實踐。

異常捕獲和處理

應用運行過程中難免會有異常發生,再大神的程序員也不敢保證自己寫的代碼不出問題。其實出現異常不可怕,可怕的是異常沒有捕獲,進而引起應用進程 crash,導致應用不可用。

正常來說,捕獲異常有一下幾種方式:

try/catchtry/catch 是捕獲異常的常用方式,可以幫助我們可控的捕獲錯誤,但是 try/catch 無法捕獲異步異常。

try {
setTimeout(() => {
throw new Error('error');
}, 0);
} catch(err) {
// can't catch it
console.log(err);
}


上面的異步異常使用 try/catch 是無法捕獲的。捕獲異步日常我們可以使用一下的方式。異步異常callback 異步回調

通過異步回調來處理異步錯誤可能是目前最廣泛的方案。
function demo(callback) {

setTimeout(() => {
callback(new Error('error'), null);
}, 0);
}demo((err, res) => {

if (err) console.log(err);
});


當然,callback 方式存在一直被人詬病的嵌套問題
promise

使用 promise 可以通過 reject 拋出錯誤,通過 catch 捕獲錯誤
new Promise((resolve, reject) => {

setTimeout(() => {
reject(new Error('error'));
}, 0);
}).catch(err => {

console.log(err);
});generator

使用 generator 可以讓我們使用同步的代碼寫法來調用異步函數,可以直接 try/catch 來捕獲異常
function* demo() {

try {
yield new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error'));
}, 0);
});
} catch(err) {
// can catch
console.log(err);
}
}yield demo();async/await

async/await 應該是目前最簡單和優雅的異步解決方案了,寫起來和同步代碼一樣直觀,可以直接使用 try/catch 來捕獲異常


const demo = async function() {

try {
await new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error'));
}, 0);
});
} catch(err) {
// can catch
console.log(err);
}
};uncaughtException當異常拋出未被捕獲時,會觸發 uncaughtException 事件。只要監聽了 uncaughtException 事件並設置了回調,Node 進程就不會異常退出。

process.on('uncaughtException', function(err) {
console.error(err);
});
但是這時異常的上下文會丟失(respond 對象),無法給用戶友好的返回。而且由於uncaughtException 事件發生後,會丟失當前環境的堆棧,可能導致 Node 不能正常進行內存回收,從而導致內存洩露。因此,使用 uncaughtException 的正確做法一般是,當 uncaughtException 發生時,記錄詳細的日誌,然後結束進程,通過日誌和報警來及時的定位和排查問題。domain為了彌補 try/catch、uncaughtException 的不足,Node 新增了一個 domain 模塊,可以捕獲異步異常並且不會丟失上下文。聽起來很完美,但是該模塊目前是不穩定的(Stability: 0 - Deprecated)。同時其可能存在穩定性和內存洩露的問題,因此要謹慎使用。一般來說,我們開發 Node 應用,只需要關注我們應用邏輯異常的捕獲即可,本身我們使用的 Node 框架,比如:Egg、Midway 等都會在底層幫我們進行處理,保證一些我們不可控或者未預期的異常出現時,不會導致應用崩潰。

雖然框架幫我們進行的兜底,但是依然需要我們針對自己的應用邏輯進行異常處理,給用戶友好的異常提示。一般出現異常時,我們需要儘可能保證:

對出現異常的用戶,進行友好的提示不影響應用其他用戶的正常使用不影響應用進程的正常運行詳細的異常日誌記錄和報警機制,方便快速定位、解決問題

如果你使用的是 Egg,你可以使用 onerror 插件來做統一的處理。同時不建議將異常信息直接返回給用戶,返回用戶的應該是更語義化更友好的信息,而原始的錯誤堆棧和信息等,你可以通過日誌進行記錄,日誌信息越詳細越好,比如除了最基本的 name、message、stack 外,你還可以記錄當前一些關鍵的參數以及當前調用鏈路的 traceId 等,這樣的目的只有一個,就是可以快速定位到錯誤,以及錯誤發生的上下文。具體的鏈路監控下文會講到。

強弱依賴

在設計應用架構時,重要的一步就是區分強弱依賴。強弱依賴的定義應該視對業務的影響程度而定,並不能單純的認為會導致系統掛掉的依賴才是強依賴。儘量減少強依賴,因為強依賴意味著,一旦該強依賴出現問題,會導直接影響業務的進行。一個應用的依賴可能涉及到以下幾個部分。

數據

應用的開發基本離不開數據的讀寫,這也導致我們的應用基本都是強依賴 DB 的,DB 一旦出現問題,那我們的應用可能就不可用了,因此我們可以通過 DB 上加一層緩存來增加一層保險,當數據更新的時候刷新對應的緩存,這樣任何一層出現問題,都不會對應用帶來災難性後果。這裡你需要額外注意數據同步的機制和一致性的保證,同時對於數據讀取要設置合理的超時時間,比如讀取緩存,如果 10ms 內沒有響應就直接讀取數據庫,再有就是異常的處理,比如要保證讀取緩存時出現異常不能影響 DB 的正常讀取。
中間件

如果依賴了其他的中間件,也要考慮是否對某個中間件進行了強依賴,如果這個中間件故障了,會不會對我們的應用造成嚴重故障。
二方/三方系統

我們的應用或多或少都會依賴其他的二方或者三方系統,對我們依賴的這些系統的穩定性,我們儘量要做到心中有數,儘量不進行強依賴,如果出現異常,要做好詳細的日誌記錄,快速定位出現問題的依賴方和出現問題的上下文,不然定位問題和復現問題可能就要花去你大部分時間了,同時提前做好處理方案,不要出現問題了就抓瞎了。當然如果我們依賴其他系統提供的數據,那依然可以使用緩存來加一層保障。

其中有可能你的應用面臨突發流量時,需要對一些下游弱依賴進行降級,以保證當前系統以及下游的正常運行使用。需要明確的是依賴可以降級,但是功能不能降級,舉個例子,實現一個商品收藏夾的頁面功能,每個商品上會有一個加購按鈕,如果商品是否可以加購的查詢依賴於二方系統,那你就需要考慮面臨突發流量時對該依賴進行降級,錯誤的降級方式是直接不展示這個加購按鈕,這種方式降級了依賴同時降級了功能。比較好的處理方式是,全部商品都展示加購按鈕,當用戶點擊加購時,才去請求二方系統,檢查是否可以加購。通過犧牲一點用戶的體驗來保證整個系統的穩定性。

多進程

我們知道 JavaScript 單線程運行的,換句話說一個 Node.js 進程只能運行在一個 CPU 上,因此無法享受到多核運算的好處。Node.js 針對這個問題提供了 Cluster 模塊,可以在服務器上同時啟動多個進程,每個進程裡都跑的是同一份源代碼,並且可以同時監聽一個端口。當然作為一個對外服務的應用來說,要考慮的東西還有很多,比如異常如何處理,進程間如何共享資源,進程間如何調度等等。如果你使用的是 Egg/Midway,這些問題框架已經幫你解決掉了。對於 Egg 來說,你可以詳細參考:多進程模型和進程間通訊。這裡不再贅述。

單元/功能測試

單元/功能測試的重要性毋容置疑,為代碼質量提供持續性的保障,同時可以增強你修改、發佈代碼的信心。單元測試用於測試最小功能單元,比如單個方法。而針對 Node 開發的 Web 應用,我們可以直接針對接口進行功能測試,如果針對函數方法寫單元測試的話,成本有點高,而接口的功能測試基本可以覆蓋 Router、Controller、Model 整條鏈路了,覆蓋不到的函數邏輯再對其單獨編寫單元測試用例,這樣成本會小很多,而且達到的測試覆蓋率並沒有折扣。

如果你使用了 Egg/Midway 等框架,框架本身對單元測試能力已經幫你進行了集成,你只需要按照約定編寫用例並使用即可,可以參考 Egg 單元測試。

持續集成

有了單元/功能測試以後,下一步就需要考慮持續集成了。阿里雲提供了 CodePipline 以及雲效 幫助你進行快速可靠的持續集成與交付

流程規範

開發、測試、發佈過程中的流程規範也是保障穩定性的重要一環,可以有效避免一些人為的疏忽。比如應用寫了測試用例,但是在用例沒通過的情況下發布上線等等。因此配置一套自動化的流程規範十分有必要,阿里雲的雲效提供了完整的項目管理、持續集成的能力,在上面可以完成日常開發、測試、發佈的流程。詳細的操作可以參考其幫助文檔。這裡補充一些流程上的實踐。

CodeReview

CodeReview 十分重要,它可以及時發現一些比較明顯的代碼、邏輯問題,同時可以保證多人合作的代碼理解和維護。但是如果沒有一個流程規範和卡口,CodeReview 是很難自發堅持下去的。

CodeReview 可以分為提交前(pre-commit)和提交後(post-commit)兩種。本身就是字面意思,pre-commit 既必須通過 CodeReview 才可以提交代碼,而 post-commit 既先提交代碼,然後發起 CodeReview。相比起來,pre-commit 流程更加合理,因為 post-commit 不阻礙代碼提交變更、發佈的流程,既即使沒有 reivew 通過,依然可以提交變更併發布。而 post-commit 相對於 pre-commit 來說會更容易實施。

而對於 post-commit,如果其 review 的結果並不影響代碼提交變更和發佈,那如何做流程卡口呢?你可以使用雲效自定義流水線,通過人工卡點的方式來保證流程。

通過人工卡點,來增加流程卡口,後續雲效也會上線 CodeReivew 功能,敬請期待。更多流水線的操作,你可以參考其幫助文檔

如果你覺得配置 pre-commit 過於麻煩,而 post-commit 流程上過於滯後的話,也可以採用依靠約定的折中方案,使用 Git 的 PR 功能。我們不從部署分支上進行開發,而是基於部署分支繼續檢出開發分支,開發完成需要提交部署時,提交 PR,指定給需要 review 的同學,通過後會將開發分支合併到部署分支。當然這種方式依賴流程規範的約定,無法進行強制的卡口。

增加測試卡點

前文講過,我們需要為應用實現單元/功能測試,那如何保證應用部署發佈前一定通過了單元/功能測試呢?我們可以在雲效的流程中增加測試卡點,來保證我們編寫的測試用例通過後,當前部署分支才可進行發佈,通過雲效的自動化測試卡口保障持續交付質量。

首先我們需要新建一個測試任務,在 [雲效的測試服務]https://testing.rdc.aliyun.com/)中選擇“單元測試”。

將創建的測試任務和流水線關聯,作為持續集成交付的測試卡口。每次集成交付,都會運行測試任務,同時保證測試結果達到紅線要求,否則流水線運行失敗。

性能測試

應用在發佈前以及上線後周期性的,都需要做性能測試,一方面讓我們對應用的吞吐心裡有數,另一方面保證長時間運行的穩定,畢竟有些問題可能是運行很多次才可能出現的,比如 OOM 等。阿里雲提供了方便的性能測試產品:PTS。

PTS 支持構建串行、並行的構建你的壓測場景,並且支持併發和 TPS 模式來控制你的壓測流量,最後,PTS 還提供了豐富的監控和壓測報告,實時監控和報告中包括但不侷限於各 API 的併發、TPS、響應時間和採樣的日誌,請求和響應時間還有不同的細分數據,和阿里雲生態內的雲監控、ARMS監控無縫集成。

創建壓測場景

首先你需要對壓測進行計劃,需要明確場景,對流量進行預估,設定目標值,否則壓測毫無意義,你完全無法明確當前系統是否可以穩定的支撐你的業務場景。其次需要對各種系統預案進行摸高壓測,明確各個預案下能支持的壓力上限,以此來保證在合適的情況下可以執行對應的預案並可以達到預期效果。

詳細的創建壓測場景的步驟,可以參考 PTS 幫助文檔。一般來說,我們可以創建兩個場景,分別用來回歸測試和容量評估,迴歸測試的場景,可以設置固定的併發數量,週期性的持續壓測,來暴露一些長時間運行可能的潛在問題、而容量評估場景,需要設置自動增長的方式,用來尋找系統的壓力上限。

施壓配置

對於容量評估的場景,我們可以開啟自動增長,按照固定比例進行壓測量級的遞增,並在每個量級維持固定壓測時長,以便觀察業務系統運行情況。

同時 PTS 給我們提供了更加方便的智能測試模式,幫我們探測系統的最佳壓力點、極限壓力點和破壞壓力點,幫助我們評估系統容量。更詳細的操作步驟,可以參考 PTS 容量評估

性能指標

對於預估正常的併發量來說,性能測試一般通過標準為:

超時率小於萬分之一錯誤率小於萬分之一CPU 利用率小於 75%Load 平均每核 CPU 小於 1內存使用率小於 80%

更多可參考 PTS 測試指標。對於壓力測試來說,一般我們把 CPU 壓到 100% 或者內存壓到 90% 左右,既可認為壓到了極限,如果此時你發現其他指標可能都是正常的,那麼說明你的應用可能還有很大的優化空間,可以有針對性的去檢查並進一步優化。

迴歸測試

我們需要保證應用長時間持續性的穩定,而有些問題可能是運行很多次才可能出現的,比如 OOM 等。而回歸測試指的是週期性的持續壓測,通過迴歸測試,來提前暴露出系統長時間運行中可能出現的潛在問題。

PTS 為我們提供的方便的定時功能,可以指定測試任務的執行日期、執行時間、循環週期和通知方式等,從而實現定時壓測。你可以參考 PTS 定時壓測來配置自己的迴歸測試。

當然雲效也給我們提供了功能更為強大的迴歸測試平臺,可以將線上真實流量複製並用於自動迴歸測試的平臺。通過它,不僅能夠實現低成本的日常自動化迴歸,同時通過它提供擴展能力可以支持系統重構升級的自動迴歸。比如系統重構時,複製真實線上環境流量到被測試環境進行迴歸,相當於在不影響業務的情況下提前上線檢測系統潛在的問題。同時還可以將錄製的流量作為用例管理起來進行自動化迴歸。

你可以參考自動迴歸服務接入使用文檔來配置功能強大的迴歸測試。

監控報警

應用出現異常並不可怕,可怕的是出現問題以後而並不自知。沒有哪個系統可以保證線上不出現問題,重要的是及時發現問題並解決,不讓問題持續惡化。因此線上的監控和報警十分重要。

監控與日誌

一般來說我們需要進行三個方面的監控:業務可用性、業務指標衡量、業務錯誤追蹤,而對應的方式為:健康檢查、單點度量、錯誤日誌和鏈路。

健康檢查

健康檢查是用來定義一個應用當前的狀態,它需要能頻繁調用並快速返回,而健康檢查包含著一系列的檢查項,比如:

一般來說,我們可以通過 Pandora + 雲監控 CloudMonitor 來幫助我們進行健康檢查。

首先 Pandora 是阿里內部開源出去的,提供一個通用的 Node.js 應用運行時模型和相關基礎設施。提供一個標準的 Node.js 的 DevOps 流程。其提供了一些基礎的檢查,比如磁盤檢查,端口檢查等。同時我們也可以自定義更多的檢查項。

你可以參考 Pandora 健康檢查來使用其提供的健康檢查能力。

Pandora 配置好後,我們可以通過雲監控對暴露出來的檢查服務進行監控。

你可以參考雲監控的主機監控來配置你的監控能力。

單點度量

阿里雲提供了 Node.js 性能平臺來幫助我們對 Node.js 應用進行單點度量。Node.js 性能平臺是面向中大型 Node.js 應用提供性能監控、安全提醒、故障排查、性能優化等服務的整體性解決方案。

Node.js 性能平臺提供了豐富的度量指標,包括系統、進程的內存、CPU、QPS 等等。

同時,其還為我們提供的故障排查的能力,比如熱點函數分析、內存洩露分析等。你可以參考 Node 應用內存洩漏分析方法論與實戰來學習使用 Node.js 性能平臺發現、定位解決內存洩露問題。

錯誤日誌和鏈路

一般來說,我們需要採集以下幾類日誌:

trace:請求鏈路的監控日誌。當出現錯誤時,可以根據 traceId 快讀的定位到產生問題的那個請求鏈路,還原上下文。尤其是我們的應用如果依賴了其他二方/三方系統,鏈路比較長時,可以明確的知道調用依賴系統時的入參和返回,快讀定位出現問題的環節,減少扯皮和定位還原問題的時間。error:錯誤日誌。包括應用本身和業務邏輯的錯誤。metric:CPU、內存等機器指標nginx:如果你的應用用了 nginx,nginx 的錯誤日誌的採集也是很關鍵的。nginx 的錯誤日誌可能是最容易被忽略的,經常見到這樣的場景,應用沒有異常,但是訪問就是掛的,開發吭哧吭哧排查半天,終於定位到 nginx 有錯誤拋出。

其中,trace 鏈路日誌是很重要但是容易被忽略的日誌,鏈路的重要性不言而喻,可以幫助我們分析上下游依賴、進行節點分析和故障排查,尤其是依賴其他二方/三方系統時,trace 鏈路日誌十分重要,但是也是需要花非常大的精力去做,業界的 newRelic,oneAPM 都有著非常明顯的鏈路視圖。

一般來說我們採用 Pandora + SLS 日誌服務 + Node.js 性能平臺 來進行日誌收集。

其中 Pandora 通過攔截 httpServer 和 httpClient,在對我們系統業務沒有侵入性的同時幫助我們收集 trace 鏈路日誌,詳細的配置,你可以參考 Pandora 鏈路追蹤及監控。

Node.js 性能平臺會幫助我們收集 error 日誌。

配合 SLS 日誌服務,可以幫助我們無死角的採集我們需要的任何日誌信息。SLS 詳細的配置可以參考其幫助文檔。

報警

應用出現異常後,需要有及時的報警機制來提醒我們,以便快速響應和處理。

監控項與報警指標

一般來說,需要的監控項及報警指標為:

日誌監控Nginx 錯誤日誌應用 Error 日誌Trace 鏈路日誌日誌報警每分鐘錯誤日誌數量 > 流量 * SLA 等級機器指標CPU > 70%內存洩露:@heap_used / @heap_limit > 0.7Load > CPU 核數流量監控周同比監控:同比下降 > SLA 的承諾流量預警,接近 QPS 峰值

其中 SLA 為服務等級,用百分比的服務可用性來來定義服務質量。

報警配置

一般來說我們使用 雲監控 CloudMonitor + SLS 日誌服務 + Node.js 性能平臺的報警配置即可。

其中雲監控 CloudMonitor的報警主要針對上文提到的健康檢查。你可以參考雲監控報警服務來配置報警功能。

對於 SLS,我們可以對錯誤數量進行報警,或者根據同比環比來進行報警。比如我們可以新建兩個快速查詢,針對我們應用 error 和 nginx error 日誌。

這裡的查詢語句為 * | select count(*) as sum。然後將快速查詢另存為告警,根據需要配置告警規則,觸發告警時,可以選擇通過釘釘機器人進行通知。詳細的配置,可以參考 SLS 官方文檔設置告警。

對於服務器指標告警,比如 CPU、內存等。我們可以利用 Node.js 性能平臺 配置監控。

可以看到,上面配置的告警規則是:堆上線 80%、load1 和 load5 <= 3、cpu 上線 80%。這裡需要編寫監控項的表達式,可以參考如何進行監控項表達式的編寫。

最後

其實穩定性的保障還有很多工作和措施可以做,比如我們的部署可以採取多集群、多 Region 的部署,這樣可以保證當某個集群或者 Region 出現故障,不會造成更大範圍的問題,保證故障範圍可控。同時我們還可以採取灰度發佈的方式,在不斷驗證新上線功能的情況下,平滑的過渡發佈上線,保證應用整體穩定性等等。

最後的最後,穩定性保障是應用整個生命週期內的事情,是每個開發者的責任和義務。