初心者向け解説:Node.jsでOSコマンドを扱うchild_process.exec入門

CPUみたいな画像 Node.js

はじめに

Node.jsでOSコマンドを実行したい人向けにchild_processモジュールのexecメソッドを調べてみた。
ユースケースとしては、利用したい外部モジュールがNode.js向けにライブラリ化されていない場合などが挙げられる。その場合、コマンドラインの文字列を自分で組み立て、execに渡して実行することで、標準出力の文字列として結果を取得できる。取得した文字列は用途に応じて辞書オブジェクやJSONにパースする必要があり、やや手間はかかる。しかしモジュール化されているものが存在しない場合は、自分で組み立てるしかない。せっかく学習した内容なので、記事としてまとめて共有しようと思ったのが今回執筆の動機。

対象バージョン

Node.js:v22.19.0
今回紹介するchild_processモジュールに関するドキュメントは以下の通り。
Node.js v22.19.0 documentation

child_process.exec()の使い方とユースケース

概要・基本的な使い方

冒頭リンクのドキュメントによると、execメソッドのシグネチャは以下の通り。

child_process.exec(command[, options][, callback])

第一引数のcommandにコマンドライン文字列を与えて実行するとNode.js上でコマンドを実行できる。最も簡単な例を挙げると、”ls” コマンド文字列をexec関数の引数に渡すと、ディレクトリ内のファイル・フォルダ一覧を表示することができる。

const { exec } = require("child_process");

const command = "ls";
exec(command, (error, stdout, stderr) => {
    // エラー出力
    if (error) console.log(`error: ${error}`);
    // 標準出力
    if (stdout) console.log(`stdout: ${stdout}`);
    // 標準エラー出力
    if (stderr) console.log(`stderr: ${stderr}`);
});

第二引数、第三匹数はオプションなので指定せずに実行することは可能だが、コマンド実行時の振る舞いを微調整、カスタマイズしたい時に設定するとよい。上の例では、第二引数のoptionsを指定せず、第三引数のcallbackをラムダ形式で指定している。このcallbackはerror, stdout, stderrという3つの引数を持っているが、いずれもコマンド実行時の結果を保持する変数である。これを初めて見た時、errorとstderrの違いがよくわからなかった。どちらもエラーという名称で似ているが、実際は異なる結果を保持するということがわかった。以下に整理してみる。

・stderr → コマンドが実行されて、そのプログラム内でエラーが発生した場合に出力される。(例えば、入力ファイルがない等)
・error → コマンドをそもそも起動できなかった時にNode.js側が投げるエラー(例えば、コマンドが存在しない、コマンド実行権限がない等)

要するに、コマンド自体は実行できたが、実行中に内部で失敗した場合に出力されるのがstderrで、コマンドすら実行できず、なんの成果も得られませんでしたという場合に出力されるのがerrorというわけ。なるほど。

第二引数(options)について

コマンド文字列を指定して実行するだけのメソッドと思っていたが、ドキュメントを見るとoptionsで指定できる引数が想定以上に多い。それぞれ、どのような役割があり、どのような子輿用法があるか、またそのユースケースを考えてみる。

cwd <string> | <URL> Default: process.cwd()

cwdというのは “Current Working Directory (現在の作業ディレクトリ) ” という意味。execを実行すると子プロセスが生成されるが、この子プロセスの作業ディクレトリを指定するのに使用する。例えば、特定のディレクトリをベースとしてコマンドを実行したい時に使用する。デフォルトでは親プロセスのcwd (process.cwd())と同じになる。指定できる型はstringかWHATWGのURL(file:)形式。WHATWGとはWeb Hypertext Application Technology Working Groupのことで、ブラウザとサーバで共通して扱える統一的なURL仕様のこと。言葉で言われても「なんのこっちゃ」だと思うので、以下に具体的なサンプルを示す。

const { exec } = require("child_process");

// WHATWG の URL を使って cwd を指定
const dirUrl = new URL('file:///Users/kn/myproject/');

exec("ls", { cwd: dirUrl }, (err, stdout, stderr) => {
  if (err) console.error("error:", err);
  if (stdout) console.log("stdout:", stdout);
  if (stderr) console.log("stderr:", stderr);
});

また、process.cwd()を呼ぶと、Node.jsのプロセスが現在どのディレクトリで動いているのかを知ることができる。親プロセスの場合はターミナルでnodeコマンドを叩いた時の場所である。使い所としては、相対パスの基準を確認したい時や、実行環境によってディレクトリが変わるときにパスの差異を補正する用途で使うことになる、などが考えられる。

console.log(process.cwd());

// 実行結果
/Users/kn/myproject

env <Object> Default: process.env

子プロセスに渡す環境変数オブジェクトで、引数の方はObject。デフォルトでは親プロセスの環境変数オブジェクト(process.env)が渡されるので、コマンドに影響する環境変数を切り替えたい時に使用できる。例えば、API_KEYという環境変数を使っているとする。アプリの開発中はテスト用の環境変数を使い、本番環境へデプロイした後は本番用を使いたいというユースケースで使うことができる。以下に簡単なサンプルを示す。

const { exec } = require("child_process");

// 実行モードを決める(例: 引数や環境変数で指定)
const mode = process.argv[2] === "prod" ? "production" : "test";

// APIキーをモードごとに切り替え
const API_KEYS = {
    production: "PROD-KEY-XXXX",
    test: "TEST-KEY-YYYY"
};

// 子プロセスに渡す環境変数を設定
const envVars = {
    ...process.env, // 親の環境変数を引き継ぐ
    API_KEY: API_KEYS[mode] // 指定した環境のAPI_KEY
};

// 設定したAPI_KEY(環境変数)でスクリプトを実行
exec("node script.js", { env: envVars }, (err, stdout, stderr) => {
    // 略
});

ちなみに、「…process.env」はスプレッド構文というもの。親プロセスの環境変数を引き継ぐ際に、環境変数のキーと値を展開してコピーを作るという操作になる。その後ろに「API_KEY: “TEST-1234″」と書くことで、既存の環境変数を残しつつ特定のキーだけ上書きするという使い方ができる。

encoding <string> Default: ‘utf8’

stdout/stderrを返す際のテキストエンコーディングを指す。デフォルトではstring型の “utf8” が指定される。バイナリで取得したいときは “buffer” (Node.jsのBufferオブジェクトとして返る)を指定するとよい。lsやecho、Node.jsスクリプトの文字列出力の場合はutf8を指定すれば、そのままstdoutで受け取れる。バイナリ取得は子プロセスの出力がバイナリデータの場合に使う。例えば画像や動画をffmpegコマンドで生成した場合や(ffmpegは動画像や音声を合成するのに使う外部モジュール。Node.jsにはデフォルトで含まれないので、別途npm install ffmpegが必要)、圧縮ファイル(zip, tar)の内容を標準出力stdoutで取得する場合に利用できる。

例として、作業ディレクトリにある画像(image.png)のコピー(copy.png)を作るサンプルコードを示す。

exec("cat image.png", { encoding: "buffer" }, (err, stdout) => {
  require("fs").writeFileSync("copy.png", stdout);
});

shell <string> Default: ‘/bin/sh’

コマンドを実行するシェルを指定する。デフォルト値として、Unix/macOSでは “/bin/sh” が使用され、Windowsでは “process.env.ComSpec” が指定される。子プロセスをどのシェルで実行するかを指定する時に使用する。ちなみにUnix系OSの場合はfalseを指定することもできるが、その場合はシェルを使わずに直接コマンドを実行する動きになる。この場合、シェルなら使用できるパイプやリダイレクト機能を使った、複雑なコマンドを一行で実行するという用途では使用できないので注意。

// ファイル一覧をテキストファイルにまとめる
exec("ls -l | grep .js > jsfiles.txt", { shell: "/bin/sh" });

また、クロスプラットフォームなアプリを作っている場合、各OS毎のシェルを指定してあげると、OS間の差異を吸収することができる。

exec("dir", { shell: "cmd.exe" }); // Windows用
exec("ls -la", { shell: "/bin/bash" }); // Linux/macOS用

signal <AbortSignal>

AbortSignal型オブジェクトを引数に指定することで、子プロセスの中断制御ができる。具体的には、AbortContorllerを使用して外部からプロセスをキャンセルする場合に使用する。例として、処理に10秒かかる子プロセスを起動した後、3秒後にabort(中断)するというサンプルを示す。中断時は “The operation was aborted” というメッセージがerr.messageに格納される。

const { exec } = require("child_process");

// AbortController を作る
const controller = new AbortController();
const signal = controller.signal;

// 長時間かかるコマンドを実行
const child = exec("sleep 10", { signal }, (err, stdout, stderr) => {
    if (err) console.error("Error:", err.message); // 下でabort()したときに、ここが呼ばれる
    else console.log("Finished"); // 下でabort()するので、これは呼ばれない
});

// 3秒後に中断
setTimeout(() => {
    controller.abort();  // 子プロセスをキャンセル
}, 3000);

ユースケースとしては上記のようなタイムアウト処理として使う他に、webアプリにキャンセルボタンを設けておき、ユーザが任意の非同期で中断処理を差し込めるようにするなどが考えられる。あるいは、ものすごく処理に時間がかかるような画像処理などのタスクを複数のプロセスで並列処理している時に、一時的に負荷を下げたい場合に子プロセスの一部を中断するような制御が考えられる。

timeout <number> Default: 0

子プロセスが指定した時間(ミリ秒)を超えたら強制終了するためのオプション。デフォルトは0(無制限)となっており、処理に時間がかかりすぎる場合に強制的に処理を中断するのに使用できる。なお、中止された場合はcallback(第三引数)のerrorオブジェクトに “Error: Command timed out” が設定される。

ユースケースとしては長時間かかる処理を制限する場合に使う。例えば30秒を超えた場合に自動終了する場合のサンプルを示す。

// 最大30秒まつ
exec("node very_long_process.js", { timeout: 30 * 1000 }, callback);

また、curlやwgetをexecで呼ぶ場合に、通信先のサーバが応答しない場合のタイムアウトにも使える。実行の成否を問わず、いつまで経っても応答が返ってこないAPIというのはユーザ体験を損ねるため「いつまで待つか」は検討の余地があるものの、少なくとも無限に待つような設計にはならないよう、最大待機時間を考えるとよいかもしれない。

// 最大5秒間まつ
exec("curl http://example.com/slow-api", { timeout: 5 * 1000 }, callback);

maxBuffer <number> Default: 1024 * 1024

プロセスのstdout/stderrのバッファ上限(バイト単位)を指定する。デフォルト値は1024 * 102 (1M)となっている。出力が大きい場合、過剰出力でプロセスが強制終了することがあるので、十分な値を設定しておくとよい。

ちなみにバッファ上限を超えた場合、callback引数のerr(stderrではない)にエラーメッセージが出力される。バッファ上限をあえて下げ、上限を超える状況を再現してみよう。

const { exec } = require("child_process");

exec("ls -la", { maxBuffer: 1 }, (err, stdout, stderr) => {
    if (err) console.log(err);
});

このスクリプトを実行すると、以下のように “stdout maxBuffer length exceeded” というエラーがerrに出力される。子プロセスでバッファ上限を超過した場合は、強制的に終了させられるようだ。

$ node script.js 
RangeError [ERR_CHILD_PROCESS_STDIO_MAXBUFFER]: stdout maxBuffer length exceeded
    at Socket.onChildStdout (node:child_process:484:14)
    at Socket.emit (node:events:519:28)
    at addChunk (node:internal/streams/readable:561:12)
    at readableAddChunkPushByteMode (node:internal/streams/readable:512:3)
    at Readable.push (node:internal/streams/readable:392:5)
    at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) {
  code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
  cmd: 'ls -la'
}

デフォルト1Mのバッファ容量で足りないことが予想されるケース、例えば大量の標準出力(DBクエリの結果を出力する場合など)が生成されることが予測される場合や、実行時に上記エラーが出た場合はバッファ上限の増加を考えよう。ただし、出力結果が数百MBを超えるような場合は、ロジックそのものを見直した方がよいかもしれない。

killSignal <string> | <integer> Default: ‘SIGTERM’

シグナルとはUnix系OSにおけるプロセス制御の仕組みのこと。色々なシグナルがあるが、有名なのはSIGTERM(終了依頼)やSIGKILL(強制終了)がある。例として、処理に時間がかかるプロセスに対して、タイムアウトを2秒に設定し、時間を超過したらSIGKILLを子プロセスに送るようなスクリプトは以下のように書ける。

const { exec } = require("child_process");

exec("sleep 100", { timeout: 2 * 1000, killSignal: "SIGKILL" }, (err) => {
  if (err) console.error("Process killed:", err.signal);
});

実行結果は以下の通り。err.signalで子プロセスが受け取ったシグナルを受け取れる。ここでは事前に指定したSIGKILLを確かに受け取っている。

$ node script.js 
Process killed: SIGKILL

なお、スクリプト実行中にターミナルでCtrl + Cを押して終了した場合は、SIGINTというシグナルがプロセスに送られる。ただしSIGINTを受け取ったプロセスはただちに処理を中断するのかというとそうではない。プロセスがその後どう動くかは、そのプロセスの実装内容による。例えば、プロセスが処理結果をディスクに書き出している状況とする。その場合、途中で処理をやめてしまうと、中途半端な状態のファイルがディスクに残ってしまうかもしれない。そのようなケースでは途中まで書いたデータをきちんと破棄した上で処理終了することが望ましい。

Node.jsでプロセスがSIGINTを受け取った場合のイベント処理のサンプルを以下に示す。setInterval()で的なログを1秒間隔で出し続けておき、途中でCtrl + Cを押すと、処理が中断されるというスクリプトになっている。

process.on("SIGINT", () => {
  console.log("Ctrl+C detected! Cleaning up...");
  process.exit(0);
});

setInterval(() => console.log("Running..."), 1000);

留意するべき点としては、SIGINTを受け取ったプロセスは可能な限り早く処理を中断した方が良いということ。SIGINTを受け取ったということは、処理途中にもかかわらずユーザ(あるいは処理主体の親プロセス)がその処理を中断させたい状況下であるということを意味する。そのため、子プロセス側の実装としては、途中状態の出力ファイルや、さらにその子プロセス(孫プロセス)がいた場合、すばやく片付け処理に切り替えて親プロセスに制御を戻すよ設計することが望ましい。

uid <number> または gid <number>

子プロセスを実行するユーザID、グループIDを指定することができる。特定のユーザまたはグループ権限で子プロセスを実行したい時に指定する。LinuxやmacOSのようなUnix系OSのみでしか使用できないことに注意。Unix系OSでは、特定のファイルに対する読み書きや実行をread, write, execute(権限なしは “-” で表現)で制御している。ユーザIDやグループIDを指定すると、例えば、ls -laコマンドをターミナルで実行すると以下のように、そのファイルに対する権限を確認することができる。

-rwxr-xr-x 1 Alice staff  1234 Sep  5 10:00 text.txt

サンプルスクリプトを示す前に、まずターミナルで以下のコマンドを実行し、自分のユーザIDとグループIDを確認しておこう。

$ id -u && id -g
501 ←これがユーザID
20  ←これがグループID

それではサンプルスクリプトを以下に示す。上記で取得したユーザIDとグループIDを子プロセスにセットしてスクリプトを実行する。なお、自分以外のユーザID、グループIDを指定した場合、当然他人のIDになりすましてプロセスを実行することはできないので、EPERM(パーミッション、権限なし)というエラーが発生するので注意。

const { exec } = require("child_process");

exec("id -u && id -g", { uid: 501, gid: 20 }, (err, stdout, stderr) => {
    const [uid, gid] = stdout.trim().split("\n");
    console.log(`Parent -> uid:${process.getuid()}, gid:${process.getgid()}`);
    console.log(`Child  -> uid:${uid}, gid:${gid}`);
});

実行結果は以下の通り。親プロセス、子プロセスのuid, gidをそれぞれ出力している。子プロセスのuid=501, gid=20に指定したことがきちんと反映されている。

$ node script.js 
Parent -> uid:501, gid:20
Child  -> uid:501, gid:20

なお、通常のユーザ権限でスクリプトを実行すると、親プロセスは自分のuid, gidしか子プロセスに指定できないが、rootユーザの場合は特別で任意のuid, gidを指定することができる。これを検証するために、sudoをつけて同じスクリプトを実行してみよう。rootユーザはuid=0, gid=0であり、任意のuid, gidで子プロセスを実行することができることが確認できる。

$ sudo node script.js
Parent -> uid:0, gid:0
Child  -> uid:501, gid:20

ユースケースとして、あるユーザしかアクセスできないファイル、フォルダを実現したり、ユーザごとにプロセスの実行権限を制御するようなアプリを開発する際に役立つだろう。何よりUnixの既存仕様を踏襲した制御方式のため、独自にユーザIDやグループIDの仕組みを実装するよりも安全かつ簡単に設計できると考えられる。

windowsHide <boolean> Default: false

Windows環境限定のオプションで、trueを指定するとWindows上で子プロセスを作成した時に新しいコンソールウィンドウを表示しないようにする。GUIアプリでバックグラウンド処理を行う際に、ユーザに画面を見せたくない場合に利用すればよい。ただし、Linux/macOSでは本オプションは無視されることに注意。また、ウィンドウを非表示にするということは、裏を返せばユーザ視点では「何が実行されているか気づきにくい」ということ。これを見た開発者の皆さんは絶対に悪用しないようにしてほしい。以下にサンプルを示す。

const { exec } = require("child_process");

exec("ping google.com", { windowsHide: true }, (err, stdout) => {
  if (err) throw err;
  console.log(stdout);
});

セキュリティ上の問題と対策について

execを使用する上でのセキュリティ上の問題の一つとして、コマンドインジェクションがあげられる。コマンドインジェクションというのは、ユーザ入力などをコマンド実行に渡す際に、悪意ある命令を実行されてしまう脆弱性のこと。例えばファイルの中身をcatで出力するような簡単なスクリプトを見てみよう。

const { exec } = require("child_process");

const userInput = "file.txt";
exec(`cat ${userInput}`, (err, stdout, stderr) => {
    if (err) console.error(err);
    else console.log(stdout);
});

例えば、中身を表示するファイルの指定をユーザに入力してもらうようなアプリを開発する場合、ユーザに直接ファイル名の文字列を入力するような設計にするかもしれない。もし、入力値に対してなんの対策も施さなかった場合、悪意あるユーザ(Xさんとする)に対して任意の命令を入力する余地を与えてしまう。以下のコードはファイル名に加えてセミコロン(;)+ファイル削除(rm -rf)のコマンドを入力されたケースである。

const { exec } = require("child_process");

// 悪意あるコマンド(フォルダを階層的に強制削除)
const userInput = "file.txt; rm -rf";
exec(`cat ${userInput}`, (err, stdout, stderr) => {
    if (err) console.error(err);
    else console.log(stdout);
});

対策としては、まずコマンドライン文字列の結合を避けることである。例えばexec(“cat ” + userInput)のような文字列結合は危険である。また、入力値の検証を行い、ファイル名として許可する文字列を限定しておくことも効果的である。例えば、以下のように正規表現やホワイトリストで制限することで入力値のサニタイズを行うことができる。あるいはuid, gidのオプションを使い、なるべく低権限のユーザで実行するようにすることも有効な対策になるだろう。

if (!/^[a-zA-Z0-9_\-\.]+$/.test(userInput)) {
    throw new Error("Invalid input");
}

さいごに

child_process.execは使い方次第で何でも実行できる優れたメソッドだが、何でもできるが故に使い方を誤ると予期せぬ実行結果になったり、意図せずセキュリティホール(脆弱性)を作り込んでしまうことにもなりかねない。メソッドの使い方だけにとどまらず、その他、オプションの用途やユースケース、注意点を理解した上で実装できると安全なアプリケーション開発に役立つかもしれない。

コメント

タイトルとURLをコピーしました