40. Head of Line Blocking, Slowloris и деградация под нагрузкой
Введение
«Управление сложностью — вот суть программирования».
— Брайан Керниган
Когда мы проектируем высоконагруженный бэкенд, производительность системы измеряется не только числом операций с плавающей точкой или скоростью сериализации JSON. В реальном сетевом мире главными врагами масштабируемости становятся сетевые задержки, неравномерность очередей и непредсказуемое поведение клиентов.
Две классические проблемы, которые часто становятся причиной внезапной деградации или катастрофического отказа сервиса — Head of Line Blocking (блокировка начала очереди) и атака Slowloris («медленный лори»). Обе эти проблемы коварны тем, что они практически не нагружают процессор сервера: загрузка CPU может составлять скромные 2–5%, в то время как сервис полностью перестает отвечать живым пользователям.
В этой статье мы разберем, как эти явления возникают на разных уровнях сетевого стека, как они влияют на планировщик Go и выделение памяти, и какие production-паттерны необходимо внедрять в http.Server для надежной защиты от деградации.
Бытовые модели: Узкий Drive-Through и медлительные посетители
Чтобы наглядно представить суть этих проблем, обратимся к простым физическим аналогиям:
- Head of Line Blocking (Автокафе с одной полосой):
Представьте узкое окошко автокафе (drive-through), куда ведет единственная автомобильная полоса (одно TCP-соединение или очередь HTTP/1.1). Первый водитель в очереди заказывает эксклюзивный многоярусный свадебный торт со сложной выпечкой на 40 минут. За ним в очереди выстроились тридцать автомобилей, водителям которых нужна всего лишь чашка готового эспрессо. Кофе уже налит и остывает на стойке, но тридцать машин стоят намертво: узкий проезд физически заблокирован первой машиной. - Slowloris (Занятые столики в кафе):
Представьте уютное кафе на 50 посадочных мест. В зал заходят 50 злоумышленников, каждый садится за отдельный столик, заказывает стакан воды за 10 рублей и делает один микроскопический глоток через соломинку ровно раз в 4 минуты и 59 секунд. Никто не шумит, не бьет посуду и не ломает мебель (никакого шторма трафика или нагрузки на кухню). Но все 50 столиков заблокированы на много часов, и реальные платежеспособные гости у дверей разворачиваются и уходят.
Head of Line Blocking: Ловушка последовательного исполнения
Head of Line (HoL) Blocking — это фундаментальное архитектурное явление в сетях передачи данных, при котором потеря или задержка одного-единственного элемента данных в начале очереди полностью блокирует доставку и обработку всех последующих элементов, даже если они уже физически доставлены на принимающую сторону.
1. Уровень TCP (Транспортный уровень)
Протокол TCP гарантирует строгий порядок доставки байт (In-Order Delivery). Каждому отправляемому байту присваивается порядковый номер Sequence Number.
Если в потоке из десяти сетевых пакетов теряется всего один (например, пакет №3):
- Механизм
Retransmissionинициирует повторную отправку потерянного пакета. - Пакеты с №4 по №10, которые благополучно долетели до сервера, складываются в буфер сокета (Out-of-Order Queue) ядра Linux.
- Ядро категорически отказывается отдавать пакеты №4–10 приложению через сокетный API, пока не получит повторный пакет №3 и не подтвердит его через
ACK. Вышележащие протоколы (например, HTTP) не могут прочитать готовые данные.
Отправлено: [1] [2] [3 - ПОТЕРЯН] [4] [5] [6] [7]
Получено NIC: [1] [2] [--- ДЫРА ---] [4] [5] [6] [7]
Сокет ядра: Выдано приложению: [1, 2]
Заблокировано в очереди: [4, 5, 6, 7] (ждут retransmit 3)
2. Уровень HTTP/1.1 (Прикладной уровень)
Проблема многократно усугубляется на прикладном уровне. В HTTP/1.1 для выполнения нескольких запросов к одному хосту используется Keep-Alive, но запросы обрабатываются строго последовательно (FIFO):
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Клиент"]:::entry -->|"Запрос 1<br>Ожидание бэкенда"| B["Сервер"]
A -->|"Запрос 2<br>Блокировка HoL"| B
A -->|"Запрос 3<br>Блокировка HoL"| B
B -->|"Ответ 1<br>Только после обработки 1"| A
Если первый запрос ждет ответа от бэкенда (например, долгий SQL-запрос или блокирующий вызов внешней API), второй, третий и N-й запросы физически не могут быть отправлены по этому соединению, даже если сеть свободна. Попытка решить это через конвейеризацию (HTTP Pipelining) провалилась на практике из-за проблем с прокси-серверами.
3. Как решается в современных протоколах
- HTTP/2: Вводит Multiplexing (мультиплексирование). Несколько запросов и ответов упаковываются в отдельные легковесные кадры (
Frames) с идентификаторами потоков (Stream ID) внутри одного TCP-соединения. Потеря пакета в одном кадре задерживает только этот поток, не блокируя остальные на уровне логики HTTP. Однако на уровне TCP проблема осталась: потеря одного TCP-сегмента парализует передачу всех параллельных потоков HTTP/2 внутри этого соединения! Подробнее в 22. HTTP 2. Multiplexing, Frames, HPACK. - HTTP/3 + QUIC: Полностью устраняет HoL на уровне транспортного стека. QUIC работает поверх UDP и реализует собственную независимую логику
Loss Recoveryи мультиплексирования на уровне независимых стримов. Если пакет одного стрима теряется, QUIC выполняетretransmitтолько для него, ни на миллисекунду не останавливая передачу данных в других стримах. Подробнее в 23. HTTP 3 и QUIC. Почему будущее уходит от TCP и 24. Deep Dive в QUIC. Пакеты, Stream, Loss Recovery.
Slowloris: Атака медленной смерти
Slowloris — это классическая атака типа DoS (Denial of Service), направленная не на перегрузку пропускной способности канала (как в случае с UDP-флудом), а на исчерпание пула ресурсов сервера через удержание сокетов открытыми.
sequenceDiagram
participant Attacker as Атакующий (Slowloris)
participant Server as Go http.Server (Userspace / Netpoller)
Attacker->>Server: TCP Handshake (SYN -> SYN-ACK -> ACK)
Note over Server: Выделен net.FD, struct sock, bufio.Reader
Attacker->>Server: POST /login HTTP/1.1\r\nHost: example.com\r\n
Note over Server: Ожидание \r\n\r\n (конец заголовков)
loop Каждые 10-15 секунд
Attacker->>Server: X-Keep-Alive: 42\r\n
Note over Server: Таймаут сброшен, соединение живо!
end
Note over Attacker: Открыто 10 000 таких соединений
Note over Server: Исчерпаны дескрипторы, память буферов,<br>легитимные клиенты отвергаются
Механизм атаки
- Атакующий открывает тысячи параллельных TCP-соединений к веб-серверу.
- Отправляет валидные, но частичные HTTP-запросы (например,
GET / HTTP/1.1\r\nHost: ...безConnection: closeиContent-Length). - Периодически отправляет крошечные фрагменты заголовков (например,
X-Magic: 1\r\nкаждые 15 секунд), чтобы сервер не сработал по таймауту и не закрыл соединение. - Никогда не отправляет пустую завершающую строку
\r\n\r\n, означающую конец HTTP-заголовков.
Сервер, ожидая завершения запроса или отключения клиента, держит соединение открытым. В Go это означает:
- Выделенный файловый дескриптор
net.FD. - Буфер
bufio.Reader(по умолчанию 4 КБ на соединение). - Горутина, ожидающая чтения из сокета (стек 2-4 КБ, блокировка в
netpoller). - Поля
http.Server.MaxConnsв стандартной библиотеке нет: число одновременных соединений сервера не ограничено ничем, кроме системного пределаulimit -n(при его исчерпанииacceptвозвращаетEMFILE). Явное ограничение делают на уровне listener'а, напримерnetutil.LimitListenerизgolang.org/x/net/netutil.
Деградация под нагрузкой: Системный взгляд
Когда HoL-блокировки и атаки типа Slowloris (или просто медленные мобильные клиенты с нестабильным 3G/LTE) накапливаются, система проходит четыре стадии деградации:
- Истощение пула соединений (
http.Transport): Клиентские коннекты не освобождаются. Новые запросы начинают ждать в очереди. - Рост tail-latency: Из-за HoL запросы, которые должны обрабатываться параллельно, выстраиваются в очередь. P99 latency резко растет при стабильном P50 (характерный маркер проблем с сетевыми очередями в APM-метриках).
- Утечка горутин и памяти: Если асинхронная обработка не завершается из-за заблокированных соединений, горутины не собираются GC. Лимит памяти
GOMEMLIMITили рабочий набор RSS растут вплоть до срабатывания Linux OOM Killer. - Контекстные переключения: Планировщик Go (
runtime.scheduler) видит заблокированные вsyscallилиnetpollгорутины, переносит их в очередьrunqи создает новые треды ОС (M). Это вызывает избыточныйContext Switchи колоссальную нагрузку на ядро Linux.
Go-специфика: Управление соединениями и таймауты
В Go защита от деградации строится на двух уровнях: строгая настройка http.Server (серверная сторона) и конфигурация http.Transport (клиентская сторона).
1. Серверные таймауты (Обязательный минимум)
Никогда не используйте стандартный http.Server{} без явного указания таймаутов! Главное оружие против Slowloris — параметр ReadHeaderTimeout (появившийся в Go 1.8), который жестко ограничивает время чтения исключительно заголовков запроса:
package main
import (
"context"
"net"
"net/http"
"time"
)
func createSecureServer(handler http.Handler) *http.Server {
return &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second, // Смертельное оружие против Slowloris!
ReadTimeout: 15 * time.Second, // Время на чтение заголовков и тела
WriteTimeout: 30 * time.Second, // Время на отправку ответа
IdleTimeout: 60 * time.Second, // Сколько держать idle-соединение в пуле
MaxHeaderBytes: 1 << 20, // 1 MB лимит на заголовки (защита от переполнения)
// Базовый контекст (Go 1.13+). Внимание: BaseContext вызывается один раз на listener
// при Serve, а не для каждого запроса, поэтому время начала запроса сюда класть нельзя —
// его фиксируют в middleware (и не строковым ключом context.WithValue: это антипаттерн).
BaseContext: func(listener net.Listener) context.Context {
return context.Background()
},
}
}
2. Клиентский пул соединений (http.Transport)
По умолчанию Go использует http.DefaultTransport. Для production нужно явно управлять пулом, чтобы избежать HoL на клиенте и истощения file descriptors.
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10, // Критично! Ограничивает HoL на одном хосте
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second, // Жесткий таймаут на весь запрос
}
3. Взаимодействие с netpoller
Когда горутина блокируется на чтении из сокета (из-за Slowloris или HoL), она переходит в состояние waiting и перестает потреблять CPU. netpoller (epoll/kqueue) регистриует fd и ждет событий. Когда таймаут срабатывает, срабатывает дедлайн чтения, и Read возвращает ошибку i/o timeout (os.ErrDeadlineExceeded); затем сервер закрывает соединение. Горутина разблокируется, буферы bufio сбрасываются, и ресурсы возвращаются в пул.
Итоги и вопросы для собеседования
- Как HTTP/2 решает проблему HoL? Через мультиплексирование кадров (frames) поверх одного TCP-соединения. HTTP/2 решает HoL на уровне HTTP, но поверх TCP потеря пакета всё равно задерживает все потоки соединения (HoL уровня TCP); только HTTP/3 поверх QUIC изолирует потерю в конкретном stream.
- Почему Slowloris опасен для Go-сервера? Истощает file descriptors, память (буферы
bufio) и пул горутин, не нагружая CPU. Дефолтныйhttp.Serverбез таймаутов не защитит от него. - Как отличить HoL от реальной медленной бэкенд-логики? Используйте метрики (например, Prometheus) или OpenTelemetry spans (типа
http.ServerMetricsвnet/httpнет). Еслиconn_durationрастет, ноcpu_timeнизкий — проблема в сети или ожидании внешних зависимостей. - Что делает
MaxConnsPerHostвhttp.Transport? Ограничивает количество активных соединений к одному хосту. Превышение лимита заставляет запрос ждать освобождения коннекта в очереди, предотвращаяToo many open files.
Что дальше
Мы разобрали сетевые аномалии и механизмы деградации на уровне приложения. Следующий шаг — понимание того, как эти проблемы решаются на уровне инфраструктуры и оркестрации. В следующей статье мы перейдем к сетевому стеку Kubernetes: 41. Kubernetes Networking. CNI, Pod Network, Service, Ingress.