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, а не через командную строку.