使用子进程方式避免sharp rss占用过大
发布于 6 年前 作者 Shasharoman 3540 次浏览 来自 分享

使用子进程方式避免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内正常返回都是可接受的

因为上诉特性,所以选择子进程方式实现是可能的,而如果是一个高频、要求低延迟的接口,这种方式是万万不行的,另外这种接口还需要做一些防护措施,避免轻易遭受恶意攻击的负面影响。

为博客引流,博客原文

2 回复

文章写的不错,很详细。

对于这种队列性质功能,在设计之初就应该考虑使用子进程方式实现。既降低主进程占用也降低内存泄露几率

回到顶部