Node.jsにおける単一バイナリ配布(SEA: Single Executable Application)の実装と変遷

こんにちは、エンジニアの瀧口です。

最近のNode.jsのアップデートでSEA(Single Executable Application)周りが改善されたようなので今回は試してみました。

SEA(Single Executable Application)とは

Node.jsで単一バイナリを生成し、Node.js未インストールの環境でもそのまま動かせるアプリの事です。

# インストール不要。コピーするだけで動く
$ scp ./mytool user@server:/usr/local/bin/
$ ssh user@server "mytool --help"

SEAの仕組み

ビルド時には、指定したエントリポイントやアセットが1つのblobにまとめられ、それがNode.jsの実行ファイルに埋め込まれます。

実行時の流れは以下のようになります。

  1. Node.jsバイナリが起動
  2. 自身に埋め込まれたblobを検出
  3. その中のJavaScriptコードを実行

ネイティブコードへコンパイルされるわけではなく、あくまで「Node.js + アプリ」をひとまとめにしている仕組みです。

Node.jsのSEAの歴史

SEAが実装される前(v19.7.0未満)

SEAが導入される以前は、Node.jsアプリを単一バイナリとして配布するには vercel/pkgnexe/nexe といったサードパーティ製ツールを使うのが一般的でした。

これらのツールは便利ではあるものの、

  • サードパーティ製のツールを利用する必要がある
  • Nodeのバージョンアップへの追従が遅れがち

といった課題があり、「ちょっとしたCLIを配布する」にはやや大げさな仕組みでした。
また、内部的な仕組みもツールごとに異なり、少しブラックボックスに感じる場面もありました。

SEAの初期サポート(v19.7.0)

v19.7.0でSEAが実験的機能として追加されました。
この頃は現在のblobを埋め込む方式とは異なり、単一のJSファイルを直接指定して埋め込む方式でした。

# Node.jsバイナリをコピー
cp $(command -v node) mytool

# macOSの場合、既存の署名を削除
codesign --remove-signature mytool

# postjectでスクリプトを注入
npx postject mytool NODE_JS_CODE mytool.js \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
  --macho-segment-name NODE_SEA  # macOSのみ

# macOSの場合、署名を再適用
codesign --sign - mytool

--experimental-sea-config の導入(v20.0.0)

v20.0.0で --experimental-sea-config が導入され、JSファイルを埋め込む方式からblobを埋め込む方式に変更されました。

ただしこの時点ではまだ使い勝手がよくなく、

  • --experimental-sea-config でblobを生成
  • Nodeバイナリをコピー
  • postject でblobを埋め込む

といった複数の手順が必要でした。

// sea-config.json
{
  "main": "mytool.js",
  "output": "sea-prep.blob"
}
# blobを生成
node --experimental-sea-config sea-config.json

# Node.jsバイナリをコピー
cp $(command -v node) mytool

# macOSの場合、既存の署名を削除
codesign --remove-signature mytool

# postjectでblob を注入
npx postject mytool NODE_SEA_BLOB sea-prep.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
  --macho-segment-name NODE_SEA  # macOS のみ

# macOSの場合、署名を再適用
codesign --sign - mytool

useCodeCache / useSnapShot の追加(v20.6.0)

v20.6.0で sea-config.jsonuseCodeCacheuseSnapShot が追加されました。

// sea-config.json
{
  "main": "mytool.js",
  "output": "sea-prep.blob",
  "useCodeCache": true, // デフォルトはfalse
  "useSnapShot": true.  // デフォルトはfalse
  ]
}

useCodeCache

JavaScriptコードを事前にコンパイルした状態(V8のコードキャッシュ)をバイナリに含めることで、起動時のパースやコンパイル処理を省略し、起動を高速化します。

手軽に有効化できる一方で、動的インポートなど一部の機能と相性が悪い場合がある点には注意が必要です。

useSnapShot

アプリケーションの初期状態をV8のスナップショットとして保存し、起動時にその状態を復元することで、さらに高速に起動できるようになります。

ただし、ビルド時に初期化処理が実行されるなど、通常とは処理の流れが異なる点に注意が必要です。

アセットの埋め込みに対応(v20.12.0)

v20.12.0で sea-config.jsonassets フィールドが追加され、ファイルを埋め込めるようになりました。

// sea-config.json
{
  "main": "mytool.js",
  "output": "sea-prep.blob",
  "assets": [ // 埋め込むファイルを指定
    "image.jpg": "path/to/image.jpg",
    "data.txt": "path/to/data.txt" 
  ]
}

スクリプト側では、 getAsset()getAssetAsBlob() で埋め込まれたファイルを取得します。

const { getAsset, getAssetAsBlob } = require('node:sea')

// 画像ファイルを取得
const image = getAsset('image.jpg')        // ArrayBuffer
const image2 = getAssetAsBlob('image.jpg') // Blob

// テキストファイルを取得
const text = getAsset('data.txt', 'utf-8') // string

execArgv / execArgvExtension の追加(24.7.0)

v24.7.0で execArgvexecArgvExtension が追加され、実行時のオプションを指定できるようになりました。

execArgv

バイナリ起動時に適用される Node.jsの実行オプションを固定で指定できます。
例えば警告の抑制や、--trace-warnings などのデバッグオプションを埋め込んでおくことができます。

// sea-config.json
{
  "execArgv": ["--no-warnings"]
}

execArgvExtension

execArgv に加えて、実行時に追加のオプションをどの方法で受け付けるかを制御します。
指定できる値は以下の3つです。

  • none: 追加のオプションは一切受け付けません(NODE_OPTIONS も無視)
  • env:NODE_OPTIONS 環境変数から追加のオプションを受け付けます。(未指定時はデフォルトでこちらが設定される)
  • cli--node-options="..." という形で引数で実行時にオプションを渡せるようになります
// sea-config.json
{
  "execArgvExtension": "cli"
}

--build-sea の追加(v25.5.0)

2026年リリースのv25.5.0で --build-sea が追加され、blobの生成から注入までがワンコマンドで行えるようになりました。

// sea-config.json
{
  "main": "mytool.js",
  "output": "mytool"
}
# バイナリ生成
node --build-sea sea-config.json

# macOSでは署名が必要
codesign --sign - mytool

基本的な使い方

新しいビルド方法を試すために簡単なアプリを作ってみます。

// hello.js
const { isSea } = require('node:sea')

console.log(`Hello, World!`)
console.log(`実行環境: ${isSea() ? 'Single Executable Application' : 'Node.js'}`)
console.log(`Node.js バージョン: ${process.version}`)
console.log(`プラットフォーム: ${process.platform} (${process.arch})`)

続いてビルド用の設定ファイルを作成します。

// sea-config.json
{
  "main": "hello.js",                    // エントリポイント
  "output": "dist/hello",                // 出力先
  "disableExperimentalSEAWarning": true, // SEAはまだ実験的機能なので警告が出るので気になる場合はこれで消せる
}

あとはビルドして実行するだけ。

# 事前に出力用ディレクトリを作っておく
mkdir -p dist

# ビルド
node --build-sea sea-config.json

# macOSでは署名が必要
codesign --sign - dist/hello

# 実行
./dist/hello
# => Hello, World!
# => 実行環境: Single Executable Application
# => Node.js バージョン: v25.x.x
# => プラットフォーム: darwin (arm64)

無事に動きました。
同じプラットフォームであれば生成したバイナリをコピーするだけでそのまま動きます。

クロスプラットフォームビルド

次に別プラットフォーム向けのビルドも試してみましょう。

まずは対象プラットフォーム向けのNode.jsのバイナリを用意する必要があります。
今回はlinux-x64向けで試してみます。

# ビルドに使うNode.jsのバージョンの確認
node -v
# => v25.5.0

# ダウンロードしたファイルを保存するディレクトリを作成しておく
mkdir -p platform

# 対象プラットフォーム向けのNode.jsバイナリのダウンロード
# nodeVersion: node -vで出力されたバージョン
# platform: win/linux/darwin
# arch: x64/arm64
# ext
#   win:: zip
#   linux: tar.xz
#   darwin: tar.gz
# 
# URL: https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-${platform}-${arch}.${ext}`

# Linux(x64)向けの場合
curl https://nodejs.org/dist/v25.5.0/node-v25.5.0-linux-x64.tar.xz -o ./platform/node-linux-x64.tar.xz
tar -xJf ./platform/node-linux-x64.tar.xz -C ./platform

# macOS(x64)向けの場合
# curl https://nodejs.org/dist/v25.5.0/node-v25.5.0-darwin-x64.tar.gz -o ./platform/node-darwin-x64.tar.gz
# tar -xzf ./platform/node-darwin-x64.tar.gz -C ./platform

# Windows(x64)向けの場合
# curl https://nodejs.org/dist/v25.5.0/node-v25.5.0-win-x64.zip -o ./platform/node-win-x64.zip
# unzip ./platform/node-win-x64.zip -d ./platform 

次にビルド用の設定ファイルを作ります。
executable フィールドに先ほどダウンロードした対象プラットフォーム向けのバイナリを指定する事で、指定したバイナリにblobが注入されます。

注意点としては、以下のような点があります。

  • 他プラットフォーム向けのバイナリのバージョンはビルドに使うNode.jsとバージョンを一致させる必要がある
  • Windows向けのビルドの場合はexecutable に指定する値に.exeまで含める必要がある
  • useCodeCache 及び useSnapShot は別プラットフォーム向けにビルドする際には false にしておく必要がある
// sea-config-cross-platform.json
{
  "main": "hello.js",
  "output": "dist/hello-linux-x64", // 名前もlinux-x64向けとわかるようにここでは変えておきます
  "executable": "platform/node-v25.5.0-linux-x64/bin/node",     // Linuxの場合
  // "executable": "platform/node-v25.5.0-darwin-x64/bin/node"  // macOSの場合
  // "executable": "platform/node-v25.5.0-win-x64/node.exe"     // Winの場合。.exeも含めるのを忘れずに
  "disableExperimentalSEAWarning": true,
  "useCodeCache": false, // 他プラットフォーム向けにビルドする場合は必ずfalseに
  "useSnapShot": false   // 他プラットフォーム向けにビルドする場合は必ずfalseに
}

続けてビルドを行います。

# 事前に出力用ディレクトリを作っておく
mkdir -p dist

# ビルド
node --build-sea sea-config-cross-platform.json

# Linux用の実行ファイルを確認
ls dist
# => hello-linux-x64

最後に動作確認をしてみましょう。

# Dockerを使ってLinux環境で動作確認
docker run --rm -it \
  --platform linux/amd64 \
  -v "$(pwd)/dist:/dist" \
  debian:bookworm \
  # 最小構成のコンテナ環境だとlibatomic1(Debian系)や libatomic(RHEL系)が入っていないことがあるので、必要に応じてインストール
  bash -c "apt-get update -qq && apt-get install -y -qq libatomic1 && bash"

# コンテナ内で
/dist/hello-linux-x64
# => Hello, World!
# => 実行環境: Single Executable Application
# => Node.js バージョン: v25.5.0
# => プラットフォーム: linux (x64)

SEA利用時の注意点

複数ファイルはそのままでは使えない

SEAは基本的に1つのエントリポイントを実行する仕組みのため、複数ファイルに分かれたアプリケーションをバイナリ化したい場合には、事前に何らかのバンドラーを用いて1ファイルにしておく必要があります。

ネイティブアドオンはそのままでは使えない

ネイティブアドオンは、そのままでは動作しません。
必要な場合はバイナリに assets として含めたうえで実行時に展開するなど、追加の対応が必要になります。

ファイルパスの扱いに注意

SEAではアプリケーションコードがバイナリ内に埋め込まれるため、__dirname や相対パスの扱いには注意が必要です。
外部ファイルを扱う場合は、実行時のカレントディレクトリや process.cwd() を基準に設計する方が安全です。

感想

今回SEAを試してみて、以前と比べるとかなり扱いやすくなったと感じました。
特に --build-sea の登場で、これまで煩雑だったビルド手順がシンプルになり、「とりあえず試してみる」ハードルはかなり下がったと思います。

一方で、生成されるバイナリにはNode.jsランタイムが丸ごと含まれるため、どうしてもサイズが大きくなってしまうというデメリットもあります。
用途によっては問題にならないケースもありますが、新規でCLIツールなどを作る場合は、バイナリサイズや起動速度の観点からGoやRustといった言語の方が適している場面もありそうです。

とはいえ、既存のNode.js資産を活かしたまま単一バイナリとして配布できるのは大きなメリットなので、用途に応じて使い分けていくのが良さそうです。

参考情報