【Node.js】child_processでコマンドを実行する方法|exec・execSync・spawnの使い分け

【Node.js】child_processでコマンドを実行する方法|exec・execSync・spawnの使い分け Node.js

Node.jsからgitffmpegなどの外部コマンドを実行したいときは、標準のchild_processモジュールを使います。コマンドを実行して結果を受け取ったり、ビルドやデプロイのスクリプトを自動化したりできます。実行方法にはexecSyncexecspawnの3つがあり、用途によって使い分けます。

つまずきやすいのは、execには出力サイズの上限(maxBuffer)があり、大量の出力でエラーになること、そしてexecSyncはコマンドが失敗(終了コードが0以外)すると例外を投げることです。また、コマンドに外部からの値を埋め込むとシェルインジェクションの危険もあります。この記事では、実機のNode.jsで実際にコマンドを実行しながら、child_processの使い方を整理します。

先に結論

  • 手軽に同期実行するならexecSync("コマンド")(戻り値で結果を受け取る)。
  • 非同期で実行するならexecpromisifyawaitでき、コールバックでも書けます。
  • execSyncexecは出力を全部メモリに溜めるため、maxBufferの上限があります。
  • 大量出力・長時間の処理はspawn(ストリーミングで少しずつ受け取る)。
  • execSync終了コードが0以外だと例外を投げます。
  • 外部の値をコマンドに埋め込むときはspawnの引数分離でインジェクションを防ぎます。

CPUを活かす並列処理としての使い方はChild Processを使った並列処理、終了コードや環境変数はprocess完全ガイド、出力をファイルに保存するならfsでファイルを読み書きするもあわせて参考になります。

スポンサーリンク

3つの方法と使い分け

まず全体像です。3つの方法は次のように使い分けます。

  • execSync:同期実行。すぐ結果がほしい簡単なコマンドに。スクリプト向き。
  • exec:非同期実行。出力をまとめて受け取る。中程度の出力まで。
  • spawn:ストリーミング。大量出力・長時間処理・リアルタイムに出力を受け取りたいとき。

「短くて結果が小さいならexecSyncexec、出力が大きい・長く動くならspawn」と覚えると選びやすいです。

execSync(同期・手軽)

もっとも手軽なのがexecSyncです。コマンドを実行し、その出力を戻り値として受け取れます。同期的に動くので、結果を使う処理をそのまま続けて書けます。

execSync で同期実行
const { execSync } = require("node:child_process");

// コマンドを実行し、標準出力を受け取る(戻り値はBuffer → toStringで文字列に)
const version = execSync("node --version").toString().trim();
console.log(version);   // v20.x.x など

// 結果をそのまま使える(同期なので順番に実行される)

実機でも、execSync("node --version")でNode.jsのバージョン文字列が取得できました。戻り値はBuffer(バイト列)なので、.toString()で文字列に変換します。execSyncは処理が終わるまで待つため、ビルドスクリプトのように「コマンドを順番に実行する」用途に向いています。ただし同期実行は、その間ほかの処理が止まる点に注意してください(サーバーの中では避けます)。

exec(非同期・promisify/コールバック)

execは非同期でコマンドを実行します。コールバックで結果を受け取るか、util.promisifyawaitできる形にして使います。

exec を await で使う
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const execAsync = promisify(exec);

// promisify で await できる
const { stdout, stderr } = await execAsync("node -e \"console.log(1 + 2)\"");
console.log(stdout.trim());   // 3

// コールバックで書く場合
exec("node --version", (err, stdout, stderr) => {
  if (err) return console.error(err);
  console.log(stdout.trim());
});

実機でも、promisify(exec)await execAsync(...)とすると、stdoutに実行結果(3)が得られました。コールバック版は(err, stdout, stderr)の3引数を受け取り、第1引数のerrでエラーを判定します。非同期なので、サーバーの中など「処理を止めたくない場面」ではexec(やspawn)を使います。

stdout・stderr・終了コード

コマンドの出力には、通常の出力(標準出力stdout)とエラー出力(stderr)があります。execでは両方を別々に受け取れます。execSyncはコマンドが失敗すると例外を投げます。

stderrと終了コードの扱い
const { execSync } = require("node:child_process");

// stdout と stderr を分けて受け取る(exec のコールバック)
// exec("コマンド", (err, stdout, stderr) => { ... });

// execSync は終了コードが0以外だと例外を投げる
try {
  execSync("node -e \"process.exit(3)\"");
} catch (e) {
  console.log("失敗:", e.status);   // 3(終了コード)
}
execSyncは失敗で例外、終了コードはe.status

実機で確認したところ、execSyncで終了コード3を返すコマンドを実行すると例外が投げられ、そのe.status3になりました。コマンドが失敗したかどうかをexecSyncではtry-catchで判定し、終了コードはe.statusで取れます。またexecのコールバックでは、(err, stdout, stderr)errが失敗時にセットされ、エラー出力はstderrに入ります。実機でも、標準出力にout出力、エラー出力にerr出力を出すコマンドで、stdoutstderrがそれぞれ正しく分かれて取得できました。コマンドの成否をきちんと扱うことが、自動化スクリプトの信頼性につながります。

execのmaxBuffer(大量出力の罠)

注意したいのが、execexecSyncは出力を全部メモリに溜め込むことです。そのためmaxBuffer(既定で約1MB)を超える大量の出力があると、エラーになって結果を受け取れません。

maxBuffer の上限
const { execSync } = require("node:child_process");

// 出力が maxBuffer(既定 約1MB)を超えるとエラーになる
try {
  // maxBuffer を小さく設定して再現
  execSync("node -e \"console.log('x'.repeat(2*1024*1024))\"", { maxBuffer: 1024 });
} catch (e) {
  console.log("バッファ超過:", e.code);   // ENOBUFS など
}

// 上限を引き上げる(10MB)
// execSync("コマンド", { maxBuffer: 10 * 1024 * 1024 });
大量出力のコマンドはexecだとエラーになる

実機で確認したところ、maxBufferを小さく設定した状態で大きな出力のコマンドを実行すると、バッファ超過のエラー(e.codeENOBUFSになりました。execexecSyncは出力をすべてメモリに溜めてから返すため、ログ全体の取得や巨大なファイルの出力など、出力が大きいコマンドには向きません。対策は2つあります。maxBufferオプションで上限を引き上げる(例: { maxBuffer: 10 * 1024 * 1024 })、または②次に紹介するspawnを使い、出力をストリーミングで少しずつ受け取る方法です。出力サイズが読めないコマンドでは、最初からspawnを使うのが安全です。

spawn(大量出力・長時間処理)

spawnは、出力をストリーミング(少しずつ流れてくる形)で受け取ります。メモリに溜め込まないため、大量出力や長時間動くコマンドに向いています。リアルタイムに進捗を表示したいときにも使えます。

spawn でストリーミング受信
const { spawn } = require("node:child_process");

// コマンドと引数を「配列で別々に」渡す
const child = spawn("node", ["-e", "console.log('hello')"]);

// 出力が来るたびに少しずつ受け取る
child.stdout.on("data", (data) => {
  process.stdout.write(data);   // リアルタイムに表示
});

child.stderr.on("data", (data) => {
  console.error("エラー出力:", data.toString());
});

// 終了時(終了コードを受け取れる)
child.on("close", (code) => {
  console.log("終了コード:", code);   // 0 など
});

実機でも、spawnで実行したコマンドの出力をstdout.on("data", ...)で受け取り、終了時にcloseイベントで終了コード(0)を取得できました。spawnコマンド名と引数を配列で分けて渡すspawn("node", ["-e", "..."]))のが特徴です。出力はイベントとして少しずつ届くため、何GBものログでもメモリを圧迫しません。長時間動くプロセスの進捗をリアルタイムに表示したい場合にも最適です。

シェルインジェクションを避ける

セキュリティ上の重要な注意です。execコマンド文字列をシェル経由で実行するため、外部から受け取った値をそのまま埋め込むと、シェルインジェクション(意図しないコマンドの実行)の危険があります。

インジェクションを防ぐ
const { exec } = require("node:child_process");

// NG: ユーザー入力を exec の文字列に直接埋め込む
const userInput = "file.txt; rm -rf /";   // 悪意ある入力
// exec(`cat ${userInput}`);   // 危険! rm -rf / まで実行されうる

// OK: spawn でコマンドと引数を分離する(シェルを介さない)
const { spawn } = require("node:child_process");
spawn("cat", [userInput]);   // userInput は1つの引数として安全に扱われる
外部の値を渡すならspawnの引数分離

execはコマンドをシェルで解釈するため、file.txt; rm -rf /のような入力を文字列に埋め込むと、;以降が別のコマンドとして実行されてしまう恐れがあります。これがシェルインジェクションです。ユーザー入力など外部の値をコマンドに渡すときは、spawn("コマンド", [引数1, 引数2])のように引数を配列で分離してください。spawnは(既定では)シェルを介さず、配列の各要素を1つの引数として安全に扱うため、;|などの記号が含まれていてもコマンドとして解釈されません。固定のコマンドだけを実行する場合はexecでも問題ありませんが、外部の値が混じるならspawnの引数分離が鉄則です。

主な使い分けまとめ

3つの方法の特徴をまとめます。

方法 同期/非同期 向いている場面
execSync 同期 手軽に結果がほしい・スクリプト
exec 非同期 中程度の出力・サーバー内
spawn 非同期(ストリーム) 大量出力・長時間・リアルタイム・外部の値

よくある失敗

execで大量出力を受け取ろうとする

maxBuffer(約1MB)を超えるとエラーです。spawnを使うか、maxBufferを上げます。

execSyncの失敗を確認しない

終了コードが0以外だと例外を投げます。try-catchで囲み、e.statusを確認します。

ユーザー入力をexecに直接埋め込む

シェルインジェクションの危険があります。spawnの引数分離を使います。

戻り値をそのまま文字列として使う

execSyncの戻り値はBufferです。.toString()で文字列にします。

サーバー内でexecSyncを使う

同期実行は処理を止めます。サーバーではexecspawnを使います。

よくある質問

QNode.jsから外部コマンドを実行するには?
A標準のchild_processモジュールを使います。手軽に同期実行するならexecSync("コマンド")、非同期ならexec、大量出力や長時間処理ならspawnです。実行結果は標準出力(stdout)として受け取れます。
Qexecとspawnの違いは?
Aexecは出力をすべてメモリに溜めてからまとめて返すため、出力が大きいとmaxBuffer(約1MB)を超えてエラーになります。spawnは出力をストリーミングで少しずつ受け取るため、大量出力や長時間動くコマンドに向いています。出力サイズが読めないときはspawnが安全です。
Qコマンドが失敗したか判定するには?
AexecSyncは終了コードが0以外だと例外を投げるので、try-catchで囲みe.statusで終了コードを確認します。execのコールバックでは第1引数のerrが失敗時にセットされ、spawnではcloseイベントで終了コードを受け取れます。
Qexec実行で「maxBuffer」エラーが出ます。
AexecexecSyncは出力を全部メモリに溜めるため、既定の約1MBを超えるとエラーになります。{ maxBuffer: 10 * 1024 * 1024 }のように上限を引き上げるか、出力をストリーミングで受け取るspawnに切り替えてください。
Qユーザー入力をコマンドに渡しても安全ですか?
Aexecに文字列で埋め込むのは危険です。;|を含む入力でシェルインジェクションが起こり得ます。spawn("コマンド", [入力])のようにコマンドと引数を配列で分離すれば、入力は1つの引数として安全に扱われます。

まとめ

  • 外部コマンドの実行はchild_process。手軽なexecSync、非同期のexec、ストリームのspawn
  • execはmaxBuffer(約1MB)の上限があり、大量出力はspawnを使います。
  • execSync失敗で例外try-catche.statusで扱います。
  • 出力はstdoutstderrに分かれます。戻り値はBufferなのでtoString()します。
  • 外部の値を渡すならspawnの引数分離でインジェクションを防ぎます。

Node.jsからコマンドを実行できると、ビルドやデプロイ、ファイル処理などの自動化が一気に広がります。「大量出力はspawn」「外部の値は引数分離」という2つの勘所を押さえれば、安全で安定したスクリプトが書けます。まずはexecSyncで手軽に試し、必要に応じてexecspawnへ広げてみてください。