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 — не способ остановки, а последнее средство.
Порядок важнее кода
Собранное вместе выглядит так, и порядок здесь не украшение:
- пришёл сигнал — контекст закрылся;
srv.Shutdown(ctx)— новых не берём, начатых дожидаемся;- и только теперь
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, не общий.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Чем
srv.Shutdownотличается отsrv.Closeс точки зрения читателя? - Почему базу закрывают после
Shutdown, а не до? - Почему
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), и по этой строке потом ищут причину падения.
Долги. Блог всё ещё не ставит таймауты на чтение и запись запроса, а без них одно медленное соединение может занять поток надолго. Это чек-лист перед запуском — следующий урок.
Ответы
Показать ответы
Closeрвёт соединения немедленно: читатель получает обрыв вместо страницы.Shutdownперестаёт принимать новые запросы, дожидается начатых и только потом возвращается — читатель получает свой ответ.- Потому что ответы, которых мы дожидаемся, могут ходить в базу. Закрыв её первой, мы сломаем именно те запросы, ради которых ждём.
- Потому что systemd ждёт
TimeoutStopSecи потом убивает процесс. Если он меньше, программа не успеет дописать ответы, и аккуратная остановка превратится в тот же обрыв.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.