Search code examples
node.jsdockerpipechild-process

Node.js spawn `docker run` command with extra streams


I'm running a command line app from a Node.js process using spawn(). The process is launched with extra pipes in the stdio option. Here's a simplified code sample:

const stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
const process = spawn('/path/to/command', [], { stdio });

// Later...
const { 3: pipeWrite, 4: pipeRead } = process.stdio;
pipeRead.on('data', (data) => {
  if (String(data) === "PING?") {
    pipeWrite.write("PONG!");
  }
}); 

Now, this works fine but I want to run the command inside a docker container, using docker run as the spawned executable:

const stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
const process = spawn(
  '/usr/bin/env', [
    'docker',
    'run',
    '--rm',
    'my-image',
    '/path/to/command'
  ], { stdio }
);

This fails, the command line app inside the docker container says it cannot write to pipe. Is it possible to achieve this with docker run?

I've set up a Github repo that demonstrates the problem, though I should make it clear that this is merely a demonstration and I do not have the means to change behaviour of the real child process (which is Chromium, in case anyone is interested!).


Solution

  • The method you are trying isn't achievable. A child process can share the file descriptors with parent however when running a process inside docker it is containerized first. You can achieve this using named pipes instead of pipes. Named pipes are like pipes but have the interface of a file.

    According to this it is doable using named pipes:

    This is the modified code of the Github repo you posted:

    test.js

    import { spawn } from "child_process";
    import path from 'path';
    import { fileURLToPath } from 'url';
    import {describe, jest} from '@jest/globals'
    import fs from "fs";
    
    const __dirname = path.dirname(fileURLToPath(import.meta.url));
    
    describe("on docker container", () => {
      let child;
      jest.setTimeout(60000);
      afterEach(() => child?.kill('SIGINT'));
      it("responds with PONG! when PING? is sent", async () => {
        child = spawn(
          "/usr/bin/env",
          [
            "docker",
            "run",
            "--rm",
            "--init",
            `-v=${ __dirname }:/usr/app`,
            "node:latest",
            "node",
            "/usr/app/ponger.js"
          ],
          {
            stdio: ['inherit', 'inherit', 'inherit']
          }
        );
    
        await new Promise((resolve, reject) => {
          const fifoWritePath = path.resolve(`${ __dirname }/named_pipe_in`);
          const fifoReadPath = path.resolve(`${ __dirname }/named_pipe_out`);
          const pipeWrite = fs.createWriteStream(fifoWritePath);
          const pipeRead = fs.createReadStream(fifoReadPath);
    
          pipeRead.on('data', (message) => {
            console.log(message)
            if (String(message) === "PONG!\n") {
              resolve();
            }
            else {
              reject();
            }
          });
          pipeWrite.write("PING?\n");
        });
      });
    });
    

    ponger.js

    import fs from "fs";
    import path from 'path';
    
    const fifoReadPath = path.resolve('/usr/app/named_pipe_in');
    const fifoWritePath = path.resolve('/usr/app/named_pipe_out');
    
    const pipeRead = fs.createReadStream(fifoReadPath);
    const pipeWrite = fs.createWriteStream(fifoWritePath);
    
    pipeRead.on("data", (message) => {
      if (String(message) === "PING?\n") {
        pipeWrite.write("PONG!\n");
      }
    });
    

    Before you run npm test while in the directory of the project execute the following :

    mkfifo --mode=777 named_pipe_out
    mkfifo --mode=777 named_pipe_out
    

    The previledges are only for test purposes, keep in mind that such a setting can ruin the security of your application.

    PS. I can elaborate further on why your approach will not going to work in this setting.