HTTP-сервер под конкурентной нагрузкой

Перед началом: модули 1–9 (горутины, контекст, лимиты, таймауты). Инструменты: Go 1.22+, net/http, httptest, hey или wrk для нагрузки. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: медленные клиенты, бесконечные горутины и зелёный health-check

Мобильный фронт ходит в API через балансировщик. У части клиентов слабый канал: соединение открыто, заголовки приходят по байту, тело запроса льётся минутами. В http.Server не было ни одного таймаута — значения по умолчанию нулевые, то есть «ждать вечно». Горутины обработчиков копились, память росла, пул БД был занят ожидающими запросами.

Балансировщик всё это время видел 200 OK от /health: процесс жив, значит, инстанс здоров. Он продолжал слать трафик, пока сервис не упал по OOM. Health-check проверял существование процесса, а не его способность работать.

📖 Теория

10.1 Горутина на запрос: что она держит

На каждое соединение net/http выделяет горутину; в HTTP/2 — на каждый поток, то есть фактически на запрос. Пока хендлер не вернулся, горутина держит:

  • стек и состояние хендлера — плюс всё, что вы положили в замыкания и локальные переменные;
  • *http.Request с заголовками, телом и r.Context();
  • буферы соединения и само соединение (keep-alive);
  • ожидание внешних ресурсов — БД, HTTP-клиента, мьютекса.

r.Context() отменяется, когда клиент отключился или хендлер вернулся. Это главный сигнал «работа больше не нужна», и его нужно прокидывать вниз. Тело запроса сервер закрывает сам, но если вы читаете его частично, соединение может не переиспользоваться; клиентские resp.Body закрывать обязаны вы.

Диагностика: runtime.NumGoroutine(), pprof/goroutine, http.Server.ConnState для переходов соединений. Рост горутин без роста RPS — первый признак утечки или медленных клиентов.

10.2 Таймауты сервера и TimeoutHandler

У http.Server четыре независимых таймаута, и все по умолчанию нулевые:

go
srv := &http.Server{
    Addr:              ":8080",
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      20 * time.Second,
    IdleTimeout:       60 * time.Second,
}
  • ReadHeaderTimeout — сколько ждать заголовки. Главная защита от Slowloris: медленный клиент не занимает горутину вечно.
  • ReadTimeout — от начала чтения до полного тела запроса; для медленных загрузок может быть слишком строгим.
  • WriteTimeout — сколько даётся на запись ответа (для HTTP/1 — от конца чтения заголовков); долгий стриминг в него не влезает без продления дедлайна.
  • IdleTimeout — сколько живёт простаивающее keep-alive соединение.

Поверх этого — http.TimeoutHandler(h, d, msg): запускает хендлер и, если тот не уложился в d, отдаёт 503 с текстом msg. Важно: горутину хендлера он не убивает — та продолжает работу, а её записи после таймаута отбрасываются с ErrHandlerTimeout. Это защита клиента и соединения, а не отмена работы: r.Context() получает дедлайн, но хендлер должен сам его проверять. TimeoutHandler также буферизует ответ и плохо совместим со стримингом.

10.3 Лимит тела и MaxHeaderBytes

Размер запроса — вектор отказа не хуже количества запросов.

  • Тело: http.MaxBytesReader(w, r.Body, 1<<20) ограничивает чтение и возвращает ошибку при превышении; отвечайте 413. io.LimitReader тоже режет, но не даёт понять клиенту, что лимит превышен.
  • Заголовки: MaxHeaderBytes по умолчанию около 1 МБ — для API это много; разумные 16–64 КБ снижают давление на память.
  • JSON: json.Decoder поверх ограниченного тела, а не io.ReadAll всего запроса; проверяйте dec.More() и хвост.
  • Мусор в теле: после отказа дочитывать гигабайты не нужно; соединение можно закрыть.

Лимит тела — часть контракта API: клиент должен получать предсказуемый 413, а не таймаут или OOM.

10.4 Ограничение in-flight запросов: семафор, 503 vs очередь

Число одновременных хендлеров нужно ограничивать явно, иначе единственный способ узнать предел — падение. Семафор вокруг хендлера:

go
select {
case sem <- struct{}{}:
    defer func() { <-sem }()
default:
    http.Error(w, "busy", http.StatusServiceUnavailable)
    return
}

Два режима:

  • неблокирующий (default) — быстрый 503 и Retry-After: load shedding, задержка не растёт;
  • блокирующий — запрос ждёт слот: это очередь, которая увеличивает latency, память и число клиентских таймаутов. Иногда очередь нужна, но её длина должна быть явной и маленькой.

Лимит стоит ставить до дорогих операций: аутентификации, похода в БД, парсинга. Health-check и служебные ручки обычно исключают из общего лимита, иначе сервис сам себя объявит мёртвым. Ориентир для лимита — закон Литтла: concurrency = throughput × latency; при 500 rps и 100 мс нужно около 50 слотов, а не 5.

10.5 Стриминг vs буферизация ответа

Буферизация собирает ответ в память: можно заранее отдать Content-Length, сжать gzip, аккуратно вернуть ошибку до первого байта. Цена — задержка до первого байта и память, пропорциональная размеру ответа; гигабайтный экспорт в буфере — это OOM.

Стриминг пишет по частям и вызывает Flush:

go
w.Header().Set("Content-Type", "application/x-ndjson")
enc := json.NewEncoder(w)
for _, row := range rows {
    if err := enc.Encode(row); err != nil {
        return // клиент ушёл — ctx уже отменён
    }
}

Стриминг даёт низкий TTFB и константную память, но держит горутину и соединение дольше, а WriteTimeout должен покрывать всю передачу; для длинных потоков дедлайн продлевают через http.NewResponseController(w).SetWriteDeadline. Сжатие и прокси-буферы могут съесть выгоду стриминга. Клиентский обрыв виден через r.Context().Done() и ошибку записи.

10.6 Компромиссы

  • Таймауты короткие vs длинные: короткие освобождают горутины, но режут медленные легитимные запросы; длинные безопаснее для клиента, но дороже при атаке и деградации.
  • Лимит in-flight vs очередь: лимит с 503 сохраняет предсказуемую задержку; очередь повышает успешность в коротком всплеске, но при насыщении превращается в усилитель таймаутов.
  • Глобальный лимит vs per-route: глобальный проще, но тяжёлая ручка может занять все слоты; per-route честнее, но требует настройки и защиты от суммарной перегрузки.
  • Буферизация vs стриминг: буфер удобен для ошибок и gzip, но ест память; стриминг экономен, но сложнее с таймаутами, сжатием и частичными ошибками.
  • Health-check «процесс жив» vs «готов работать»: первый скрывает деградацию, второй требует честных сигналов (очередь, latency, отказы), иначе балансировщик выключит здоровый сервис.

💻 Практика

  1. Slowloris своими руками. TCP-клиент отправляет заголовки по байту раз в секунду. Запустите сервер без ReadHeaderTimeout и снимите рост горутин; затем поставьте 2 с и проверьте, что соединения закрываются, а сервис жив.
  2. Проверьте TimeoutHandler. Хендлер спит 5 с, обёрнут в TimeoutHandler с 1 с. Клиент должен получить 503, но горутина продолжит спать; покажите это по метрике горутин и объясните, чем это отличается от отмены через ctx.
  3. Сравните 503 и очередь. Лимит 4 слота, 20 одновременных запросов: вариант с default и вариант с блокирующим захватом и очередью 100. Сравните p99, пик памяти и число успешных ответов; health-check вынесите из лимита.
  4. Запустите сниппет модуля: конфиг с четырьмя таймаутами и семафор на 4 слота. Из 6 запросов четыре займут слоты, два получат быстрый отказ. Вывод: отклонено сразу 2, пик в полёте 4 (лимит 4) — очередь не копится.

🧩 Quiz

1. Что нужно учесть при стриминге ответа?
2. Зачем ограничивать тело запроса через MaxBytesReader?
3. Что происходит с горутиной хендлера при срабатывании TimeoutHandler?
4. Какой таймаут сервера защищает от Slowloris?
5. Почему при перегрузке предпочтительнее быстрый 503, а не очередь?

✅ Итоги модуля

Студент должен:

  • Объяснить, что держит горутина обработчика запроса
  • Настраивать ReadHeader, Read, Write и Idle таймауты
  • Использовать TimeoutHandler и понимать, что он не отменяет работу
  • Ограничивать тело и заголовки запроса
  • Ставить лимит in-flight и выбирать между 503 и очередью
  • Выбирать стриминг или буферизацию под задачу
  • Строить health-check, отражающий деградацию, а не только жизнь
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 11

Лимиты и таймауты удерживают сервер в рабочем режиме, но рано или поздно его нужно остановить: деплой, масштабирование, рестарт. Если просто убить процесс, клиенты получат обрывы, а незавершённые запросы — потерю работы. Дальше — graceful shutdown: перестать принимать новое, дать доработать начатое и закрыть ресурсы в правильном порядке.