Ротация IP: смена адреса по таймеру, на каждый запрос и удержание сессии
- Как ротация выглядит со стороны клиента
- Режим первый: новый адрес на каждый запрос
- Режим второй: смена IP по таймеру и выбор интервала
- Режим третий: одно соединение на всю сессию
- Режим четвёртый: смена выхода по сигналу от софта
- Как посчитать частоту под лимиты площадки
- Что видно в логах при каждом режиме
- Ротация и авторизация: привязка своего адреса и доступ по логину
- Куки, заголовки и состояние между сменами
- Как я подбираю режим под задачу
Ротация адресов это автоматическая смена выходного IP при работе через прокси: клиент обращается по одной и той же строке подключения, а трафик выходит в интернет через разные адреса пула. Сайт на той стороне видит каждый раз другой источник. Перебор идёт внутри сервиса, код клиента о нём ничего не знает и списков у себя не держит.
Темп смены задаёт клиент. Он определяется тем, как приложение открывает и закрывает соединения, поэтому вся настройка живёт в вашем коде, в трёх-четырёх полях. Отсюда четыре режима работы: новый адрес на каждый запрос, смена по интервалу, одно соединение на всю сессию, смена по сигналу от софта. Ниже я разбираю каждый режим отдельно: под какие задачи он подходит, что правится в клиенте, что появляется в логах и как посчитать частоту под лимит площадки.
Как ротация выглядит со стороны клиента
В кабинете я забираю доступы в двух формах: IP:PORT и IP:PORT:LOGIN:PASS. Список отдаётся ссылкой или файлом и обновляется в реальном времени, в пуле держится около 12 000 активных адресов. Строка подключения при этом одна на весь прогон, перебирать её руками не нужно.
Смена выхода привязана к соединению. Новое TCP-соединение уходит через другой адрес пула, переиспользование уже открытого сокета оставляет прежний выход. Отсюда правило настройки, из которого вырастает вся статья: чем чаще клиент закрывает соединения, тем чаще меняется адрес. Дальше речь идёт про способы управлять моментом закрытия.
Второй параметр это потоки. На обычных пакетах доступна 1000 одновременных потоков, на корпоративном до 3000. Пакеты по потокам не складываются, и при двух привязанных адресах лимит делится пополам: два сервера получают по 500 потоков каждый. Я держу это в голове при раскладке воркеров, потому что упереться в потолок потоков легче, чем выглядит на бумаге.
Проверяю режим я одной командой, ещё до запуска прогона:
for i in 1 2 3; do
curl -s -x http://login:password@gate.example:8080 https://api.ipify.org
echo
done
Три вызова, три отдельных соединения, три разных адреса в выводе. Если во всех трёх строках стоит один адрес, клиент переиспользует сокет, и разбираться надо с keep-alive. Пул, где смена выхода живёт на стороне сервиса, снимает с кода задачу перебора: прокси с автоматической ротацией отдают следующий адрес сами, а в конфиге остаётся одна пара хоста и порта.
Режим первый: новый адрес на каждый запрос
Этот режим подходит там, где запросы независимы и состояние между ними не переносится. Обход каталога по списку URL. Съём выдачи по ключам. Проверка индексации после выкладки. Пинг страниц, сбор карточек товара, разовое чтение статей по sitemap. Общий признак простой: ответ на второй запрос никак не зависит от того, что вернул первый.
Настройка сводится к отказу от keep-alive. В requests я обрубаю переиспользование сокетов на уровне адаптера и заголовка:
import requests
from requests.adapters import HTTPAdapter
s = requests.Session()
s.proxies.update(PROXIES)
s.headers["Connection"] = "close"
s.mount("http://", HTTPAdapter(pool_connections=1, pool_maxsize=1))
s.mount("https://", HTTPAdapter(pool_connections=1, pool_maxsize=1))
В httpx то же самое делает один параметр лимитов:
limits = httpx.Limits(max_connections=20, max_keepalive_connections=0)
client = httpx.Client(proxy=PX, limits=limits,
timeout=httpx.Timeout(connect=4.0, read=20.0))
В curl отключение делается флагом, в Scrapy заголовком по умолчанию:
curl -x "$PX" --no-keepalive -H "Connection: close" \
-s -o /dev/null -w "%{http_code} %{time_total}\n" "$URL"
DEFAULT_REQUEST_HEADERS = {"Connection": "close"}
CONCURRENT_REQUESTS = 20
CONCURRENT_REQUESTS_PER_DOMAIN = 8
В логах режим читается сразу. Каждая строка несёт свой выходной адрес, повторов в колонке выхода почти нет, и число уникальных адресов за прогон совпадает с числом запросов с точностью до пары процентов. Отклик при этом выше соседних режимов: у меня рукопожатие TCP вместе с TLS добавляет 120-180 миллисекунд на запрос, на 3000 URL набегает около семи лишних минут.
Взамен частотных отказов почти не остаётся. На один адрес приходится ровно один запрос, счётчик площадки не успевает накопить историю, и последний мой прогон на 3000 URL дал 11 кодов 429 против 240 с лишним при работе через одно долгое соединение. Когда список большой и однородный, я включаю именно этот режим и добираю скорость числом потоков, темп с одного выхода при этом остаётся низким.
Режим второй: смена IP по таймеру и выбор интервала
Таймер нужен там, где несколько запросов подряд логично сделать с одного выхода, а вся задача при этом состояния не требует. Пагинация листинга на 40 страниц. Карточки товаров из одной категории. Выгрузка отчёта, где сначала запрашивается идентификатор задания, потом файл. Интервал даёт компромисс: соединение живёт достаточно, чтобы не переустанавливать TLS на каждый шаг, и достаточно мало, чтобы счётчик частоты на площадке не рос.
Первая ловушка тут в понимании параметра keep-alive у клиентов. Поле keepalive_expiry в httpx измеряет простой соединения, поэтому под непрерывной нагрузкой сокет живёт дольше заданного интервала, иногда часами. Реальный таймер приходится держать самому и закрывать пул принудительно:
import time, requests
class TimedSession:
def __init__(self, ttl=90):
self.ttl, self.born, self.s = ttl, 0.0, None
def _fresh(self):
if self.s:
self.s.close()
self.s = requests.Session()
self.s.proxies.update(PROXIES)
self.s.headers.update(HEADERS)
self.born = time.monotonic()
def get(self, url, **kw):
if self.s is None or time.monotonic() - self.born > self.ttl:
self._fresh()
return self.s.get(url, timeout=(4, 20), **kw)
Вызов close рвёт сокеты пула, следующий запрос поднимает соединение заново и получает другой адрес. В httpx я делаю то же самое через пересоздание клиента внутри контекстного менеджера, в Scrapy через DOWNLOAD_SLOTS и собственное middleware, которое считает время жизни слота. Значение ttl я подбираю от лимита площадки, расчёт разбираю ниже отдельным разделом.
Логи в этом режиме выглядят сериями. Идёт группа строк с одним выходом, потом граница, потом группа с другим. Границы стоят ровно по интервалу, и это самая быстрая проверка того, что таймер работает. Если серии тянутся дольше ttl, значит где-то остался живой сокет: часто виноват отдельный клиент под метрики или health-check, который держит своё соединение мимо общей сессии.
Такой режим я включаю на сборах, где важна и скорость, и ровный темп на выходе. Для длинных списков URL я держу под задачу отдельный пул: HTTP-доступы под списки ссылок раскладываются по воркерам заранее, до старта прогона, и каждый воркер ведёт свой таймер независимо от соседей.
Режим третий: одно соединение на всю сессию
Есть задачи, где все шаги удобно пройти с одного выхода подряд. Вход в кабинет площадки. Корзина и оформление. Многошаговая форма с токеном на каждом экране. Выгрузка через задание, когда сначала создаётся отчёт, а через минуту забирается файл по идентификатору. Тут я держу одно соединение на весь сценарий и меняю выход только на границе, после завершения последнего шага.
Со стороны клиента настройка обратна первому режиму. Один объект сессии живёт от начала сценария до конца, пул ограничен единственным соединением, переходы по редиректам разрешены:
import httpx
def scenario(px, creds):
limits = httpx.Limits(max_connections=1, max_keepalive_connections=1,
keepalive_expiry=120.0)
with httpx.Client(proxy=px, limits=limits, follow_redirects=True,
timeout=httpx.Timeout(connect=4.0, read=30.0),
headers=HEADERS) as c:
c.post("https://example.com/login", data=creds)
orders = c.get("https://example.com/account/orders")
page2 = c.get("https://example.com/account/orders?page=2")
return orders.status_code, page2.status_code
Ограничение max_connections=1 тут работает как страховка. Без него параллельные запросы внутри одного сценария поднимут второй сокет, второй сокет уйдёт другим выходом, и площадка увидит вход с одного источника вместе с чтением заказов с другого. Ту же роль в requests выполняет pool_maxsize=1 на смонтированном адаптере, в Selenium один браузерный профиль на один сценарий.
Отдельно про таймаут простоя. Сервер площадки закрывает keep-alive по своему усмотрению, обычные значения лежат в диапазоне от 5 до 75 секунд. Если между шагами сценария у вас пауза длиннее, соединение умрёт само, клиент откроет новое и получит другой адрес в середине работы. Я держу паузы короче серверного таймаута, а на длинных ожиданиях отправляю лёгкий запрос на статику того же домена, чтобы сокет оставался живым.
В логах режим виден по одной строке установки соединения на весь блок и по низкому отклику: повторные запросы идут по готовому TLS-каналу, у меня выигрыш выходит 30-40 процентов ко времени ответа. Колонка выхода при этом одинакова на всех строках сценария, и это главный признак корректной работы. Для сценариев с входом я держу отдельный профиль антидетект-браузера со своим выходом: окружение сессии не меняется между заходами, и темп внутри сессии остаётся предсказуемым от первого шага до последнего.
Режим четвёртый: смена выхода по сигналу от софта
Четвёртый режим стоит поверх остальных трёх. Смену выхода тут запускает событие, которое клиент прочитал в ответе площадки. Код 429, проверочное окно на месте страницы, короткий ответ с редиректом на заглушку. Клиент закрывает сокет, выдерживает паузу и продолжает работу уже с другим выходом.
Механика простая до банальности: закрытие соединения плюс sleep. Важна тут именно пауза, потому что мгновенный повтор придёт в тот же счётчик частоты и получит тот же ответ.
import time
def fetch(session, url, tries=4):
delay = 4
for attempt in range(tries):
r = session.get(url, timeout=(4, 20))
if r.status_code not in (429, 503, 403):
return r
session.close() # рвём сокет: следующий запрос уйдёт другим выходом
log.info("retry=%d code=%s url=%s pause=%d", attempt, r.status_code, url, delay)
time.sleep(delay)
delay *= 2
return None
Разные ответы требуют разной реакции, и одинаковая обработка всего подряд заметно портит статистику прогона.
| Ответ площадки | Что он говорит о темпе | Что делает клиент | Пауза перед повтором |
|---|---|---|---|
| 200 с полным содержимым | темп внутри лимита | продолжает на том же выходе | нет |
| 429 | темп выше лимита адреса | закрывает сокет, берёт следующий выход | 4, 8, 16 секунд |
| 403 на всех страницах подряд | выход попал под фильтр площадки | меняет выход, повтор на прежнем пропускает | нет |
| Проверочное окно вместо страницы | площадка просит подтверждение | завершает сессию, открывает новую | 30 секунд |
| 407 | доступ по логину не принят | останавливает поток и чинит доступ | повтор бесполезен |
| Обрыв на этапе подключения | адрес машины отсутствует в списке разрешённых | останавливает поток | повтор бесполезен |
Два нижних ответа я вынес отдельно намеренно. Они говорят про настройку доступа, повторять их бессмысленно ни через секунду, ни через минуту, и счётчик повторов на них тратить не нужно. Я вешаю на них аварийный останов: десять кодов 407 подряд гасят прогон целиком, потому что дальше поток тратит время впустую.
Замер с последнего сбора: 12 400 запросов, 20 потоков, интервал таймера 90 секунд. Кодов 429 пришло 318, повтор с растущей паузой и сменой выхода вернул 291 из них. Оставшиеся 27 URL я отложил в отдельный список и добрал позже в режиме одного запроса на адрес, там прошли все.
Как посчитать частоту под лимиты площадки
Арифметика тут короткая, и она снимает большую часть вопросов про интервал. Число запросов, которое придёт на один адрес, равно темпу потока, умноженному на время жизни соединения. Темп я меряю в запросах в минуту с одного воркера, время жизни в минутах.
Возьмём типовой темп 50 запросов в минуту с потока. При таймере 30 секунд на адрес приходит 25 запросов, при таймере 5 минут уже 250. За час поток выдаёт 3000 запросов, и они лягут либо на 120 разных выходов, либо на 12, разница ровно в интервале.
| Настройка клиента | Запросов на один адрес | Уникальных выходов за час | Типовые задачи |
|---|---|---|---|
| Сокет закрывается после ответа | 1 | 3000 | обход каталогов, проверка страниц по списку |
| Таймер 30 секунд | 25 | 120 | пагинация выдачи, карточки категории |
| Таймер 5 минут | 250 | 12 | отчёты и выгрузки по расписанию |
| Одно соединение на сценарий | 900 | 1-2 | вход в кабинет, многошаговые формы |
Обратная задача встречается чаще. Площадка отдаёт лимит вида «не больше 100 обращений за 10 минут с адреса», и надо получить из него интервал. Делю лимит на темп: 100 запросов при темпе 50 в минуту набираются за 2 минуты. Ставлю таймер с запасом в треть, выходит 80 секунд. Запас нужен, потому что окно у площадки скользящее, и хвост предыдущей серии накладывается на начало следующей.
Когда точный лимит неизвестен, я снимаю его сам. Один поток, фиксированный выход, темп поднимается ступенями: 10 запросов в минуту, потом 20, потом 40, и так до первого кода 429. Точка перелома и есть лимит адреса, дальше я беру от неё 60 процентов и живу спокойно. Замер занимает минут двадцать и экономит часы разбора на большом прогоне.
Общий темп прогона считается отдельно от темпа на адрес. Двадцать потоков по 50 запросов в минуту дают 1000 обращений в минуту к площадке суммарно, и вот этот показатель площадка тоже видит, уже на уровне всего пула. Я развожу их сознательно: темп на адрес держу низким за счёт интервала, общий объём набираю числом воркеров. Пакет с 1000 потоков позволяет разложить нагрузку широко, при корпоративном лимите в 3000 потоков раскладка получается ещё ровнее.
Что видно в логах при каждом режиме
Лог без колонки выхода бесполезен для разбора ротации. Пока в строке стоит только URL и код ответа, любой перекос выглядит как случайность, и понять, дал ли сотню таймаутов один нездоровый адрес или весь пул, нельзя. Я держу в каждой строке четыре поля: воркер, выход, код, время ответа.
log.info("w=%s exit=%s code=%s ms=%d url=%s",
worker_id, exit_ip, r.status_code, int(r.elapsed.total_seconds() * 1000), url)
Выходной адрес я узнаю без лишней нагрузки на прогон. Дёргать эхо-сервис на каждый запрос смысла нет: это удваивает трафик и вдвое ускоряет расход лимита площадки. Я спрашиваю адрес один раз, сразу после открытия соединения, и держу его в переменной воркера до следующего закрытия сокета:
def refresh_exit(session):
return session.get("https://api.ipify.org", timeout=(4, 8)).text.strip()
Разбор готового лога занимает одну строку в терминале:
awk '{print $2}' run.log | sort | uniq -c | sort -rn | head -20
Читаю я вывод так. При режиме одного запроса на адрес счётчик у всех выходов равен единице, редкие двойки нормальны. При таймере счётчики группируются вокруг расчётного значения: при интервале 90 секунд и темпе 50 в минуту я жду около 75 запросов на адрес, разброс от 60 до 90 говорит, что таймер работает верно. Хвост из адресов с сотнями запросов означает застрявший сокет, который держит какой-то отдельный клиент внутри процесса.
Вторая вещь, которую видно в логах, это связь кода ответа с номером запроса внутри серии. Я добавляю счётчик позиции и смотрю, на каком месте серии приходит 429: если отказы стабильно садятся на 40-й запрос, лимит площадки лежит примерно там, и интервал надо резать вдвое. Такой замер точнее любых догадок про частоту, потому что снят на живом прогоне вашего же профиля запросов.
Ротация и авторизация: привязка своего адреса и доступ по логину
Доступ к пулу открывается двумя способами, и режим ротации от выбора способа не зависит. Первый способ это привязка своего адреса в кабинете: вы вносите внешний IP сервера в список разрешённых, и строка подключения сокращается до хоста с портом. В пакет входит два таких адреса, менять их можно свободно, при двух привязанных лимит потоков делится между ними пополам. Второй способ это формат с логином, когда пара доступа стоит прямо в строке.
Разница ощущается в трёх местах.
Хранение секрета. При привязке своего адреса пароля в конфиге нет вовсе, поэтому он не уезжает в репозиторий вместе с проектом и не оседает в логах при подробном выводе curl. Я на постоянных серверах включаю именно привязку и держу второй слот под резервную машину.
Совместимость с браузерными сценариями. Chrome через ключ --proxy-server принимает хост и порт, пару логина с паролем он молча отбрасывает и поднимает системный диалог, а в headless-режиме диалог невидим и выглядит как зависший драйвер. Привязка своего адреса снимает вопрос целиком, строка становится короткой:
opts.add_argument("--proxy-server=http://gate.example:8080")
Диагностика отказов. Код 407 приходит только при доступе по логину и говорит, что пара не принята. При привязке своего адреса неверная настройка выглядит иначе: сервер не отвечает незнакомому источнику, и клиент получает обрыв на этапе подключения. По тексту исключения я сразу понимаю, куда смотреть, в кабинет или в переменные окружения.
Ротация работает в обоих форматах одинаково. Строка подключения остаётся постоянной весь прогон, смена выхода идёт внутри пула, и переписывать конфиг между режимами не требуется. Если сценарий уходит за пределы HTTP, я подключаю доступ по протоколу SOCKS5: он несёт TCP вместе с UDP и передаёт имя хоста на сторону выхода, что удобно для клиентов, которые сами резолвом заниматься не должны.
Куки, заголовки и состояние между сменами
Куки живут в клиенте, а выходной адрес живёт в сети, и эти две вещи меняются независимо. Отсюда типичная картина: адрес сменился, а cookie jar тот же, площадка видит знакомую сессию с нового источника и просит подтверждение. Обратная картина не лучше: jar сброшен, выход прежний, и на площадке появляется второй анонимный визит с того же адреса через минуту после первого.
Правило, к которому я пришёл, звучит коротко. Состояние и выход меняются вместе, на границе сценария. Практически это значит, что cookie jar создаётся в момент открытия соединения и умирает вместе с ним:
def new_context(px):
jar = httpx.Cookies()
client = httpx.Client(proxy=px, cookies=jar, headers=HEADERS,
limits=httpx.Limits(max_connections=1),
timeout=httpx.Timeout(connect=4.0, read=30.0))
return client, jar
Каждый воркер держит свой контекст. Общий jar на весь процесс это распространённая ошибка в многопоточных прогонах: воркеры перезаписывают друг другу идентификатор сессии, площадка получает один и тот же токен с десятка источников, и весь пул выглядит как один пользователь с очень странной географией.
Заголовки внутри сессии держатся постоянными от первого запроса до последнего. Accept-Language, User-Agent, порядок и набор заголовков клиента, версия TLS-стека. Менять их посреди сессии не нужно, потому что вместе со сменой адреса это дважды меняет контекст на одной цепочке действий. Я задаю набор заголовков при создании клиента и больше к нему не возвращаюсь:
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
}
Для режимов с частой сменой адреса состояние вообще не нужно. Тогда я отключаю приём кук целиком: в Scrapy это настройка COOKIES_ENABLED = False, в httpx передача пустого хранилища на каждый запрос, в curl отсутствие флагов -b и -c. Прогон становится проще: каждый запрос независим, разбирать после него нечего.
Как я подбираю режим под задачу
Выбор сводится к одному вопросу: переносится ли состояние между запросами. Если ответ на второй запрос зависит от первого, я держу соединение. Если запросы независимы, я рву соединение как можно чаще, потому что частая смена выхода снижает нагрузку на каждый адрес и убирает большинство частотных отказов ещё до того, как они появились.
| Режим | Что правится в клиенте | Признак в логах | Когда я его включаю |
|---|---|---|---|
| Новый адрес на запрос | keep-alive выключен, пул на одно соединение | уникальный выход в каждой строке | списки URL, проверка страниц, пинг |
| Смена по таймеру | принудительное закрытие пула по ttl | серии строк с границами по времени | пагинация, категории, выгрузки |
| Одно соединение на сессию | один клиент на сценарий, max_connections=1 | один выход на весь блок шагов | вход в кабинет, корзина, формы |
| Смена по сигналу | обработка кодов ответа и пауза с ростом | строки retry рядом со сменой выхода | площадки с жёстким счётчиком частоты |
Смешивать режимы внутри одного прогона нормально. У меня в типовом сборе первый этап идёт с полным отказом от keep-alive: собирается карта ссылок по категориям. Второй этап читает карточки сериями по таймеру, третий заходит в кабинет и снимает отчёт на одном удержанном соединении. Три этапа, три настройки, один и тот же пул под всеми.
Длинные прогоны я планирую по времени доступа. Сутки, неделя или месяц берутся под задачу, объём при этом не считается, поток трафика ограничен только вашим темпом: безлимитные адреса под долгие прогоны позволяют держать сбор круглосуточно и не сверяться со счётчиком объёма. Когда перебор выходов нужен постоянно, я подключаю пул со сменой выхода на стороне сервиса и оставляю в коде ровно один параметр: время жизни соединения.
Рядом в этом же разделе лежат заметки, которые продолжают тему с других сторон. Сами строки подключения, схемы протоколов и раскладка таймаутов собраны в материале прокси в Python и curl с готовыми фрагментами для requests, httpx и Scrapy. Проверочные окна, которые площадка показывает на месте содержимого, разобраны в заметке капча при сборе данных: там же про то, какая частота их вызывает. Как эти же четыре режима собираются мышью, без единой строки кода, показано в статье прокси в n8n и Make. Из раздела основ по теме ближе всего порт прокси-сервера, где разложено, почему у одного адреса разные порты под HTTP и SOCKS и чем оборачивается их подмена.