使用子進程方式避免sharp rss佔用過大
前些日子寫了一篇文章-記一次內存泄漏處理,曲折過後發現並不是內存泄漏問題,而是rss佔用過大,這篇文章講述自己解決這個rss佔用過大問題的一種方式:子進程調用。
前情回顧
應用中有一個服務端合成海報圖片的功能,這個功能借助第三方庫sharp實現,在應用運行一段時間後發現內存佔用在500M以上,最初當成內存泄漏定位並在mac下重現了內存泄漏現象,但劇情反轉,同樣的代碼在linux下並沒有出現內存泄漏現象,隨後經過一系列驗證發現的確是rss佔用過大的問題,最起碼在linux下是。
詳細內容可以參考記一次內存泄漏處理這篇文章,或者這個issue。
tips
rss是resident set size的縮寫,即實際使用物理內存,rss佔用過大與內存泄漏的區別在於前者是內存需求後者則是BUG,如果機器內存不夠兩者都會導致OOM(Out Of Memory)異常最終系統崩潰
子進程
進程是操作系統進行資源分配和調度的基本單位,從一個運行中的進程(父進程)中創建一個新的進程,這個新的進程就稱爲子進程,在Node.js中創建子進程主要依靠child_process
模塊,但這個模塊提供了多種創建的方式,相互之間也有一些差異,下面做一些簡單的說明和對比。
child_process.spawn
spawn(command[, args][, options])
執行command命令,args是命令行參數數組,options則是關於子進程行爲控制的選項,常用而且重要的有detached與stdio。
detached
分離,意思是子進程是否從父進程中分離出來,分離出來的子進程在父進程退出後仍可運行,未分離的子進程則受到linux下進程組等概念約束會隨主進程一起退出。
曾經在做一個測試環境根據git webhook通過子進程方式自動部署功能時踩過這個坑,由於沒有設置detached爲true,當子進程通過pm2 reload xxx
時有概率先重啓自己的父進程導致自己被殺掉了,從而導致其他進程沒有重啓。
stdio
stdio是standard input output的簡稱,標準輸入輸出,此處主要指如何處理子進程的stdin、stdout與stderr,主要有三種方式:
- pipe
pipe可以爲子進程創建管道,靈活的處理子進程輸入輸出相關邏輯,這是默認行爲
- ignore
ignore會忽視子進程的輸入輸出,類似在shell中執行命令沒有輸入並把輸出重定向到/dev/null
- inherit
inherit是共享父進程的標準輸入輸出,如父進程的
console.log
是輸出在屏幕上,則子進程中的輸出同樣會打印在屏幕上
child_process.exec
exec(command[, options][, callback])
在shell中執行command命令,callback函數會返回err、stdout與stderr,options與spawn類似也有不同。
首先是command的區別,在spawn中命令行參數通過args數組傳遞,在exec中則是與command拼接在一起,拿列出/root
目錄下的文件舉例:
const cp = require('child_process');
// spawn
cp.spawn('ls', ['/root'], {});
// exec
cp.exec('ls /root', {}, (err, stdout, stderr) => {});
其次是stdio的區別,exec中options參數不支持傳stdio,也就無法對子進程進行輸入,但輸出可以通過callback獲取,不過需要注意options.maxBuffer參數會限制輸出的最大長度,默認大小200 * 1024。
從設計上看,exec比spawn更適合執行一些持續過程短暫的命令,苦於未找到exec如何支持stdin,後面還是選擇了spawn方式。
child_process.execFile
execFile(file[, args][, options][, callback])
與exec基本一樣,區別是直接運行執行文件而不是通過shell,可以帶來些許性能優勢。
child_process.fork
fork(modulePath[, args][, options])
fork是spawn的一個特例,專門用於生成新的Node.js進程,modulePath是一個Node.js可執行文件的路徑,這種方式會在父子進程間建立一個額外的通信通道,這對於需要頻繁溝通的父子進程來說,是一個非常有幫助的特性。
除fork外,另外幾個方法也都有對應的同步版本,就不再進行說明,如果對child_process不熟悉的童鞋強烈建議看看官方文檔,然後寫代碼熟悉一下,沒有一兩個小時的思考和琢磨,弄不太明白。
實現思路
由於sharp合成圖片這個功能會導致應用多佔用300+M內存(長期累積,sharp內部不及時釋放),所以就設想把該功能用子進程調用的方式實現,每次調用子進程合成圖片,隨後子進程被銷燬其對應的內存也就釋放了。
子進程依舊使用Node.js腳本,輸入需要合成的圖片信息,返回合成後的圖片,這裏選擇使用spawn方法創建子進程,圖片以及信息輸入採用offset + buffer的方式。
使用sharp合成的原理是有一張background原圖,然後疊加其他圖片如img1:{top, left}
、img2:{top, left}
等,由於所疊加的圖片是變量,因此需要由應用(父進程)傳遞給子進程,普通信息傳遞一般選擇json,但img的內容是buffer導致直接使用json不可信,所以這裏選擇採用offset + buffer的方式:
通過命令行參數傳遞offset、通過spawn的標準輸入傳遞一個完整buffer,在子進程內部根據offset按規則解析buffer,還原所有的信息
假設需要在background上合成img1(size: 100, top: 0, left: 0)與img2(size: 50, top: 100, left: 100),我們先構建一個原始的buffer數組:
buffer = [buffer(background), buffer(JSON.stringify([100, 0, 0])), buffer(img1), buffer(JSON.stringify([50, 100, 100])), buffer(img2)]
隨後根據數組中每一個buffer長度得到一個offset數組:
offset = [12738, 281, 18282, 281, 28329] // 瞎填的
最終傳遞offset.join('|')
與Buffer.concat(buffer)
,至於子進程內部如何還原參考後面的代碼。這裏也可以選擇將圖片內容base64編碼後使用json方式傳遞,但offset + buffer的方式理論上更高效。
簡化實現代碼
爲了減少文章長度,下面是經過簡化的代碼且未經過測試,主要用於傳達一些實現細節。
應用內邏輯:
const C = require('../constant');
const cp = require('child_process');
const nPath = require('path');
const _ = require('lodash');
const Promise = require('bluebird');
// 等待被疊加的圖片數組,如用戶頭像,商品信息等等
let images = [];
let buffer = _.flatten(_.map(images, item => {
// 子進程可以根據name做一些其他處理,如製作圓形圖片,白色邊框等等
// offset是圖片合成時在背景圖上的位置
return [Buffer.from(JSON.stringify(_.pick(item, ['name', 'offset'])), 'utf8'), item.buffer];
}));
buffer.unshift(C.SHARE_BUFFER); // 背景圖
let offset = _.map(buffer, item => item.length);
return spawn('node', [nPath.join(__dirname, 'sharpPost.js'), offset.join('|')], {
stdin: Buffer.concat(buffer)
}).then(ret => {
if (!_.isEmpty(ret.stderr)) {
return Promise.reject(new Error(ret.stderr.toString()));
}
return ret.stdout; // 合成後的圖片
});
// spawn的二次包裝
function spawn(command, args, options = {}) {
return new Promise((resolve, reject) => {
let stdout = [];
let stderr = [];
let fork = cp.spawn(command, args, _.assign({
detached: true
}, options));
let timeout = setTimeout(() => {
if (fork.connected) {
fork.stdin.pause();
fork.kill();
}
reject(new Error('resulted in a timeout.'));
}, 1000 * 60 * 5); // five minutes
fork.stdin.once('error', reject);
if (options.stdin) {
let input = new stream.PassThrough();
input.end(options.stdin);
input.pipe(fork.stdin);
}
else {
fork.stdin.end(null);
}
fork.stdout.on('data', data => stdout.push(data));
fork.stderr.on('data', data => stderr.push(data));
fork.on('error', reject);
fork.on('close', (code, signal) => {
clearTimeout(timeout);
if (code !== 0 || signal !== null) {
let err = new Error('command failed.');
err.code = code;
err.signal = signal;
return reject(err);
}
return resolve({
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr)
});
});
});
}
腳本文件sharpPost.js:
const Promise = require('bluebird');
const sharp = require('sharp');
sharp.cache(false);
let offset = process.argv[2].split('|').map(item => Number(item));
let buffer = [];
process.stdin.on('data', chunk => {
buffer.push(chunk);
});
process.stdin.on('end', () => {
buffer = Buffer.concat(buffer);
let background = buffer.slice(0, offset[0]);
let items = [];
let current = offset[0];
for (let i = 1; i < offset.length; i = i + 2) {
let item = JSON.parse(buffer.slice(current, current + offset[i]).toString('utf8'));
item.buffer = buffer.slice(current + offset[i], current + offset[i] + offset[i + 1]);
items.push(item);
current += offset[i] + offset[i + 1];
}
return Promise.reduce(ret, (buffer, item) => {
return sharp(buffer)
.overlayWith(item.buffer, {
top: item.offset[0],
left: item.offset[1]
})
.toBuffer();
}, background).then(ret => {
return sharp(ret).jpeg().toBuffer();
}).then(ret => {
process.stdout.write(ret);
return {};
});
}
總結
sharp合成圖片的代碼沒有從應用中獨立出來時,線上應用啓動10小時後內存佔用穩定在500M左右,而採用子進程調用的方式後應用內存大小穩定在200M左右,效果顯著。
這個圖片合成功能本身具有一些特性:
- 低頻調用,一天200次左右
- 高資源佔用,CPU/內存
- 對響應延遲不敏感,1-2s內正常返回都是可接受的
因爲上訴特性,所以選擇子進程方式實現是可能的,而如果是一個高頻、要求低延遲的接口,這種方式是萬萬不行的,另外這種接口還需要做一些防護措施,避免輕易遭受惡意攻擊的負面影響。