open atlas
↑ К треку
Go с нуля до senior GO · 03 · 03

Таймауты и лимиты: нулевые значения, которые держат соединения вечно

У http.Server нулевые таймауты: ReadHeader, Read, Write и Idle по умолчанию бесконечны, и один slowloris-клиент исчерпывает соединения. Задайте их, добавьте контекстные дедлайны на запрос, настройте таймауты Transport у исходящих клиентов и ограничьте тело через MaxBytesReader.

GO Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Отчёт об аварии читался как загадка: API умер, но CPU на 4%, память ровная, база простаивает. Исчерпались файловые дескрипторы — все 65 535, почти все в ESTABLISHED, почти все принадлежали нескольким сотням IP, каждый из которых открыл тысячи соединений и слал по одной строке HTTP-заголовка в минуту. Хрестоматийный slowloris (атака, намеренно растягивающая отправку заголовков, чтобы удерживать соединение бесконечно). Сервер был http.ListenAndServe(addr, mux) — а это конструирует http.Server со всеми полями таймаутов в нулевом значении, и в Go ноль означает никакого таймаута вообще. Каждое цедящее байты соединение бессрочно держало горутину и FD, accept-цикл продолжал принимать, пока ядро не сказало «нет», а легитимные клиенты получали connection refused. Фикс занял восемь строк: сконфигурированный Server с ReadHeaderTimeout и компанией. Самая острая строка постмортема: никто не принимал решения, что эти таймауты должны быть бесконечными, — нулевое значение решило за всех, двумя годами раньше, в первый день.

Таймауты Server, которые никто не задаёт

Почему API умирает при 4% CPU и простаивающей базе? Почти наверняка потому, что нулевое значение приняло решение за тебя — а в Go нулевой таймаут означает бесконечный. Вот что нужно задать вместо него.

http.ListenAndServe(addr, h) враждебен продакшену ровно по одной причине: на нём нельзя задать таймауты. Четыре важных поля живут на http.Server, и каждое сторожит свою фазу соединения:

srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,   // строка запроса + заголовки
	ReadTimeout:       10 * time.Second,  // заголовки И всё тело
	WriteTimeout:      30 * time.Second,  // от конца заголовков до последнего байта ответа
	IdleTimeout:       120 * time.Second, // пауза keep-alive между запросами
}
log.Fatal(srv.ListenAndServe())
  • ReadHeaderTimeout — убийца slowloris: пир обязан доставить весь блок заголовков за это время. Можно ставить жёстко (2–5 с): честные клиенты шлют заголовки одним пакетом.
  • ReadTimeout покрывает заголовки плюс всё тело. Жёсткое значение ломает легитимные большие загрузки — поэтому многие сервисы ставят маленький ReadHeaderTimeout и щедрый ReadTimeout либо управляют дедлайнами тела по маршрутам.
  • WriteTimeout ограничивает запись ответа. Жестокая деталь: дедлайн абсолютный с момента окончания чтения заголовков, а не на каждую запись — стриминговый эндпоинт, который шлёт события дольше WriteTimeout, получит закрытое соединение посреди стрима, каким бы живым он ни был.
  • IdleTimeout ограничивает простой keep-alive-соединения между запросами; если не задан — берётся ReadTimeout, а если и тот ноль, простаивающие соединения живут вечно. Конвенция — 60–120 с.

Ещё одна честность: WriteTimeout не останавливает хендлер. Когда он срабатывает, net/http закрывает соединение; горутина хендлера продолжает считать до конца, и её записи проваливаются. Таймауты Server защищают соединения и FD, не CPU.

Викторина

Сервер ставит WriteTimeout: 10s. Хендлер стримит длинный отчёт, записывая чанк каждую секунду. На t=10s соединение умирает. Хендлер не простаивал ни секунды — почему сработал таймаут?

Дедлайны на запрос: контекст и что на самом деле отменяется

Таймауты сервера — сантехника соединений; работа ограничивается на каждый запрос, контекстом:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	row := db.QueryRowContext(ctx, query, id) // уважает дедлайн
	// r.Context() отменяется и при отключении КЛИЕНТА —
	// работа вниз по стеку останавливается, когда ответа никто не ждёт.
}

r.Context() отменяется, когда клиент уходит или соединение рвётся, поэтому прокидывание его в каждый вызов базы и каждый запрос вниз — бесплатный сброс нагрузки: работа по брошенному запросу останавливается, а не завершается в пустоту. Сверху накладывайте свой WithTimeout для бюджета на маршрут. Дисциплина, делающая это реальным: ctx обязан принимать каждый блокирующий вызов в хендлере — один QueryRow вместо QueryRowContext, и в вашем двухсекундном бюджете дыра. Stdlib также предлагает http.TimeoutHandler(h, 2*time.Second, "timeout"), возвращающий 503 по дедлайну; честная оговорка — как и WriteTimeout, он не умеет убить горутину хендлера, лишь перестаёт её ждать (а его ResponseWriter прячет Flusher и ломает стриминг внутри).

Клиентская сторона: дефолтный http.Client ждёт вечно

Та же ловушка нулевых значений существует на исходящих, и она хуже, потому что http.DefaultClient так удобен. У него нет никакого таймаута: зависший апстрим держит вашу горутину, её FD и захваченную память запроса вечно. Продакшен-конфигурация задаёт бюджеты на двух уровнях:

client := &http.Client{
	Timeout: 10 * time.Second, // жёсткий потолок: dial + TLS + заголовки + ЧТЕНИЕ ВСЕГО ТЕЛА
	Transport: &http.Transport{
		DialContext:           (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
		TLSHandshakeTimeout:   3 * time.Second,
		ResponseHeaderTimeout: 5 * time.Second,  // «время на подумать» апстрима
		IdleConnTimeout:       90 * time.Second, // жизнь соединения в пуле
		MaxIdleConnsPerHost:   32,               // дефолт 2 душит fan-out
	},
}

Client.Timeout — грубый инструмент: он накрывает всё, включая чтение тела, и потому должен превышать ваш самый медленный легитимный полный обмен. Поля Transport режут тоньше: dial, TLS-рукопожатие и время до первого байта заголовков — ровно те места, где зависшие апстримы на самом деле виснут. Два дефолта больно кусают в проде: MaxIdleConnsPerHost равен 2, так что сервис с fan-out (веерные параллельные вызовы к одному хосту) в 200 одновременных запросов молотит 198 свежих соединений (TLS-рукопожатия, нарастающий TIME_WAIT); а дедлайны на запрос по-прежнему живут в контексте — http.NewRequestWithContext(ctx, ...), — чтобы бюджет вызывающего распространялся, а не каждый хоп ждал свои полные 10 секунд.

MaxBytesReader: лимит на то, что вы готовы проглотить

Таймауты ограничивают время; нужно ограничивать и байты. Неаутентифицированный io.ReadAll(r.Body) — приглашение: тело JSON на 2 ГБ — это аллокация на 2 ГБ, и несколько одновременных кладут под по OOM. Ответ stdlib:

func createUser(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // потолок 1 МиБ
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		var mbe *http.MaxBytesError
		if errors.As(err, &mbe) {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return // 413, и сервер перестаёт читать остальное
		}
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
}

MaxBytesReader делает больше, чем обрезка: при переполнении он возвращает *http.MaxBytesError и сообщает серверу прекратить чтение соединения, чтобы враждебный клиент не мог продолжать качать. Ставьте его по маршрутам — 1 МиБ для JSON-API, больше только на эндпоинтах загрузки — и помните: Content-Length — это заявление, а не гарантия; reader контролирует фактические байты.

Викторина

Сервис ходит к апстриму через http.DefaultClient. Апстрим принимает TCP-соединения, но никогда не присылает заголовки ответа. Что происходит с каждой вызывающей горутиной?

Почему это работает

Почему Go по умолчанию делает все таймауты бесконечными, вместо разумных дефолтов? Потому что stdlib не может угадать ваш трафик: дефолтный ReadTimeout в 5 секунд сломал бы загрузку файлов, ResponseHeaderTimeout в 30 секунд сломал бы long-polling, а молча меняющиеся между версиями Go дефолты стали бы кошмаром совместимости при обещании Go 1. Ноль-как-бесконечность последовательна и предсказуема — но перекладывает обязанность на вас, и дешевле всего исполнить её одним конструктором newServer() / newClient() в кодовой базе, который никто не обходит: отревьюен один раз, используется везде.

Вспомните перед уходом
  1. 01
    Назови четыре поля таймаутов http.Server, какую фазу сторожит каждое, и острый край WriteTimeout.
  2. 02
    Исходящий вызов должен уложиться в бюджет 2 секунды от и до. Где ты его обеспечиваешь и какие дефолты тебя предадут?
Итог

HTTP-стек Go трактует нулевой таймаут как отсутствие таймаута, и http.ListenAndServe не даёт это изменить — продакшен-сервисы конструируют http.Server с четырьмя полями, выбранными осознанно. ReadHeaderTimeout закрывает дыру slowloris, ограничивая доставку заголовков; ReadTimeout покрывает всё тело и должен вмещать легитимные загрузки; WriteTimeout — один абсолютный дедлайн на фазу ответа, убивающий даже здоровые стримы и никогда не останавливающий горутину хендлера, только соединение; IdleTimeout пожинает простаивающие keep-alive-соединения и молча наследует ReadTimeout, если не задан. Внутри хендлера время принадлежит контексту: r.Context() отменяется при уходе клиента, WithTimeout на маршрут задаёт бюджет работы, и бюджет держится, только если каждый блокирующий вызов принимает ctx — один запрос без контекста, и в нём дыра. http.TimeoutHandler возвращает 503 по дедлайну, но не умеет убить горутину за ним. Исходящие — та же ловушка в зеркале: http.DefaultClient ждёт вечно, поэтому настоящие клиенты ставят Client.Timeout как потолок на весь обмен и бюджеты Transport на dial, TLS-рукопожатие и заголовки ответа, поднимают MaxIdleConnsPerHost выше душащих fan-out дефолтных 2 и передают контексты на запрос, чтобы дедлайны распространялись. Байтам тоже нужны границы: MaxBytesReader ограничивает тело, возвращает MaxBytesError для чистого 413 и прекращает чтение враждебного потока. Теперь, когда видишь аварию с пустым CPU и молчащей базой, первый вопрос: какое поле таймаута по-прежнему равно нулю?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.