Зачем это вообще нужно

Вчера pypi.org несколько часов был недоступен из российских сетей. Для кого-то это «подождём», а для CI/CD, прода и просто рабочего дня — это вставший pip install и красные сборки.

Причина системная: pypi.org и хранилище пакетов files.pythonhosted.org живут на CDN Fastly, у которого нет точек присутствия в России и доступ к которому уже не раз ограничивался. Вчерашняя недоступность — не первая и почти наверняка не последняя.

Хорошая новость: чтобы застраховаться, не нужно зеркалировать весь PyPI (это терабайты и постоянная синхронизация). Достаточно поднять лёгкий реверс-прокси на nginx. В этом гайде соберём такой с нуля — с кешированием и прозрачным переключением для pip.

Не хотите хостить сами? Есть уже готовое зеркало — pypi.depkit.ru. Оно работает на российских IP, имеет большой объём кеша под пакеты и отдаёт их очень быстро. Можно просто подставить его в index-url (как — в конце статьи) и пропустить всю настройку. Дальше — для тех, кому интересно поднять своё.

Как это устроено

pip работает с PyPI в два шага:

  1. Берёт индекс /simple/<пакет>/ — небольшую HTML-страницу со списком всех файлов пакета и ссылками на них.

  2. По ссылкам из индекса скачивает сами файлы (wheels и sdists) — они лежат на files.pythonhosted.org.

Идея зеркала: проксируем индекс с pypi.org, но на лету переписываем в HTML ссылки на файлы так, чтобы они вели на наш домен. Тогда и индекс, и файлы pip тянет через нас. Хранить ничего заранее не нужно — файлы проксируются (и кешируются) по запросу.

Переписывание делает директива nginx sub_filter — построчная замена в теле ответа.

Шаг 1. Базовый конфиг

Минимальный рабочий вариант — один server-блок с двумя location:

  server {
      listen 443 ssl;
      server_name pypi.example.com;     # ваш домен

      # ssl_certificate     /etc/letsencrypt/live/pypi.example.com/fullchain.pem;
      # ssl_certificate_key /etc/letsencrypt/live/pypi.example.com/privkey.pem;

      # --- индекс /simple/: проксируем pypi.org и переписываем ссылки на файлы ---
      location /simple/ {
          proxy_pass https://pypi.org;
          proxy_set_header Host pypi.org;
          proxy_ssl_server_name on;

          # отключаем сжатие от апстрима — иначе sub_filter нечего будет менять
          proxy_set_header Accept-Encoding "";

          sub_filter_types text/html;
          sub_filter "https://files.pythonhosted.org/" "https://pypi.example.com/files/";
          sub_filter_once off;          # заменяем ВСЕ вхождения, а не только первое
      }

      # --- сами wheels/sdists: чистый проксипасс на хранилище ---
      location /files/ {
          rewrite ^/files/(.*)$ /$1 break;
          proxy_pass https://files.pythonhosted.org;
          proxy_set_header Host files.pythonhosted.org;
          proxy_ssl_server_name on;
      }
  }

Логика:

Подставьте свой server_name в обоих местах (в server_name и в строке sub_filter) и не забудьте сертификат — pip ходит только по HTTPS.

Шаг 2. Грабли, которые стоят пары часов отладки

  • Accept-Encoding “” обязателен. sub_filter работает только с несжатым текстом. Без обнуления Accept-Encoding апстрим вернёт gzip, и замена молча не сработает. Это причина №1 у тех, «у кого не переписывается».

  • sub_filter_once off. По умолчанию меняется только первое совпадение. На странице пакета ссылок десятки — нужно off.

  • proxy_ssl_server_name on. Оба апстрима за TLS с SNI; без этого прилетит неправильный сертификат.

  • rewrite ^/files/(.*)$ /$1 break;. Префикс /files — наш, на хранилище его быть не должно, поэтому срезаем перед проксированием.

Шаг 3. Добавляем кеширование

Главный смысл своего зеркала — чтобы популярные пакеты не дёргались с Fastly каждый раз, а отдавались с диска быстро и независимо. Включаем proxy_cache для /files/.

В http {}:

  proxy_cache_path /var/cache/nginx/pypi
      levels=1:2
      keys_zone=pypi_files:100m
      max_size=200g            # сколько места отдать под кеш пакетов
      inactive=90d
      use_temp_path=off;

И в location /files/:

  location /files/ {
      rewrite ^/files/(.*)$ /$1 break;
      proxy_pass https://files.pythonhosted.org;
      proxy_set_header Host files.pythonhosted.org;
      proxy_ssl_server_name on;

      proxy_cache pypi_files;
      proxy_cache_valid 200 90d;       # артефакты PyPI неизменяемы — можно кешировать надолго
      proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
      proxy_cache_lock on;
      add_header X-Cache-Status $upstream_cache_status;   # HIT/MISS для проверки
  }

Файлы пакетов на PyPI неизменяемы (новая версия = новый файл), поэтому их можно держать в кеше сколько угодно. После первого скачивания пакет отдаётся уже с вашего диска — быстро и без обращения к Fastly. Индекс /simple/ лучше кешировать коротко (минуты) или не кешировать вовсе, чтобы новые релизы появлялись без задержки.

Как этим пользоваться

Разово:

pip install --index-url https://pypi.example.com/simple/ 

Постоянно — в ~/.config/pip/pip.conf (или pip.ini на Windows):

[global] index-url = https://pypi.example.com/simple/

Или через переменную окружения:

export PIP_INDEX_URL=https://pypi.example.com/simple/

uv:

UV_INDEX_URL=https://pypi.example.com/simple/ uv pip install 

Poetry (pyproject.toml):

[[tool.poetry.source]] name = “mirror” url = “https://pypi.example.com/simple/” priority = “primary”

Проверка

  # в индексе не осталось ссылок на pythonhosted — все переписаны
  curl -s https://pypi.example.com/simple/flask/ | grep -c files.pythonhosted.org   # 0

  # реальная загрузка пакета через зеркало
  pip download --no-deps --index-url https://pypi.example.com/simple/ click==8.1.7

  # кеш работает? второй запрос файла должен дать X-Cache-Status: HIT
  curl -sI https://pypi.example.com/files/<путь-к-файлу> | grep X-Cache-Status

Если pip резолвит пакет и тянет wheel через ваш /files/ — всё собрано правильно.

Не хотите поднимать своё — берите готовое

Если разворачивать и обслуживать собственный сервер не хочется, есть уже работающее зеркало — pypi.depkit.ru. Оно:

  • работает на российских IP, внутри страны — не зависит от доступности Fastly;

  • имеет большой объём кеша под пакеты, так что популярные wheels отдаются мгновенно;

  • быстрое и прозрачное — достаточно подставить его в index-url.

Подключение — те же команды, что выше, только домен другой:

  pip install --index-url https://pypi.depkit.ru/simple/ <package>

  # ~/.config/pip/pip.conf
  [global]
  index-url = https://pypi.depkit.ru/simple/

Итог

Своё зеркало PyPI — это:

  • один server-блок nginx из двух location плюс proxy_cache;

  • никаких терабайт: индекс проксируется, файлы кешируются по запросу;

  • независимость от иностранного CDN для базового инструмента разработчика.

Поднять можно за вечер, а если некогда — pypi.depkit.ru уже работает на российских IP, с большим кешем и быстро. Вчерашняя блокировка лишний раз показала: такой риск дешевле закрыть заранее, чем чинить сборки в момент аварии.