1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 2 из 5

Таймауты и буферизация

Коротко

Таймауты меряют не длительность ответа, а паузу. Буферизация освобождает бэкенд от медленного клиента, и в этом главный смысл прокси.

  • proxy_read_timeout считает промежуток между двумя чтениями. Ответ, идущий восемь секунд по кусочку, проходит при таймауте в три.
  • Чанкованный поток (ответ кусками без объявленной длины) буферизация не задерживает. Задерживает она ответ с объявленной длиной и ответ под gzip - там замер даёт 4 секунды вместо 0,0015.
  • Буферизация помогает потому, что умеет слить ответ на диск. proxy_max_temp_file_size 0 делает её бесполезной.
  • Keepalive к бэкенду работает только через блок upstream. С литеральным адресом в proxy_pass каждое соединение новое.

Если знаешь, что таймаут меряет паузу, и не путаешь буферизацию со сжатием - листай до «Теперь сам».

Сначала ответь сам

proxy_read_timeout 3s;. Два запроса. Первый идёт к обработчику, который восемь секунд отдаёт ответ по кусочку раз в секунду. Второй - к обработчику, который молчит пять секунд и отвечает целиком.

Какой из них упрётся в таймаут?

↳  выводcurl -s -o /dev/null -w '%{http_code}, всего %{time_total}s\n' http://to.local/s/stream/8
200, всего 8.004266s
↳  выводcurl -s -o /dev/null -w '%{http_code}, всего %{time_total}s\n' http://to.local/a/slow/5
504, всего 3.004419s

Восьмисекундный прошёл, пятисекундный упал. Таймаут не про длительность ответа, он про молчание.

На что это похоже

Ты ждёшь человека у метро и договорился: «если пропадёшь больше чем на три минуты - ухожу». Он может добираться час, но каждые две минуты пишет «еду». Ты дождёшься. А может выйти из дома через пять минут после разговора - и не застать тебя, хотя опоздал всего на пару минут.

Отсчёт идёт от последней весточки, а не от начала истории.

Механизм: четыре таймаута на разных участках

клиент nginx бэкенд keepalive_timeout 75s простой соединения с клиентом proxy_connect_timeout 60s установка соединения proxy_send_timeout 60s пауза при передаче запроса proxy_read_timeout 60s пауза при чтении ответа
Три таймаута отмеряют паузы на участке до бэкенда, четвёртый - простой соединения с клиентом.

Практический вывод из «таймаут меряет паузу»: тяжёлую операцию лучше сделать потоковой или асинхронной задачей со статусом (приложение сразу отвечает «принято, номер такой-то», а результат отдаёт по отдельному запросу), чем поднимать таймаут до получаса. Потоковая проходит при любом разумном таймауте, а получасовой таймаут означает, что зависший запрос будет занимать соединение полчаса.

Правило

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

/etc/nginx/conf.d/shop.local.confhttp server location /api/
proxy_buffers     8 16k;    # восемь буферов по 16 КБ на соединение (умолчание - 8 4k или 8 8k)
proxy_buffer_size 16k;      # отдельный буфер под заголовки ответа (умолчание - 4k или 8k)

proxy_buffer_size стоит помнить отдельно: это он даёт 502 со строкой upstream sent too big header, когда бэкенд отвечает раздутой кукой сессии (разбирали в шестом разделе).

Разбор: буферизация против медленного клиента

Ответ 50 МБ, клиент читает со скоростью 50 КБ/с и уходит через восемь секунд. Бэкенд печатает, за сколько он отдал ответ:

настройка что стало с бэкендом
proxy_buffering on отдал за 0,26 c и свободен
proxy_buffering off держал соединение, пока клиент не ушёл
on + proxy_max_temp_file_size 0 держал соединение, как при off

Третья строка объясняет механизм. Буферизация выигрывает не потому, что «есть буферы», а потому, что умеет слить ответ на диск: буферов всего восемь по 16 КБ, то есть 128 КБ, а ответ в 50 МБ - в четыреста раз больше. Запретили временный файл - и польза исчезла.

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

Разбор: когда буферизация правда задерживает поток

«События приходят пачками, выключи proxy_buffering» - самый частый совет. Замер показывает, что он верный, но причина не та, о которой думают. Ответ из четырёх кусков по секунде, время до первого байта:

как устроен ответ buffering on buffering off
Transfer-Encoding: chunked 0,0015 с 0,0014 с
объявлен Content-Length, тело пишется по частям 4,005 с 0,0015 с
chunked плюс gzip 4,003 с 0,0015 с

Первая строка ломает привычное объяснение: чанкованный поток буферизация не задерживает вовсе. nginx отдаёт клиенту каждый кусок, как только его прочитал, и лишь читает вперёд, когда клиент не успевает.

Задержка появляется там, где nginx не может отдать кусок отдельно: у ответа с объявленной длиной он собирает тело, а сжатие вынуждено накопить данные, прежде чем их сжать. Поэтому «пачками» приходят именно эти два случая.

Отсюда и лечение. proxy_buffering off снимает оба - в таблице при нём и ответ с длиной, и сжатый поток отдают первый байт за 0,0015 с, - но вместе с ними снимает и защиту бэкенда от медленного клиента, поэтому его ставят точечно, на потоковом маршруте (SSE, серверные события, - именно такой маршрут):

/etc/nginx/conf.d/shop.local.confhttp server location /events/
proxy_pass http://app:8000;
proxy_buffering off;
proxy_read_timeout 3600s;

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

Есть и путь со стороны приложения, без правки nginx вовсе: заголовок X-Accel-Buffering: no в ответе. Проверено на том же стенде - при включённом gzip он вернул первый байт на 0,0016 секунды. Приложение решает это для конкретного ответа само.

Что ломается без этого

Симптом «через curl мгновенно, через браузер с задержкой» при включённой буферизации означает сжатие (gzipРазбирается в разделе 11, глава «gzip и brotli»: Что сжимать и чем - nginx пережимает ответ, если клиент сказал, что умеет его распаковывать): curl по умолчанию не просит Accept-Encoding, а браузер просит всегда. Проверяется одной парой команд:

$  команда
curl -s -o /dev/null -w '%{time_starttransfer}\n' https://shop.local/events/
curl -s -o /dev/null -w '%{time_starttransfer}\n' -H 'Accept-Encoding: gzip' https://shop.local/events/

Разница в секунды - виновато сжатие. Одинаково медленно в обоих - смотри, как устроен ответ: если приложение объявляет Content-Length, поток собирается в буфере целиком. Одинаково медленно и при proxy_buffering off - буферизует само приложение, и nginx тут ни при чём.

Второе, что ломается тихо, - соединения с бэкендом. Замер портов на бэкенде для шести запросов подряд:

Порт - номер, с которого nginx открыл соединение к бэкенду: новое соединение - новый порт. Бэкенд видит его в $remote_port.

как записан бэкенд порты со стороны nginx
proxy_pass http://app:8000/ шесть разных
upstream app { server app:8000; } один и тот же

Первое app - имя группы (его и пишут в proxy_pass http://app;), второе - имя хоста в Docker; совпадать им не обязательно.

С версии 1.29.7 keepalive к бэкенду включён по умолчанию, но включён он в группе. Литеральный адрес в proxy_pass группой не является, и каждый запрос открывает новое TCP-соединение, а на HTTPS - ещё и новое рукопожатие. Одна строка upstream вокруг того же адреса убирает это целиком; подробнее - в следующем разделе.

Зачем это в работе

Три вопроса, которые стоит задать конфигу до того, как его выложат:

Есть ли маршрут, где ответ идёт дольше минуты? Если да, он должен быть либо потоковым, либо асинхронным - таймаут поднимают только когда деваться некуда.

Есть ли потоковый маршрут? Если да, на нём выключают сжатие и буферизацию и поднимают proxy_read_timeout - но именно на нём, а не глобально.

Записан ли бэкенд группой? Если нет, ты платишь по соединению на запрос, причём в логах это никак не видно.

Теперь сам

На маршруте выгрузки отчёта в CSV стоит proxy_buffering off; - кто-то поставил, «чтобы файл шёл сразу». Файлы большие, клиенты разные, приложение периодически упирается в нехватку воркеров. Что происходит и что делать?

Выключенная буферизация держит воркер приложения всё время скачивания: клиент на мобильной связи занимает его минутами. Помогает ли она «идти сразу» - зависит от ответа: чанкованный пошёл бы сразу и с буферизацией, а вот выгрузка с объявленной длиной действительно копилась бы в буфере. Разумный ход - вернуть умолчание и отдавать отчёт чанками, тогда работают обе стороны сразу.

Главное

Таймауты меряют паузу, а не длительность: поток по кусочку проходит при маленьком таймауте, а «молчал и ответил» не проходит. Буферизация освобождает бэкенд от медленного клиента, и работает это за счёт временного файла - запрет файла делает её бесполезной. Чанкованный поток она не задерживает; задерживают её объявленная длина ответа и gzip, и лечится это proxy_buffering off на потоковом маршруте или заголовком X-Accel-Buffering: no от приложения. proxy_buffer_size - это про 502 с too big header. Keepalive к бэкенду с 1.29.7 работает сам, но только когда бэкенд записан блоком upstream.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.