Node.jsで数GBのJSONを「無慈悲に」捌く:JSONStreamによるメモリ支配戦略
エンジニアとしてキャリアを積めば、一度は対峙する「JSONの暴走」。数MBのAPIレスポンスなら`JSON.parse()`で十分だが、データレイクからのエクスポートやログのバッチ処理で数GBのJSONを扱う際、安易なパースはNode.jsのV8エンジンをOOM(Out of Memory)という断末魔と共にクラッシュさせる。
本稿では、単なる「ライブラリの使い方」を超え、V8のヒープメモリ管理をハックし、ストリーミングアーキテクチャで巨大データを制御下に置くための「極限のエンジニアリング」を伝授する。
—
1. なぜ `JSON.parse()` が悪なのか:メモリレイアウトの深淵
`JSON.parse()` は、入力文字列全体をメモリにロードし、完全に解釈して一つの巨大なオブジェクトツリーをヒープ上に構築する。この時、データサイズの3倍から5倍のメモリが消費される。
- シリアライズ・デシリアライズのコスト: 巨大な文字列を一度に確保するため、ガベージコレクション(GC)が頻発し、イベントループがブロッキングされる。
- ヒープの断片化: 巨大オブジェクトの構築は、V8のメモリ配置をいびつにし、最終的に`heap out of memory`を引き起こす。
我々アーキテクトが目指すべきは、データを「塊」としてではなく、「流体」として扱うことだ。ここで登場するのが `JSONStream` である。
—
2. JSONStreamによるパイプライン構築:メモリ効率の真髄
`JSONStream` は、Node.jsの `ReadableStream` から流れてくるバイト列を、JSONの構造を維持したまま最小単位で抽出する。これにより、メモリ上には常に「現在処理している要素」のみが存在することになる。
実践:巨大ファイル処理の最適化スクリプト
以下のコードは、数GBの巨大なJSON配列から特定条件のデータのみを抽出し、別のファイルへ書き出すパイプラインだ。
const fs = require(‘fs’);
const JSONStream = require(‘JSONStream’);
const { Transform } = require(‘stream’);
// メモリを極限まで節約するためのフィルタリング・ロジック
const filterStream = new Transform({
objectMode: true,
transform(chunk, encoding, callback) {
// chunkはパースされた単一のオブジェクト。これ以上メモリを食わない。
if (chunk.status === ‘active’) {
callback(null, JSON.stringify(chunk) + ‘\n’); // NDJSON形式で出力
} else {
callback(); // 不要なデータは破棄し、GCを促す
}
}
});
// ファイルストリームを開き、パイプラインを接続する
const readStream = fs.createReadStream(‘huge_data.json’);
const writeStream = fs.createWriteStream(‘filtered_data.jsonl’);
readStream
.pipe(JSONStream.parse(”)) // 配列の各要素を個別にストリームへ流す
.pipe(filterStream) // ロジック処理
.pipe(writeStream) // 書き出し
.on(‘finish’, () => console.log(‘処理完了:メモリ消費は常に一定に保たれました’));
—
3. DevOps的視点:Dockerコンテナでのメモリ限界突破
巨大ファイルを処理する際、コンテナのメモリ制限(`–memory`)とNode.jsのデフォルトのヒープ制限が衝突することが多い。これを回避し、かつパフォーマンスを最大化するためのDocker構成術を解説する。
`max-old-space-size` の最適化
Node.jsはデフォルトでヒープサイズを自動調整するが、コンテナ環境ではこれを見誤ることがある。`JSONStream` を使う場合、ヒープはあまり食わないため、むしろGCの頻度を下げてスループットを上げる設定が有効だ。
Dockerfileの最適化例
FROM node:18-alpine
V8のヒープ制限を明示的に設定する
ストリーム処理なら、あえて小さめ(例: 512MB)に制限し、
頻繁にGCを走らせてメモリ消費を安定させるのが定石
ENV NODE_OPTIONS=”–max-old-space-size=512 –nouse-idle-notification”
WORKDIR /app
COPY . .
RUN npm ci –only=production
CMD [“node”, “–expose-gc”, “processor.js”]
—
4. パイプラインにおける実務的なパフォーマンスチューニング
CI/CDパイプライン(GitHub Actions, GitLab CIなど)でこのスクリプトを動かす際、ボトルネックはメモリよりも「I/O」になることが多い。
アーキテクトの知見:バックプレッシャーの制御
ストリーム処理のキモは「読み込み速度」と「書き込み速度」の同期にある。
1. バックプレッシャーの監視: `writeStream.write()` が `false` を返した場合、メモリバッファが溢れかけている合図だ。`drain` イベントを待ち合わせることで、メモリ爆発を論理的に封じ込める。
2. パイプラインの非同期化: 大規模なバッチ処理を行う場合、`stream.pipeline` を使用してエラーハンドリングを強固にせよ。
const { pipeline } = require(‘stream/promises’);
// エラーハンドリングを確実に行い、パイプラインを安全に終了させる
async function run() {
await pipeline(
fs.createReadStream(‘huge.json’),
JSONStream.parse(”),
filterStream,
fs.createWriteStream(‘out.jsonl’)
);
console.log(‘完全同期パイプライン完了’);
}
—
結論:技術の真髄は「制御」にあり
巨大なJSONを扱う技術の本質は、ライブラリの便利さを享受することではなく、「データが計算機の中でどう流れ、どの瞬間にメモリを占有しているか」を完全にコントロールすることにある。
`JSONStream` はその強力な武器だが、真のアーキテクトはそこにバックプレッシャー制御、Dockerのヒープ最適化、そして非同期パイプラインによる堅牢性を組み合わせる。これらをマスターした時、あなたのNode.jsアプリケーションは、数GBのデータに動じない「鉄の意志」を持つことになるだろう。
現場で迷った時は思い出してほしい。メモリは「節約する」ものではなく、「流れを作り、管理するもの」であると。