Продвинутый zero-downtime рестарт сервиса на Go
Простой 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 канале 👋