Как в восемь раз уменьшить количество DNS-запросов в Go

Kate

Administrator
Команда форума

Cgo-резолвер vs Go-резолвер​

Начнём немного издалека. Будет здорово, если вы освежите знания о том, как работает DNS Lookup в Linux. В данной статье я будут рассматривать версию Go 1.16.
В Go можно использовать две реализации резолверов: Go и Cgo.
Cgo использует системный резолвер, а Go — резолвер, написанный на Go. Казалось бы, они должны работать одинаково, но на самом деле есть различия. И какой включится по умолчанию, не очень очевидно, даже несмотря на документацию и код.
Различия в работе резолверов как раз и определяют, какое количество запросов будет сгенерировано в том или ином случае. Моей целью было выяснить, в каком случае стоит запустить один резолвер, а в каком — другой, и стараться учитывать это на старте проектов.
Итак, первым делом нам нужно понять, какой резолвер будет использоваться в конкретном случае. Это сильно зависит от среды, в которой запускается приложение, а иногда даже от имени хоста. Я использую преимущественно macOS и Linux, поэтому буду рассматривать только их.
В основном я сталкивался с такими внешними факторами, влияющими на выбор резолвера, как CGO_ENABLED и опции, используемые в resolv.conf и nsswitch.conf. Но не только с ними.

CGO_ENABLED​

TablesCGO_ENABLED = 0CGO_ENABLED = 1
Linuxgoit depends =)
MacOsgocgo
Если Cgo отключён, то всё просто — используется Go-резолвер. А если включён, то на macOS по умолчанию используется Cgo-резолвер. На Linux же всё сложнее и зависит от используемых env-переменных и опций в resolv.conf и nsswitch.conf.

resolv.conf​

Будет включаться Cgo-реализация, если в resolv.conf есть опции, кроме следующих:
  • ndots
  • timeout
  • attempts
  • rotate
  • single-requests
  • single-requests-reopen
  • use-vc, usevc, tcp
Полный список всех проверяемых опций
Тут включится Go-резолвер:
$ cat /etc/resolv.conf
nameserver 127.0.0.53
А тут уже будет работать Cgo-резолвер (это пример с десктопной Ubuntu):
$ cat /etc/resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad

nsswitch.conf​

Что касается nsswitch.conf, то, если файл отсутствует, включается Go-резолвер.
С таким nsswitch включается реализация на Go (это файл в контейнере ubuntu:focal):
passwd: compat
group: compat
shadow: compat
gshadow: files
hosts: files dns
networks: files
protocols: db files
services: db files
ethers: db files
rpc: db files
netgroup: nis
У меня на десктопной Ubuntu было вот такое: hosts: files dns mymachines. С такой конфигурацией включается Cgo-резолвер. Код проверок опций в nsswitch.

Принудительное включение резолвера​

Можно с помощью env-переменной GODEBUG принудительно включать нужный резолвер:
GODEBUG=netdns=cgo
GODEBUG=netdns=go
На самом деле, есть ещё много вещей, которые оказывают влияние на выбор резолвера. Но я в повседневной практике с ними не сталкивался. Если интересно, можно изучить код.

Проверка какой резолвер включается​

Напишем простую программу, которая сделает GET-запрос:
req, err := http.NewRequest("GET", url, nil)
if err != nil {
panic(err)
}

_, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
Запускаем с дебагом DNS в системе, где по умолчанию выбирается Cgo. Выглядит логично:
f00dbdb512f68a36c3aee58b770927d4.png

Добавляем резолв ещё одного домена: my.local.
253e99dda5b2d25e2ae8aef8548fbf1c.png

Принудительно указываем Go-резолвер. Пока ещё логично:
99519b70c87201c31f40c90281abce3e.png

Теперь запускаем программу в системе, где Go-резолвер будет выбираться по умолчанию:
9254ed6fed5dd1231657a71966972eec.png

WAT?! Почему-то для домена my.local включается реализация Cgo-резолвера, хотя для golang.org выбрался Go-резолвер. В коде Go-резолвера есть условие для доменов .local. Поэтому я рекомендую делать реальные запросы и сниффать DNS-запросы через tcpdump -i any -nnn port 53.

Подводные камни​

Я нашёл примеры нескольких ситуаций, которые влияют на количество запросов. Это IPv6, опция ndots в resolv.conf и резолвинг localhost.

IPv6​

В Go-резолвере всегда делается два запроса в DNS: A и AAAA (даже если вы не используете IPv6). Если у вас нет опции single-request в resolv.conf, они выполняются параллельно, но всё же это дополнительные запросы.
Cgo resolver
Cgo resolver
Go resolver
Go resolver
Чтобы решить эту проблему, можно использовать кастомный DialContext. Внутри исходников http.Transport зашито использование TCP, а нам надо пробросить значение tcp4:
bfd7d1a57ba06f3138e7f0e9ab7364b7.png

Посмотрим, есть ли сейчас АААА-запросы в DNS. При использовании Cgo-резолвера дополнительного запроса нет:
61723c370edfec2c4ed7fb8babde1dae.png

А вот при использовании Go-резолвера мы видим запрос для получения адреса IPv6:
a0105bc2d5dcc93c7cdcd55e3f3ab627.png

В Go 1.17 это исправили:
6463724228efb1802d71f6d30c70f644.png

В итоге получается такая ситуация с IPv6:
resolver
1.16
1.17
cgo
2​
2​
go
2​
2​
cgo custom transport
1​
1​
go custom transport
2​
1​

ndots​

Эта тема особенно актуальна для тех, кто использует Kubernetes. По умолчанию в подах Kubernetes будет примерно такой resolv.conf:
72edb5d4511a438b8ef7409df5ecbed3.png

ndots:n Sets a threshold for the number of dots which must appear in a name given to res_query(3) (see resolver(3)) before an initial absolute query will be made. The default for n is 1, meaning that if there are any dots in a name, the name will be tried first as an absolute name before any search list elements are appended to it. The value for this option is silently capped to 15.
Можно увидеть много запросов:
a32bddd8c84570e3cd92060d8888b602.png

В итоге делается восемь запросов — по два запроса (A и AAAA) на каждый из этих доменов:
  • golang.org.ns.svc.cluster.local
  • golang.org.svc.cluster.local
  • golang.org.cluster.local
  • golang.org
Это нужно для того, чтобы под мог ходить в другой сервис в том же неймпейсе только по имени сервиса, без полного FQDN. Но ситуация усугубляется, если в вашей сети есть ещё и ваши собственные search-домены. Каждый такой домен будет подставляться при попытке резолва адреса.
Лечится это несколькими способами. Можно использовать полный FQDN с точкой в конце. А можно добавить настройку в поды. Но в обоих случаях вам нужно использовать полные домены при обращении к другим сервисам внутри Kubernetes.
dnsConfig:
options:
- name: ndots
value: "1"

localhost​

В Kubernetes могут осуществляться вызовы localhost. Обычно это происходит, когда используются сайдкары для сетевых вызовов. По сути, это прокси, которое работает в соседнем контейнере в поде, но в том же сетевом неймспейсе.
Смотрим на системе, в которой по умолчанию включается Cgo-резолвер и отсутствует файл /etc/nsswitch.conf (привет, Alpine). Запрашивается DNS, так как системный резолвер не знает, где сначала смотреть домен, и сразу делает запрос.
02568a05fc3a7e34fd8f9153336fd670.png

Принудительно включаем Go-резолвер. Запросов нет:
c3480db54872478c544d23cf23fccad9.png

Теперь попробуем запустить программу на системе, где Go-резолвер включается по умолчанию:
3274462a748fab9111b9f3f66923cddb.png

WAT?!
В версии 1.16 это исправили.
f9a9e016da222411ef829c740a162946.png

В версии Go до 1.16
hostLookupOrder(localhost) = dns,files
В версии Go 1.16
hostLookupOrder(localhost) = files,dns
Если файл такой, то резолвер будет сначала искать совпадения в /etc/hosts, даже в приведённых выше примерах, и не будет лишних запросов:
$ cat /etc/nsswitch.conf
hosts: files dns
А если такой, то всегда сначала будет делаться запрос в DNS:
$ cat /etc/nsswitch.conf
hosts: dns files
В моей практике был забавный случай с localhost: он резолвился в адрес.
6d8c0c7ba578031e623a5a577ed7a1e2.png
5f132585cd7feee7a0cc968f1da57ee8.png

Как говорится, happy debugging, suckers!

Баги ядра​

Ещё я натыкался на баги в ядре. Были race conditions в DNAT Conntrack. Возникали они, когда отправлялись одновременно два UDP-пакета через один сокет из разных тредов. Это поправлено в версии 4.19 и совсем исправлено — в 5.0.

Итоги​

Если использовать образ Ubuntu Focal, то будет использоваться Go-резолвер, а если Alpine 3.13 — то Cgo-резолвер. На macOS будет использоваться Cgo-резолвер. Как мне кажется, сейчас большинство программ на Go запускается в Kubernetes, поэтому quick win будет исправление ndots. Это позволит в четыре раза уменьшить количество DNS-запросов.
Если же использовать дополнительный код для исключения IPv6 адресов, то можно ещё в два раза уменьшить количество DNS-запросов. В результате мы можем добиться сокращения запросов в целых восемь раз. При этом проверять, какие запросы отправляются в DNS, лучше через tcpdump.
Ну и последнее: не стесняйтесь копаться в кишочках используемых технологий — вы можете открыть для себя много нового и серьёзно улучшить производительность ваших приложений.
Полезные ссылки:
Как Linux делает резолв:
Интересные статьи-расследования про DNS-запросы:

 
Сверху