
Интернет — это зависимость без зафиксированной версии. Сегодня он возвращает одно, завтра — другое, а послезавтра — ничего. Поэтому мы решили сохранить для сборки тот интернет, который она однажды увидела. Для этого пришлось перехватывать HTTPS, выпускать собственные сертификаты, переподписывать индексы Debian и выяснять, почему Docker отправляет «случайные» заголовки.
Привет! Меня зовут Денис, я из команды сетевой автоматизации в Yandex Infrastructure, где мы разрабатываем YaSONiC — сетевую ОС на базе SONiC.
В этой статье я расскажу, как разрабатывал кеширующую MITM-проксю, которая перехватывает весь HTTP/HTTPS-трафик и отдаёт ранее сохранённые ответы. Разберу также неочевидные технические проблемы, с которыми пришлось столкнуться.
Проблема: нет воспроизводимости сборки SONiC
Полная сборка образа SONiC (и YaSONiC) занимает несколько часов, в ходе которых выполняется несколько тысяч запросов в сеть: выкачиваются пакеты из Debian-репозиториев, исходники с GitHub, Python-пакеты с PyPI, образы из нескольких docker-registry и другие внешние артефакты, необходимые для сборки.
Всё это живёт своей жизнью и постоянно меняется.
Недостатков такого положения дел несколько:
две сборки, запущенные друг за другом, могут установить разные версии пакетов;
используемый нами пакет в любой момент может быть удалён из Debian-репозитория, что сделает сборку невозможной и заставит бросить разработку фичи, чтобы починить сборку. Особенно неприятно с таким сталкиваться во время сборки хотфикса :)
собрать старую версию крайне сложно, так как она зависит от пакетов неопределённых версий, которых вдобавок уже нет в Debian-репозиториях.
В апстримном SONiC для борьбы со схожей проблемой есть механизм SONIC_VERSION_CONTROL_COMPONENTS. Он включает «пиннинг» версий для разных классов ресурсов:
# SONIC_VERSION_CONTROL_COMPONENTS - Valid values: none|all|components..., the components consist of one or multiple: deb,py2,py3,web,git,docker, seperated by comma # none : disable the version control # all : enable the version control for all components # deb : debian packages # py2 : python2 packages # py3 : python3 pakcages # web : web packages, downloaded by wget, curl # git : git repositories, donloaded by git clone # docker: docker base images SONIC_VERSION_CONTROL_COMPONENTS ?= py2,py3,web,git,docker
Реализован он по-разному для каждого компонента. В образ устанавливается пакет sonic-build-hooks, который через манипуляции с PATH подменяет команды скачивания (apt-get, pip, wget, curl, git) своими обёртками. Версии хранятся в текстовых файлах в дереве files/build/versions/. Конкретно:
deb— два уровня. Во-первых,sources.listпереписывается на снапшот зеркала, зафиксированный по timestamp (.../snapshot/debian/<timestamp>/), а проверкаValid-Untilдля таких снапшотов отключается. Во-вторых, точная версия каждого пакета фиксируется черезaptpreferences с приоритетом 999.py2/py3— точные версии передаются вpip installкак constraints.web(wgetиcurl) — для каждого URL хранится MD5-сумма содержимого, и обёртка проверяет, что MD5-сумма скачанного файла совпадает с ожидаемой.git— после обычногоgit cloneрепозиторий переводится на зафиксированный коммит черезgit reset --hard <commit>.docker— в Dockerfile базовый образ переписывается сimage:tagнаimage:tag@sha256:....
Но всё это не спасает от двух основных проблем:
Пины не персистентны. При очистке локальной копии на следующей сборке можно получить другой результат. Два worktree — два набора пинов, а у коллеги — третий.
Пины не спасают от исчезновения ресурса. Если Debian-репозиторий перестал хранить нужную версию пакета, знание точной версии или хеша само по себе уже не поможет — файла больше нет. Снапшоты зеркал тоже живут не вечно.
Нам нужно было другое: сохранять каждый ресурс, который скачивается по сети во время сборки, чтобы переиспользовать его в последующих сборках как есть — и желательно надолго, чтобы можно было собрать даже довольно старый образ.
По сути, нужен не контроль версий, а полный архив всего трафика сборки.
Идея: закешировать весь трафик
Решение: поднять HTTP/HTTPS-проксю, которая либо отдаёт закешированный ответ (если такой запрос уже встречался), либо выполняет сетевой запрос, кеширует ответ и отдаёт его.
Как завернуть трафик в проксю
Самый простой механизм — переменные окружения http_proxy/https_proxy. Они уже прокидываются через всю инфраструктуру сборки, остаётся лишь указать в них адрес нашей прокси.
Приложения формально не обязаны учитывать эти переменные — это соглашение, а не часть стандарта, поэтому мы ожидали, что часть трафика утечёт мимо. На практике все клиенты в сборке учитывают http_proxy/https_proxy, и трафика в обход прокси мы не обнаружили.
Хранилище: запрос → ответ
Концептуально хранилище — это маппинг запрос → ответ. Чтобы по запросу найти ответ, мы считаем хеш запроса и используем его как ключ.
Необходимо, чтобы хеш зависел только от тех частей запроса, которые влияют на смысл запроса. URL, метод и тело влияют на смысл запроса. А заголовки вроде Authorization, traceparent или User-Agent — нет: они меняются от машины к машине и от запуска к запуску.
Упрощённая логика хеширования выглядит примерно так:
Будем собирать длинную строку: начнём с пустой.
Дописываем к ней в конец URL и метод.
Выбрасываем hop-by-hop заголовки (
connection,keep-aliveи т. д.),host,content-lengthи заведомо нерелевантные, остальное дописываем к строке.Для нерелевантных заголовков (
authorization,traceparent,User-Agent) фиксируем только факт наличия, но не значение.Дописываем тело запроса.
Считаем SHA-256 от полученной строки — это и будет результатом.
User-Agent исключён из хеша намеренно. В противном случае разные версии ядра и Docker-клиента на разных машинах давали бы разные значения UA, а значит, и разные хеши, что приводило бы к лишним кеш-промахам.
Версионирование кеша
Иногда хочется и обновить используемые версии пакетов, и сохранить возможность вернуться к старым.
Решение: версионируем кеш.
Пока используется одна и та же версия, в ответ на уже закешированные запросы будут возвращаться одни и те же данные. Для обновления пакетов нужно поменять версию кеша — кеш по сути станет пустым и начнёт наполняться свежими данными. Старые данные остаются доступными при использовании старой версии кеша.
Обратная совместимость хеш-функций
Менее очевидный момент. Хеш-функция — это часть формата хранилища. Если однажды поменять логику хеширования (например, начать иначе нормализовать заголовки), то все ранее сделанные записи станут недоступны: для тех же запросов будет вычисляться другой хеш, и старые данные просто не найдутся.
Поэтому нужно сохранять все когда-либо использовавшиеся хеш-функции:
При чтении перебираются все исторические хеш-функции (от самой новой к самой старой), пока в кеше не найдётся подходящая запись. Благодаря этому старые записи остаются доступными.
При записи используется только самая новая хеш-функция.
Это позволяет менять хеш-функцию, не инвалидируя и не мигрируя уже накопленный архив.
Реализация кеша
Кеш — это небольшой интерфейс с двумя методами:
class Cache(Protocol): def read(self, req: Request) -> Response: ... def write(self, req: Request, resp: Response) -> None: ...
Реализовать его можно по-разному.
Можно реализовать кеш с хранилищем на диске, в S3, в памяти или вообще сделать «перманентно пустой» кеш, который приведёт к тому, что всегда будут совершаться «реальные» сетевые запросы.
Можно сделать и комбинированный кеш, который при чтении будет смотреть сначала на диск, и только потом в S3 (что значительно медленнее диска), а при сохранении будет записывать данные и на диск, и в S3.
Дедупликация ответов
Дисковое хранилище и S3-хранилище устроены примерно следующим образом:
requests/<version>/<url>/<req_hash>.json # метаданные: запрос, статус ответа, заголовки ответа, хеш тела ответа blobs/<sha256(body)> # тела ответов
Файл с метаданными запроса содержит SHA-256 от тела ответа, по которому можно найти и само тело ответа.
Это даёт дедупликацию: один и тот же ответ будет сохранён лишь один раз. Так как соседние версии кеша содержат в основном идентичные файлы, то это значительно уменьшает необходимый размер хранилища.
Проблема 1: как читать HTTPS
Большая часть трафика — это HTTPS, а значит прокся видит лишь зашифрованный поток байт: ни URL, ни тела, ни статуса. Кешировать такое не получится.
Чтобы кешировать HTTPS-ответы, трафик надо расшифровать, то есть провести добровольную MITM-атаку на своей машине.
Механика: терминируем TLS у себя
Когда клиент хочет открыть HTTPS-соединение через проксю, он шлёт CONNECT example.com:443. Обычный прокси-сервер после CONNECT просто передавал бы TLS-туннель насквозь, ничего не видя. Мы поступаем иначе — терминируем TLS прямо на проксе:
Отвечаем клиенту
200 Connection Established.На лету генерируем сертификат для запрошенного хоста (example.com), подписанный нашим собственным корневым CA.
Выполняем с клиентом TLS-хендшейк, представляясь этим сертификатом.
Теперь мы видим расшифрованные HTTP-запросы и можем их кешировать, а в реальный мир ходим уже сами — отдельным TLS-соединением, как обычный клиент.
Сертификаты на хосты генерируются по запросу и кешируются. Корневой сертификат CA создаётся один раз и живёт долго.
Цена решения: нам должны доверять
Раз мы предъявляем клиентам сертификаты, подписанные нашим CA, клиенты обязаны доверять этому корневому CA — иначе получим классическое x509: certificate signed by unknown authority.
Значит, CA прокси нужно добавить в список доверенных сертификатов на всех машинах, участвующих в сборке — и на хосте, и во всех создаваемых Docker-контейнерах.
Проблема 2: у каждого инструмента свой trust store
На практике оказалось недостаточно положить CA прокси в /usr/local/share/ca-certificates/ и выполнить update-ca-certificates: часть инструментов использует не тот trust store, который мы только что обновили.
pip до версии 24.2 не использует системный trust store по умолчанию. Путь к бандлу можно указать через переменную PIP_CERT:
PIP_CERT=/usr/local/share/ca-certificates/ca-bundle.crt
curl тоже пришлось явно направить в нужный бандл через CURL_CA_BUNDLE:
CURL_CA_BUNDLE=/usr/local/share/ca-certificates/ca-bundle.crt
Проблема 3: старый Docker-клиент, который отправляет случайный заголовок Accept
Этот баг хорошо маскировался. Старый Docker-клиент при запросе манифестов образов вёл себя так, будто отправлял в заголовке Accept случайное значение из нескольких вариантов. Без прокси всё работало корректно, а если пустить трафик через проксю, то выкачивание образов ломалось.
Проверил функцию хеширования — она работала корректно: в неё приходят разные значения в заголовке Accept, поэтому и хеши отличаются, что приводит к промахам.
Попробовал отключить кеширование вовсе — всё равно выкачивание образа ломалось.
Это меня несколько озадачило: пришлось смотреть на то, какие на самом деле запросы приходили в проксю и что она с ними делала.
Дамп зашифрованного трафика
Трафик зашифрован, но, поскольку мы сами терминируем TLS, у нас есть ключи сессий. Достаточно выставить переменную SSLKEYLOGFILE (её соблюдают и наш Python-сервер, и многие клиенты) — в неё пишутся ключи шифрования сессий. Затем я снял дамп через Wireshark и загрузил лог ключей. Wireshark расшифровал сессии, и картина прояснилась.
Разгадка: несколько заголовков с одним именем
Оказалось, что Docker-клиент отправлял Accept не одним заголовком, а несколькими:
Accept: application/vnd.docker.distribution.manifest.v2+json Accept: application/vnd.docker.distribution.manifest.list.v2+json Accept: application/vnd.oci.image.manifest.v1+json ...
Заголовок Accept относится к списочным: RFC 9110 §5.2 разрешает передавать его значения несколькими строками, которые можно объединить в одну через запятую. А у прокси заголовки хранились как dict[str, str], поэтому каждое следующее значение заголовка Accept затирало предыдущее — в словаре оставалось только последнее.
Дополнительно этот Docker-клиент перемешивал порядок заголовков между запросами. Из-за этого «последним» оказывалось то одно значение Accept, то другое — с точки зрения прокси это выглядело как случайное значение заголовка.
Решение
Храним заголовки как
dict[str, list[str]], чтобы сохранять все значения одноимённых заголовков.При хешировании все значения заголовка сортируем, чтобы случайный порядок не порождал разные хеши.
Проблема 4: короткоживущие токены авторизации
Некоторые Docker-registry (например, publicmirror.azurecr.io) требуют токен для скачивания образов. Сценарий стандартный:
Пробуем скачать образ → получаем 401 Unauthorized.
Идём на эндпоинт выдачи токена и получаем токен.
Повторяем запрос образа уже с токеном.
Проблема в том, что такие токены живут несколько минут, а сборка целиком — несколько часов:
в начале сборки выкачивается образ A: прокся кеширует и токен, и образ;
через час выкачивается образ B, но прокся находит в кеше ответ эндпоинта выдачи токена и отдаёт истёкший токен;
Docker-registry на запрос с истёкшим токеном отвечает 403, и сборка падает.
Наивное кеширование «всего подряд» здесь работает против нас: токен — это как раз тот ответ, который кешировать нельзя.
Решение: passthrough-эндпоинты
Некоторые URL будем обрабатывать особым образом: сначала выполняется реальный сетевой запрос, и только если сеть недоступна, ответ отдаётся из кеша как fallback.
Так эндпоинты выдачи токенов при наличии сети всегда возвращают свежий токен, но сборку по-прежнему можно полностью воспроизвести офлайн из кеша.
Проблема 5: «всё работает» ровно неделю
Ровно через неделю ломается установка пакетов через apt.
Чтобы понять причину, нужно немного знать про устройство Debian-репозитория. У каждого репозитория есть Release-файл — это индекс верхнего уровня: в нём перечислены контрольные суммы и размеры всех остальных индексных файлов репозитория (списков пакетов Packages и т. д.). Доверие к репозиторию строится сверху вниз: если apt доверяет Release, то через контрольные суммы он доверяет и всему остальному. Поэтому Release подписывается GPG-ключом репозитория. Подпись бывает двух видов: отделённая (Release + отдельный файл Release.gpg) и встроенная — это и есть InRelease, где текст индекса и подпись лежат в одном файле (clearsigned).
Помимо контрольных сумм, в Release/InRelease есть поле Valid-Until — дата, после которой индекс считается устаревшим. Для большинства репозиториев оно выставлено примерно на неделю вперёд: так apt защищается от replay-атак, когда злоумышленник подсовывает старый (но валидно подписанный) индекс с известными уязвимыми версиями пакетов.
Вот и причина бага: мы закешировали InRelease и неделю отдавали его из кеша, а когда наступила дата, указанная в Valid-Until, apt стал считать индекс просроченным и отвергать его.
Решение: переписываем Valid-Until на лету
Мы можем не просто отдавать кешированные Release/InReleaseфайлы, а на лету заменять в них Valid-Until датой из будущего.
Но есть нюанс: InRelease — это GPG-документ с inline-подписью, любое изменение содержимого инвалидирует подпись.
Решение: собственная GPG-подпись
Заводим собственный GPG-ключ и переподписываем им изменённые InRelease.
Чтобы apt принял новую подпись, GPG-ключ прокси добавляется в его keyring во всех собираемых образах — наравне с TLS-сертификатом.
В итоге связка получается полной: наш CA даёт право переписывать HTTPS-ответы, а наш GPG-ключ — право переподписывать изменённые индексы apt.
Как доставить сертификаты и ключи внутрь образов
Вернёмся почти в самое начало. У нас есть CA и GPG-ключ, и нужно, чтобы им доверяли все контейнеры, которые SONiC собирает по ходу дела. Их много, и генерация Dockerfile для них частично автоматизирована.
Свою логику я добавил именно туда — в скрипт генерации Dockerfile. Он теперь дописывает в начало генерируемого Dockerfile инструкции, которые подтягивают сертификаты и GPG-ключи и регистрируют их в нужных trust stores.
Дальше встал вопрос: как именно положить файлы в образ?
Почему не COPY
Очевидный вариант — COPY — не подходит по двум причинам:
Единого источника для
COPYне было: часть образов собиралась на хосте, а часть — внутри других контейнеров, поэтому каждая сборка видела свой build-контекст.Сборка использует docker-in-docker, и образы собираются не только из хостового контекста, но и из других образов. То есть «откуда копировать» — нетривиальный вопрос, и
COPYлегко берёт файл не из того места.
Почему не curl/wget
Вторая идея — скачать сертификаты по сети, благо прокся доступна. Но скачать нечем: curl и wget в базовом образе может не быть, а чтобы их установить, apt должен обратиться в сеть через нашу же проксю, которой он ещё не доверяет — ведь мы как раз и пытаемся доставить сертификат. Получается замкнутый круг.
Решение: ADD умеет скачивать по URL
Выручила особенность docker: инструкция ADD умеет скачивать файлы прямо по URL, не требуя в образе ни curl, ни wget. А прокся как раз раздаёт свои CA- и GPG-файлы по HTTP как обычный сервер статических файлов.
Получился такой инжектируемый блок (упрощённо):
# GPG-ключ для apt ADD --chmod=644 ${http_proxy}gpg/caching-proxy.gpg /etc/apt/trusted.gpg.d/caching-proxy.gpg ADD --chmod=644 ${http_proxy}gpg/caching-proxy.asc /etc/apt/trusted.gpg.d/caching-proxy.asc RUN cat /etc/apt/trusted.gpg.d/caching-proxy.gpg >> /usr/share/keyrings/debian-archive-keyring.gpg # TLS-сертификаты ADD --chmod=644 ${http_proxy}ca/ca-bundle.crt /usr/local/share/ca-certificates/ca-bundle.crt ADD --chmod=644 ${http_proxy}ca/ca.crt /usr/local/share/ca-certificates/ca.crt RUN apt-get update && apt-get install -y ca-certificates RUN update-ca-certificates # pip — см. проблему №2 ENV PIP_CERT=/usr/local/share/ca-certificates/ca-bundle.crt
${http_proxy} здесь — это URL самой прокси, который и так прокинут в сборку. Получается замкнутая, но удобная схема: прокся раздаёт ключи, которыми ей же потом и доверяют.
Что получилось
Между сборкой и интернетом теперь стоит прокся, которая:
перехватывает весь HTTP/HTTPS-трафик через
http_proxy/https_proxy;расшифровывает HTTPS, терминируя TLS своим CA;
кеширует ответы по хешу значимой части запроса;
дедуплицирует тела ответов и поддерживает разные хранилища;
обходит ряд подводных камней: короткоживущие токены, истекающие
Valid-Untilв apt-индексах.
В итоге получили:
Воспроизводимость. Сборка получает одни и те же уже закешированные ответы — независимо от изменений во внешних репозиториях и удаления отдельных пакетов.
Надёжность. Сборка не падает из-за того, что кто-то удалил версию пакета.
Скорость. Если кеш быстрее сети (а локальный диск, скорее всего, быстрее), некоторые этапы сборки значительно ускоряются.
Узнать больше о том, как мы делаем инфраструктуру Яндекса, можно в нашем канале.
S-Dzucev
Добрый день!
Скажите, почему не использовали Nexus?
Задача была переизобрести собственный продукт?
Если так, то непонятен посыл "Интернет плохой и каждый раз отдаёт разное", достаточно было обозначить что разрабатываете Local Registry(что с моей точки зрения очень полезно в нашем мире).