RingSync: синхронизируем на полной скорости сети
В комментарии к статье про dVHD заморозку системы я намекнул на быстрый способ распространения/синхронизации больших файлов по локальной сети.
Конкретно, в моем случае VHD файл весил примерно 120 GiB, и его нужно было залить на 30 PC. При каждом обновлении VHD он перезаливался. Под катом читайте историю, про то, как с таким большим файлом не справлялся BTSync, и про создание RingSync.
Начну с небольшого QA (Вопрос-Ответа), сравнивая различные схемы обмена данными.
Классика: Server → Client
Q: Что происходит в классическом варианте, когда один сервер раздает файл множеству клиентов?
A: Допустим, что и на сервере и на клиентах установлены обычные HDD (последовательное чтение/запись выполняется быстро, произвольное - медленно).
У нас есть:
- 1 сервер (файловое хранилище);
- 4 клиента (загружающие с файлового хранилища один и тот же файл);
- 1 switch (все порты работают в дуплексном режиме; скорость соединения на всех портах одинаковая).
Еще один важный момент - скорость используемых HDD (при последовательных операциях доступа к данным) выше скорости сетевого подключения.
В этом случае узким местом будет сетевое соединение между сервером и switch'ом. Сервер инициирует 4 потока (по потоку для каждого клиента) передачи данных, которые разделят общую пропускную способность сетевого соединения сервера.
В итоге скорость загрузки упадет в 4 раза, по сравнению со случаем, когда только 1 клиент загружает файл. Чем больше клиентов, тем меньше скорость загрузки у каждого из этих клиентов. Для 30 клиентов и 100 Mbps сети 120 GiB файл будет загружаться минимум 3 дня 13 часов и 54 минуты, т.е. для клиента 100 Mbps превратятся в 3 Mbps .
При этом сервер вынужден несколько раз (по количеству клиентов) отправлять одну и ту же часть файла в сеть. Последствия этого можно увидеть, сравнив две ситуации (два граничных условия):
- Все клиенты запустили загрузку файла в одно и то же время.
- Запуск загрузки не был синхронизирован, например, получился разброс ±10 минут.
Вспомним, что у нас обычные HDD, которым трудно дается произвольный доступ к данным...
В первом случае система сможет кешировать считанную один раз из HDD часть файла (для одного клиента), и будет раздавать оставшимся клиентам эту часть файла уже из кеша (без обращения к HDD). Для HDD это будет выглядеть, как последовательное чтение данных, которое ему легко дается.
Во втором случае клиенты станут обращаться к разным частям, и у системы будет меньше шансов повторно воспользоваться кешированными частями файла. Для HDD это будет выглядеть как произвольный доступ к данным. Например, второй клиент запустил загрузку с 10 минутным отставанием от первого клиента, значит, первый клиент уже успел загрузить 7 GiB, и чтобы система смогла отдавать части файла для второго клиента без повторного обращения к HDD, ей нужен кеш размером в 7 GiB.
В реальности 100% “попадание” в файловый кеш не требуется, например, в кеш можно помещать не только текущую часть файла, но и несколько следующих частей...
P2P: BTSync
Q: В чем заключается польза от использования p2p в локальной сети?
A: Если p2p клиенты используют следующую схему передачи данных:
- сид отправляет каждому пиру разную часть файла,
- каждый пир пересылает полученную часть файла другому пиру,
- сид отправляет каждому пиру разную часть файла, которую сид не отправлял никому ранее,
- одновременно с предыдущим пунктом, пиры продолжают обмениваться частями файла,
- пункты 3-4 повторяются, пока сид не раздаст все части файла, и все пиры получат эти части
, и все они подключены к одному switch'у, то, теоретически, скорость получения данных пиром не будет уменьшаться с увеличением числа пиров (в классическом варианте Server → Client скорость уменьшалась). Это работает благодаря возможности switch “соединять” любые 2 своих порта, и данные между этими портами смогут передаваться на полной скорости соединения.
Похожий режим работы в BitTorrent клиентах называется Super-seeding.
Q: Какой может быть негативный эффект от использования этой схемы?
A: Так как теперь части файла передаются не последовательно (от начала до конца), а в разброс, то скорость чтения(для сида)/записи(для пира) с/на HDD упадет (последствия произвольного доступа к данным). Поэтому клиенты должны выбирать оптимальный размер части файла, и использовать адаптированный под эту схему работы алгоритм кеширования.
История про BTSync и большой файл
Про BTSync написано уже много статьей ( 1 2 3 ), но самое интересное можно было найти на официальном форуме (он переехал сюда). Вкратце, баги были, и с ними приходилось бороться, например, в некоторых версиях были проблемы с Local Peer Discovery (не всегда работал) - обходилась проблема прописыванием IP одного из пиров в сети. У него есть еще тележка интересных “особенностей”, но сейчас речь пойдет про его работу с большими файлами...
Для развертывания системы на VHD, был собран минимальный WinPE, содержащий:
- BTSync - запускался через “BTSync.exe /config config.json”;
- программу ShowWindow, которая используя FindWindow, SendMessage (WinAPI), и переданное ей имя класса окна “BTSync4823DF041B09”, открывает скрытое окно BTSync (он запускается “свернутым в трей”, а трея у нас нет...);
- драйверы (в основном для NIC);
- скрипты (startnet.cmd: тюнинг сетевого стека, вызов пред и пост разверточных скриптов).
Всё это происходило в середине 2014 года (или 2013...), и пишется по памяти4.
На сиде BTSync запускался с RW секретом, а на пирах - с RO секретом (с включенной опцией принудительной синхронизации, если файл существует, и отличается от файла у сида).
После трех “разворачиваний” системы по 8-10 PC за раз (скорость сетевого соединения 100 Mbps; PC подключены к одному switch; максимальная скорость HDD была в районе 110 MiBps; размер файла тот же - 120 GiB), собралась следующая “статистика”:
- после запуска BTSync на всех PC, и начала синхронизации, в районе часа сетевая активность была минимальна, т.е. файл не передавался;
- на некоторых (1-2) пирах процесс синхронизации прерывался, и приходилась вручную перезапускать BTSync;
- при подключении через нормальный switch средняя скорость загрузки была 8 MiBps (падала до 6, поднималась до 9) - при отключенном шифровании трафика;
- при подключении через устройство, очень похожее на ZyXEL ES-116S, средняя скорость загрузки была 3.4 MiBps (иногда поднималась до 6) - шифрование трафика также было отключено.
Эксперимент 1
После этого было решено провести эксперимент: взять 2-3 PC, нормальный switch, и добавить Process Explorer в WinPE для наблюдения за активностью BTSync. Судя по описанию проблем на форуме - у каждой версии имелся свой набор “особенностей” (новая версия не значит более лучшая версия), поэтому в эксперименте участвовало несколько версий BTSync (1.0.134 - 1.3.106). После каждого опыта, скаченный пирами файл стирался, и все PC перезагружались.
- И так, запускаем BTSync на сиде, запускаем на одном пире, и вуаля: сетевая активность появляется сразу же, и файл синхронизируется со скоростью 11 MiBps.
- Теперь сделаем то же самое, но запустим два пира одновременно: сетевая активность минимальна, у сида наблюдается высокая дисковая активность (BTSync запустил подсчет контрольных сумм частей файла?), и только после уменьшения дисковой активности начинается синхронизация с некоторыми “особенностями” (про них - ниже).
- А если взять первый вариант, и запустить второго пира посередине синхронизации: синхронизация приостанавливается, и возрастает дисковая активность у сида (вычисляет контрольные суммы?), затем (когда вычислит все контрольные суммы?) синхронизация продолжается (опять же с “особенностями”).
Особенности синхронизации заключались в том, что при каждом новом запуске, схема работы отличалась от предыдущего запуска. Всего получилось 3 схемы работы (сокращения: сид - s, пир1 - p1, пир2 - p2):
1. s --{7 MiBps}--> p1; s --{4.5 MiBps}--> p2; p1 --{7 MiBps}--> p2; p2 --{4.5 MiBps}--> p1; через определенный промежуток времени p1 и p2 меняются местами: s --{7 MiBps}--> p2; s --{4.5 MiBps}--> p1; p2 --{7 MiBps}--> p1; p1 --{4.5 MiBps}--> p2; смена происходит с определенным периодом времени; 2. s --{10 MiBps}--> p1; после того, как p1 все загрузит: s --{10 MiBps}--> p2; 3. s --{7.5 MiBps}--> p1; s --{3.5 MiBps}--> p2; p1 --{7.5 MiBps}--> p2; p2 --{3.5 MiBps}--> p1; затем: s --{4.5 MiBps}--> p2; p2 --{4.5 MiBps}--> p1; и возвращение: s --{7.5 MiBps}--> p1; s --{3.5 MiBps}--> p2; p1 --{7.5 MiBps}--> p2; p2 --{3.5 MiBps}--> p1; смена происходит с определенным периодом времени.
Причем первая схема работы (самая лучшая) включалась, когда BTSync на обоих пира запускался одновременно. Если же была задержка между запусками, то чаще встречалась схема 3 или 2.
Что касается различий в версиях BTSync, то в опытах с более новыми версиями второй вариант схемы встречался реже. Но это ничего не значит, т.к. испытания каждой версией повторялись всего 4 раза, что очень мало. Версии 1.0.134 и 1.3.106 испытывались большее число раз.
Однако в новых версиях (предположительно начиная с 1.3. x), появилась еще одна “особенность”: когда размер загружаемого файла (можно посмотреть командой dir в консоли) на пире достигает 70% (или около того), рост размера файла приостанавливается на некоторое время.
Эксперимент 2
За основу брался первый эксперимент, с отличиями:
- использовалась только последняя версия BTSync;
- загруженный пирами файл не удалялся, а изменялось его содержимое.
Эти испытания BTSync провалил - синхронизация с сидом так и не начиналась (опция принудительной синхронизации была включена).
В повседневном использовании с 1 GiB файлами мне помогала "легкая приостановка" - через контекстное меню “иконки в трее” пира поставить BTSync на паузу, а затем убрать паузу.
RingSync
Во время борьбы с “особенностями” BTSync появились несколько мыслей:
- большинство проблем с BTSync связаны с теми возможностями, которые в данном случае не нужны;
- нам не нужно отслеживание изменений в файле, т.к. каждый раз его все равно приходится передавать целиком, и источник всегда один - сид;
- следовательно, нет необходимости в отдельном этапе подсчитывания контрольных сумм, и определения “кто из пиров обладает более новой версией файла”;
- у нас всего один файл;
- HDD попросил сделать последовательное чтение/запись файла
.
С этими мыслями родилась следующая схема:
- сид передает часть файла пиру;
- пир сохраняет ее на HDD, и параллельно передает следующему пиру (то же самое делают остальные пиры в цепочке);
- лич (последний пир) просто сохраняет часть файла на HDD.
Причем это все происходит параллельно (в потоковом режиме) - когда первый пир отправляет часть файла следующему пиру, он уже получает следующую часть файла от сида. А сам файл передается от начала до конца, что очень хорошо для HDD (последовательный доступ к данным).
От switch здесь требуется всего лишь “соединить” Rx одного порта с Tx другого порта на полной скорости (см. схему), с чем он отлично справляется.
Программа на Golang
На современном языке сделать PoC этой схемы не составит никакого труда. На Golang основную часть программы пира можно записать всего в одну строчку:
io.Copy(io.MultiWriter(outFile, peerConn), seedConn)
, все остальное - обвязка.
Последовательность запуска
Код RingSync написан максимально просто, и при этом в нем предусмотрена возможность запуска сида и пиров в произвольном порядке. Что это означает? Можно одновременно запустить всех пиров и сида, дождаться пока они включатся, а затем запустить лича. Именно лич запускает процесс синхронизации.
Под капотом это выглядит так:
- запускаются пиры и лич, и начинают слушать входящие соединения;
- запускается лич, и устанавливает соединение с последним пиром;
- каждый пир устанавливают соединение с предыдущим пиром (по цепочке);
- первый пир устанавливают соединение с сидом, и сид начинает отправку данных.
Если немного пожертвовать простотой, и добавить возможность переподключения при неудачной “установке соединения”, то можно будет и лича, и сида, и пиров запускать в любой последовательности.
Репозиторий
RingSync можно форкнуть здесь.
Там же можно скачать уже собранный для Windows 32bit бинарник.
Q: Почему именно Windows 32bit?
A: Потому, что я его использовал с WinPE 32bit. К тому же, вместе с RingSync лежит специальный launcher, который облегчает конфигурирование после сетевой загрузки. Launcher считывает конфиг из файла (одного и того же для всех PC), и в зависимости от MAC-адреса первого сетевого интерфейса PC, настраивает этот сетевой интерфейс, и запускает RingSync с нужными параметрами. Сейчас launcher завязан на использование netsh, поэтому результат кросс-компиляции под не Windows работать не будет. В отличие от launcher, у самого RingSync таких проблем нет
Опции запуска RingSync приводить не буду - их можно увидеть, просто запустив его без опций. А вот примеры для разных режимов (сид, пир, лич) будут полезны.
Сид (слушает порт 5001, ожидает подключения от лича/пира с IP “192.168.0.2”, рассылает содержимое файла “big_file.vhd”):
RingSync -mode=seed -port=5001 ^ -leech=192.168.0.2 -if=big_file.vhd
Пир (слушает порт 5001, ожидает подключения от лича/пира с IP “192.168.0.3”, подключается к порту 5001 сида/пира с IP “194.168.0.1”, записывает полученные данные в файл “big_file.vhd”):
RingSync -mode=peer -port=5001 ^ -leech=194.168.0.3 ^ -seed=194.168.0.1:5001 -of=big_file.vhd
Лич (подключается к порту 5001 сида/пира с IP “194.168.0.1”, записывает полученные данные в файл “big_file.vhd”):
RingSync -mode=leech ^ -seed=194.168.0.2:5001 -of=big_file.vhd
Сборку этих же строк можно увидеть в исходниках launcher'а.
TCP Congestion Control
До написания кода RingSync была идея собрать свой TCP-велосипед, в котором данные передавались бы по цепочке от сида -> через пиров -> к личу, а ответ (ACK), подтверждающий получение данных, отправлял бы лич к сиду. Причем, если сида переименовать в “сервер”, лича - в “клиент”, а пиров в “маршрутизаторы”, то получится типичная схема передачи данных в Интернете. Вот только маршрутизаторы не сохраняют переданные через себя данные. В этот TCP-велосипед, можно было бы включить наиболее подходящий для данных условий передачи данных TCP Congestion Avoidance Algorithm ( 1 2 3 ), а не полагаться на системный TCP Congestion Control и его настройки. А в будущем можно было бы получить больший контроль над передачей данных, например, добавив возможность отправки сообщений о “заторах” в сети или HDD.
Но был и более легкий путь - использовать стандартный системный TCP. В этом случае пир, получивший данные, сам отправляет подтверждение (об успешной доставке данных) пиру, отправившему эти данные. И информация о случившимся “заторе” автоматически передается через цепочку пиров к сиду:
- вначале ближайший к “затору” пир, не получив вовремя подтверждение, сбрасывает скорость, и его буфер на прием данных (входной буфер) начинает наполняться;
- при переполнении буфера, предыдущий в цепочке пир также перестает получать подтверждения вовремя, и начнет сбрасывать скорость;
- то же самое делают остальные пиры, вплоть до сида.
Это напоминает движение длинного состава (локомотив-сид тянет спереди) с большим числом вагонов (пиров), и пружиной (буфером) вместо сцепки.
На самом деле второй вариант даже лучше, т.к. для некоторых алгоритмов TCP Congestion Avoidance (косвенно это влияет и на остальных) чем меньше RTT - тем лучше. А в первом варианте мы заведомо создаем более неблагоприятные условия...
В итоге, второй вариант, даже в сети с двумя switch:
switch{1 Gbps}[сид -> пир -> пир] └─{100 Mbps}—↓ switch{100 Mbps}[пир -> пир -> пир -> лич]
показал стабильные 11.8 MiBps.
PC подключены к разным switch
В сети с несколькими switch узким местом может стать соединение между ними. Например, если строить цепочку пиров в произвольном порядке, то при сети:
switch1{1 Gbps}[сид, пир2, пир4, пир6] └─{1 Gbps}─┐ switch2{1 Gbps}[пир1, лич, пир3, пир5]
, и цепочке:
сид->пир1->пир2->пир3->пир4->пир5->пир6->лич
, скорость упадет в 4 раза.
Q: Почему скорость падает в 4 раза? Как получилось это число “4”?
A: Если возьмем все потоки данных в направлении от switch1 к switch2, то получим:
сид -> пир1
пир2 -> пир3
пир4 -> пир5
пир6 -> лич
Теперь посмотрим на потоки данных в обратном направлении (от switch2 к switch1):
пир2 <- пир1
пир4 <- пир3
пир6 <- пир5
В направлении от switch1 к switch2, общий канал в 1 Gbps делили 4 потока, получая максимум 0.25 Gbps на поток. В обратном направлении получилось 3 потока, соответственно максимум 0.33 Gbps на поток. Самым узким местом стал канал от switch1 к switch2 (максимальная скорость потока 0.25 Gbps). В самом узком месте скорость потока в 4 раза меньше, по сравнению с максимальной скорости в “самом широком месте” (1 Gbps).
Q: Как исправить ситуацию?
A: Достаточно строить цепочку примерно так (можно сразу перейти к TL;DR) :
- добавить в цепочку сида;
- взять switch, к которому подключен сид, и добавить в цепочку всех пиров (в любом порядке), подключенных к этому switch;
- взять следующий, ранее не взятый, switch (к которому подключены пиры), подключенный к предыдущему switch (возможно не прямое подключение, а подключение через несколько промежуточных switch, к которым не подключен ни один пир), и добавить в цепочку всех пиров (в любом порядке), подключенных к этому switch;
- повторять предыдущий пункт, пока он будет выполним (“следующий switch” будет существовать);
- если можно вернуться к предыдущему switch, то вернуться к нему, и выполнить предыдущий пункт;
- последнего пира в цепочке назвать личем.
TL;DR: используем обход графа в глубину, при этом:
- вершины - это switch;
- в вершине хранится список пиров, подключенных к этому switch/вершине;
- начинаем со switch/вершины, к которому подключен сид;
- при входе в вершину, добавляем в цепочку всех пиров из списка этой вершины.
Для использования этого способа нужна информация о топологии сети на канальном уровне.
Ссылки «в тему»
- Project Iris - Completely decentralized cloud messaging
- Filesync ( Golang : (
- Недостатки TCP и новые протоколы транспортного уровня
- Catapult - Fast Data Transfers
- Pulse - free (as in freedom), secure, and distributed file synchronization engine
- Фильм Unstoppable 2010 + сдвинуть тяжелый состав ("Интересный факт" про сцепку; подробнее)
Как обойтись без копирования большого VHD файла по сети?
На самом деле можно не копировать каждый раз полный VHD при обновлении системы, а распространять только dVHD с обновлением. Такая схема обновления работает следующим образом (система обновляется на одном Master-PC, и копируется на множество Slave-PC):
- На Master-PC все изменения, в ходе обновления системы, сохраняются в новый dVHD файл.
- Все Slave-PC должны хранить оригинальный VHD файл с Master-PC, поэтому на них нужно создавать дополнительный dVHD файл, в котором будут сохранены все отличия Slave-PC от Master-PC. Без этого следующий пункт работать не будет.
- На Slave-PC копируется dVHD файл с обновлением, и, затем, объединяется с VHD файлом (если бы VHD файл на Master-PC и Slave-PC отличался, то объединение завершилось бы с ошибкой).
- На каждом Slave-PC заново создается тот дополнительный dVHD файл, хранящий отличия каждого Slave-PC от Master-PC.
- На Master-PC dVHD файл с обновлением системы также объединяется с VHD файлом.
При этом время распространения обновления будет включать в себя время копирования обновления на Slave-PC + время объединения VHD с dVHD. Здесь важно, чтобы объединение выполнялось быстро, иначе суммарное время (копирование dVHD + объединение) может превысить время простого копирования цельного VHD файла. Еще один минус – из-за появления на Slave-PC дополнительного dVHD файла, система будет работать медленней.