Балансувальник 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 для зменшення спаму. Дізнайтеся, як обробляються ваші дані коментарів.