Примечание: однажды наши коллеги по цеху, специалисты из другого сервиса по ускорению и защите сайтов, столкнулись с тем, что некоторые очень медленные скачивания файлов пользователями внезапно обрывались. Ниже мы приводим перевод их рассказа о возникшей проблеме с нашими комментариями.
Проблема: некоторые пользователи не могли скачать бинарный файл объемом несколько мегабайт. Соединение почему-то обрывалось, хотя файл находился в процессе скачивания. Вскоре мы убедились, что где-то в нашей системе был баг. Воспроизвести проблему можно было достаточно просто единственной командой 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 можно получить неожиданные тайм-ауты при отправке данных.
Комментарии (13)
Dmitry_4
26.04.2016 14:42Нечто похожее происходит при скачивании яндекс-карт, например. Телефон блокируется, решает поэкономить электричество владельца, рвет соединение. При разблокировке докачка не выполняется. Раньше против этого всякие регеты помогали.
suratovvlad
26.04.2016 16:11Здесь, возможно, проблема приложения, а не сервера с яндекс-картами.
Насколько я понял из моих небольших познаний в написании приложения для Android, если требуется продолжение какого-либо действия приложения при заблокированном телефоне, то, во-первых, пишется сервис, выполняющий нужные действия, во-вторых, приложению выдается разрешение WAKE_LOCK, оно через PowerManager говорит, что не надо блочить процессор или экран (или все вместе), а, в-третьих, надо отдельно прописать, что не блочить WiFi — тоже не надо. Обычная мобильная сеть, судя по-всему, не блочится, если процессор не спит.
zuborg
27.04.2016 09:48Крис Бранч подготовил патч для NGINX, в котором реализован вариант с опцией send_minimum_rate, которая позволяет определить насколько медленное скачивание разрешено клиенту.
Все же лучше просто сделать workaround для этой особенности Linux ядра, тем более что это можно сделать без особенного оверхеда на проверку текущего размера буффера через ioctl(TIOCOUTQ):
— устанавливаем таймаут в половину от заданного. Для быстрых клиентов это не будет иметь значения.
— по достижению таймаута проверяем размер буффера, запоминаем, повторно устанавливаем таймаут (в половину от заданного).
— по достижению повторного таймаута делаем ещё раз проверку, если размер буффера не изменился (клиент не читает данные) — закрываем соединение, иначе ещё раз запоминаем новый размер, ставим опять половинный таймаут и повторяем.
Cheriksoft
27.04.2016 10:47Удивительно, что корни этой проблемы нашли только сейчас, где же они были во времена диалапа и безлимиток со скоростями 64-128 кбит/с?
До сих пор хорошо помнится то чувство 8-10 летней давности, когда скачивание какого-нибудь объемного архива через несколько часов внезапно и типа нормально завершалось в районе 90%, а по факту оказывался «неожиданный конец архива» и необходимость качать заново. К сожалению за давностью лет уже не помню, почему не пользовался всякими менеджерами закачек типа Регета, вроде они не особо дружили со всякими калечными файлообменниками типа покойной рапидшары, ну и я сам тогда скорее всего «не умел их готовить», как говорится.
iZENfire
03.05.2016 13:30Есть ли эта проблема на FreeBSD? На какие sysctl обратить внимание, чтобы проверить это?
% sysctl net.inet.tcp net.inet.tcp.rfc1323: 1 net.inet.tcp.mssdflt: 536 net.inet.tcp.keepidle: 7200000 net.inet.tcp.keepintvl: 75000 net.inet.tcp.sendspace: 32768 net.inet.tcp.recvspace: 65536 net.inet.tcp.keepinit: 75000 net.inet.tcp.delacktime: 100 net.inet.tcp.v6mssdflt: 1220 net.inet.tcp.nolocaltimewait: 0 net.inet.tcp.maxtcptw: 27767 net.inet.tcp.per_cpu_timers: 0 net.inet.tcp.v6pmtud_blackhole_mss: 1220 net.inet.tcp.pmtud_blackhole_mss: 1200 net.inet.tcp.pmtud_blackhole_failed: 0 net.inet.tcp.pmtud_blackhole_activated_min_mss: 0 net.inet.tcp.pmtud_blackhole_activated: 0 net.inet.tcp.pmtud_blackhole_detection: 0 net.inet.tcp.rexmit_drop_options: 0 net.inet.tcp.keepcnt: 8 net.inet.tcp.finwait2_timeout: 60000 net.inet.tcp.fast_finwait2_recycle: 0 net.inet.tcp.always_keepalive: 1 net.inet.tcp.rexmit_slop: 200 net.inet.tcp.rexmit_min: 30 net.inet.tcp.msl: 30000 net.inet.tcp.persmax: 60000 net.inet.tcp.persmin: 5000 net.inet.tcp.syncache.rst_on_sock_fail: 1 net.inet.tcp.syncache.rexmtlimit: 3 net.inet.tcp.syncache.hashsize: 512 net.inet.tcp.syncache.count: 0 net.inet.tcp.syncache.cachelimit: 15375 net.inet.tcp.syncache.bucketlimit: 30 net.inet.tcp.syncookies_only: 0 net.inet.tcp.syncookies: 1 net.inet.tcp.soreceive_stream: 0 net.inet.tcp.isn_reseed_interval: 0 net.inet.tcp.icmp_may_rst: 1 net.inet.tcp.pcbcount: 234 net.inet.tcp.do_tcpdrain: 1 net.inet.tcp.tcbhashsize: 131072 net.inet.tcp.log_debug: 0 net.inet.tcp.minmss: 216 net.inet.tcp.sack.globalholes: 0 net.inet.tcp.sack.globalmaxholes: 65536 net.inet.tcp.sack.maxholes: 128 net.inet.tcp.sack.enable: 1 net.inet.tcp.reass.overflows: 0 net.inet.tcp.reass.cursegments: 0 net.inet.tcp.reass.maxsegments: 45800 net.inet.tcp.sendbuf_max: 2097152 net.inet.tcp.sendbuf_inc: 8192 net.inet.tcp.sendbuf_auto: 1 net.inet.tcp.tso: 1 net.inet.tcp.path_mtu_discovery: 1 net.inet.tcp.recvbuf_max: 2097152 net.inet.tcp.recvbuf_inc: 16384 net.inet.tcp.recvbuf_auto: 1 net.inet.tcp.insecure_rst: 0 net.inet.tcp.ecn.maxretries: 1 net.inet.tcp.ecn.enable: 0 net.inet.tcp.abc_l_var: 2 net.inet.tcp.rfc3465: 1 net.inet.tcp.experimental.initcwnd10: 1 net.inet.tcp.rfc3390: 1 net.inet.tcp.rfc3042: 1 net.inet.tcp.do_pipe: 0 net.inet.tcp.drop_synfin: 0 net.inet.tcp.delayed_ack: 1 net.inet.tcp.blackhole: 0 net.inet.tcp.log_in_vain: 0 net.inet.tcp.hostcache.purge: 0 net.inet.tcp.hostcache.prune: 300 net.inet.tcp.hostcache.expire: 3600 net.inet.tcp.hostcache.count: 87 net.inet.tcp.hostcache.bucketlimit: 30 net.inet.tcp.hostcache.hashsize: 512 net.inet.tcp.hostcache.cachelimit: 15360 net.inet.tcp.cc.available: newreno net.inet.tcp.cc.algorithm: newreno
seregamorph
Я не очень силен во внутренностях nginx, но 5МБ в буфер отправки — слишком расточительно. Сколько одновременных медленных клиентов потянет такой сервер? Даже при уменьшении буфера до мегабайта. Или здесь идет расчет на то, что клиентов не сильно много?
К примеру, выдержит ли сервер простую атаку на открытие ~1000 одновременных медленных curl с единственного хоста? Да и файл может быть не 5МБ, а куда больше.
sunnybear
Файл положится в буфер. По поводу 1000 соединений: они, скорее всего будут заблокированы или попадут в очередь (точно не 1000 параллельно открытых файлов для 1 сервера). И 5 Гб оперативной памяти под буферизацию — это совсем мало.
Теоретически, можно на 1 сервер попробовать атаку slow download с 100 000 клиентов, которые будут качать разные большие файлы (500 Гб оперативной памяти точно нет на сервере). Но в архитектуре же не единственный сервер используется, а десятки и сотни. Т.е. для практической реализации этого вектора нужно несколько (десятков) миллионов разных клиентов, которые медленно «тянут» (и быстро бросают) разные большие файлы (чтобы забить буфера). При ботнете такого масштаба эффективнее использовать уже другие методы (хотя бы тот же DNS Flood).
zuborg
5ГБ под буфферизацию конкретно 1000 соединений — это очень много, а если эта 1000 соединений ещё и с ограничением по скорости — то это очень-очень много.
Гораздо оптимальнее будет использовать эту память под кеширование файлов, например.
vmarunin
Это внутренности ядра. net.ipv4.tcp_wmem это именно ядро.
Такой буфер создаётся под каждое соединение и размер файла не важен. Но и память будет невыгружаемая.
Больше того, ядро может раздуть буффер до 32 мегов (как написано в примере), но раздует оно его только если клиент будет быстрым.
Скорее всего объём RAM будет одним из ограничений на количество возможных соединений
Мне вот интересно, понимает ли ядро Linux что 100 одинаковых sendfile посылают один и тот же файл (можно же отследить теоретически) и используют одну копию в памяти или делают 100 буферов.
mcfist
насколько я помню sendfile, созданный специально чтобы не гонять данные из буфера в буфер, там память может вообще не расходоваться на буфер, используя чтение через mmap