child_process: запуск внешних программ

Когда работу проще отдать готовой программе — ffmpeg, git, python, — Node запускает её как дочерний процесс через child_process и общается с ней потоками ввода-вывода.

child_process — встроенный модуль Node.js для запуска внешних программ и других процессов Node. Он даёт четыре способа — spawn, exec, execFile и fork, — различающихся буферизацией вывода, наличием оболочки и каналом связи.

worker_threads запускали JavaScript в потоках, cluster — копии вашего же приложения. child_process решает третью задачу: запустить любую внешнюю программу и работать с ней. Конвертировать видео через ffmpeg, дёрнуть git, вызвать Python-скрипт для расчёта, сжать архив утилитой системы — всё это запуск отдельного процесса ОС, которым Node управляет через стандартные потоки stdin, stdout, stderr.

Зачем это на практике

Переписывать на JavaScript то, что уже идеально делает специализированная утилита, — пустая трата сил. ffmpeg кодирует видео лучше любой npm-обёртки, ImageMagick жмёт картинки, системный tar архивирует. child_process позволяет оркестрировать эти инструменты из Node: принять файл по HTTP, прогнать через внешний кодек, отдать результат. Здесь же кроется и главная опасность приёма — если строить команду из пользовательского ввода неаккуратно, получаешь classic-уязвимость command injection. Поэтому важно не только «как запустить», но и «как запустить безопасно».

Четыре способа и чем они отличаются

Все методы запускают процесс, но по-разному. Вот ориентир для выбора:

МетодОболочкаВыводКогда брать
spawnнет (по умолчанию)потоком (streaming)большой/длительный вывод, потоковая обработка — основной выбор
execFileнетбуфер целиком в колбэккороткий вывод, программа по пути, без оболочки — безопасно
execда (через /bin/sh)буфер целиком в колбэкнужны возможности shell (пайпы, &&); осторожно с вводом
forkнет+ IPC-каналзапустить ДРУГОЙ Node-скрипт и общаться сообщениями

spawn: потоковый и основной

spawn не копит вывод в памяти, а отдаёт его потоком по мере поступления — это правильный выбор, когда программа печатает много или работает долго.

const { spawn } = require('child_process');

// аргументы — отдельным массивом, НЕ склеенной строкой
const child = spawn('ls', ['-la', '/tmp']);

child.stdout.on('data', (chunk) => process.stdout.write(chunk)); // поток вывода
child.stderr.on('data', (chunk) => console.error('ошибка:', chunk.toString()));
child.on('close', (code) => console.log('процесс завершился с кодом', code));

Поскольку stdout и stderr — это Readable-потоки, их можно соединять в пайплайны: например, направить вывод одной программы во вход другой или в файл, не держа всё в памяти.

execFile: коротко и без оболочки

Если программа выдаёт немного текста и удобнее получить его целиком, берут execFile — он буферизует результат и отдаёт в колбэк. Важно: оболочку он не запускает.

const { execFile } = require('child_process');

execFile('git', ['rev-parse', 'HEAD'], (err, stdout, stderr) => {
  if (err) return console.error('git упал:', err.message);
  console.log('текущий коммит:', stdout.trim());
});

exec: удобно, но с оболочкой

exec запускает команду через системную оболочку (/bin/sh -c), поэтому работают пайпы, &&, подстановки. Цена — именно оболочка и делает приём опасным при недоверенном вводе.

const { exec } = require('child_process');
exec('cat access.log | grep ERROR | wc -l', (err, stdout) => {
  if (!err) console.log('ошибок в логе:', stdout.trim());
});

stdio: как устроен ввод-вывод дочернего процесса

У дочернего процесса три стандартных канала: stdin (вход), stdout (вывод), stderr (ошибки). Опцией stdio при запуске вы решаете, куда их направить.

// 'inherit' — переиспользовать каналы родителя: вывод программы
// польётся прямо в консоль Node (удобно для логов сборки)
spawn('npm', ['run', 'build'], { stdio: 'inherit' });

// 'pipe' (по умолчанию) — каналы доступны как child.stdin / child.stdout
const child = spawn('sort');
child.stdin.write('banana\napple\ncherry\n'); // кормим вход
child.stdin.end();                              // закрываем вход — сигнал «данные кончились»
child.stdout.on('data', (d) => console.log('отсортировано:\n' + d));

Передача данных во вход через stdin — частый и безопасный паттерн: вместо того чтобы вставлять пользовательский текст в командную строку (риск инъекции), вы пишете его в stdin программы, где он трактуется как данные, а не как часть команды.

fork: дочерний процесс Node с каналом сообщений

fork — частный случай spawn специально для запуска другого скрипта Node. Он автоматически поднимает IPC-канал, и процессы общаются через send / message — как воркеры в cluster.

// parent.js
const { fork } = require('child_process');
const child = fork('./calc.js');
child.send({ a: 21, b: 21 });
child.on('message', (msg) => console.log('сумма из дочернего процесса:', msg.sum));

// calc.js
process.on('message', (m) => {
  process.send({ sum: m.a + m.b });
});

Отличие от worker_threads принципиальное: fork создаёт отдельный процесс (своя память, свой PID, изоляция как у cluster), а worker_threads — поток внутри текущего процесса. fork тяжелее по ресурсам, но надёжнее изолирован.

Как это работает под капотом

Все четыре функции в конечном счёте опираются на spawn, а тот — на системный вызов порождения процесса (fork+exec на Unix, CreateProcess на Windows). Разница между execFile и exec ровно в одном: execFile('git', ['log']) запускает программу напрямую, передавая ей массив аргументов как есть, а exec('git log') на самом деле запускает /bin/sh -c "git log" — то есть отдаёт строку оболочке на разбор. Именно поэтому exec понимает пайпы и && (их обрабатывает shell), и именно поэтому он опасен: оболочка трактует ;, |, $() как управляющие символы. Когда метод не использует оболочку (spawn/execFile с массивом аргументов), даже строка вроде "; rm -rf /" попадёт в программу как один безобидный строковый аргумент, а не как новая команда.

Безопасность аргументов

Главное правило: никогда не склеивайте пользовательский ввод в командную строку для exec. Сравните:

// ОПАСНО: ввод уходит в оболочку. filename = 'x.txt; rm -rf ~'
exec('convert ' + filename + ' out.png');   // оболочка выполнит и rm!

// БЕЗОПАСНО: программа вызвана напрямую, аргументы — массив, оболочки нет
execFile('convert', [filename, 'out.png']);  // filename — всегда один аргумент

В безопасном варианте filename передаётся как единый элемент массива и не интерпретируется оболочкой, что бы в нём ни было. Правило простое: предпочитайте spawn/execFile с массивом аргументов; используйте exec только для доверенных, фиксированных команд; данные передавайте через stdin, а не через командную строку.

Частые ошибки

  • Склеивать ввод пользователя в строку для exec. Прямой путь к command injection. Берите execFile/spawn с массивом аргументов.
  • Использовать exec для большого вывода. Он буферизует всё в память; при превышении maxBuffer процесс падает с ошибкой. Для объёмного вывода — spawn с потоком.
  • Игнорировать stderr и код возврата. Программа может «молча» провалиться: судить об успехе нужно по коду выхода в событии close/exit, а не по наличию какого-то вывода.
  • Путать fork и worker_threads. fork — это новый процесс (тяжело, изолированно), worker — поток в текущем процессе (легче). Для CPU внутри сервиса обычно нужен второй.
  • Забывать закрывать stdin. Многие утилиты (например, sort) ждут конца ввода; без child.stdin.end() процесс зависнет в ожидании данных.

Итоги

  • child_process запускает внешние программы и другие Node-скрипты; общение идёт через стандартные потоки stdin/stdout/stderr.
  • spawn — потоковый, для большого/длительного вывода; execFile — буфер без оболочки; exec — буфер через оболочку; fork — другой Node-скрипт с IPC.
  • Опция stdio направляет каналы: 'inherit' — в консоль родителя, 'pipe' — доступ через child.stdout/stdin.
  • Главный риск — command injection: exec отдаёт строку оболочке, поэтому пользовательский ввод туда склеивать нельзя.
  • Безопасно — spawn/execFile с массивом аргументов; данные передавайте через stdin, а не через командную строку.
Проверьте себя
1. В чём ключевое отличие exec от execFile в модуле child_process?
Aexec работает только на Windows, а execFile — только на Linux
Bexec запускает команду через системную оболочку (/bin/sh -c), а execFile вызывает программу напрямую с массивом аргументов
Cexec быстрее, потому что не буферизует вывод
DexecFile умеет запускать только Node-скрипты
2. Какой метод стоит выбрать для программы с большим или длительным потоковым выводом (например, ffmpeg)?
Aexec — он удобнее всего собирает весь вывод
Bspawn — он отдаёт вывод потоком по мере поступления, не копя всё в памяти
CexecFile — он буферизует вывод целиком
Dfork — он специально создан для внешних программ
3. Как безопасно передать пользовательское имя файла во внешнюю программу, чтобы избежать command injection?
AСклеить его в строку и передать в exec
BПередать его отдельным элементом массива аргументов в execFile или spawn (без оболочки)
CУдалить из него все буквы перед передачей в exec
DКомандную строку можно строить как угодно — инъекция в Node невозможна