Одна маленькая проблема скачивания файлов на медленных соединениях

от автора

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

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

Проблемные скачивания

Две вещи нас удивили в этой проблеме: во-первых, только пользователи мобильных устройств были подвержены проблеме, во-вторых, файл, который вызывал проблемы, был все-таки достаточно большим – порядка 30 мегабайт.

После плодотворной работы с tcpdump один из наших инженеров смог воспроизвести проблему. Оказалось, что достаточно положить большой файл для скачивания на тестовом домене, и использовать опцию --limit rate в команде curl:

$ curl -v http://example.com/large.bin --limit-rate 10k > /dev/null * Closing connection #0 curl: (56) Recv failure: Connection reset by peer

Ковыряние в tcpdump показало, что всегда был RST-пакет, который прилетал с нашего сервера точно на 60 секунде после установки соединения:

$ tcpdump -tttttni eth0 port 80 00:00:00 IP 192.168.1.10.50112 > 1.2.3.4.80: Flags [S], seq 3193165162, win 43690, options [mss 65495,sackOK,TS val 143660119 ecr 0,nop,wscale 7], length 0   ... 00:01:00 IP 1.2.3.4.80 > 192.168.1.10.50112: Flags [R.], seq 1579198, ack 88, win 342, options [nop,nop,TS val 143675137 ecr 143675135], length 0

Наш сервер точно делал что-то неправильно. RST-пакет, уходящий с нашего сервера, – это плохо. Клиент «ведет себя хорошо», присылает ACK-пакеты, потребляет данные с той скоростью, с которой может, а мы внезапно обрубаем соединение.

Не наша проблема?

Чтобы изолировать проблему, мы запустили базовый NGINX-сервер со стандартными настройками, и проблема оказалась легко воспроизводима локально:

$ curl --limit-rate 10k  localhost:8080/large.bin > /dev/null * Closing connection #0 curl: (56) Recv failure: Connection reset by peer

Это показало, что проблема не является специфичной для нашей установки, это была более широкая проблема, связанная с NGINX.

После дальнейшего изучения, мы выяснили, что у нас используется настройка reset_timedout_connection. Это приводит к тому, что NGINX обрывает соединения. Когда NGINX хочет закрыть соединение по тайм-ауту, он задает SO_LINGER без тайм-аута на сокете, с последующим close().

Это запускает RST-пакет вместо нормального завершения TCP-соединения. Вот лог strace из NGINX:

04:20:22 setsockopt(5, SOL_SOCKET, SO_LINGER, {onoff=1, linger=0}, 8) = 0   04:20:22 close(5) = 0

Мы могли бы просто отключить reset_timedout_connection, но это не решило бы проблему. Вопрос стоял так: почему вообще NGINX закрывает это соединение?

Далее мы обратили внимание на параметр send_timeout. Его значение по умолчанию – 60 секунд, в точности, как мы наблюдали в своем случае.

http {        send_timeout 60s;      ...

Параметр send_timeout используется в NGINX, чтобы убедиться, что все соединения рано или поздно будут завершены. Этот параметр контролирует время, разрешенное между последовательными вызовами send/sendfile в каждом соединении. Говоря по-простому, это неправильно, чтобы одно соединение использовало ресурс сервера слишком долго. Если скачивание длится слишком долго, или вообще прекратилось, это нормально, если http-сервер оборвет соединение.

Также и не-NGINX проблема

C strace в руках мы посмотрели, что NGINX делает:

04:54:05 accept4(4, ...) = 5   04:54:05 sendfile(5, 9, [0], 51773484) = 5325752   04:55:05 close(5) = 0

В конфигурации мы указали NGINX использовать sendfile, чтобы передавать данные. Вызов sendfile проходит успешно и отправляет 5 мегабайт данных в буфер отправки. Что интересно: это почти такой же размер, который у нас установлен по умолчанию в буфере записи:

$ sysctl net.ipv4.tcp_wmem net.ipv4.tcp_wmem = 4096 5242880 33554432

Спустя минуту после первого вызова sendfile сокет закрывается. Что будет, если мы увеличим значение send_timeout до какого-то большего значения, например 600 секунд:

08:21:37 accept4(4, ...) = 5   08:21:37 sendfile(5, 9, [0], 51773484) = 6024754   08:24:21 sendfile(5, 9, [6024754], 45748730) = 1768041   08:27:09 sendfile(5, 9, [7792795], 43980689) = 1768041   08:30:07 sendfile(5, 9, [9560836], 42212648) = 1768041   ...

После первого большого «выпихивания» данных,
sendfile вызывается еще несколько раз. Между каждым последовательным вызовом он передает примерно 1,7 мегабайт. Между этими вызовами, примерно каждые 180 секунд, сокет постоянно пустел из-за медленного curl, так почему же NGINX не пополнял его постоянно?

Асимметрия

Девиз Unix: «всё является файлом». По-другому можно сказать «всё может быть прочитано или записано с помощью poll». Давайте рассмотрим поведение сетевых сокетов в Linux.

Семантика чтения из сокета проста:

  • Вызов read() будет возвращать данные, доступные в сокете, пока он не опустеет.
  • poll отвечает, что сокет доступен для чтения, когда в нем есть какие-то данные.

Можно подумать, что похожим образом выглядит запись в сокет:

  • Вызов write() будет копировать данные в буфер, пока буфер отправки не заполнится.
  • poll отвечает, что сокет доступен для записи, если в нем есть хоть сколько-нибудь свободного места.

Как ни удивительно, но это НЕ так.

Разные «пути» кода

Важно понять, что в ядре Linux есть два различных «пути» работы исполняемого кода: по одному отправляются данные, а по второму проверяется, является ли сокет доступным для записи.

Чтобы команда send() выполнилась успешно, нужно, чтобы были выполнены два условия:

  • Должно быть свободное место в буфере отправки.
  • Количество неотправленных данных, стоящих в очереди, должно быть меньше чем параметр LOWAT. В этом случае все было хорошо, поэтому просто опустим это условие.

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

  • Должно быть свободное место в буфере отправки.
  • Количество неотправленных данных, стоящих в очереди, должно быть меньше чем параметр LOWAT.
  • Свободное место в буфере отправки должно быть больше, чем половина занятого места в буфере.

Последнее условие является критичным. После того, как буфер отправки заполнен на 100%, он снова будет доступен для записи не раньше, чем его уровень его заполнения опустится хотя бы до 66%.

Если мы вернемся к отслеживанию поведения NGINX, то во втором случае c sendfile мы увидели вот что:

08:24:21 sendfile(5, 9, [6024754], 45748730) = 1768041

Успешно были отправлены 1,7 мегабайт данных, это близко к 33% от 5 мегабайт, нашего дефолтного значения размера буфера отправки wmem.

Вероятно, такой порог был установлен в Linux чтобы избежать пополнения буферов слишком часто. Нет необходимости «пинать» отправляющую программу после каждого отправленного байта.

Решение

Теперь мы можем точно сказать, когда случается проблема:

  1. Буфер отправки сокета заполнен на как минимум 66%.
  2. Скорость скачивания пользователем низкая, и буфер не опустошается до 66% за 60 секунд.
  3. Когда это происходит, буфер отправки не пополняется, он не считается доступным для записи, и соединение разрывается по тайм-ауту.

Существует несколько способов решить проблему.

Один – это увеличить send_timeout до, скажем, 280 секунд. Тогда при заданном размере буфера отправки, пользователи, чья скорость больше, чем 50Kb/s, не будут отключаться по тайм-ауту.

Другой вариант, это уменьшить размер буфера отправки tcp_wmem.

Ну и последний вариант, это пропатчить NGINX, чтобы он по-другому реагировал на тайм-аут. Вместо того, чтобы сразу закрывать соединение, можно посмотреть на объем данных в буфере отправки. Это можно сделать с помощью ioctl(TIOCOUTQ). Тогда мы сможем понять, насколько быстро опустошается буфер, и возможно дать соединению еще чуть-чуть времени.

Крис Бранч подготовил патч для NGINX, в котором реализован вариант с опцией send_minimum_rate, которая позволяет определить насколько медленное скачивание разрешено клиенту.

Выводы

Сетевой стек Linux очень сложен. Хотя обычно все работает хорошо, иногда в нем можно найти сюрпризы.

Не все даже опытные программисты знают все его внутренности. Пока мы разбирались с этой ошибкой, мы поняли, что назначение тайм-аутов в отношении записи данных требует особого внимания, нельзя просто так взять и задать тайм-ауты на запись такие же, как для чтения.

И теперь мы знаем, что из-за значений wmem можно получить неожиданные тайм-ауты при отправке данных.

ссылка на оригинал статьи https://habrahabr.ru/post/282465/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *