MAATRIX / Блог / Буферизация ответа в nginx: как один медленный клиент занимает ваш бэкенд

Буферизация ответа в nginx: как один медленный клиент занимает ваш бэкенд

MAATRIX

Ваше приложение отдаёт ответ за миллисекунды, а воркер всё равно занят десятки секунд. Причина обычно не в коде и не в базе — в том, как nginx решает, кому и когда отдавать байты, которые уже получил от бэкенда. По умолчанию nginx буферизует ответ, и это осознанное архитектурное решение, а не побочный эффект. Разберёмся, зачем оно нужно, что ломается без него и как настроить буферы так, чтобы они действительно защищали приложение, а не создавали новую проблему.

Что вообще происходит между бэкендом и клиентом

Когда nginx работает как reverse proxy (мы подробно разбирали путь запроса через reverse proxy в отдельной статье), он выступает посредником в двух независимых TCP-соединениях: одно — между nginx и вашим приложением, второе — между nginx и браузером или клиентом пользователя. Это два разных мира с разной скоростью.

Соединение nginx-бэкенд обычно живёт в пределах одного дата-центра или даже одной машины: задержки — доли миллисекунды, пропускная способность — гигабиты. Соединение nginx-клиент — это интернет: мобильная сеть с рваным сигналом, спутниковый канал, перегруженный домашний Wi-Fi, иногда просто медленный или неполноценный клиент, который читает ответ по одному байту в секунду (случайно из-за плохого кода или намеренно, если это бот-сканер).

Задача nginx — не дать разнице в скорости этих двух миров просочиться обратно в приложение. Именно для этого существует буферизация: nginx полностью (или почти полностью) принимает ответ от бэкенда в свою память, а дальше уже сам, не торопясь, раздаёт его клиенту с той скоростью, с которой клиент готов его принимать.

Управляется это директивой proxy_buffering, включённой по умолчанию:

location / {
    proxy_pass http://backend;
    proxy_buffering on;   # значение по умолчанию, можно не писать явно
}

Почему это хорошо: бэкенд отдаёт ответ и сразу освобождается

Представим, что ваше приложение — это воркер Node.js, поток PHP-FPM или процесс Django за Gunicorn. У каждого из них ограниченное число одновременных обработчиков: сколько воркеров в пуле, столько запросов приложение может обслуживать параллельно. Всё остальное встаёт в очередь.

С включённой буферизацией сценарий выглядит так:

  1. Клиент присылает запрос, nginx передаёт его бэкенду.
  2. Бэкенд считает ответ (запрос к базе, рендер шаблона, сборка JSON) и отдаёт готовые байты в сокет между собой и nginx.
  3. nginx читает эти байты в свой буфер настолько быстро, насколько позволяет локальное соединение — обычно это происходит практически мгновенно по сравнению со временем генерации ответа.
  4. Как только nginx принял весь ответ (или ту его часть, что помещается в буферы — подробнее ниже), соединение с бэкендом закрывается или возвращается в пул keepalive. Воркер приложения свободен и берёт следующий запрос.
  5. nginx дальше сам, независимо от приложения, отдаёт содержимое буфера клиенту — хоть 200 миллисекунд, хоть 20 секунд, если у клиента слабый канал.

Ключевой момент: шаги 3 и 5 разъединены по времени. Воркер приложения участвует только в быстрой части (генерация ответа + передача его в локальный буфер nginx), а медленную часть (доставка до реального клиента) на себя берёт nginx, у которого архитектура заточена именно под то, чтобы держать много медленных соединений одновременно, почти не тратя на каждое из них процессорное время.

Это ровно то разделение труда, ради которого reverse proxy вообще ставят перед приложением: nginx — event-driven, он может держать тысячи вялых соединений на одном воркере, потому что не блокируется на ожидании ввода-вывода. Ваше приложение, скорее всего, устроено иначе: у него дорогие обработчики (потоки, процессы, ограниченный пул корутин), и каждый занятый обработчик — это ресурс, который вы не можете размножить бесконечно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что происходит без буферизации: бэкенд ждёт медленного клиента

Теперь отключим буферизацию явно:

location /api/ {
    proxy_pass http://backend;
    proxy_buffering off;
}

Разница принципиальная. Без буферизации nginx превращается в почти прозрачную трубу: как только бэкенд отдал очередной кусок данных, nginx тут же пытается протолкнуть его клиенту. Но если клиент читает медленно, nginx физически не может отдать байты быстрее, чем клиент готов их принимать — сеть просто не пропустит. TCP-окно клиента заполняется, и запись в это соединение блокируется.

А раз nginx не может дочитать данные из соединения с бэкендом быстрее, чем успевает их слить клиенту, соединение с бэкендом остаётся открытым и ждёт. Приложение с его стороны видит: сокет, в который оно пишет ответ, ещё не готов принять данные (то есть буфер сокета на стороне nginx заполнен и не освобождается, потому что nginx сам застрял на отдаче клиенту). В зависимости от того, как приложение написано, это выражается по-разному:

  • Поток или воркер занят до последнего байта. Если сервер приложения синхронный (классический PHP-FPM, Gunicorn с sync-воркерами, Puma в стандартном режиме), тот самый обработчик, что считал ответ, теперь простаивает, дожидаясь, пока nginx допишет данные клиенту. Всё это время он не берёт новые запросы.
  • Соединение просто висит дольше, чем должно. Даже у асинхронных runtime (Node.js, ASGI-приложения) держать соединение открытым лишние секунды — это лишний файловый дескриптор, лишняя запись в таблице соединений, лишняя нагрузка на менеджер событий, и при достаточном числе таких клиентов лимиты (ulimit -n, размер пула соединений к бэкенду) начинают ощущаться раньше, чем должны.
  • Таймауты бэкенда становятся частью уравнения. Если у приложения или у прокси перед ним (например, uWSGI harakiri-таймаут) есть ограничение по времени ответа, медленный клиент может упереться в этот таймаут только потому, что физическая передача байтов растянулась дольше лимита — хотя сам код отработал за миллисекунды.

Важно: и с включённой, и с выключенной буферизацией сеть между nginx и клиентом не становится быстрее — данные всё равно идут со скоростью, которую позволяет канал клиента. Разница не в скорости доставки клиенту, а в том, кто именно платит своим временем и ресурсами за эту медленную доставку: специализированный event-driven процесс nginx (при буферизации) или дорогой обработчик вашего приложения (без неё).

Куда именно nginx складывает буферизуемый ответ

Буферизация — это не один безразмерный буфер, а связка настроек, которая по умолчанию рассчитана на не слишком большие ответы:

proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
  • proxy_buffer_size — отдельный буфер под первую часть ответа (заголовки и начало тела).
  • proxy_buffers — количество и размер буферов под остальное тело ответа. 8 4k значит: до восьми буферов по 4 килобайта, то есть до 32 КБ тела помещается в память.
  • proxy_busy_buffers_size — сколько из этих буферов может быть занято одновременно отправкой клиенту, пока остальные ещё принимают данные от бэкенда.

Если ответ бэкенда больше, чем суммарный объём этих буферов, nginx не начинает блокироваться — он временно сбрасывает избыток на диск, во временный файл:

proxy_max_temp_file_size 1024m;
proxy_temp_file_write_size 8k;
proxy_temp_path /var/cache/nginx/proxy_temp;

Это спасает от переполнения памяти на больших ответах (выгрузка отчёта, крупный JSON, генерируемый файл), но означает, что для по-настоящему больших ответов на диске должно быть место и достаточно быстрый I/O — иначе временные файлы сами становятся узким местом. Если /var/cache живёт на медленном диске или на переполненном разделе, буферизация больших ответов будет тормозить, и это стоит проверить отдельно от логики самого приложения.

Для большинства обычных API-ответов и HTML-страниц эти буферы даже не заполняются — ответ помещается в proxy_buffer_size целиком, и всё происходит в памяти без обращения к диску.

Когда buffering off — осознанный, а не случайный выбор

Отключать буферизацию есть смысл в конкретных, довольно узких случаях, и делать это стоит точечно, через location, а не глобально в http {}:

  • Server-Sent Events и потоковые ответы. Если бэкенд отдаёт данные постепенно и клиент должен видеть их по мере поступления (стриминг логов, SSE, chunked-ответы с прогрессом), буферизация всей нагрузки в nginx означает, что клиент увидит первый байт только тогда, когда сервер закончит (или буфер переполнится) — то есть убивает саму идею потоковой передачи.
  • WebSocket. Для него буферизация ответа неприменима в привычном смысле — соединение двунаправленное и постоянное, здесь работают другие директивы (proxy_http_version 1.1, Upgrade/Connection), а не proxy_buffering.
  • Проксирование к системе, которая сама уже устойчиво отдаёт потоковый контент (видео, большие файлы через специализированный сервис раздачи) — там логика раздачи и так рассчитана на медленных клиентов, дублировать буферизацию не обязательно.

Для обычных REST API, серверного рендеринга HTML, JSON-ответов — то есть подавляющего большинства запросов на типичном VPS с приложением за nginx — буферизацию стоит оставлять включённой. Это тот редкий случай в конфигурации сервера, где значение по умолчанию действительно оптимально для большинства нагрузок, и трогать его нужно осознанно, а не «для скорости».

Отдельно стоит развести буферизацию ответа (proxy_buffering, о которой эта статья) и постоянство соединений между nginx и бэкендом — keepalive между nginx и приложением решает другую задачу: экономит на установлении TCP-соединения для каждого запроса, а не на скорости медленных клиентов. Обе настройки снижают нагрузку на бэкенд, но по независимым друг от друга причинам, и путать их не стоит.

Как заметить проблему, если буферизацию всё же выключили

Если в конфиге где-то стоит proxy_buffering off (часто это наследие скопированного откуда-то location-блока под WebSocket, которое случайно распространили на весь сайт), симптомы такие:

  • Число активных воркеров или процессов приложения растёт вместе с числом медленных или мобильных клиентов, а не с реальной вычислительной нагрузкой.
  • В логах приложения время обработки запроса (время внутри кода) сильно расходится с временем ответа, которое видит клиент или которое пишет сам nginx в $request_time.
  • При параллельном мониторинге видно, что процессы приложения подолгу находятся в состоянии записи в сокет (для PHP-FPM это заметно в fpm status по состоянию Writing to socket), а не в состоянии реальных вычислений.

Проверить, включена ли буферизация для конкретного location, проще всего явным чтением конфига:

nginx -T | grep -A2 -B5 'proxy_buffering'

Если директива нигде явно не переопределена — она включена по умолчанию, и это, скорее всего, правильное состояние. Если видите off в location, который отдаёт обычные API-ответы, а не поток данных, — это повод перепроверить, зачем он там оказался.

Также стоит смотреть на $upstream_response_time и $request_time в логе nginx — если добавить оба значения в log_format, разница между ними покажет, сколько времени ушло на разговор именно с бэкендом, а сколько — на разговор с клиентом:

log_format timing '$remote_addr - $status '
                   'upstream=$upstream_response_time '
                   'total=$request_time';

Большая разница между upstream_response_time и request_time при включённой буферизации — это нормально и ожидаемо: бэкенд отработал быстро, а доставка клиенту заняла время уже без его участия. Именно этого эффекта буферизация и добивается.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Буферизация замедляет ответ клиенту?

Нет, скорость доставки клиенту определяется его собственным каналом и TCP-окном, а не тем, буферизует nginx или нет. Меняется не скорость для клиента, а то, чей ресурс (nginx или бэкенд) занят всё время передачи.

Нужно ли увеличивать proxy_buffers для API с большими JSON-ответами?

Если ответы стабильно крупнее суммарного размера буферов по умолчанию (32 КБ тела в примере выше), увеличение снизит обращения к временным файлам на диске и немного снизит нагрузку на I/O. Конкретные цифры зависят от размера типичного ответа в вашем приложении — стоит замерить на реальных данных, а не переносить чужие значения.

Если у меня SSE или стриминг, нужно ли выключать буферизацию для всего сайта?

Нет, и не стоит. Отключайте proxy_buffering off точечно, в отдельном location, который обслуживает именно потоковые эндпоинты, оставляя буферизацию включённой для всего остального.

Буферизация помогает от DDoS медленными клиентами (slowloris-подобных атак)?

Она снимает нагрузку с бэкенда, но не решает проблему на уровне самого nginx — большое число одновременных медленных соединений всё равно потребляет память и файловые дескрипторы у nginx. Для защиты от таких атак нужны отдельные механизмы: client_body_timeout, limit_conn, ограничение числа соединений на уровне файрвола.

Отличается ли поведение для proxy_pass и для FastCGI/uwsgi (PHP-FPM, uWSGI)?

Логика та же самая, но директивы называются иначе: fastcgi_buffering, uwsgi_buffering с аналогичными по смыслу буферами (fastcgi_buffers, uwsgi_buffers). Настраивать их стоит по той же логике, что описана выше для proxy_*.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →