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

image-loader.svg

Привет, Хабр. Меня зовут Рустам. Я работаю в Ozon: админю Kubernetes и пишу на Go.

У нас очень много сервисов на Go — их количество исчисляется тысячами. Запускаются они внутри кластеров Kubernetes. А я плотно работаю с Kubernetes и заметил, что при запуске кода внутри Kubernetes для резолва одного адреса делается до десяти DNS-запросов. Это, конечно, влияет на производительность.

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

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

Tables

CGO_ENABLED = 0

CGO_ENABLED = 1

Linux

go

it depends =)

MacOs

go

cgo

Если 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. Выглядит логично:

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

image-loader.svg

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

image-loader.svg

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

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 resolverCgo resolverGo resolverGo resolver

Чтобы решить эту проблему, можно использовать кастомный DialContext. Внутри исходников http.Transport зашито использование TCP, а нам надо пробросить значение tcp4:

image-loader.svg

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

image-loader.svg

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

image-loader.svg

В Go 1.17 это исправили:

image-loader.svg

В итоге получается такая ситуация с 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:

image-loader.svg

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.

Можно увидеть много запросов:

image-loader.svg

В итоге делается восемь запросов — по два запроса (A и AAAA) на каждый из этих доменов:

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

Лечится это несколькими способами. Можно использовать полный FQDN с точкой в конце. А можно добавить настройку в поды. Но в обоих случаях вам нужно использовать полные домены при обращении к другим сервисам внутри Kubernetes.

dnsConfig:
  options:
    - name: ndots
      value: "1"

localhost

В Kubernetes могут осуществляться вызовы localhost. Обычно это происходит, когда используются сайдкары для сетевых вызовов. По сути, это прокси, которое работает в соседнем контейнере в поде, но в том же сетевом неймспейсе.

Смотрим на системе, в которой по умолчанию включается Cgo-резолвер и отсутствует файл /etc/nsswitch.conf (привет, Alpine). Запрашивается DNS, так как системный резолвер не знает, где сначала смотреть домен, и сразу делает запрос.

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

image-loader.svg

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

image-loader.svg

WAT?!

В версии 1.16 это исправили.

image-loader.svg

В версии 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: он резолвился в адрес.

image-loader.svgimage-loader.svg

Как говорится, 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-запросы:

© Habrahabr.ru