Одна маленькая проблема скачивания файлов на медленных соединениях
Примечание: однажды наши коллеги по цеху, специалисты из другого сервиса по ускорению и защите сайтов, столкнулись с тем, что некоторые очень медленные скачивания файлов пользователями внезапно обрывались. Ниже мы приводим перевод их рассказа о возникшей проблеме с нашими комментариями.
Проблема: некоторые пользователи не могли скачать бинарный файл объемом несколько мегабайт. Соединение почему-то обрывалось, хотя файл находился в процессе скачивания. Вскоре мы убедились, что где-то в нашей системе был баг. Воспроизвести проблему можно было достаточно просто единственной командой curl, но исправить ее потребовало невероятных затрат сил и времени.
Проблемные скачивания
Две вещи нас удивили в этой проблеме: во-первых, только пользователи мобильных устройств были подвержены проблеме, во-вторых, файл, который вызывал проблемы, был все-таки достаточно большим – порядка 30 мегабайт.
После плодотворной работы с tcpdump один из наших инженеров смог воспроизвести проблему. Оказалось, что достаточно положить большой файл для скачивания на тестовом домене, и использовать опцию --limit rate в команде curl :
Ковыряние в tcpdump показало, что всегда был RST-пакет, который прилетал с нашего сервера точно на 60 секунде после установки соединения:
Наш сервер точно делал что-то неправильно. RST-пакет, уходящий с нашего сервера, – это плохо. Клиент «ведет себя хорошо», присылает ACK-пакеты, потребляет данные с той скоростью, с которой может, а мы внезапно обрубаем соединение.
Не наша проблема?
Чтобы изолировать проблему, мы запустили базовый NGINX-сервер со стандартными настройками, и проблема оказалась легко воспроизводима локально:
Это показало, что проблема не является специфичной для нашей установки, это была более широкая проблема, связанная с NGINX.
После дальнейшего изучения, мы выяснили, что у нас используется настройка reset_timedout_connection . Это приводит к тому, что NGINX обрывает соединения. Когда NGINX хочет закрыть соединение по тайм-ауту, он задает SO_LINGER без тайм-аута на сокете, с последующим close() .
Это запускает RST-пакет вместо нормального завершения TCP-соединения. Вот лог strace из NGINX:
Мы могли бы просто отключить reset_timedout_connection , но это не решило бы проблему. Вопрос стоял так: почему вообще NGINX закрывает это соединение?
Далее мы обратили внимание на параметр send_timeout . Его значение по умолчанию – 60 секунд, в точности, как мы наблюдали в своем случае.
Параметр send_timeout используется в NGINX, чтобы убедиться, что все соединения рано или поздно будут завершены. Этот параметр контролирует время, разрешенное между последовательными вызовами send / sendfile в каждом соединении. Говоря по-простому, это неправильно, чтобы одно соединение использовало ресурс сервера слишком долго. Если скачивание длится слишком долго, или вообще прекратилось, это нормально, если http-сервер оборвет соединение.
Также и не-NGINX проблема
C strace в руках мы посмотрели, что NGINX делает:
В конфигурации мы указали NGINX использовать sendfile , чтобы передавать данные. Вызов sendfile проходит успешно и отправляет 5 мегабайт данных в буфер отправки. Что интересно: это почти такой же размер, который у нас установлен по умолчанию в буфере записи:
Спустя минуту после первого вызова sendfile сокет закрывается. Что будет, если мы увеличим значение send_timeout до какого-то большего значения, например 600 секунд:
После первого большого «выпихивания» данных, sendfile вызывается еще несколько раз. Между каждым последовательным вызовом он передает примерно 1,7 мегабайт. Между этими вызовами, примерно каждые 180 секунд, сокет постоянно пустел из-за медленного curl, так почему же NGINX не пополнял его постоянно?
Асимметрия
Девиз Unix: «всё является файлом». По-другому можно сказать «всё может быть прочитано или записано с помощью poll». Давайте рассмотрим поведение сетевых сокетов в Linux.
- Вызов read() будет возвращать данные, доступные в сокете, пока он не опустеет.
- poll отвечает, что сокет доступен для чтения, когда в нем есть какие-то данные.
- Вызов write() будет копировать данные в буфер, пока буфер отправки не заполнится.
- poll отвечает, что сокет доступен для записи, если в нем есть хоть сколько-нибудь свободного места.
Разные «пути» кода
Важно понять, что в ядре Linux есть два различных механизма работы исполняемого кода: по одному пишутся (отправляются) фактические данные, а по второму проверяется, является ли сокет доступным для записи.
- Должно быть свободное место в буфере отправки.
- Количество неотправленных данных, стоящих в очереди, должно быть меньше чем параметр LOWAT . В этом случае все было хорошо, поэтому просто опустим это условие.
- Должно быть свободное место в буфере отправки.
- Количество неотправленных данных, стоящих в очереди, должно быть меньше чем параметр LOWAT .
- Свободное место в буфере отправки должно быть больше, чем половина занятого места в буфере.
Если мы вернемся к отслеживанию поведения NGINX, то во втором случае c sendfile мы увидели вот что:
Успешно были отправлены 1,7 мегабайт данных, это близко к 33% от 5 мегабайт, нашего дефолтного значения размера буфера отправки wmem.
Вероятно, такой порог был установлен в Linux чтобы избежать пополнения буферов слишком часто. Нет необходимости «пинать» отправляющую программу после каждого отправленного байта.
Решение
- Буфер отправки сокета заполнен на как минимум 66%.
- Скорость скачивания пользователем низкая, и буфер не опустошается до 66% за 60 секунд.
- Когда это происходит, буфер отправки не пополняется, он не считается доступным для записи, и соединение разрывается по тайм-ауту.
Существует несколько способов решить проблему.
Один – это увеличить send_timeout до, скажем, 280 секунд. Тогда при заданном размере буфера отправки, пользователи, чья скорость больше, чем 50Kb/s, не будут отключаться по тайм-ауту.
Другой вариант, это уменьшить размер буфера отправки tcp_wmem .
Ну и последний вариант, это пропатчить NGINX, чтобы он по-другому реагировал на тайм-аут. Вместо того, чтобы сразу закрывать соединение, можно посмотреть на объем данных в буфере отправки. Это можно сделать с помощью ioctl(TIOCOUTQ) . Тогда мы сможем понять, насколько быстро опустошается буфер, и возможно дать соединению еще чуть-чуть времени.
Крис Бранч подготовил патч для NGINX, в котором реализован вариант с опцией send_minimum_rate , которая позволяет определить насколько медленное скачивание разрешено клиенту.
Выводы
Сетевой стек Linux очень сложен. Хотя обычно все работает хорошо, иногда в нем можно найти сюрпризы.
Не все даже опытные программисты знают все его внутренности. Пока мы разбирались с этой ошибкой, мы поняли, что назначение тайм-аутов в отношении записи данных требует особого внимания, нельзя просто так взять и задать тайм-ауты на запись такие же, как для чтения.
И теперь мы знаем, что из-за значений wmem можно получить неожиданные тайм-ауты при отправке данных.