СправочникСбор данных › n8n прокси и Make

n8n прокси и Make: как отправить HTTP Request через прокси и поставить сценарий на расписание

Приватный серверный пул IPv4 и SOCKS5 для сценариев автоматизации

Прокси в сценарии автоматизации это промежуточная точка, через которую узел отправляет исходящий запрос: клиент соединяется с ней, она обращается к площадке и возвращает ответ обратно в поток данных. Смысл для n8n и Make одинаковый. Различается место, где записывается адрес: в n8n он живёт в опциях узла HTTP Request, в переменных среды процесса либо в объекте агента внутри узла Code, а у Make отдельного поля под прокси в HTTP-модуле нет, и трафик уводят на промежуточный сервис.

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

Схема прохождения запроса через узлы сценария от триггера до площадки

Узел HTTP Request и где в нём задаётся адрес

Узел HTTP Request закрывает почти всю исходящую работу сценария. Адрес прокси лежит в разделе Options: нужно добавить опцию Proxy и вписать строку целиком, вместе со схемой и портом. Схема обязательна. Без неё клиент разбирает строку как имя хоста и уходит в отказ ещё до соединения.

http://203.0.113.24:8080
http://LOGIN:PASS@203.0.113.24:8080
socks5://LOGIN:PASS@203.0.113.24:1080

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

Сам узел хранится в описании сценария обычным объектом JSON, и правку иногда быстрее сделать в нём, чем мышью в интерфейсе. Выглядит поле так:

{
  "parameters": {
    "url": "https://example.org/catalog/page/17",
    "method": "GET",
    "options": {
      "proxy": "http://LOGIN:PASS@203.0.113.24:8080",
      "timeout": 20000,
      "redirect": { "redirect": { "followRedirects": true, "maxRedirects": 5 } },
      "response": { "response": { "fullResponse": true, "neverError": true } }
    }
  },
  "type": "n8n-nodes-base.httpRequest",
  "name": "Съём страницы каталога"
}

Два ключа в этом фрагменте важнее прочих. Параметр fullResponse заставляет узел отдавать дальше по сценарию код ответа и заголовки, без него в следующий узел приходит только тело. Параметр neverError снимает автоматический обрыв ветки на коде 429 или 403: узел вернёт объект с кодом, а разбирать его я буду сам следующим узлом, по своим правилам. Такая пара включена у меня во всех узлах, которые ходят наружу, потому что молчаливый обрыв ветки крадёт статистику отказов.

Мест, откуда узел может взять адрес, всего пять, и путать их между собой дорого.

Где задаётсяУровень действияФормат записиНа что влияетКогда беру этот способ
Поле Proxy в OptionsОдин узел HTTP Requesthttp://IP:PORT со схемойТолько этот вызовЧасть узлов ходит наружу, часть внутрь
Переменные HTTP_PROXY и HTTPS_PROXYВесь процесс n8nhttp://LOGIN:PASS@IP:PORTВсе узлы разом, включая служебныеСвоя установка в контейнере, весь сценарий наружу
Агент соединения в узле CodeОдин вызов внутри кодаОбъект agent из библиотекиЗапрос, написанный рукамиНужен SOCKS5 или своя логика повторов
Промежуточный сервисВнешний обработчикСвой эндпоинт в поле URLЛюбой облачный сценарийMake и другие площадки без поля прокси
Узел Execute CommandРазовый вызов оболочкиcurl -x http://IP:PORTОдну командуПроверка доступа до сборки сценария

Проверять доступ я начинаю с последней строки таблицы. Узел Execute Command с однострочным curl -x http://LOGIN:PASS@203.0.113.24:8080 -s https://api.ipify.org отвечает за пару секунд и сразу показывает, дошёл ли запрос до внешней точки и какой адрес видит удалённая сторона. Если ответ пришёл, значит порт открыт, пара логин и пароль принята, и дальше можно спокойно переносить строку в узел HTTP Request. Когда сценарий строится вокруг регулярного обхода страниц, полезно сразу посмотреть условия под прокси для многопоточного обхода в A-Parser: темп и число одновременных обращений там уже прикинуты под такую нагрузку.

Две схемы авторизации и что они меняют в сценарии

Доступ к пулу открывается двумя способами, и выбор влияет на то, как выглядит строка в узле. Первый способ: привязка своего внешнего адреса в кабинете. Сервис запоминает адрес сервера, с которого приходят запросы, и пускает их без пароля. В пакет входит 2 привязки, менять их можно свободно. Строка тогда короткая, http://203.0.113.24:8080, и в описании сценария не остаётся ни логина, ни пароля.

Второй способ: авторизация по паре логин и пароль. Привязка при этом не нужна, сценарий переезжает между машинами без правок в кабинете, а строка удлиняется до http://LOGIN:PASS@203.0.113.24:8080. Оба формата выдаются списком, простым IP:PORT и расширенным IP:PORT:LOGIN:PASS, ссылкой либо файлом, так что подстановка в сценарий делается одним прогоном по шаблону.

У привязок есть арифметическая деталь, которая всплывает у всех, кто держит два сервера. Лимит потоков в пакете один: 1000 на обычных пакетах и до 3000 на корпоративном. Если привязано 2 адреса, тысяча делится пополам, и каждая машина получает по 500 одновременных соединений. Когда n8n стоит на одном сервере, а планировщик прогонов на втором, я оставляю привязку только на n8n, а второй машине выдаю доступ по логину. Пакеты по потокам между собой не складываются, поэтому разносить одну задачу на два пакета ради удвоения смысла не имеет.

// превращаем строку списка IP:PORT:LOGIN:PASS в строку для узла HTTP Request
const line = '203.0.113.24:8080:u91f2:s7k3qd';
const [ip, port, user, pass] = line.split(':');
const proxyUrl = `http://${user}:${pass}@${ip}:${port}`;
return [{ json: { proxyUrl } }];

Этот фрагмент я держу первым узлом Code в каждом сценарии, где список доступов подтягивается из файла. Дальше строка уходит в поле Proxy выражением, которое читает поле proxyUrl из входного элемента, и правка списка перестаёт требовать открытия узлов по одному. Основа всей схемы это приватные серверные прокси на собственном оборудовании: пул держит около 12 000 активных адресов, ротация внутри пула автоматическая, и на стороне сценария об этом заботиться не нужно вовсе.

Переменные среды на своей установке n8n в контейнере

Когда n8n поднят своим контейнером, адрес прокси удобнее задать один раз на весь процесс. Node.js читает переменные HTTP_PROXY, HTTPS_PROXY и NO_PROXY при старте, и после перезапуска контейнера через пул уходят все исходящие вызовы: узлы HTTP Request без поля Proxy, готовые узлы интеграций, запросы библиотек внутри Code.

Четыре места, откуда узел берёт адрес прокси на своей установке

Кусок описания контейнера у меня выглядит так:

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    ports:
      - "5678:5678"
    environment:
      - HTTP_PROXY=http://LOGIN:PASS@203.0.113.24:8080
      - HTTPS_PROXY=http://LOGIN:PASS@203.0.113.24:8080
      - NO_PROXY=localhost,127.0.0.1,postgres,redis,10.0.0.0/8
      - N8N_PAYLOAD_SIZE_MAX=32
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=336
    volumes:
      - ./n8n-data:/home/node/.n8n

Строка NO_PROXY тут важнее двух первых. В неё идут все адреса, к которым контейнер обращается внутри своей сети: база, кэш, внутренние вебхуки, служебный монитор. Забыв её заполнить, вы отправите запрос к собственному Postgres на внешнюю точку входа, получите отказ соединения и потратите вечер на разбор ошибки, которая выглядит как поломка базы. Я пришёл к тому, что заполняю NO_PROXY первой строкой, ещё до адреса пула.

Вторая тонкость связана со схемами. Переменная называется HTTPS_PROXY, но её значение почти всегда начинается с http://: схема в значении описывает способ добраться до самой точки входа, протокол целевого сайта тут ни при чём. Записав туда https://, вы заставите клиент открывать TLS до прокси, чего обычная точка входа не ждёт. Для запросов к сайтам по защищённой схеме этого достаточно, и отдельные доступы по протоколу HTTPS подключаются к сценарию той же строкой, меняется только порт.

Третья тонкость касается переменных внутри контейнера. Их видно любому, у кого есть доступ к оболочке: printenv показывает пару логин и пароль открытым текстом, и то же самое попадает в дамп конфигурации при отладке. Поэтому в среду я выношу адрес пула для служебных вызовов, а рабочие сценарии, где нужен разбор доступов по узлам, держу на credentials. Про них ниже отдельный раздел.

Проверка после перезапуска занимает полминуты. Одноразовый сценарий из одного узла HTTP Request без поля Proxy, URL https://api.ipify.org?format=json, запуск руками. Ответ с внешним адресом пула означает, что переменные подхватились. Ответ с адресом самого сервера означает, что контейнер поднялся со старым окружением и его надо пересоздать, потому что простой рестарт переменные не перечитывает.

Узел Code и собственный агент соединения

Поле в узле HTTP Request закрывает схемы HTTP и HTTPS. Как только требуется SOCKS5, своя логика повторов или разные адреса на каждый элемент входного массива, я перехожу на узел Code с явно созданным агентом соединения. Агент это объект, который клиент использует для установки сокета: внутри него лежит адрес точки входа, пара логин и пароль и настройки таймаута.

Внешние библиотеки в узле Code по умолчанию закрыты, их надо разрешить переменной среды:

      - NODE_FUNCTION_ALLOW_EXTERNAL=socks-proxy-agent,https-proxy-agent

После этого код внутри узла получает доступ к агенту, а весь запрос собирается руками:

const { SocksProxyAgent } = require('socks-proxy-agent');

const agent = new SocksProxyAgent('socks5://LOGIN:PASS@203.0.113.24:1080', {
  timeout: 15000,
  keepAlive: false
});

const out = [];
for (const item of $input.all()) {
  const res = await this.helpers.httpRequest({
    method: 'GET',
    url: item.json.url,
    agent,
    timeout: 15000,
    returnFullResponse: true,
    ignoreHttpStatusErrors: true,
    headers: { 'Accept-Language': 'ru,en;q=0.8' }
  });
  out.push({ json: { url: item.json.url, code: res.statusCode, len: (res.body || '').length } });
}
return out;

Ключ keepAlive: false тут поставлен намеренно. Открытое соединение занимает поток, пока сокет живёт, и десяток висящих сокетов после завершения сценария продолжает держать слоты. Закрывая соединение сразу после ответа, я возвращаю поток в общий счёт и получаю честную картину одновременности. Схему SOCKS5 удобно ставить туда, где нужен обмен по произвольному порту или где приложение само разбирает имена хостов: точка входа принимает и такой формат, и обычный HTTP, порт различается.

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

Make: сценарий без поля прокси и обход через промежуточный сервис

В Make модуль HTTP собирает запрос из URL, метода, заголовков и тела. Поля под адрес прокси в нём нет, потому что запрос уходит с инфраструктуры самого сервиса, куда клиентские настройки сети не пробрасываются. Работает обход через промежуточный сервис: на своём сервере поднимается маленький обработчик, Make обращается к нему, а наружу обработчик уходит уже через пул.

Обработчик получается в тридцать строк. Он принимает целевую ссылку и заголовки, подставляет адрес точки входа, отдаёт обратно код ответа и тело:

// forwarder.js, запускается на своём сервере рядом с n8n
const express = require('express');
const { HttpsProxyAgent } = require('https-proxy-agent');
const app = express();
app.use(express.json({ limit: '4mb' }));

const AGENT = new HttpsProxyAgent(process.env.POOL_URL);
const KEY = process.env.FORWARD_KEY;

app.post('/fetch', async (req, res) => {
  if (req.get('X-Forward-Key') !== KEY) return res.status(401).json({ error: 'bad key' });
  try {
    const r = await fetch(req.body.url, {
      method: req.body.method || 'GET',
      headers: req.body.headers || {},
      agent: AGENT,
      signal: AbortSignal.timeout(20000)
    });
    res.json({ code: r.status, body: await r.text() });
  } catch (e) {
    res.status(502).json({ error: String(e.message || e) });
  }
});

app.listen(8791);

В Make остаётся один модуль HTTP, который стучится на https://forwarder.example.org/fetch с телом вида {"url": "...", "method": "GET"} и заголовком X-Forward-Key. Дальше сценарий Make разбирает поле code обычным роутером: успех идёт в ветку записи, отказ уходит в ветку повтора. Заголовок с ключом нужен обязательно, открытый обработчик найдут сканеры за сутки.

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

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

Запуск по расписанию с сервера и накопление ошибок

Узел Schedule Trigger даёт два способа задать частоту: интервалом и выражением cron. Интервал удобен для коротких прогонов, выражение нужно там, где важно попасть в конкретное окно. Съём цен я ставлю на ночные часы, обход выдачи на утренние, отчётную сборку на середину дня.

0 3 * * *        ежедневный обход каталога, 3 часа ночи
*/20 6-22 * * *  проверка наличия, каждые 20 минут днём
0 9 * * 1        недельная сводка, понедельник

Расписание с сервера ломается в одном месте: запуски накладываются друг на друга. Ночной обход рассчитан на 40 минут, площадка отвечает медленнее обычного, прогон растягивается на полтора часа, и следующий запуск стартует поверх незакрытого. Одновременность удваивается, счётчик потоков растёт, а сценарий продолжает считать, что работает в один поток. Я закрываю это статическими данными самого сценария: первый узел ставит метку занятости, последний её снимает, повторный запуск при живой метке останавливается сразу.

// первый узел сценария: защита от наложения запусков
const st = $getWorkflowStaticData('global');
const now = Date.now();
if (st.running && now - st.running < 3 * 3600 * 1000) {
  throw new Error('Предыдущий прогон ещё идёт, старт отменён');
}
st.running = now;
return $input.all();

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

Плотность расписания упирается в потоки и в срок доступа, вес выкачанных страниц в расчёт не входит: трафик безлимитный на всех пакетах. Это заметно упрощает планирование ночных прогонов, где выкачиваются каталоги с картинками и большими выгрузками, и позволяет ставить обходы чаще. Условия по срокам собраны на странице пакетов без тарификации объёма. Часть регулярной работы вообще удобнее оставить отдельной программе: съём позиций по расписанию я держу в Key Collector, где прокси для Key Collector подключаются списком, а сценарий n8n забирает готовую выгрузку и раскладывает её по таблицам.

Повторные попытки внутри сценария

Повтор настраивается на самом узле, в разделе Settings: переключатель Retry On Fail, число попыток в Max Tries и пауза в Wait Between Tries. Три попытки с паузой в две секунды закрывают короткие сетевые сбои, дальше начинается область, где нужна своя логика.

Три попытки с растущей паузой и запись каждой в журнал выполнений

Ровная пауза плоха тем, что при отказе по темпу она бьёт в ту же стену. Площадка ответила кодом 429, сценарий подождал 2 секунды и постучался снова, получил тот же код, подождал ещё 2 секунды. Три попытки уходят впустую за 6 секунд. Растущая пауза ведёт себя иначе: 2 секунды, потом 8, потом 30, и к третьему обращению окно ограничения обычно уже сдвинулось. Собирается это узлом Wait, куда пауза приходит выражением от номера попытки.

// узел Code перед Wait: считаем паузу от номера попытки
const n = $json.attempt || 1;
const delay = Math.min(2 * Math.pow(4, n - 1), 60);   // 2, 8, 32, 60
return [{ json: { ...$json, attempt: n + 1, delay } }];

Отказы бывают разные, и валить их в одну ветку смысла нет. Часть кодов означает, что площадка просит сбавить темп, часть указывает на ошибку в самой строке доступа, часть на маршрут. Разбор я держу таблицей и повторяю его в каждом сценарии одинаково.

Что пришло в узелОткуда отказЧто делает сценарийПауза до повтораПризнак в журнале
Код 429Площадка режет темпПовтор с растущей паузой, снижение параллели8 секунд, дальше 32Доля 429 растёт к концу окна
Код 403Площадка проверяет обращениеПовтор с новым выходом и полным набором заголовков5 секундОтказы идут пачками по времени
Код 407Пара логин и пароль не принятаОстановка ветки, разбор строки доступаПовтора нетОтказ на первой же попытке
ECONNREFUSEDПорт или число потоков на своей сторонеОстановка, проверка порта и счётчика потоковПовтора нетОшибка до отправки запроса
ETIMEDOUTМаршрут или тяжёлая страницаПовтор с увеличенным таймаутом15 секундВремя узла ползёт вверх
Коды 5xxПлощадка легла самаПовтор редкими попытками30 секунд, до 3 разОтказ у всех выходов разом

Две строки в этой таблице стоят особняком. Код 407 и отказ соединения приходят до того, как запрос ушёл наружу, поэтому повторять их бесполезно: пока строка доступа неверна или пока свободных потоков нет, повтор вернёт тот же результат через ту же долю секунды. Ветку я обрываю сразу и отправляю уведомление, потому что молчаливое повторение таких ошибок за ночь наматывает тысячи пустых попыток и портит статистику.

Отдельная привычка: считать попытки в самой записи элемента. Поле attempt едет вместе с данными, и в конце прогона видно, сколько элементов прошло с первого раза, сколько со второго и сколько ушло в отбраковку. Средняя доля первых попыток у меня держится около 94 процентов на спокойных площадках и падает до 70 на защищённых, и любое отклонение от привычной цифры я разбираю до запуска следующего прогона.

Хранение пары логин и пароль в credentials

Держать доступ строкой прямо в узле удобно ровно до первой выгрузки сценария. Экспорт JSON, копия для коллеги, резервная копия в репозитории: пара логин и пароль разъезжается по местам, откуда её потом не собрать. В n8n для этого есть хранилище credentials, где значение шифруется ключом установки и в экспорт попадает одной ссылкой на идентификатор.

Под доступ к пулу подходит тип Generic Credential Type с подтипом Basic Auth или Header Auth. Заведя запись, вы получаете возможность подставлять её в узел без открытого текста. Строку прокси при этом собирает узел Code из полей credentials, а сам сценарий видит только готовый результат:

const c = await this.getCredentials('httpBasicAuth');
const proxyUrl = `http://${c.user}:${c.password}@203.0.113.24:8080`;
return [{ json: { proxyUrl } }];

Второй слой это ключ шифрования установки. Он лежит в файле ~/.n8n/config внутри тома контейнера, и без него база credentials бесполезна. Том я включаю в резервное копирование отдельно от базы, потому что восстановление одной базы без ключа приводит к пересозданию всех записей руками.

Третья привычка касается смены доступов. Когда пара логин и пароль меняется, правится одна запись credentials, а все узлы подхватывают её при следующем запуске. Со строками, разбросанными по узлам, та же правка занимает вечер и почти гарантированно оставляет забытый узел, который начнёт отдавать код 407 в самое неудобное время. Формат выдачи это упрощает: список приходит готовыми строками IP:PORT и IP:PORT:LOGIN:PASS, ссылкой либо файлом, и разбор занимает один узел Code.

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

Журнал выполнений и признаки упора в лимит площадки

Раздел Executions хранит каждый запуск целиком: входные данные узлов, выходные, время работы и текст ошибки. Настройки EXECUTIONS_DATA_PRUNE и EXECUTIONS_DATA_MAX_AGE из фрагмента выше держат глубину журнала в две недели, чего хватает для разбора и не раздувает базу.

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

Упор со стороны площадки выглядит так: код 429 или 403 приходит после нормального ответа, доля отказов растёт к концу окна прогона, время узла при этом почти не меняется, и после паузы в несколько минут первые запросы снова проходят. Картина повторяется по часам: утренние прогоны идут ровно, вечерние собирают отказы, потому что площадка нагружена. Лечится сдвигом расписания и снижением параллели.

Своя сторона выглядит иначе: отказ приходит до ответа, в тексте видно ECONNREFUSED или обрыв на этапе подключения, коды ответа отсутствуют вовсе. Такое означает, что все свободные потоки заняты: висящие соединения из предыдущего прогона, keep-alive без таймаута или наложение двух запусков. Счёт слотов идёт по одновременным соединениям, поэтому лишний параллельный прогон съедает лимит быстрее, чем растущее число ссылок.

// сводка по журналу: доля кодов за прогон
const codes = {};
for (const i of $input.all()) {
  const c = i.json.code || 'net';
  codes[c] = (codes[c] || 0) + 1;
}
const total = $input.all().length;
return [{ json: { total, codes, ok: Math.round(100 * (codes[200] || 0) / total) } }];

Этот узел я ставлю последним в каждом сценарии, и его вывод складывается в отдельную таблицу. Через месяц накапливается ряд, по которому видно сезонность площадок и точный момент, когда обход начал деградировать. Основание под всей конструкцией простое: пул серверных адресов IPv4 и SOCKS5 с автоматической ротацией внутри пула, тысяча потоков на обычном пакете и до трёх тысяч на корпоративном, безлимитный трафик и выдача списком в двух форматах. Сценарий забирает из этого набора ровно то, что нужно конкретному узлу.

Собранный по такой схеме сценарий переносится между машинами за один экспорт. Адреса доступа лежат в credentials и в среде контейнера, повторы описаны в настройках узлов, расписание защищено меткой занятости, журнал накапливает статистику сам. Остаётся следить за тремя цифрами и раз в неделю сверять их с прошлой неделей, что занимает пару минут утром.

Соседние заметки этого раздела продолжают тему с других сторон. Строки подключения, схемы протоколов и раскладка таймаутов для кода собраны в материале прокси в Python и curl, там же готовые фрагменты для requests и httpx. Четыре режима смены выхода и управление моментом закрытия соединения разобраны в заметке ротация адресов: без этого повторы в сценарии работают вслепую. Проверочные окна, которые площадка подставляет при частом обращении, и порог, с которого они начинают появляться, описаны в статье капча при сборе данных. Из раздела практики ближе всего лимит потоков, где показано, как перевести объём задачи и срок прогона в число одновременных соединений под ваш пакет.