10.2HTTP-сервер в Go
Проследи, как net/http принимает соединение, вызывает handler и удерживает ресурсы на время запроса. Настрой таймауты по фазам, размер тела и shutdown так, чтобы медленный клиент или SIGTERM не оставляли процесс без контроля.
Путь запроса: от Listener до твоего хендлера
Внутри http.ListenAndServe(addr, handler) всего три строчки: создать
&http.Server{Addr: addr, Handler: handler}, открыть слушающий сокет через
net.Listen("tcp", addr) и уйти в srv.Serve(l). Дальше начинается
цикл, который стоит уметь рассказать пошагово.
l.Accept()в бесконечном цикле в горутине, вызвавшейServe. ЕслиAcceptвернул временную ошибку (например, кончились файловые дескрипторы), сервер не падает, а спит по нарастающей (5 мс, 10, 20… до 1 с) и пробует снова.- На принятое соединение создаётся
c := srv.newConn(rw)и сразу запускаетсяgo c.serve(connCtx). Пула воркеров нет: одна горутина на одно TCP-соединение. В HTTP/1.x не на запрос, а именно на соединение; HTTP/2 внутри соединения запускает ещё по горутине на каждый поток. - Внутри
c.serveдля HTTPS сначала идёт TLS-handshake (там же согласуется ALPN:h2илиhttp/1.1), потом цикл чтения запросов. c.readRequest(ctx)парсит стартовую строку и заголовки в*http.Request. Тело не читается: вr.Bodyкладётся обёртка над буферизованным ридером соединения, и байты потекут, только когда хендлер сам вызоветRead.serverHandler{srv}.ServeHTTP(w, req)приsrv.Handler == nilподставляетhttp.DefaultServeMux. Затем роутер выбирает нужныйHandlerи вызывает егоServeHTTP. Это уже твой код.- Когда хендлер вернулся,
w.finishRequest()сбрасывает буфер ответа в сокет, дочитывает и выбрасывает остаток тела запроса (иначе соединение нельзя переиспользовать) и закрывает chunked-поток. - Если клиент и сервер согласны на keep-alive и ошибок не было, цикл делает
continue, и та же горутина ждёт следующий запрос на том же соединении. Иначе вызываетсяc.close(), и горутина завершается.
В C++/Java эта модель считается антипаттерном: поток ОС стоит мегабайты стека и переключение
через ядро. В Go горутина стартует со стеком от 2 КиБ (с Go 1.19 рантайм может сразу дать больше, по среднему размеру стеков на прошлой сборке мусора), а все сетевые вызовы проходят через
netpoller (epoll/kqueue внутри рантайма). Когда горутина упирается в Read
на сокете без данных, рантайм её паркует (gopark) и снимает с потока ОС; поток
уходит выполнять другие горутины. Получается модель, которая пишется как блокирующая, а
работает как асинхронная. За это платят памятью: каждое соединение держит горутину, буфер
чтения (4 КиБ) и буфер записи (4 КиБ), то есть примерно 10–20 КиБ на idle-соединение.
Отсюда вывод: IdleTimeout нужен, чтобы сервер не держал десятки тысяч
бесполезных горутин.
http.Handler и HandlerFunc: почему это одно и то же
Весь net/http стоит на интерфейсе из одного метода:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Писать структуру ради одного метода лень, а обычная функция интерфейс не реализует. Выручает адаптер на именованном функциональном типе:
type HandlerFunc func(ResponseWriter, *Request)
// метод на функциональном типе просто вызывает f
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
Запись http.HandlerFunc(myFunc) ничего не вызывает и не заворачивает в структуру.
Это конверсия типа: та же функция, но теперь у неё есть метод ServeHTTP,
значит, она удовлетворяет Handler. Разница между mux.Handle и
mux.HandleFunc ровно в этом - второй делает конверсию за тебя.
// три эквивалентные записи
mux.Handle("GET /ping", http.HandlerFunc(ping))
mux.HandleFunc("GET /ping", ping)
mux.Handle("GET /ping", pingHandler{}) // структура со своим ServeHTTP
«Handler - интерфейс, HandlerFunc - функциональный тип с методом
ServeHTTP, который просто вызывает себя. Это стандартный приём адаптера в Go:
превратить функцию в реализацию интерфейса без структуры. Тот же паттерн у
expvar.Func и у RoundTripperFunc, который пишут руками.» Из этого же растут middleware: интерфейс Handler
можно обернуть другим Handler.
Когда нужна структура, а не функция? Когда хендлеру нужны зависимости: репозиторий, логгер, конфиг. Подходов два, и оба в ходу:
// структура-хендлер
type PostsHandler struct {
repo Repo
log *slog.Logger
}
func (h PostsHandler) ServeHTTP(
w http.ResponseWriter, r *http.Request) {
// h.repo, h.log доступны
}
// замыкание-конструктор (чаще)
func GetPost(repo Repo) http.HandlerFunc {
return func(w http.ResponseWriter,
r *http.Request) {
// repo захвачен замыканием
}
}
mux.Handle("GET /v1/posts/{id}", GetPost(repo))
Хендлер вызывается конкурентно из множества горутин. Если он пишет в поле своей
структуры, это гонка. Поля должны быть либо read-only после старта, либо
под мьютексом, либо atomic. Классический провал: счётчик h.count++
в хендлере и удивление, почему -race ругается.
Роутинг: ServeMux после Go 1.22 против chi/gin/echo
До Go 1.22 стандартный ServeMux умел стыдно мало: только префиксы и точные пути,
никаких методов и параметров. Поэтому все тащили сторонний роутер. В Go 1.22 мукс
переписали, и теперь паттерн выглядит так:
[МЕТОД ][хост]/путь.
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/posts", listPosts) // только GET (и HEAD автоматически)
mux.HandleFunc("POST /v1/posts", createPosts)
mux.HandleFunc("GET /v1/posts/{id}", getPost) // сегмент-параметр
mux.HandleFunc("GET /v1/files/{path...}", serveFile) // «остаток пути», строго последним
mux.HandleFunc("GET /v1/posts/{$}", exactOnly) // {$} = конец пути, без префиксного матча
mux.HandleFunc("api.example.com/", apiOnly) // матч по хосту
func getPost(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22
pattern := r.Pattern // Go 1.23: "GET /v1/posts/{id}", годится как метка метрики
_ = id
_ = pattern
}
Что новый ServeMux умеет:
- Метод в паттерне;
GETавтоматически покрываетHEAD. - Параметры пути
{id}и хвост{rest...}, значения черезr.PathValue/r.SetPathValue. - Конфликты решаются по специфичности, а не по порядку регистрации:
/v1/posts/{id}проигрывает более конкретному/v1/posts/latest. Если два паттерна пересекаются, а специфичнее ни один, мукс паникует при регистрации, а не затирает молча. - Если путь совпал, а метод - нет, мукс отдаёт
405с заголовкомAllow. Раньше был404, и это сбивало клиента с толку. {id}матчится по распаршенному сегменту:%2Fвнутри сегмента не «расклеит» путь, в отличие от наивных роутеров наstrings.Split.
Чего не умеет и за чем всё ещё идут в chi/gin/echo:
- Группы роутов со своим набором middleware. В
ServeMuxmiddleware либо один на весь сервер, либо навешивается вручную на каждый хендлер. Можно завести отдельныйmuxпод/admin/плюсhttp.StripPrefix- работает, но громоздко. - Ограничений на параметры (
{id:[0-9]+}) нет, валидируй сам. - Опциональные сегменты, необязательный завершающий слэш: мукс сам редиректит
/xна зарегистрированный/x/- и это бывает сюрпризом для клиентов, которые не следуют редиректам на POST. До Go 1.26 это был301, и он был вдвойне опасен: браузер кэширует «навсегда», а на301POST исторически превращается в GET с потерей тела. С Go 1.26 код сменили на307: такой ответ не кэшируется и обязан сохранять метод и тело (проверено на go1.27.0:POST /x→307 Location: /x/). Тот же307теперь приходит и при чистке пути, вроде/a//b→/a/b. - Кастомные
404/405-хендлеры, интроспекция списка роутов, генерация URL по имени роута. - Биндинг и валидация тела, рендеринг, контекст фреймворка (
*gin.Context).
| Критерий | net/http ServeMux (1.22+) | chi | gin | echo |
|---|---|---|---|---|
| Зависимости | ноль, stdlib | ноль сторонних (только stdlib) | несколько (binding, валидатор) | несколько |
Совместимость с http.Handler | полная | полная - весь экосистемный middleware работает | своя сигнатура func(*gin.Context), нужен адаптер | своя func(echo.Context) error |
| Алгоритм | дерево сегментов + правило специфичности | radix trie | radix trie | radix trie |
| Группы + per-group middleware | нет | r.Route/r.Group/r.Use | есть | есть |
| Регэкспы в параметрах | нет | есть | нет | нет |
| Биндинг + валидация тела | сам | сам | c.ShouldBindJSON + теги binding: | c.Bind + валидатор |
| Скорость роутинга | достаточная (сотни нс) | достаточная | очень быстрый, zero-alloc | очень быстрый |
| Когда брать | сервис на 10–40 ручек, хочется меньше магии | средний/большой REST, нужны группы и стандартные интерфейсы | быстро слепить, команда уже на нём | то же, что gin |
Ответ «gin, потому что он быстрый» не годится: на роутинге разница в наносекундах, а один
поход в БД стоит миллисекунды. Выбирают по другим признакам: совместимость с http.Handler (не
придётся переписывать middleware и переезжать), нужны ли группы, сколько магии
готовы терпеть. Хороший ответ звучит так: «Сейчас на новых сервисах беру стандартный
ServeMux - после 1.22 его хватает, ноль зависимостей и никакого своего контекста.
Там, где много групп и версий, беру chi, потому что он остаётся в терминах
http.Handler. С gin работал на легаси-сервисе, главный минус - свой
*gin.Context протекает во все слои.»
http.HandleFunc(...) без mux регистрирует в глобальный
DefaultServeMux. Туда же в init() прописывает свои ручки
net/http/pprof. Достаточно одного import _ "net/http/pprof" в любой
зависимости, и, если сервер поднят с Handler: nil, наружу торчат
/debug/pprof/*: дампы горутин, профайлер (который ещё и тормозит процесс) и
/debug/pprof/cmdline с аргументами запуска. Правило: всегда свой
mux, а pprof выносят на отдельный порт, закрытый от внешнего мира.
Middleware: цепочка «луковицей»
Middleware - это функция func(http.Handler) http.Handler: берёт хендлер, возвращает
хендлер, который что-то делает до, вызывает next.ServeHTTP и что-то делает после.
Цепочка собирается вложенными вызовами, поэтому структура выходит концентрической.
type Middleware func(http.Handler) http.Handler
func Chain(mws ...Middleware) Middleware {
return func(final http.Handler) http.Handler {
// идём с конца, чтобы mws[0] оказался самым внешним
for i := len(mws) - 1; i >= 0; i-- {
final = mws[i](final)
}
return final
}
}
stack := Chain(RequestID, Logging(log), Recover(log), Auth(keys), RateLimit(lim))
srv.Handler = stack(mux)
// эквивалентно: RequestID(Logging(Recover(Auth(RateLimit(mux)))))
next.ServeHTTP идёт «внутрь», код после - «наружу».Логирующий middleware: перехват статуса и латенси
Сложнее всего со статусом ответа. http.ResponseWriter его не отдаёт: узнать,
что хендлер записал 404, можно только перехватив вызов WriteHeader.
Значит, нужна обёртка со встроенным ResponseWriter.
type statusWriter struct {
http.ResponseWriter // встраивание: остальные методы наследуются
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status != 0 {
return // защита от повторного WriteHeader (иначе superfluous WriteHeader в логах)
}
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK // хендлер писал тело без явного WriteHeader
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// без него http.ResponseController не развернёт обёртку (Go 1.20+)
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func Logging(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
if sw.status == 0 {
sw.status = http.StatusOK // хендлер не написал вообще ничего
}
log.LogAttrs(r.Context(), slog.LevelInfo, "http_request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("route", r.Pattern), // Go 1.23: низкая кардинальность
slog.Int("status", sw.status),
slog.Int("bytes", sw.bytes),
slog.Duration("latency", time.Since(start)),
slog.String("request_id", RequestIDFrom(r.Context())),
slog.String("ip", clientIP(r)),
)
})
}
}
В интерфейсе http.ResponseWriter три метода, но реальный
*http.response дополнительно реализует http.Flusher (SSE),
http.Hijacker (websocket-апгрейд), io.ReaderFrom (быстрая отдача файла
через sendfile) и http.CloseNotifier. У твоей обёртки уже другой
тип, и w.(http.Hijacker) внутри библиотеки websocket вернёт ok == false.
Классический баг: «после добавления логирующего middleware сломался websocket».
Раньше обходились костылём: руками пробрасывали все интерфейсы и получали комбинаторный взрыв
из 2N вариантов: для четырёх интерфейсов их уже 16, а httpsnoop генерирует десятки. Сейчас на обёртку добавляют метод
Unwrap() http.ResponseWriter, а на стороне потребителя берут
http.NewResponseController(w) (Go 1.20): контроллер сам
разворачивает цепочку обёрток и находит настоящий writer.
// вместо w.(http.Flusher) и w.(http.Hijacker):
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil { /* не поддерживается */ }
_ = rc.SetWriteDeadline(time.Now().Add(30 * time.Second)) // продлить WriteTimeout для SSE
conn, buf, err := rc.Hijack()
Recovery middleware
func Recover(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w}
defer func() {
rec := recover()
if rec == nil {
return
}
// штатный способ оборвать ответ: пробрасываем дальше, сервер его ждёт
if rec == http.ErrAbortHandler {
panic(rec)
}
log.Error("panic in handler",
"err", rec,
"path", r.URL.Path,
"request_id", RequestIDFrom(r.Context()),
"stack", string(debug.Stack()))
// заголовки уже ушли: 500 не написать, рвём соединение
if sw.status != 0 {
panic(http.ErrAbortHandler)
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w,
`{"type":"about:blank","title":"internal error","status":500}`)
}()
next.ServeHTTP(sw, r)
})
}
}
recover()работает только внутриdeferтой же горутины. Паника в горутине, которую хендлер запустил сам (go doWork()), убьёт весь процесс - middleware её не поймает. Поэтому в каждой новой горутине нужен свойdefer recover.- Сам
net/httpтоже ловит панику вconn.serve, пишет стек вErrorLogи закрывает соединение, так что процесс не падает. Но клиент получит оборванное соединение, а не500, и в метриках это будет выглядеть как сетевая ошибка. Свой recovery нужен ради корректного ответа и своего лога. - Не отдавать наружу стек или текст паники: это утечка внутренностей
(пути, имена таблиц, версии). Клиенту уходит обезличенный
500сrequest_id, всё остальное идёт в лог.
Request ID через контекст
type ctxKey int // неэкспортируемый тип: чужой пакет не подменит
const requestIDKey ctxKey = iota
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" || len(id) > 64 || !isSafeID(id) {
id = uuid.New().String() // не доверяем чужому значению вслепую
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-Id", id) // вернуть клиенту для саппорта
next.ServeHTTP(w, r.WithContext(ctx)) // r.WithContext делает shallow copy
})
}
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string) // type assertion с ok не паникует
return id
}
uuid переехал в стандартную библиотеку
Тот uuid в примере годами означал github.com/google/uuid - модуль,
который тянули почти в каждый сервис. С Go 1.27 пакет uuid есть в
стандартной библиотеке (RFC 9562), и внешняя зависимость для этого больше не нужна.
import "uuid"
u := uuid.New() // случайный, синоним uuid.NewV4()
u = uuid.NewV7() // сортируемый по времени: первые 48 бит = миллисекунды Unix
s := u.String() // "01a041dd-3cde-794b-a425-da2cd9f4379f"
v, err := uuid.Parse(s)
_ = uuid.MustParse(s) // паникует вместо ошибки: только для констант в коде
_ = uuid.Nil() // 00000000-…-000000000000
_ = uuid.Max() // ffffffff-…-ffffffffffff
Два практических следствия. Первое: тип объявлен как UUID [16]byte, обычный массив. Он
сравним через ==, годится ключом map и не аллоцирует; в fmt и в
JSON сам превращается в привычную строку с дефисами (String() и
MarshalText), а database/sql знает про него напрямую - и как
параметр запроса, и как приёмник в Scan, без всяких driver.Valuer у
прикладного кода (проверено на go1.27.0). Второе: отдельной функции NewString()
здесь нет - пишут uuid.New().String(), и такой код одинаково работает и со
старым модулем. А для первичных ключей бери NewV7(): случайный v4 разбрасывает
вставки по всему B-дереву индекса, а v7 монотонно растёт и ложится в хвост.
- Ключом делают неэкспортируемый тип (
type ctxKey struct{}илиtype ctxKey int), никогда не строку: строковый ключ может совпасть с чужим. - В контекст кладут только request-scoped метаданные: request id, trace id, user id, локаль. Зависимости (репозиторий, логгер, конфиг) не кладут, их передают явными аргументами или полями структуры. «Контекст как мешок для DI» на код-ревью считают красным флагом.
- Значение ищется за
O(n)по цепочке родителей, но n мал. Проблема не в скорости, а в неявности: компилятор не проверит, что значение положили. - Всегда
value, ok := ctx.Value(k).(T)и разумный фолбэк, а не.(T)безok.
Таймауты сервера: четыре штуки плюс TimeoutHandler
Нулевые таймауты в http.Server{} означают «без ограничений». Это дефолт
http.ListenAndServe, и он небезопасен: одно медленное или злонамеренное
соединение может держаться вечно, занимая горутину, дескриптор и буферы. Каждый таймаут
режет свою фазу.
WriteTimeout начинает тикать не с начала
записи, а сразу после чтения заголовков, так что покрывает ещё и чтение тела, и работу
хендлера.| Поле | Что именно ограничивает | С какого момента тикает | Что будет при срабатывании | Разумное значение |
|---|---|---|---|---|
ReadHeaderTimeout | чтение стартовой строки и всех заголовков | с начала чтения запроса | соединение закрывается, хендлер не вызывался | 3–10 с |
ReadTimeout | чтение всего запроса: заголовки + тело | с начала чтения запроса | r.Body.Read вернёт ошибку, соединение закроется | 10–30 с (больше для загрузок) |
WriteTimeout | чтение тела + работа хендлера + запись ответа | сразу после чтения заголовков | w.Write вернёт ошибку, соединение рвётся | ReadTimeout + бюджет хендлера |
IdleTimeout | простой между запросами на keep-alive-соединении | после отправки ответа | соединение закрывается | 60–120 с |
MaxHeaderBytes | суммарный размер заголовков (дефолт 1 МиБ) | - | 431 Request Header Fields Too Large | оставить дефолт или 64 КиБ |
MaxHeaderValueCount Go 1.27 | суммарное количество значений заголовков (дефолт DefaultMaxHeaderValueCount = 500) | - | 431 Request Header Fields Too Large | оставить дефолт |
http.TimeoutHandler | только время работы хендлера | с вызова хендлера | клиенту корректный 503 с телом, соединение живо | под SLA ручки |
srv := &http.Server{
Addr: ":8080",
Handler: stack(mux),
ReadHeaderTimeout: 5 * time.Second, // главный щит от slowloris
ReadTimeout: 15 * time.Second,
WriteTimeout: 20 * time.Second, // > ReadTimeout, иначе долгое тело съест бюджет ответа
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
MaxHeaderValueCount: 500, // Go 1.27: потолок числа значений; дефолт тот же 500
ErrorLog: slog.NewLogLogger(handler, slog.LevelError), // вместо дефолтного stderr
BaseContext: func(net.Listener) context.Context { return rootCtx },
}
WriteTimeoutне отменяет хендлер. Он ставит дедлайн на сокет: горутина хендлера молотит дальше, запрос к БД выполняется - просто ответ уже некому отдать. Отменяет работу толькоr.Context(), который закрывается, когда клиент отвалился, илиhttp.TimeoutHandler, который отменяет контекст запроса.WriteTimeoutломает SSE, long polling и большие выгрузки. Для таких ручек поднимают отдельный сервер безWriteTimeoutили зовутhttp.NewResponseController(w).SetWriteDeadline(...)прямо из хендлера.IdleTimeout == 0означает «использоватьReadTimeout», а если и он ноль, простой не ограничен вовсе. Поэтому нельзя выставить толькоReadHeaderTimeoutи считать, что защитился.
Таймауты ограничивают одно соединение, но не их количество. Нужны ещё:
netutil.LimitListener(l, N) (жёсткий потолок одновременных соединений),
Server.ConnState для метрик по фазам (new/active/idle/hijacked/closed),
Server.MaxHeaderBytes и Server.MaxHeaderValueCount,
http.MaxBytesReader на тело,
Server.ConnContext для проброса данных о соединении, а для HTTP/2 ещё и
http2.Server{MaxConcurrentStreams}, потому что там тысяча стримов может
прилететь в одном TCP-соединении (та самая атака Rapid Reset, CVE-2023-44487; в Go её
чинили в 1.21.3/1.20.10). В Go 1.24 у Server и Transport появились
поля Protocols/HTTP2, и настраивать HTTP/2 или включать его
без TLS теперь можно без костылей с golang.org/x/net/http2.
MaxHeaderValueCount и дыра, которую не закрывал MaxHeaderBytes
При заголовочном флуде клиент шлёт не один огромный заголовок, а
очень много маленьких: десятки тысяч строк вида X-A: 1. По байтам такой
запрос спокойно влезает в MaxHeaderBytes, зато в Request.Header
получается map с десятками тысяч ключей: аллокации, память и время разбора растут, хотя
формально лимит не превышен. В HTTP/2 тот же приём усиливается сжатием HPACK - несколько
килобайт на проводе разворачиваются в мегабайты заголовков.
В Go 1.27 у Server появилось поле MaxHeaderValueCount и
константа DefaultMaxHeaderValueCount = 500, которая применяется, если поле не
задано. Считаются именно значения: перечисленные через запятую в одной строке идут за
одно, а у заголовка, повторённого в нескольких строках, считается каждая строка. Превышение даёт
431 Request Header Fields Too Large (проверено запуском на go1.27.0).
Обновление на 1.27 включает эту защиту само, но если у тебя есть законный
клиент, который шлёт сотни повторяющихся заголовков, лимит придётся поднять руками.
Клиент: таймауты, транспорт и пул соединений
За http.Get(url) и http.DefaultClient стоит клиент с
Timeout: 0, то есть без таймаута вообще. Если удалённый сервис принял
соединение и замолчал, твоя горутина повиснет навсегда. Дальше сценарий известный: горутины
копятся, каждая держит соединение и память, метрики показывают рост
go_goroutines, через час - OOM. Это самый частый ответ на «падал ли у тебя прод?».
var transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second, // установка TCP-соединения
KeepAlive: 30 * time.Second, // TCP keepalive-пробы
}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second, // от конца запроса до первого байта заголовков
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 256, // всего idle-соединений в пуле
MaxIdleConnsPerHost: 128, // дефолт 2, почти всегда узкое место
MaxConnsPerHost: 256, // потолок активных, 0 = без лимита
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
}
var client = &http.Client{
Transport: transport,
Timeout: 10 * time.Second, // общий потолок: dial + TLS + запрос + ответ + чтение тела
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 3 { return http.ErrUseLastResponse }
return nil
},
}
Client.Timeout задаёт один общий дедлайн на всю операцию, включая чтение тела
ответа. Он работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленный
io.Copy из resp.Body. Таймауты Transport работают пофазно:
быстро отсекают «сервис недоступен» (dial 3 с) и не ограничивают законно долгую
выгрузку. Правильная схема: пофазные таймауты в транспорте + общий потолок в клиенте +
http.NewRequestWithContext(ctx, ...) с дедлайном из контекста запроса. Контекст
сильнее: при его отмене запрос обрывается, даже если Timeout ещё не вышел.
Пул соединений живёт в Transport, а не в Client. Отсюда
правило: один Transport (и обычно один Client) на всё
приложение, его создают один раз при старте. Новый http.Transport{}
на каждый запрос даёт классическую утечку: у каждого свой пул (голый http.Client{}
без Transport берёт общий DefaultTransport и не течёт), соединения не переиспользуются,
порты уходят в TIME_WAIT, при высоком RPS кончаются эфемерные порты
(cannot assign requested address).
MaxIdleConnsPerHost по умолчанию 2. Если сервис делает 500 RPS в один
апстрим, то из пула переиспользуются два соединения, а остальные создаются и закрываются
заново - на каждый запрос новый TCP-хендшейк и полный TLS-хендшейк. Симптом:
латенси p99 выше, чем объясняет апстрим, и куча соединений в TIME_WAIT в
ss -s. Лечится одной строкой.
// тело надо дочитать и закрыть, иначе соединение не вернётся в пул
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
// дочитываем остаток (с лимитом, чтобы злой сервер не залил нас гигабайтом)
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upstream: %s", resp.Status) // тело всё равно дочитается в defer
}
Недостаточно. Close() на недочитанном теле заставляет транспорт закрыть
соединение, а не вернуть его в пул: остаток тела всё ещё в сокете, и переиспользовать такой
сокет нельзя. Особенно больно на ветках ошибок, где тело обычно игнорируют: пул
«худеет» ровно во время инцидента, когда апстрим сыплет пятисотками. Поэтому сначала
дочитать в io.Discard (с LimitReader), потом закрыть. Второй частый промах:
defer resp.Body.Close() до проверки err != nil. При ошибке
resp равен nil, и получаешь панику вместо понятного лога.
Что изменилось в Go 1.27. HTTP/1-транспорт теперь при Close() сам пробует
дочитать недочитанный остаток и всё-таки вернуть соединение в пул, но при двух жёстких
условиях: тело не длиннее 256 КиБ (или его длина неизвестна) и остаток успевает вычитаться за
50 мс. То есть маленькие тела ошибок (типичный problem+json) с 1.27 прощаются
сами, а большая или медленная выгрузка соединения по-прежнему стоит. Явный
io.Copy(io.Discard, io.LimitReader(...)) остаётся правильной привычкой: он
работает одинаково на всех версиях Go и не зависит от того, знал ли апстрим длину заранее.
httptest.NewTestServer
И сервер, и клиента из предыдущих разделов надо чем-то тестировать, и проще всего для этого
поднять фейковый сервер. Обычный httptest.NewServer открывает при этом
настоящий сокет на 127.0.0.1 со случайным портом. Чаще всего это незаметно,
но в CI даёт знакомые неприятности: тесты конкурируют за порты, песочница может вообще не
разрешать listen, а после Close() остаётся TIME_WAIT.
// Go 1.27: сервер живёт на фейковой сети в памяти, сокета и порта нет вовсе
ts := httptest.NewTestServer(t, mux) // t.Cleanup(ts.Close) регистрируется сам
client := ts.Client() // ходить надо именно этим клиентом
resp, err := client.Get(ts.URL + "/v1/orders/1")
// ts.URL == "http://example.com": имя синтетическое, DNS и порт не участвуют
// https://example.com/... тоже работает: TLS поднят на той же фейковой сети
Соединения идут через подменённый DialContext транспорта,
поэтому сторонний клиент со своим транспортом до такого сервера не достучится. Тестируй
либо через ts.Client(), либо прокидывай его Transport в свой код.
Взамен получаешь тест без портов, без гонок за них и без сетевых таймаутов, а паника в хендлере
прилетает в вывод теста, а не в stderr. Проверено на go1.27.0.
Graceful shutdown
Задача: получив SIGTERM (его шлёт Kubernetes при выкатке), перестать принимать
новые запросы, дать доработать текущим, закрыть внешние ресурсы и выйти с нулевым кодом.
Успеть надо за terminationGracePeriodSeconds, иначе прилетит
SIGKILL.
func main() {
// 1. ловим сигналы: ctx закроется по SIGINT/SIGTERM
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{ /* ... таймауты ... */ }
errCh := make(chan error, 1)
go func() {
// ErrServerClosed: штатный возврат после Shutdown, не ошибка
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
log.Error("сервер не поднялся", "err", err)
os.Exit(1)
case <-ctx.Done():
// Go 1.26: NotifyContext отменяет контекст через CancelCauseFunc,
// поэтому причина отмены прямо называет пришедший сигнал.
// SIGTERM -> "terminated signal received"
// SIGINT -> "interrupt signal received"
// stop() -> context.Canceled
log.Info("начинаем graceful shutdown", "cause", context.Cause(ctx))
}
stop() // вернуть дефолтное поведение: второй Ctrl+C убьёт процесс немедленно
// 2. readiness начинает отдавать 503, но запросы ещё принимаем
ready.Store(false)
time.Sleep(5 * time.Second) // дать балансировщику убрать нас из пула
// 3. перестать принимать новые, дождаться активных
shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
log.Error("не успели за отведённое время, рвём соединения", "err", err)
_ = srv.Close() // жёстко
}
// 4. только теперь закрываем ресурсы: до этого ими пользуются хендлеры
consumer.Stop() // остановить читателей Kafka, закоммитить оффсеты
_ = db.Close() // закрыть пул соединений с БД
_ = tracerProvider.Shutdown(context.Background()) // выплюнуть накопленные спаны
if f, ok := log.Handler().(interface{ Flush() error }); ok {
_ = f.Flush() // у JSONHandler и TextHandler Flush нет
}
}
До 1.26 signal.NotifyContext просто отменял контекст, и в логе оставалось
безликое context canceled: пришёл SIGTERM от Kubernetes при штатной
выкатке или человек нажал Ctrl+C - по логам не различить. С Go 1.26
внутри работает context.CancelCauseFunc - та же отмена, но с прикреплённой
причиной: ошибкой, которую потом достают через context.Cause(ctx).
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
<-ctx.Done()
ctx.Err() // context.Canceled, как раньше, ничего не говорит
context.Cause(ctx) // "terminated signal received", а вот это уже SIGTERM
errors.Is(context.Cause(ctx), context.Canceled) // true: старые проверки не ломаются
Зачем это на практике. Во-первых, логи выкатки: строка «остановлены по SIGTERM»
отличает штатный роллинг-апдейт от Ctrl+C на стенде и от
SIGHUP при потере терминала. Во-вторых, поведение можно развести:
по SIGTERM отработать полный цикл с preStop-паузой, а по
SIGINT от разработчика гаситься быстро. Причина приходит строкой
(os.Signal из неё не достать), так что для типизированного сигнала
по-прежнему нужен собственный signal.Notify в канал. Проверено на go1.27.0.
connection refused
вместо мягкой деградации.- Сказать миру, что мы уходим: readiness-проба отдаёт
503, ждём 5–10 секунд (илиpreStop: sleepв манифесте), пока балансировщик и service mesh перестанут слать трафик. - Перестать принимать новое:
Shutdownзакрывает listener и все idle keep-alive-соединения. - Дождаться текущего:
Shutdownблокируется, пока активные запросы не завершатся, и добавляет в их ответыConnection: close. - Только потом закрывать ресурсы: БД, брокер, кэш, экспортёры трейсов и метрик.
Если закрыть
dbдоShutdown, доигрывающие хендлеры получатsql: database is closedи отдадут500. - Таймаут
Shutdownдолжен быть меньшеterminationGracePeriodSeconds(обычно 30 с), иначеSIGKILLприлетит посреди уборки.
Тело запроса: Decoder против Unmarshal, лимиты, стриминг
// json.Unmarshal
b, err := io.ReadAll(r.Body) // всё в память
if err != nil { ... }
err = json.Unmarshal(b, &in)
// + можно перечитать/залогировать сырое тело
// + понятные ошибки с позицией
// - память = размер тела
// - без MaxBytesReader это DoS
// json.Decoder
dec := json.NewDecoder(r.Body) // стрим
dec.DisallowUnknownFields()
err := dec.Decode(&in)
// + не держит тело в памяти целиком
// + умеет поток JSON-объектов (dec.More)
// + UseNumber(), Token() для больших данных
// - сырое тело уже не залогируешь
В обычной API-ручке Decoder ставят поверх MaxBytesReader.
Потоковую загрузку (NDJSON, батчи) тот же Decoder читает в цикле по
dec.More(), и память не растёт с размером входа.
func createPost(w http.ResponseWriter, r *http.Request) {
// 1. жёсткий потолок на тело; w нужен, чтобы сервер закрыл соединение корректно
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 МиБ
// 2. на публичных API стоит проверить и Content-Type
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
writeProblem(w, http.StatusUnsupportedMediaType, "нужен application/json")
return
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // поле с опечаткой даст 400, а не пропадёт молча
var in createPostReq
if err := dec.Decode(&in); err != nil {
var maxErr *http.MaxBytesError // Go 1.19+
var synErr *json.SyntaxError
var typErr *json.UnmarshalTypeError
switch {
case errors.As(err, &maxErr):
writeProblem(w, http.StatusRequestEntityTooLarge, "тело больше 1 МиБ")
case errors.As(err, &synErr):
writeProblem(w, http.StatusBadRequest,
fmt.Sprintf("битый JSON на позиции %d", synErr.Offset))
case errors.As(err, &typErr):
writeProblem(w, http.StatusBadRequest,
fmt.Sprintf("поле %q ожидает тип %s", typErr.Field, typErr.Type))
case errors.Is(err, io.EOF):
writeProblem(w, http.StatusBadRequest, "пустое тело")
default:
writeProblem(w, http.StatusBadRequest, "не удалось разобрать тело")
}
return
}
// 3. в теле ровно один объект, иначе {"a":1}{"b":2} проедет молча
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeProblem(w, http.StatusBadRequest, "ожидается ровно один JSON-объект")
return
}
if err := in.Validate(); err != nil { /* 422 */ }
}
errors.AsType[T] вместо трёх переменных заранее
Схема «объяви переменную нужного типа, потом errors.As(err, &v)» появилась
не от хорошей жизни: когда писали errors.As, дженериков в Go ещё не было.
С Go 1.26 есть дженерик-версия: тип передаётся параметром, переменная объявляется прямо
в условии, а промах, на котором errors.As паникует в рантайме (цель не указатель),
с AsType просто не скомпилируется.
// Go 1.26: тип в параметре, переменная прямо в условии
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
writeProblem(w, http.StatusRequestEntityTooLarge, "тело больше 1 МиБ")
} else if se, ok := errors.AsType[*json.SyntaxError](err); ok {
writeProblem(w, http.StatusBadRequest, fmt.Sprintf("битый JSON на позиции %d", se.Offset))
} else if te, ok := errors.AsType[*json.UnmarshalTypeError](err); ok {
writeProblem(w, http.StatusBadRequest, fmt.Sprintf("поле %q ожидает тип %s", te.Field, te.Type))
}
Старый errors.As никуда не делся и в switch из примера выше
по-прежнему удобнее: там переменные объявлены один раз, а case-ветки читаются
в столбик. AsType выигрывает там, где проверка одна-две и заводить переменную
заранее не хочется. Проверено на go1.27.0.
json.Unmarshal в структуру не различает отсутствующее поле и нулевое значение:
{"title":""} и {} дадут одинаковый in.Title == "".
А PATCH должен знать, чего от него хотят: «не трогать поле» или «затереть в пустоту».
Лечится это указателями (*string: nil = не пришло), обёрткой
type Optional[T any] struct{ Value T; Set bool } со своим
UnmarshalJSON или разбором в map[string]json.RawMessage с проверкой
ключа. И не путай DisallowUnknownFields с required:
обязательность полей encoding/json не проверяет вообще, это работа
валидатора.
Загрузка файлов и multipart
Браузерная форма с файлом шлёт Content-Type: multipart/form-data; boundary=....
Тело состоит из частей, разделённых boundary, и у каждой свои заголовки
(Content-Disposition, Content-Type). В Go его разбирают двумя способами.
// Вариант A: ParseMultipartForm - просто, для небольших файлов
func upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<20) // общий потолок 32 МиБ
// аргумент задаёт порог памяти; остальное уйдёт во временные файлы в os.TempDir
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeProblem(w, http.StatusBadRequest, "битая форма")
return
}
defer func() { _ = r.MultipartForm.RemoveAll() }() // сервер чистит только форму исходного r, не копии
file, hdr, err := r.FormFile("avatar")
if err != nil { /* 400 */ }
defer file.Close()
// hdr.Filename и hdr.Header.Get("Content-Type") прислал клиент, им не доверяем
name := filepath.Base(hdr.Filename) // с Go 1.17 путь отрезан и так, это страховка
if hdr.Size > 5<<20 { /* 413 */ }
// определяем реальный тип по содержимому, а не по расширению
head := make([]byte, 512)
n, _ := io.ReadFull(file, head)
mime := http.DetectContentType(head[:n])
if mime != "image/jpeg" && mime != "image/png" { /* 415 */ }
if _, err := file.Seek(0, io.SeekStart); err != nil { /* 500 */ }
dst, err := os.CreateTemp(uploadDir, "up-*") // имя генерируем сами
if err != nil { /* 500 */ }
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil { /* 500 */ }
_ = name
}
// Вариант B: MultipartReader - потоково, без буферизации, годится для гигабайтов
func uploadStream(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 256<<20)
mr, err := r.MultipartReader() // до этого нельзя вызывать r.FormValue/r.FormFile: съедят тело
if err != nil {
writeProblem(w, http.StatusUnsupportedMediaType, "ожидается multipart/form-data")
return
}
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil { /* 400 */ return }
switch part.FormName() {
case "meta":
var meta Meta
if err := json.NewDecoder(io.LimitReader(part, 64<<10)).Decode(&meta); err != nil {
/* 400 */ return
}
case "file":
// сразу в S3/minio, минуя диск и память
// не LimitReader: тот молча обрежет файл, и в хранилище уйдёт обрубок
_, err = uploader.Upload(r.Context(), key, http.MaxBytesReader(w, part, 200<<20))
if err != nil { /* 500 */ return }
}
_ = part.Close()
}
}
MaxBytesReaderна всё тело и лимит на каждую часть. Для файла такой, что вернёт ошибку:io.LimitReaderмолча обрежет.- Имя файла от клиента пропускай только через
filepath.Base, а лучше не используй вовсе: генерируйuuid, оригинальное имя храни в метаданных. - Тип определяй по
http.DetectContentType(первые 512 байт), а не по расширению и не поContent-Typeчасти. Разрешённые типы задаёт whitelist, не blacklist. - Отдавай загруженное с
Content-Typeиз whitelist,X-Content-Type-Options: nosniffиContent-Disposition: attachment- и с отдельного домена, чтобы залитый HTML не выполнился в origin приложения. - Никогда не клади загруженные файлы в директорию, откуда сервер исполняет код.
- Большие файлы пусть идут по presigned URL прямо в объектное хранилище: бэкенд не должен проксировать гигабайты.
- Вызывай
RemoveAll()сам. Сервер чистит только форму исходного запроса, а за middleware сr.WithContextхендлер работает с копией, и/tmpпостепенно забивается.
Вопросы
8Accept в одной горутине, на каждое принятое
соединение запускается отдельная горутина conn.serve, которая в цикле читает
запросы и вызывает Handler.ServeHTTP. Handler - интерфейс с одним
методом, HandlerFunc - функциональный тип с методом ServeHTTP,
вызывающим сам себя.Пошагово
ListenAndServe→net.Listen("tcp", addr)→srv.Serve(l).- В цикле
l.Accept(). Временные ошибки не роняют сервер: ретрай с экспоненциальной паузой от 5 мс до 1 с. c := srv.newConn(rw);go c.serve(connCtx)запускает горутину на соединение. Пула воркеров нет, лимита на число соединений тоже.- Горутина делает TLS-handshake (если нужно) и входит в цикл
c.readRequest(): разбирает стартовую строку и заголовки. Тело не читается,r.Bodyостаётся ленивым ридером поверх сокета. serverHandler{srv}.ServeHTTP(w, req); еслиsrv.Handler == nil, подставляетсяhttp.DefaultServeMux. Роутер выбирает хендлер.- Когда хендлер вернулся,
finishRequestсбрасывает буфер и дочитывает остаток тела (иначе соединение не переиспользовать). - Keep-alive →
continue, та же горутина обслуживает следующий запрос. Иначеc.close().
Handler и HandlerFunc
type Handler interface { ServeHTTP(ResponseWriter, *Request) }
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }
В http.HandlerFunc(fn) нет обёртки в структуру, только конверсия типа:
функция та же, но теперь с методом, и она удовлетворяет интерфейсу. Классический приём
«функция как реализация интерфейса». На интерфейсе Handler напрямую держатся
middleware: func(http.Handler) http.Handler.
Чем добить ответ
- Модель «горутина на соединение» работает потому, что горутина стоит от 2 КиБ стека, а блокирующие сетевые вызовы уходят в netpoller (epoll/kqueue): рантайм паркует горутину и освобождает поток ОС. Код пишешь блокирующий, а ведёт он себя как асинхронный.
- Соединение обходится в горутину (стек быстро дорастает до 8 КиБ) и два буфера по 4 КиБ,
на чтение и на запись, всего около 20 КиБ. На 50 000 idle-соединений набегает около гигабайта, поэтому
IdleTimeoutи нужен. - Хендлер вызывается конкурентно, так что любое разделяемое изменяемое состояние в нём даёт гонку.
- Ограничить число соединений сам
net/httpне умеет, для этого естьnetutil.LimitListener(l, N). - При HTTP/2 схема другая: одно TCP-соединение, много стримов, и горутина создаётся на стрим, а не на соединение.
На фразе «горутина на запрос». При keep-alive одна горутина
обслуживает десятки запросов подряд, поэтому ReadHeaderTimeout и
IdleTimeout и нужны: без них соединение (а с ним и горутина) висит бесконечно.
ServeMux умеет метод в паттерне,
{id}, {rest...}, разрешение конфликтов по специфичности и корректный
405 - для типового REST-сервиса этого хватает. Внешний роутер берут ради групп
с per-group middleware, регэкспов в параметрах и биндинга.Что дал Go 1.22
mux.HandleFunc("GET /v1/posts/{id}", getPost)
mux.HandleFunc("GET /v1/files/{path...}", files) // хвост пути
mux.HandleFunc("GET /v1/posts/{$}", exact) // без префиксного матча
id := r.PathValue("id")
route := r.Pattern // Go 1.23: готовая метка для метрик
- Конфликты решаются по специфичности, а не по порядку регистрации. Пересекающиеся паттерны, из которых ни один не специфичнее, вызывают панику при регистрации - баг всплывает на старте, а не в проде.
- Совпал путь, не совпал метод →
405+ заголовокAllow(раньше был404). {id}берётся из распаршенного сегмента, поэтому%2Fне «расклеит» путь.
Чего нет
- Групп со своим набором middleware. Остаётся общий стек на сервер, обёртка на каждый
хендлер или вложенный
ServeMux, обёрнутый в свой middleware. - Ограничений на параметры (
{id:[0-9]+}), кастомных404/405, интроспекции роутов, генерации URL. - Биндинга и валидации тела.
- Есть неочевидность: мукс сам редиректит
/xна/x/и чистит путь, а POST-клиенты, которые по редиректам не ходят, получают сюрприз. Тут важна версия: до Go 1.26 отдавался301(браузер кэширует его надолго, POST по дороге вырождается в GET), с Go 1.26 отдаётся307, который не кэшируется и сохраняет метод с телом.
Как звучит хороший ответ
«На новых сервисах беру стандартный ServeMux: после 1.22 функциональности хватает,
ноль зависимостей, никакого своего Context, всё в терминах
http.Handler. Где много версий и групп с разными middleware, ставлю chi: он тоже
держится стандартных интерфейсов, и миграция туда-обратно почти бесплатная. На легаси
работал с gin. Главный минус: *gin.Context протекает в сервисный слой и
привязывает код к фреймворку, а выигрыш в скорости роутинга (наносекунды) тонет в
походе в БД (миллисекунды).»
«Чем опасен http.DefaultServeMux?» Тем, что
net/http/pprof регистрирует в нём свои ручки в init(). Хватит одного
import _ "net/http/pprof" в транзитивной зависимости и
Handler: nil, чтобы наружу торчали /debug/pprof/*. Всегда заводи свой
mux, а pprof вешай на отдельный внутренний порт.
func(http.Handler) http.Handler;
цепочка собирается вложенными вызовами и работает «луковицей»: первый в списке - самый внешний.
Статус ответа перехватывается обёрткой над ResponseWriter, request ID
прокидывается через context.WithValue с неэкспортируемым ключом.Цепочка
type Middleware func(http.Handler) http.Handler
func Chain(mws ...Middleware) Middleware {
return func(final http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- { // с конца: mws[0] станет внешним
final = mws[i](final)
}
return final
}
}
srv.Handler = Chain(RequestID, Logging(log), Recover(log), Auth)(mux)
Код до next.ServeHTTP выполняется на пути внутрь в порядке объявления,
а код после отрабатывает на пути наружу, в обратном порядке. Отсюда правила:
RequestID ставят самым внешним (id нужен всем, включая лог паники),
Recover кладут внутрь Logging (иначе паника не попадёт в
access-лог со статусом), а Auth и RateLimit пускают до тяжёлой работы.
Логирующий middleware
type statusWriter struct {
http.ResponseWriter
status, bytes int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status != 0 { return } // гасим повторный WriteHeader
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 { w.status = http.StatusOK }
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func Logging(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
if sw.status == 0 { sw.status = http.StatusOK }
log.Info("http",
"method", r.Method, "path", r.URL.Path, "route", r.Pattern,
"status", sw.status, "bytes", sw.bytes,
"latency_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFrom(r.Context()))
})
}
}
Обёртка над ResponseWriter «съедает» опциональные интерфейсы:
http.Flusher (SSE), http.Hijacker (websocket),
io.ReaderFrom (sendfile). После такого middleware
w.(http.Hijacker) вернёт ok == false и websocket сломается.
Правильный ответ: дать обёртке метод Unwrap() http.ResponseWriter и
работать через http.NewResponseController(w) (Go 1.20), который сам разворачивает
цепочку обёрток. Старый способ: руками пробрасывать все комбинации интерфейсов,
как делает httpsnoop.
Recovery
func Recover(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil { return }
if rec == http.ErrAbortHandler { panic(rec) } // штатный обрыв, не глотаем
log.Error("panic", "err", rec, "stack", string(debug.Stack()),
"request_id", RequestIDFrom(r.Context()))
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"title":"internal error","status":500}`)
}()
next.ServeHTTP(w, r)
})
}
}
recover()работает только в своей горутине: паника вgo doWork(), запущенной хендлером, уронит процесс. Каждой новой горутине нужен свойdefer recover.- Сам
net/httpтоже ловит панику вconn.serve, но клиент получает оборванное соединение, а не500. Свой recovery нужен ради корректного ответа, метрики и лога. - Если заголовки уже ушли,
WriteHeader(500)ничего не даст - останется только оборвать ответ (для этого и естьpanic(http.ErrAbortHandler)). - Наружу уходит обезличенный
500сrequest_id, стек только в лог.
Request ID
type ctxKey int
const requestIDKey ctxKey = iota // неэкспортируемый тип: чужой пакет не подменит ключ
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" || len(id) > 64 { id = uuid.New().String() } // Go 1.27: uuid уже в stdlib
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
})
}
func RequestIDFrom(ctx context.Context) string { s, _ := ctx.Value(requestIDKey).(string); return s }
Дальше id уезжает в логи всех слоёв (log.With("request_id", ...) в контексте) и
заголовком в исходящие запросы - так и собирается сквозная трассировка. В проде вместо
самописного id обычно берут traceparent из W3C Trace Context и OpenTelemetry,
механика та же.
Ключом делают неэкспортируемый тип, а не строку. В контекст кладут только
request-scoped метаданные (request id, trace id, user id, локаль), зависимостям там не место:
репозиторий и логгер передаются явно. Читают всегда через
v, ok := ctx.Value(k).(T).
| Таймаут | Что ограничивает | С какого момента |
|---|---|---|
ReadHeaderTimeout | чтение стартовой строки и заголовков | с начала чтения запроса |
ReadTimeout | чтение всего запроса: заголовки + тело | с начала чтения запроса |
WriteTimeout | чтение тела + работа хендлера + запись ответа | сразу после чтения заголовков |
IdleTimeout | простой между запросами на keep-alive | после отправки ответа |
http.TimeoutHandler | только время работы хендлера | с вызова хендлера |
Slowloris
Атакуют ресурсы, а не полосу. Клиент открывает тысячи соединений и в каждом
шлёт по одному байту заголовка раз в несколько секунд, никогда не завершая блок
заголовков пустой строкой. Запрос формально ещё не пришёл - хендлер не вызывался, логов нет,
трафик мизерный. Но каждое такое соединение держит горутину, файловый дескриптор и буферы.
Сервер перестаёт принимать легитимных клиентов и «умирает» без единого обработанного запроса.
Та же идея для тела зовётся slow POST / R-U-Dead-Yet: клиент заявляет
Content-Length: 10000000 и шлёт по байту в секунду.
Лечат это ReadHeaderTimeout (от slowloris), ReadTimeout (от slow POST),
MaxHeaderBytes, netutil.LimitListener на число соединений и лимитами
на соединения с одного IP на балансировщике. Поэтому голый
http.ListenAndServe(":8080", mux) в продакшене сразу настораживает.
Закончить ответ можно свежим дополнением: в Go 1.27 появилось
Server.MaxHeaderValueCount (по умолчанию берётся константа
DefaultMaxHeaderValueCount, равная 500). Оно закрывает соседний сценарий -
заголовочный флуд: вместо одного гигантского заголовка идут десятки тысяч крошечных,
которые по байтам влезают в MaxHeaderBytes, но раздувают
Request.Header и время разбора. Превышение даёт те же
431 Request Header Fields Too Large.
Тонкости, которые отличают сильный ответ
WriteTimeoutне останавливает хендлер. Это дедлайн на сокет: горутина продолжает работать, запрос в БД продолжает выполняться, просто ответ уже некому отдать. Отменяют работу толькоr.Context()иhttp.TimeoutHandler.WriteTimeoutтикает с момента, когда прочитаны заголовки, а не с начала записи. Значит, он должен быть большеReadTimeout, иначе долгая загрузка тела съест весь бюджет ответа.WriteTimeoutубивает SSE, long polling и большие выгрузки. Им нужен отдельный сервер илиhttp.NewResponseController(w).SetWriteDeadline().IdleTimeout == 0означает «взятьReadTimeout», а если и он ноль, простой не ограничен. Выставить одинReadHeaderTimeoutи считать, что закрылся, не выйдет.- Для HTTP/2 отдельная история: тысяча стримов в одном соединении, нужен
MaxConcurrentStreams(атака Rapid Reset, CVE-2023-44487).
http.DefaultClient Timeout == 0 -
запрос может висеть вечно и копить горутины до OOM. Нужен свой клиент: общий потолок в
Client.Timeout, пофазные таймауты в Transport, увеличенный
MaxIdleConnsPerHost (дефолт 2) и обязательное дочитывание
resp.Body.Два уровня таймаутов
Client.Timeoutставит один дедлайн на всю операцию: dial + TLS + отправка запроса + получение заголовков + чтение тела. Работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленныйio.Copy(dst, resp.Body).- Таймауты
Transportдействуют пофазно:DialContext.Timeout(установка TCP),TLSHandshakeTimeout,ResponseHeaderTimeout(сколько ждать заголовков ответа после отправки запроса),ExpectContinueTimeout,IdleConnTimeout(сколько соединение живёт в пуле без дела). contextсильнее обоих:http.NewRequestWithContext(ctx, ...)обрывает запрос при отмене родительского контекста, даже еслиTimeoutне вышел. Им дедлайн входящего запроса и пробрасывают в исходящие.
var transport = &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 128, // дефолт 2
MaxConnsPerHost: 256,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
}
var client = &http.Client{Transport: transport, Timeout: 10 * time.Second}
Пул соединений
Пул принадлежит Transport, а не Client, отсюда
два правила. Первое: один Transport на приложение, созданный при старте.
Новый &http.Transport{} на каждый запрос даёт утечку: у каждого свой пул
(голый &http.Client{} без Transport берёт общий
DefaultTransport и не течёт),
переиспользования нет, сокеты уходят в TIME_WAIT, при высоком RPS кончаются
эфемерные порты (cannot assign requested address). Второе:
MaxIdleConnsPerHost = 2 по умолчанию, и при 500 RPS в один апстрим
переиспользуются два соединения, а остальные каждый раз пересоздаются с полным TCP- и
TLS-хендшейком. Симптом: p99 выше, чем объясняет апстрим, и гора TIME_WAIT
в ss -s.
Почему обязательно закрывать и дочитывать тело
resp, err := client.Do(req)
if err != nil { return err } // сначала err: при ошибке resp == nil
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) // дочитать
_ = resp.Body.Close()
}()
Если тело не закрыть, соединение и горутина readLoop утекут навсегда.
Если закрыть, но не дочитать, соединение не вернётся в пул, а закроется: в сокете остались
непрочитанные байты. Больнее всего это на ветках ошибок, где тело обычно игнорируют, - пул
«худеет» ровно во время инцидента. LimitReader нужен, чтобы недобросовестный
апстрим не залил тебя гигабайтом в io.Discard.
Оговорка про версию: с Go 1.27 HTTP/1-транспорт при Close() сам пробует
дочитать остаток, если Content-Length ≤ 256 КиБ и остаток вычитывается за
50 мс. Мелкие тела ошибок с 1.27 прощаются сами, крупные и медленные нет, так что явное
дочитывание по-прежнему нужно.
Назови ещё три вещи: (1) http.DefaultTransport общий на весь процесс, включая
сторонние библиотеки - его поля глобально не меняют, а делают свой;
(2) Client.CheckRedirect по умолчанию идёт до 10 редиректов и перекладывает
заголовки: при редиректе на чужой хост Authorization отбрасывается, но
кастомные заголовки с секретами могут утечь, а бесконтрольный редирект заодно открывает дорогу
SSRF; (3) без MaxConnsPerHost клиент может открыть тысячи соединений и
сам положить апстрим, так что лимит на клиенте бережёт и чужой сервис.
SIGTERM через
signal.NotifyContext, сначала выключаем readiness и ждём, пока балансировщик
уберёт нас из пула, потом srv.Shutdown(ctx) с таймаутом меньше
terminationGracePeriodSeconds, и только после этого закрываем БД, брокер
и экспортёры.ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: h, ReadHeaderTimeout: 5 * time.Second}
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
log.Error("не поднялись", "err", err); os.Exit(1)
case <-ctx.Done():
// Go 1.26: причина отмены называет сигнал - "terminated signal received" и т. п.
log.Info("получен сигнал", "cause", context.Cause(ctx))
}
stop() // второй Ctrl+C убьёт процесс сразу
ready.Store(false) // /readyz отдаёт 503
time.Sleep(5 * time.Second) // ждём, пока нас выведут из балансировки
shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
log.Error("не успели", "err", err)
_ = srv.Close() // рвём остатки
}
consumer.Stop() // и только теперь ресурсы
_ = db.Close()
_ = tracer.Shutdown(context.Background())
Порядок и почему он такой
- Объявить о выходе: readiness →
503, подождать 5–10 с. Kubernetes помечает Pod неготовым в EndpointSlice асинхронно с отправкойSIGTERM: если закрыть listener сразу, часть трафика упрётся вconnection refused. Тот же эффект даёт встроенное действиеpreStop: sleepв манифесте;exec sleep 10в distroless-образе не сработает - там нет бинарникаsleep. - Перестать принимать новое:
Shutdownзакрывает listener и все idle keep-alive-соединения. - Дождаться текущих:
Shutdownблокируется до завершения активных запросов и добавляет им в ответConnection: close, чтобы клиент не пытался переиспользовать соединение. - Потом ресурсы: вызовешь
db.Close()доShutdown, и доигрывающие хендлеры получатsql: database is closedи отдадут500.
Что Shutdown НЕ делает
- Не закрывает hijacked-соединения: websocket после
Hijackон не считает активным запросом и просто про него не знает. Закрывай такие сам черезsrv.RegisterOnShutdown(func(){ hub.CloseAll() })и свой broadcast-канал. SSE обходится безHijack: это обычный активный запрос, иShutdownпрождёт его до таймаута, так что стримы гаси тем же хуком. - Не отменяет
r.Context()у работающих хендлеров: висящий 10 минут хендлер продержитShutdownвсе 10 минут. Поэтому нужен отдельный «shutdown-контекст», который хендлеры уважают, и таймаут наShutdownс фолбэком вClose(). Close()в отличие отShutdownрвёт всё немедленно, включая активные запросы.- После
Shutdownсервер нельзя переиспользовать:ListenAndServeсразу вернётErrServerClosed.
С Go 1.26 signal.NotifyContext отменяет контекст через
context.CancelCauseFunc, поэтому context.Cause(ctx) возвращает
ошибку с текстом вида terminated signal received (для
SIGTERM) или interrupt signal received (для
SIGINT). Раньше в логе оставалось безликое context canceled, и
штатная выкатка не отличалась от Ctrl+C на стенде.
ctx.Err() и errors.Is(cause, context.Canceled) при этом
работают по-старому, так что существующий код не ломается.
Бюджет: terminationGracePeriodSeconds (по умолчанию 30 с) = preStop sleep +
таймаут Shutdown + закрытие ресурсов, и всё это должно уложиться с запасом,
иначе прилетит SIGKILL посреди уборки. Отдельно проговори, что процесс
обязан сам обрабатывать SIGTERM: если приложение запущено через
shell-обёртку, сигнал может до него не дойти (PID 1 и отсутствие проброса) - отсюда
и «необъяснимые» обрывы при выкатке.
Unmarshal требует всё тело в памяти,
Decoder читает потоком. В хендлере - всегда
http.MaxBytesReader + Decoder с
DisallowUnknownFields и проверкой, что после объекта в теле ничего нет.json.Unmarshal | json.Decoder | |
|---|---|---|
| Вход | []byte - нужен io.ReadAll | io.Reader - читает по мере разбора |
| Память | весь документ + результат | буфер декодера + результат |
| Поток объектов | нет | да: for dec.More() { dec.Decode(&v) } - NDJSON, батчи |
| Сырое тело для лога/подписи | есть | нет (нужен io.TeeReader) |
| Строгий режим | нет | DisallowUnknownFields(), UseNumber() |
| Типичное применение | тело из очереди, конфиг, ответ апстрима под подпись | HTTP-хендлер, большие потоки |
Скелет хендлера
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 МиБ, w нужен для корректного закрытия
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in createReq
if err := dec.Decode(&in); err != nil {
var maxErr *http.MaxBytesError // Go 1.19+
var typErr *json.UnmarshalTypeError
switch {
case errors.As(err, &maxErr): // 413
case errors.As(err, &typErr): // 400 с именем поля и ожидаемым типом
case errors.Is(err, io.EOF): // 400 «пустое тело»
}
return
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
// в теле было {"a":1}{"b":2} - второй объект молча проехал бы
return
}
Почему MaxBytesReader, а не io.LimitReader
io.LimitReader на границе просто выдаст io.EOF - тело будет выглядеть
как валидно закончившееся, и клиент получит невнятный 400.
http.MaxBytesReader возвращает типизированную
*http.MaxBytesError (можно отдать честный 413) и через
ResponseWriter велит закрыть соединение после ответа,
чтобы сервер не читал остаток гигабайтного тела. Лимит ставят до
первого чтения, и лучше не в каждом хендлере, а в middleware с дефолтом и точечными
исключениями для загрузок.
Тонкости, за которые дают плюс
- Отсутствующее поле и нулевое значение неразличимы.
{}и{"title":""}дают одинаковый результат. ДляPATCHберут указатели (*string), обёрткуOptional[T]со своимUnmarshalJSONили разбор вmap[string]json.RawMessage. DisallowUnknownFieldsловит опечатки клиента, но ломает обратную совместимость наоборот: если клиент шлёт поле из будущей версии, ты его отвергнешь. На внутренних API его включают, на публичных сначала думают.- Обязательность полей
encoding/jsonне проверяет вообще - это работа валидатора (go-playground/validatorили ручнойValidate()). - Большие числа:
float64по умолчанию теряет точность на int64 больше 2^53, спасаютdec.UseNumber()илиjson.Numberв структуре. - Очень большой массив разбирают потоково через
dec.Token(): читают открывающую скобку, потом элементы по одному. - Нужны и сырое тело (для HMAC-подписи вебхука), и разбор? Ставь
io.TeeReaderвhash.Hashвместо двойного чтения.
ParseMultipartForm (просто, часть
в памяти, часть во временных файлах) и MultipartReader (потоково, ничего не
буферизуем). В обоих - MaxBytesReader, whitelist типов по содержимому, свои имена
файлов и обязательный RemoveAll().Как устроен multipart
Тело состоит из частей, разделённых строкой-boundary из
Content-Type: multipart/form-data; boundary=.... У каждой части свои заголовки:
Content-Disposition: form-data; name="file"; filename="cat.png" и
необязательный Content-Type. Всё это прислал клиент, значит ничему из
этого доверять нельзя.
// A: ParseMultipartForm
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
return // 400
}
defer r.MultipartForm.RemoveAll() // /tmp!
f, hdr, err := r.FormFile("avatar")
if err != nil {
return // 400
}
defer f.Close()
// hdr.Filename, hdr.Size, hdr.Header
// B: MultipartReader - стриминг
mr, err := r.MultipartReader()
if err != nil { return } // 415
for {
p, err := mr.NextPart()
if errors.Is(err, io.EOF) { break }
if err != nil { return } // 400
if p.FormName() == "file" {
uploader.Upload(ctx, key,
http.MaxBytesReader(w, p, 200<<20))
}
p.Close()
}
Аргумент ParseMultipartForm(n) не лимит: он говорит, сколько держать в памяти,
а остальное молча уезжает во временные файлы в os.TempDir(). Без
MaxBytesReader и RemoveAll() так недолго забить диск.
MultipartReader после FormValue или
FormFile не сработает: они уже разобрали тело. ParseForm
multipart-тело не трогает.
Чек-лист безопасности
- Лимиты:
MaxBytesReaderна всё тело,io.LimitReaderна каждую часть, ограничение на количество частей. - Имя: не доверяй ему. С Go 1.17
FileName()сам отрезает путь вроде../../etc/cron.d/x, но лучше генерироватьuuidи хранить оригинал в метаданных. - Тип: по содержимому, через
http.DetectContentType(first512), а не по расширению и не поContent-Typeчасти. Whitelist, не blacklist. Картинки ещё и перекодируют: так гибнет полезный груз, вшитый в EXIF. - Отдача: с другого домена или через прокси-ручку, с
X-Content-Type-Options: nosniff,Content-Disposition: attachmentи жёстко заданнымContent-Type- иначе залитый HTML/SVG выполнится как страница в origin приложения (хранимый XSS). - Хранилище: не в директорию, откуда исполняется код. Большие файлы грузят по presigned URL прямо в S3/MinIO, гонять гигабайты через бэкенд незачем.
- Антивирус/санитайзинг для пользовательского контента, если файлы потом видят другие пользователи.
Забыть defer r.MultipartForm.RemoveAll() в надежде
на сервер. net/http чистит только форму исходного запроса, а любой middleware
с r.WithContext отдаёт хендлеру копию. Симптом
приходит через месяц: no space left on device в совершенно другом месте
сервиса, потому что /tmp забит файлами multipart-*.
Что стоит запомнить
- ReadHeaderTimeout защищает приём заголовков; ReadTimeout, WriteTimeout и IdleTimeout относятся к другим фазам соединения.
- Response body клиента нужно закрыть, чтобы transport мог вернуть соединение в пул; для reuse его обычно дочитывают в ограниченном объёме.
- Shutdown прекращает принимать новые соединения и ждёт активные handlers до заданного контекста.
Частые ошибки
- Не полагайся на дефолты http.Client/http.Server для production latency и защиты ресурсов.
- Не пиши 500 после того, как заголовки уже ушли клиенту; в этот момент остаётся логирование/закрытие.
- Не делай shutdown timeout неограниченным: он должен укладываться в termination grace period.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Найди пределы сервера
Какой риск ограничивает каждый из заданных параметров? Что не покрыто этими настройками?
srv := &http.Server{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
}
Ответ и разбор
ReadHeaderTimeout ограничивает медленную отправку заголовков; ReadTimeout ограничивает чтение запроса; WriteTimeout - запись ответа; IdleTimeout - ожидание следующего запроса на keep-alive соединении; MaxHeaderBytes ограничивает размер заголовков. Эти поля сами не задают лимит тела запроса и не организуют graceful shutdown.
Код-ревью
2. Исправь утечку соединения клиента
На ошибочном статусе функция выходит до закрытия response body. Исправь управление телом, чтобы соединение могло вернуться в пул и не читался бесконечный ответ.
resp, err := client.Do(req)
if err != nil { return err }
if resp.StatusCode != http.StatusOK { return fmt.Errorf("upstream: %s", resp.Status) }
return json.NewDecoder(resp.Body).Decode(&out)
Ответ и разбор
Сразу после успешного Do поставь defer Close; при reuse дочитай остаток с ограничением через io.Copy(io.Discard, io.LimitReader(...)), затем закрой body. Неограниченно дочитывать тело злого/ошибочного upstream тоже опасно. Ошибки чтения и закрытия не замалчивай там, где они меняют исход операции.
Применение
3. Спланируй graceful shutdown
Pod получает SIGTERM и имеет terminationSeconds=30. Составь порядок действий для http.Server и объясни, что делать, если активные запросы не завершились за отведённое время.
Ответ и разбор
Сигнал переводит readiness в false, сервер прекращает принимать новые соединения через Shutdown(ctx), а активные handlers получают ограниченное время на завершение. Задай deadline меньше 30 секунд, оставив время на завершение процесса и закрытие зависимостей. При истечении deadline вызови Close как аварийный предел и залогируй незавершённые запросы; handlers должны передавать ctx в I/O.
Применение
4. Ограничь входящий JSON
Публичный endpoint читает JSON в структуру. Назови четыре проверки до бизнес-операции и объясни, почему Decoder сам по себе не защищает от слишком большого тела.
Ответ и разбор
Проверь Content-Type, ограничь тело MaxBytesReader, декодируй в отдельный request DTO с DisallowUnknownFields, затем валидируй поля и бизнес-инварианты. Decoder читает поток, но без ограничения может принять очень большой документ и занять память/время. Преобразуй ошибки формата и валидации в стабильный публичный Problem response.