---
title: "Конфигурация и настройка сервера"
description: "Урок fiber конфигурация: поля fiber.Config v3.5.0, таймауты Read/Write/Idle, Prefork и SO_REUSEPORT, graceful shutdown fiber с ShutdownWithTimeout."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/20-konfiguraciya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 20 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/20-konfiguraciya/*

Девятнадцать уроков мы запускали сервер одной строкой 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.

### Мост из прошлого урока: ${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. Фабрики из [шестого урока](/courses/fiber/lessons/06-middleware/) вы уже настраивали — порядок 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 |
| 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?

- Скомпилируется без изменений: оба поля остались в fiber.Config
- ✅ Ошибка: у fiber.Config нет поля Prefork — в v3 оно называется EnablePrefork и переехало в ListenConfig вместе с DisableStartupMessage; их передают вторым аргументом app.Listen(addr, fiber.ListenConfig{...})
- Скомпилируется с предупреждением об устаревании: поля работают, но их не рекомендуют
- Ошибка: в v3 Prefork удалён навсегда — мультипроцессность больше не поддерживается

Сверка по исходникам: в структуре Config файла app.go ветки main нет ни Prefork, ни DisableStartupMessage — оба поля живут в ListenConfig файла listen.go, первое под именем EnablePrefork. Компилятор ругнётся на неизвестное поле ещё до запуска — и это хорошая новость: сломанная миграция видна сразу, а не в проде. Сам механизм мультипроцессности в v3 жив и здоров, просто изменил адрес и имя. Отсюда правило чтения документации: любой материал про fiber конфигурацию, в котором Prefork стоит внутри fiber.New, написан про v2 и механически на v3 не переносится.

### Таймауты сервера: 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-соединению?

```go
app := fiber.New(fiber.Config{
        ReadTimeout: 5 * time.Second,
        // IdleTimeout не задан: ноль
})
// клиент держит keep-alive и молчит
// после первого ответа
```

**Что выведет программа?**

- Никогда не закроет: IdleTimeout не задан — простой unlimited
- ✅ Закроет через 5 секунд: при нулевом IdleTimeout используется значение ReadTimeout
- Закроет через 4096 миллисекунд: срабатывает ReadBufferSize
- Закроет через 256 секунд: срабатывает Concurrency

Дословно из комментария к полю в app.go: If IdleTimeout is zero, the value of ReadTimeout is used. Бесконечный простой получился бы только при обоих нулях — и ReadTimeout, и IdleTimeout. Два неверных варианта подсовывают числа из соседних полей конфига: ReadBufferSize — это 4096 байт буфера, а не миллисекунды, а Concurrency — 256 * 1024 соединений, а не секунд. Мораль для чтения любых конфигов: не полагайтесь на «похожие числа», проверяйте правила наследования между полями — они существуют, потому что авторы решили проблему «задал одно — забыл другое» за вас.

### 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-хендлер, читающий тот же счётчик?

- Честное суммарное число: Fiber синхронизирует State между воркерами
- ✅ Локальное значение своего процесса: State process-local и не шарится между воркерами; для общего состояния нужен SharedStorage и app.SharedState()
- Панику: State запрещён при включённом Prefork
- Ноль: State сбрасывается на каждом запросе

Две цитаты из app.go решают спор: State is process-local and is not shared across prefork workers и SharedState is prefork-safe when Config.SharedStorage is configured. Восемь процессов — это восемь изолированных куч: POST-хендлер инкрементирует счётчик в своём процессе, и GET, которого ядро через SO_REUSEPORT отправило в другой процесс, увидит своё, отставшее значение. Fiber не запрещает State при prefork и не паникует — он просто не может быть синхронным между процессами, для этого в v3 есть SharedState поверх SharedStorage из поля конфига. Заметьте, как единый сокет-порт скрывает множественность процессов от клиентов: расползание счётчиков будет тихим, без ошибок.

### ${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](/courses/go-advanced/lessons/25-proekt-vorker/), где канон 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 секунд. Что произойдёт?

- Процесс дождётся конца дренажа: SIGKILL тоже перехватывается, если зарегистрировать его в signal.Notify
- ✅ Через 10 секунд ядро убьёт процесс: SIGKILL нельзя перехватить через signal.Notify, дренаж оборвётся на середине
- Docker подождёт все 15 секунд: SIGKILL отправляется только после явного docker kill
- Fiber перезапустит приложение: Listen перехватит SIGKILL и продолжит работу

SIGKILL — единственный «выход без ритуала»: ядро доставляет его процессу в обход любых обработчиков, signal.Notify его не видит в принципе (как и SIGSTOP), и никакой код Go исполнить не успеет. Дренаж оборвётся: недописанные строки журнала, незакрытые соединения, хуки OnPostShutdown не сработают. Поэтому арифметика таймаутов важнее любого кода: docker stop -t и systemd TimeoutStopSec должны быть строго больше вашего ShutdownTimeout, а сам ShutdownTimeout — больше реального времени дренажа с запасом. Правильный graceful shutdown проектируется не в горутине, а в единице systemd: если ваш дренаж занимает 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 на незапущенном сервере?

```go
package main

import (
        "fmt"
        "time"

        "github.com/gofiber/fiber/v3"
)

func main() {
        app := fiber.New()
        // Listen ни разу не вызывали
        err := app.ShutdownWithTimeout(time.Second)
        fmt.Println(err)
}
```

**Что выведет программа?**

- Печатает <nil>: без Listen ошибок нет
- ✅ Печатает shutdown: server is not running
- Паника: nil-указатель внутри ShutdownWithTimeout
- Зависает на секунду и печатает shutdown: graceful timeout has been reached, exiting

ShutdownWithContext в app.go первым делом читает поле app.server под мьютексом; если оно равно nil, возвращается ErrNotRunning — а его текст в error.go именно такой: shutdown: server is not running. Паники нет: метод спроектирован так, что его безопасно вызвать из любого сценария остановки, даже избыточного — например, из двух обработчиков сигналов сразу. Вариант с ErrGracefulTimeout — это другой сценарий: сервер запущен, дренаж начался, но дедлайн контекста истёк раньше, чем соединения вернулись в idle. Разница между «сервера нет» и «сервер не успел» — две разные ошибки двух разных стадий; ваши логи при остановке должны их различать.

### Соберите канон остановки

Заполните пропуски так, чтобы горутина ловила оба сигнала остановки, вызывала вежливый shutdown с контекстом, а Listen запускался без ASCII-арта.

```go
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})
```

Подсказка: Сигнал живёт в пакете syscall; метод с контекстом — общий случай shutdown; поле стартового сообщения — в ListenConfig, не в fiber.Config.

Правильные ответы в пропусках: `syscall.SIGTERM`, `ShutdownWithContext`, `DisableStartupMessage`

### Соберите последовательность graceful shutdown

Расставьте шаги от сигнала до финальной строки main — от того, что происходит первым, к тому, что происходит последним.

Правильный порядок строк:

1. `systemd или docker stop посылает процессу сигнал SIGTERM`
2. `signal.Notify доставляет сигнал в буферизованный канал`
3. `горутина выходит из <-sig и создаёт context.WithTimeout`
4. `app.ShutdownWithContext(ctx) начинает остановку сервера`
5. `выполняются хуки OnPreShutdown — последний шанс закрыть ресурсы`
6. `слушатель закрыт: новые соединения не принимаются`
7. `дренаж: сервер ждёт inflight-хендлеры в пределах дедлайна контекста`
8. `дедлайн истёк — принудительное закрытие оставшихся соединений`
9. `ShutdownWithContext возвращает итог, хук OnPostShutdown получает финальную ошибку`
10. `Listen выходит из Serve, 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](/courses/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 вниз и сменил имя.
- **Три таймаута сервера** — ReadTimeout — на чтение всего запроса с телом, дедлайн ставится при открытии соединения. WriteTimeout — на запись ответа. IdleTimeout — пауза между запросами в keep-alive; при нуле наследует ReadTimeout. Все три по умолчанию unlimited.
- **Prefork** — EnablePrefork: мастер плодит настоящие Go-процессы (по числу GOMAXPROCS), каждый слушает свой сокет того же порта через SO_REUSEPORT. IsChild() различает мастера и воркера; State process-local — общее состояние только через SharedStorage и SharedState(); сети — только tcp4/tcp6.
- **ShutdownWithTimeout** — Закрывает слушатели, ждёт возврата соединений в idle в пределах дедлайна; keep-alive принудительно не рвёт — пусть их закроет ReadTimeout. На незапущенном сервере возвращает ErrNotRunning «shutdown: server is not running»; дедлайн дренажа — ErrGracefulTimeout.
- **Сигналы остановки** — signal.Notify(sig, os.Interrupt, syscall.SIGTERM) в буферизованный канал; SIGINT — Ctrl+C, SIGTERM — systemd и docker stop. SIGKILL через signal.Notify не перехватывается никогда: время на остановку задаёт docker stop -t или TimeoutStopSec юнита.
- **GracefulContext** — Поле ListenConfig: передаёте контекст — Listen сам заводит горутину graceful shutdown и ждёт ShutdownTimeout (дефолт 10 * time.Second; отрицательное — ждать вечно, ноль — дефолт). С EnablePrefork контекст гасит только свой процесс — сигнал нужен каждому воркеру.

## Коротко: что запомнить

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