Деплой тормозит из-за DNS: как кеш резолвера на сервере экономит минуты
Пайплайн деплоя тянется по три-пять минут, хотя сама сборка занимает секунды, а тесты — ещё минуту. Первая мысль — виноваты медленные раннеры или перегруженный registry. Но если разложить время шаг за шагом, часто выясняется, что заметная его часть уходит не на скачивание и не на выполнение кода, а на то, чтобы просто узнать, по какому IP стучаться — то есть на DNS. И виновата в этом не сеть сама по себе, а отсутствие кеша там, где он мог бы накопиться.
Содержание
- Откуда в пайплайне берутся лишние DNS-резолвинги
- Почему на эфемерном раннере это не сглаживается кешем
- Почему это не бросается в глаза, но набегает
- Локальный кеширующий резолвер на самом раннере
- Кеш DNS на уровне Docker и контейнерного рантайма
- Встроенное кеширование пакетных менеджеров — не только DNS
- Локальные зеркала и прокси реестров пакетов — радикальное решение
Откуда в пайплайне берутся лишние DNS-резолвинги
CI/CD-пайплайн — это цепочка сетевых обращений, и у каждого обращения есть имя хоста, которое нужно превратить в адрес:
- Скачивание зависимостей.
npm installрезолвитregistry.npmjs.org,pip install—pypi.orgиfiles.pythonhosted.org(часто это отдельный хост под пакеты),apt-get update— один или несколько зеркал в/etc/apt/sources.list,composer install—packagist.orgплюс сами репозитории пакетов. docker pullобразов. Здесь резолвинг скрыт от глаз сильнее всего: клиент Docker обращается не к одному хосту, а к нескольким — сам registry (registry-1.docker.ioдля Docker Hub или свойghcr.io/приватный registry), отдельно сервер аутентификации (auth.docker.io), и отдельно CDN, откуда реально льются слои образа (у Docker Hub это, как правило, домены Cloudflare). Каждый из них — самостоятельное DNS-имя.- Внешние API в скриптах деплоя. Уведомление в Slack или Telegram по вебхуку, обращение к API облака для создания или обновления ресурса, отправка события в Sentry, проверка статуса через API хостинга — в каждом таком
curlили SDK-вызове скрипт заново резолвит соответствующий домен, если только явно не переиспользует уже открытое соединение.
По отдельности каждый такой запрос — это доли секунды. Проблема не в одном запросе, а в том, что в типичном пайплайне их набирается несколько десятков: свои зависимости у бэкенда, свои у фронтенда, отдельный образ для сборки, отдельный — для рантайма, плюс шаги линтинга, тестов, публикации артефакта и уведомлений. Если каждое имя резолвится заново и с нуля, время просто складывается — линейно, шаг за шагом, без амортизации.
Почему на эфемерном раннере это не сглаживается кешем
На проде картина принципиально другая. Сервер, который работает неделями, успевает накопить локальный DNS-кеш под свой стабильный набор хостов: он уже знает адрес платёжного шлюза, S3-совместимого хранилища, внешнего API, с которым интегрирован сервис. TTL записей истекает и обновляется, но сама инфраструктура кеша — резолвер, его память, его статистика попаданий — живёт столько же, сколько живёт сервер. О вкладе резолвинга в задержки при обычной, не CI-нагрузке подробно разобрано в статье про вклад DNS-резолвинга во время загрузки — там счёт идёт на конкретные запросы страницы, но логика кеша та же.
CI-раннер устроен иначе — он эфемерен по определению. GitHub Actions поднимает под задачу свежую виртуальную машину и уничтожает её после job'а. GitLab Runner с Docker executor стартует новый контейнер на каждый пайплайн. Self-hosted раннер может жить дольше, но если он тоже пересоздаётся из образа или сбрасывается между джобами ради изоляции — эффект тот же. У свежесозданного окружения /etc/resolv.conf только что записан, локального резолвера с историей запросов ещё не существует, и любой процесс, которому нужно постучаться наружу, идёт за ответом с нуля — к резолверу облачного провайдера, к резолверу докер-демона или к тому, что прописано в образе по умолчанию.
Отдельно эту цепочку удлиняет параметр ndots в resolv.conf — особенно в контейнерных и Kubernetes-окружениях, где он часто выставлен агрессивно ради резолвинга коротких внутренних имён сервисов. В результате один внешний запрос превращается в несколько последовательных DNS-запросов, пока резолвер не переберёт все домены поиска и не попробует имя как есть. Разбор именно этого механизма и его последствий — в статье DNS внутри кластера отвечал 5 секунд: виноват был ndots. В CI это работает так же: если в образе раннера или в контейнере сборки ndots выставлен неаккуратно, каждое обращение к внешнему registry или API молча увеличивается в числе запросов.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему это не бросается в глаза, но набегает
Отдельный некешированный DNS round-trip — это, как правило, десятки миллисекунд, а при обращении к резолверу в другом регионе или с ограниченной пропускной способностью — больше. Само по себе это не то, что человек замечает, глядя на лог одного шага. Проблема в умножении: сборка из нескольких сервисов, у каждого свои зависимости и свой базовый образ; параллельные джобы, каждая из которых стартует с нуля и повторяет тот же путь резолвинга; шаги линтинга, тестов, сборки и публикации, каждый из которых тянет что-то своё из сети.
Точную цифру экономии на конкретном пайплайне можно получить только измерением — она зависит от количества зависимостей, региона раннера, латентности до апстрим-резолвера и от того, насколько параллельны шаги. Но направление эффекта понятно: когда DNS-резолвинг не кешируется совсем, время на него линейно растёт с числом внешних обращений, а когда кешируется — после первого резолвинга повторные обращения к тому же хосту в рамках одного job'а практически ничего не стоят. Разница особенно заметна в матричных сборках (несколько версий языка, несколько ОС) и в монорепозиториях, где один пайплайн триггерит десяток независимых джобов — каждая из них платит DNS-цену заново, потому что кеш предыдущей джобы для неё не существует.
Локальный кеширующий резолвер на самом раннере
Первое и самое дешёвое решение — поднять локальный кеширующий резолвер прямо в окружении раннера, чтобы хотя бы в рамках одного job'а повторные резолвинги одного и того же хоста не улетали наружу.
Для self-hosted раннера (GitHub Actions self-hosted, GitLab Runner на своей VPS) это делается через dnsmasq или unbound, установленный в базовый образ раннера или запускаемый как системный сервис на хосте:
# минимальный /etc/dnsmasq.conf на раннере
no-resolv
server=1.1.1.1
server=8.8.8.8
cache-size=1000
neg-ttl=30
listen-address=127.0.0.1
После этого /etc/resolv.conf на хосте раннера указывает на 127.0.0.1, и все процессы, которые резолвят имена напрямую на хосте (не внутри контейнера), получают кешированный ответ уже со второго обращения к тому же домену. Даже в пределах одного job'а это снимает повтор: если сборка резолвит registry.npmjs.org пять раз для разных пакетов одним и тем же клиентом, DNS-запрос по факту уйдёт в сеть один раз.
Важная оговорка: кеш всё равно исчезает вместе с раннером. Это не замена долгоживущему серверному резолверу, а способ не платить DNS-цену внутри одной сессии сборки повторно. Если раннер живёт дольше одного job'а (постоянный self-hosted раннер, не пересоздаваемый между запусками), кеш частично сохраняется и между job'ами — тогда выгода растёт, потому что первый запрос "прогревает" резолвер для следующих запусков, пока не истечёт TTL.
Кеш DNS на уровне Docker и контейнерного рантайма
Отдельная сложность в том, что контейнер — это своя сетевая изоляция, и локальный резолвер на хосте раннера сам по себе контейнеру не виден. Docker по умолчанию встраивает собственный DNS-сервер на 127.0.0.11 внутри пользовательских bridge-сетей — он умеет резолвить имена других контейнеров в той же сети, но внешние запросы всё равно проксирует наружу без долговременного кеша между независимыми контейнерами.
Есть два практических пути:
- Указать DNS явно на уровне демона, чтобы все контейнеры на хосте резолвили через локальный кеширующий резолвер, поднятый на хосте (например, тот же
dnsmasq, слушающий не только127.0.0.1, но и адрес мостаdocker0):
// /etc/docker/daemon.json
{
"dns": ["172.17.0.1", "1.1.1.1"]
}
Здесь 172.17.0.1 — типичный адрес хоста на мосту docker0 по умолчанию (для кастомных сетей адрес будет другим — проверяется через docker network inspect). После правки конфига нужен перезапуск демона (systemctl restart docker), а сами контейнеры получат новый DNS только при пересоздании.
- Указать DNS точечно для конкретного запуска или сервиса — флагом
--dnsуdocker runили полемdns:вdocker-compose.yml, если хочется завести резолвер только для сборочных контейнеров, не трогая остальной хост.
Для managed CI (GitHub-hosted runners, GitLab.com SaaS-раннеры) доступа к демону в таком виде обычно нет — там остаётся либо смириться с холодным стартом на каждый job, либо переносить сборку на свой раннер (self-hosted), где такие настройки уже в вашей власти. Это одна из причин, по которой self-hosted раннер на отдельном VPS для CI/CD окупается не только гибкостью тарифа, но и контролем над такими мелочами инфраструктуры — тема разобрана отдельно применительно к ресурсам под раннер и настройке окружения разработчика.
Встроенное кеширование пакетных менеджеров — не только DNS
DNS-кеш решает только часть задачи: он ускоряет момент "узнать адрес", но не сам факт обращения в сеть за файлом. Здесь на помощь приходит второй слой — HTTP-кеш и локальный кеш самих пакетных менеджеров, который снижает число сетевых обращений вообще, а вместе с ними и число DNS-резолвингов.
- npm хранит скачанные пакеты в
~/.npm(или в$npm_config_cache). Если этот каталог сохраняется между запусками пайплайна через встроенный механизм кеширования CI (actions/cacheв GitHub Actions,cache:в.gitlab-ci.yml), повторная установка тех же зависимостей идёт из локального кеша без обращения к registry вообще — соответственно, без DNS-резолвингаregistry.npmjs.orgна этом шаге. - pip кеширует скачанные wheel-файлы аналогично, каталог кеша указывается через
PIP_CACHE_DIR, и его тоже можно сохранять между запусками job'ов. - apt таким кешем "из коробки" не располагается — пакеты скачиваются заново при каждом
apt-get update && apt-get install, если не настроен прокси вродеapt-cacher-ngили локальное зеркало. - Docker BuildKit кеширует слои сборки и умеет использовать cache mounts (
--mount=type=cache) для кеша менеджеров пакетов внутри самого Dockerfile — это тоже снижает число внешних обращений на пересборках.
Мораль: кеширующий резолвер экономит время на резолвинге имени, но если сам пакет всё равно скачивается заново с нуля при каждом запуске, львиная доля сетевого времени всё равно уходит на закачку. Правильная последовательность — сначала настроить кеш зависимостей и слоёв, и только затем сверху добавлять DNS-кеш для того, что кешу зависимостей не подвластно (аутентификация, метаданные, внешние API-вызовы).
Локальные зеркала и прокси реестров пакетов — радикальное решение
Если пайплайн заметно нагружен внешними зависимостями — много сервисов, много языков, частые сборки — разумно уйти от повторяющихся резолвингов и обращений к внешним registry в принципе, подняв собственное зеркало или прокси-кеш пакетного реестра внутри своей инфраструктуры. Тогда DNS-резолвинг для сборочных шагов сводится к одному внутреннему хосту, который либо резолвится мгновенно (внутренняя зона), либо не резолвится вовсе, если указан по IP.
Практические варианты:
- Nexus Repository или Artifactory как единая точка проксирования npm, PyPI, Maven и других реестров — сборки обращаются к своему Nexus, который сам кеширует и при необходимости идёт наружу за недостающим. Разбор именно такого сценария и того, зачем это нужно бизнесу, а не только для удобства разработчиков — в статье npm, PyPI и Maven через свой Nexus.
- Собственное зеркало пакетного репозитория для системных пакетов (apt/yum), когда официальный источник то недоступен, то отвечает медленно — вариант разворачивания такого зеркала на своём сервере за вечер описан в статье Репозиторий пакетов недоступен: поднимаем своё зеркало.
- Приватный Docker registry или pull-through cache перед внешним registry — снимает не только DNS-издержки, но и риск рейт-лимитов внешнего реестра образов.
Это решение требует отдельной инфраструктуры и её обслуживания, поэтому имеет смысл не с первого дня, а когда число сборок в сутки и число зависимостей делают повторяющиеся внешние обращения заметной статьёй расходов времени — и когда стабильность пайплайна важнее, чем экономия на управлении ещё одним сервисом.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Кеширующий резолвер на раннере — это то же самое, что публичный DNS вроде Cloudflare или Google?
Нет. Публичный резолвер кеширует у себя, а не у вас, и каждый запрос к нему всё равно уходит по сети за пределы раннера. Локальный резолвер кеширует в памяти самого раннера или контейнера — повторный запрос к уже резолвленному имени не покидает хост вообще.
Стоит ли ставить unbound или dnsmasq на managed раннерах GitHub Actions или GitLab.com?
На стандартных hosted-раннерах нет доступа к системным настройкам такого уровня, и окружение всё равно пересоздаётся под каждый job — выгода будет минимальной. Смысл появляется на self-hosted раннерах, где вы управляете образом и его жизненным циклом.
DNS-кеш поможет, если пайплайн тормозит из-за скачивания больших docker-образов?
Частично. Резолвинг ускорится, но сама передача байтов слоёв образа — это отдельная статья времени, которую DNS-кеш не трогает. Здесь помогает многоступенчатая сборка, кеш слоёв и локальный registry с pull-through кешем.
Можно ли просто прописать нужные хосты в /etc/hosts вместо резолвера и не думать о кеше?
Технически можно для стабильного, редко меняющегося набора хостов, но это ломается при ротации IP на стороне провайдера (например, у CDN) и требует ручного обновления — кеширующий резолвер с нормальным TTL безопаснее и не требует ручного сопровождения.
Нужно ли одновременно чистить кеш зависимостей и DNS-кеш, если пайплайн начал вести себя странно после смены registry?
Да — если сменился адрес или хост реестра, старые записи в DNS-кеше резолвера могут пережить TTL дольше ожидаемого при агрессивных настройках cache-size/neg-ttl, а локальный кеш пакетного менеджера может держать метаданные со старого источника. Проверяйте оба слоя по отдельности.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →