Привет, Хабр! Меня зовут Анатолий Кохан, я — DevOps-инженер в К2Тех.

Когда мы вводим в браузере имя сервера или доменное имя сайта, выполняем ping или запускаем любое удаленное приложение, операционная система должна преобразовать указанные имена в IP-адреса. Этот процесс называется разрешением доменного имени. На первый взгляд он может показаться весьма прозрачным, однако за ним скрывается многослойный механизм.

Данная статья —  начало серии, посвященной низкоуровневой архитектуре разрешения имен. Поговорим о том, как устроен этот процесс в Linux на уровне ядра, различных библиотек C и системных вызовов.

---

Многие знают, что процесс разрешения имен в Linux — это не просто «вызов DNS», а цепочка из библиотек, конфигурационных записей и вызовов, зависящих от реализации конкретного приложения, используемых типов библиотек и системных настроек. 

Однако у инженеров остаются вопросы. Например, требуется ли перезагрузка приложения после того, как изменился адрес DNS-сервера? Кроме того, чтобы диагностировать ошибки, таймауты, иные проблемы с разрешением имен в приложении и в системе, важно понимать, как работает вся эта цепочка — от getaddrinfo() до resolv.conf. В этой части постараемся разобрать все по слоям и собрать некую фундаментальную базу в кратком и доступном виде.

Вершина айсберга

Почти все современные приложения в Linux, от curl до systemd, используют функцию getaddrinfo() из стандартной библиотеки C (glibc или musl). Именно она выполняет основную работу по переводу доменного имени в IP-адрес (A, AAAA-записи) в зависимости от настроек и запроса.

При этом она не только выполняет DNS-запросы, но и обрабатывает другие типы данных, такие как имена сервисов, например, преобразует имя сетевого сервиса “http” в порт 80, используя /etc/services. Это делает ее универсальным инструментом для сетевых приложений.

Функция getaddrinfo() возвращает список структур addrinfo, каждая из которых содержит IP-адрес, тип сокета, протокол и другие параметры. Это позволяет приложениям выбирать наиболее подходящий адрес для подключения.

Пример использования getaddrinfo() в псевдокоде:

```
struct addrinfo hints, *res;
zero_memory(hints);
hints.ai_family = ANY_FAMILY;   
hints.ai_socktype = TCP;

err = getaddrinfo("example.com", "http", hints, &res);
if (err == 0) {
    for each addr in res:
        use(addr)
    freeaddrinfo(res);
} else {
    print(gai_strerror(err));
}

```

При этом getaddrinfo() — это вершина айсберга. Для получения IP-адреса она вызывает цепочку внутренних механизмов, прописанную в конфигурационных данных системы. Один из этих механизмов — NSS (Name Service Switch).

NSS

NSS реализован на основе подгружаемых модулей — динамических библиотек, соответствующих API glibc, таких как libnss_dns.so, libnss_files.so, libnss_myhostname.so и других. Они функционируют как плагины и подгружаются библиотекой glibc во время выполнения, отвечая за конкретные методы разрешения IP-адресов. Порядок и набор источников, используемых для разрешения имен, задается в конфигурационном файле /etc/nsswitch.conf.

Пример содержания nsswitch.conf:

# /etc/nsswitch.conf

passwd:         files systemd
group:          files systemd
shadow:         files
gshadow:        files

hosts:          files dns myhostname
networks:       files

protocols:      db files
services:       db files
ethers:         db files
rpc:            db files

netgroup:       nis

Например, строка в модулях с содержанием ``hosts: files dns`` говорит, что сначала ищется соответствие в локальном файле /etc/hosts, и если модуль files возвращает результат, то последующие модули, такие как dns (делающий DNS-запрос) не будут вызваны.

Соответственно, если в nsswitch.conf строка hosts не включает упоминание модуля dns, то конфигурационный файл resolv.conf, содержащий настройки обращения к DNS-источникам, проигнорируется, и DNS-запрос не будет сформирован.

Также в NSS могут задействоваться модули mdns (для Zeroconf/Avahi), nis (в старых системах) и myhostname.

Модуль myhostname является частью systemd и используется для разрешения локального имени хоста. Он не всегда присутствует в минималистичных системах, таких как Alpine Linux.

Библиотеки

Нижеперечисленные библиотеки экосистемы Linux являются ключевыми и предоставляют приложениям определенный набор функций, включая разрешение доменных имен.

Glibc — наиболее распространённая реализация стандартной библиотеки языка C, реализует высокоуровневые функции, такие как getaddrinfo(). Она взаимодействует с NSS (Name Service Switch) для определения источников разрешения имён (например, /etc/hosts, DNS) и использует библиотеку libresolv для выполнения DNS-запросов.

Glibc может использовать системные вызовы, такие как sendto и recvfrom для отправки и получения DNS-запросов по UDP или TCP. Широко распространена в большинстве дистрибутивов Linux (Ubuntu, Debian, Fedora и др.)

Musl — альтернативная стандартная библиотека C, разработанная с упором на минимализм, производительность и совместимость со стандартами POSIX. Она используется в легковесных дистрибутивах, таких как Alpine Linux.

Musl реализует разрешение доменных имён напрямую, без использования NSS,  самостоятельно читает /etc/hosts и /etc/resolv.conf и отправляет DNS-запросы, не используя внешние библиотеки вроде libresolv. Однако musl имеет ограничения в поддержке некоторых параметров resolv.conf, таких как rotate или сложные search.

Libresolv.so — является частью glibc, реализующая низкоуровневую работу с DNS, выполняя такие запросы, как res_query() и res_send(), но может использоваться независимо в некоторых приложениях вроде nslookup (что позволяет выполнять DNS-запросы напрямую, минуя стандартные механизмы разрешения имен).

Libresolv используется glibc для выполнения DNS-запросов, когда NSS указывает, что нужно обратиться к DNS. Она читает /etc/resolv.conf, формирует DNS-пакеты и отправляет их на указанные серверы по UDP или TCP.

Стоит отметить, что некоторые приложения, например, написанные на Go, могут полностью обходить glibc/musl и использовать собственные DNS-резолверы.

Как обрабатывается resolv.conf

Файл /etc/resolv.conf содержит основные настройки клиента DNS, а именно: список серверов, параметры, search-домены. Например:

nameserver 192.168.1.1
search dev.local
options timeout:2 attempts:3

Glibc и libresolv парсят его вручную при необходимости.

Важные моменты и ограничения:

- опции вроде rotate, ndots, timeout и attempts влияют на поведение запроса;

- опция rotate используется для циклического выбора серверов из списка nameserver, но она не поддерживается в musl;

- search используется для автодополнения, например, если имя db01 не является FQDN, к нему будут по очереди подставляться домены из директивы search.

Важно отметить, что файл resolv.conf может быть динамически изменён DHCP-клиентом, NetworkManager или утилитой resolvconf, что может вызывать путаницу при решении проблем с DNS. Об этом мы поговорим в одной из следующих частей.

Что делает res_query()

Это функция из libresolv, вызываемая внутренне в процессе разрешения имени. Она формирует DNS-пакет вручную и отправляет его на указанные в resolv.conf DNS-серверы. Её используют утилиты вроде nslookup, а также некоторые программы, которые обходят getaddrinfo().

Функция отправляет DNS-запросы с помощью res_send() по UDP, а при необходимости, например, при получении ответов, превышающих 512 байт, переключается на TCP.

Важно: при использовании res_query() вы не получите информацию из /etc/hosts, NSS или других источников. Это DNS-запрос в чистом виде. Поэтому dig или nslookup могут получить один результат, а, например, ping или curl — совсем другой.

Res_query() считается устаревшей функцией, использовать ее не рекомендуется. Для более удобной и безопасной работы с DNS лучше отдать предпочтение getaddrinfo() или таким библиотекам, как c-ares или libdns.

  • c-ares — легковесная библиотека для асинхронных DNS-запросов, часто используется в высоконагруженных приложениях (например, curl и Node.js)

  • libunbound (из проекта Unbound) — более мощная библиотека с поддержкой DNSSEC и гибкой настройкой запросов.

Порядок реализации запросов и приоритеты

Вот типичный порядок разрешения имени в Linux при использовании glibc и NSS:

1. приложение вызывает getaddrinfo();

2. getaddrinfo() обращается к системе NSS и следует заданному в nsswitch.conf порядку;

3. если первым указан модуль files, имя ищется в файле /etc/hosts;

4. если включён модуль dns, NSS вызывает libnss_dns.so, которая обращается к функциям из libresolv;

5. libresolv формирует DNS-запрос через res_query() и отправляет его с помощью res_send() на указанные в resolv.conf адреса DNS-серверов, затем получает и возвращает IP-адрес.

Упрощенная схема разрешения имен в Linux через glibc. Иллюстрирует базовый путь, но возможно использование других источников в NSS. Порядок источников (files/dns) настраивается в /etc/nsswitch.conf. В современных системах может также использоваться кэш DNS (systemd-resolved, nscd)
Упрощенная схема разрешения имен в Linux через glibc. Иллюстрирует базовый путь, но возможно использование других источников в NSS. Порядок источников (files/dns) настраивается в /etc/nsswitch.conf. В современных системах может также использоваться кэш DNS (systemd-resolved, nscd)

Важно: если имя найдено на одном из шагов, например, в hosts, последующие источники не используются.

В минималистичных системах, таких как Alpine Linux с musl, порядок может отличаться, так как musl не использует NSS и реализует DNS-запросы напрямую, читая /etc/hosts и resolv.conf самостоятельно.

Некоторые приложения и языки (например, Go, Java, Node.js) могут использовать собственные DNS-резолверы, полностью игнорируя системные настройки.

Для примера проанализируем работу утилиты curl.

Команда:

strace -f -e trace=network curl -s download.astralinux.ru > /dev/null
Вывод strace:
socket(AF_INET6, SOCK_DGRAM, IPPROTO_IP) = 3
socketpair(AF_UNIX, SOCK_STREAM, 0, [3, 4]) = 0
socketpair(AF_UNIX, SOCK_STREAM, 0, [5, 6]) = 0
strace: Process 283163 attached
[pid 283163] socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 7
[pid 283163] connect(7, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (Нет такого файла или каталога)
[pid 283163] socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 7
[pid 283163] connect(7, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (Нет такого файла или каталога)
[pid 283163] socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 7
[pid 283163] connect(7, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.24.31.107")}, 16) = 0
[pid 283163] sendmmsg(7, [{msg_hdr={msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\250\207\1\0\0\1\0\0\0\0\0\0\10download\nastralinux"..., iov_len=40}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, msg_len=40}, {msg_hdr={msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\240\215\1\0\0\1\0\0\0\0\0\0\10download\nastralinux"..., iov_len=40}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, msg_len=40}], 2, MSG_NOSIGNAL) = 2
[pid 283163] recvfrom(7, "\250\207\201\200\0\1\0\1\0\0\0\0\10download\nastralinux"..., 2048, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.24.31.107")}, [28->16]) = 56
[pid 283163] recvfrom(7, "\240\215\201\200\0\1\0\0\0\1\0\0\10download\nastralinux"..., 65536, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.24.31.107")}, [28->16]) = 114
[pid 283163] sendto(6, "\1", 1, MSG_NOSIGNAL, NULL, 0) = 1
[pid 283163] +++ exited with 0 +++
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 5
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(5, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPIDLE, [60], 4) = 0
setsockopt(5, SOL_TCP, TCP_KEEPINTVL, [60], 4) = 0
connect(5, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("130.193.50.59")}, 16) = -1 EINPROGRESS (Операция выполняется в данный момент)
getsockopt(5, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
getpeername(5, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("130.193.50.59")}, [128->16]) = 0
getsockname(5, {sa_family=AF_INET, sin_port=htons(48488), sin_addr=inet_addr("172.24.31.241")}, [128->16]) = 0
sendto(5, "GET / HTTP/1.1\r\nHost: download.a"..., 86, MSG_NOSIGNAL, NULL, 0) = 86
recvfrom(5, "HTTP/1.1 200 OK\r\nServer: nginx/1"..., 102400, 0, NULL, NULL) = 1617

Что мы видим в этом strace

1. Попытка использовать NSCD (Name Service Cache Daemon)

connect(..., "/var/run/nscd/socket", ...) = -1 ENOENT

Это означает, что glibc сначала пытается использовать кеш имён из NSCD, если он запущен. В системе его нет, и запрос идёт дальше.

2. Вызов socket() и connect() к DNS-серверу

socket(AF_INET, SOCK_DGRAM|..., IPPROTO_IP) = 7
connect(7, ..., sin_addr=inet_addr("172.24.31.107")...)

Здесь создаётся UDP-сокет для обращения к DNS-серверу, указанному в /etc/resolv.conf.

3. Вызов sendmmsg() — отправка DNS-запросов

sendmmsg(7, [ { "download.astralinux.ru" }, { "download.astralinux.ru" } ], ...)

Здесь отправляются запросы на резолв имени. 

4. Ответ от DNS

recvfrom(...) = 56
recvfrom(...) = 114

Теперь IP-адрес известен.

56 - это размер DNS-ответа в байтах, содержащего А-запись (IPv4-адрес)

114 - размер дополнительных данных, например CNAME, или авторитетные серверы в случае рекурсивного запроса. 

5. TCP-соединение по IP

connect(5, ..., sin_addr=inet_addr("130.193.50.59"))

Здесь уже сам curl устанавливает TCP-соединение по IP-адресу, который ему вернула getaddrinfo().

Таким образом, когда мы вызываем curl, мы не видим DNS-запросов напрямую — их делает библиотека glibc внутри вызова getaddrinfo(). Но strace позволяет увидеть косвенные признаки:

Среди вызовов будет попытка подключиться к nscd, вызов connect() к DNS-серверу, отправка UDP-пакета через sendmmsg(), а затем — стандартное TCP-соединение по IP:

connect(7, {AF_INET, 172.24.31.107:53}) = 0
sendmmsg(7, [{ "download.astralinux.ru" }]) = 2
recvfrom(7, ...) = ...
connect(5, {130.193.50.59:80}) = 0

Важно отметить, что поведение getaddrinfo() может зависеть от реализации libc. Например, в glibc результаты могут кэшироваться, что влияет на производительность и актуальность данных.

Краткое резюме и ключевые моменты

  • DNS-запрос в Linux — это не обязательно запрос к DNS-серверу. Цепочка обращений может включать hosts, NSS, glibc и другие источники.

  • NSS и nsswitch.conf определяют порядок и источники разрешения имён.

  • glibc использует NSS и может кэшировать результаты; musl реализует DNS-резолвинг напрямую с ограниченной поддержкой опций resolv.conf.

  • Resolv.conf управляет настройками резолвера, но может быть изменен динамически.

  • Getaddrinfo() — основной интерфейс для разрешения имен, обрабатывает как DNS, так и другие источники.

  • В разных языках программирования (Go, Java, Python с dns.resolver, Node.js) могут использоваться собственные механизмы DNS-запросов.

В следующей части дополним картину общим представлением того, как устроено кэширование DNS записей — ключевой механизм, который напрямую влияет на производительность, надежность и поведение приложений при смене IP-адресов.

Комментарии (5)


  1. lrrr11
    18.06.2025 11:28

    Стоит отметить, что некоторые приложения, например, написанные на Go, могут полностью обходить glibc/musl и использовать собственные DNS-резолверы.

    в Go вообще черная магия. Там есть 2 ресолвера - один написанный на Go, который парсит /etc/resolv.conf, и другой, который вызывает getaddrinfo через Cgo. Выбирается один из этих двух примерно так:

    When cgo is available, the cgo-based resolver is used instead under a variety of conditions: on systems that do not let programs make direct DNS requests (OS X), when the LOCALDOMAIN environment variable is present (even if empty), when the RES_OPTIONS or HOSTALIASES environment variable is non-empty, when the ASR_CONFIG environment variable is non-empty (OpenBSD only), when /etc/resolv.conf or /etc/nsswitch.conf specify the use of features that the Go resolver does not implement.

    The resolver decision can be overridden by setting the netdns value of the GODEBUG environment variable (see package runtime) to go or cgo

    The decision can also be forced while building the Go source tree by setting the netgo or netcgo build tag. The netgo build tag disables entirely the use of the native (CGO) resolver, meaning the Go resolver is the only one that can be used. With the netcgo build tag the native and the pure Go resolver are compiled into the binary, but the native (CGO) resolver is preferred over the Go resolver. With netcgo, the Go resolver can still be forced at runtime with GODEBUG=netdns=go

    как говорится, удачной отладки))


  1. Andrei9385
    18.06.2025 11:28

    Здравствуйте. А вот допустим есть DNS сервер на Linux, BIND.

    У него прописаны DNS гугла и яндекса.

    Как я могу узнать, с помощью какого адреса я делаю разрешение в данный момент ?


    1. rearranged Автор
      18.06.2025 11:28

      Если коротко, то можно включить логирование на bind, или через tcpdump посмотреть. Более подробно планировал про дебаг описать в третьей части серии, поэтому не хотелось бы сразу спойлерить...


      1. Andrei9385
        18.06.2025 11:28

        Спасибо Вам большое. Обождем. Я смотрел в tcpdump, но не нашел, буду ждать следующую серию Ваших статей. Благодарю !


  1. eaa
    18.06.2025 11:28

    Прошли старые добрые времена, когда все было понятно в /etc/resolv.conf, теперь там отправляют в nameserver 127.0.0.53 и пишут "DO NOT EDIT THIS FILE BY HAND"

    И про это ни слова в статье.