Содержание урока · 17 разделов
- Мост из прошлого урока: ${pid}, судьба строк и два файла
- fiber конфигурация на двух этажах: Config и ListenConfig
- Карта ключевых полей fiber.Config
- Таймауты сервера: ReadTimeout, WriteTimeout, IdleTimeout
- Keep-alive и дренаж: зачем таймауты нужны graceful shutdown
- Prefork: SO_REUSEPORT и воркеры-процессы
- ${pid}, startup-сообщение и предохранители Prefork
- graceful shutdown fiber: инвариант и API
- GracefulContext: встроенный путь Listen
- env-конфигурация: собираем конфиг из переменных окружения
- systemd: KillSignal, TimeoutStopSec и два потока журнала
- Чек-лист: конфигурация на проде
- Прогон: curl-сценарии и границы урока
- Что дальше: тестирование Fiber-приложений
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Мост из прошлого урока: ${pid}, судьба строк и два файла#
Восемнадцатый блок прошлого урока обещал ровно три вещи, и сегодня мы их исполняем. Первая: тег ${pid} из таблицы тегов перестанет быть диковинкой — с Prefork родительский процесс плодит воркеров, и в общем журнале строки легко разнести по процессам именно по PID. Вторая: graceful shutdown — это в том числе судьба последних строк журнала: слушатель закрыт, но запросы в полёте должны дописать свои записи, а буферы — дойти до потока. Третья: поток ошибок fiberlog на stderr и журнал запросов на stdout при настройке сервиса systemd получают буквальные пути — Stdout и Stdout-сосед Stderr в описании юнита, два файла, две судьбы.
Ключ «graceful shutdown fiber» ведёт поиск не к конфигу, а к сценарию остановки, и это не случайно: конфигурация сервера — это три biography-вопроса, заданные одному процессу. Как ты рождаешься: кто слушает порт и сколько тебя таких. Как ты живёшь: сколько байт читаешь, сколько секунд ждёшь, кого пускаешь. Как ты умираешь: успеваешь ли дописать журнал и ответить последним клиентам. Fiber v3 раскладывает эти вопросы по разным структурам, и путаница между ними — главный источник сломанных миграций с v2. Дальше по уроку: карта полей fiber.Config, таймауты по одному, механика Prefork с SO_REUSEPORT, канон сигналов и env-конфигурация — всё с цитатами из исходников.
fiber конфигурация на двух этажах: Config и ListenConfig#
Главное структурное знание урока: в v3 конфигурация живёт на двух этажах. Первый — fiber.Config, аргумент fiber.New: он описывает поведение приложения на уровне запросов — BodyLimit, таймауты, ErrorHandler, AppName; в документации v2 этот объект называли Settings, и старый термин до сих пор всплывает в поиске, хотя в исходниках v3 структура носит имя Config. Второй — fiber.ListenConfig, второй аргумент app.Listen: он описывает старт процесса — TLS, сеть, startup-сообщение, Prefork. И вот то, что ломает половину существующих гайдов: поля Prefork и DisableStartupMessage, которые в v2 были частью fiber.Config, в v3 из Config исчезли. В ListenConfig их преемники называются EnablePrefork и DisableStartupMessage — комментарий исходника формулирует назначение прямо: When set to true, this will spawn multiple Go processes listening on the same port. Логика переезда честная: всё, что касается слушателя и процессов, — это не «поведение приложения», а «способ запуска», поэтому место ему рядом с Listen.
Третий этаж — конфиги самих middleware: у logger.New и cache.New из прошлых уроков свои структуры Config, и они не имеют ничего общего с fiber.Config. Фабрики из шестого урока вы уже настраивали — порядок requestid → logger → recover задаётся порядком app.Use, а не полем конфига. Стало быть, «настроить Fiber» — это всегда три разных литерала структуры: fiber.Config, ListenConfig и конфиги слоёв. Часть полей fiber.Config мы уже невольно трогали: ErrorHandler из четырнадцатого урока — тоже поле этого конфига, как и Views из тринадцатого. Сегодня соберём всю карту целиком.
Канонический минимум: конфиг приложения и конфиг запуска
GOpackage main
import (
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
)
// Task — форма данных из девятого урока
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
func main() {
// первый этаж: поведение на уровне запросов
app := fiber.New(fiber.Config{
AppName: "tasks-api",
BodyLimit: 4 * 1024 * 1024, // 4 MiB — дефолт, показан явно
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
})
app.Use(logger.New())
app.Get("/api/tasks", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"items": []Task{
{ID: 1, Title: "починить шлюз"},
}})
})
// второй этаж: старт процесса. Без ASCII-арта в журнале контейнера
_ = app.Listen(":8080", fiber.ListenConfig{
DisableStartupMessage: true,
})
}Оба этажа в одном файле. fiber.Config получает таймауты — единственные поля, которые мы задаём явно и которые иначе остаются бесконечными: ReadTimeout 5 секунд на чтение всего запроса с телом, WriteTimeout 10 на запись ответа, IdleTimeout 60 на паузу между запросами живого keep-alive-соединения. BodyLimit записан явно для читателя — его значение совпадает с дефолтом 4 1024 1024. AppName не декоративен: стартовое сообщение печатает его строкой Application name, а при SharedState имя становится префиксом ключей. ListenConfig в вызове Listen — место, где в v2-гайдах ищут Prefork и не находят: в v3 здесь EnablePrefork, GracefulContext, ShutdownTimeout и DisableStartupMessage, который мы включили, чтобы журналы контейнера не пачкал ASCII-арт. Канон :8080 и /api/tasks продолжает счёт уроков.
Карта ключевых полей fiber.Config#
| Поле | Дефолт | Что делает |
|---|---|---|
| AppName | "" (пусто) | Имя приложения: строка в startup-сообщении, префикс ключей SharedState |
| ErrorHandler | DefaultErrorHandler | Обрабатывает все ошибки хендлеров — подробно в четырнадцатом уроке |
| BodyLimit | 4 1024 1024 | Максимальный размер тела; ноль и отрицательные значения откатываются к дефолту |
| ReadTimeout | unlimited | Время на чтение всего запроса, включая тело; дедлайн выставляется при открытии соединения |
| WriteTimeout | unlimited | Максимальная длительность записи ответа |
| IdleTimeout | unlimited | Ожидание следующего запроса в keep-alive; при нуле берётся ReadTimeout |
| Concurrency | 256 * 1024 | Максимум одновременных соединений |
| ReadBufferSize | 4096 | Буфер чтения на соединение; ограничивает максимальный размер заголовков |
| WriteBufferSize | 4096 | Буфер записи на соединение |
| MaxRanges | 16 | Максимум диапазонов из заголовка Range |
| ServerHeader | "" | Включает заголовок ответа Server: значение |
| StrictRouting | false | При true «/foo» и «/foo/» — разные маршруты |
| CaseSensitive | false | При true регистр пути учитывается: «/FoO» не равен «/foo» |
| SkipUnmatchedRoutes | false | Отвечает 404/405 до middleware; исходник предупреждает: logger, лимитер и статика такие запросы не увидят |
| ProxyHeader и TrustProxy | "" / false | c.IP() и c.Host() из X-Forwarded-* — только для доверенных прокси из TrustProxyConfig |
| Immutable | false | При true значения контекста иммутабельны, ценой аллокаций |
| GETOnly | false | Отбрасывает все не-GET запросы — анти-DoS для чисто читающих API |
| DisableKeepalive | false | При true соединение закрывается после первого ответа |
| JSONEncoder / JSONDecoder | json.Marshal / Unmarshal | Замена JSON-движка на альтернативную библиотеку |
| Views / ViewsLayout | nil / "" | Шаблонизатор и глобальный layout — из тринадцатого урока |
| SharedStorage | nil | Хранилище общего состояния, безопасного между prefork-воркерами |
Проверьте себя: миграция v2 → v3
В наследство достался код из гайда Fiber v2: fiber.New(fiber.Config{Prefork: true, DisableStartupMessage: true}). Что скажет компилятор на fiber v3.5.0?
Таймауты сервера: ReadTimeout, WriteTimeout, IdleTimeout#
Три таймаута — единственные поля дефолтного конфига, которые нельзя оставить нетронутыми в продакшене, потому что их дефолт — unlimited, бесконечность. ReadTimeout — время, отведённое на чтение всего запроса, включая тело; комментарий исходника уточняет две вещи: дедлайн чтения выставляется в момент открытия соединения, и он сбрасывается, когда хендлер вернулся. Это защита от медленного клиента, который открывает TCP-соединение и тянет заголовки по байту в минуту — класс Slowloris-атаки: без ReadTimeout такие соединения копятся и съедают лимит Concurrency, который по умолчанию равен 256 * 1024 и потому кажется неисчерпаемым. WriteTimeout ограничивает запись ответа — от «клиент на модемном подключении читает файл час» до «противник тянет ответ, не подтверждая пакеты». IdleTimeout — самый коварный: это пауза между запросами внутри одного keep-alive-соединения, когда всё уже прочитано и записано, а клиент молчит, надеясь отправить следующий запрос по тому же соединению.
С IdleTimeout связана единственная в этой тройке неочевидная связка, зафиксированная в комментарии к полю дословно: If IdleTimeout is zero, the value of ReadTimeout is used — нулевой IdleTimeout наследует ReadTimeout. Это не баг, а конвенция: сервер, который ограничил чтение, но забыл про простой, всё равно не даст соединениям висеть вечно. Отдельно стоит ReadBufferSize — 4096 байт на соединение; он же ограничивает максимальный размер заголовков, и его приходится поднимать, когда клиенты шлют большие cookies или длинные RequestURI — комментарий предлагает именно этот сценарий.
Keep-alive и дренаж: зачем таймауты нужны graceful shutdown#
Таймауты и graceful shutdown кажутся соседями по оглавлению, но не более того — пока не читаешь документацию ShutdownWithTimeout целиком. Последняя строка её описания в app.go — приговор наивной конфигурации: ShutdownWithTimeout does not close keepalive connections so its recommended to set ReadTimeout to something else than 0. Механика такая: при остановке сервер закрывает слушатель и ждёт, пока соединения вернутся в idle, но keep-alive-соединения он принудительно не рвёт — он ждёт, что клиент сам отправит следующий запрос или закроет соединение. Если ReadTimeout и IdleTimeout остались бесконечными, а браузер держит открытое соединение в фоне, дренаж рискует затянуться на всё время жизни чужого таба.
Отсюда практический вывод, который стоит записать в чек-лист любого сервиса: ShutdownWithTimeout задаёт потолок ожидания, а таймауты конфига делают этот потолок достижимым. Сервер с ReadTimeout 5 секунд и ShutdownWithTimeout 10 секунд гарантированно освободится: даже молчащее keep-alive-соединение умрёт по таймауту чтения и не удержит дренаж. Это же объясняет, почему нельзя «для надёжности» оставить таймауты нулевыми и положиться на shutdown-таймаут: вежливое завершение не должно зависеть от дисциплины чужих клиентов. Связка «таймауты в конфиге + дедлайн в shutdown» — пример того, как два несоседних поля конфигурации работают в паре.
Что применит сервер к молчащему keep-alive-соединению?
Прочитайте код и выберите, что он выведет:
app := fiber.New(fiber.Config{
ReadTimeout: 5 * time.Second,
// IdleTimeout не задан: ноль
})
// клиент держит keep-alive и молчит
// после первого ответаPrefork: SO_REUSEPORT и воркеры-процессы#
Prefork — единственная настройка Fiber, которая меняет количество процессов. Включатель называется EnablePrefork и живёт в ListenConfig; исходник prefork.go описывает механику одной фразой: prefork manages child processes to make use of the OS REUSEPORT feature. Мастер-процесс запускает дочерние Go-процессы — не горутины, а настоящие exec-процессы, по одному на ядро: число процессов в стартовом сообщении равно runtime.GOMAXPROCS(0). Каждый ребёнок открывает собственный сокет на том же порту с флагом SO_REUSEPORT — это возможность ядра Linux, позволяющая многим сокетам слушать один адрес, — и ядро само распределяет входящие соединения между сокетами. Функция IsChild() из корневого пакета позволяет коду понять, кто он: мастер или воркер.
Когда Prefork нужен: CPU-загруженный сервис (тяжёлые вычисления, компрессия, криптография) на многоядерном хосте, где один процесс Go упирается в ядро. Когда вреден: на машинах с малым числом ядер, в контейнерах с жёсткими лимитами CPU, и всегда — когда приложение держит общее состояние в памяти, потому что состояние процесса локально. Отдельное ограничение сети зафиксировано в ListenConfig предупреждением: при prefork допустимы только сети tcp4 и tcp6 — Unix-сокеты не поддерживаются. Аварийность зашита в конфиг: PreforkRecoverInterval задаёт паузу перед перезапуском упавшего ребёнка (дефолт — мгновенно), PreforkRecoverThreshold — максимум рестартов, после которого мастер сдаётся (дефолт max(1, runtime.GOMAXPROCS(0) / 2)), а PreforkShutdownGracePeriod — сколько мастер ждёт детей после SIGTERM, прежде чем послать SIGKILL: по умолчанию 5 * time.Second.
Проверьте себя: состояние под Prefork
Включили EnablePrefork — приложение выросло в восемь процессов. Хендлер POST растит счётчик в app.State() на каждом запросе. Что увидит GET-хендлер, читающий тот же счётчик?
${pid}, startup-сообщение и предохранители Prefork#
Мост из прошлого урока исполняется здесь. Startup-сообщение fiber — тот самый ASCII-арт с адресом — печатает не только адрес и версию: prepareListenData в listen.go собирает ListenData с полями PID, ProcessCount, ChildPIDs и Prefork, и в сообщении видны строки PID (текущий процесс), Prefork: Enabled или Disabled и Total process count. У прошлого урока было объяснение, почему тег ${pid} есть в словаре logger: без Prefork он всегда печатает одно число и кажется балластом, с Prefork — это единственный способ в общем журнале понять, какой из воркеров написал строку. Журнал собирается с stdout всех процессов в один поток journald или docker logs, и PID — ключ группировки.
Startup-сообщение тоже настраивается, и не только DisableStartupMessage. Цвета гасятся знакомыми по девятнадцатому уроку переменными TERM=dumb и NO_COLOR=1 — тот же механизм isatty, что у access-лога. EnablePrintRoutes печатает таблицу всех маршрутов с методом, путём и хендлером — удобно на старте сверять развернутое с ожидаемым. И важно для контейнеров: в prefork-режиме сигнал остановки должен доставляться каждому процессу — оговорка из ListenConfig, к которой мы вернёмся в блоке про GracefulContext, гласит, что контекст гасит только тот процесс, в котором он отменён. Мастер и дети — отдельные адресаты сигналов, и systemd в этом случае видит дерево процессов, а не один PID.
graceful shutdown fiber: инвариант и API#
Термин graceful shutdown fiber означает сценарий: сервер прекращает принимать новые запросы, дождывается тех, что в полёте, отпускает ресурсы и выходит — ничего не потеряно и ничего не оборвано. Это тот же инвариант, что в финальном проекте курса go-advanced, где канон graceful shutdown собирался на каналах: закрыть приём работы — дождаться бригады через WaitGroup — и только потом закрыть выход. Здесь роли другие, но последовательность та же: вместо закрытого канала — закрытый слушатель, вместо WaitGroup — дренаж inflight-хендлеров, вместо чтения остатков канала — дописанные строки журнала из прошлого урока.
API v3 умещается в три метода. app.Shutdown() — вежливая остановка без дедлайна, под капотом это ShutdownWithContext(context.Background()). app.ShutdownWithTimeout(timeout time.Duration) — тот же сценарий с потолком ожидания; документация описывает механику: сначала закрываются все открытые слушатели, затем сервер ждёт, пока соединения вернутся в idle. app.ShutdownWithContext(ctx) — общий случай: если дедлайн контекста истёк, сервер останавливается принудительно, включая force-закрытие. Три полезных факта из исходников. Первый: вызов shutdown на незапущенном сервере не паникует, а возвращает ErrNotRunning с текстом shutdown: server is not running. Второй: в error.go объявлена и ошибка дедлайна — ErrGracefulTimeout, shutdown: graceful timeout has been reached, exiting. Третий: у процедуры есть хуки — OnPreShutdown выполняются до дренажа, OnPostShutdown получают финальную ошибку после: места, чтобы закрыть базу и зафиксировать в логе итог остановки.
Канон сигналов: signal.Notify и ShutdownWithContext
GOpackage main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
)
func main() {
app := fiber.New(fiber.Config{AppName: "tasks-api"})
app.Use(logger.New())
app.Get("/api/tasks", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"items": []string{"починить шлюз"}})
})
// горутина ловит сигналы и начинает graceful shutdown
go func() {
sig := make(chan os.Signal, 1) // буфер 1: сигнал не потеряется
// Interrupt — это Ctrl+C в терминале, SIGTERM шлют systemd и docker stop
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
slog.Info("сигнал получен: начинаю graceful shutdown")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := app.ShutdownWithContext(ctx); err != nil {
slog.Error("shutdown завершился с ошибкой", "err", err)
}
}()
// Listen вернётся, когда сервер остановится
if err := app.Listen(":8080"); err != nil {
slog.Error("listen", "err", err)
}
fmt.Println("сервер остановлен, inflight-запросы дописаны")
}Это канонический каркас, который стоит набить рукой: Fiber сам сигналы не подписывает — в listen.go нет ни signal.Notify, ни вызовов пакета os/signal, выбор остаётся за вами. Канал буферизован на одну ячейку: signal.Notify не блокируется, и без буфера сигнал во время занятой горутины мог бы потеряться. os.Interrupt — это SIGINT, Ctrl+C в терминале, удобно для локальной проверки; syscall.SIGTERM — то, что шлёт systemd и docker stop; без него контейнер гасил бы сервер только по SIGKILL-таймауту, без всякого дренажа. Дедлайн 10 секунд передан контекстом — ShutdownWithContext останавливает сервер по истечении принудительно. После остановки Listen возвращает управление, и main успевает напечатать финальную строку — тот самый хвост журнала, судьбу которого мы обещали разобраться в прошлом уроке.
Проверьте себя: SIGKILL не перехватить
docker stop по умолчанию шлёт SIGTERM и через 10 секунд — SIGKILL. Ваш graceful shutdown занимает 15 секунд. Что произойдёт?
GracefulContext: встроенный путь Listen#
Ручная горутина с signal.Notify — универсальный путь, но у ListenConfig есть штатная кнопка: поле GracefulContext. Документация поля формулирует задачу прямо: поле нужно, чтобы гасить Fiber по данному контексту вежливо. Вы передаёте контекст, отменяете его из своего обработчика сигналов — и Listen сам заводит горутину graceful shutdown: в исходнике Listen видно stop-канал с defer close(stop) и вызов app.gracefulShutdown, который при отмене контекста вызывает ShutdownWithTimeout с значением поля ShutdownTimeout. Дефолт этого поля — 10 * time.Second; правило из комментария стоит запомнить дословно: Negative disables the timeout and waits indefinitely; zero applies the default — отрицательное значение отменяет потолок и ждёт бесконечно, ноль подставляет дефолт.
Экономия — в двух строках вместо горутины, но настоящая ценность — в prefork-оговорке, которую ListenConfig несёт в комментарии к тому же полю: With EnablePrefork the context stops only the process it is canceled in; deliver the signal to every process so each child drains its own connections. Контекст гасит только тот процесс, в котором его отменили: под Prefork сигнал нужно доставить каждому воркеру, чтобы каждый осушил собственные соединения. Ручная горутина в детях с IsChild() и адресная доставка сигналов дают контроль, которого у общего контекста нет. Для одиночного процесса GracefulContext — самый короткий корректный путь; для prefork-кластера — деталь, о которой нужно знать заранее, а не обнаруживать по оборванным журналам.
Что напечатает shutdown на незапущенном сервере?
Прочитайте код и выберите, что он выведет:
package main
import (
"fmt"
"time"
"github.com/gofiber/fiber/v3"
)
func main() {
app := fiber.New()
// Listen ни разу не вызывали
err := app.ShutdownWithTimeout(time.Second)
fmt.Println(err)
}Соберите канон остановки
Заполните пропуски так, чтобы горутина ловила оба сигнала остановки, вызывала вежливый shutdown с контекстом, а Listen запускался без ASCII-арта.
Соберите последовательность graceful shutdown
Расставьте шаги от сигнала до финальной строки main — от того, что происходит первым, к тому, что происходит последним.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
env-конфигурация: собираем конфиг из переменных окружения#
Одно и то же приложение в деве, стейджинге и проде должно получать разные лимиты и таймауты, и канонический способ — переменные окружения: их задают docker compose, Kubernetes и systemd через Environment или EnvironmentFile. Честный факт: встроенной поддержки env в fiber.Config нет — ни поле не читает os.Getenv само, ни fiber.New не принимает файл настроек. Это осознанная простота: конфигурация — это ваш код между env и fiber.Config, и он обычно занимает экран. Правила хорошей фабрики конфига три. Первое: читайте env явно и типизированно — strconv.Atoi для целых, strconv.ParseFloat для долей, time.ParseDuration для таймаутов, потому что 5 * time.Second в окружении живёт как строка «5s». Второе: мусор в env не должен ронять старт — неверное значение заменяется дефолтом, а решение записывается в журнал при старте. Третье: дефолты Fiber разумны, и поле BodyLimit тому пример — исходник прямо говорит, что ноль и отрицательные значения откатываются к дефолту, так что «ошибка парсинга → оставить ноль» безопасна не для всех полей, но для лимитов — да.
Тот же принцип работает для ListenConfig: EnablePrefork удобно вешать на строку «1» в FIBER_PREFORK, а порт — на PORT. Одна оговорка: не тащите в env то, что имеет смысл фиксировать в коде — ErrorHandler и маршруты не конфигурируются окружением, они часть программы.
Фабрика конфига из переменных окружения
GOpackage main
import (
"os"
"strconv"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
)
// configFromEnv собирает fiber.Config из окружения:
// при отсутствии или мусоре остаётся дефолт Fiber
func configFromEnv() fiber.Config {
cfg := fiber.Config{AppName: os.Getenv("APP_NAME")}
// лимит тела: «8388608» → 8 MiB, мусор → дефолт 4 MiB
if v := os.Getenv("FIBER_BODY_LIMIT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.BodyLimit = n
}
}
// таймауты: time.ParseDuration понимает «5s», «30s», «2m»
if v := os.Getenv("FIBER_READ_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.ReadTimeout = d
}
}
return cfg
}
func main() {
app := fiber.New(configFromEnv())
app.Use(logger.New())
app.Get("/api/tasks", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"items": []string{"починить шлюз"}})
})
// порт из env, канон :8080 как запасной
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
_ = app.Listen(":"+port, fiber.ListenConfig{DisableStartupMessage: true})
}Фабрика ничего не знает о caller'е и возвращает готовый fiber.Config — её легко покрыть тестами, подавая env через t.Setenv, что и станет мостом к следующему уроку. Обратите внимание на двойной фильтр: err == nil отсекает мусор, n > 0 — бессмысленные нули, и лишь затем значение попадает в конфиг. Для BodyLimit ноль был бы безопасен сам по себе — исходник откатывает его к дефолту, — но явный фильтр читается без обращения к документации. Тот же приём годится для ListenConfig: FIBER_PREFORK == "1" превращается в EnablePrefork: true. В журнале при старте стоит печатать итоговую конфигурацию — не через fiberlog уровня Info, а одной строкой при запуске: рассинхрон между окружением и ожиданиями — классическая причина ночных инцидентов.
systemd: KillSignal, TimeoutStopSec и два потока журнала#
Сервис под systemd — место, где все три темы урока встречаются в одном текстовом файле. Юнит запускает бинарник, и ExecStart получает процесс; когда администратор делает systemctl stop, systemd по умолчанию шлёт главному процессу SIGTERM — это настраивается директивой KillSignal, — ждёт TimeoutStopSec (дистрибутивные дефолты обычно 90 секунд) и затем убивает всё дерево SIGKILL. Сопоставьте с уроком: ваш ShutdownTimeout 10 секунд укладывается в любой разумный TimeoutStopSec, дренаж успевает, OnPostShutdown срабатывает, journalctl получает последнюю строку. Если же дренаж длиннее таймаута юнита, вы увидите статус timeout и killed — тот же сценарий, что в quiz про SIGKILL, только в форме systemd.
Два потока журнала из прошлого урока здесь получают адреса: StandardOutput=journal и StandardError=journal по умолчанию собирают stdout и stderr в один журнал, но их можно развести в файлы или сокеты — access-лог и события fiberlog окажутся в разных местах, как и обещал девятнадцатый урок. Для prefork-сервиса важно, что systemd по умолчанию следит за главным процессом; KillMode=control-group гасит всю группу — и воркеров тоже, каждый со своим дренажом, если сигнал доставлен каждому. Всё это — конфигурация вне Go, но именно она определяет, выполнится ли graceful shutdown, который вы написали в коде: код задаёт поведение остановки, окружение задаёт время на его исполнение.
Чек-лист: конфигурация на проде#
- Задайте все три таймаута явно: ReadTimeout, WriteTimeout, IdleTimeout — дефолт unlimited держит соединения вечно и подарит Slowloris
- ReadTimeout не ноль при ShutdownWithTimeout: keep-alive соединения shutdown не рвёт — их закроет таймаут чтения
- Помните, что Prefork и DisableStartupMessage в v3 живут в ListenConfig, а не в fiber.Config — v2-гайды врут
- Prefork включайте по бенчмарку: CPU-bound на многоядерном хосте; иначе память на восемь копий и распухшие Heap-графики
- State под Prefork process-local: общее состояние — только SharedStorage и app.SharedState()
- EnablePrefork тянет за собой tcp4/tcp6 и prefork-параметры: PreforkShutdownGracePeriod 5 секунд, PreforkRecoverThreshold max(1, GOMAXPROCS/2)
- Сигналы: буферизованный канал на 1, os.Interrupt плюс syscall.SIGTERM — без второго контейнер умирает по SIGKILL без дренажа
- docker stop -t и TimeoutStopSec юнита строго больше ShutdownTimeout — иначе SIGKILL оборвёт дренаж и хуки
- GracefulContext в ListenConfig — самый короткий корректный путь для одиночного процесса: дефолт ShutdownTimeout 10 секунд
- Конфиг из env — типизированно: strconv.Atoi для лимитов, time.ParseDuration для таймаутов, мусор заменяется дефолтом и логируется при старте
Прогон: curl-сценарии и границы урока#
Соберите канон урока: конфиг с таймаутами, logger.New, /api/tasks и горутина сигналов. Клиент — curl, сценарии три. Первый: в одном терминале curl -i http://localhost:8080/api/tasks, во втором kill -TERM $(pgrep tasks-api) — сервер допечатывает ответ, печатает строку журнала, Listen возвращается, main выводит финальную строку. Второй: добавьте хендлер с time.Sleep(3 * time.Second), запустите curl в фоне и сразу шлите SIGTERM — ответ всё равно уезжает целиком: inflight-запросы дожидаются внутри дренажа, в этом весь смысл graceful. Третий: проверьте таймаут конфига — curl с удавкой --max-time 1 на медленный хендлер даст клиентский таймаут раньше серверного, а хендлер, читающий тело дольше ReadTimeout, получит обрыв со стороны сервера.
Честное слово про инструментарий: песочница сайта сетевые сценарии не исполняет — runner-ов в уроках Fiber нет с первого урока, интерактивы здесь quiz, predict, fill, order и флешкарты; сигналы и prefork проверяются только локально, где у процесса есть настоящий PID и настоящие потоки. Материал держится на исходниках ветки main ядра fiber v3.5.0: app.go — Config и DefaultErrorHandler, listen.go — ListenConfig, startup-сообщение и gracefulShutdown, prefork.go — SO_REUSEPORT и восстановление детей, error.go — ErrNotRunning и ErrGracefulTimeout. Полный план курса — на странице курса Fiber.
Что дальше: тестирование Fiber-приложений#
Следующий, двадцать первый урок — тестирование Fiber-приложений: app.Test, httptest и юнит-тесты хендлеров. Связь с сегодняшней темой прямая. Во-первых, фабрика configFromEnv из этого урока просится под тесты: t.Setenv подставляет окружение, app.Test прогоняет запрос через приложение без сети — и вы проверяете, что мусорная переменная действительно оставила дефолт. Во-вторых, у app.Test есть TestConfig с полем Timeout и управлением FailOnTimeout — таймауты, о которых мы говорили в масштабе сервера, возвращаются в масштабе теста. В-третьих, graceful shutdown тоже тестируется: хендлер с задержкой, ShutdownWithTimeout и наблюдение, что ответ уехал, — это обычный тест с goroutine. Конфигурация, которую нельзя проверить тестом, — это надежда, а не конфигурация; двадцать первый урок даст инструменты, чтобы превратить весь этот урок в зелёный прогон.
Карточки: fiber конфигурация
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Конфигурация v3 живёт на двух этажах: fiber.Config в fiber.New — поведение запросов (BodyLimit, таймауты, ErrorHandler), fiber.ListenConfig в app.Listen — старт процесса (EnablePrefork, DisableStartupMessage, GracefulContext, ShutdownTimeout).
- Таймауты по умолчанию unlimited: ReadTimeout — на весь запрос с телом, WriteTimeout — на запись ответа, IdleTimeout — на паузу keep-alive, и при нуле он наследует ReadTimeout.
- Prefork — это EnablePrefork: процессы-воркеры на SO_REUSEPORT, IsChild() для различения, ListenerNetwork только tcp4/tcp6; State process-local, общее состояние — SharedStorage и app.SharedState().
- graceful shutdown fiber: ShutdownWithTimeout и ShutdownWithContext закрывают слушатель и ждут inflight-хендлеры; хуки OnPreShutdown и OnPostShutdown обрамляют процедуру; незапущенный сервер возвращает ErrNotRunning.
- Сигналы ловите сами: signal.Notify с os.Interrupt и syscall.SIGTERM в буферизованный канал; SIGKILL не перехватывается — таймаут systemd/docker обязан быть больше вашего ShutdownTimeout.
- env-конфигурация — ваш код: strconv.Atoi для лимитов, time.ParseDuration для таймаутов, мусор молча заменяется дефолтами Fiber, итог печатается при старте.
Домашнее задание
Возьмите канон урока и пройдите три шага. Первый: поднимите хендлер GET /api/slow с time.Sleep(2 * time.Second), запустите сервер, запустите curl в фоне и немедленно шлите kill -TERM — убедитесь, что ответ уезжает целиком и main печатает финальную строку; затем уменьшите дедлайн ShutdownWithTimeout до одной секунды и опишите разницу. Второй: перенесите порт, BodyLimit и ReadTimeout в переменные окружения через фабрику из урока, добавьте strconv.ParseInt для лимита и запустите сервис трижды: без переменных, с корректными значениями и с мусором — сравните поведение. Третий: соберите юнит systemd с KillSignal=SIGTERM и TimeoutStopSec=30, поставьте DisableStartupMessage: true и проверьте journalctl: access-лог должен прийти в stdout, события fiberlog — в stderr; если разводите потоки по файлам через StandardOutput и StandardError, покажите оба файла. Отдельно запустите локально EnablePrefork с GOMAXPROCS=2 и сравните startup-сообщение: PID, Total process count и строки ${pid} в access-логе двух воркеров.
Личные заметкиПусто
Вопросы и ответы по уроку
Где в fiber v3 поле Prefork из документации v2?
В v3 его нет в fiber.Config: поле называется EnablePrefork и живёт в ListenConfig — втором аргументе app.Listen. Туда же переехали DisableStartupMessage и новые prefork-настройки: PreforkRecoverInterval, PreforkRecoverThreshold, PreforkShutdownGracePeriod и PreforkLogger. Любой гайд с Prefork внутри fiber.New описывает v2.
Как проверить graceful shutdown без Kubernetes?
Локально достаточно kill -TERM с PID процесса и curl в фоне на медленный хендлер: ответ должен уехать целиком, Listen — вернуться, main — дописать хвост. Для полной проверки добавьте в тест хендлер с задержкой и ShutdownWithTimeout: app.Test из следующего урока позволяет прогнать запрос без сети.
Нужен ли Prefork моему сервису?
Скорее нет, чем да: если узкое место — база или сеть, восемь процессов лишь умножат память и соединения. Prefork даёт прирост на CPU-bound задачах и многоядерных хостах. Держите в уме ограничения: process-local состояние, сети tcp4/tcp6, необходимость доставлять сигнал каждому воркеру, и общий порт SO_REUSEPORT, который скрывает, в какой процесс попал запрос.
Что происходит с inflight-запросами при истечении ShutdownTimeout?
Сервер останавливает их принудительно: документация ShutdownWithContext говорит о force-закрытии по истечении дедлайна контекста, а в error.go на этот случай объявлена ErrGracefulTimeout. Хук OnPostShutdown получит итоговую ошибку; ответ оборванного клиента не уедет, а строка журнала может не дописаться — именно поэтому дедлайн должен быть больше самого медленного легитимного запроса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT