2021/8 Node.js Lambda メモリやバイナリについて

佐々木誠

Lambdaに限った話ではありませんが、Node.jsで使用メモリに気をつけたことと、バイナリ処理について紹介します。

typescript&node.js

stream

Lambdaでは、メモリ量と使用時間が利用料となるため、不用意にメモリは増やしたくありませんので、実装も配慮が必要です。

S3からファイルダウンロード

普通に実装すると、以下のような形になると思います。

// ダウンロード
const { Body } = await s3.getObject({
    Bucket: 'foo',
    Key: 'bar',
}).promise();
// ファイルに保存
await fs.promises.writeFile(`/tmp/${new Date().getTime()}/somewhere`, Body);

数MBなら問題ないのですが、100MBオーバーのファイルだった場合、Body変数が当然100MB以上となってしまいます。 Lambdaのメモリはデフォルトで128MBなので、メモリが足りなくなってきます。

そこで、streamを使います。 メモリ観点から説明すると、streamは全データを一気にヒープにのせず、都度データをヒープにのせてコールバック関数で渡してくれるので、省メモリで大きなファイルを扱えます。

川
const stream = fs.createWriteStream(`/tmp/${new Date().getTime()}/somewhere`);
s3.getObject({
    Bucket: 'foo',
    Key: 'bar',
}).on('error', (err)=>{
    // エラー
    stream.destroy();
}).on('httpData', (chunk)=>{
    // 書き込み
    stream.write(chunk);
}).on('httpDone', ()=>{
    // 完了
    stream.end();
})
.send();

プログラム的に説明すると、先ほどのBody変数のように全データは扱えませんが、httpDateコールバック関数のchunk変数のようにダウンロード途中のデータを都度渡してくれます。 そして、渡してくれたデータをどんどんwriteStreamに書き出すことで、省メモリで100MBファイルをダウンロード&保存できます。

streamは仕組み上、どうしてもasync/awaitではなくコールバック関数となってしまうので、手間ですがPromiseでラップするのがオススメです。

highwatermarkで最適化

streamはまるでバケツリレーのように、何度も何度も渡していく仕組みです。

バケツリレー

このバケツのサイズを指定することで、処理スピードの最適化を図ることができます。

const write = fs.createWriteStream(`somewhere`);
↓↓↓
const write = fs.createWriteStream(`somewhere`, {highWaterMark: 1 * 1024 * 1024});

こちらの例では、 writeStreamhighWaterMarkを1MBに設定しました。 stream.write(chunk)でwriteされたデータ(バケツ)が1MBに達したら、実際にファイルに書き出すという仕組みです。

デフォルトのhighWaterMarkは16KBです。このままでは、100MB保存するには何度も実行されることとなり、保存処理として長くなってしまうという理屈です。逆に、highWaterMarkを大きくすればしただけ、メモリが必要になります。

実験的にさらに、highWaterMarkを10MBにして手元のLambdaで試しましたが、処理速度は1MBと全く変わりませんでした。

最適化するには実測しながら

を天秤にかけながら調整してください。

バイナリ

Node.jsでバイナリを扱うことが初めてだったので苦戦しました。

バイナリ

ここでは、以下のバイナリ解析例を紹介します。

最初の数バイトだけ

const buffer = await new Promise((resolve)=>{
    const stream = fs.createReadStream(target, {highWaterMark: 7});
    stream.on('data', (chunk) => {
        // 読んだので破棄
        stream.destroy();
        resolve(chunk);
    });
});

解析したいデータは3+4=7の先頭7バイトのデータだけです。 ファイルから全データを取得するのはメモリ的にもったいないので、先ほど紹介したstreamで先頭データだけ読み込みます。

streamは次々とデータを読み込んでしまうので、stream.destory()で以降のデータは読み込まないように節約することが、ここでのポイントです。

Bufferから文字列へ

まず、Node.jsでバイナリを扱うのは「Buffer」が基本のクラスとなります。 そして、先ほどのreadStreamで取得したデータもBufferです。 また、Node.jsで一般的なファイル読み込みreadFile()もデフォルトではBufferデータが帰ってきます。

const id = buffer.slice(0, 3).toString('ascii');
if(id != 'CVC'){
    console.log('識別子エラー');
}

Bufferにはslice()が備わっており、特定の範囲のバイトデータを取得できます。 そして取得したバイナリデータをascii文字列に変換して、このように文字列チェックができます。

Bufferから数値算出へ

続いてリトルエンディアン形式の数値を取得します。

ディスタンス
const data: = [];
// 4バイト以降のデータを1バイトづつ
buffer.slice(3).forEach((b)=>{
    const bHex = b.toString(16).padStart(2, '0');
    data.push(bHex);
});
// リトルエンディアンなので逆順に
data.reverse();

const hexString = data.join('');
const size = parseInt(hexString, 16);

1バイトは8ビットなので、16進数で考えます。 これは、自力でリトルエンディアンの順番を整えて、16進数文字列から10進数のsizeを算出しました。

ただ、この記事を書いている途中で気づいたのですが、Node.jsのBufferクラスにはreadUInt16LE()という関数が用意されていました。 おそらくこれを使えば、自力でエンディアンロジックを組むことなく、データを抽出できそうです。。。

終わりに

WebサイトとしてNode.jsを利用する場合は、役立つケースは少ないかもしれません。 しかし、いざという時の問題解決の糸口となることがあると思いますので、頭の片隅に記憶していただければと思います。

こんな記事も読まれています