Выберите продукт

Go‑приложение в проде: systemd, graceful shutdown и readiness‑пробы

Как довести Go‑API до продакшена без сюрпризов: корректный unit в systemd, обработка SIGTERM, закрытие соединений, readiness и liveness‑пробы, проксирование через Nginx и аккуратный деплой без простоя. Плюс частые ошибки и практические советы.
Go‑приложение в проде: systemd, graceful shutdown и readiness‑пробы

Собрать двоичный файл на 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 и параметры для graceful shutdown

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 только после успешной инициализации.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Интеграция с 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 к Go‑бэкенду с эндпоинтами healthz/readyz

Если у вас два и более инстанса за nginx, можно организовать «мягкое» включение в баланс: пока /readyz отвечает 503, инстанс не получает боевой трафик (например, через дополнительный уровень оркестрации). В одиночной машине полезно в момент рестарта на стороне клиента видеть предсказуемые ошибки 503 на /readyz, а на боевых маршрутах по возможности не обрывать запросы за счёт корректного shutdown.

Порядок деплоя без простоя

Даже без контейнеров и Kubernetes можно добиться приличного «zero downtime» на одной машине:

  1. Разместите новый бинарник рядом с текущим, не перезаписывая файл, который сейчас запущен (например, добавляйте суффикс версии).
  2. Прогрейте всё, что можно, до переключения: загрузка конфигурации, подключение к БД.
  3. Переключите unit на новый бинарник (обновите путь в ExecStart и сделайте systemctl daemon-reload).
  4. Выполните systemctl restart myapp. systemd пошлёт SIGTERM текущему процессу; ваш код закончит активные запросы в течение TimeoutStopSec.
  5. Проверяйте /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

Подробный сценарий миграции без простоев разобран в статье миграция сайта без простоя.

FastFox SSL
Надежные SSL-сертификаты
Мы предлагаем широкий спектр SSL-сертификатов от GlobalSign по самым низким ценам. Поможем с покупкой и установкой SSL бесплатно!

Тонкая настройка остановки в 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 и понятный сценарий деплоя. С этими компонентами ваш сервис будет запускаться и останавливаться без сюрпризов, а клиенты — не замечать релизов.

Поделиться статьей

Вам будет интересно

Debian/Ubuntu: APT repository does not have a Release file — как исправить ошибку OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: APT repository does not have a Release file — как исправить ошибку

Ошибка APT repository does not have a Release file в Debian и Ubuntu обычно связана с неподдерживаемым репозиторием, неверным code ...
Debian/Ubuntu: SSH зависает на Connecting to — как найти и убрать задержку входа OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: SSH зависает на Connecting to — как найти и убрать задержку входа

Если SSH в Debian или Ubuntu зависает на этапе Connecting to, долго показывает banner или тормозит уже после ввода пароля, причина ...
Debian/Ubuntu: конфликт systemd-resolved DNSStubListener на 127.0.0.53 с dnsmasq, Unbound и BIND OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: конфликт systemd-resolved DNSStubListener на 127.0.0.53 с dnsmasq, Unbound и BIND

Если локальный DNS в Debian или Ubuntu не стартует с ошибкой address already in use, причина часто в systemd-resolved и DNSStubLis ...