2021/8 Node.js Lambda メモリやバイナリについて
Lambdaに限った話ではありませんが、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});
こちらの例では、 writeStream
のhighWaterMark
を1MBに設定しました。
stream.write(chunk)
でwriteされたデータ(バケツ)が1MBに達したら、実際にファイルに書き出すという仕組みです。
デフォルトのhighWaterMark
は16KBです。このままでは、100MB保存するには何度も実行されることとなり、保存処理として長くなってしまうという理屈です。逆に、highWaterMark
を大きくすればしただけ、メモリが必要になります。
実験的にさらに、highWaterMarkを10MBにして手元のLambdaで試しましたが、処理速度は1MBと全く変わりませんでした。
最適化するには実測しながら
- 扱うデータの大きさ
- サーバのメモリ量
- 達成したい速度と粒度
を天秤にかけながら調整してください。
バイナリ
Node.jsでバイナリを扱うことが初めてだったので苦戦しました。
ここでは、以下のバイナリ解析例を紹介します。
- 最初の数バイトだけ取り出す
- 先頭3バイトのデータが「CVC」となっているか
- 次の4バイトから、リトルエンディアン形式で数値を取得する
最初の数バイトだけ
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を利用する場合は、役立つケースは少ないかもしれません。 しかし、いざという時の問題解決の糸口となることがあると思いますので、頭の片隅に記憶していただければと思います。