Как Linux врет вам про DNS

Предисловие: выложил эту статью на Хабр, но тамошние модераторы с синдромом вахтера решили, что раз я пишу грамотно и структурированно, то я нейросеть. Я человек двуязычный и, возможно, это сказывается, но апломб и бескомпромиссность, с которой модераторы вещают, меня взбесили. Мол, мы тут вообще-то в универах учились, в отличие от вас, и знаем что и как. Уверен, что всегда найдутся те, кто так скажет, но это лишь их мнение. Моя же задача рассказать о проблеме, на мой взгляд любопытной, и показать инструмент.

———

Однажды ты запускаешь dig домен_нейм, видишь исчерпываюший ответ, идешь дальше — а твой процесс резолвит что-то другое. Просто твой процесс и твой dig живут в двух параллельных вселенных, и обе вселенные “резолвинг имени”.

Если ты никогда с этим не сталкивался — либо у тебя мало продакшена за плечами, либо ты просто не заметил. Оно не падает с ошибкой. Оно просто отвечает не то, что ты ожидал, и ты лезешь в дебри, да еще и не в ту сторону.

Я на это наткнулся ровно так, как обычно натыкаются на подобное — не потому что курил мануалы, а потому что два часа дебажил то, что по всем признакам не должно было ломаться. И только на дне этого дебага понял: я вообще не знаю, что происходит между вызовом getaddrinfo() и ответом. Никто, по сути, толком не знает. Все просто верят, что DNS резолвит имена и живут с этим счастливым заблуждением, пока оно их не настигает.

Так что сядем и разберем, что там реально происходит. По пути будет несколько точек, в которых система может соврать, но не со зла, а просто потому что так устроена. И в конце будет маленький инструмент, который я написал, чтобы больше не гадать.

DNS — это не резолвинг. DNS — это один из вариантов

Вот с чего стоит начать разгребать: getaddrinfo() — это не функция “спроси DNS”. Это функция “спроси NSS”, а NSS (Name Service Switch) — это диспетчер, который решает, у кого спрашивать: у файла, у mDNS, у DNS, у LDAP, у самого черта, что тебе кто-то подсунул модулем. DNS там просто один из кандидатов, причем необязательно приоритетный.

Вся эта логика описана в одном скромном файле, который почти никто не читает после того, как система его замутила при установке:

$ cat /etc/nsswitch.conf hosts: files mdns4_minimal [NOTFOUND=return] dns

Вроде все просто, сначала спроси /etc/hosts, потом mDNS, потом DNS. Ну да, пока не обращаешь внимание на [NOTFOUND=return] (кому ведь интересно че там в скобках пишут?) — а ведь это уже маленькая бомбочка, о которой мы поговорим отдельно.

Первый источник вранья: /etc/hosts

Самый банальный и самый частый в реальности. Кто-то полгода назад добавил staging.internal в /etc/hosts, чтобы обойти DNS во время какого-то расследования, и забыл убрать. И из-за этого Пинкертона теперь этот хост “резолвится” на машине навсегда независимо от того, что говорит настоящий DNS, независимо от того, что там сейчас в зоне, независимо от того, помнит ли об этом кто-то живой.

files стоит первым в цепочке почти на любом дистрибутиве по умолчанию. И это значит: если имя есть в /etc/hosts — DNS вообще не спросят. Не DNS ответит неправильно, а DNS не спросят вовсе. Для человека, который смотрит на dig и видит правильный ответ, это выглядит как необъяснимая магия. Ведь как это может резолвиться в другое, если DNS говорит правильно? А очень просто — потому что digделает прямой DNS-запрос, а твой процесс до DNS в принципе не доходит.

Ловушка [NOTFOUND=return]

Вот эта конструкция в nsswitch.conf заслуживает отдельного разговора, потому что она реально может испортить тебе в лучшем случае вечер. [NOTFOUND=return] значит: если предыдущий источник явно ответил “такого имени нет” — остановить цепочку прямо здесь и вернуть “ниче не нашел”, не пытаясь спросить следующего.

Казалось бы, разумная оптимизация — mDNS не должен ходить в DNS за именами .local, которых там заведомо не будет. Но на практике это означает, что если что-то в цепочке до DNS решило, что имени не существует, DNS никогда не получит шанс сказать свое слово. Ты можешь идеально настроить зону, ты можешь трижды проверить записи и все равно получить NXDOMAIN на уровне приложения, потому что где-то раньше в цепочке был молчаливый шлагбаум.

Самое неприятное здесь — это происходит тихо. Просто отрицательный ответ, который выглядит абсолютно легитимно.

mDNS и иллюзия .local

Второй пункт в нашей цепочке — mDNS, тот самый multicast DNS, который отвечает за .local-имена в локальной сети. Штука сама по себе рабочая и полезная (Avahi, Bonjour и вот это все), но она добавляет еще один недетерминированный слой: ответ зависит от того, что прямо сейчас есть в сегменте сети, кто отозвался на multicast-запрос за отведенные ей несколько сотен миллисекунд, и повезло ли пакету дойти.

Тут вранья как такового меньше, но неопределенности — выше крыши. Один и тот же .local-хост может резолвиться по-разному в зависимости от того, кто именно ответил первым и насколько загружена сеть в этот момент.

systemd-resolved и обман через 127.0.0.53

А вот это, пожалуй, самый недооцененный источник путаницы на современных дистрибутивах. Открываешь /etc/resolv.conf, видишь:

nameserver 127.0.0.53

и на автомате читаешь это как “вот мой DNS-сервер”. Нет. Это заглушка, локальный прокси systemd-resolved. Реальные нейм-серверы, которые он спрашивает от твоего имени, спрятаны у него внутри в per-link конфиге, до которого обычный resolv.conf не дотягивается вообще. Чтобы узнать кто там реально отвечает за резолвинг, нужно спрашивать не файл, а сам systemd-resolved через D-Bus (org.freedesktop.resolve1.Manager).

Почему это важно? Потому что если ты хочешь сделать независимую проверку и наивно шлешь запрос на 127.0.0.53, ты снова спрашиваешь ту же самую цепочку, только другим путем. Ты сверяешься сам с собой. Ложное чувство независимой проверки, эдакая форма вранья, просто вежливая.

И вот тут есть нюанс, который удивляет: у systemd-resolved DNS-серверы не одни на всю систему, а per-link — свои на каждый сетевой интерфейс. Поднял VPN (тот же Tailscale) — получил отдельный резолвер для его зоны, который живет только на этом линке и в глобальный список серверов не попадает. Если имя относится к такой зоне, а ты сверяешься с глобальными ней серверами — ты гарантированно получишь расхождение, хотя формально все настроено правильно, просто ты спросил не того прохожего.

И тут всплывает со дна еще один источник лжи: gai.conf и сортировка.

DNS в ответе на запрос имени может вернуть сразу оба семейства адресов — и A-записи (IPv4), и AAAA-записи (IPv6). getaddrinfo() получает этот список и не отдает его как есть, а пересортировывает его по правилам RFC 6724, а конкретные веса этих правил живут в /etc/gai.conf. По умолчанию файл почти всегда пустой или отсутствует, что означает “используй дефолты RFC 6724 как есть”, и дефолты там говорят: предпочитай IPv6, если он вообще есть в ответе. Не если он работает. Просто если есть.

И вот тут расходится теория с продакшеном: IPv6 у хоста может быть объявлен в DNS, но реально не маршрутизируется, задроплен на файрволе или тупо висит на мертвом линке. getaddrinfo() про это ничего не знает — он смотрит только на RFC-таблицу приоритетов, а не на реальную доступность адреса. В итоге приложение честно берет “предпочтительный” IPv6-адрес первым, пытается на него достучаться, получает таймаут и только потом падает на IPv4-фолбэк — если он вообще предусмотрен. А dig тем временем как ни в чём не бывало показывает тебе чистый и рабочий IPv4-адрес, потому что dig никого не сортирует и никаких приоритетов не расставляет — он просто печатает, что вернул сервер.

Самое обидное, что это не баг ни у кого. Ни у DNS, ни у приложения, ни у ядра. Это ровно то поведение, которое RFC и просил реализовать. Просто оно молчаливо предполагает мир, где если IPv6-адрес объявлен, то он работает. В 2026 году это предположение все еще сильно оптимистичнее, чем хотелось бы.

Ловушка, о которой почти никто не думает: статический Go-бинарник

И последняя, моя любимая, потому что она рвет шаблон у всех, кто искренне верит, что везде же NSS одна и та же.

Тут стоит сразу закрыть придирку, которая иначе прилетит в комментарии: Go в принципе умеет резолвить и через NSS, если собран с cgo и он доступен на платформе. Но статически слинкованный бинарь — а это ровно то, что обычно льют в прод (CGO_ENABLED=0, никакой libc-зависимости, один файл, копипаст на сервер) этой возможности лишен по построению. Со стандартной статической сборкой Go использует свой собственный, чистый резолвер, написанный на Go, который парсит resolv.conf сам и ходит в DNS сам, минуя NSS, glibc и все, о чем мы говорили выше, целиком.

Это значит следующее: ты можешь идеально настроить nsswitch.conf, добавить нужную запись в /etc/hosts, все перепроверить через getent, но твой Go-сервис все равно этого не увидит, потому что он вообще не спрашивает те источники, которые ты только что поправил. Он живет в своей отдельной реальности резолвинга, которая пересекается с системной только в одной точке — в resolv.conf, да и то не полностью.

Как я до этого докопался

Я это все, честно говоря, не вычитал заранее — я это выковырял руками, комбинацией dig, getent hosts, resolvectl status с легкой злостью. У каждой команды свой кусок правды, ни одна не показывает полную картину, и собрать их вместе в голове каждый раз совсем не кайф, особенно когда уже третий час ищешь глазами несуществующую опечатку. А пресловутый ИИ путается, несет пургу и лишь добавляет сомнений в адекватности происходящего.

В какой-то момент стало проще написать тулзу, которая проходит эту цепочку сама именно так, как ее проходил бы резолвер ОС и параллельно делает независимый DNS-запрос, чтобы явно показать: вот путь, вот его результат, а вот что говорит DNS напрямую, если их спросить в обход всего. Если расходится — вот тебе конкретная причина, а не “попробуй еще раз с флагом -v”.

Так появился gai — от getaddrinfo, а не то, что вы подумали.

$ gai doctor testhost.local

[gai] Simulating name resolution for "testhost.local"... (reality check via 212.227.123.16, 212.227.123.17, systemd-resolved stub: true) RESOLUTION PATH (simulated): 1. Files FOUND 10.0.0.1 DIAGNOSIS:

┌─ ISSUE ──────────────────────────────────────────────────────────────────┐ │ The OS chain and a direct DNS query disagree: 10.0.0.1 vs (none). │ │ Something earlier in the chain (files/mdns) is answering instead of DNS. │ └──────────────────────────────────────────────────────────────────────────┘

Никакого перехвата процессов, никакого LD_PRELOAD, никакого eBPF или ptrace — gai просто читает ту же конфигурацию, что читает сам резолвер ОС (nsswitch.conf, resolv.conf, gai.conf, /etc/hosts, D-Bus-состояние systemd-resolved, одноразовый mDNS-запрос), и честно моделирует путь, которым прошел бы NSS-диспетчер glibc. Отдельно он умеет заметить статический Go-бинарник и прямо сказать, что для этого процесса вся симуляция выше не имеет значения, у него свой резолвер.

Как поставить тулзу и что дальше

Ставится одной строкой, без Rust-тулчейна, без зависимостей вообще — статический бинарник, который просто лежит и работает.

Пойдет в /usr/local/bin/gai с обычными правами 0755 — как любой другой системный бинарник, ничего экзотического. Если хочется собрать самому — cargo install gai-inspector или cargo build --release из исходников, все через crates.io. Никакого рантайма с собой не тащит, ни Python, ни JVM, ни shared-либ, за которые надо переживать при переносе на голый сервер. Скачал, chmod не нужен даже, и можно спрашивать gai doctor <имя> хоть в контейнере, хоть на минимальном Alpine-образе.

Инструмент пока Linux-only и MVP:

  • Split-DNS/per-link-роутинг (VPN-зоны вроде Tailscale, о которых я говорил выше) он пока не умеет и честно репортит NOT FOUND вместо того чтобы соврать, но и не резолвит.

  • IPv6 mDNS — в процессе.

Репо: github.com/casablanque-code/gai. Там подробнее про возможности и флаги.

Если случится ситуация, где gai тоже наврал — тем более буду рад услышать. Код открыт, пулл реквесты принимаются. Интереснее разобрать баг, чем сделать вид, что такого не бывает.

Темы

Политика

Теги

Популярные авторы

Сообщества

18+

Теги

Популярные авторы

Сообщества

Игры

Теги

Популярные авторы

Сообщества

Юмор

Теги

Популярные авторы

Сообщества

Отношения

Теги

Популярные авторы

Сообщества

Здоровье

Теги

Популярные авторы

Сообщества

Путешествия

Теги

Популярные авторы

Сообщества

Спорт

Теги

Популярные авторы

Сообщества

Хобби

Теги

Популярные авторы

Сообщества

Сервис

Теги

Популярные авторы

Сообщества

Природа

Теги

Популярные авторы

Сообщества

Бизнес

Теги

Популярные авторы

Сообщества

Транспорт

Теги

Популярные авторы

Сообщества

Общение

Теги

Популярные авторы

Сообщества

Юриспруденция

Теги

Популярные авторы

Сообщества

Наука

Теги

Популярные авторы

Сообщества

IT

Теги

Популярные авторы

Сообщества

Животные

Теги

Популярные авторы

Сообщества

Кино и сериалы

Теги

Популярные авторы

Сообщества

Экономика

Теги

Популярные авторы

Сообщества

Кулинария

Теги

Популярные авторы

Сообщества

История

Теги

Популярные авторы

Сообщества

Недвижимость и ремонт

Теги

Популярные авторы

Сообщества