Примечание: однажды наши коллеги по цеху, специалисты из другого сервиса по ускорению и защите сайтов, столкнулись с тем, что некоторые очень медленные скачивания файлов пользователями внезапно обрывались. Ниже мы приводим перевод их рассказа о возникшей проблеме с нашими комментариями.
Проблема: некоторые пользователи не могли скачать бинарный файл объемом несколько мегабайт. Соединение почему-то обрывалось, хотя файл находился в процессе скачивания. Вскоре мы убедились, что где-то в нашей системе был баг. Воспроизвести проблему можно было достаточно просто единственной командой 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 чтобы избежать пополнения буферов слишком часто. Нет необходимости «пинать» отправляющую программу после каждого отправленного байта.
Решение
Теперь мы можем точно сказать, когда случается проблема:
- Буфер отправки сокета заполнен на как минимум 66%.
- Скорость скачивания пользователем низкая, и буфер не опустошается до 66% за 60 секунд.
- Когда это происходит, буфер отправки не пополняется, он не считается доступным для записи, и соединение разрывается по тайм-ауту.
Существует несколько способов решить проблему.
Один – это увеличить send_timeout
до, скажем, 280 секунд. Тогда при заданном размере буфера отправки, пользователи, чья скорость больше, чем 50Kb/s, не будут отключаться по тайм-ауту.
Другой вариант, это уменьшить размер буфера отправки tcp_wmem
.
Ну и последний вариант, это пропатчить NGINX, чтобы он по-другому реагировал на тайм-аут. Вместо того, чтобы сразу закрывать соединение, можно посмотреть на объем данных в буфере отправки. Это можно сделать с помощью ioctl(TIOCOUTQ)
. Тогда мы сможем понять, насколько быстро опустошается буфер, и возможно дать соединению еще чуть-чуть времени.
Крис Бранч подготовил патч для NGINX, в котором реализован вариант с опцией send_minimum_rate
, которая позволяет определить насколько медленное скачивание разрешено клиенту.
Выводы
Сетевой стек Linux очень сложен. Хотя обычно все работает хорошо, иногда в нем можно найти сюрпризы.
Не все даже опытные программисты знают все его внутренности. Пока мы разбирались с этой ошибкой, мы поняли, что назначение тайм-аутов в отношении записи данных требует особого внимания, нельзя просто так взять и задать тайм-ауты на запись такие же, как для чтения.
И теперь мы знаем, что из-за значений wmem можно получить неожиданные тайм-ауты при отправке данных.
Автор: sunnybear