Criei meu próprio resolvedor DNS em Go em vez de usar o Unbound: Eis o porquê e o que aprendi

Criei meu próprio resolvedor DNS em Go em vez de usar o Unbound: Eis o porquê e o que aprendi

O autor relata a experiência de construir um resolvedor DNS recursivo em Go para um projeto similar ao NextDNS, abordando os desafios e as razões para não utilizar soluções prontas como o Unbound. Ele detalha os problemas enfrentados, as otimizações implementadas e os resultados alcançados, além de compartilhar o código open-source.

MundiX News·15 de maio de 2026·10 min de leitura·👁 1 views

Olá, Habr!

Há três meses, comecei a criar um clone do NextDNS para a Europa: um DNS recursivo com filtragem de anúncios, rastreadores e malware. No primeiro dia, abri o Unbound, li o manual e tudo pareceu claro. À noite, percebi que não era adequado. Uma semana depois, estava escrevendo meu próprio resolvedor em Go e me lembrei do ditado sobre a pessoa que decidiu escrever um servidor de e-mail. Aconteceu de novo.

Atualmente, em produção: 10 nós ao redor do mundo, respondendo a DoH/DoT, filtrando milhões de domínios, com 60 MB de RAM por nó. Vou explicar por que abandonei as soluções prontas, quais foram os pontos problemáticos e onde o Unbound ainda é mais rápido. Spoiler: quase em todos os lugares, mas em nossas condições isso não importa. No final, há links para o núcleo open-source do resolvedor e nossa tarifa gratuita, para você experimentar.

Por que criar seu próprio resolvedor se existe o Unbound?

Unbound é um excelente software. PowerDNS Recursor também. Eu os adoro como se adora um gato velho: pelo simples fato de existirem e não exigirem explicações. Em uma situação normal, eu simplesmente instalaria um deles e iria tomar um chá. Mas eu tinha três requisitos que nenhum dos dois atendia de forma nativa:

Milhões de endpoints DoH com diferentes regras de filtragem

Em um produto similar ao NextDNS, cada usuário recebe seu próprio config_id, um token público curto na URL:

https://dns.vantagedns.com/<config_id>/dns-query

Um usuário pode ter perfis "casa", "crianças", "escritório", cada um com suas próprias blocklists e whitelists. Não é "um resolvedor com uma configuração", mas dezenas de milhares de resolvedores lógicos em um único nó.

O Unbound tem views, mas eles não escalam para dezenas de milhares de conjuntos de regras independentes sem dor de cabeça com a geração de configuração e SIGHUP a cada cinco minutos. Eu vi uma construção semelhante em uma empresa de telecomunicações em 2014. Lá, o administrador carregava um pedaço de papel com a ordem das ações para não esquecer. Eu não queria me envolver nisso.

Query log in-memory sem gravar no disco

O nó de borda não deve gravar nada no disco que se refira ao usuário. Isso faz parte do posicionamento. Portanto, o query log reside em um buffer circular na memória, enviado de forma assíncrona para o ClickHouse em Helsinque, e só. O Unbound grava logs em um arquivo; para o envio assíncrono, é preciso construir um tail+parser.

Retention: 24 horas no plano gratuito, até 30 dias nos planos pagos, depois o ClickHouse TTL limpa.

Filtros de Bloom para blocklists com compartilhamento entre perfis

Temos mais de 10 blocklists, de 50K a 5M de domínios cada. Se armazená-los por perfil como sets, seriam centenas de MB de RAM por nó. Com Bloom: 8 MB por lista, compartilhado entre todos os perfis, com uma taxa de falsos positivos < 0,1%. O Unbound não tem isso; há local-zone (lento para listas grandes) ou RPZ (também não é exatamente o que eu queria).

Considerei por muito tempo as opções de "customizar o Unbound através de módulos" e desisti. A API de módulos em C, milhões de configurações através de um socket Unix, é um caminho para o inferno do debugging, e logo no quinto círculo. Eu não tenho nervos nem vida para isso.

Por que Go, e não Rust/C++?

Resposta curta: eu conheço Go e não conheço Rust no nível de "escrever um programa de sistema de rede sob carga". E a última vez que escrevi em C foi na universidade, quando ainda pensava que um segfault era um problema de diagnóstico interessante. Agora eu sei que é apenas uma punição pela arrogância.

Resposta longa:

CritérioGoRustC/C++
Velocidade de start✅ < 1 s✅ < 1 s✅ < 1 s
Memory safety✅ GC✅ borrow check
Tempo até protótipo✅ dias⚠️ semanas❌ meses
Library: DNSmiekg/dns (padrão de fato)⚠️ trust-dns ok, mas mais pobre✅ muitos
Gorout./async✅ goroutine por requisição - normal⚠️ async runtime❌ epoll na mão

miekg/dns resolveu 80% das tarefas prontas para uso: parsing de wire-format, serialização, EDNS, DNSSEC. Eu escrevi sobre ele recursive resolution, cache, filtragem.

Com Rust, eu ganharia 10-15% em latência e alguns MB em RAM. Mas perderia 2 meses reescrevendo. Para um projeto bootstrap de 1 pessoa, isso não é vantajoso.

O que foi doloroso

A recursive resolution não é apenas "perguntar ao .com NS, depois ao NS da zona"

Quando você escreve um recursive resolver pela primeira vez, parece: bem, vamos recursivamente aos NS, armazenamos em cache, tudo certo. Qualquer calouro escreverá em uma noite. A realidade, como sempre, me bateu com um porrete na cabeça e me chamou de ingênuo.

  • NS lookups paralelos. Uma zona geralmente tem 2-4 NS autoritativos. Se você chamar cada um sequencialmente em um timeout, em um cache frio, um resolve de example.com pode levar mais de 5 segundos. Goroutines paralelas, race-to-first-response, eliminação de lentos.
  • Glue records. Quando você solicita ns1.example.com para a zona example.com, é um problema do ovo e da galinha. O servidor autoritativo retorna um registro A para o NS diretamente na seção additional. Se você ignorá-lo, a recursão entra em loop. Houve um bug em que o resolvedor travava em um loop porque ignorava o additional.
  • Negative caching. RFC 2308 (1998, aliás, saudações da era em que o DNS ainda era considerado simples). Se o NS retornou NXDOMAIN, você deve armazenar em cache com o TTL do SOA, e não da resposta. Caso contrário, no primeiro timeout, armazenamos em cache "o domínio não existe" por uma hora padrão e o usuário reclama, e eu olho para os logs e não entendo por que o GitHub está fora do ar só para mim.
  • QNAME minimisation. Deve ser ativado por padrão, é privacidade. Você solicita mail.google.com → para . você pergunta apenas com, para com apenas google.com, para google.com já o mail.google.com completo. Não "mostramos o qname completo para todos os NS no caminho". Implementar com cuidado é uma história separada: alguns NS ruins respondem NODATA a uma solicitação minimizada, e você fica sentado, pegando-os nos logs.
  • EDNS Client Subnet — um inferno à parte. Sem ECS, o Google retorna o IP do CDN do país onde seu nó está localizado. Um usuário em Berlim através do nosso nó em Helsinque recebe um IP finlandês para googlevideo.com. Ping de 80 ms em vez de 5 ms. O YouTube fica lento, o usuário escreve que "tudo está quebrado" para nós.

Com o ECS, você publica a sub-rede /24 do usuário para o NS autoritativo. E aqui começa. Parte do NS cospe no ECS. O Cloudflare não usa por princípio, porque é anycast. Parte retorna o IP "correto". Parte retorna o IP incorreto, e então você passa uma semana descobrindo o porquê. RFC 7871 é formalmente opcional, e cada servidor interpreta "opcional" à sua maneira, como ex-cônjuges interpretam "vamos continuar amigos".

Testar em mais de 30 domínios manualmente foi a etapa mais tediosa em todos os três meses.

DNSSEC validation

Implementar uma validation chain do zero leva duas semanas de tempo puro. No final, eu o ativei opcionalmente: para a maioria dos usuários, agora é principalmente cosmético (95% dos domínios ainda estão sem assinatura), e a baggage é séria.

Cache poisoning — paranoia 24/7

Qualquer resolvedor de cache é um alvo potencial para cache poisoning. Source port randomization, txn-id randomization, 0x20 case randomization (sim, aquele truque com a capitalização das letras no qname, que parece um remendo e é), não confiar na seção additional sem confirmação através do bailiwick. Tudo isso deve ser feito antes do lançamento, não depois.

Reescrevi a validação da resposta umas 5 vezes. Cada vez encontrava um novo edge case e pensava "agora está tudo certo". Não é assim que funciona. Lembrem-se disso, crianças.

Números: o que aconteceu no final

Métricas de um nó (Hetzner CPX21, 3 vCPU, 4 GB RAM, Helsinque):

QPS sustained:           ~12 000 cached / ~3 500 cold
P50 latency cached:      0.3 ms
P50 latency cold (.com): 38 ms
P99 latency cold:        180 ms
RAM steady state:        62 MB
Binary size:             14 MB (go build -ldflags="-s -w")
Cold start to ready:     0.4 s

Para comparação, Unbound no mesmo nó com uma configuração semelhante:

QPS sustained:           ~18 000 cached / ~5 000 cold
P50 latency cached:      0.18 ms
RAM steady state:        85 MB

O Unbound é 30-40% mais rápido em cached lookups. Isso é esperado: ele é em C, o código foi aprimorado por 20 anos. Em solicitações frias, a diferença é menor, porque estamos limitados pela rede.

Para nossa carga (~500 QPS por nó no pico na fase atual), essa diferença não existe.

O que eu faria de diferente

Eu teria ativado o pprof e o continuous profiling desde o primeiro dia. Nos primeiros dois meses, depurei as alocações através de runtime.ReadMemStats e top como um homem das cavernas. Com pprof + Parca, eu encontraria vazamentos em minutos, não em noites. Em minha defesa, direi que às três da manhã parece que top é uma ferramenta normal.

Eu não escreveria Bloom filters imediatamente, eu pegaria suffix trees. Bloom é necessário na escala de milhões de domínios. No início, eu tinha 200K registros na lista, um suffix-trie normal daria exact matching sem falsos positivos, mais fácil de depurar. Eu adicionaria Bloom mais tarde, quando já estivesse apertando. Esta, aliás, é a regra principal: não otimize o que não está quebrado. Eu o conheço desde 2010 e o quebro regularmente.

Eu teria feito feature flags desde o primeiro dia. Agora eu tenho 4 caminhos diferentes no código "se o ECS estiver ativado - assim, caso contrário, assim", e eles estão entrelaçados como os cabos atrás da mesa de qualquer administrador antigo. Com flags, seria possível testar em produção em 1% do tráfego. Sem eles, eu testo em mim mesmo, o que é bastante humilhante.

Open-source

O Edge-resolver é open source, MIT. Link no final do artigo. O que NÃO é open-source: control plane (billing, dashboard, blocklist-catalog) - isso é negócio.

Por que open-source edge: eu quero que o usuário possa levantar sua própria instância e certificar-se de que não estamos mentindo sobre "não escrever no disco". No transparency report - estatísticas de solicitações para policiais. Warrant canary atualizamos semanalmente.

O que vem a seguir

Nos próximos posts, pretendo analisar os detalhes:

  • como fiz Bloom filters com compartilhamento entre perfis;
  • por que abandonei o anycast em favor do geo-DNS em 10 PoPs e quanto isso realmente custa;
  • como enviamos o query log de forma assíncrona para o ClickHouse sem perdas e sem disco na edge.

Se você estiver interessado em algo específico - escreva nos comentários, tentarei priorizar.

Links:

Obrigado por ler até o fim. Comentários e perguntas escreva nos comentários.

📤 Compartilhar & Baixar