Интернет — это зависимость без зафиксированной версии. Сегодня он возвращает одно, завтра — другое, а послезавтра — ничего. Поэтому мы решили сохранить для сборки тот интернет, который она однажды увидела. Для этого пришлось перехватывать HTTPS, выпускать собственные сертификаты, переподписывать индексы Debian и выяснять, почему Docker отправляет «случайные» заголовки.В этой статье я расскажу, как разрабатывал кеширующую MITM-проксю, которая перехватывает весь HTTP/HTTPS-трафик и отдаёт ранее сохранённые ответы. Разберём также неочевидные технические проблемы, с которыми пришлось столкнуться. Читать далее

Интернет — это зависимость без зафиксированной версии. Сегодня он возвращает одно, завтра — другое, а послезавтра — ничего. Поэтому мы решили сохранить для сборки тот интернет, который она однажды увидела. Для этого пришлось перехватывать 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 для таких снапшотов отключается. Во-вторых, точная версия каждого пакета фиксируется через apt preferences с приоритетом 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.crtcurl тоже пришлось явно направить в нужный бандл через 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]], чтобы сохранять все значения одноимённых заголовков.
При хешировании все значения заголовка сортируем, чтобы случайный порядок не порождал разные хеши.
Некоторые 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 стал считать индекс просроченным и отвергать его.
Мы можем не просто отдавать кешированные Release/InReleaseфайлы, а на лету заменять в них Valid-Until датой из будущего.
Но есть нюанс: InRelease — это GPG-документ с inline-подписью, любое изменение содержимого инвалидирует подпись.
Заводим собственный 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 в базовом образе может не быть, а чтобы их установить, apt должен обратиться в сеть через нашу же проксю, которой он ещё не доверяет — ведь мы как раз и пытаемся доставить сертификат. Получается замкнутый круг.
Выручила особенность 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 | Генеративный Postgres-дайджест: от информационного шума — к сигналу | 5 | 7 | 26-06-2026 |
| 2 | Многоэтапные сборки в Docker: как уменьшить образ с 1,2 ГБ до 50 МБ | 5 | 8 | 28-06-2026 |
| 3 | Новое облако для умных устройств Яндекса: IoT без хаба, без лишних соединений и без надежды на хороший Wi‑Fi | 0 | 8.46 | 28-07-2026 |
| 4 | Архивация текста через инференс llama.cpp | 0 | 7.54 | 07-07-2026 |
| 5 | Как я сделал сканер под iOS и Android для диагностики Wi-Fi-сети | 0 | 7.86 | 05-06-2026 |
| 6 | redb.Identity: OAuth 2.1 / OpenID сервер на .NET, где протокол отделён от транспорта, по шине или вообще без сети | 0 | 12.74 | 14-07-2026 |
| 7 | Айсберг Доменов Верхнего Уровня (ICANN и не только) | 5 | 6 | 29-06-2026 |
| 8 | Продакшн‑разработка в одиночку с AI‑агентами: принуждай к правилам, а не объясняй их | 0 | 8.25 | 24-07-2026 |
| 9 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 10 | Почему интервью для разрабов — такое непроходимое говно, и что с этим делать? | -5 | 6 | 03-07-2026 |