golang

Чистим main.go: предсказуемый старт и надежный Graceful Shutdown

  • вторник, 16 декабря 2025 г. в 00:00:09
https://habr.com/ru/articles/976800/

Сталкивались ли вы с болью при управлении порядком запуска и остановки зависимостей в вашем Go-сервисе?

Разработка больших сервисов неизбежно приводит к необходимости управлять множеством зависимостей. В этом контексте мы говорим о долгоживущих компонентах, чья работа обеспечивается отдельными горутинами: как правило, это блокирующий метод (например, Start), внутри которого крутится цикл обработки.

Примерный сценарий жизненного цикла сервиса выглядит так:

При запуске критически важно, чтобы пул соединений с БД, кэш и очереди были полностью готовы до того, как HTTP-сервер откроет порт и начнет принимать входящий трафик.

С graceful shutdown ситуация обратная: порядок должен быть строго зеркальным. Сначала нужно перестать принимать новые запросы, дождаться завершения текущих, остановить воркеры, и только потом разрывать соединения с инфраструктурой. Иначе мы получаем неприятные ошибки подключения и даже потерянные транзакции в момент деплоя.

Если эти проблемы вам не знакомы, смело закрывайте вкладку. Скорее всего, эта статья не принесет вам пользы.

Но если вы ищете способ автоматизировать эту рутину, сохранив код чистым - добро пожаловать под кат.

Как это обычно решается

Чаще всего разработчики идут по пути ручного связывания. Вы пишете код инициализации прямо в main, используя errgroup и много defer. Это решение отлично работает на малых масштабах, но с ростом проекта становится хрупким: стоит поменять местами два defer (например, закрытие базы и остановку воркера), как ваш graceful shutdown становится не таким и изящным.

Второй путь - DI-фреймворки (например, Uber Fx). Они действительно управляют зависимостями за вас. Но мало того, что такие инструменты, как правило, обладают весьма спорным синтаксисом, так они еще и «пролезают» по всему коду, делая его зависимым от конкретной библиотеки.

Третий путь: GOscade

Как сохранить явное связывание зависимостей, но полностью избавиться от ручного управления порядком старта и остановки? Идея проста: оставить инициализацию явной, но полностью автоматизировать порядок выполнения.

Небольшое уточнение перед примерами. Визуализация, которую вы увидите ниже - это отдельный проект, который лежит в репозитории библиотеки. Он отображает работу на синтетическом наборе компонентов и позволяет интерактивно посмотреть, как goscade управляет запуском, остановкой и различными сценариями. Этот инструмент удобен для понимания и экспериментов, но не является частью ядра библиотеки и никак не влияет на её работу в ваших приложениях.

Штатный режим
Штатный режим

Вы инициализируете структуры и передаете в них зависимости, как обычно. Никаких специальных реестров или сложной конфигурации.

Библиотека берет эти структуры и автоматически строит граф зависимостей, анализируя их поля через рефлексию.

Компоненты запускаются строго в топологическом порядке (от независимых к зависимым).

При остановке гарантируется корректный обратный порядок.

В итоге ваш доменный код остается чистым и мало что знает о библиотеке, а main.go избавляется от хрупкой логики управления горутинами.

Важно уточнить: рефлексия используется ровно один раз - на этапе инициализации приложения для построения графа. На производительность работающего сервиса это никак не влияет.

Лучше один раз увидеть код, чем сто раз услышать про графы

package main

import (
	"context"
	"log"

	"github.com/ognick/goscade/v2"
)

// 1. Database - независимый компонент
type Database struct{}

func (d *Database) Run(ctx context.Context, ready func(error)) error {	
	// Здесь происходит реальное подключение к БД
	ready(nil) // Сигнал: "Готов, можно запускать зависимые компоненты"
	<-ctx.Done() // Ждем до сигнала остановки
	// Закрытие соединений...
	return nil
}

// 2. Server - зависит от Database
type Server struct {
	DB *Database // <--- Goscade увидит эту связь через рефлексию
}

func (s *Server) Run(ctx context.Context, ready func(error)) error {
	// Этот код выполнится ТОЛЬКО после того, как Database вызовет ready(nil)
	ready(nil) // Сигнал: "Сервер принимает трафик"
	<-ctx.Done() // Работаем до сигнала остановки
	// Остановка...
	return nil
}

func main() {
	// Инициализируем Lifecycle менеджер
	lc := goscade.NewLifecycle(log.Default(), goscade.WithShutdownHook())

	// Создаем компоненты
	db := &Database{}
	server := &Server{DB: db} // Явно передаем зависимость

	// Регистрируем (порядок не важен)
	goscade.Register(lc, server)
	goscade.Register(lc, db)

	// Запускаем
	goscade.Run(context.Background(), lc, func() {
		log.Println("Все компоненты запущены!")
	})
}

Как вы видите, единственное, что навязывает требуется от зависимостей - имплементировать метод Run:

Run(ctx context.Context, readinessProbe func(error)) error
Сбой инициализации
Сбой инициализации

Вы вызываете readinessProbe(nil), когда компонент полностью готов к работе.

Если же на этапе старта произошла ошибка - передаете её в коллбек, и библиотека корректно прервет запуск всей цепочки.

Аварийная остановка
Аварийная остановка

Библиотека продолжает следить за компонентами и после успешного старта.

Если в процессе работы метод Run любого из них завершится с ошибкой, весь граф будет остановлен в правильной обратной последовательности

Кроме того, библиотека берет на себя рутину, которую обычно приходится копипастить из проекта в проект:

  • Перехват сигналов ОС: Опция WithShutdownHook() автоматически подписывается на SIGINT и SIGTERM. Вам не нужно создавать каналы для os.Signal вручную.

  • Таймауты на старт и стоп: Вы можете ограничить время на инициализацию или завершение приложения, чтобы зависший драйвер не вешал деплой бесконечно.

  • Управление через контекст: Вся работа построена на стандартном context.Context. Если вы отмените родительский контекст, вся цепочка сервисов корректно остановится.

Забирайте в свои проекты, экспериментируйте. Если найдете баги или придумаете, как сделать лучше - велкам в Issues и PR 👉 github.com/ognick/goscade