Собрать двоичный файл на golang — полдела. Настоящие проблемы начинаются в продакшене: сервис должен корректно стартовать и останавливаться, возвращать стабильно быстрые ответы, сообщать о своей готовности, переживать непрерывные релизы, логироваться предсказуемо и не «пугать» reverse‑proxy. В этом разборе — практичные рецепты вокруг systemd, graceful shutdown (обработка SIGTERM) и health‑проб (readiness/liveness), а также как подружить это с nginx и деплоем без простоев.
Процессная модель в Linux и systemd: что важно знать
systemd — стандартный менеджер служб в большинстве дистрибутивов Linux. Для golang‑сервисов он удобен тем, что умеет запускать бинарник как есть, следить за процессом, перезапускать при падении, ограничивать ресурсы и аккуратно завершать работу. Однако поведение по умолчанию не всегда безопасно для веб‑приложений — несколько параметров нужно задать явно.
Ключевые элементы unit‑файла:
- Type=simple или Type=notify — влияет на то, когда systemd считает сервис запущенным.
- ExecStart — команда запуска вашего бинарника.
- Restart=on-failure и RestartSec — политика перезапуска.
- KillSignal=SIGTERM, TimeoutStopSec и KillMode — как и когда посылать сигнал завершения и что делать по таймауту.
- EnvironmentFile и WorkingDirectory — окружение и директория приложения.
- LimitNOFILE, MemoryMax, TasksMax — эксплуатационные лимиты.
- StandardOutput=journal — куда писать логи.
Для веб‑сервиса на golang, который слушает TCP‑порт, чаще всего подходит Type=simple: процесс в переднем плане, без форкования. Если нужна «истинная» готовность на уровне менеджера, используйте Type=notify и отправляйте READY=1 из приложения. На собственном VDS вы контролируете лимиты и окружение сервиса без ограничений шаред‑хостинга.
Если вы администрируете сервер через панель, посмотрите сравнение панелей для VDS — это упрощает рутину, но базовые принципы systemd остаются теми же.
Готовим Go‑сервис к корректному завершению (graceful shutdown)
В мире golang graceful shutdown — это обработка сигналов (SIGTERM/SIGINT) с аккуратным завершением активных запросов и фоновых задач. Для HTTP это значит: перестать принимать новые соединения, дождаться обработки текущих, закрыть idle‑коннекты и ресурсы (подключения к БД, очередям, кешам).
SIGTERM — основной сигнал от systemd при остановке/перезапуске. Именно его надо перехватывать и завершать работу без резкого обрыва трафика.
Минимальный каркас для http.Server в Go выглядит так: создаём сервер с контекстными таймаутами, запускаем listen в отдельной горутине, ловим сигналы, вызываем Shutdown с дедлайном. Ниже пример, который можно адаптировать под свой стек (роутер, middleware, БД):
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func livenessHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func readinessHandler(w http.ResponseWriter, r *http.Request) {
// Здесь быстрые проверки готовности: пулы БД, кеш, внешние зависимости
// Не делайте дорогих операций и сетевых обращений без таймаутов
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ready"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", livenessHandler)
mux.HandleFunc("/readyz", readinessHandler)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Println("http: starting on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http: listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
log.Println("http: shutting down")
if err := srv.Shutdown(ctx); err != nil {
log.Printf("http: graceful shutdown error: %v", err)
}
log.Println("http: stopped")
}
Здесь важно:
- Использовать
Shutdown, а неClose. Первый ждёт завершения активных запросов. - Иметь реалистичный таймаут для завершения (например, 20–30 секунд). Он должен быть меньше
TimeoutStopSecв systemd. - Задать таймауты сервера:
ReadHeaderTimeout,WriteTimeout,IdleTimeout, чтобы не зависнуть на вечных клиентах.
Readiness и liveness: чем отличаются и как проверять
liveness отвечает на вопрос «жив ли процесс» — часто достаточно, чтобы процесс мог обработать примитивный запрос в оперативной памяти. readiness — «готов ли сервис принимать реальный трафик»; здесь учитываем внешние зависимости: соединение с БД, готовность миграций, доступность бекендов, заполненность кеша. Используйте раздельные эндпоинты, например /healthz и /readyz.
Практика показала:
/healthzдолжен быть максимально дешёвым и всегда быстрым./readyzможет включать короткие проверки с таймаутами и кэшировать результат на 100–500 мс, чтобы не бомбить БД.- Ошибки должны иметь явный код: 200 — готов, 503 — не готов.

Unit‑файл systemd для Go‑сервиса
Базовый unit для запуска HTTP‑сервиса на порту 8080:
[Unit]
Description=Go API service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/srv/myapp
ExecStart=/srv/myapp/myapp -config /srv/myapp/config.yaml
Restart=on-failure
RestartSec=3s
# Корректная остановка
KillSignal=SIGTERM
TimeoutStopSec=30s
KillMode=mixed
# Лимиты
LimitNOFILE=131072
TasksMax=4096
# Окружение и логи
EnvironmentFile=-/etc/default/myapp
Environment=GOMEMLIMIT=512MiB
StandardOutput=journal
StandardError=journal
# Снижение прав, если у вас есть отдельный пользователь
User=myapp
Group=myapp
[Install]
WantedBy=multi-user.target
Пояснения по критически важным строкам:
KillSignal=SIGTERM— именно этот сигнал ждёт ваше приложение (см. код выше).TimeoutStopSec=30s— timebox для graceful. Сделайте его чуть больше, чем таймаут вShutdown.KillMode=mixed— сначала сигнал основному процессу, затем всей группе; снижает риск «зависших» воркеров.Restart=on-failure— не перезапускайте на «нормальном выходе»; это полезно для ручных остановок и деплоя.LimitNOFILE— увеличьте лимит дескрипторов для высоконагруженных API.
Тип notify и готовность на уровне systemd
Если хотите, чтобы systemd сам знал момент готовности сервиса, используйте режим Type=notify. Тогда приложение должно отправить уведомление READY=1. Делать это стоит после того, как слушатель поднят и зависимые ресурсы инициализированы.
[Service]
Type=notify
NotifyAccess=main
WatchdogSec=0
ExecStart=/srv/myapp/myapp
В приложении можно отправить уведомление через стандартный сокет NOTIFY_SOCKET. Существуют готовые обёртки, но допустимо вызвать нотификацию простым UDP‑датаграммным сообщением. В любом случае сигнальте READY=1 только после успешной инициализации.
Интеграция с nginx: проксирование и поведение при неготовности
Для одиночного инстанса за nginx при старте/рестарте вы хотите минимизировать окна ошибок. В простом случае nginx будет проксировать на 127.0.0.1:8080. Readiness используйте, чтобы быстро отдавать 503 до готовности, либо, наоборот, разрешать трафик только когда сервис готов. Для HTTPS дополнительно подключите SSL-сертификаты и завершайте TLS на nginx.
upstream go_backend {
server 127.0.0.1:8080 max_fails=3 fail_timeout=10s;
keepalive 64;
}
server {
listen 80;
server_name example.local;
location = /healthz {
proxy_pass http://go_backend/healthz;
proxy_read_timeout 2s;
}
location = /readyz {
proxy_pass http://go_backend/readyz;
proxy_read_timeout 2s;
}
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering on;
proxy_connect_timeout 1s;
proxy_send_timeout 15s;
proxy_read_timeout 30s;
proxy_pass http://go_backend;
}
}

Если у вас два и более инстанса за nginx, можно организовать «мягкое» включение в баланс: пока /readyz отвечает 503, инстанс не получает боевой трафик (например, через дополнительный уровень оркестрации). В одиночной машине полезно в момент рестарта на стороне клиента видеть предсказуемые ошибки 503 на /readyz, а на боевых маршрутах по возможности не обрывать запросы за счёт корректного shutdown.
Порядок деплоя без простоя
Даже без контейнеров и Kubernetes можно добиться приличного «zero downtime» на одной машине:
- Разместите новый бинарник рядом с текущим, не перезаписывая файл, который сейчас запущен (например, добавляйте суффикс версии).
- Прогрейте всё, что можно, до переключения: загрузка конфигурации, подключение к БД.
- Переключите unit на новый бинарник (обновите путь в
ExecStartи сделайтеsystemctl daemon-reload). - Выполните
systemctl restart myapp. systemd пошлёт SIGTERM текущему процессу; ваш код закончит активные запросы в течениеTimeoutStopSec. - Проверяйте
/readyz— nginx начнёт проксировать только когда вернётся 200.
Полезные команды оператора:
sudo systemctl daemon-reload
sudo systemctl restart myapp
sudo systemctl status myapp --no-pager
sudo journalctl -u myapp -f
curl -sS http://127.0.0.1:8080/healthz
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/readyz
Подробный сценарий миграции без простоев разобран в статье миграция сайта без простоя.
Тонкая настройка остановки в systemd
Чтобы graceful shutdown сработал, соблюдайте несколько правил:
- Сигнал. Убедитесь, что systemd отправляет SIGTERM (по умолчанию именно так). Не переопределяйте
KillSignalв экзотику, если в коде её не ждёте. - Время.
TimeoutStopSecдолжен покрывать худший кейс завершения запросов. Если не успеете, systemd пришлёт SIGKILL и оборвёт соединения. - Группа процессов.
KillMode=mixedпомогает завершить дочерние процессы, не кинув KILL раньше времени в основной. - ExecStop обычно не нужен для Go‑сервиса; лучше дайте приложению погаснуть по SIGTERM.
Readiness‑гейт в самом приложении
Во время старта приложение может быть «живым», но не «готовым» (инициализируется пул БД, прогреваются данные). Часто удобно иметь внутренний флаг readiness и переключать его только после успешной инициализации. Эндпоинт /readyz тогда читает этот флаг и возвращает 503, пока тот не поднят.
type Health struct { ready atomic.Bool }
// При старте: health.ready.Store(false)
// После успешной инициализации: health.ready.Store(true)
func (h *Health) readinessHandler(w http.ResponseWriter, r *http.Request) {
if !h.ready.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("not ready"))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ready"))
}
Дополнительно проверяйте внешние зависимости с короткими контекстными таймаутами — лучше вернуть 503 за 50–100 мс, чем «залипнуть» на секунды.
Логи и наблюдаемость
По умолчанию StandardOutput=journal отправляет stdout/stderr в journald, откуда удобны просмотр и ротация. Рекомендации:
- Логируйте в стандартные потоки в одном формате (часто JSON‑строки одной строкой на событие).
- Указывайте request id, метод, путь, код ответа, длительность, размер ответа, клиентский IP (из
X-Forwarded-For), чтобы потом просто диагностировать инциденты. - Не дублируйте логи в файлы — журнал сам их сохранит и ротирует.
Watchdog systemd: когда нужен
Если сервис критичен и должен самосдерживающе «пиноваться», включите Watchdog: systemd перезапустит процесс, если не придёт пинг за заданный интервал. Для этого задайте WatchdogSec и из приложения периодически отправляйте WATCHDOG=1 через тот же механизм уведомлений, что и READY=1. Используйте осторожно, чтобы не скрыть реальные деградации.
Частые ошибки и как их избежать
- Отсутствие graceful shutdown. В результате nginx видит обрыв соединения, клиенты получают 499/502. Лечится обработкой SIGTERM и
Shutdown. - Долгие readiness‑проверки. Если
/readyzобращается к медленным внешним системам без таймаутов, это станет точкой отказа. Используйте короткие таймауты и кэширование результата. - Слишком короткий TimeoutStopSec. systemd убьёт процесс SIGKILL до завершения запросов. Подберите значение от нагрузки и SLAs.
- Неправильные таймауты сервера. Отсутствие
ReadHeaderTimeoutиWriteTimeoutоткрывает двери для медленных клиентов и DoS через медленные соединения. - Смешивание liveness и readiness. Если вы используете один эндпоинт для обоих, получите ложные перезапуски или пропуски трафика. Разделяйте обязанности.
Маленький чек‑лист перед продом
- Код ловит SIGTERM и делает
srv.Shutdown(ctx)с реалистичным таймаутом. - Есть
/healthz(всегда быстрый 200) и/readyz(200/503, быстрые проверки). - Unit systemd настроен:
Type=simpleилиType=notify,TimeoutStopSec,Restart=on-failure, лимиты. - nginx проксирует, имеет разумные timeouts и keepalive к upstream.
- Деплой заменяет бинарник атомарно и делает
systemctl restart, проверяя/readyz. - Логи идут в journald, формат согласован, есть request id.
Итоги
Правильная эксплуатация golang‑сервиса — это не только «быстрый бинарник», но и аккуратная интеграция с systemd, дисциплина сигналов, качественные readiness/liveness‑проверки, предсказуемая работа за nginx и понятный сценарий деплоя. С этими компонентами ваш сервис будет запускаться и останавливаться без сюрпризов, а клиенты — не замечать релизов.


