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

Для чего нужен Graceful Shutdown

Вы наверняка слышали такое словосочетание как stateless service. Это сервис, который не хранит никакого состояния внутри себя, а делегирует эту задачу внешнему хранилищу. Под состоянием чаще всего имеют ввиду данные. Стремление к тому, чтобы сервис был “stateless”, т.е. свободным от состояния, является хорошей архитектурной практикой, поскольку облегчает жизнь, когда дело доходит до масштабирования. Иногда это «правило» игнорируется для повышения производительности. Например, большинство современных СУБД хранит часть актуальных данных не на диске, а прямо в памяти процесса. Периодически и, самое главное, перед завершением процесса СУБД актуализирует эти данные на диске.

Если всё, что делает сервис это принимает a и b, и возвращает a+b, то никаких проблем нет, поскольку у сервиса в данном случае нет состояния. Скорее всего большая часть сервисов, которые вы разрабатываете или будете разрабатывать устроены несколько сложнее. Даже если они свободны от состояния в классическом понимании, но обращаются к СУБД, диску или другим сервисам, они всё ещё обладают состоянием, просто оно выражено не данными, а ресурсами. Почему ресурсы, которые мы запрашиваем у операционной системы необходимо освобождать — тема отдельного разговора. Сейчас давайте примем как аксиому, что за очисткой ресурсов важно следить.

Системные ресурсы можно разделить на два вида:

  1. С коротким жизненным циклом — например, чтение конфигурационного файла или соединение для REST-запроса. Обычно освобождаются сразу после использования.
  2. С неопределённым жизненным циклом — вебсокеты, стриминг, консьюминг из очереди, кэш на диске и т.д. Информация, которую они предоставляют, требуют быстрого доступа, поэтому такие ресурсы занимаются редко, но надолго.

Второй тип ресурсов я назвал так именно потому, что скорее всего мы не знаем, когда нам придётся их очистить. Интуиция подсказывает, что это имеет смысл делать перед тем как программа завершится. Но когда она завершится тоже неизвестно, строго говоря. Поэтому нам требуется механизм, который позволил бы понять, что операционная система по какой-то причине намерена завершить процесс, чтобы программа могла очистить ресурсы. Таким механизмом как раз и является Graceful Shutdown или изящное завершение программы по-русски.

Как устроен механизм Graceful Shutdown

Операционная система общается с запущенными в ней процессами с помощью сигналов. Часть из этих сигналов отвечает за завершение программы. Круто то, что эти сигналы можно «перехватить» в коде. После перехвата можно их проигнорировать (так делать не нужно) или очистить ресурсы, занятые программой, а затем явно завершить программу.

Давайте перейдем от слов к делу и взглянем на код:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os/signal"
	"syscall"
	"time"
)

const (
	listenAddr      = "127.0.0.1:8080"
	shutdownTimeout = 5 * time.Second
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	if err := runServer(ctx); err != nil {
		log.Fatal(err)
	}
}

func runServer(ctx context.Context) error {
	var (
		mux = http.NewServeMux()
		srv = &http.Server{
			Addr:    listenAddr,
			Handler: mux,
		}
	)

	mux.Handle("/", handleIndex())

	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen and serve: %v", err)
		}
	}()

	log.Printf("listening on %s", listenAddr)
	<-ctx.Done()

	log.Println("shutting down server gracefully")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("shutdown: %w", err)
	}

	longShutdown := make(chan struct{}, 1)

	go func() {
		time.Sleep(3 * time.Second)
		longShutdown <- struct{}{}
	}()

	select {
	case <-shutdownCtx.Done():
		return fmt.Errorf("server shutdown: %w", ctx.Err())
	case <-longShutdown:
		log.Println("finished")
	}

	return nil
}

func handleIndex() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("Hello, World!"))
	})
}

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

«Паттерн» Closer

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

Можно делегировать машинерию по доставке сигнала на завершение в разные части приложения с помощью специального компонента. Назовём его Closer. Пусть каждый компонент, заинтересованный в том, чтобы ему сообщили, когда необходимо очистить ресурсы и прекратить работу, сообщит об этом Closer, зарегистрировав у него обработчик:

1
2
3
4
5
6
7
closer.Add(func(ctx context.Context) error {
	if err := srv.Shutdown(ctx); err != nil {
		return fmt.Errorf("shutdown: %w", err)
	}

	return nil
})

В свою очередь Closer будет ловить сигналы от операционной системы и следить за тем, чтобы завершение программы не вышло за таймаут.

Давайте переработаем программу, реализовав трюк с Closer. Для начала создадим папку closer и положим туда файл closer.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package closer

import (
	"context"
	"fmt"
	"strings"
	"sync"
)

type Closer struct {
	mu    sync.Mutex
	funcs []Func
}

func (c *Closer) Add(f Func) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.funcs = append(c.funcs, f)
}

func (c *Closer) Close(ctx context.Context) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	var (
		msgs     = make([]string, 0, len(c.funcs))
		complete = make(chan struct{}, 1)
	)

	go func() {
		for _, f := range c.funcs {
			if err := f(ctx); err != nil {
				msgs = append(msgs, fmt.Sprintf("[!] %v", err))
			}
		}

		complete <- struct{}{}
	}()

	select {
	case <-complete:
		break
	case <-ctx.Done():
		return fmt.Errorf("shutdown cancelled: %v", ctx.Err())
	}

	if len(msgs) > 0 {
		return fmt.Errorf(
			"shutdown finished with error(s): \n%s",
			strings.Join(msgs, "\n"),
		)
	}

	return nil
}

type Func func(ctx context.Context) error

Во-первых, нам нужен метод, регистрирующий обработчики на завершение:

1
2
3
4
5
6
func (c *Closer) Add(f Func) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.funcs = append(c.funcs, f)
}

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

Внутри метода Close по порядку вызываются обработчики. Для того чтобы завершить остановку программы по таймауту, цикл вызовов работает в отдельной горутине, а в «текущей» мы ждём одно из двух событий:

  1. Все обработчики уложатся в таймаут, и тогда в канал complete произойдёт запись;
  2. Наступит дедлайн, и придётся выйти из Close принудительно с ошибкой.

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

1
2
3
4
5
6
7
8
if len(msgs) > 0 {
		return fmt.Errorf(
			"shutdown finished with error(s): \n%s",
			strings.Join(msgs, "\n"),
		)
	}

return nil

Файл main.go теперь будет выглядеть вот так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package main

import (
	"context"
	"fmt"
	"graceful-shutdown/closer"
	"log"
	"net/http"
	"os/signal"
	"syscall"
	"time"
)

const (
	listenAddr      = "127.0.0.1:8080"
	shutdownTimeout = 5 * time.Second
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	if err := runServer(ctx); err != nil {
		log.Fatal(err)
	}
}

func runServer(ctx context.Context) error {
	var (
		mux = http.NewServeMux()
		srv = &http.Server{
			Addr:    listenAddr,
			Handler: mux,
		}
		c = &closer.Closer{}
	)

	mux.Handle("/", handleIndex())

	c.Add(srv.Shutdown)

	c.Add(func(ctx context.Context) error {
		time.Sleep(3 * time.Second)

		return nil
	})

	// c.Add(func(ctx context.Context) error {
	// 	return errors.New("oops error occurred")
	// })

	// c.Add(func(ctx context.Context) error {
	// 	return errors.New("uh-oh, another error occurred")
	// })

	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen and serve: %v", err)
		}
	}()

	log.Printf("listening on %s", listenAddr)
	<-ctx.Done()

	log.Println("shutting down server gracefully")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()

	if err := c.Close(shutdownCtx); err != nil {
		return fmt.Errorf("closer: %v", err)
	}

	return nil
}

func handleIndex() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("Hello, World!"))
	})
}

Если немного поиграться с кодом в main.go, а именно снова увеличить время сна на строке 43 или раскомментировать код на строках 48-54, можно увидеть, как Closer реагирует на различные ситуации при завершении программы.

Если обернуть Closer в синглтон (да, иногда так делать можно), то он не будет привязан к какому-то конкретному компоненту приложения, и тогда каждый компонент при своей инициализации сможет самостоятельно зарегистрировать обработчик на завершение программы.

Для простоты я привёл именно такую реализацию «доводчика» программы, и она обладает недостатком. Заключается он в том, что если один из обработчиков зависнет на время, достаточное для выхода за таймаут, Closer не вызовет все последующие, т.к. при выходе за таймаут, процедура завершения программы мгновенно останавливается.

Предлагаю вам подумать, как устранить данный недостаток, используя возможности Go для работы с конкурентным кодом.

Полезные ссылки