Простой zero-downtime (хоть и не прям "zero") я вам показал, и обещал показать сложный (уже прям zero).

Помимо socket-activation есть другой подход: когда процесс передаёт сокет ребёнку, и тот начинает его обрабатывать только тогда, когда всё проинициализировал. После начала обработки отправляется сигнал родителю, и тот завершает свою работу. Интересно то, что ребёнка запускает именно родитель.

Далее я покажу это на примере Linux и systemd, хотя сам механизм должен работать на любом UNIX. И давайте пока вот как договоримся: я покажу высокоуровневую реализацию с готовой библиотекой от cloudflare, а если вы захотите, чтобы я копнул глубже, вы об этом напишете в Telegram канале. Иначе всё-всё в пост не уместишь при всём желании 🙂 

Как это выглядит на практике?   

Нам понадобится библиотека: 

go get -u github.com/cloudflare/tableflip

tableflip как раз инкапсулирует запуск слушателя, передачу сокета ребёнку и обратный сигнал об успешном завершении. 

Код на Go: 

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/cloudflare/tableflip"
)

const (
	addr            = ":8080"
	shutdownTimeout = 30 * time.Second // время на graceful shutdown
)

func main() {
	upg, err := tableflip.New(tableflip.Options{
		PIDFile: "/run/my-app2.pid",
	})
	if err != nil {
		log.Fatalf("tableflip: %v", err)
	}
	defer upg.Stop()

	log.Printf("PID=%d starting...", os.Getpid())

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
		w.Write([]byte(fmt.Sprintf("Hello from PID %d\n", os.Getpid())))
	})

	srv := &http.Server{Handler: mux}
	ln, err := upg.Listen("tcp", addr) // tableflip сам наследует/передаёт FD
	if err != nil {
		log.Fatalf("listen: %v")
	}

	log.Printf("PID=%d: initializing...", os.Getpid())

	// Имитируем долгую инициализацию
	time.Sleep(5 * time.Second)
	// Здесь мы как бы инициализировались

	// Запускаем сервер
	go func() {
		log.Printf("PID=%d serving on %s", os.Getpid(), addr)
		if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Printf("serve: %v", err)
		}
	}()

	// Сообщаем, что готовы принимать трафик, тогда родитель получит Exit() (ниже)
	if err := upg.Ready(); err != nil {
		log.Fatalf("ready: %v", err)
	}
	log.Printf("PID=%d: READY (accepting traffic)", os.Getpid())

	// Обрабатываем сигналы SIGHUP/SIGUSR2 для reload и SIGTERM/SIGINT для shutdown
	waitForSignals(upg)

	// Как только новый процесс скажет Ready(), старый получит Exit()
	waitForExitAndShutdown(upg.Exit(), srv)

	select {} // выше управляем процессом сами
}

func waitForSignals(upg *tableflip.Upgrader) {
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGHUP, syscall.SIGUSR2, syscall.SIGTERM, syscall.SIGINT)
	go func() {
		for s := range sigs {
			switch s {
			// Сигнал родителю при `reload`
			case syscall.SIGHUP, syscall.SIGUSR2:
				log.Printf("PID=%d: upgrade requested", os.Getpid())
				if err := upg.Upgrade(); err != nil {
					log.Printf("upgrade: %v", err)
				}

			case syscall.SIGTERM, syscall.SIGINT:
				log.Printf("PID=%d: stop requested", os.Getpid())
				upg.Stop() // разбудит upg.Exit()
			}
		}
	}()
}

func waitForExitAndShutdown(exit <-chan struct{}, srv *http.Server) {
	go func() {
		<-exit // = upg.Exit()
		log.Printf("PID=%d: received Exit -> graceful shutdown", os.Getpid())
		ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
		defer cancel()

		// Отключаем keep-alive, чтобы клиенты не «зависали» на старом процессе
		srv.SetKeepAlivesEnabled(false)
		if err := srv.Shutdown(ctx); err != nil {
			log.Printf("failed to shutdown server: %v", err)
		}

		// Имитация долгого завершения: можно закрыть БД, дождаться завершения запросов и т.п.
		log.Printf("PID=%d: post-shutdown cleanup...", os.Getpid())
		time.Sleep(5 * time.Second)
		os.Exit(0) // завершаем процесс
	}()
}

И, разумеется, myapp2.service файл для systemd: 

[Unit]
Description=My Go App (tableflip)
After=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/myapp2
ExecReload=/bin/kill -HUP $MAINPID
PIDFile=/run/my-app2.pid

# Чтобы systemd не убил «ребёнка» во время апгрейда:
KillMode=process
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target

Тестирование

Всё как в прошлый раз: скопируйте бинарник в /usr/bin/myapp2 с правами на исполнение, скопируйте myapp2.service в /etc/systemd/system/. Затем запускаете сервис через systemd start myapp2.

Для теста всё также запускаете команду в соседней вкладке терминала: 

while true; do   printf '%s ' "$(date +'%Y-%m-%dT%H:%M:%S%z')";   curl -s -w " | time=%{time_total}s\n" 127.0.0.1:8080;   sleep 0.2; done

Вы будете видеть примерно такое: 

2025-10-01T04:10:05+0400 Hello from PID 7767
 | time=0.000731s
2025-10-01T04:10:05+0400 Hello from PID 7767
 | time=0.000517s

Затем запускаете systemctl reload myapp2, и спустя 5 секунд, которыми мы выше имитировали "инициализацию" (коннект с базами, чтение файла и так далее) лог станет таким: 

2025-10-01T04:10:05+0400 Hello from PID 7767
 | time=0.000517s
2025-10-01T04:10:06+0400 Hello from PID 7767
 | time=0.000477s
2025-10-01T04:10:06+0400 Hello from PID 22231
 | time=0.000612s
2025-10-01T04:10:06+0400 Hello from PID 22231
 | time=0.000467s

PID сменился, а мы не словили никакого простоя! 

Про PIDFile

PIDFile нужен для того, чтобы systemd знал актуальный ID главного процесса. Иначе при завершении родителя он будет думать, что сервис умер, хотя ребёнок в это время прекрасно существует. Это нормальный подход, но в качестве альтернативы без файла можно передавать в systemd MAINPID из ребёнка, когда он готов обрабатывать запросы.

tableflip пишет новый PID во время вызова Ready(), конечно. Если ребёнок не сможет запуститься, то родитель останется и будет в MAINPID у systemd. 

Минусы подхода

Основной – это, конечно, двойной расход памяти в моменте (старый процесс + новый). И ещё мне не нравится select {}, но я что-то пока не придумал, как сделать лучше.

А в остальном я пока что недостатков не вижу, поэтому буду рад вашему экспертному комментарию в Telegram канале 👋