使用子进程方式避免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内正常返回都是可接受的
因为上诉特性,所以选择子进程方式实现是可能的,而如果是一个高频、要求低延迟的接口,这种方式是万万不行的,另外这种接口还需要做一些防护措施,避免轻易遭受恶意攻击的负面影响。
为博客引流,博客原文
文章写的不错,很详细。
对于这种队列性质功能,在设计之初就应该考虑使用子进程方式实现。既降低主进程占用也降低内存泄露几率