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():
- Обращается к файлу
/etc/hosts. Если IP найден локально — поиск завершается без сетевых пакетов. - Проверяет настройки
nsswitch.conf(модули files, dns, mdns4). - Парсит домены
searchи параметрndots. - Формирует UDP-пакет запроса (стандартный порт 53) и отправляет его на первый адрес
nameserver. - Если ответ DNS-сервера оказывается длиннее 512 байт, в заголовке выставляется флаг усечения
TC(Truncation flag = 1). В этом случае клиент обязан повторить запрос по протоколу TCP! - Аллоцирует связанный список структур
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:
- Последовательный опрос серверов с собственными таймаутами: Если в конфигурации указано несколько
nameserver, Go перебирает их по очереди (в цикле по числу попыток и по списку серверов), а не запрашивает все сразу. Параллельно идут лишь запросы разных типов (A и AAAA). Таймаут на каждую попытку и число попыток берутся изresolv.conf(timeout,attempts), поэтому зависание первого сервера задержит ответ, но не заблокирует его навсегда. - Интеллектуальная обработка
ndotsиsearch: Go точно следует спецификациям RFC, но оптимизирует очевидные случаи: например, Go никогда не станет подставлять суффиксыsearchк адресуlocalhostили литеральным IP-адресам. - Отсутствие встроенного кэширования: По умолчанию резолвер
netgoне кэширует результаты DNS-запросов в оперативной памяти процесса! Каждый вызовnet.Dialилиnet.LookupIPпроизводит реальный сетевой обмен по UDP, если в системе не установлен локальный кэширующий DNS-демон.
Ниже приведен практический пример создания изолированного DNS-резолвера в Go с защитой от сбоев системного /etc/resolv.conf:
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 с точки зрения системного программирования и ресурсов процессора:
- UDP-дейтаграмма (DNS Query): Размер стандартного запроса составляет всего 50–70 байт. Пакет уходит через сисколл
sendto(), проходит через подсистему маршрутизации ядраip_output()и летит в сетевую карту. Ответ вычитывается черезrecvfrom(). В Go под капотом создается сокетnet.UDPConn, а данные читаются методомReadFromUDP(). - Локальный сокет против сети и аппаратные прерывания: При первом запросе к новому 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-запрос локально в оперативной памяти через кольцевой буфер без отправки радиосигналов и фотонов в физическую сеть, предотвращая лишние переключения контекста и аппаратные прерывания. - Блокировки горутин и планировщик: Базовый вызов
net.Dial("tcp", "host:port")синхронно паркует горутину до тех пор, пока DNS-резолвер не вернет IP-адрес. В сетевом поллере Gonetgoне блокирует системный тред ОС (M), поскольку дескриптор UDP регистрируется вepoll. Однако на прикладном уровне горутина простаивает. Для высоконагруженных микросервисов критически важно использоватьnet.Resolverс жесткимcontextтайм-аутом (DialContext). - Аллокации памяти и давление на 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
Итоги
- OS vs Go: Классический libc (
getaddrinfo) опрашивает серверы последовательно (с ожиданием таймаута при сбое), кэширует конфигурацию и блокирует системные потоки. Встроенный резолвер Go (netgo) тоже опрашивает серверы последовательно, но запросы A и AAAA отправляет параллельно, интегрируется сnetpollerи не зависит от динамических библиотек. - Системный конвейер: DNS-резолвинг на уровне ядра — это быстрый обмен легковесными UDP-дейтаграммами через
socket,sendto,recvfrom. В случае усечения ответа (флагTC) повторный запрос по TCP делает сам резолвер (libc или Go), а не ядро. - musl против glibc: Для контейнеризованных Go-приложений золотым правилом остается компиляция с флагом
CGO_ENABLED=0, исключающая несовместимости с libc хоста. - Механическое сочувствие и оптимизации: Избегайте аллокаций
[]net.IPна каждый чих, внедряйте кэширование на базеhashicorp/golang-lru, контролируйте параметрndotsв Kubernetes и всегда ограничивайте DNS-запросы дедлайнами контекстаcontext.WithTimeout.
Мы досконально разобрали, как символические имена превращаются в IP-адреса на уровне операционной системы и рантайма Go. Но как операционная система защищает себя от перегрузки ресурсами, утечек памяти и неконтролируемого открытия сокетов? В следующей статье: 52. Ограничения ОС. ulimit, cgroups, quotas, где мы исследуем механизмы квот, лимитов ulimit и контрольных групп cgroups v2, управляющих жизнью Go-сервисов в контейнерах.