Why Go’s Cmd.Run fails without /dev/null

Go’s Cmd.Run can fail in environments where /dev/null isn’t present (or isn’t accessible).

To explore this behaviour, let’s start with a quick test program:

package main

import (
	"fmt"
	"os"
	"os/exec"
)

func main() {
	cmd := exec.Command("/usr/local/bin/docker-explorer", "echo", "hey")

	err := cmd.Run()
	if err != nil {
		fmt.Println("Error:", err)
		os.Exit(1)
	}

	fmt.Println("Executed command")
}

This program runs the following command:

$ /usr/local/bin/docker-explorer echo hey

Note: docker-explorer is an executable program that exposes the echo command. I have it locally installed at /usr/local/bin/docker-explorer.

Running this locally works fine, since /dev/null is available.

$ go run main.go
Executed command

To simulate an environment where /dev/null isn’t available, let’s setup a chroot jail.

# Build the program to /tmp/test-program
$ CGO_ENABLED=0 go build -o /tmp/test-program main.go
# Create a directory to use as the chroot jail
$ mkdir chroot_jail
# Copy docker-explorer into the chroot jail
$ mkdir -p chroot_jail/usr/local/bin && cp /usr/local/bin/docker-explorer chroot_jail/usr/local/bin
# Copy the test program into the chroot jail directory
$ cp /tmp/test-program chroot_jail/

Note: The setup above might not work on macOS because of this change.

Now, let’s execute the test program within the chroot jail:

$ sudo chroot chroot_jail /test-program
Error: open /dev/null: no such file or directory

That fails citing that /dev/null isn’t available.

Since our small Go program just executes /usr/local/bin/docker-explorer, let’s try running that directly:

$ sudo chroot chroot_jail /usr/local/bin/docker-explorer echo hey
hey

That didn’t fail, so the /dev/null error definitely has something to do with our Go script, and not docker-explorer itself.

A look at the source for Cmd.Run reveals why /dev/null is used:

os-exec-source

Let’s validate this by rewriting our program to use dummy values for Stdout, Stderr and Stdin, so that Go doesn’t use /dev/null:

package main
import (
"fmt"
"os"
"os/exec"
)
+ type nullReader struct{}
+ type nullWriter struct{}
+
+ func (nullReader) Read(p []byte) (n int, err error) { return len(p), nil }
+ func (nullWriter) Write(p []byte) (n int, err error) { return len(p), nil }
+
func main() {
cmd := exec.Command("/usr/local/bin/docker-explorer", "echo", "hey")
+ cmd.Stdin = nullReader{}
+ cmd.Stdout = nullWriter{}
+ cmd.Stderr = nullWriter{}
+
err := cmd.Run()
if err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
fmt.Println("Executed command")
}

When run with the new code, the program executes successfully:

$ CGO_ENABLED=0 go build -o /tmp/test-program-2 /vagrant_data/main2.go 
$ cp /tmp/test-program-2 chroot_jail/test-program-2
$ sudo chroot chroot_jail /test-program-2
Executed command

TLDR: If either one of Cmd.Stdout, Cmd.Stderr, Cmd.Stdin are not explicitly set, Go sets them to /dev/null. This can cause failures when using Cmd.Run in Go programs that execute in environments where /dev/null isn’t accessible. To fix this, either assign dummy values to Cmd.Stdout, Cmd.Stderr and Cmd.Stdin, or ensure that /dev/null is accessible.

Note: I stumbled upon this behaviour when working on the “Build your own Docker” challenge.