Chapter 74 -- Processes from Go: exec, exit codes, signals, and PID 1
In one sentence
Go starts processes with a combined fork+exec, reports their exit status, and can catch signals, but when your program is PID 1 in a container it also inherits the kernel's least-known duty: reaping orphaned processes.
Why this matters
Go programs routinely run other programs (git, ffmpeg, health-check
scripts) and are themselves run as a container's first process. Both roles
have traps: zombies that pile up, children that outlive a timeout, signals
that never arrive, and exit codes that hide what happened.
How it actually works
Running a program well with os/exec:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", "-i", in, out) // killed if ctx expires
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // own process group...
cmd.Cancel = func() error { // ...so we can kill ALL of it,
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // including grandchildren
}
out, err := cmd.CombinedOutput()
var ee *exec.ExitError
if errors.As(err, &ee) {
fmt.Println("exit code", ee.ExitCode()) // -1 means "killed by a signal"
}
The Setpgid + negative-PID kill matters because killing only the direct
child leaves its children (a shell's pipeline, say) running as orphans.
PID 1 is special. When a process exits, its parent must wait() for it;
until then it's a zombie (Chapter 5): no memory, but a PID-table entry.
When a parent dies first, the kernel re-parents its children to PID 1. A
normal init (systemd, tini) loops on wait() to reap them. A Go server running
as PID 1 in a container doesn't, unless you write that loop.
Also: the kernel doesn't apply default signal actions to PID 1, so a PID 1
without a SIGTERM handler simply ignores docker stop and is SIGKILLed after
the grace period. Go installs handlers when you call signal.Notify.
Build it in Go (20 min) -- make zombies, then stop making them
//go:build linux
// pid1: the job nobody tells you your program has when it is PID 1 in a
// container -- reaping orphaned processes. Run it as a container's entrypoint.
//
// docker run --rm -v "$PWD/bin":/b debian:bookworm /b/pid1 # zombies pile up
// docker run --rm -v "$PWD/bin":/b debian:bookworm /b/pid1 -reap # we reap them
// docker run --rm --init -v "$PWD/bin":/b debian:bookworm /b/pid1 # tini reaps them
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
func main() {
reap := flag.Bool("reap", false, "reap orphaned children (what an init process must do)")
flag.Parse()
fmt.Println("my PID is", os.Getpid())
if *reap {
go reaper()
}
for i := 0; i < 5; i++ {
// sh starts a background sleep and exits at once. The sleep is now an
// ORPHAN, and the kernel re-parents orphans to PID 1: us.
cmd := exec.Command("sh", "-c", "sleep 0.2 & exit 0")
cmd.Run() // we reap sh itself; nobody has waited for the sleep yet
}
time.Sleep(time.Second) // the orphaned sleeps exit in the meantime
fmt.Println("zombie processes now:", zombies())
}
// reaper waits for ANY child whenever SIGCHLD arrives. In a real init this
// loop must not race with exec.Cmd.Wait for children you started yourself --
// one reason to use a tiny dedicated init (tini, dumb-init, docker --init).
func reaper() {
ch := make(chan os.Signal, 16)
signal.Notify(ch, syscall.SIGCHLD)
for range ch {
for {
var ws syscall.WaitStatus
pid, err := syscall.Wait4(-1, &ws, syscall.WNOHANG, nil)
if pid <= 0 || err != nil {
break
}
}
}
}
// zombies scans /proc for processes in state Z (exited, never waited for).
func zombies() int {
n := 0
stats, _ := filepath.Glob("/proc/[0-9]*/stat")
for _, f := range stats {
b, err := os.ReadFile(f)
if err != nil {
continue
}
// format: pid (comm) STATE ... -- comm may contain spaces, so split after ')'
if i := strings.LastIndex(string(b), ")"); i > 0 && strings.HasPrefix(string(b[i+2:]), "Z") {
n++
}
}
return n
}
Run it three ways (real output):
$ docker run --rm -v "$PWD/bin":/b debian:bookworm /b/pid1
my PID is 1
zombie processes now: 5
$ docker run --rm -v "$PWD/bin":/b debian:bookworm /b/pid1 -reap
my PID is 1
zombie processes now: 0
$ docker run --rm --init -v "$PWD/bin":/b debian:bookworm /b/pid1
my PID is 7
zombie processes now: 0
What to notice:
- Five zombies from five innocent shell commands. Each
sh -c "sleep 0.2 &"left an orphanedsleepthat became ours. A service that runs scripts thousands of times a day in a container eventually runs out of PIDs. docker --initputstiniin as PID 1. It reaps zombies and forwards signals, and our program became PID 7. In Kubernetes, use an init binary in the image (tini,dumb-init), or share the pod's process namespace withshareProcessNamespace: trueso the pause container reaps.- The
-reaploop races withexec.Cmd.Wait: it can collect a child thatcmd.Runwas waiting for, soRunreturns "no child processes". That race is the main reason to use a dedicated init rather than reaping inside your application.
Exercises:
- Run
/b/pid1withdocker runand, from another terminal,docker exec <id> ps -eo pid,ppid,stat,comm. Find theZentries and their parent. - Remove the
signal.Notifyfrom Chapter 19 of the HTTPS guide's server and run it as PID 1. How long doesdocker stoptake now? (10 s, Docker's default grace period, then SIGKILL.) - Write
runWithTimeout(cmd string, d time.Duration)that kills the whole process group on timeout, and test it withsh -c "sleep 100 & sleep 100".
Common mistakes
- Running a Go binary as PID 1 that starts subprocesses, with no init.
- Killing only the child PID on timeout, leaving grandchildren running.
- Treating
ExitCode() == -1as a normal failure (it means a signal killed it; checkee.Sys().(syscall.WaitStatus).Signal()). - No SIGTERM handling in PID 1, so every shutdown is a SIGKILL.
Check yourself
- Why do orphans become PID 1's responsibility?
- What does
docker run --initchange? - Why use
Setpgidbefore killing a command on timeout?
Further reading
man 2 wait,man 7 signal; the tini README ("What is advantage of Tini?") explains the PID 1 problem in a page.- Chapter 5 (process states) and Chapter 20 (signals) of this guide.