Я написал свой DNS-резолвер на Go вместо того, чтобы взять Unbound. Вот почему и что из этого вышло

от автора

Привет, Хабр!

Три месяца назад я начал делать 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

miekg/dns (де-факто стандарт)

⚠️ trust-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://habr.com/ru/articles/1035394/