Интернет — это зависимость без зафиксированной версии. Сегодня он возвращает одно, завтра — другое, а послезавтра — ничего. Поэтому мы решили сохранить для сборки тот интернет, который она однажды увидела. Для этого пришлось перехватывать 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/. Конкретно: 

  1. deb — два уровня. Во-первых, sources.list переписывается на снапшот зеркала, зафиксированный по timestamp (.../snapshot/debian/<timestamp>/), а проверка Valid-Until для таких снапшотов отключается. Во-вторых, точная версия каждого пакета фиксируется через apt preferences с приоритетом 999.

  2. py2/py3 — точные версии передаются в pip install как constraints. 

  3. web (wget и curl) — для каждого URL хранится MD5-сумма содержимого, и обёртка проверяет, что MD5-сумма скачанного файла совпадает с ожидаемой. 

  4. git — после обычного git clone репозиторий переводится на зафиксированный коммит через git reset --hard <commit>

  5. docker — в Dockerfile базовый образ переписывается с image:tag на image:tag@sha256:...

Но всё это не спасает от двух основных проблем: 

  1. Пины не персистентны. При очистке локальной копии на следующей сборке можно получить другой результат. Два worktree — два набора пинов, а у коллеги — третий. 

  2. Пины не спасают от исчезновения ресурса. Если Debian-репозиторий перестал хранить нужную версию пакета, знание точной версии или хеша само по себе уже не поможет — файла больше нет. Снапшоты зеркал тоже живут не вечно. 

Нам нужно было другое: сохранять каждый ресурс, который скачивается по сети во время сборки, чтобы переиспользовать его в последующих сборках как есть — и желательно надолго, чтобы можно было собрать даже довольно старый образ. 

По сути, нужен не контроль версий, а полный архив всего трафика сборки. 

Идея: закешировать весь трафик 

Решение: поднять HTTP/HTTPS-проксю, которая либо отдаёт закешированный ответ (если такой запрос уже встречался), либо выполняет сетевой запрос, кеширует ответ и отдаёт его. 

Как завернуть трафик в проксю 

Самый простой механизм — переменные окружения http_proxy/https_proxy. Они уже прокидываются через всю инфраструктуру сборки, остаётся лишь указать в них адрес нашей прокси.

Приложения формально не обязаны учитывать эти переменные — это соглашение, а не часть стандарта, поэтому мы ожидали, что часть трафика утечёт мимо. На практике все клиенты в сборке учитывают http_proxy/https_proxy, и трафика в обход прокси мы не обнаружили.

Хранилище: запрос → ответ 

Концептуально хранилище — это маппинг запрос → ответ. Чтобы по запросу найти ответ, мы считаем хеш запроса и используем его как ключ. 

Необходимо, чтобы хеш зависел только от тех частей запроса, которые влияют на смысл запроса. URL, метод и тело влияют на смысл запроса. А заголовки вроде Authorization, traceparent или User-Agent — нет: они меняются от машины к машине и от запуска к запуску.

Упрощённая логика хеширования выглядит примерно так: 

  1. Будем собирать длинную строку: начнём с пустой. 

  2. Дописываем к ней в конец URL и метод. 

  3. Выбрасываем hop-by-hop заголовки (connection, keep-alive и т. д.), host, content-length и заведомо нерелевантные, остальное дописываем к строке. 

  4. Для нерелевантных заголовков (authorization, traceparent, User-Agent) фиксируем только факт наличия, но не значение.

  5. Дописываем тело запроса. 

  6. Считаем 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 прямо на проксе

  1. Отвечаем клиенту 200 Connection Established

  2. На лету генерируем сертификат для запрошенного хоста (example.com), подписанный нашим собственным корневым CA

  3. Выполняем с клиентом TLS-хендшейк, представляясь этим сертификатом.

  4. Теперь мы видим расшифрованные 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, то другое — с точки зрения прокси это выглядело как случайное значение заголовка.

Решение

  1. Храним заголовки как dict[str, list[str]], чтобы сохранять все значения одноимённых заголовков. 

  2. При хешировании все значения заголовка сортируем, чтобы случайный порядок не порождал разные хеши. 

Проблема 4: короткоживущие токены авторизации

 Некоторые Docker-registry (например, publicmirror.azurecr.io) требуют токен для скачивания образов. Сценарий стандартный: 

  1. Пробуем скачать образ → получаем 401 Unauthorized.

  2. Идём на эндпоинт выдачи токена и получаем токен. 

  3. Повторяем запрос образа уже с токеном. 

Проблема в том, что такие токены живут несколько минут, а сборка целиком — несколько часов: 

  • в начале сборки выкачивается образ 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 — не подходит по двум причинам:

  1. Единого источника для COPY не было: часть образов собиралась на хосте, а часть — внутри других контейнеров, поэтому каждая сборка видела свой build-контекст.

  2. Сборка использует 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-индексах. 

В итоге получили: 

  1. Воспроизводимость. Сборка получает одни и те же уже закешированные ответы — независимо от изменений во внешних репозиториях и удаления отдельных пакетов. 

  2. Надёжность. Сборка не падает из-за того, что кто-то удалил версию пакета. 

  3. Скорость. Если кеш быстрее сети (а локальный диск, скорее всего, быстрее), некоторые этапы сборки значительно ускоряются. 

Узнать больше о том, как мы делаем инфраструктуру Яндекса, можно в нашем канале.

Комментарии (1)


  1. S-Dzucev
    20.07.2026 08:32

    Добрый день!
    Скажите, почему не использовали Nexus?
    Задача была переизобрести собственный продукт?

    Если так, то непонятен посыл "Интернет плохой и каждый раз отдаёт разное", достаточно было обозначить что разрабатываете Local Registry(что с моей точки зрения очень полезно в нашем мире).