streamは明示的に閉じておこう [Node.js]
はじめに
こんにちは、Buzo(@buzou_muzou)です。
最近Node.jsで画像処理を行う処理を書きました。その際に、streamとpipeを使いました。
改めてドキュメントを読んでみると、autoClose
が有効な場合でもstreamを閉じる処理を明示的に書かないとリソースリークを起こしてしまう場合があることを知ったのでまとめてみました。
リソースリークが起きる可能性があるコード
以下のようなコードには問題があります。
import fs from "node:fs";
const readStream = fs.createReadStream('hoge.png');
const writeStream = fs.createWriteStream('fuga.png');
readStream
.on("error",(err) => {
console.log(err)
})
.pipe(writeStream)
.on("error",(err) => {
readStream.destroy()
console.log(err)
})
こちらのコードではhoge.png
という画像をストリームで読み込んで、fuga.png
にストリームで書き込みを行っています。また、writeStream
でエラーが起きたらreadStream
を閉じています。
このコードの問題は、readStream
でエラーが起きた場合にwriteStream
はcloseされずに残ってしまう点です。
Node.jsの公式ドキュメントを見てみると、下記のような記述が見つかりました。[1]
One important caveat is that if the Readable stream emits an error during processing, the Writable destination is not closed automatically. If an error occurs, it will be necessary to manually close each stream in order to prevent memory leaks.
(↓DeepLで翻訳)
重要な注意点として、Readableストリームが処理中にエラーを発した場合、Writable宛先は自動的にクローズされない。エラーが発生した場合、メモリ・リークを防ぐために、各ストリームを手動で閉じる必要がある。
ドキュメントによると、readStream
でエラーが発生したときは、明示的にwriteStream
を閉じない限り、writeStream
は閉じられないとあります。
fs.createWriteStream
にはautoClose
というパラメータがあり、デフォルトでtrue
になっています。そのことを知っていたので、明示的にwriteStream
を閉じなくてもstream元のreadStream
でエラーが起きればwriteStream
を閉じられると思っていました。
しかし、実際には全ての場合において空気を読んで自動的にwriteStream
を閉じてくれるわけではありませんでした。
error時にwriteStreamを明示的に閉じよう
上記を踏まえてエラー時にwriteStreamを閉じる処理を書くと以下のようになります。
import fs from "node:fs";
import sharp from "sharp";
const readStream = fs.createReadStream('hoge.png');
const writeStream = fs.createWriteStream('fuga.png');
readStream
.on("error", (err) => {
// writeStreamを明示的に閉じる
writeStream.end();
console.log(err)
})
.pipe(sharp().resize(50))
.pipe(writeStream)
.on("error", () => {
readStream.destroy();
console.log(err)
})
実際にローカルで実験してみる
それぞれのパターンをローカルで実行し、それぞれstreamが閉じられているかを確認してみます。
1.readでエラーが起きるパターン
a.エラー時に明示的にwriteStreamを閉じる場合
コード
import fs from "node:fs";
// 空文字を指定
const readStream = fs.createReadStream("");
const writeStream = fs.createWriteStream("fuga.png");
readStream
.on("error", (err) => {
// writeStreamを明示的に閉じる
writeStream.end();
console.log(err);
})
.on("close", () => {
console.log("📚Read stream closed🫡");
})
.pipe(writeStream)
.on("error", (err) => {
readStream.destroy();
console.log(err);
})
.on("close", () => {
console.log("📝Write stream closed🫡");
});
[Error: ENOENT: no such file or directory, open ''] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: ''
}
📝Write stream closed🫡
📚Read stream closed🫡
readStream
、writeStream
ともに閉じられている。
b.エラー時に明示的にwriteStreamを閉じない場合
コード
import fs from "node:fs";
// 空文字を指定
const readStream = fs.createReadStream("");
const writeStream = fs.createWriteStream("fuga.png");
readStream
.on("error", (err) => {
// writeStreamを明示的に閉じない
// writeStream.end();
console.log(err);
})
.on("close", () => {
console.log("📚Read stream closed🫡");
})
.pipe(writeStream)
.on("error", (err) => {
readStream.destroy();
console.log(err);
})
.on("close", () => {
console.log("📝Write stream closed🫡");
});
[Error: ENOENT: no such file or directory, open ''] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: ''
}
📚Read stream closed🫡
writeStream
は閉じられない。readStream
は閉じられている。
2.writeでエラーが起きるパターン
a.エラー時に明示的にreadStreamを閉じない場合
コード
import fs from "node:fs";
const readStream = fs.createReadStream("hoge.png");
// 空文字を指定
const writeStream = fs.createWriteStream("");
readStream
.on("error", (err) => {
writeStream.end();
console.log(err);
})
.on("close", () => {
console.log("📚Read stream closed🫡");
})
.pipe(writeStream)
.on("error", (err) => {
// readStreamを明示的に閉じる
readStream.destroy();
console.log(err);
})
.on("close", () => {
console.log("📝Write stream closed🫡");
});
[Error: ENOENT: no such file or directory, open ''] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: ''
}
📝Write stream closed🫡
📚Read stream closed🫡
readStream
、writeStream
ともに閉じられている。
b.エラー時に明示的にreadStreamを閉じない場合
コード
import fs from "node:fs";
const readStream = fs.createReadStream("hoge.png");
// 空文字を指定
const writeStream = fs.createWriteStream("");
readStream
.on("error", (err) => {
writeStream.end();
console.log(err);
})
.on("close", () => {
console.log("📚Read stream closed🫡");
})
.pipe(writeStream)
.on("error", (err) => {
// readStreamを明示的に閉じない
// readStream.destroy();
console.log(err);
})
.on("close", () => {
console.log("📝Write stream closed🫡");
});
[Error: ENOENT: no such file or directory, open ''] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: ''
}
📝Write stream closed🫡
readStream
は閉じられない。writeStream
は閉じられている。
まとめ
readStream
,writeStream
はお互いにエラー時に明示的にstreamを閉じる必要があることが分かりました。
長時間稼働するようなアプリケーションではリソースリークが重大な問題になってくるので注意が必要です。
当たり前ではありますが、日頃からちゃんとドキュメントを読んで実装していこうと思いました。
Discussion
Node.jsのstream回りって非常にややこしくてきちんと処理しようとすると大変ですよね。
私が理解する限り、stream.promises.pipelineはエラー時のリソース解放の面倒も見てくれるので大変便利です。
Node.jsはドキュメントの重複を避けるためか、昔からあったcallback版にしか詳細なドキュメントがないことがあって困ります。上記スニペットではpromises版を使っていますが以下はcallback版のドキュメントです。
ソースを掘ってみましたが、destroyを呼ばれたらcloseされるので、前述のfd leakは起きません。
open/closeはPromiseで呼ばれるのでclosedになるのは少なくともmicrotask queueまで遅延します。
ここ以降は追いきれなかったですが(多分ここ以降はNode.jsランタイムかlibuv実装です)、closeのエラーはハンドルしてないかもですから、errorイベントは拾ったほうがいいかもしれません。
src.destroy(err)
されるので、await stream.finished(src)
したり、await events.once(src, "close")
したりするとthrowしてしまうのでそこに注意が必要です。