Node.js
TypeScript
VSTS

node の spawn に関して調べてみた

More than 1 year has passed since last update.

VSTS の VSTS Task lib というライブラリを触っていると、よく、spawn という言葉に出くわす。この spawn は一体何なのかを調べてみた。

spawn は node で、OS のコマンドを node から実行したい場合に使われるもので、新しい子プロセスを生成してコマンドを実行するメソッドの一部だ。英語の意味合い的には、魚が産卵するときの様子を指している。

子プロセスを生成して、コマンドを実行するメソッドにはいくつかあるので、そのメリットデメリットを整理しておきたい。


1. execFile

execFile は、子プロセスを作成して、コマンドを実行する。例えばこんな感じ。

どのようなケースで使うかというと、あまり重くない処理、時間がかからないものに関して使われる。

import * as child_process from "child_process";

namespace ExecfileSample {
console.log("parent:" + process.pid);
let child = child_process.execFile("ls", ["-l"], (error, stdout, stderr) => {

if (error) {
console.error("stderr", stderr);
throw error;
}
console.log(stdout);
console.log("pid: " +child.pid);
});
}

実行結果はこうなる。ちゃんと、子プロセスが生成されて動いているのがわかる。

$ node execfile.js

parent:2636
合計 11654
-rwxrwxrwx 1 root root 32 7月 16 15:49 Dockerfile
-rwxrwxrwx 1 root root 901 7月 16 16:58 exec.js
-rwxrwxrwx 1 root root 777 7月 16 16:43 exec.ts
-rwxrwxrwx 1 root root 635 7月 16 16:58 execfile.js
-rwxrwxrwx 1 root root 507 7月 16 16:58 execfile.ts
drwxrwxrwx 0 root root 512 7月 16 15:18 node_modules
-rwxrwxrwx 1 root root 331 7月 16 15:18 package.json
-rwxrwxrwx 1 root root 423 7月 16 16:58 spawn.js
-rwxrwxrwx 1 root root 319 7月 16 15:59 spawn.ts
-rwxrwxrwx 1 root root 695 7月 16 16:58 spawnspike.js
-rwxrwxrwx 1 root root 550 7月 16 16:48 spawnspike.ts
-rwxrwxrwx 1 root root 4616 7月 16 15:14 tsconfig.json

pid: 2642


2. spawn

さて、問題の spawn だが、execFile が入出力が固定のものになっているの対し、spawn はストリームになっている部分が異なる。execFile が大量のデータなどに対応できないのに対して、spawnは、ストリームなので、効率よく、大量データのケースにも対応できる。重くて、時間のかかる、docker build コマンドを実行させてみよう。

spawn を実行すると、docker build の実行に時間がかかるが、少しづつ結果が反映される。

import * as child_process from "child_process";

namespace spawn {
console.log("parent:" + process.pid);
let proc = child_process.spawn('docker', ['build', '-t', 'aaa', '.']);
console.log("child:" + proc.pid);
proc.stdout.on('data', (data) => {
console.log(data.toString());
});
}

結果は一気に出てこずに、少しづつ出力される。(ストリームなので)

$ node spawn.js

parent:2664
child:2670
Sending build context to Docker daemon 27.46 MB

Step 1/3 : FROM nginx
---> 2f7f7bce8929

Step 2/3 : RUN ls
---> Using cache
---> 971b157ac789
Step 3/3 : COPY . .

---> 26edec92b96b

Removing intermediate container b5073d23183c

Successfully built 26edec92b96b

Successfully tagged aaa:latest

さて、これと同じコードを、execFile で書いたらこうなる。

import * as child_process from "child_process";

namespace ExecfileSample {
console.log("parent:" + process.pid);
let child = child_process.execFile("docker", ["build", "-t", "aaa", "."], (error, stdout, stderr) => {
if (error) {
console.error("stderr", stderr);
throw error;
}
console.log(stdout);
console.log("pid: " +child.pid);
});
}

実行結果は上記と同じだが、徐々に結果がでてくるのではなく、全部終わってから一気に出てくる。ストリームではなく、一括で処理されるので、大量データには向いていない。


3. exec

他には、exec という形態もある。これは、内部的にシェルを実行するようになっている。上記の2つが、パイプに対応していないのに対し、exec はシェルファイルを作って実行するようなものなので、パイプに対応している。ただし、なんでも実行できてしまうので、shell injection というセキュリティ問題をはらんでいる。比較してみよう。

import * as child_process from "child_process";

namespace ExecSample {
child_process.exec('ls -l | grep exec', (error, stdout, stderr) => {
if ( error instanceof Error) {
console.error(error);
console.log('exec Error *******');
} else {
console.log(stdout);
console.log('exec Success!');
}
});
child_process.execFile('ls -l | grep exec' , (error, stdout, stderr) => {
// child_process.execFile('ls', ['-l', '|','grep', 'exec'] , (error, stdout, stderr) => {
if (error instanceof Error) {
console.error(error);
console.log('execFile Error ******');
} else {
console.log('execFile Success!');
}
} );

結果はこんな感じ

$ node exec.js

{ Error: spawn ls -l | grep exec ENOENT
at exports._errnoException (util.js:1018:11)
at Process.ChildProcess._handle.onexit (internal/child_process.js:193:32)
at onErrorNT (internal/child_process.js:367:16)
at _combinedTickCallback (internal/process/next_tick.js:80:11)
at process._tickCallback (internal/process/next_tick.js:104:9)
at Module.runMain (module.js:606:11)
at run (bootstrap_node.js:389:7)
at startup (bootstrap_node.js:149:9)
at bootstrap_node.js:504:3
code: 'ENOENT',
errno: 'ENOENT',
syscall: 'spawn ls -l | grep exec',
path: 'ls -l | grep exec',
spawnargs: [],
cmd: 'ls -l | grep exec' }
execFile Error ******
-rwxrwxrwx 1 root root 901 7月 16 16:58 exec.js
-rwxrwxrwx 1 root root 777 7月 16 16:43 exec.ts
-rwxrwxrwx 1 root root 635 7月 16 16:58 execfile.js
-rwxrwxrwx 1 root root 507 7月 16 16:58 execfile.ts

exec Success!

ENOENT は、ディレクトリにエントリがないということを意味する。ちなみに、この書き方はexecFile の書き方としては間違っているので、違う書き方をしてみる。

  // child_process.execFile('ls -l | grep exec' , (error, stdout, stderr) => {

child_process.execFile('ls', ['-l', '|','grep', 'exec'] , (error, stdout, stderr) => {

$ node exec.js

-rwxrwxrwx 1 root root 896 7月 16 17:14 exec.js
-rwxrwxrwx 1 root root 777 7月 16 17:13 exec.ts
-rwxrwxrwx 1 root root 635 7月 16 17:14 execfile.js
-rwxrwxrwx 1 root root 507 7月 16 16:58 execfile.ts

exec Success!
{ Error: Command failed: ls -l | grep exec
ls: '|' にアクセスできません: そのようなファイルやディレクトリはありません
ls: 'grep' にアクセスできません: そのようなファイルやディレクトリはありません
ls: 'exec' にアクセスできません: そのようなファイルやディレクトリはありません

at ChildProcess.exithandler (child_process.js:204:12)
at emitTwo (events.js:106:13)
at ChildProcess.emit (events.js:191:7)
at maybeClose (internal/child_process.js:891:16)
at Process.ChildProcess._handle.onexit (internal/child_process.js:226:5) killed: false, code: 2, signal: null, cmd: 'ls -l | grep exec' }
execFile Error ******

こちらの方は実行前に、止めてくれる。ちなみに、spawn で書くと何も出力されない、実行されない感じだった。この辺デバッグの知見があるかたは、コメントいただけると嬉しいです。


おわりに

今回は、spawn という意味を調べて、その周辺のメソッドを探索した。基本的に、プロセスを生成して、コマンドを実行する仕組みだが、exec メソッド以外では、パイプは使えないとわかった。(プロセスをもう一つ生む必要があるからと思われる)VSTS の内部では、いづれかのメソッドが使われているだろうから、今度はでてきた エラーメッセージでより推測が立てやすくなりそうだ。

 ちなみに、folkという、spawn の発展形でプロセス間通信ができるものも存在する。今回はタイムアップで眠いのでここまで。node のストリーム周りとかも探索してみたいし、パイプもメソッドがありそうなので、使える仕組みも探索してみたい。

ここら辺と、Mock 周りを理解していると、vsts task lib のコードは結構読めるようになるかもしれない。

追記 2017/07/17

spawn が何も出力されない件に関して、問題がわかったのと、spawn は shell も実行できるのがわかったので次のエントリをかきました。

node の spawn に関して調べてみた その2


参考

Understanding execFile, spawn, exec, and fork in Node.js

Readable Stream

VSTS task lib