Node.jsにおけるモダンなファイル操作
Node.jsの最新ファイル処理をfs/promises、stream、file handle、エラーコード、並行処理、path traversal対策で解説。
モダンなNode.jsでは、ファイルI/Oのデフォルト選択肢はPromiseベースのnode:fs/promisesモジュールとasync/awaitの組み合わせです。ファイルサイズが大きい場合やサイズが不明な場合はnode:stream/promisesのpipelineを使ったストリーム処理を、バイトレベルの位置指定アクセスが必要な場合はファイルハンドル(open/read/close)を使用します。fsモジュールには現在も同期・コールバック・Promiseという3つの並行APIが含まれており、古いチュートリアルの多くはコールバックAPIとCommonJSのrequire()を中心に解説しています。しかしそのアプローチは時代遅れです。現行ランタイム上の新規コードには、node:fs/promisesとESモジュールのimportが正しい出発点となります。
本記事では、このモダンAPIを使ったファイルの読み書きと処理について解説します。エンコーディングの挙動、上書きのセマンティクス、codeによるエラーハンドリング、Promise.allとPromise.allSettledを使った並行処理、readFileを破綻させるメモリ制限、大容量データ向けのストリームとファイルハンドル、ディレクトリ操作、そしてパストラバーサル対策を取り上げます。サンプルコードは2026年時点のActive LTSであるNode 24を対象としています(Node.jsリリーススケジュール)。紹介するすべての機能はそのベースラインを十分に下回っているため、コードはNode 24上でそのまま動作します。また、すべてのサンプルはESMのimportとnode:プレフィックス、およびトップレベルawaitを使用しています。
重要なポイント
- ファイルI/Oのデフォルトとして
node:fs/promisesとasync/awaitを使用してください。readFileSyncなどの同期メソッドはイベントループをブロックするため、単発実行のCLIスクリプトにのみ許容され、サーバーでは絶対に使用しないでください。 readFileはファイル全体をメモリにバッファリングし、2 GiBを超えるファイルに対してERR_FS_FILE_TOO_LARGEをスローします。これはBufferや文字列長の上限とは別の、libuvの固定I/O制限です。数百MiBを超える場合はすでにストリーミングを検討すべきです。node:stream/promisesのpipeline()(Node 15以降で安定版)はストリームを接続し、エラーの伝播とクリーンアップを自動的に処理します。- すべてのファイル読み込みが成功しなければならない場合は
Promise.allを、一部の失敗が許容される場合はPromise.allSettledを使用してください。 - 未検証のユーザー入力を
fs関数に渡さないでください。path.resolveでパスを解決し、意図したベースディレクトリ内に収まっていることを確認してパストラバーサルを防いでください。
3つのfs API、そしてfs/promisesがデフォルトである理由
Node.jsは同じファイル操作を3つのAPIで公開しています。同期(readFileSync)、コールバック(readFile(path, cb))、そしてPromise(node:fs/promises)です。Promise APIはNode 14.0.0で安定版となり(Node.js 14リリースノート)、async/awaitとクリーンに統合でき、イベントループをブロックしないため、モダンなデフォルトとなっています。コールバックAPIはPromise登場以前のレガシーであり、深くネストしたコードを生み出します。同期APIはI/O処理の間中ずっとイベントループをブロックします。新規コードにはPromise APIを使用してください。
import { readFile } from 'node:fs/promises'
const data = await readFile('config.json', 'utf8')
console.log(data.length)
node:プレフィックスはそのインポートがNode.jsのビルトインであることを示し、同名のnpmパッケージによって上書きされることを防ぎます。これによりモジュールがNode.jsのコアモジュールであることが明示されます。このサンプルでは**トップレベルawait**も使用しています。これはNode 14.8.0以降、フラグなしでESモジュール内で利用可能です(Node.js 14.8.0リリースノート)。モジュールスコープでファイル読み込みをasync IIFEでラップする必要はもうありません。
どのAPIを使うべきか
選択はファイルサイズ、必要なアクセス種別、そしてプロセスが並行処理を行うかどうかによって決まります。以下の表を判断基準として活用してください。
| シナリオ | 推奨API | 理由 |
|---|---|---|
| 小さなファイル(おおよそ100 MB未満) | node:fs/promisesのreadFile/writeFile | 最もシンプルなAPI。ファイル全体がメモリに収まり、1回のawaitで処理が完結する |
| 大きなファイル、またはサイズ不明のファイル | ストリーム:createReadStream/createWriteStreamとpipeline | サイズに関わらずメモリ使用量が一定で、readFileの2 GiB上限を完全に回避できる |
| バイトレベルまたは位置指定アクセス(特定オフセットへの読み書き) | ファイルハンドル:open/read/close | 特定のバイト範囲を任意の位置で読み書きできるのはハンドルAPIのみ |
| 単発のCLIスクリプトやビルドツール(並行処理なし) | 同期メソッドが許容される:readFileSync/writeFileSync | 他に何も実行されていない場合、イベントループのブロックは無害 |
| HTTPサーバーや並行処理を含むコード | 非同期のPromise APIのみ — 同期は絶対に不可 | イベントループがブロックされると、すべての保留中のリクエストが一度に停止する |
約100 MBという数値はストリームへの切り替えタイミングの実用的な目安であり、ハードリミットではありません。readFileがハードエラーになるのは2 GiBのlibuvキャップです。fs/promisesとストリームのどちらを選ぶか迷った場合、サイズを制御できないものに対してはストリーミングがより安全なデフォルトです。
fs/promisesによるファイル読み込み
Discover how at OpenReplay.com.
readFile()はファイル全体をメモリに読み込み、文字列またはBufferを返します。'utf8'などのエンコーディングを指定するとデコードされた文字列が得られ、省略すると生のバイト列がBufferとして返されます。設定ファイルやデータファイルは文字列として読み込んでパースします。
import { readFile } from 'node:fs/promises'
const raw = await readFile('config.json', 'utf8')
const config = JSON.parse(raw)
console.log(config.port)
エンコーディングを指定しない場合、戻り値はBufferになります。これは画像、音声、またはテキスト以外のペイロードに適した選択です。
import { readFile } from 'node:fs/promises'
const bytes = await readFile('logo.png') // Buffer
console.log(bytes.length, 'bytes')
JSON.parseは不正な入力に対してSyntaxErrorをスローするため、パース処理をtry/catchで囲むことでI/O失敗と不正なJSONの両方に対応できます。オプションの全詳細はfs.readFileドキュメントを参照してください。
fs/promisesによるファイル書き込み
writeFile()はファイルが存在しない場合は作成し、存在する場合は完全に上書きします。呼び出し元の視点では、1回のawaitでファイルの内容全体が置き換わります。ファイルを置き換えるのではなく追記するにはappendFile()を使用します。こちらもファイルが存在しない場合は作成します。ファイルを固定長に切り詰めるにはtruncate()を使用します。
import { writeFile, appendFile } from 'node:fs/promises'
const user = { name: 'Ada', email: 'ada@example.com' }
// user.jsonを作成するか、内容を完全に置き換える
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')
// ファイルを書き直さずに行を追加する。存在しない場合は作成する
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')
truncate(path, n)はファイルの先頭nバイトを保持し、残りを破棄します。引数は削除するバイト数ではなく保持するバイト数であることに注意してください。名前から受ける印象とは逆です。1234567890を5で切り詰めると12345が残ります。
import { writeFile, truncate, readFile } from 'node:fs/promises'
await writeFile('data.txt', '1234567890')
await truncate('data.txt', 5)
console.log(await readFile('data.txt', 'utf8')) // '12345'
エラーハンドリング:error.codeによる分岐
ファイル操作はcodeプロパティを持つシステムエラーで失敗します。エラーメッセージをパースするのではなく、error.codeで分岐してください。最もよく扱うコードはNode.jsの一般的なシステムエラーリファレンスに記載されています。
error.code | 意味 |
|---|---|
ENOENT | ファイルまたはディレクトリが存在しない |
EACCES | 権限が拒否された |
EISDIR | ディレクトリをファイルとして読み込もうとした |
ENOSPC | デバイスに空き容量がない(ディスクフル) |
EMFILE | オープンしているファイルディスクリプタが多すぎる |
1つのtry/catchでI/O失敗とJSONのパース失敗の両方をカバーできます。
import { readFile } from 'node:fs/promises'
try {
const config = JSON.parse(await readFile('config.json', 'utf8'))
console.log('loaded', config)
} catch (error) {
if (error.code === 'ENOENT') {
console.error('config.json is missing; using defaults')
} else if (error.code === 'EACCES') {
console.error('no permission to read config.json')
} else {
throw error // JSON.parseのSyntaxErrorや予期しないコードを含む
}
}
明示的に処理しないコードは飲み込まずに再スローしてください。ENOSPCやEMFILEエラーを黙って捕捉してしまうのは、本当の原因を隠蔽するよくある本番障害のパターンです。
同期 vs 非同期:同期メソッドが許容されるケース
同期fsメソッド(readFileSync、writeFileSync)はI/O操作の間中ずっとNode.jsのイベントループをブロックし、その間は他のJavaScriptが実行されません。並行処理が存在しない単発のCLIスクリプトやビルドツールでは許容されますが、HTTPサーバーや並行リクエストを処理するコードでは絶対に使用しないでください。イベントループがブロックされると、すべての保留中のリクエストが一度に停止するからです(Node.jsイベントループガイド)。
// 許容される:単発で実行して終了するスクリプト
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
console.log(pkg.version)
判断基準:プロセスが複数のものを同時に処理する場合は、非同期のPromise APIを使用してください。
並行処理:Promise.all vs Promise.allSettled
すべてのファイル読み込みが成功しなければならず、1つの失敗でバッチ全体を中断すべき場合はPromise.allを使用してください。一部の失敗が許容され、成功したものを処理したい場合はPromise.allSettledを使用してください。Promise.allSettledは常に解決し、各エントリが{ status: 'fulfilled', value }または{ status: 'rejected', reason }のいずれかである配列を返します。
ループ内でawaitせずにファイル名をPromiseにマッピングすることで、読み込みを並行実行できます。
import { readFile } from 'node:fs/promises'
const files = ['a.json', 'b.json', 'c.json']
// 全か無か:1つのファイルが欠けているとバッチ全体がrejectされる
const all = await Promise.all(
files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)
存在するものをロードして残りを報告したい場合は、settled結果を検査します。
import { readFile } from 'node:fs/promises'
const files = ['a.json', 'b.json', 'missing.json']
const results = await Promise.allSettled(
files.map((f) => readFile(f, 'utf8')),
)
const loaded = results.filter((r) => r.status === 'fulfilled').map((r) => r.value)
const failed = results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason.code) // 例:'ENOENT'
console.log(`loaded ${loaded.length}, failed:`, failed)
fs promisesと大容量ファイル:readFileを破綻させるメモリ制限
readFileはファイル全体をメモリにバッファリングし、2 GiBを超えるファイルに対してERR_FS_FILE_TOO_LARGEをスローします。これはBufferサイズの制限ではなく、libuvの固定I/O制限です(node#55864、ERR_FS_FILE_TOO_LARGE)。そのハードキャップに達するはるか前に、数百メガバイトを単一のバッファに読み込むとメモリ圧迫とレスポンス低下を引き起こすため、数百MiBを超えた時点でストリーミングを検討すべきです。
混同しやすい3つの異なる上限があり、多くのチュートリアルではこれらが混在しています。
| 制限 | 値(64ビット) | 適用対象 |
|---|---|---|
| libuvのファイル読み込みキャップ | 2 GiB | あらゆるファイルへのreadFile。ERR_FS_FILE_TOO_LARGEをスロー |
buffer.constants.MAX_STRING_LENGTH | 536,870,888バイト(約512 MiB) | 文字列、つまりエンコーディングを指定したreadFile |
buffer.constants.MAX_LENGTH | 2⁵³−1バイト(約8 PiB) | Bufferの最大アロケーション |
readFileにエンコーディングを指定した場合(文字列を返す場合)、実効上限はbuffer.constants.MAX_STRING_LENGTHです。64ビットプラットフォームでは536,870,888バイトで、Node 14.4.0で約1 GBから引き下げられました(node#33960)。この上限を超えると「Cannot create a string longer than…」エラーがスローされ、ERR_FS_FILE_TOO_LARGEではありません。buffer.constants.MAX_LENGTHはBufferの最大アロケーション値であり、これもまた別の制限です。readFileの上限とは関係なく、readFileは2 GiBで失敗します。実用的な結論:BufferキャップからではなくlibuvのI/O制限からreadFileの挙動を考えてください。2 GiBのlibuvリミットが最初に問題になります。
ストリーム:createReadStream、createWriteStream、pipeline
ストリームはファイルデータをチャンク単位で処理し、ファイル全体をメモリにバッファリングしません。そのため、大容量またはサイズ不明のファイルに適したツールです。createReadStreamとcreateWriteStreamはReadableストリームとWritableストリームを生成します。これらをNode 15以降で安定版となったnode:stream/promisesのpipelineで接続します。pipeline()はReadableストリームをWritableストリームに接続し、エラーの伝播とストリームのクリーンアップを自動的に処理します。pipe()もバックプレッシャーを管理しますが、pipeline()はより安全なエラーハンドリングとクリーンアップを提供します。
import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
// 一定のメモリ使用量で任意サイズのファイルをコピーする
await pipeline(
createReadStream('huge-input.log'),
createWriteStream('huge-output.log'),
)
pipeline()はストリームエラーが発生するとrejectし、失敗時にストリームを破棄するPromiseを返すため、1つのtry/catchで十分です。'error'リスナーを手動でセットアップする必要はありません。
バックプレッシャーは、高速なリーダーが低速なライターを圧倒しないようにするメカニズムです。Writableの内部バッファが満杯になると、Readableはドレインされるまで一時停止します。バッファの閾値はhighWaterMarkです。ファイル読み込みストリームのデフォルトhighWaterMarkは64 KiBで、汎用stream.Readableのデフォルト16 KiBとは異なります。fsドキュメントではこの64 KiBの値がファイルストリームのデフォルトとして明示されています。プロファイリングで効果が確認できた場合にチューニングしてください。
import { createReadStream } from 'node:fs'
const stream = createReadStream('data.bin', { highWaterMark: 128 * 1024 })
CSVのような行区切り形式には、手動でパースするのではなく、csv-parserなどのパーサーを通じて読み込みストリームをパイプしてください。
ファイルハンドル:バイトレベルと位置指定アクセス
特定のオフセットにある特定のバイト範囲を読み書きする必要がある場合は、open()から取得したファイルハンドルを使用してください。これはreadFileやストリームでは公開されていない機能です。FileHandleは低レベルのリソースです。バッファと位置は自分で管理する必要があり、finallyブロックで必ずclose()を呼び出してリリースしなければなりません。そうしないとディスクリプタがリークし、リークが積み重なるとEMFILEが発生します。
import { open } from 'node:fs/promises'
async function readInChunks(path) {
let handle
try {
handle = await open(path, 'r')
const chunkSize = 64 * 1024
const buffer = Buffer.alloc(chunkSize)
let position = 0
while (true) {
const { bytesRead } = await handle.read(buffer, 0, chunkSize, position)
if (bytesRead === 0) break
// buffer.subarray(0, bytesRead)を処理する
position += bytesRead
}
} finally {
await handle?.close()
}
}
handle.read(buffer, offset, length, position)は指定されたファイルのpositionからbufferを埋め、bytesReadを報告します。positionを手動で追跡することで位置指定アクセスが可能になります。コピーと変換の処理にはストリームの方が適したツールです。ファイルハンドルの冗長さが正当化されるのは、バイトレベルの制御が本当に必要な場合のみです。
AbortSignalはreadFileとfs.watchでサポートされているため、リクエストがタイムアウトした場合などに遅い読み込みをキャンセルできます。中断された読み込みはnameが'AbortError'のエラーでrejectされます。
import { readFile } from 'node:fs/promises'
const controller = new AbortController()
setTimeout(() => controller.abort(), 1000)
try {
await readFile('slow-source.bin', { signal: controller.signal })
} catch (error) {
if (error.name === 'AbortError') console.error('read cancelled')
else throw error
}
ディレクトリとパス
ネストしたディレクトリはmkdir({ recursive: true })で作成し(すでに存在してもエラーにならない。mkdir -pと同様)、エントリの一覧はreaddir({ withFileTypes: true })でDirentオブジェクトとして取得し、ツリーの削除はrm({ recursive: true })で行います。これらはすべて同じnode:fs/promisesモジュールから利用できます。パスの構築にはpath.joinを使用して、OS間でセパレーターが正しくなるようにしてください。
import { mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
await mkdir('output/reports', { recursive: true })
const entries = await readdir('output', { withFileTypes: true })
for (const entry of entries) {
const full = join('output', entry.name)
if (entry.isFile()) console.log('file:', full)
else if (entry.isDirectory()) console.log('dir: ', full)
}
await rm('output/reports', { recursive: true, force: true })
配列のイテレートにはfor...inではなくfor...ofを使用してください。配列に対してfor...inを使うと値ではなく文字列のインデックスが得られるため、古いガイドでよく見られるバグの原因になります。ディレクトリツリーの削除にはrm(recursive: true付き)が現在の方法です。rmdirの再帰オプションは非推奨となっています。
プロセスのワーキングディレクトリではなく現在のモジュールからの相対パスでファイルを参照するには、import.meta.dirnameを使用してください。Node 20.11.0以降で利用可能なimport.meta.dirnameは、CommonJSで__dirnameが提供していたのと同じカレントディレクトリの参照をESMモジュールに提供します。
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const config = await readFile(join(import.meta.dirname, 'config.json'), 'utf8')
権限とオーナーシップの関数(chmod、chown)とリンク関数(symlink、link)もPromise APIに存在しますが、Unixライクなシステム向けであり、Windowsでは予期しない動作やエラーが発生します。これらはプラットフォーム固有として扱ってください。
セキュリティ:ユーザー入力によるパストラバーサルの防止
ユーザーが提供した文字列をreadFile、writeFile、またはその他のfs関数に直接渡さないでください。処理を進める前にpath.resolveでパスを正規化し、意図したベースディレクトリ内に収まっていることを確認してください。そうしないと、../../../etc/passwdのような入力が相対パスの制約を突き破ってしまいます。単純なjoin(baseDir, userInput)では保護になりません。..セグメントが上位に解決されてしまうからです。
import { resolve, sep } from 'node:path'
import { readFile } from 'node:fs/promises'
const baseDir = resolve('uploads')
async function readUserFile(userPath) {
const target = resolve(baseDir, userPath)
if (target !== baseDir && !target.startsWith(baseDir + sep)) {
throw new Error('path traversal blocked')
}
return readFile(target, 'utf8')
}
最初に解決してからbaseDir + sepに対してプレフィックスをチェックすることで、入力に..セグメントがいくつ含まれていても、リクエストを許可されたディレクトリ内に閉じ込めることができます。+ sepガードは、uploads-privateのようなサブディレクトリが単純なstartsWith('uploads')チェックを通過してしまうのを防ぎます。
まとめ
新規のNode.jsコードでは、node:fs/promisesとasync/awaitから始め、ファイルが数百メガバイトを超えるかサイズが不明な場合はpipeline()を使ったストリームに移行し、バイトレベルの位置指定アクセスが必要な場合のみファイルハンドルに降りてください。各操作を実際の制約に合わせて選択してください。最も問題になりやすいのは、メモリ上限、並行処理、そして信頼できない入力の3つです。オプションの全詳細が必要な場合は公式fsドキュメントを参照してください。次のステップは、サーバー内の既存のコールバックベースまたは同期のfsコードを監査し、Promise APIに移行することです。
よくある質問
Node.jsにおけるfsとfs/promisesの違いは何ですか?
どちらも同じファイル操作を参照しますが、公開するインターフェースが異なります。ベースのnode:fsモジュールは同期メソッド(readFileSync)とコールバックベースの非同期メソッド(コールバック引数を取るreadFile)を提供し、node:fs/promisesは同じ操作のPromiseを返すバージョンを提供してasyncとawaitで動作します。Promise APIはNode 14.0.0で安定版となり、コールバックのネストを避けてイベントループをブロックしないため、新規コードの推奨デフォルトとなっています。
fs/promisesでrequireを使えますか?それともESモジュールが必要ですか?
どちらでも使用できます。ESモジュールではimport { readFile } from 'node:fs/promises'と記述します。CommonJSではconst { readFile } = require('node:fs/promises')と記述します。Promise API自体はESMを必要としません。トップレベルのawaitとimport.meta.dirnameのみがESモジュールのコンテキストを必要とします。node:プレフィックスはどちらの形式でも機能し、インポートをNode.jsのビルトインとしてマークし、npmパッケージによって上書きされることを防ぎます。
なぜreadFileはBufferサイズの制限に達する前にERR_FS_FILE_TOO_LARGEをスローするのですか?
ERR_FS_FILE_TOO_LARGEは単一の読み込み操作に対するlibuvの固定2 GiB I/Oキャップであり、Bufferのアロケーション制限とは独立しています。2 GiBをわずかに超えるファイルは、buffer.constants.MAX_LENGTHがはるかに大きな値であっても失敗します。これは基礎となる読み込みsyscallのパスがBufferが保持できるメモリ量に関わらず2 GiBの上限を強制するからです。より大きなファイルを処理するには、ファイル全体をバッファリングするのではなく、pipelineを使ったストリームを使用してください。
特定のバイトオフセットでファイルの一部を読み込むにはどうすればよいですか?
node:fs/promisesのopen()でファイルを開いてFileHandleを取得し、handle.read(buffer, offset, length, position)を呼び出します。これは指定されたファイルのpositionからbufferを埋め、bytesReadを報告します。ファイルを移動するために複数の読み込みにわたってpositionを手動で追跡してください。必ずfinallyブロックでhandle.close()を呼び出してハンドルを解放してください。そうしないとディスクリプタがリークし、リークが積み重なるとEMFILEが発生します。通常のreadFileとストリームは位置指定アクセスを公開していません。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k