Как не потерять клиентские запросы и не пятисотить при рестарте сервиса на Go?
Покажу простую технику, которая применима при использовании systemd (не подойдёт для приложений в контейнере).
Существует подход "Socket activation" – это когда вы делегируете прослушивание сокета на systemd. Тогда, даже если ваше приложение не запущено, нужный сокет/порт уже прослушивается, просто не обрабатывается (не принимается), и соединения копятся в очереди. Затем, вместо запуска слушателя, ваше приложение начинает обрабатывать соединения прямо из этого "активированного" сокета.
Как это выглядит на практике?
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/coreos/go-systemd/v22/activation"
"github.com/coreos/go-systemd/v22/daemon"
)
func main() {
pid := os.Getpid()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ok pid=%d\n", pid)
})
srv := &http.Server{Handler: mux}
// запрашиваем сокет у systemd
ls, err := activation.Listeners()
if err != nil {
log.Fatalf("activation.Listeners: %v", err)
}
if len(ls) == 0 {
log.Fatal("no socket activation listener found")
}
// у нас в примере один сокет, поэтому берем первый
ln := ls[0]
// запускаем сервер в отдельной горутине
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serve: %v", err)
}
}()
_, _ = daemon.SdNotify(false, "READY=1")
log.Printf("started pid=%d", pid)
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
<-sigc
log.Print("signal received: graceful shutdown")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// завершаем сервер
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
}
// имитация какой-то работы перед завершением
time.Sleep(2 * time.Second)
}
Не забудь поставить пакет:go get -u github.com/coreos/go-systemd/v22
И виновник торжества! myapp.socket
выглядит так:
[Unit]
Description=MyApp socket
[Socket]
ListenStream=127.0.0.1:8080
[Install]
WantedBy=sockets.target
И myapp.service
:
[Unit]
Description=MyApp service
Requires=myapp.socket
After=myapp.socket
[Service]
Type=notify
ExecStart=/usr/local/bin/myapp
[Install]
WantedBy=multi-user.target
Тестирование
Скопируйте бинарник в /usr/local/bin/myapp
, сделайте исполняемым, и не забудьте скопировать systemd файлы в /etc/systemd/system/
. Затем запускаете сервис через systemd start myapp
.
А затем можете в соседней вкладке терминала запустить такое:
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
Оно будет раз в 200 мсек отправлять запрос на порт 8080
и писать время ответа.
Теперь если порестартить сервис через systemd restart myapp
и посмотреть на вывод:
2025-09-24T04:37:00+0400 ok pid=8715
| time=0.000296s
2025-09-24T04:37:00+0400 ok pid=8715
| time=0.000254s
2025-09-24T04:37:01+0400 ok pid=8914
| time=1.894271s
2025-09-24T04:37:03+0400 ok pid=8914
| time=0.000416s
То вы увидите, что PID сменился, а значит рестарт случился, но запрос не отвалился, пришлось ли подождать те самые 2 секунды, которые мы заложили под рестарт.
Размер очереди
Обратите обязательно внимание на настройку Backlog
в .socket
файле: она позволяет вам увеличить очередь, если вдруг запросов в вашем случае очень много, и на все нужно ответить. Полный список опций можете посмотреть тут https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html.
Минусы подхода и когда такой способ не подойдёт
Как вы заметили, в момент рестарта новые соединения хоть и не сбрасываются, но всё равно ждут, когда их начнёт обслуживать новый инстанс. Это терпимо, когда ваш сервис стартует быстро, но если ему нужно сходить в базу, прогреть кеш и что-то другое длительное, то есть другая техника: передача сокета новому процессу тогда, когда новый процесс готов обрабатывать соединения. Своего рода reload
.