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

Комментарии (6)


  1. F0rzend
    15.12.2025 10:29

    Рефлексия отталкивает, конечно… неужели нельзя было сделать без неё?..


    1. Ognick Автор
      15.12.2025 10:29

      Рефлексия здесь - это трудный компромисс) Она используется только на этапе сборки графа на старте, чтобы автоматически определить зависимости между компонентами и убрать ручное связывание.


  1. vkomp
    15.12.2025 10:29

    Всё не настолько плохо, чтобы тащить новую зависимость. Мне не зашел "uber/fx". И за несколько дней разобрался в теме - задача красиво остановить сильно сложнее, чем запустить. Пучок каналов, несколько контекстов, waitgroups для внешних сервисов - и готово. Решение на пару сотен строк, разбросанных по модулям.
    Я за отсутствие магии в разработке, если что.


    1. Ognick Автор
      15.12.2025 10:29

      Я как раз и отталкивался от того, что «красиво остановить сильно сложнее, чем запустить», и что в реальных проектах это обычно вырастает в очень похожий код, размазанный по модулям.

      Такой код часто не самый простой в отладке и сопровождении, поэтому хотелось собрать lifecycle в одном выделенном месте. Кроме того, при ручном управлении обычно приходится явно прокидывать зависимости между реализациями, что со временем увеличивает связанность.

      Но если в конкретном проекте это удобно держать вручную - это действительно лучше, чем тянуть ещё одну библиотеку.


  1. gohrytt
    15.12.2025 10:29

    Ух ты, мы с вами буквально одну и ту же проблему решили, только я нигде не публиковал кроме корп репозитория. Чуть проще:

    package graceful
    
    import (
    	"context"
    	"time"
    
    	"go.uber.org/multierr"
    )
    
    type (
    	GroupConfiguration struct {
    		Timeout time.Duration `name:"TIMEOUT" default:"30s"`
    	}
    
    	TimeoutOption time.Duration
    
    	Group struct {
    		configuration *GroupConfiguration
    		ctx           context.Context
    		cancel        context.CancelFunc
    		errors        chan error
    		closers       []closer
    		wait          uint64
    	}
    
    	Resource[T_resource any] struct {
    		group    *Group
    		resource *T_resource
    	}
    
    	Task[T_resource any] func(context context.Context, resource *T_resource) error
    
    	closer struct {
    		resource any
    		task     any
    		wrapper  func(ctx context.Context, resource, task any, errors chan error)
    	}
    )
    
    func New(ctx context.Context, configuration *GroupConfiguration) *Group {
    	ctx, cancel := context.WithCancel(ctx)
    
    	return &Group{
    		configuration: configuration,
    		ctx:           ctx,
    		cancel:        cancel,
    		errors:        make(chan error, 1),
    	}
    }
    
    func WithOptions(ctx context.Context, options ...any) *Group {
    	configuration := &GroupConfiguration{
    		Timeout: 30 * time.Second,
    	}
    
    	for i := range options {
    		switch typed := options[i].(type) {
    		case TimeoutOption:
    			configuration.Timeout = time.Duration(typed)
    		}
    	}
    
    	ctx, cancel := context.WithCancel(ctx)
    
    	return &Group{
    		configuration: configuration,
    		ctx:           ctx,
    		cancel:        cancel,
    		errors:        make(chan error, 1),
    	}
    }
    
    func (group *Group) Context() context.Context {
    	return group.ctx
    }
    
    func (group *Group) Wait() error {
    	<-group.ctx.Done()
    
    	ctx, cancel := context.Background(), context.CancelFunc(nil)
    
    	if group.configuration.Timeout > 0 {
    		ctx, cancel = context.WithTimeout(ctx, group.configuration.Timeout)
    	}
    
    	for _, closer := range group.closers {
    		go closer.wrapper(ctx, closer.resource, closer.task, group.errors)
    	}
    
    	errors := []error(nil)
    
    cycle:
    	for group.wait > 0 {
    		select {
    		case <-ctx.Done():
    			break cycle
    		case err := <-group.errors:
    			if err != nil {
    				errors = append(errors, err)
    			}
    		}
    
    		group.wait -= 1
    	}
    
    	if cancel != nil {
    		cancel()
    	}
    
    	return multierr.Combine(errors...)
    }
    
    func AddResource[T_resource any](group *Group, resource *T_resource) Resource[T_resource] {
    	return Resource[T_resource]{
    		group:    group,
    		resource: resource,
    	}
    }
    
    func (resource Resource[T_resource]) OnStart(task Task[T_resource]) Resource[T_resource] {
    	resource.group.wait += 1
    
    	go func(resource Resource[T_resource], task Task[T_resource]) {
    		err := task(resource.group.ctx, resource.resource)
    
    		resource.group.cancel()
    		resource.group.errors <- err
    	}(resource, task)
    
    	return resource
    }
    
    func (resource Resource[T_resource]) OnClose(task Task[T_resource]) Resource[T_resource] {
    	resource.group.wait += 1
    
    	wrapper := func(ctx context.Context, resource, task any, errors chan error) {
    		errors <- task.(Task[T_resource])(ctx, resource.(*T_resource))
    	}
    
    	resource.group.closers = append(resource.group.closers, closer{
    		resource: resource.resource,
    		task:     task,
    		wrapper:  wrapper,
    	})
    
    	return resource
    }
    
    package notifier
    
    import (
    	"context"
    	"errors"
    	"fmt"
    	"os"
    	"os/signal"
    )
    
    type (
    	Notifier struct {
    		signals  []os.Signal
    		receiver chan os.Signal
    	}
    )
    
    var (
    	ErrSignalReceived = errors.New("signal received")
    )
    
    func New(signals []os.Signal) *Notifier {
    	return &Notifier{
    		signals:  signals,
    		receiver: make(chan os.Signal, 1),
    	}
    }
    
    func OnStart(ctx context.Context, notifier *Notifier) (err error) {
    	signal.Notify(notifier.receiver, notifier.signals...)
    
    	select {
    	case <-ctx.Done():
    		return nil
    	case signal := <-notifier.receiver:
    		return fmt.Errorf("%w: %s", ErrSignalReceived, signal.String())
    	}
    }
    
    func OnClose(ctx context.Context, notifier *Notifier) (err error) {
    	signal.Stop(notifier.receiver)
    	return nil
    }
    

    Очень сильно упростило жизнь менее опытных коллег и меня когда за этими менее опытными коллегами надо переделать graceful shutdown.


    1. vkomp
      15.12.2025 10:29

      Не очень вчитывался, но у меня вроде посложнее:
      - на внешний сигнал стопа закрываем прием новых запросов (у меня http-сервер), доделываем имеющиеся запросы, сервер по завершении обработки завершается и кидает ок в канал
      - по сигналу закрываются соединения с ресурсами, чтобы не потерять данные,
      - и потом выход из main (или жесткий шатдаун по истечении второго времени).
      И потому есть контекст на управление сервером, есть контекст запросов внутри сервера, и есть контекст на приложуху с ресурсами - данные в одну сторону. И каналы для ответов. Потому долго ковырялся.