🤓

V8 JavaScript engineにおけるオンヒープメモリとオフヒープメモリを理解してNext.jsのビルドを救う

に公開

この記事はファインディエンジニア Advent Calendar 2025 16日目の記事です。

こんにちは。ソフトウェアエンジニアの佐藤(@t0m0h1r0x)です。

私が携わるプロダクトは、Next.jsアプリケーションをstandaloneで稼働しており、Docker内でビルドを実行しています。
このアプリケーションのパッケージマネージャーをnpmからpnpmに変えたところ、Dockerビルドがクラッシュするようになりました。[1]
具体的にはページ生成処理のタイミングでcannot allocate memoryエラーが発生するようになりました。

過去にJavaScript heap out of memoryでメモリが枯渇する現象に遭遇したことはありますが、今回のケースは初めてのケースでした。
最初はメモリが不足してるなら増やせば解消されるだろうと考え、--max-old-space-sizeの値を増やしましたが解消されませんでした。

原因を探るためprocess.memoryUsage()でメモリの使用状況を確認してみると、externalarrayBuffersの数値が大きくなっていることがわかりました。

console.log(process.memoryUsage());
// ピーク時の数値
// {
//   rss: ...,
//   heapTotal: ...,
//   heapUsed: ...,
//   external: 1848566088(約1.7GB),
//   arrayBuffers: 1813312963(約1.7GB)
// }

これはどういうことなのか?なぜV8のヒープオプションが効かないのか?

調べていくうちに、V8における オンヒープメモリオフヒープメモリ の概念に行き着きました。今回はArrayBufferを軸に、その仕組みを深掘りしていきます。[2]

オンヒープメモリとオフヒープメモリとは

簡単に言うと、V8が直接GC(ガベージコレクション)するのがオンヒープメモリで、しないのがオフヒープメモリです。この概念自体はJVMなど他のランタイムでも一般的なものです。

この概念を理解するために、ArrayBufferの実装を見ていきます。

ArrayBuffer

ArrayBufferはバイナリデータのバッファを確保するためのオブジェクトです。

const buffer = new ArrayBuffer(8);

ArrayBufferを作成すると、内部でオンヒープメモリとオフヒープメモリそれぞれにオブジェクトが作成されます。

  • オンヒープメモリ: JSArrayBuffer(JavaScriptのArrayBufferを表現するオブジェクト)
  • オフヒープメモリ: バイナリデータとBackingStore(バイナリデータを管理するオブジェクト)[3]

上記のJavaScriptコードを実行すると、まずオフヒープメモリにバイナリデータとBackingStoreが作成されます。
その後、オンヒープメモリにバイナリデータへのポインタを持つJSArrayBufferが作成されます(詳細は後述)。

コードで追ってみるとこんな感じです

new ArrayBuffer(8)を実行すると、V8内部では次の順で処理が行われます


https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/builtins/builtins-arraybuffer.cc


1. エントリーポイント

// builtins-arraybuffer.cc;l=139-189
BUILTIN(ArrayBufferConstructor) {
  // ...
  return ConstructBuffer(isolate, target, new_target, number_length,
                         number_max_length, InitializedFlag::kZeroInitialized);
}

まずConstructBuffer()が実行されます。


2. バイナリデータとBackingStoreの確保(オフヒープメモリ)

// builtins-arraybuffer.cc;l=119
auto [backing_store, range_error] = TryAllocateBackingStore(
    isolate, shared, resizable, length, max_length, initialized);
// builtins-arraybuffer.cc;l=67-68
backing_store =
    BackingStore::Allocate(isolate, byte_length, shared, initialized);

ここでオフヒープメモリにバイナリデータとBackingStoreが作成されます。
malloc / calloc は下記処理中で行われます。
https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/objects/backing-store.cc;l=208-270
https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/heap/heap.cc;l=3132-3158


nodeリポジトリのLLDBでデバッグすると、BackingStore::Allocate()が呼ばれ、base::Calloc()が実行されていることが確認できます

lldb -b -o "b ConstructBuffer" \
	-o "r" \
	-o "bt 20" \
	-o "b TryAllocateBackingStore" \
	-o "c" \
	-o "bt 20" \
	-o "b v8::internal::BackingStore::Allocate" \
	-o "c" \
	-o "bt 20" \
	-o "s" \
	-o "b v8::base::Calloc" \
	-o "c" \
	-o "bt 20" \
	-o "q" \
	-- ./out/Debug/node -e "new ArrayBuffer(8);"
  
 * frame #0: 0x000000010075bff7 node`v8::base::Calloc(count=8, size=1) at memory.h:78:10 [opt] [inlined]
    frame #1: 0x000000010075bff7 node`v8::(anonymous namespace)::ArrayBufferAllocator::Allocate(this=<unavailable>, length=8) at api.cc:341:51 [opt]
    frame #2: 0x0000000100015b6d node`node::NodeArrayBufferAllocator::Allocate(this=0x00007fdd41709a40, size=8) at environment.cc:118:23
    frame #3: 0x0000000100b0ba49 node`std::__1::unique_ptr<cppgc::internal::Heap, std::__1::default_delete<cppgc::internal::Heap>> std::__1::make_unique[abi:nn200100]<cppgc::internal::Heap, std::__1::shared_ptr<cppgc::Platform>, cppgc::Heap::HeapOptions, 0>(__args=<unavailable>, __args=<unavailable>) at unique_ptr.h:767:26 [opt] [inlined]
    frame #4: 0x0000000100d753a6 node`v8::internal::BackingStore::Allocate(isolate=0x00007fdd4220a000, byte_length=8, shared=kNotShared, initialized=<unavailable>) at backing-store.cc:231:37 [opt]
    frame #5: 0x00000001007d1806 node`v8::internal::(anonymous namespace)::TryAllocateBackingStore(isolate=0x00007fdd4220a000, shared=kNotShared, resizable=kNotResizable, length=<unavailable>, max_length=DirectHandle<v8::internal::Object> @ r13, initialized=kZeroInitialized) at builtins-arraybuffer.cc:68:11 [opt]
    frame #6: 0x00000001007d17ee node`v8::internal::(anonymous namespace)::ConstructBuffer(isolate=0x00007fdd4220a000, target=DirectHandle<v8::internal::JSFunction> @ 0x00007ff7bfefc630, new_target=DirectHandle<v8::internal::JSReceiver> @ 0x00007ff7bfefc628, length=<unavailable>, max_length=DirectHandle<v8::internal::Object> @ r13, initialized=kZeroInitialized) at builtins-arraybuffer.cc:119:39 [opt]
    frame #7: 0x00000001007cfe58 node`v8::internal::Builtin_Impl_ArrayBufferConstructor(args=BuiltinArguments @ 0x00007ff7bfefc6c8, isolate=0x00007fdd4220a000) at builtins-arraybuffer.cc:187:10 [opt] [inlined]
    frame #8: 0x00000001007cfbed node`v8::internal::Builtin_ArrayBufferConstructor(args_length=<unavailable>, args_object=<unavailable>, isolate=0x00007fdd4220a000) at builtins-arraybuffer.cc:139:1 [opt]
// ...

3. JSArrayBuffer の作成とセットアップ(オンヒープ)

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/builtins/builtins-arraybuffer.cc;l=121-133

// builtins-arraybuffer.cc;l=121-126
DirectHandle<JSObject> result;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
    isolate, result,
    JSObject::New(target, new_target, {},
                NewJSObjectType::kMaybeEmbedderFieldsAndApiWrapper));
auto array_buffer = Cast<JSArrayBuffer>(result);

汎用的なJSObjectをオンヒープメモリに作成しJSArrayBufferに型キャストします。
JSプレフィックスはオンヒープメモリに作成されるものを意味します。


https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/heap/main-allocator-inl.h;l=45-46

// main-allocator-inl.h;l=45-46
Tagged<HeapObject> obj =
    HeapObject::FromAddress(allocation_info().IncrementTop(size_in_bytes));

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/heap/linear-allocation-area.h;l=41-46

// linear-allocation-area.h;l=41-46
V8_INLINE Address IncrementTop(size_t bytes) {
  Address old_top = top_;
  top_ += bytes;
  Verify();
  return old_top;
}

バンプアロケータでJSArrayBufferをオンヒープメモリに割り当てていることがわかります。


LLDBからもその様子が確認できます。

lldb -b -o "b ConstructBuffer" \
	-o "r" \
	-o "b main-allocator-inl.h:32" \
	-o "b main-allocator-inl.h:46" \
	-o "b factory.cc:326" \
	-o "c" \
	-o "bt 20" \
	-o "c" \
	-o "bt 20" \
	-o "q" \
	-- ./out/Debug/node -e "new ArrayBuffer(8)"
  
  * frame #0: 0x0000000100ad6aa0 node`v8::internal::LinearAllocationArea::IncrementTop(this=0x00007fcfef10a0c8, bytes=<unavailable>) at linear-allocation-area.h:43:10 [opt] [inlined]
    frame #1: 0x0000000100ad6aa0 node`v8::internal::MainAllocator::AllocateFastUnaligned(this=0x00007fcfee823738, size_in_bytes=96, origin=kRuntime) at main-allocator-inl.h:46:49 [opt] [inlined]
    frame #2: 0x0000000100ad6a85 node`v8::internal::MainAllocator::AllocateRaw(this=<unavailable>, size_in_bytes=<unavailable>, alignment=<unavailable>, origin=kRuntime, hint=<unavailable>) at main-allocator-inl.h:32:14 [opt]
    frame #3: 0x0000000100ad5914 node`v8::internal::AllocationResult v8::internal::HeapAllocator::AllocateRaw<(v8::internal::AllocationType)0>(this=0x00007fcfee8236b8, size_in_bytes=96, origin=kRuntime, alignment=kTaggedAligned, hint=(may_grow_ = false)) at heap-allocator-inl.h:124:44 [opt]
    frame #4: 0x0000000100ac359e node`v8::internal::Tagged<v8::internal::HeapObject> v8::internal::HeapAllocator::AllocateRawWith<(v8::internal::HeapAllocator::AllocationRetryMode)1>(this=0x00007fcfee8236b8, size=96, allocation=kYoung, origin=kRuntime, alignment=kTaggedAligned, hint=(may_grow_ = false)) at heap-allocator-inl.h:232:14 [opt] [inlined]
    frame #5: 0x0000000100ac3589 node`v8::internal::Factory::AllocateRawWithAllocationSite(this=<unavailable>, map=DirectHandle<v8::internal::Map> @ r13, allocation=kYoung, allocation_site=<unavailable>) at factory.cc:326:20 [opt]
    frame #6: 0x0000000100ac8df1 node`v8::internal::Factory::NewJSObjectFromMap(this=0x00007fcfef10a000, map=DirectHandle<v8::internal::Map> @ r15, allocation=<unavailable>, allocation_site=<unavailable>, new_js_object_type=kMaybeEmbedderFieldsAndApiWrapper) at factory.cc:3233:7 [opt]
    frame #7: 0x00000001007d162d node`v8::internal::(anonymous namespace)::ConstructBuffer(isolate=0x00007fcfef10a000, target=DirectHandle<v8::internal::JSFunction> @ 0x00007ff7bfefc630, new_target=DirectHandle<v8::internal::JSReceiver> @ 0x00007ff7bfefc628, length=<unavailable>, max_length=<unavailable>, initialized=kUninitialized) at builtins-arraybuffer.cc:122:3 [opt]
    frame #8: 0x00000001007cfe58 node`v8::internal::Builtin_Impl_ArrayBufferConstructor(args=BuiltinArguments @ 0x00007ff7bfefc6c8, isolate=0x00007fcfef10a000) at builtins-arraybuffer.cc:187:10 [opt] [inlined]
    frame #9: 0x00000001007cfbed node`v8::internal::Builtin_ArrayBufferConstructor(args_length=<unavailable>, args_object=<unavailable>, isolate=0x00007fcfef10a000) at builtins-arraybuffer.cc:139:1 [opt]
// ...

// builtins-arraybuffer.cc;l=127-133
array_buffer->Setup(shared, resizable, std::move(backing_store), isolate);
if (backing_store_creation_failed) {
    CHECK(range_error.has_value());
    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewRangeError(range_error.value()));
}
return *array_buffer;

最後に、先ほど確保したバイナリデータとBackingStoreがJSArrayBufferに関連づけられます(array_buffer->Setup()の詳細は次のセクションで見ていきます)。

オフヒープメモリはどうやって解放されるのか

先ほど、V8はオフヒープメモリを直接GCしないと言いました。では、使用されなくなったオフヒープメモリ上のデータはどうなるのでしょうか?

もちろん問題なくメモリ解放は行われます。
V8はJSArrayBufferを作成する際に、対応するArrayBufferExtensionというオブジェクトをオフヒープメモリに作成します。ArrayBufferExtensionは、JSArrayBufferがGCで回収対象かどうかを追跡するためのマーカーを保持します。
そして、JSArrayBufferはArrayBufferExtensionへのポインタを持ち、ArrayBufferExtensionはBackingStoreへのスマートポインタを持ちます。この構造を図にするとこんな感じです。
JSArrayBuffer、ArrayBufferExtension、BackingStoreの関係図
詳細はV8チームによるConcurrent and compact ArrayBuffer trackingに記載されている図がわかりやすいです。

コードで追ってみるとこんな感じです

1. エントリーポイント

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/builtins/builtins-arraybuffer.cc;l=128

// builtins-arraybuffer.cc;l=128
array_buffer->Setup(shared, resizable, std::move(backing_store), isolate);

array_buffer->Setup()が実行されます。


2. JSArrayBuffer, ArrayBufferExtension, BackingStoreを関連付ける

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/objects/js-array-buffer.cc

// js-array-buffer.cc;l=60
init_extension();

ArrayBufferExtensionを指すextensionフィールドをJSArrayBufferに作成します。nullptrで初期化します。


// js-array-buffer.cc;l=255-257
ArrayBufferExtension* extension =
  new ArrayBufferExtension(std::move(backing_store), age);
set_extension(extension);

CreateExtension()を実行し、BackingStoreの所有権をArrayBufferExtensionに移しArrayBufferExtensionを作成します。
その後ArrayBufferExtensionを先ほど作成したJSArrayBufferのextensionフィールドにセットします。

ちなみに、JSArrayBufferのバイナリデータへのポインタは下記でセットされます

// js-array-buffer.cc;l=98
set_backing_store(isolate, backing_store_buffer);

3. ArrayBufferExtensionを追跡リストに登録する

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/heap/array-buffer-sweeper.cc;l=356-371

// array-buffer-sweeper.cc;l=356-371
switch (extension->age()) {
  case ArrayBufferExtension::Age::kYoung:
    young_.Append(extension);
    break;
  case v8::internal::ArrayBufferExtension::Age::kOld:
    old_.Append(extension);
    break;
}

AppendArrayBufferExtension()が実行されると、ageに応じてArrayBufferExtensionをそれぞれの世代のGC追跡リストに登録します。

この構造により、次の流れでメモリが解放されます。

  1. GCでJSArrayBufferが回収される
  2. 対応するArrayBufferExtensionがSweeperによって削除される
  3. BackingStoreへのスマートポインタが破棄され、BackingStoreが解放される
  4. バイナリデータが解放され、オフヒープメモリが返却される

つまりオフヒープメモリの解放は、オンヒープメモリ上のJSArrayBufferのGCをトリガーに間接的に行われるわけです。

コードで追ってみるとこんな感じです

1. マーキングする

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/objects/js-array-buffer.cc;l=272-277

// js-array-buffer.cc;l=272-277
void JSArrayBuffer::MarkExtension() {
  ArrayBufferExtension* extension = this->extension();
  if (extension) {
    extension->Mark();
  }
}

GCがJSArrayBufferを走査し、到達可能(生存)と判定された場合、対応するArrayBufferExtensionのmarked_フラグをtrueにします。


2. スイープする

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/heap/array-buffer-sweeper.cc

// array-buffer-sweeper.cc;l=493-499
if (!current->IsMarked()) {
  freed_bytes += current->accounting_length();
  FinalizeAndDelete(current);
} else {
  current->Unmark();
  new_old.Append(current);
}

ArrayBufferExtensionリストを走査し、マークされていないArrayBufferExtensionはFinalizeAndDelete()で削除されます。マークされているものは次のGCのためにフラグをリセットし、生存リストに移動します。コード例はフルGC時です。


3. Extension(ArrayBufferExtension)を削除し、BackingStoreとバイナリデータも解放する

// array-buffer-sweeper.cc;l=438-443
void ArrayBufferSweeper::FinalizeAndDelete(ArrayBufferExtension* extension) {
  delete extension;
}

https://source.chromium.org/chromium/chromium/src/+/68852a942b46b1bb2a64198210d8923874869af0:v8/src/objects/backing-store.cc;l=135-205

// backing-store.cc;l=201-204
auto allocator = get_v8_api_array_buffer_allocator();
TRACE_BS("BS:free   bs=%p mem=%p (length=%zu, capacity=%zu)\n", this,
        buffer_start_, byte_length(), byte_capacity_);
allocator->Free(buffer_start_, byte_length_);

deleteによりArrayBufferExtensionのデストラクタが呼ばれ、BackingStoreへのスマートポインタが破棄されます。これによりBackingStoreも解放され、オフヒープメモリが返却されます。


LLDBからもその様子が確認できます。

lldb -b -o "b v8::internal::BackingStore::~BackingStore" \
	-o "b v8::internal::ArrayBufferSweeper::FinalizeAndDelete" \
	-o "b node::NodeArrayBufferAllocator::Free" \
	-o "breakpoint modify 1 --condition 'this->byte_length_ == 8'" \
	-o "r" \
	-o "bt 20" \
	-o "c" \
	-o "bt 20" \
	-o "c" \
	-o "bt 20" \
	-o "q" \
	-- ./out/Debug/node --expose-gc -e "let buf = new ArrayBuffer(8);buf = null;global.gc();"

  * frame #0: 0x0000000100015d0c node`node::NodeArrayBufferAllocator::Free(this=0x00007fcc4405ff30, data=0x00007fcc44108920, size=8) at environment.cc:139:3
    frame #1: 0x0000000100d74d92 node`v8::internal::BackingStore::~BackingStore(this=0x00007fcc44108fb0) at backing-store.cc:204:14 [opt]
    frame #2: 0x00000001007647f7 node`std::__1::default_delete<v8::internal::BackingStore>::operator()[abi:nn200100](this=<unavailable>, __ptr=0x00007fcc44108fb0) const at unique_ptr.h:78:5 [opt] [inlined]
    frame #3: 0x00000001007647ea node`std::__1::__shared_ptr_pointer<v8::internal::BackingStore*, std::__1::default_delete<v8::internal::BackingStore>, std::__1::allocator<v8::internal::BackingStore>>::__on_zero_shared(this=<unavailable>) at shared_ptr.h:122:3 [opt]
    frame #4: 0x0000000100a467f1 node`std::__1::__shared_count::__release_shared[abi:nn200100](this=0x00007fcc44108900) at shared_count.h:91:7 [opt] [inlined]
    frame #5: 0x0000000100a467d6 node`std::__1::__shared_weak_count::__release_shared[abi:nn200100](this=0x00007fcc44108900) at shared_count.h:120:25 [opt] [inlined]
    frame #6: 0x0000000100a467d6 node`std::__1::shared_ptr<v8::internal::BackingStore>::~shared_ptr[abi:nn200100](this=0x00007fcc441089d0) at shared_ptr.h:558:17 [opt] [inlined]
    frame #7: 0x0000000100a467cd node`std::__1::shared_ptr<v8::internal::BackingStore>::~shared_ptr[abi:nn200100](this=0x00007fcc441089d0) at shared_ptr.h:556:39 [opt] [inlined]
    frame #8: 0x0000000100a467cd node`v8::internal::ArrayBufferExtension::~ArrayBufferExtension(this=0x00007fcc441089d0) at js-array-buffer.h:193:7 [opt] [inlined]
    frame #9: 0x0000000100a467cd node`v8::internal::ArrayBufferExtension::~ArrayBufferExtension(this=0x00007fcc441089d0) at js-array-buffer.h:193:7 [opt] [inlined]
    frame #10: 0x0000000100a467cd node`v8::internal::ArrayBufferSweeper::FinalizeAndDelete(extension=0x00007fcc441089d0) at array-buffer-sweeper.cc:442:3 [opt] [inlined]
    frame #11: 0x0000000100a467cd node`v8::internal::ArrayBufferSweeper::SweepingState::SweepingJob::SweepListFull(this=0x00007fcc42f389f0, delegate=0x000070000811dee0, list=<unavailable>, age=kYoung) at array-buffer-sweeper.cc:495:7 [opt]
    frame #12: 0x0000000100a466f6 node`v8::internal::ArrayBufferSweeper::SweepingState::SweepingJob::SweepFull(this=0x00007fcc42f389f0, delegate=0x000070000811dee0) at array-buffer-sweeper.cc:467:8 [opt]
    frame #13: 0x0000000100a44f01 node`v8::internal::ArrayBufferSweeper::SweepingState::SweepingJob::Sweep(this=0x00007fcc42f389f0, delegate=<unavailable>) at array-buffer-sweeper.cc:454:21 [opt]
    frame #14: 0x0000000100a44d6f node`v8::internal::ArrayBufferSweeper::SweepingState::SweepingJob::Run(this=0x00007fcc42f389f0, delegate=0x000070000811dee0) at array-buffer-sweeper.cc:235:3 [opt]
    frame #15: 0x0000000102129dcb node`v8::platform::DefaultJobWorker::Run(this=0x00007fcc42f2a6a0) at default-job.h:147:18 [opt]
    frame #16: 0x000000010035fcf9 node`node::(anonymous namespace)::PlatformWorkerThread(data=0x00007fcc4405ec80) at node_platform.cc:80:18
    frame #17: 0x00007ff81394fe59 libsystem_pthread.dylib`_pthread_start + 115
    frame #18: 0x00007ff81394b857 libsystem_pthread.dylib`thread_start + 15

冒頭の事象を解決する

冒頭のNext.jsビルドがクラッシュした事象に戻ります。

事象のおさらい

Next.jsをDocker内でビルドすると、ページ生成処理のタイミングでcannot allocate memoryが発生してクラッシュしていました。
process.memoryUsage()でメモリの使用状況を確認してみると、externalとarrayBuffersの数値が大きくなっていたのでした。externalはV8が追跡しているオフヒープメモリの使用量を、arrayBuffersはその内訳としてArrayBufferが占めている量を示しています。

ArrayBufferの知識のおさらい

ArrayBufferはオンヒープとオフヒープそれぞれにメモリ領域を持っていて、V8が直接GCするのはオンヒープ上のオブジェクトのみで、オフヒープ上のバイナリデータは別途メモリ解放されます。

これらを踏まえると、ページ生成処理で必ずクラッシュしていたのは、短期間で大量のArrayBufferが作成されてGCとメモリ解放が追いつかず、オフヒープメモリが枯渇したからなのではないかと考えました。

そこでファイルI/Oに絞ってNext.jsのビルドについて調査したところ、Next.jsのDocsにあるMemory UsageページのDisable Webpack cacheにたどり着きました。
Next.jsはプロダクションビルド時にwebpackのキャッシュをファイルシステムキャッシュに設定しています。公式ドキュメントではメモリ使用量を抑えるためにメモリキャッシュへの変更が紹介されていました。2つのキャッシュ方式の違いは次の通りです。[4]

  • ファイルシステムキャッシュ: データをディスクに保存。そのためのシリアライズ処理過程でBuffer(内部的にArrayBuffer)が作成される。
  • メモリキャッシュ: データをそのままオンヒープメモリ上に保持。シリアライズ不要でBufferの作成が発生しない。

公式ドキュメント通りにメモリキャッシュに変更してビルドしてみると、見事にビルドが成功するようになりました!
process.memoryUsage()の結果にも大きな変化がありました。

console.log(process.memoryUsage());
// ピーク時の数値
// {
//   rss: ...,
//   heapTotal: ...,
//   heapUsed: ...,
//   external: 445917430(約425MB),
//   arrayBuffers: 410674790(約392MB)
// }

解決までの流れをまとめると次のようになります

  1. webpackのファイルシステムキャッシュにより、Next.jsのビルド時のページ生成処理で大量のArrayBufferが作成されていた
  2. それに伴い大量のBackingStore(バイナリデータ)もオフヒープメモリに作成されていた
  3. V8はオフヒープメモリの使用状況をトラッキングしており、状況に応じてJSArrayBufferをGCする。それによってBackingStore(バイナリデータ)のメモリ解放も行われる。しかしページ生成処理ではオフヒープメモリの解放が追いつかないスピードでArrayBufferが作成されたため、オフヒープメモリが枯渇しOSレベル(Docker)でOOMが発生した
  4. --max-old-space-sizeはV8ヒープ(オンヒープメモリ)のサイズ上限を設定するオプションであり、オフヒープメモリには影響しないため、値を増やしても解決しなかった
  5. webpackをファイルシステムキャッシュからメモリキャッシュに変更したところ、オフヒープメモリの使用が落ち着き事象が解決した

おわりに

開発をする上で、利用している技術の実装の詳細を知ることは必ずしも必要ではありませんが、いざという時に役立つ心強い武器になるなと思います。

脚注
  1. なぜpnpmへの変更後にクラッシュするようになったかは分かっていません... ↩︎

  2. Node.jsだと基本的にはBufferを使用すると思いますが、Bufferも内部的にはArrayBufferを使用しているため、ArrayBufferをベースにしました ↩︎

  3. 小さなTypedArrayはオンヒープメモリにバイナリデータ(ByteArray)を持ちます ↩︎

  4. キャッシュ方式による具体的な実装の詳細の違いまでは調査できていません ↩︎

Discussion