Перейти к содержимому
arckep.ru — все нейросети в одном месте без VPN Перейти
>AISTUDY_
Поддержать
AUTHORСвежий выпуск №024 → Куда внедрять агентов: фронт или тыл
Авторская колонка · где держать AI-бэкенд и как дотянуться до закрытогоСЕРИЯ 010
Авторская колонка · выпуск №010

Куда
идёт
трафик

Бэкенд в Европе, пользователи и часть данных в России, модели разбросаны по всему миру. Как сшить это туннелями, не отдать наружу ключи от платных API и не подвесить интерфейс лишними миллисекундами. Практический разбор на реальной архитектуре.

У любого AI-продукта есть три точки, которые живут в разных местах планеты: пользователь, который сидит в браузере; модель, которую вы зовёте платным API; и данные, часть которых по закону обязана остаться дома. Эти три точки почти никогда не совпадают географически. Вся «инфраструктура» сводится к одному вопросу: куда физически уходит каждый запрос и кто им управляет. Дальше — как я отвечаю на этот вопрос у себя, с замерами и граблями.
Как читать этот выпускЯ три раза показываю один и тот же приём — туннель — в разные стороны, плюс прокси-перехват перед платным API. Каждый раздел заканчивается готовым промптом с синей меткой PROMPT: скопировали, отдали своему агенту, получили настройку, не переписывая руками. Технические термины объясняю по ходу в блоках «Простым языком».
Раздел 01

Где стоит бэкенд и почему Амстердам

Первое решение в таком продукте — самое скучное и самое влияющее: на какой физической машине крутится бэкенд. От него зависит, будет ли интерфейс отвечать «сразу» и сможете ли вы вообще дотянуться до моделей и до данных. Меня тянули в три стороны: пользователи в Москве хотят отзывчивый интерфейс; глобальные LLM-провайдеры доступны не из любой точки; часть данных по закону обязана лежать в России. Один сервер должен был ужиться со всеми тремя.

Я держу вычислительный узел в Амстердаме. Это не «потому что Европа красивая» — это про сеть. Разберу по цифрам, потому что именно здесь чаще всего пишут ерунду.

Схема 1Три направления, которые сходятся на одном сервере
flowchart LR
  U["Пользователь в Москве"] -->|"42 мс"| AMS["Бэкенд в Амстердаме"]
  AMS -->|"около 1 мс, через CDN"| LLM["Глобальные LLM-провайдеры"]
  AMS -->|"туннель наружу"| EXT["Внешний узел, выход в мир"]
  AMS -->|"туннель внутрь"| RU["Сервисы и база ПД в России"]
Слева пользователь, в центре сервер, справа три направления трафика. Весь выпуск — про эти три стрелки.

Миф про «пинг до OpenAI»

Самое частое заблуждение: «надо сидеть ближе к серверам OpenAI и Anthropic в США, там же модели». Я замерил с моего сервера пинг до их API — и до Anthropic, и до OpenAI получилось около 1 миллисекунды. Не 80, не 100, а единицы. Потому что API больших провайдеров спрятаны не в одном далёком датацентре, а за глобальной сетью доставки, у которой точка присутствия стоит прямо в Амстердаме, на том же сетевом хабе, что и мой сервер.

Простым языком: что такое CDN-крайБольшой сервис не держит один сервер на весь мир. Он расставляет «приёмные окна» (точки присутствия) в десятках городов. Вы соединяетесь с ближайшим окном, а оно уже само добирается до начинки сервиса по своим быстрым каналам. Поэтому «пинг до OpenAI» рядом с любым крупным узлом связи — это единицы миллисекунд, и гнаться за ним бессмысленно. Реальное время ответа модели — это её «думание», секунды, и оно одинаково, откуда ни зови.

Вывод из этого замера: сетевой задержки до провайдера у меня практически нет, и она не зависела бы от выбора между Амстердамом и, скажем, Франкфуртом. Этот фактор из уравнения можно вычеркнуть. А вот что в уравнении осталось — расстояние до пользователя.

А вот это уже важно: расстояние до пользователя

Пинг от моего сервера до Москвы я замерил — 42 миллисекунды, стабильно. Для веб-интерфейса это «отвечает сразу»: на такой задержке человек не замечает, что между ним и сервером лежит пол-Европы.

Теперь контрпример. Если поставить тот же бэкенд в США (соблазн «ближе к моделям» как раз туда и тянет), каждый обмен между московским пользователем и сервером начинает прыгать через Атлантику. Я замерил с Амстердама пинг до узла на западном побережье США — 151 миллисекунда. Каждое действие в интерфейсе — это не одна поездка туда-обратно, а несколько. Разница между 42 и 150 миллисекундами — это разница между «интерфейс живой» и «интерфейс подлагивает на каждый клик», и пользователь это чувствует, даже если не может объяснить словами.

Итог разделаАмстердам выбран не за близость к моделям (до них одинаково быстро из любого крупного хаба), а за близость к пользователю — 42 мс против 150 мс из США — при этом он стоит на нейтральной территории, откуда есть прямой выход на глобальные API. Дальше — как из этой точки дотянуться до того, что осталось в России, и до платных моделей.
Раздел 02

Туннель наружу: один прокси на все инструменты

С сервера и с локальных машин мне нужно ходить во внешние API: вызывать модели, тянуть пакеты, гонять автономных CLI-агентов. Казалось бы, просто. Но инструментов десяток — curl, скрипты на Python, Node, сами агенты — и у каждого свой способ прописать прокси. Настроишь в одном месте, забудешь в пяти других, при обновлении всё слетит. Мне нужен был один перехват на всю машину, чтобы новый инструмент не требовал вообще никакой настройки.

Собирается это из трёх кирпичей: туннель, перехватчик и подмена сетевых вызовов.

Схема 2Как любой инструмент попадает в одну трубу
flowchart LR
  TOOL["Любой инструмент: curl, python, CLI-агент"] -->|"LD_PRELOAD подменяет сетевые вызовы"| PC["proxychains"]
  PC -->|"SOCKS5 на порту 1080"| SSH["SSH туннель, ssh -N -D"]
  SSH --> EXT["Внешний узел"]
  EXT --> NET["Внешние API"]
Программа думает, что звонит напрямую. На деле звонок перехвачен и уведён в трубу. Кода программы это не касается.

Кирпич 1. Сам туннель

Поднимаю SSH с динамическим пробросом и заворачиваю в systemd-сервис, чтобы держался всегда и сам переподнимался при обрыве:

[Service]
Type=simple
# -N: не выполнять команду на той стороне, только держать туннель
# -D 1080: поднять локальный SOCKS5 на порту 1080
ExecStart=/usr/bin/ssh -i /root/.ssh/id_ed25519 \
  -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
  -N -D 1080 user@external_node
Restart=always
RestartSec=5
Простым языком: SOCKS5 и динамический пробросSOCKS5 — это универсальная труба для трафика: программа отдаёт в неё любые соединения, а труба выносит их наружу через другую машину. Флаг -D у SSH превращает обычное защищённое соединение в такую трубу. Порт 1080 здесь условный, поставьте любой свободный.

Кирпич 2. Перехватчик

proxychains-ng — утилита, которая заставляет чужую программу ходить через указанный прокси. В её конфиге включаю строгий режим и наш SOCKS5:

strict_chain
proxy_dns
remote_dns_subnet 224

[ProxyList]
socks5 127.0.0.1 1080

Кирпич 3. Подмена сетевых вызовов

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

export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libproxychains.so.4
Простым языком: что делает LD_PRELOADКогда программа собирается выйти в сеть, она зовёт стандартные системные функции «соедини меня туда-то». LD_PRELOAD подсовывает программе свою версию этих функций до того, как она доберётся до системных. Программа уверена, что звонит напрямую, а звонок на деле уходит в трубу. Менять код программы не нужно вообще.

Три грабли, на которых это молча ломается

Сам рецепт короткий, а вот что в нём неочевидно и где он подводит без предупреждения:

01Утечка DNSЕсли программа резолвит доменное имя локально, этот запрос уходит мимо трубы — и его видит ваш провайдер. Флаг proxy_dns в конфиге заставляет резолвить имя на дальнем конце трубы, а не дома. Без него труба есть, а адрес сервиса вы всё равно спрашиваете в открытую.
02Статически слинкованные бинарникиФокус с LD_PRELOAD работает только для программ с динамической линковкой. Бинарь, собранный статически (типичный случай — утилиты на Go без C-зависимостей), подменить нельзя: он пойдёт в сеть напрямую и упрётся в таймаут. Лечится либо явным запуском через proxychains4 <бинарь>, либо настройкой прокси внутри самого инструмента.
03Фоновые процессы под systemdСервис, запущенный через systemd, не читает ваш ~/.bashrc — значит, и LD_PRELOAD оттуда не возьмёт. Если автономный агент крутится как сервис, переменную надо прописать прямо в его unit-файле через Environment=, иначе труба молча не подключится, а вы будете гадать, почему «локально работает, а в проде нет».

Чтобы не собирать это вручную, отдайте задачу агенту — он знает все три грабли, если их перечислить:

Подними на сервере исходящий прокси через SSH и proxychains, чтобы любой инструмент ходил во внешний интернет через внешний узел.
1. systemd-сервис с командой ssh -N -D 1080 на внешний узел, с автоперезапуском и keepalive.
2. proxychains-ng в строгом режиме, proxy_dns включён, в списке socks5 127.0.0.1:1080.
3. LD_PRELOAD на libproxychains в профиле shell, и отдельно через Environment= в unit-файлах фоновых сервисов.
Учти ловушки: статически слинкованные бинарники LD_PRELOAD не перехватывает, для них предложи запуск через proxychains4 явно. Покажи пути ко всем созданным файлам.
Итог разделаОдин раз настроил — и любой инструмент на машине, от curl до автономного агента, выходит наружу через одну трубу. Новый инструмент не требует ни строчки настройки: он просто наследует общий перехват.
Раздел 03

Туннели внутрь: дотянуться до закрытого

Бэкенд в Амстердаме, а часть мира осталась дома. Два разных требования тянут трафик обратно в Россию, и оба надо закрыть.

Первое: некоторые российские сервисы просто не отвечают на соединение из-за рубежа — для них европейский IP «чужой», и они молча отбрасывают запрос. Чтобы передавать данные в такой сервис (а это бывает нужно, когда из ЕС он недоступен), запрос должен прийти будто бы изнутри страны. Второе: персональные данные пользователей по закону о локализации обязаны храниться на территории России. Бэкенд в Европе — а база с этими данными должна стоять дома.

Схема 3Два туннеля обратно в Россию под две разные задачи
flowchart LR
  BE["Бэкенд в Амстердаме"] -->|"точечный прокси для нужных запросов"| T1["SOCKS5 на порту 10800"]
  T1 --> RUSVC["Региональные сервисы в России"]
  BE -->|"подключение к localhost:15999"| T2["Проброс порта, ssh -N -L"]
  T2 --> DB["База персональных данных в России"]
Сверху — труба для сервисов (тот же приём, что наружу, но в обратную сторону). Снизу — проброс порта для базы.

Сервисы: точечный прокси, не глобальный

Для региональных сервисов поднимаю такой же SSH-туннель, как наружу, но теперь до узла внутри России — он открывает локальный SOCKS5-порт. Важный нюанс: прокси я указываю не на весь бэкенд, а точечно, только для запросов к этим конкретным адресам. Всё остальное продолжает ходить напрямую.

import httpx

# прокси включается только для соединений, которые мы сами в него отдали,
# а не для всего приложения
transport = httpx.AsyncHTTPTransport(proxy="socks5://127.0.0.1:10800")

async with httpx.AsyncClient(transport=transport) as client:
    resp = await client.post("https://regional-service.example/v1/submit", json=payload)

База: проброс порта

Для базы данных нужен не SOCKS-прокси, а проброс конкретного порта. Локальный порт на европейском сервере «прокидывается» на порт базы внутри России:

[Service]
# -L 15999:127.0.0.1:5432 — локальный порт 15999 ведёт на порт базы на удалённом узле
ExecStart=/usr/bin/ssh -o ServerAliveInterval=30 \
  -N -L 15999:127.0.0.1:5432 user@ru_node
Restart=always
RestartSec=5

Бэкенд подключается к localhost:15999 и работает с базой как с локальной. На деле трафик идёт зашифрованным каналом в нужный регион, а персональные данные физически лежат дома.

Простым языком: проброс портаПроброс порта (-L) — это «дырочка»: на вашей машине появляется локальный порт, который на самом деле ведёт к порту на удалённой машине в другой стране. Программа видит обычный localhost и ничего не знает про географию. Вся дорога спрятана внутри зашифрованного SSH-канала. Порты 10800 и 15999 здесь условные.

Так получается естественное разделение: персональные данные — в базе внутри России через туннель, всё остальное (метаданные приложения, контент) — рядом с бэкендом в Амстердаме. Один продукт, две базы, граница между ними проходит ровно по линии «что является персональными данными».

Нюанс: канал до удалённого узла бывает рваныйТуннель в дальний регион — это не локальная сеть. Канал может терять пакеты и скакать по задержке. Для долгоживущих соединений (вебсокеты, длинные запросы) обязательно поднимите таймауты пингов (ServerAliveInterval на туннеле и тайм-ауты на стороне приложения), иначе соединение будет рваться на ровном месте, а вы — искать несуществующую ошибку в коде.

Готовый промпт под оба туннеля сразу:

Настрой два входящих туннеля с этого сервера до узла внутри нужного региона.
1. SOCKS5 на локальном порту 10800 через ssh -N -D до регионального узла, для точечных запросов к региональным сервисам. В коде клиента укажи этот прокси только для нужных адресов, не глобально.
2. Проброс порта через ssh -N -L 15999 на порт удалённой базы данных, чтобы бэкенд подключался к localhost:15999 как к локальной базе.
Оба туннеля как systemd-сервисы с автоперезапуском и keepalive. Для нестабильного канала подними таймауты пингов. Покажи пути к файлам.
Итог разделаБэкенд из Европы дотягивается до того, что закрыто из Европы, а персональные данные не покидают Россию. С точки зрения кода это просто localhost и обычный прокси — вся география спрятана в туннелях, и приложение про неё не знает.
Раздел 04

Биллинг-прокси: встать между фронтом и платным API

Третье направление трафика — к платным моделям. Здесь типичная ситуация: я беру готовый чат-интерфейс (их сейчас много, написаны под прямой вызов LLM-API) и хочу пустить его в работу, но с двумя жёсткими условиями. Ключ провайдера нельзя отдавать на клиент: любой откроет консоль браузера и заберёт его за минуту. И пользователю нельзя позволить жечь мой баланс без счёта: платный API биллится по токенам, один человек за ночь высадит всё.

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

Схема 4Путь одного запроса через биллинг-прокси
flowchart TD
  FE["Готовый чат-фронт"] --> BE["Биллинг-прокси на бэкенде"]
  BE --> AUTH{"Пользователь и баланс в порядке?"}
  AUTH -->|"нет"| STOP["Отказ, запрос наружу не уходит"]
  AUTH -->|"да"| KEY["Подставляем настоящий ключ"]
  KEY --> PROV["Запрос провайдеру, через трубу наружу"]
  PROV --> STREAM["Читаем поток, вынимаем расход"]
  STREAM --> BILL["Списываем с баланса"]
  STREAM --> CLIENT["Поток идёт клиенту без разрыва"]
Ключ живёт только на сервере. Клиент уверен, что говорит с провайдером напрямую.

Звучит просто: «принял, переслал, посчитал». Вся сложность — в том, что ответ модели идёт потоком, и деньги надо посчитать, не разрывая этот поток для клиента. Вот четыре места, где это больно.

1. Считать токены на лету, не ломая поток

Модель отдаёт ответ маленькими кусками (Server-Sent Events). Чтобы списать деньги, надо выловить из потока служебный кусок с расходом (usage) и при этом отдать клиенту всё остальное без задержки. Два подводных камня:

01Пакет рвётся посреди строкиСетевой пакет может оборваться прямо в середине JSON-строки. Если читать поток как попало, склеишь половинки и получишь мусор. Спасает чтение построчно (aiter_lines): библиотека сама дожидается конца строки и только потом отдаёт её дальше.
02Провайдер падает в середине ответаЕсли апстрим начал отдавать ответ и вдруг упал (502), внешний try/except вокруг запроса уже не сработает — поток-то начался. Ошибку надо ловить внутри генератора и отдавать клиенту корректный кусок обрыва, иначе у него просто зависнет «печатает...».

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

async def stream_and_bill(target_url, headers, body, provider):
    usage = {}
    try:
        async with httpx.AsyncClient() as client, client.stream(
            "POST", target_url, content=body, headers=headers
        ) as resp:
            if resp.status_code != 200:
                err = await resp.aread()
                yield f'data: {{"error": "upstream {resp.status_code}"}}\n\n'
                return
            async for line in resp.aiter_lines():
                yield f"{line}\n"                      # клиенту как есть
                if line.startswith("data: ") and line != "data: [DONE]":
                    chunk = parse_json_safe(line[6:])  # частичные строки игнорируем
                    if chunk:
                        collect_usage(usage, chunk, provider)
    except (httpx.ReadTimeout, httpx.NetworkError):
        yield 'data: {"error": "upstream connection dropped"}\n\n'
    finally:
        if usage:
            await asyncio.shield(do_billing(usage))     # спишем, даже если оборвало

2. Мобильные обрывают долгий запрос

Если модель долго думает или медленно печатает, мобильный браузер на iOS глушит фоновый запрос примерно через 25-30 секунд тишины. Пользователь видит ошибку, а деньги при этом могли уже частично списаться — обидно вдвойне.

Лечится «сердцебиением»: если реальных данных от провайдера нет дольше ~18 секунд, я сам подсовываю в поток пустой служебный комментарий. Для браузера соединение остаётся живым, для пользователя ничего не меняется, обрыва нет.

3. Клиент закрыл вкладку посреди генерации

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

4. Асинхронные генерации и возвраты

Картинки и видео часто генерируются не потоком: API возвращает номер задачи, а результат вы потом отдельно спрашиваете. Деньги я придерживаю на старте, по номеру задачи. Если в итоге приходит статус «провалено» — делаю идемпотентный возврат на баланс (идемпотентный значит, что даже если возврат случайно вызвать дважды, деньги вернутся ровно один раз).

Промпт, который собирает каркас этого прокси:

Сделай на FastAPI прокси между фронтом и платным LLM-API, который не отдаёт ключ на клиент и списывает расход с баланса пользователя.
1. Фронт шлёт запросы на мой бэкенд, бэкенд проверяет пользователя и баланс, подставляет настоящий ключ, пересылает провайдеру.
2. Ответ модели идёт потоком (SSE): читай построчно, вынимай usage из нужного куска, не разрывая поток для клиента.
3. Лови ошибку внутри генератора (провайдер может упасть посреди стрима), отдавай клиенту корректный кусок обрыва.
4. Списание и сохранение результата заворачивай в фоновую задачу, чтобы они пережили закрытие вкладки клиентом.
5. Для асинхронных генераций (видео, картинки) холдируй деньги на старте и делай идемпотентный возврат при статусе провала.
Сам вызов провайдера направь через исходящий прокси из прошлого шага.
Итог разделаКлючи живут только на сервере, баланс под контролем, перерасход невозможен — а к самому фронту я не написал ни строчки чужого кода. Он по-прежнему думает, что говорит с провайдером напрямую.
Раздел 05

Одна мысль на всё

Если убрать частности, вся эта конструкция — ответ на один вопрос: куда физически уходит каждый запрос и кто это контролирует. Наружу, к глобальным моделям, — через трубу из нейтральной точки. Внутрь, к тому, что закрыто из Европы и обязано остаться дома, — через трубы в Россию. К платному API — через свой прокси-перехват, чтобы не отдать ключи и не потерять деньги. Сервер в Амстердаме — не самоцель, а просто удачно выбранная точка, из которой все три направления тянутся коротко.

Цифры, которые держат это решение: 42 миллисекунды до пользователя, около 1 миллисекунды до края сети провайдеров, 151 миллисекунда — цена ошибки, если уехать ближе к моделям в США. Ключи — только на сервере, персональные данные — дома. Туннель здесь оказался универсальным инструментом: один и тот же приём, развёрнутый в три стороны, закрывает и доступ к моделям, и доступ к закрытому, и безопасную доставку платного трафика. А сам вызов модели, как обычно, — самая короткая часть всей работы.

Серия 010 · 2026-06-05 · гибридная сеть AI-продукта, разобрано на замерах и граблях
Авторская колонка · выпуск №010 · «Куда идёт трафик»

// Обсуждение

Можно писать анонимно. Укажите email, чтобы получать уведомления об ответах.