Day 112 — Zero-Downtime Deployment via FD Inheritance
| Learning objective | Pass a listening socket FD from the old process to a new process without dropping connections |
| Key packages | net, os, os/exec, syscall, os/signal |
| Security context | Zero-downtime restarts are required for security patch deployments — you can't afford a restart gap |
Key Concepts
FD inheritance flow:
- Running process receives
SIGUSR1 - Running process starts new process, passing its listener FD via
cmd.ExtraFiles - New process detects FD 3 (first ExtraFile), wraps it as
net.Listener - New process starts serving; old process drains remaining connections and exits
Exercise — FD Inheritance Server
package ops
import (
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"syscall"
"time"
)
const fdEnvVar = "LISTENER_FD"
// ListenerFromEnv retrieves an inherited listener FD, if present.
// Returns (listener, true) if FD was inherited; (nil, false) if starting fresh.
func ListenerFromEnv() (net.Listener, bool) {
fdStr := os.Getenv(fdEnvVar)
if fdStr == "" {
return nil, false
}
fd, err := strconv.Atoi(fdStr)
if err != nil {
return nil, false
}
// Wrap the FD as a net.Listener
file := os.NewFile(uintptr(fd), "listener")
if file == nil {
return nil, false
}
defer file.Close()
l, err := net.FileListener(file)
if err != nil {
return nil, false
}
return l, true
}
// StartServer starts the server, using an inherited listener if available
func StartServer(addr string, handler http.Handler) error {
var l net.Listener
var err error
if inherited, ok := ListenerFromEnv(); ok {
l = inherited
fmt.Println("server: using inherited listener FD")
} else {
l, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
fmt.Printf("server: listening on %s\n", addr)
}
server := &http.Server{
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
// Watch for SIGUSR1 (upgrade signal)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGUSR1)
go func() {
<-sigCh
fmt.Println("server: received upgrade signal, starting new process")
if err := startNewProcess(l); err != nil {
fmt.Printf("server: failed to start new process: %v\n", err)
return
}
// Gracefully drain: stop accepting, wait for in-flight
fmt.Println("server: draining connections (old process)")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
return server.Serve(l)
}
// startNewProcess starts a new instance of the current binary
// and passes the listener FD via ExtraFiles
func startNewProcess(l net.Listener) error {
// Get the underlying *os.File from the listener
tcpListener, ok := l.(*net.TCPListener)
if !ok {
return fmt.Errorf("listener is not a *net.TCPListener")
}
f, err := tcpListener.File()
if err != nil {
return fmt.Errorf("get listener file: %w", err)
}
defer f.Close()
// os.Args[0] is the current binary
cmd := exec.Command(os.Args[0], os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{f} // FD 3 (stdin=0, stdout=1, stderr=2, extraFiles[0]=3)
cmd.Env = append(os.Environ(), fmt.Sprintf("%s=3", fdEnvVar))
return cmd.Start()
}