Привет, Хабр!
Три месяца назад я начал делать NextDNS-clone для Европы. Рекурсивный DNS с фильтрацией рекламы, трекеров и malware. Первый день: открываю Unbound, читаю man, всё понятно. К вечеру понимаю, что не подходит. Через неделю пишу свой резолвер на Go и вспоминаю поговорку про человека, который решил написать почтовый сервер. Никогда такого не было, и вот опять.
Сейчас в проде: 10 нод по миру, отвечает на DoH/DoT, фильтрует по миллионам доменов, RAM 60 МБ на ноду. Расскажу, почему ушёл от готового, что было больно, и где Unbound всё ещё быстрее. Спойлер: почти везде, но в наших условиях это не имеет значения.
В конце ссылки на open-source ядро резолвера и наш бесплатный тариф, можно потыкать.
Зачем вообще свой резолвер, если есть Unbound
Unbound — отличный софт. PowerDNS Recursor тоже. Я их обожаю как обожают старого кота: за то, что они есть и не требуют объяснений. В обычной ситуации просто бы поставил один из них и пошёл пить чай. Но у меня было три требования, которые ни тот, ни другой не закрывают штатно.
Миллионы DoH-эндпойнтов с разными правилами фильтрации
В NextDNS-like продукте каждый юзер получает свой config_id, короткий публичный токен в URL: https://dns.vantagedns.com/<config_id>/dns-query. У одного юзера может быть профиль «дом», «дети», «офис», у каждого свои блок-листы и whitelist. Это не «один резолвер с одним конфигом», это десятки тысяч логических резолверов на одной ноде.
В Unbound views есть, но они не масштабируются на десятки тысяч независимых наборов правил без боли с конфиг-генерацией и SIGHUP’ом каждые пять минут. Я как-то видел подобную конструкцию в одной телекомовской конторе в 2014-м. Там админ носил с собой бумажку с порядком действий, чтобы не забыть. Не хотелось в это ввязываться.
In-memory query log без записи на диск
Edge-нода не должна писать на диск ничего, что относится к юзеру. Это часть позиционирования. Поэтому query log живёт в кольцевом буфере в памяти, async-шипится в ClickHouse в Хельсинки, и всё. Unbound пишет логи в файл; для async-shipping надо городить tail+парсер.
Retention: 24 часа на free, до 30 дней на платных, дальше ClickHouse TTL чистит.
Bloom-фильтры для блок-листов с шарингом между профилями
У нас 10+ блок-листов от 50K до 5M доменов каждый. Если хранить их per-profile в виде set’ов, это сотни МБ RAM на ноду. С Bloom: 8 МБ на лист, шарится между всеми профилями, false-positive rate < 0.1%. В Unbound такого нет, там либо local-zone (медленно на больших списках), либо RPZ (тоже не совсем то).
Долго смотрел на варианты «допилить Unbound через модули» и отказался. Модульный API на C, миллионы конфигов через unix-сокет — это путь в ад дебага, причём сразу пятый круг. У меня нет на это ни нервов, ни жизни.
Почему Go, а не Rust/C++
Короткий ответ: я знаю Go и не знаю Rust на уровне «писать сетевую системную программу под нагрузкой». А C я в последний раз писал в университете, когда ещё думал, что segfault — это интересная диагностическая проблема. Сейчас я знаю, что это просто наказание за гордыню.
Длинный ответ:
|
Критерий |
Go |
Rust |
C/C++ |
|---|---|---|---|
|
Скорость старта |
✅ < 1 с |
✅ < 1 с |
✅ < 1 с |
|
Memory safety |
✅ GC |
✅ borrow check |
❌ |
|
Время до прототипа |
✅ дни |
⚠️ недели |
❌ месяцы |
|
Library: DNS |
✅ |
⚠️ |
✅ много |
|
Gorout./async |
✅ goroutine на запрос — норма |
⚠️ async runtime |
❌ epoll руками |
miekg/dns решал 80% задач из коробки: парсинг wire-format, сериализация, EDNS, DNSSEC. Я писал поверх неё recursive resolution, кеш, фильтрацию.
С Rust я бы выиграл 10-15% по latency и какие-то МБ по RAM. Но потерял бы 2 месяца на rewriting. Для бутстрап-проекта 1-человека это не выгодно.
Что было больно
Рекурсивная резолюция — это не просто «спросить .com NS, потом NS зоны»
Когда впервые пишешь recursive resolver, кажется: ну подумаешь, рекурсивно ходим по NS, кешируем, всё. Любой первокурсник напишет за вечер. Реальность, как водится, побила меня палкой по голове и обозвала наивным.
Параллельные NS lookups. У зоны обычно 2-4 авторитетных NS. Если последовательно дёргать каждый при таймауте, на холодном кеше один резолв example.com может занять 5+ секунд. Параллельные goroutines, race-to-first-response, отсев медленных.
Glue records. Когда запрашиваешь ns1.example.com для зоны example.com, это chicken-and-egg. Авторитетный сервер отдаёт A-запись для NS прямо в additional section. Если её игнорировать, рекурсия зацикливается. Был баг, где резолвер упирался в loop, потому что пропускал additional.
Negative caching. RFC 2308 (1998 год, между прочим, привет из эпохи, когда DNS ещё считался простым). Если NS вернул NXDOMAIN, надо кешировать с TTL из SOA, а не из ответа. Иначе при первом таймауте кешируем «домен не существует» на стандартный час и юзер ругается, а я смотрю в логи и не понимаю, почему GitHub лежит только у меня одного.
QNAME minimisation. Должно быть включено по умолчанию, это privacy. Запрашиваешь mail.google.com → у . спрашиваешь только com, у com только google.com, у google.com уже полный mail.google.com. Не «у всех NS на пути показываем full qname». Реализовать аккуратно отдельная история: некоторые корявые NS отвечают NODATA на минимизированный запрос, и ты сидишь, ловишь их в логах.
EDNS Client Subnet — отдельный ад
Без ECS Google отдаёт IP CDN из той страны, где стоит твоя нода. Юзер в Берлине через нашу ноду в Хельсинки получает финский IP googlevideo.com. Пинг 80 мс вместо 5 мс. YouTube тормозит, юзер пишет, что у нас «всё сломано».
С ECS публикуешь /24 подсеть юзера авторитетному NS. И вот тут начинается. Часть NS на ECS плюёт. Cloudflare принципиально не использует, потому что anycast. Часть отдаёт «правильный» IP. Часть выдаёт неправильный, а потом ты неделю выясняешь, почему. RFC 7871 формально опционален, и каждый сервер интерпретирует «опционально» по-своему, как бывшие супруги интерпретируют «давай останемся друзьями».
Тестирование на 30+ доменов руками было самым нудным этапом за все три месяца.
DNSSEC validation
Реализовать с нуля validation chain — две недели чистого времени. Я в итоге включил его опционально: для большинства юзеров он сейчас mostly cosmetic (95% доменов всё равно без подписи), а baggage серьёзный.
Cache poisoning — параноя 24/7
Любой кеширующий резолвер — потенциальная цель для cache poisoning. Source port randomization, txn-id randomization, 0x20 case randomization (да-да, тот самый трюк с регистром букв в qname, который выглядит как костыль и им и является), не доверять additional section без подтверждения через bailiwick. Это всё надо сделать до релиза, не после.
Раз 5 переписывал валидацию ответа. Каждый раз находил новый edge case и думал «ну вот теперь точно всё». Так не бывает. Запомните это, дети.
Цифры: что в итоге получилось
Замеры с одной ноды (Hetzner CPX21, 3 vCPU, 4 GB RAM, Helsinki):
QPS sustained: ~12 000 cached / ~3 500 coldP50 latency cached: 0.3 msP50 latency cold (.com): 38 msP99 latency cold: 180 msRAM steady state: 62 MBBinary size: 14 MB (go build -ldflags="-s -w")Cold start to ready: 0.4 s
Для сравнения, Unbound на той же ноде с похожим конфигом:
QPS sustained: ~18 000 cached / ~5 000 coldP50 latency cached: 0.18 msRAM steady state: 85 MB
Unbound быстрее на 30-40% по cached lookups. Это ожидаемо: он на C, код вылизан 20 лет. На холодных запросах разница меньше, потому что упираемся в сеть.
Для нашей нагрузки (~500 QPS на ноду пиково на текущем этапе) этой разницы не существует.
Что бы я сделал по-другому
Включил бы pprof и continuous profiling с первого дня. Первые два месяца дебажил allocations через runtime.ReadMemStats и top как пещерный человек. С pprof + Parca находил бы лики за минуты, а не за вечера. В оправдание скажу, что в три ночи кажется, будто top — это нормальный инструмент.
Не писал бы сразу Bloom-фильтры, взял бы суффиксные деревья. Bloom нужен на масштабе миллионов доменов. На старте у меня было 200K записей в листе, обычный suffix-trie дал бы exact matching без false positives, проще дебажить. Bloom добавил бы потом, когда уже припекает. Это, кстати, главное правило: не оптимизируй то, что не болит. Я его знаю с 2010 года и регулярно нарушаю.
Сделал бы feature flags с первого дня. Сейчас у меня 4 разных пути в коде «если ECS включён — так, иначе так», и они переплетены, как кабели за рабочим столом любого старого админа. С флагами можно было бы тестировать в проде на 1% трафика. Без них тестирую на себе, что довольно унизительно.
Open-source
Edge-резолвер — open source, MIT. Ссылка в конце статьи. Что НЕ open-source: control plane (биллинг, дашборд, blocklist-каталог) — это бизнес.
Зачем open-source edge: я хочу, чтобы юзер мог поднять свой собственный экземпляр и убедиться, что мы не врём про «не пишем на диск». В transparency report — статистика запросов на правоохранителей. Warrant canary обновляем еженедельно.
Что дальше
В следующих постах планирую разобрать конкретику:
-
как делал Bloom-фильтры с шарингом между профилями;
-
почему отказался от anycast в пользу geo-DNS на 10 PoPs и сколько это реально стоит;
-
как async-шипим query log в ClickHouse без потерь и без диска на edge.
Если интересно что-то конкретное — пишите в комментарии, постараюсь приоритизировать.
Ссылки:
-
Лендинг: https://vantagedns.com
-
Попробовать без регистрации, выдаём DNS-эндпойнт за 30 секунд: https://vantagedns.com/try
-
Free Pro на 90 дней для авторов Хабра: https://vantagedns.com/press-kit
Спасибо, что дочитали. Замечания и вопросы пишите в комменты.
ссылка на оригинал статьи https://habr.com/ru/articles/1035394/