Frontend Labs
⋯

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:

  1. Running process receives SIGUSR1
  2. Running process starts new process, passing its listener FD via cmd.ExtraFiles
  3. New process detects FD 3 (first ExtraFile), wraps it as net.Listener
  4. 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()
}

Generated from week15.md · 8 Oct 2026 16:36