Каждый, кто занимался сбором данных во «внешнем мире» знает, что этот мир жесток. И парсер сторонних сайтов всегда может наткнуться на какие-то блокировки, задержки и множество других проблем, которые либо замедляют работу, либо вообще делают парсинг невозможным. Лимиты по IP и капча — нам не друзья. Поэтому было решено сделать инструмент, позволяющий с ними бороться.

Как проблему решают сейчас

  1. Банальное добавление задержек между запросами. Простая реализация, но растягивает время сбора данных вплоть до бесконечности, а чтобы этого избежать — требует долгой и нудной калибровки. Отметаем.

  2. Прикинуться браузером, например, с помощью Selenium. Обычно помогает, когда страница рендерится с помощью js на ходу, но сильно утяжеляет парсер. Да и парсеры на Selenium не всегда работают стабильно. Тоже мимо.

  3. Использование прокси-серверов. Пока что наиболее эффективный метод борьбы с блокировками. Но для нормальной работы прокси-серверы нужно постоянно менять. Годится, но чтобы не дублировать логику ротации в каждом парсере, будем унифицировать прокрутку прокси-серверов с помощью подключаемых python-модулей.

Как прокси хранятся

Для начала я решил сделать отдельный модуль, в котором будем хранить реквизиты прокси и другие сущности системы. С ними будут общаться другие наши подсистемы. Получилось django-приложение — proxy manager, которое хранит в себе всё необходимое для работы.

Сущности системы

Что содержит в себе proxy manager:

  • Сами прокси-серверы: их ip-адреса, логины и пароли для подключения;

  • Список сайтов, на которые мы разрешаем заходить через нашу систему;

  • То, что необходимо для работы конечного прокси-сервера: тайм-ауты и история запросов. Об этом расскажу дальше.

Также в этом приложении реализован веб-интерфейс, в котором можно просмотреть все сущности, отредактировать списки прокси-серверов и ACL, а ещё проверить прокси на их сетевую доступность.

Как не получились python-модули

Причина появления этой статьи :)

Немного о проблемах, которые предстояло решить. Я решил сделать кастомные подключаемые модули для всех инструментов, которые мы используем в работе: requests, aiohttp, selenium, scrapy. Это все python-модули, поэтому написать модули поверх них должно было быть несложно. Все работало хорошо с request и aiohttp, пока не дошло дело до selenium. Его оказалось невозможно заставить работать так, как нам нужно. Пришлось бы реализовать свой кастомный прокси-сервер.

Но такой модуль получился бы намного сложнее в разработке и сопровождении, чем остальные «обёртки» и перекрывал бы все потребности. Поэтому было решено разработать единый кастомный прокси-сервер — универсальный инструмент, который можно будет использовать со всеми python-модулями и другими инструментами, которые общаются с сетью. Для клиента это выглядит как обычный прокси-сервер, а вся логика ротации заложена внутри него.

Как я встретил человека посередине

Встал вопрос: как реализовать кастомный прокси-сервер и логику ротации внутри него. Сначала я попытался реализовать прокси-сервер с помощью python-модулей socket и twisted, но быстро понял, что у меня лапки и пока не хватает скиллов, хотя в теории должна была получиться относительно низкоуровневая реализация, которая должна быть эффективнее, чем другие варианты.

Дальше я попробовал использовать squid. Это инструмент, похожий на nginx, в котором можно настроить пересылку трафика через внешний прокси. В целом, то, что нужно, но не хватает возможностей для настройки логики проксирования. Третьим вариантом стал ещё один python-модуль — mitmproxy. Это тоже готовый python-инструмент, позволяющий реализовать прокси-сервер, в котором логику можно кастомизировать обычным python-кодом. Плюс это развивающийся проект — к нему выходят патчи и новые версии, и у него довольно активное комьюнити.

В то же время, в некоторых местах это ещё сырой проект — не хватает некоторых важных возможностей, и, что самое критичное — с помощью встроенных механизмов нельзя переотправлять запрос после получения ответа или после возникновения ошибки. Тем не менее, инструмент показался довольно интересным. Я решил использовать его и обойти ограничения нативных функций.

Лирическое отступление о том, что вообще такое MITM

По сути, хакерский вид атаки, когда агент встраивается между клиентом и сервером. Он может просматривать, изменять и перенаправлять их трафик. Mitmproxy использует эту парадигму. В нём также можно просматривать http и https-запросы. Но мы же не хакеры, поэтому не будем никуда вламываться. Для просмотра и редактирования трафика есть веб-инструмент, но, что для нас наиболее важно, mitmproxy имеет несколько вариантов работы, каждый из которых, по сути, это прокси-сервер: regular, transparent, reverse, upstream. Нас в этом списке интересует upstream.

Принцип работы стандартного режима upstream в mitmproxy

Как он работает. Клиент отправляет запрос через mitmproxy→mitmproxy отправляет запрос через ещё один внешний прокси-сервер → дальше он попадает на конечный сайт. Это то, что нам нужно. Осталось только немного изменить логику под себя.

Кастомный ротирующий прокси-сервер

Теперь логика upstream выглядит так:

Upstream с ротацией второго слоя прокси-серверов

Клиент отправляет запрос через mitmproxy → mitmproxy отправляет запрос через один из множества прокси-серверов, выбранный по определённой логике → дальше он попадает на конечный сайт.

Подробнее о том, как работает наш готовый прокси-сервер. Сперва он действовал на основе обычного рандома, но это оказалось абсолютно неэффективно. Если запрос возвращался с ошибкой, то не было гарантий, что следующий запрос не отправится через тот же прокси с ошибкой. Плюс в этой версии не было никакого логирования и проверки наличия разрешения на отправку запросу к конкретному сайту. Так что убираем глупый рандом и добавляем логирование всех запросов (кто отправил, куда, что и когда) и тайм-аутов, которые не позволяют использовать прокси второй раз после ошибки.

Как я говорил, нативные инструменты mitmproxy не позволяют настроить повторную отправку запросов. Из-за этого нельзя реализовать ротацию прокси-серверов с переотправкой запросов с новым внешним прокси. Чтобы решить эту проблему, используем внутри mitmproxy другой python-модуль для отправки запросов. Я выбрал HTTPX, так как он позволяет работать с ним и в асинхронном, и в синхронном режиме.

Как все работает в итоге

Первым делом клиент отправляет запрос. Далее запрос проходит аутентификацию. Если что-то пошло не так, то клиент получит ошибку 407. Если всё ок, то происходит проверка наличия разрешения отправки запросов через наш прокси-сервер к серверу, который указал клиент. Если разрешения в списке нет, то клиент получает ошибку 423. Если все проверки завершились успешно, то запрос переходит к проксированию.

Случайно выбирается внешний прокси, который не имеет ограничений для целевого сервера, и запрос отправляется через него. Если пришёл код, не входящий в список ошибочных, например 200, то клиент получает ответ с этим кодом. Если приходит ошибка, то используемый внешний прокси получает тайм-аут для проксирования этого целевого сервера, а на его место выбирается новый и процесс повторяется. Цикл будет повторяться N раз, пока либо не получит валидный ответ, либо не исчерпает количество попыток — в этом случае клиент получит ошибку 566.

Клиент может влиять на работу прокси-сервера, изменяя некоторые параметры, которые может передавать через заголовки запроса. Заголовок должен быть с именем в формате proxyserver.<название_параметра> и соответствующим требующимся значением. После прочтения заголовков-параметров, они удаляются из запроса, чтобы не влиять на результат запроса.

Параметры:

  • proxy_mode: Режим работы proxyserver

    • random: Упрощённый. Проверяется только аутентификация и разрешение на отправку запроса. Проксирование осуществляется внутренним механизмом mitmproxy, плюс к каждому запросу применяется случайный прокси сервер без учёта тайм-аутов. Это позволяет существенно ускорить работу прокси-сервера. Запросы логируются. Такой режим подходит для парсинга, когда не принципиально, чтобы запросы всегда завершались успешно, или если на целевом сайте не слишком активная защита и просто хочется скрыть источник запроса.

    • rotate: Основной (по-умолчанию). Запрос и код ответа логируются в БД, плюс по факту запросов и ответов отправляются метрики. Проксирование осуществляется следующим алгоритмом: выбирается случайный доступный для целевого хоста прокси-сервер. Запрос повторяется request_attempts раз, пока не будет получен ответ, код которого не входит в список error_statuses.

      Если получен валидный ответ, то он возвращается клиенту.

      Если ответ невалидный, то цикл повторяется, а текущему используемому прокси серверу выставляется тайм-аут длиной proxy_timeout секунд, в течение которого он не может быть выбран для проксирования запрашиваемого хоста. Если количество попыток request_attempts исчерпано и валидный ответ не получен, то клиенту вернётся ответ с кодом 566 — Proxying failed.

  • proxy_n: Порядковый номер прокси (по умолчанию не задан). Параметр используется в случае, когда необходимо распределить запросы по конкретным прокси-серверам (некоторое подобие sticky proxy, но очень упрощённое). При выборе прокси-сервера для запроса, будет выбран сервер с порядковым номером, равным значению данного параметра. Если передано значение, превышающее размер пула прокси-серверов, то порядковый номер будет запущен по новому кругу. То есть, если в пуле 10 серверов, и передано значение 13, то будет выбран 3 сервер.

  • error_statuses: Список HTTP-кодов ответов, которые считаются ошибочными. Передаются в формате строки, разделённые ";". По умолчанию: 401, 403, 429.

  • proxy_timeout: Тайм-аут (в секундах), накладываемый на прокси-сервер после получения ошибочного ответа от хоста. По умолчанию: 60.

  • request_attempts: Количество попыток отправки запроса. По умолчанию: 3.

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

Второе — это запись тех самых тайм-аутов, которые не позволяют внешнему прокси снова примениться к серверу, по которому имеется тайм-аут. Так как эту запись нужно сделать максимально быстро, приходится писать в постгрес напрямую, из-за чего возникает точка синхронизации. Сейчас это одно из самых болезненных узких мест и, увы, не единственное.

Пайплайн проксирования запросов

Какие проблемы осталось решить

  1. Замедление скорости из-за того, что запрос пересылается через два уровня прокси и потому, что пересылка происходит не через внутренние средства mitmproxy, а через сторонний модуль.

  2. Кастомизация работы прокси-сервера со стороны клиента. Клиент может добавить в свои запросы определённые настройки, которые изменят логику работы прокси-сервера. Но только в рамках этого запроса. Приходится добавлять каждый раз, а не в целом для всего парсера.

Как это будет решаться

Для устранения узких мест есть несколько идей:

  1. Чтобы сделать конфигурирование работы прокси-сервера для конкретного парсера более удобным и простым, предполагается доработать proxy manager — наше админское django-приложение. К нему можно будет добавить пользовательскую часть, в которой пользователь сможет настроить поведение прокси-сервера для своего парсера: для всего в целом или для определённых роутов конкретно. Параметры будут теми, что сейчас передаются через заголовки, плюс могут добавятся новые. Применяться конфигурация будет, например, на основе логина, который клиент передаёт при обращении через прокси-сервер. 

    Помимо конфигурации пользователи смогут добавлять свои собственные пулы внешних прокси-серверов и работать с ними, чтобы не бороться с нагрузкой на общие прокси.

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

  3. Проблема с необходимостью максимально быстро сохранять записи о тайм-аутах прокси может быть решена сменой хранилища этих записей. Например, использовать что-то быстрее чем Postgres. Как вариант, Redis или KeyDB.

Заключение

В итоге полученное решение позволяет сильно упростить парсинг «внешнего мира», просто направив запросы через созданный прокси-сервер. Пропускная способность сильно зависит от инфраструктуры, на которой будет разворачиваться система, но на наших рельсах прокси стабильно работает с несколькими парсерами одновременно и пережёвывает сотни тысяч запросов.

Да, не всё работает идеально. Парсинг, конечно, замедлился, но этого невозможно избежать при использовании даже одного слоя прокси, а у нас их два плюс логика ротации. Есть идеи как ускорить — буду пробовать.

Надеюсь, материал был интересным и для кого-то даже полезным. Буду рад предложениям и критике в комментариях.