圖片來源: https://medium.com/encored-te...本文做者:夏銀竹javascript
2019 年初的時候,咱們業務組上線了一個新的 Node.js 應用,主要提供C端的 API 服務。 隨着應用流量的逐漸增長,線上監控平臺會偶發性報警,提示磁盤 disk_io 平均等待時間超出 1000ms,隨後觀察發現磁盤 IO 每秒寫字節量忽然飆高,但很快又降低。前端
具體的監控圖表如上所示,線上 Node.js 應用向磁盤寫入數據的惟一場景就是打印日誌,經過iotop、ps 等命令也驗證了該異常正是因爲 Node.js 的日誌切割進程致使的。java
但是爲何日誌切割會引發這麼詭異的 IO 問題呢?在揭開謎底以前,咱們先聊一聊 Node.js 應用日誌切割的原理。node
日誌打印是 Node.js 工程服務化過程當中一個必不可少的環節。日誌文件中包含了系統運行過程當中的關鍵信息,經常使用於故障診斷和系統的性能分析。git
在日誌打印的過程當中一般會遇到兩個問題:github
因此,在生產環境中一般會採用日誌切割來解決上述的問題。日誌切割能夠限制日誌文件個數, 刪除舊的日誌文件,並建立新的日誌文件,起到「轉儲做用」。架構
不一樣的日誌切割組件會採用不一樣的實現機制,常見的有如下兩種:app
copytruncate 模式:框架
sign 通知模式:async
日誌切割組件通常都支持可配置日誌切割頻率,也有兩種方式:
對於前端 Node.js 應用來講,根據項目的框架和部署架構,可選擇不一樣的日誌切割方案,例如:
pm2-logrotate
實現日誌切割。egg-logrotator
實現日誌切割。下面咱們詳細介紹一下這兩種日誌切割方案的實現原理。
pm2-logrotate
是 pm2 擴展的日誌管理插件,適用於 pm2 部署的 Node.js 應用,同時支持定時切割和按大小切割。
可經過以下的命令來設置一些配置參數:
pm2 set pm2-logrotate:<param> <value>
其中的關鍵配置項以下:
日誌文件的最大致積,默認值爲 10 1024 1024
切割出的日誌文件最大份數,默認值爲 30
日期格式,基於該日期名生成切割出的日誌文件名稱,默認值爲 YYYY-MM-DD_HH-mm-ss
按大小切割時,文件掃描的間隔時間(s),默認值爲 30
定時切割的時間間隔(支持cron表達式),默認值爲 0 0 *
對於pm2-logrotate
自己的實現機制,咱們探究源碼總結出了一些關鍵的步驟,具體如圖所示:
其中切割機制的核心源碼以下,從這裏咱們能夠看出 pm2-logrotate
採用了上文介紹的 copytruncate 模式。
// 日誌切割的具體過程 function proceed(file) { // 設置時間做爲新的日誌文件名 var final_time = moment().format(DATE_FORMAT); var final_name = file.substr(0, file.length - 4) + '__' + final_time + '.log'; // 建立 read/write streams,將日誌寫入到新的日誌文件中 var readStream = fs.createReadStream(file); var writeStream = fs.createWriteStream(final_name, {'flags': 'w+'}); readStream.pipe(writeStream); // 日誌寫入完畢後, 清空應用的日誌文件 writeStream.on('finish', function() { readStream.close(); writeStream.close(); fs.truncate(file, function (err) { console.log('"' + final_name + '" has been created'); if (typeof(RETAIN) === 'number') delete_old(file); }); }); }
從新回到本文開頭的線上問題,因爲咱們的 Node.js 應用是基於 pm2 實現進程管理,一樣也是基於 pm2-logrotate
來實現日誌切割。
在排查異常的時候,咱們發現了 pm2-logrotate
在實現上的兩個問題。
經過追溯源碼,咱們能夠肯定 pm2-logrotate
會對全部由 pm2 啓動的應用進行日誌切割。
pm2.list(function(err, apps) { apps.forEach(function(app) { proceed_app(app, false); }); });
可是,在 cluster 模式下,pm2 會啓動多個相同的 woker 實例,而日誌只會打印到同一份文件中。所以,當觸發日誌切割的條件時,多個 woker 實例會同時對日誌文件進行切割,形成 IO 讀寫繁忙。
同時, copytruncate 模式在日誌切割的時候會拷貝原來的日誌文件,也會形成系統磁盤使用空間突增。
這個問題在日誌文件體積大的時候尤其明顯,也是咱們 Node.js 應用線上 IO 異常的主因。
爲了解決 cluster 模式下重複切割日誌的問題,須要對多個 worker 實例進行去重判斷,只容許一個實例進行日誌切割的操做。具體的判斷邏輯可分紅兩步:
app.pm2_env.instances > 1
則代表有多個相同的實例,可斷定爲 cluster 模式。app.name
應用名相同。所以,每第二天志切割的時候新建一個 map 映射表,key 爲 app.name
, 只有當映射表中沒有查詢到 app.name
的時候,纔會進行日誌切割。代碼部分大體以下所示:
pm2.list(function(err, apps) { ... var appMap = {}; apps.forEach(function(app) { // cluster模式下的去重判斷 if(app.pm2_env.instances > 1 && appMap[app.name]) return; appMap[app.name] = app; // 開始日誌切割 proceed_app(app, false); });
以上去重判斷的邏輯已經提交 PR 並被 merged 。該問題已在 pm2-logrotate
v2.7.0 的版本修復。
pm2-logrotate
進行日誌切割時,新的日誌文件名是根據 dateFormat
參數格式化生成的,源碼以下所示:
var DATE_FORMAT = conf.dateFormat || 'YYYY-MM-DD_HH-mm-ss'; var final_time = moment().format(DATE_FORMAT); var final_name = file.substr(0, file.length - 4) + '__' + final_time + '.log';
該參數的默認值 YYYY-MM-DD_HH-mm-ss
,能夠精確到秒級別。在實際生產環境,爲了便於檢索和查看日誌,文件的命名每每會按照按天/小時的維度進行命名。
可是若是 DATE_FORMAT
的格式爲 YYYY-MM-DD
,會存在這麼一個坑:當一天中應用日誌體積屢次超出設置的 max_size
後,會出現屢次切割的操做,可是每次切割生成的新日誌文件名倒是相同的。
在已有切割日誌的狀況下, pm2-logrotate
再次切割時不會判斷新的日誌文件是否已經存在,而是直接用新切割出來的日誌覆蓋了以前的日誌,從而會致使日誌丟失。
// 若是final_name對應的文件不存在則建立,存在則總體內容覆蓋 var writeStream = fs.createWriteStream(final_name, {'flags': 'w+'});
針對該問題的解決方案,一方面能夠經過設置 dateFormat
精確到秒級來避免;另外一方面,須要評估日誌文件的大小,合理設置 max_size
參數,避免頻繁觸發日誌切割。
egg-logrotator
是 Egg.js 框架擴展的日誌切割插件。相較於 pm2-logrotate
, 其配置更加靈活,同時支持按天切割、按小時切割和按大小切割。關鍵配置項以下:
須要按小時切割的文件列表,默認值爲 [ ]
按小時切割的文件,小時部分的分隔符號,默認值爲 '-'
須要按大小切割的文件列表,默認值爲 [ ]
日誌文件的最大致積,默認值爲 50 1024 1024
按大小切割時,文件最大切割的份數,默認值爲 10
按大小切割時,文件掃描的間隔時間, 默認值爲 60000
日誌保留的最久天數,默認值爲 31
具體切割的實現原理如圖所示:
其中的核心源碼以下,相較於 pm2-logrotate
,從這裏咱們能夠看出 egg-logrotator
則採用了另一種 sign 通知模式。
// rotate 日誌切割源碼 async rotate() { // 獲取須要切割的日誌文件列表 const files = await this.getRotateFiles(); const rotatedFile = []; for (const file of files.values()) { try { // 重命名日誌文件 await renameOrDelete(file.srcPath, file.targetPath); rotatedFile.push(`${file.srcPath} -> ${file.targetPath}`); } catch (err) { } } if (rotatedFile.length) { // reload 從新加載日誌文件 this.logger.info('[egg-logrotator] broadcast log-reload'); this.app.messenger.sendToApp('log-reload'); this.app.messenger.sendToAgent('log-reload'); } } } // reload從新加載日誌文件 reload() { this._closeStream(); // 結束當前日誌文件流 this._stream = this._createStream(); // 建立一個新的寫日誌文件流 }
就實現方式而言,copytruncate 模式較簡單,sign 通知模式則較爲複雜,須要有一套完整的通知重啓機制。
可是相較於基於 copytruncate 模式實現的pm2-logrotate
,基於 sign 通知模式實現的 egg-logrotator
具備兩點明顯的優點:
egg-logrotator
充分考慮了切割出的日誌文件命名問題。新的日誌文件會被命名爲 ${final_name}.1
。當已有切割文件時,會將原文件自增1,如文件名後綴加1( .log.1
-> .log.2
),直到日誌文件數量達到 maxFiles
最大值後,纔會直接覆蓋最後一份 .log.${maxFiles}
。這種實現方式可以最大程度上保證日誌的完整性。日誌切割是完善 Node.js 服務可觀測性的重要前提,是實現日誌可視化檢索、日誌監控告警和全鏈路日誌分析的前置條件之一。
本文對通用的日誌切割原理進行了闡述,展開說明了 Node.js 應用中兩種經常使用的日誌切割方案及其背後的實現機制,並對兩種實現方案進行了優劣對比。但願給你們在 Node.js 的生產實踐中帶來啓示和幫助。
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!