使用子進程方式避免sharp rss佔用過大

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

因爲上訴特性,所以選擇子進程方式實現是可能的,而如果是一個高頻、要求低延遲的接口,這種方式是萬萬不行的,另外這種接口還需要做一些防護措施,避免輕易遭受惡意攻擊的負面影響。

博客原文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章