Exploring the exec system call with Go
Sat, Feb 25, 2017 5 minutesIf you’ve created a Docker container, you’ve likely seen a docker-entrypoint.sh
script which ends with exec $@
. The ENTRYPOINT
directive is a common way to add some sort of initialization to a docker container. It can be used to update some configuration based on environment variables passed to the container, generate some random data or do anything else necessary for the process to start. Today I want to focus on the exec $@
line that such an entrypoint script often ends with.
Until recently, my understanding of exec $@
was limited to “runs the arguments passed to the script at the end”. It was enough for me to use it, but not to be able to actually explain it. Then, one day I wanted to write a Docker entrypoint in Go instead of bash. My usual way of shelling out to another process with os/exec
wouldn’t work here. A process started with os/exec
is still managed by the original process. I wanted my entrypoint to do its thing, but then let the next process take over completely. That’s what exec $@
does. So, I had to figure out how to express that in Go. Google didn’t really help, but I asked in the Gophers Slack and Kevin Burke pointed me to syscall.Exec
and showed me a quick example of his own. Soon enough, I had an example of my own:
package entrypoint
func Exec() {
flag.Parse()
if len(os.Args) == 1 {
return
}
cmd, err := exec.LookPath(os.Args[1])
if err != nil {
log.Fatal(err)
}
if err := syscall.Exec(cmd, flag.Args(), os.Environ()); err != nil {
log.Fatal(err)
}
}
I could now add entrypoint.Exec()
at the end of a Go script and have it behave exactly like the shell one liner.
syscall.Exec and its properties
// Exec invokes the execve(2) system call.
func Exec(argv0 string, argv []string, envv []string) (err error) {
The godoc for syscall.Exec
points us at man 2 execve
.
execve() transforms the calling process into a new process. The new process is constructed from an ordinary file, whose name is pointed to by path, called the new process file. This file is either an executable object file, or a file of data for an interpreter.
The man page is helpful, but it reads like it was written for C programmers, so writing a few working examples is actually more productive.
I started with something simple. Trying to call ls -al
.
func main() {
err := syscall.Exec("/bin/ls", []string{"-al"}, os.Environ())
log.Println(err)
}
Unfortunately, it did not work. After some trial an error, I realized I must have the binary name as the first argument in my args
slice. The first argument to exec
is the path, and the second is an array of arguments, starting with the command name.
func main() {
err := syscall.Exec("/bin/ls", []string{"ls","-al"}, os.Environ())
log.Println(err)
}
The example above works, but still doesn’t tell us much about what is actually happening. Let’s add a few debug statements to our program.
I wrote a small binary which prints its process ID, calls exec
with its own path and arguments and prints a goodbye
string before exiting.
func main() {
fmt.Printf("%s: pid is %d\n", os.Args[0], os.Getpid())
if err := syscall.Exec(os.Args[0], os.Args, os.Environ()); err != nil {
log.Fatal(err)
}
fmt.Println("goodbye")
}
Here is the output of running my ./exec-self
binary:
./exec-self: pid is 55415
./exec-self: pid is 55415
./exec-self: pid is 55415
./exec-self: pid is 55415
./exec-self: pid is 55415
./exec-self: pid is 55415
./exec-self: pid is 55415
Two interesting details immediately pop out:
- The pid stays the same.
goodbye
is never printed.
With that observation we can conclude that a process created by syscall.Exec
will inherit its process ID and completely replace the original.
Looking at the manpage shows that a range of other properties of the original process are inherited, such as working directory and open file descriptors.
Until now I was just using exec $@
in my Docker entrypoint scripts, but now having a better understanding of exec
, I see lots of ways I can take advantage of it in my work.
Deploying a Go binary to Heroku
I did get to use the exec syscall recently, while working on deploying an application to Heroku. At work we release our product as a Go binary which users can run on their own. One of the deployment methods we wanted to support right away was a Click to Deploy button for Heroku. The Kolide app is written with the guidelines of a 12 factor app in mind, so deploying to Heroku should be simple. However, Heroku does not provide a immediately obvious way of deploying an arbitrary binary to its platform. You must go through their buildpack process. There were a few things I could’ve done, such as committing the binary to a git repo. I chose to use the Go buildpack to write a script which:
- Downloads the latest.zip folder from our download site.
- Extracts the linux binary from the zip and writes it as an executable to disk.
- Sets all the required environment vars necessary for our binary to run.
- Uses
syscall.Exec
to start the real process once setup is complete.
The above strategy proved to work out quite well and I had a working script in no time. With the exception of importing the mysql library to parse a DSN string, I was able to accomplish everything with the Go standard library. You can see the entire script as part of the 1-Click Deploy pull request.