Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~20 мин +50 XP

Конфигурация и настройка сервера

Девятнадцать уроков мы запускали сервер одной строкой app.Listen(":8080") — и настал момент спросить, что стоит между этой строкой и настоящим сервисом. Тема «fiber конфигурация» распадается на три вопроса, и все три отвечают разные структуры исходников fiber v3.5.0. Первый: как приложение ведёт себя на каждом запросе — за это отвечает fiber.Config, который передаётся в fiber.New: лимиты тела, таймауты, имя приложения, обработчик ошибок. Второй: как процесс выходит в сеть — это ListenConfig, второй аргумент app.Listen, куда в третьей версии переехали Prefork и DisableStartupMessage, соблазнительно описанные в гайдах по v2 на старом месте. Третий: как процесс уходит из сети, когда пришёл сигнал, — это graceful shutdown fiber, ShutdownWithTimeout и пара SIGINT/SIGTERM. Прошлый урок про [логирование запросов](/courses/fiber/lessons/19-logirovanie/) закончился обещанием моста: тег ${pid} перестанет быть диковинкой, последние строки журнала получат судьбу, а два потока — путь в systemd. Разберём всё по живым исходникам ветки main: app.go, listen.go, prefork.go и error.go, — сверим каждое поле и каждое значение по умолчанию и честно скажем, где v3 разошлась с v2.

Содержание урока · 17 разделов

Мост из прошлого урока: ${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 из тринадцатого. Сегодня соберём всю карту целиком.

Канонический минимум: конфиг приложения и конфиг запуска

GO
package 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
ErrorHandlerDefaultErrorHandlerОбрабатывает все ошибки хендлеров — подробно в четырнадцатом уроке
BodyLimit4 1024 1024Максимальный размер тела; ноль и отрицательные значения откатываются к дефолту
ReadTimeoutunlimitedВремя на чтение всего запроса, включая тело; дедлайн выставляется при открытии соединения
WriteTimeoutunlimitedМаксимальная длительность записи ответа
IdleTimeoutunlimitedОжидание следующего запроса в keep-alive; при нуле берётся ReadTimeout
Concurrency256 * 1024Максимум одновременных соединений
ReadBufferSize4096Буфер чтения на соединение; ограничивает максимальный размер заголовков
WriteBufferSize4096Буфер записи на соединение
MaxRanges16Максимум диапазонов из заголовка Range
ServerHeader""Включает заголовок ответа Server: значение
StrictRoutingfalseПри true «/foo» и «/foo/» — разные маршруты
CaseSensitivefalseПри true регистр пути учитывается: «/FoO» не равен «/foo»
SkipUnmatchedRoutesfalseОтвечает 404/405 до middleware; исходник предупреждает: logger, лимитер и статика такие запросы не увидят
ProxyHeader и TrustProxy"" / falsec.IP() и c.Host() из X-Forwarded-* — только для доверенных прокси из TrustProxyConfig
ImmutablefalseПри true значения контекста иммутабельны, ценой аллокаций
GETOnlyfalseОтбрасывает все не-GET запросы — анти-DoS для чисто читающих API
DisableKeepalivefalseПри true соединение закрывается после первого ответа
JSONEncoder / JSONDecoderjson.Marshal / UnmarshalЗамена JSON-движка на альтернативную библиотеку
Views / ViewsLayoutnil / ""Шаблонизатор и глобальный layout — из тринадцатого урока
SharedStoragenilХранилище общего состояния, безопасного между 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

GO
package 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-арта.

go func() { sig := make(chan os.Signal, 1) // Interrupt — Ctrl+C в терминале, второй сигнал шлёт systemd и docker stop signal.Notify(sig, os.Interrupt, ) <-sig ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // вежливо остановить сервер и дождаться inflight-запросов if err := app.(ctx); err != nil { slog.Error("shutdown", "err", err) } }() // финальный штрих: без startup-сообщения в журнале контейнера _ = app.Listen(":8080", fiber.ListenConfig{: true})

Соберите последовательность 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 и маршруты не конфигурируются окружением, они часть программы.

Фабрика конфига из переменных окружения

GO
package 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 конфигурация

Термин

fiber.Config против ListenConfig

нажмите, чтобы перевернуть
Определение

fiber.New(fiber.Config{...}) — поведение на уровне запросов: BodyLimit, таймауты, ErrorHandler, AppName. app.Listen(addr, fiber.ListenConfig{...}) — старт процесса: EnablePrefork, DisableStartupMessage, GracefulContext, ShutdownTimeout. В v3 Prefork переехал из Config вниз и сменил имя.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 · Конфигурация и настройка сервера)

Вопросы и ответы по уроку

Где в 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 получит итоговую ошибку; ответ оборванного клиента не уедет, а строка журнала может не дописаться — именно поэтому дедлайн должен быть больше самого медленного легитимного запроса.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Gin25 уроков~13 чпрактика25 уроков уже доступны

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT