Балансувальник DNS-запитів по UDP

Як я й обіцяв, ми випустили в opensource балансувальник клієнтських DNS-запитів по UDP. Код можна брати тут, під катом трохи технічних деталей.

TL;DR: epoll, SO_REUSEPORT, ldns, pthreads, CRC64+hashtable.

Питання балансування DNS постало після того, як ми вирішили виносити мережеві сервіси у клауд. Користувачам віддається два DNS-сервера, і співвідношення запитів між ними складає десь 70/30, тобто, на primary-рекурсор валиться більшість запитів. Очевидно, що в такій схемі віддавати рекурсори напряму користувачам не варто, бо один із них буде завжди недовантажений (нагадаю, що клауд — це віртуалки, і ми дуже зацікавлені в тому, щоб, по-перше, рівномірно розмазати навантаження між ними, а, по-друге, мати змогу балансувати це навантаження так, як нам потрібно). Також ми відходимо від використання bind’а, принаймні, для рекурсивних запитів, оскільки він занадто багато жере ресурсів (якщо цікаво, зупинили свій вибір на PowerDNS після тривалого навантажувального тестування).

Тому зараз схема склалася така: користувачам віддається два балансера (які працюють в LXC-контейнерах для зменшення оверхеду), а самі балансери «метеликом» розкидають навантаження на два рекурсора, які розміщуються в різних віртуалках на різних гіпервізорах. Окрім того, що балансери розкидають DNS-запити, на них ще працює keepalived (як окремий демон, так і схожий вбудований у балансер механізм), і якщо щось валиться, наприклад, один із балансерів чи один із рекурсорів, усе навантаження автоматично переїжджає на інше плече схеми.

Проблема з балансуванням UDP в цілому, як виявилося, полягає в тому, що, здавалося б, мало бути його перевагою. Це я про встановлення з’єднання так кажу. Уявіть собі на місці балансера. Вам приходить запит від клієнта, наприклад, щось типу «дай мені A-запис вкантактєга». Ви, не особливо задумуючись, перенаправляєте цей запит рекурсору, чекаєте на відповідь, і отримавши її, віддаєте клієнту. Усе в шоколаді?

Как би нє так.

Отака схема, яка і прийшла першою в голову, відразу містить дві архітектурні проблеми.

По-перше, там є слово «чекаєте». Поки ви на місці балансера чекаєте, інші запити від клієнтів кладуться в ядерний буфер, і балансер їх не обробляє. Можна, наприклад, згенерувати один тред на один запит, але уявіть собі, що ви — другий за ARPU провайдер України. Розумно на сервері активно форкати більше 10 тисяч тредів на секунду? Відповідно, потрібен статичний пул потоків, які б займалися різною роботою: прийманням запитів від клієнтів, форвардінгом їх рекурсорам, прийманням відповідей від рекурсорів і видачею відповідей клієнтам. Добре, нехай буде пул потоків, але якщо вони так само будуть «чекати», тобто, обробляти клієнтські запити синхронно, то більше 20 запитів на секунду навряд чи все це вийде розігнати. Тому потрібна асинхронна схема, за якої ланцюжок обробки запиту не був би серіалізованим. А отут раптом вилазить друга і, як виявилося, основна проблема.

Знову уявіть себе на місці балансера, тільки нехай у вас тепер буде не пара рук, а штук так вісім. Назвемо ці руки воркер-тредами, по одному на процесорне ядро. Гляньте вище, я вже розписав ланцюжок, який проходить DNS-запит від клієнта: прийняти запит, передати запит, прийняти відповідь, передати відповідь. Давайте пройдемо по кожному з пунктів.

Клієнт надсилає запит балансеру. Балансер його приймає, розбирає пакет, дивиться, що він коректний, віддає його рекурсору і… забуває про нього. Нагадую, UDP працює без установлення з’єднання. Рекурсор, відпрацювавши запит, віддає відповідь балансеру, балансер її приймає і раптом не розуміє, що з ним робити, бо в пакеті немає інформації про клієнта, який надіслав відповідний запит, а з’єднання в UDP, нагадую, не встановлюється. Тому для того, щоб віддати потрібну відповідь саме тому клієнту, який її хоче отримати, необхідно забезпечити збереження відповідності між сокетом клієнта (а це пара «IP-порт») та інформацією в DNS-пакеті. І нам на місці балансера не можна махати бездумно вісьмома руками, а необхідно лізти на L7 і розбирати DNS-пакет на частини.

Окей, є пакет від клієнта. Як правильно зберегти інформацію про нього? Я зробив так: для кожного запиту генерується унікальний рядок, який містить у собі номер сокета вибраного рекурсора, ID DNS-пакета, тип запиту, клас запиту і FQDN запиту. Від цього рядка береться CRC64-сума, яка використовується для розмазування запитів по хеш-таблиці. Якщо виникає колізія хеша, запит засовується в той же кошик таблиці (кожен кошик — це просто динамічний список під локом, але оскільки таких кошиків дофіга, наприклад, 10240, то і lock contention має бути рідкісним явищем). Разом із таким згенерованим унікальним ідентифікатором пакета, який однозначно ідентифікує запит клієнта, засовується в таблицю і інформація про клієнтський сокет, куди цю інфу потрібно віддавати. Штука в тому, що аналогічний ідентифікатор можна запросто отримати і з відповіді рекурсора, оскільки вся потрібна інформація в ній вертається назад. Таким чином, після взяття CRC64 від відповіді рекурсора однозначно і за O(1) (з поправкою на хеш-колізії, які вже розрулюються звичайним лінійним пошуком з O(n)) ідентифікується клієнт, якому потрібно віддати відповідь.

Описана схема в нас на тестах пропускала через себе десятки тисяч запитів на секунду і не жужжала. Поки не всі користувачі переїхали на неї, через балансери сумарно проходить близько 4 тисяч запитів на секунду, і зі статистики завантаженості ресурсів я приблизно можу сказати, що це число може бути як мінімум у 10 разів вищим (це ще враховуючи мою економність під час виділення ОЗП і ядер віртуалкам).

Технічно у схему вписалися: SO_REUSEPORT, завдяки якому ядро само розкидає навантаження між потоками; epoll, який мультиплексує I/O для воркерів; і pthreads, без яких жити не можна.

Звісно, простір для «покращення» ще є. Мені б хотілося розібратися з lock-free hash tables, а ще можна подумати про те, як зробити з лінійного пошуку бінарний, тобто, сортувати елементи одного кошика хеш-таблиці при вставці, щоб потім не перебирати їх підряд. І, звісно, порівняльно відтестувати все це, щоб не було регресій швидкодії, бо я такий розумний намагався вже брати готові in-memory високопродуктивні БД і ловити на них торррмооозззааа.

Висловлюю подяку розробникам ldns за просту та зрозумілу бібліотеку для роботи з DNS, а також Андрію Костенецькому за навантажувальне тестування балансера.

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

*

Цей сайт використовує Akismet для зменшення спаму. Дізнайтеся, як обробляються ваші дані коментарів.