Shanraq.org Shanraq.org
Служба, которая переживёт перезагрузку: systemd и аккуратная остановка
IT

Go: с нуля до своего блога Урок 48 из 50

Служба, которая переживёт перезагрузку: systemd и аккуратная остановка

Сорок восьмой урок курса по Go. Программу останавливают не кнопкой, а сигналом, и разница измерена: при обрыве читатель получает EOF, при аккуратной остановке — ответ 200 через 270 мс. Плюс файл службы, который проверил настоящий systemd, автозапуск после перезагрузки.

Зачем это нужно

Блог запущен из терминала. Закрыли крышку ноутбука — блога нет. Перезагрузили сервер — блога нет. Обновили версию — тот, кто в этот момент читал статью, получил обрыв соединения вместо страницы.

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

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

Про вторую половину сразу: systemd есть только в Linux. Если у вас macOS или Windows, аккуратная остановка работает точно так же — это код на Go, а не системная настройка, — а файл службы проверяйте на сервере, куда выложили блог. Ту же работу в macOS делает launchd, в Windows — службы; файл другой, смысл тот же.

Сразу целиком

Новая папка, go mod init sabaq45. Программа поднимает сервер, отправляет читателя за страницей и на середине ответа останавливает сервер — четырьмя разными способами:

package main

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

// Читатель, который уже нажал на ссылку: ответ готовится треть секунды.
const answerTime = 300 * time.Millisecond

func main() {
	fmt.Println("== обрыв: сервер закрыли на полуслове")
	report(stop(func(srv *http.Server) error { return srv.Close() }))

	fmt.Println()
	fmt.Println("== аккуратно: Shutdown дождался ответа")
	report(stop(func(srv *http.Server) error {
		return srv.Shutdown(context.Background())
	}))

	fmt.Println()
	fmt.Println("== времени дали меньше, чем нужно ответу")
	report(stop(func(srv *http.Server) error {
		ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
		defer cancel()
		err := srv.Shutdown(ctx)
		if err != nil {
			// Так поступают в жизни: ждать больше нечем, соединения рвём.
			srv.Close()
		}
		return err
	}))

	fmt.Println()
	fmt.Println("== сигнал, который посылает systemd")
	signalDemo()
}

// result — что получилось у читателя и что при этом делал сервер.
type result struct {
	reader   string
	waited   time.Duration
	shutdown error
}

// stop поднимает сервер, отправляет читателя за страницей и на середине
// ответа останавливает сервер тем способом, который передали.
func stop(how func(*http.Server) error) result {
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	srv := &http.Server{Handler: http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(answerTime)
			fmt.Fprint(w, "готово")
		})}
	go srv.Serve(l)

	done := make(chan string, 1)
	go func() {
		resp, err := http.Get("http://" + l.Addr().String() + "/")
		if err != nil {
			done <- "обрыв: " + shortErr(err)
			return
		}
		defer resp.Body.Close()
		done <- fmt.Sprintf("ответ %d", resp.StatusCode)
	}()

	// Дать читателю дойти до обработчика: останавливать надо посреди ответа,
	// иначе мы измерим не то.
	time.Sleep(50 * time.Millisecond)

	start := time.Now()
	shutdownErr := how(srv)
	waited := time.Since(start)

	return result{reader: <-done, waited: waited.Round(10 * time.Millisecond), shutdown: shutdownErr}
}

func report(r result) {
	fmt.Printf("читатель получил:   %s\n", r.reader)
	fmt.Printf("остановка заняла:   %v\n", r.waited)
	if r.shutdown != nil {
		fmt.Printf("остановка вернула:  %v\n", r.shutdown)
		return
	}
	fmt.Println("остановка вернула:  nil")
}

// signalDemo показывает, как программа узнаёт о просьбе завершиться. systemd
// посылает SIGTERM и ждёт; здесь мы посылаем его сами себе.
func signalDemo() {
	ctx, stopSignals := signal.NotifyContext(context.Background(),
		syscall.SIGTERM, os.Interrupt)
	defer stopSignals()

	start := time.Now()
	if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil {
		fmt.Println("не удалось послать сигнал:", err)
		return
	}

	<-ctx.Done()
	fmt.Printf("сигнал дошёл:      %s\n", instant(time.Since(start)))
	fmt.Printf("контекст говорит:  %v\n", ctx.Err())
	fmt.Println("дальше:            Shutdown, потом закрыть базу")
}

// shortErr убирает из ошибки адрес со случайным портом, чтобы вывод был
// одинаковым при каждом запуске.
func shortErr(err error) string {
	var ue *net.OpError
	if errors.As(err, &ue) {
		return ue.Err.Error()
	}
	if e, ok := err.(interface{ Unwrap() error }); ok {
		return shortErr(e.Unwrap())
	}
	return err.Error()
}

// instant прячет точное число: важно, что сигнал доходит быстрее, чем
// программа успевает что-либо сделать, а не сколько именно микросекунд.
func instant(d time.Duration) string {
	if d < time.Millisecond {
		return "мгновенно, меньше миллисекунды"
	}
	return d.Round(time.Millisecond).String()
}

Вывод:

== обрыв: сервер закрыли на полуслове
читатель получил:   обрыв: EOF
остановка заняла:   0s
остановка вернула:  nil

== аккуратно: Shutdown дождался ответа
читатель получил:   ответ 200
остановка заняла:   270ms
остановка вернула:  nil

== времени дали меньше, чем нужно ответу
читатель получил:   обрыв: EOF
остановка заняла:   50ms
остановка вернула:  context deadline exceeded

== сигнал, который посылает systemd
сигнал дошёл:      мгновенно, меньше миллисекунды
контекст говорит:  context canceled
дальше:            Shutdown, потом закрыть базу

Разбор

Обрыв и ответ — разница в одной строке

Первые два блока отличаются одним вызовом. srv.Close() закрывает всё немедленно: остановка заняла 0s, и читатель вместо страницы получил EOF — соединение оборвалось на полуслове.

srv.Shutdown(ctx) делает другое: перестаёт принимать новые запросы, дожидается уже начатых и возвращается. Остановка заняла 270 мс — ровно столько, сколько наш обработчик готовил ответ, — и читатель получил 200.

Разница в жизни выглядит так: при обновлении сайта половина читателей видит «страница недоступна» — или не видит ничего необычного.

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

Времени должно хватать

Третий блок — та же аккуратная остановка, но ей дали 50 мс на ответ, которому нужно 300. Shutdown вернул context deadline exceeded, и дальше в программе не остаётся ничего, кроме как оборвать соединения: читатель снова получил EOF.

Отсюда правило: срок остановки должен быть больше самого долгого ответа. У блога это загрузка картинки, а не страница со статьёй. В шаге 33 стоит 15 секунд.

Сигнал — это то, как с программой разговаривает система

Четвёртый блок отвечает на вопрос «а откуда программа вообще узнаёт, что пора?». Ей посылают сигнал — короткое сообщение от системы. SIGTERM означает «заверши работу»; именно его посылает systemd, docker и нажатие «стоп» в панели хостинга. Ctrl+C в терминале — это SIGINT, тот же смысл.

В Go это одна строка:

ctx, stopSignals := signal.NotifyContext(context.Background(),
	syscall.SIGTERM, os.Interrupt)
defer stopSignals()

NotifyContext превращает сигнал в отменённый контекст — тот самый инструмент из урока про context. Дальше программа просто ждёт <-ctx.Done(), и никаких особых знаний о сигналах ей не нужно. Измерено: сигнал доходит быстрее, чем за миллисекунду.

Есть сигнал, которого не перехватить: SIGKILL (kill -9). Он не доходит до программы — процесс убивает ядро. Поэтому и говорят, что kill -9 — не способ остановки, а последнее средство.

Порядок важнее кода

Собранное вместе выглядит так, и порядок здесь не украшение:

  1. пришёл сигнал — контекст закрылся;
  2. srv.Shutdown(ctx) — новых не берём, начатых дожидаемся;
  3. и только теперь store.Close() — база закрывается последней.

Закрыть базу первой — значит своими руками сломать те самые ответы, которые мы дожидаемся. Ошибка распространённая и с виду безобидная: defer store.Close() стоит в начале main, и кажется, что этого достаточно. Достаточно — но только потому, что defer сработает после Shutdown, а не до.

Файл службы

Теперь первая половина задачи. systemd — то, что в Linux запускает и следит за программами. Ему нужен один файл; в шаге 33 он лежит целиком, а главное в нём — вот эти строки:

[Service]
User=blog
ExecStart=/opt/blog/blog
Environment=BLOG_ADDR=127.0.0.1:8080
Restart=always
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target

Restart=always — упал, подняли; RestartSec=2 — не чаще раза в две секунды, иначе сломанная программа будет перезапускаться тысячу раз в минуту. KillSignal=SIGTERM — то, что мы только что научились принимать. TimeoutStopSec=20 — сколько systemd ждёт, прежде чем убить процесс совсем; это число должно быть больше нашего срока остановки, иначе аккуратная остановка не успеет и превратится в обрыв.

WantedBy=multi-user.target — это и есть «запускать при загрузке машины».

Файл из шага проверен настоящим systemd на настоящем сервере:

$ systemd-analyze verify blog.service
blog.service: Command /opt/blog/blog is not executable: No such file or directory

Единственное замечание — что самого файла программы на той машине нет; разметка службы возражений не вызвала.

Три команды, которые нужно знать:

systemctl enable --now blog    # запустить и включить автозапуск
systemctl restart blog         # перезапустить после обновления
journalctl -u blog -f          # смотреть журнал вживую

Журнал вы уже пишете — с урока про ошибки и slog. systemd подхватывает всё, что программа печатает в вывод, и складывает в journalctl; никаких файлов журнала заводить не надо.

Ещё несколько строк, которые стоят одной минуты

В файле службы есть строки, которые ничего не стоят, но многое закрывают:

  • User=blog — блог работает от собственного пользователя, а не от root. Взломанный блог тогда не равен взломанной машине;
  • NoNewPrivileges=true — процесс не сможет получить больше прав, чем получил при запуске;
  • ProtectSystem=strict и ReadWritePaths=/var/lib/blog — вся система для него только для чтения, кроме одной папки с базой и картинками;
  • PrivateTmp=true — свой /tmp, не общий.

Карта урока

Карта урока: сигнал, Shutdown и служба

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Чем srv.Shutdown отличается от srv.Close с точки зрения читателя?
  2. Почему базу закрывают после Shutdown, а не до?
  3. Почему TimeoutStopSec в файле службы должен быть больше срока остановки в программе?

Задание

Обязательное. Научите свой блог останавливаться аккуратно: сигнал ловится через signal.NotifyContext, Shutdown получает разумный срок, база закрывается последней. Проверьте: запустите блог, пошлите ему kill -TERM, посмотрите код выхода — должен быть 0, а в журнале две строки про остановку.

Всё это сделано в step-33 — сверьтесь после того, как сделаете сами.

По желанию.

  • Напишите свой файл службы и проверьте его: systemd-analyze verify blog.service.
  • Поставьте TimeoutStopSec=1 и посмотрите, что будет с долгим ответом при перезапуске.
  • Добавьте в Shutdown журнальную строку с числом соединений, которые пришлось дождаться.

Куда это встанет в блоге

Шаг 33 отличается от шага 32 одним: он умеет останавливаться. Разница измеряется прямо в терминале:

шаг 32, SIGTERM:  код выхода 143 — процесс убит на месте,
                  последняя строка журнала — чей-то запрос

шаг 33, SIGTERM:  код выхода 0
                  «пришёл сигнал остановки, ждём 15s»
                  «все ответы дописаны, остановились»

Код 143 — это 128 + 15, то есть «процесс убит сигналом номер 15». Код 0 — «программа завершилась сама и штатно». В панели systemd первый выглядит как failed, второй — как inactive (dead), и по этой строке потом ищут причину падения.

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

Ответы

Показать ответы
  1. Close рвёт соединения немедленно: читатель получает обрыв вместо страницы. Shutdown перестаёт принимать новые запросы, дожидается начатых и только потом возвращается — читатель получает свой ответ.
  2. Потому что ответы, которых мы дожидаемся, могут ходить в базу. Закрыв её первой, мы сломаем именно те запросы, ради которых ждём.
  3. Потому что systemd ждёт TimeoutStopSec и потом убивает процесс. Если он меньше, программа не успеет дописать ответы, и аккуратная остановка превратится в тот же обрыв.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

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

Пока нет комментариев. Будьте первым.