Покажу простую технику, которая применима при использовании 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.