51. DNS глазами ОС и libc

netgo, cgo и под капотом резолвера

«Это точно не DNS. Это никак не может быть DNS. Это был DNS».
— Старинная пословица системных администраторов и SRE-инженеров


Введение

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

Для большинства прикладных программистов разрешение доменных имен (Domain Name System) представляется чем-то элементарным: «превращение строчки api.example.com в IP-адрес 93.184.216.34». Мы пишем http.Get("https://api.example.com") и не задумываемся, что DNS — это самый первый, самый незаметный и зачастую самый узкий сетевой этап в жизненном цикле любого запроса.

Задержки DNS, промахи мимо кэша, последовательные тайм-ауты в libc и неочевидные правила подстановки поисковых доменов способны превратить 2-миллисекундный микросервисный вызов в пятисекундную катастрофу (tail-latency).

В классических стеках (C, C++, Java, Python, PHP) разрешение доменов традиционно делегируется библиотеке Си — libc (через функции getaddrinfo, gethostbyname). Создатели Go пошли на смелый и революционный шаг: они спроектировали собственный автономный резолвер на чистом Go (netgo), независимый от системных библиотек.

В этой статье мы разберем, как операционная система и рантайм Go на самом деле резолвят доменные имена, где прячутся блокирующие системные вызовы, и как правильно настраивать резолвер для высоконагруженного production.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    App["Go Application (http.Get / net.Dial)"]:::warning --> ResolverChoice{"Выбор резолвера (CGO_ENABLED / netdns)"}

    ResolverChoice -->|"netcgo (CGO_ENABLED=1)"| LibC["Системный libc: getaddrinfo()
(Блокирующий поток ОС)"]
    ResolverChoice -->|"netgo (По умолчанию в Go)"| PureGo["Чистый Go: netgo
(Асинхронный netpoller)"]

    LibC -->|"Последовательный опрос"| DNS1["DNS Server 1 (timeout 5s)"]
    DNS1 -.->|"Сбой"| DNS2["DNS Server 2"]

    PureGo -->|"Параллельный / Конкурентный опрос"| BothServers["Одновременный запрос к Server 1 и Server 2
(Первый ответивший побеждает!)"]
  

Архитектура DNS-разрешения в ОС

В операционных системах семейства Linux разрешение символических имен не происходит по волшебству. Это детерминированный конвейер, управляемый конфигурационным файлом /etc/resolv.conf и подсистемой переключения служб имен NSS (Name Service Switch) через файл /etc/nsswitch.conf.

Конфигурационный файл /etc/resolv.conf служит единой точкой правды для DNS-клиентов в стандарте POSIX:

Директива Назначение параметра Влияние на производительность и задержки
nameserver IP-адреса вышестоящих DNS-серверов (стандартно максимум 3) Определяет, куда направляются UDP-пакеты. Порядок критичен для libc, но параллелизуется в Go.
search Список доменных суффиксов для автоматической подстановки Умножает число запросов! Каждая горутина может выполнить до len(search) холостых DNS-запросов.
ndots Порог количества точек в запрашиваемом имени (по умолчанию 1) Если в имени меньше точек, чем ndots, к нему сначала принудительно дописываются домены из search.
timeout / attempts Таймаут ожидания ответа и лимит повторных попыток на сервер Определяет, сколько секунд горутина проведет в блокировке при недоступности DNS.
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    AppReq["Запрос: 'payment-service'"]:::entry --> NdotsCheck{"Точек < ndots (ndots:5)?"}
    NdotsCheck -->|"Да"| Search1["1. payment-service.default.svc.cluster.local"]
    Search1 -->|"NXDOMAIN"| Search2["2. payment-service.svc.cluster.local"]
    Search2 -->|"NXDOMAIN"| Search3["3. payment-service.cluster.local"]
    Search3 -->|"NXDOMAIN"| RootQuery["4. payment-service. (Исходное имя)"]
    NdotsCheck -->|"Нет"| Direct["Прямой запрос к DNS"]
  

libc и getaddrinfo: Классический подход

Когда программа на C/C++, Java или Python выполняет сетевое подключение, она обращается к библиотечной функции libc: getaddrinfo().

Конвейер выполнения getaddrinfo():

  1. Обращается к файлу /etc/hosts. Если IP найден локально — поиск завершается без сетевых пакетов.
  2. Проверяет настройки nsswitch.conf (модули files, dns, mdns4).
  3. Парсит домены search и параметр ndots.
  4. Формирует UDP-пакет запроса (стандартный порт 53) и отправляет его на первый адрес nameserver.
  5. Если ответ DNS-сервера оказывается длиннее 512 байт, в заголовке выставляется флаг усечения TC (Truncation flag = 1). В этом случае клиент обязан повторить запрос по протоколу TCP!
  6. Аллоцирует связанный список структур struct addrinfo (содержащих адреса IPv4 и IPv6) и возвращает результат в память процесса.

Системные вызовы ядра:
Для UDP-запроса libc выполняет сисколлы: socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP), sendto(), recvfrom(), close().
Если требуется TCP (при TC=1 или запросах зон): socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), connect(), write(), read(), close().


Go-резолвер: netgo, netcgo и net.Resolver

Создатели Go стремились к созданию самодостаточных, статически скомпилированных бинарников без динамических связей с libc. Кроме того, блокирующий характер getaddrinfo() плохо вязался с легковесной моделью горутин и netpoller.

Это решало проблемы с утечками памяти в стандартном net/http и блокировками системных потоков при CGO_ENABLED=1. Так родился встроенный резолвер netgo — полноценный DNS-клиент, написанный целиком на Go.


Режимы работы

Поведение рантайма Go контролируется тегами сборки и переменной окружения GODEBUG=netdns=...:

Режим переменной netdns Механика работы Где и когда используется
cgo (или netcgo) Принудительно делегирует разрешение функции libc getaddrinfo через CGO Используется при сборке CGO_ENABLED=1 на системах со специфичными модулями NSS (LDAP, Winbind)
go (или netgo) Автономный резолвер на чистом Go На linux Go по умолчанию использует его, пока в конфигурации (nsswitch.conf, переменные LOCALDOMAIN, RES_OPTIONS, HOSTALIASES и др.) нет ничего, что он не умеет; на windows, darwin, plan9, android, ios по умолчанию используется системный резолвер

Как работает netgo

Встроенный резолвер netgo самостоятельно читает и парсит /etc/resolv.conf, но принципиально отличается от классического libc:

  1. Последовательный опрос серверов с собственными таймаутами: Если в конфигурации указано несколько nameserver, Go перебирает их по очереди (в цикле по числу попыток и по списку серверов), а не запрашивает все сразу. Параллельно идут лишь запросы разных типов (A и AAAA). Таймаут на каждую попытку и число попыток берутся из resolv.conf (timeout, attempts), поэтому зависание первого сервера задержит ответ, но не заблокирует его навсегда.
  2. Интеллектуальная обработка ndots и search: Go точно следует спецификациям RFC, но оптимизирует очевидные случаи: например, Go никогда не станет подставлять суффиксы search к адресу localhost или литеральным IP-адресам.
  3. Отсутствие встроенного кэширования: По умолчанию резолвер netgo не кэширует результаты DNS-запросов в оперативной памяти процесса! Каждый вызов net.Dial или net.LookupIP производит реальный сетевой обмен по UDP, если в системе не установлен локальный кэширующий DNS-демон.

Ниже приведен практический пример создания изолированного DNS-резолвера в Go с защитой от сбоев системного /etc/resolv.conf:

Go
package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "time"
)

func main() {
    // 1. Создаем кастомный net.Resolver, гарантированно работающий на чистом Go (PreferGo: true)
    resolver := &net.Resolver{
        PreferGo: true, // Игнорировать libc getaddrinfo, использовать чистый netgo
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            d := net.Dialer{
                Timeout: 2 * time.Second, // Жесткий тайм-аут на DNS-запрос
            }
            // Направляем запросы на выделенный корпоративный или публичный DNS-сервер
            // в обход потенциально проблемного локального /etc/resolv.conf
            return d.DialContext(ctx, "udp", "1.1.1.1:53")
        },
    }

    // 2. Ограничиваем общее время операции контекстом
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // 3. Выполняем поиск только IPv4-адресов (ip4)
    addrs, err := resolver.LookupIP(ctx, "ip4", "example.com")
    if err != nil {
        log.Fatalf("Ошибка DNS-разрешения: %v", err)
    }

    fmt.Println("Успешно получены IP-адреса через netgo:")
    for _, ip := range addrs {
        fmt.Printf(" -> %s\n", ip.String())
    }
}

Mechanical Sympathy: Сеть, кэш и блокировки

Разрешение DNS с точки зрения системного программирования и ресурсов процессора:

  1. UDP-дейтаграмма (DNS Query): Размер стандартного запроса составляет всего 50–70 байт. Пакет уходит через сисколл sendto(), проходит через подсистему маршрутизации ядра ip_output() и летит в сетевую карту. Ответ вычитывается через recvfrom(). В Go под капотом создается сокет net.UDPConn, а данные читаются методом ReadFromUDP().
  2. Локальный сокет против сети и аппаратные прерывания: При первом запросе к новому DNS-серверу в ядре может происходить Page Fault для подгрузки таблиц маршрутизации и структур сокета. Если в Linux настроен Если в Linux настроен systemd-resolved или dnsmasq, файл /etc/resolv.conf указывает на адрес 127.0.0.53:53 или Unix Domain Socket (/run/systemd/resolve/resolv.conf). Это позволяет ядру обработать DNS-запрос локально в оперативной памяти через кольцевой буфер без отправки радиосигналов и фотонов в физическую сеть, предотвращая лишние переключения контекста и аппаратные прерывания.
  3. Блокировки горутин и планировщик: Базовый вызов net.Dial("tcp", "host:port") синхронно паркует горутину до тех пор, пока DNS-резолвер не вернет IP-адрес. В сетевом поллере Go netgo не блокирует системный тред ОС (M), поскольку дескриптор UDP регистрируется в epoll. Однако на прикладном уровне горутина простаивает. Для высоконагруженных микросервисов критически важно использовать net.Resolver с жестким context тайм-аутом (DialContext).
  4. Аллокации памяти и давление на GC: Каждый вызов LookupIP или LookupHost аллоцирует в куче срезы []net.IP и []string. Если сервис делает миллион HTTP-запросов к одним и тем же хостам в секунду, это создает гигантскую нагрузку на сборщик мусора Go. В таких архитектурах обязательно внедряют пул буферов sync.Pool или локальный LRU-кэш адресов (например, библиотеку hashicorp/golang-lru).
sequenceDiagram
    participant G as Горутина Go
    participant Net as net.Resolver
    participant Sys as syscall sendto/recvfrom
    participant DNS as DNS Server (UDP 53)

    G->>Net: LookupIP(ctx, host)
    Net->>Net: Чтение /etc/resolv.conf
    Net->>Sys: socket(AF_INET, SOCK_DGRAM) + sendto()
    Sys->>DNS: UDP DNS Query (50 байт)
    DNS-->>Sys: UDP Response (A / AAAA records)
    Sys-->>Net: recvfrom()
    Net-->>G: []net.IP (слайс адресов в куче)
    Note over Net,DNS: При таймауте Net переходит к следующему nameserver
  

Итоги

  1. OS vs Go: Классический libc (getaddrinfo) опрашивает серверы последовательно (с ожиданием таймаута при сбое), кэширует конфигурацию и блокирует системные потоки. Встроенный резолвер Go (netgo) тоже опрашивает серверы последовательно, но запросы A и AAAA отправляет параллельно, интегрируется с netpoller и не зависит от динамических библиотек.
  2. Системный конвейер: DNS-резолвинг на уровне ядра — это быстрый обмен легковесными UDP-дейтаграммами через socket, sendto, recvfrom. В случае усечения ответа (флаг TC) повторный запрос по TCP делает сам резолвер (libc или Go), а не ядро.
  3. musl против glibc: Для контейнеризованных Go-приложений золотым правилом остается компиляция с флагом CGO_ENABLED=0, исключающая несовместимости с libc хоста.
  4. Механическое сочувствие и оптимизации: Избегайте аллокаций []net.IP на каждый чих, внедряйте кэширование на базе hashicorp/golang-lru, контролируйте параметр ndots в Kubernetes и всегда ограничивайте DNS-запросы дедлайнами контекста context.WithTimeout.

Мы досконально разобрали, как символические имена превращаются в IP-адреса на уровне операционной системы и рантайма Go. Но как операционная система защищает себя от перегрузки ресурсами, утечек памяти и неконтролируемого открытия сокетов? В следующей статье: 52. Ограничения ОС. ulimit, cgroups, quotas, где мы исследуем механизмы квот, лимитов ulimit и контрольных групп cgroups v2, управляющих жизнью Go-сервисов в контейнерах.