エラーの内容
Error: EMFILE: too many open files, open '/var/app/uploads/image.png'
at Object.openSync (node:fs:596:3)
at Object.readFileSync (node:fs:464:35)
プロセスがバッチ処理の途中で落ちます。警告もなく、グレースフルシャットダウンもなく — ただクラッシュするだけです。多くの場合、アップロードファイルの読み込み、画像のリサイズ、または並列での子プロセス起動中に発生します。
原因
OSには、1つのプロセスが同時に保持できるファイルディスクリプタの上限があります。Linuxのデフォルトは1024です。macOSはさらに厳しく256です。
Node.jsはノンブロッキングで設計されています。5000個のファイルの配列に対してfs.readFileをスロットルなしで呼び出すと、Node.jsはほぼ同時に5000個の操作を起動します。OSは1025番目のオープン呼び出しをEMFILEエラーで拒否します。
この問題には2つの原因があります:
- OSの制限がワークロードに対して低すぎる
- コードがファイルをクローズするより速くオープンしている — 並行処理の制御がない
修正1: ファイルディスクリプタの上限を引き上げる(ulimit)
まず現在の設定を確認しましょう:
ulimit -n # ソフトリミット
ulimit -Hn # ハードリミット
現在のシェルセッションのソフトリミットを引き上げます:
ulimit -n 65536
再起動後も設定を維持するには、Linuxの/etc/security/limits.confを編集します:
# /etc/security/limits.conf
* soft nofile 65536
* hard nofile 65536
systemdサービスで実行している場合は、ユニットファイルの[Service]セクションに以下を追加します:
[Service]
LimitNOFILE=65536
リロードして再起動します:
sudo systemctl daemon-reload
sudo systemctl restart your-app
macOSでの永続的な設定は以下のとおりです:
sudo launchctl limit maxfiles 65536 200000
プロセスが新しい制限を反映しているか確認する
# NodeプロセスのPIDを取得
pgrep -f 'node'
# 実際のfdリミットを確認
cat /proc/<PID>/limits | grep 'open files'
修正2: graceful-fsを使う(ドロップイン置き換え)
graceful-fsはNode.js組み込みのfsモジュールにパッチを当て、EMFILEが発生した際にクラッシュする代わりに操作をキューに入れます。設定は不要です。
npm install graceful-fs
インポートを置き換えるだけで、他は何も変更不要です:
// 変更前
const fs = require('fs');
// 変更後
const fs = require('graceful-fs');
ESモジュール構文:
import { readFile, writeFile } from 'graceful-fs';
内部では、graceful-fsは指数バックオフでEMFILEエラーをリトライします。Webpack、npm、Gulpがまさにこの理由で採用しており、大規模なファイルパイプラインで実績があります。
修正3: コード内の並行処理を制限する
リミットの引き上げとfsのパッチは症状への対処です。根本的な解決策は、同時に開くファイル数を制御することです。
悪いパターン — すべてのファイルを同時に開く
// 5000個のパスがあると、一度に5000個のreadFile呼び出しが発生する
const contents = await Promise.all(
filePaths.map(p => fs.promises.readFile(p))
);
オプションA: p-limitで並行数を制限する
npm install p-limit
import pLimit from 'p-limit';
import { readFile } from 'fs/promises';
const limit = pLimit(20); // 最大20ファイルを同時に開く
const contents = await Promise.all(
filePaths.map(p => limit(() => readFile(p)))
);
20並行の読み込みは、チューニングなしの多くのLinux環境で安全な上限です。ワークロードとファイルの中央値のサイズに応じて調整してください。
オプションB: ファイルを順番にバッチ処理する
import { readFile } from 'fs/promises';
async function readInBatches(paths, batchSize = 50) {
const results = [];
for (let i = 0; i < paths.length; i += batchSize) {
const batch = paths.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(p => readFile(p)));
results.push(...batchResults);
}
return results;
}
オプションC: 大きなファイルはメモリに読み込まずストリームで処理する
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
async function processFile(inputPath, outputPath) {
const source = createReadStream(inputPath);
const dest = createWriteStream(outputPath);
await pipeline(source, dest);
// ストリーム終了時にfdは自動的に解放される
}
ストリームは処理が完了するとすぐにファイルディスクリプタを解放します。大規模なファイルパイプライン — 動画処理や一括画像変換など — では、ほぼ常にこのモデルが正解です。
修正4: リークしたファイルディスクリプタを調査する
fdカウントが継続的に増加している場合はリークを示しています:ファイルが開かれたまま閉じられていません。実行中のプロセスを確認します:
# 開いているfdの数を確認
ls /proc/<PID>/fd | wc -l
# 何が開いているか詳細を確認
ls -la /proc/<PID>/fd
この数が時間とともに増加し、一向に減らない場合はリークがあります。よくある原因:
fs.open()を呼び出した後、対応するfs.close()がない- クリーンアップ処理をスキップするような未処理のPromiseリジェクション
- 起動したが待機していない子プロセス
エラーがクリーンアップをスキップできないよう、必ずfinallyブロックでクローズしましょう:
const fd = fs.openSync(path, 'r');
try {
// ... fdを使った処理
} finally {
fs.closeSync(fd);
}
推奨アプローチ
大量のファイルを処理する本番Node.jsアプリでは、3つの対策をすべて重ねてください — それぞれが他の対策でカバーできない部分をカバーします:
- systemdユニットに
LimitNOFILE=65536を設定する(一度の設定でデプロイ後も維持) - スパイク時の安全網として
graceful-fsをインストールする p-limitまたはバッチ処理で並行性を予測可能に保つ
小規模なワークロードであれば、これらのうち1つだけで回復できるかもしれません。実際の本番負荷 — 1回の実行で10,000ファイル以上 — では、インシデントが起きてからではなく、起きる前にすべての対策を整えておく必要があります。
確認方法
修正を適用したら、ワークロード実行中にfdカウントを監視します:
# 1秒ごとに更新されるfdカウントのライブ表示
watch -n 1 'ls /proc/$(pgrep -f node)/fd | wc -l'
正常なプロセスは安定しています — 数値は変動しますが、際限なく増加することはありません。カウントが落ち着き、アプリのクラッシュが止まれば完了です。

