Однажды ты запускаешь 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) — получил отдельный резолвер для его зоны, который живет только на этом линке и в глобальный список серверов не попадает. Если имя относится к такой зоне, а ты сверяешься с глобальными nameserver’ами — ты гарантированно получишь расхождение, хотя формально все настроено правильно, просто ты спросил не того прохожего.

И тут всплывает со дна еще один источник лжи: 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 тоже наврал — тем более буду рад услышать. Код открыт, пулл реквесты принимаются. Интереснее разобрать баг, чем сделать вид, что такого не бывает.