Сервіс виявлення вузлів локальної мережі для Light Upon Cloud: конкурентний C#, UDP-мультикаст і Kademlia
Light Upon Cloud (LUC) — застосунок для обміну файлами, створений для офісів, де люди й без того навантажують свої машини: великі проєктні файли, відео, набори даних і той тиск на процесор та пам'ять, що йде разом із ними. Його ціна свідомо нижча за альтернативи на кшталт Dropbox, а в планах — відкритий код, що робить його цікавим і для клієнтів, і для розробників, які хочуть на ньому вчитися.
У міру зростання LUC знову й знову спливало одне вузьке місце. Кожне завантаження файлу проходило через центральний сервер, навіть коли ідентична копія вже лежала на комп'ютері колеги за сусіднім столом. У невеликому офісі цього ніхто не помічав; але коли офіс завантажений роботою, сервер брав на себе весь трафік, якому взагалі не потрібно було залишати будівлю, і передавання сповільнювалося для всіх. Саме з цієї проблеми й постав сервіс виявлення (Discovery Service, DS). Його головна ідея проста: перш ніж звертатися до хмари, перевірити, чи немає цього файлу вже десь у локальній мережі.
Зведеної інформації про конкурентний C#, виявлення вузлів у локальній мережі та протокол Kademlia на диво важко знайти, тож ми вирішили поділитися своїм досвідом. Якщо ви читали, як функція локальної синхронізації пришвидшує передавання файлів, то ця стаття розповідає про інженерію, що стоїть за нею.
Проблема: вузьке місце сервера в офісному обміні файлами
LUC тримає всіх учасників групи — скажімо, команду дизайну чи бухгалтерію — синхронізованими через спільну папку, за якою стоїть центральний сервер із файлами всіх груп. Модель легко осмислити, і саме тому LUC обрав об'єктне сховище замість традиційних мережевих файлових систем — рішення, розглянуте в наших нотатках про мережеві та розподілені файлові системи. Слабке місце — не рівень зберігання, а шлях до нього.
Коли десять людей в одному офісі завантажують той самий файл на 88 МБ, сервер віддає його десять разів через інтернет, і його навантаження зростає з кожним новим клієнтом. На практиці це виглядає як помилка перевантаження або передавання, що просто тягнеться. А дані ж часто вже присутні в LAN — на сусідньому вузлі, який міг би віддати їх на швидкості локальної мережі. Сервіс виявлення існує, щоб помічати цю можливість і користуватися нею, звертаючись до сервера лише тоді, коли LAN справді не може допомогти. Принципи стабільності самого серверного рівня розглянуто окремо в матеріалі як спроєктувати стабільну систему зберігання.
Цілі проєктування та архітектура
Сервіс сформували п'ять вимог: виявляти вузли локальної мережі лише тоді, коли це потрібно, і робити це безпечно та швидко; обмінюватися даними між будь-якою парою вузлів якомога меншою кількістю потоків; перевіряти кожне повідомлення, перш ніж відповідати на нього; ефективно оновлювати карту відомих вузлів; і завантажувати файли швидко, зберігаючи їхню цілісність. Серед наявних проєктів виявлення не знайшлося жодного, який поєднував би повторно використовуваний пул TCP-з'єднань із тими особливими вимогами до завантаження, що їх має LUC, тож сервіс довелося будувати з нуля.
Перша версія розрахована на Windows і .NET Framework 4.8 — так само, як сам LUC, — і написана на C# у Visual Studio 2022. В основі застосунку лежать чотири складові: UDP-мультикаст для пошуку вузлів, клієнтський пул TCP-з'єднань для швидкого й ощадливого зв'язку, маршрутна таблиця Kademlia, що підтримує карту мережі актуальною, і завантажувач, який тягне файли частинами одразу з кількох вузлів. Уся система керована подіями — їх сім, від «з'явився новий мережевий інтерфейс» до «сервіс зупинено», — тож її частини слабко пов'язані між собою і, як приємний наслідок, значно легше тестуються.
Пошук вузлів: підхід на основі UDP-мультикасту
Виявлення починається з UDP-мультикасту, а не широкомовної розсилки. Broadcast оголосив би формат повідомлень сервісу кожному пристрою в мережі, зокрема будь-якому зловмиснику на ній; мультикаст доставляє лише передплатникам обраної групи, що і тихіше, і безпечніше. Сервіс використовує IPv4-групу 239.255.0.251 та IPv6-групу FF02::D і слухає на порту 17500 — тому самому, що й Dropbox, — із робочим діапазоном від 17500 до 17510.
На старті сервіс періодично надсилає мультикаст-повідомлення — кожні три хвилини в робочому режимі, частіше в налагоджувальних збірках — і робить це, доки не знайде принаймні один вузол. Вузол, який отримав мультикаст і пройшов перевірку, відповідає через TCP підтвердженням зі своїм ідентифікатором і переліком груп, до яких належить його користувач. Тоді відправник виконує з цим вузлом процедуру Kademlia-bootstrap; у разі успіху він також надсилає йому пінг і додає його до своєї маршрутної таблиці. Оскільки все відбувається в локальній мережі, увесь обмін працює навіть без підключення до інтернету.
Вибір мережевих інтерфейсів — окрема невелика задача. Сервіс відкидає loopback-інтерфейси, які не можуть передавати трафік у локальній мережі, і запитує в IP Helper API Windows, який інтерфейс справді може досягти заданої адреси. Він також повторно підписується на сповіщення операційної системи про зміну мережі, бо на практиці ці події спрацьовують не завжди: якщо вимкнути й увімкнути Wi-Fi, не відключаючи адаптер, сервіс інакше може й не дізнатися, що щось змінилося. Саме цей механізм виявлення стоїть за функцією синхронізації в локальній мережі, яку бачить користувач.
Пул TCP-з'єднань — навіщо і як
Встановлення TCP-з'єднання коштує часу, а сервіс відкриває їх багато, тож він кешує клієнтські з'єднання з віддаленими вузлами в пулі з'єднань. Ідею запозичено в драйверів баз даних — за взірець правив пул у MySqlConnector (mysqlconnector.net), — але правила тут дібрано під сервіс виявлення, який має поводитися делікатно на чужих комп'ютерах.
Два правила тримають пул безпечним за високої конкурентності: щонайбільше один сокет на віддалену кінцеву точку і щонайбільше один потік на сокет. Друге правило — найскладніше, бо якщо два потоки почнуть читати той самий сокет, вони можуть перехопити дані одне одного. Обмеження в шістнадцять одночасно задіяних сокетів стримує навантаження, яке сервіс створює і на себе, і на інші вузли: чотири зарезервовано для завантаження одного файлу, решта — для відповідей на мультикасти й обміну трафіком Kademlia. Кожне поле в пулі — потокобезпечний тип, а сам клас розрісся настільки, що його розбито на кілька файлів за допомогою часткових класів C#.
Потік бере сокет, використовує його й повертає; при поверненні пул перевіряє стан сокета — під'єднаний, застарілий чи роз'єднаний — і перепідключає або відкидає його за потреби. Мережі змінюються, і коли комп'ютер від'єднується від однієї з них, операційна система непомітно закриває його сокети, тож пул скидає й відбудовує з'єднання у фоні щоразу, коли інтерфейс з'являється чи зникає. Самі сокети — це власний тип, що додає скасовуваність операцій, відстеження стану й налаштовувані таймаути поверх вбудованого; таких можливостей стандартний сокет не має.
Тримати карту мережі свіжою за допомогою Kademlia
Щойно вузли знайшли одне одного, сервісу треба стежити, хто зараз онлайн, не перевантажуючи мережу. Це й робить Kademlia. Kademlia — усталена однорангова схема маршрутизації, що зберігає контакти в адресному просторі на основі операції XOR; сервіс використовує реалізацію Clifton.Kademlia (Clifton.Kademlia на GitHub), обрану замість єдиної альтернативи для C#, бо її код набагато читабельніший, та й про неї написано цілу книгу.
Кожен вузол має 160-бітний ідентифікатор і впорядковує відомі йому контакти в бакети — до двадцяти в кожному — у вигляді бінарного дерева, ключами якого є ці ідентифікатори. Кожні тридцять хвилин сервіс оновлює свої бакети, запитуючи в кожного контакту вузли поблизу випадкового ідентифікатора; так карта непомітно лишається актуальною без жодного зайвого мультикаст-трафіку. Із чотирьох віддалених викликів Kademlia сервіс задіює два — перевірку доступності (Ping) і пошук вузла (FindNode), — а виклики збереження й отримання значень поки не використовує; вони добре надаються для майбутньої роботи.
Знадобилися дві адаптації. Еталонна реалізація спілкувалася з окремим сервером через HTTP, що не пасує сервісу, який має власні UDP- і TCP-сервери та власний пул з'єднань, тож цей транспортний рівень довелося замінити. А оскільки ідентифікатор, що живе лише один сеанс, змусив би перезапущений комп'ютер спілкуватися сам із собою й дублюватися в таблицях інших вузлів, кожен комп'ютер виводить стійкий ідентифікатор зі свого процесора та материнської плати й тому лишається впізнаваним після перезапусків. Кожен запит також несе випадковий ідентифікатор, який вузол має повернути, — дешевий спосіб пересвідчитися, що співрозмовник справді знає протокол.
Завантаження файлів із локальної мережі
Коли вузли виявлено й відстежено, настає найцікавіше — саме завантаження. Завантажувач ділить файли на малі й великі залежно від того, чи вміщується файл в один чанк розміром два мегабайти. У будь-якому разі початок однаковий: сервіс збирає онлайн-контакти, що належать до групи файлу, а потім запитує, хто з них справді має цей файл, ідентифікуючи його за закодованим шляхом, назвою та групою — деталями, що заразом підвищують безпеку.
Перш ніж щось записувати, завантажувач перевіряє, що на диску лишиться щонайменше 100 МБ вільного місця, а потім створює цільовий файл прихованим і з розширенням .part у спеціальній тимчасовій папці, щоб недовантажений файл ніхто не сплутав зі справжнім. Він одразу задає довжину файлу, щоб зарезервувати потрібне місце. Малий файл просто тягнеться по черзі з одного контакту за іншим, доки не надійде. Великий файл позначається як розріджений (sparse), щоб байти можна було записувати в будь-яке місце, ділиться на діапазони, розподілені між контактами, і завантажується паралельно, щонайбільше в чотири потоки; діапазони, що не надійшли, повторно запитуються в інших вузлів, і лише як останній засіб сервіс звертається до сервера. Коли всі частини на місці, з файлу знімається атрибут «прихований» і його переміщують на потрібне місце.
Завантаження обережно поводиться зі збоями. Різні наслідки — відсутній аргумент, скасування, брак вузла з потрібним файлом, частково завантажений файл чи помилка файлової системи — подаються окремими винятками, щоб застосунок міг відреагувати на кожен належно. Саме це робить швидке й безпечне передавання дуже великих файлів, описаних у матеріалі обмін великими файлами, реальним у межах одного офісу.
Як повідомлення перевіряються й розподіляються
Оскільки сервіс слухає у відкритій мережі, він вважає кожен вхідний пакет підозрілим, доки не доведено протилежне. Перш ніж сервіс відповість на повідомлення, воно проходить чотири перевірки. Буфер має бути правдоподібного розміру для свого типу. Байти мають відповідати бінарному протоколу сервісу — його читає й записує власний рівень кадрування, бо вбудований бінарний читач не вміє визначати, де закінчується одне повідомлення й починається наступне, а найперший байт вказує на тип повідомлення. Порт відправника має бути в діапазоні виявлення. А повідомлення, чий ідентифікатор траплявся за останні п'ять секунд, відкидається: так сервіс знешкоджує спробу зловмисника завалити вузли дублікатами, не караючи при цьому чесні вузли, які майже ніколи не повторять ідентифікатор так швидко.
Лише пройшовши ці перевірки, повідомлення потрапляє до обробника. UDP-дейтаграми йдуть від кроку отримання до обробника мультикасту, який піднімає публічну подію, а вона запускає TCP-підтвердження — сокет беруть із пулу й одразу повертають назад. TCP-повідомлення читаються із сесії та скеровуються до обробника, що відповідає їхньому типу: один для викликів Kademlia, один для запиту «чи є тут цей файл, такого розміру й версії?» і один для запитів чанків. Кожен, хто вбудовує сервіс, може додати власні обробники; вони просто виконуються після вбудованих.
Інтеграція з Light Upon Cloud
Вбудувати сервіс у LUC означало не просто викликати його під час завантаження. Фасад ховає всі налаштування: застосунок ініціалізує сервіс на старті через свій контейнер впровадження залежностей, але виявлення поки не запускає, бо сервіс, який почав би мультикаст ще до входу користувача, розкрив би належність цього користувача до груп кожному вузлу в мережі. Виявлення стартує лише після входу, а далі маршрутну таблицю оновлює Kademlia, а не сплеск мультикасту. Скасування пронизує всю систему: вихід із застосунку, перейменування папки чи зміна папки синхронізації скасовують активні завантаження, як і цикл синхронізації, коли на своєму тридцятисекундному опитуванні помічає, що файл, який завантажується, змінився на сервері.
Дорогою до цього в самому LUC виявилася низка проблем, які варто було виправити принагідно — від інсталятора й потокобезпечності до узгодження синхронізації та обробки конфліктів, — тож інтеграція заразом зміцнила й основний застосунок, у який вбудовувався сервіс.
Здобуті уроки та майбутня робота
Три речі спрацювали краще, ніж очікувалося. Пул TCP-з'єднань виправдав свою складність: зв'язок лишався швидким, а навантаження на кожен комп'ютер — обмеженим. Періодичне оновлення Kademlia виявилося правильним інструментом, щоб дешево підтримувати карту мережі актуальною, без зайвого мультикаст-трафіку, який породив би простіший підхід. А Docker Compose зробив цілком реальним відтворення багатовузлової мережі на комп'ютері одного розробника.
Дві речі виявилися складнішими. Потокобезпечність в асинхронному C# вимагала постійної уваги: найменша частка спільного змінюваного стану тягне за собою синхронізацію, і саме опора на незмінні дані у функціональному стилі спростила і коректність, і швидкодію. А інтеграція з наявною кодовою базою, що мала схильні до дедлоків патерни, означала спершу виправити основний застосунок, і лише тоді новий сервіс починав поводитися як слід — тому розділ про інтеграцію частково й читається як перелік змін у LUC.
Дорожня карта випливає прямо з тестування. Спершу — транспорт SSL/TLS, і задля безпеки, і щоб оминути агентів, які блокують звичайний TCP. Далі: перехід на інший порт, коли бажаний недоступний; запит прав адміністратора лише тоді, коли в брандмауері справді бракує правила; використання викликів збереження й отримання Kademlia для кешування версій файлів у межах групи, щоб завантаження стартували швидше; згортання решти власних замків на користь конкурентних колекцій; перевірка, що ігнорує повідомлення від вузла, який видає себе за ідентичність локальної машини; підтримка запуску кількох екземплярів; підтримка Linux і macOS; і, зрештою, публікація повторно використовуваних частин — власного сокета та пулу з'єднань — як окремих пакетів, із самим сервісом, повністю абстрагованим від будь-якого одного застосунку обміну файлами.
Ця стаття описує проєкт, у межах якого сервіс виявлення було інтегровано в Light Upon Cloud.