Куда
идёт
трафик
Бэкенд в Европе, пользователи и часть данных в России, модели разбросаны по всему миру. Как сшить это туннелями, не отдать наружу ключи от платных API и не подвесить интерфейс лишними миллисекундами. Практический разбор на реальной архитектуре.
PROMPT: скопировали, отдали своему агенту, получили настройку, не переписывая руками. Технические термины объясняю по ходу в блоках «Простым языком».Где стоит бэкенд и почему Амстердам
Первое решение в таком продукте — самое скучное и самое влияющее: на какой физической машине крутится бэкенд. От него зависит, будет ли интерфейс отвечать «сразу» и сможете ли вы вообще дотянуться до моделей и до данных. Меня тянули в три стороны: пользователи в Москве хотят отзывчивый интерфейс; глобальные LLM-провайдеры доступны не из любой точки; часть данных по закону обязана лежать в России. Один сервер должен был ужиться со всеми тремя.
Я держу вычислительный узел в Амстердаме. Это не «потому что Европа красивая» — это про сеть. Разберу по цифрам, потому что именно здесь чаще всего пишут ерунду.
flowchart LR
U["Пользователь в Москве"] -->|"42 мс"| AMS["Бэкенд в Амстердаме"]
AMS -->|"около 1 мс, через CDN"| LLM["Глобальные LLM-провайдеры"]
AMS -->|"туннель наружу"| EXT["Внешний узел, выход в мир"]
AMS -->|"туннель внутрь"| RU["Сервисы и база ПД в России"]
Миф про «пинг до OpenAI»
Самое частое заблуждение: «надо сидеть ближе к серверам OpenAI и Anthropic в США, там же модели». Я замерил с моего сервера пинг до их API — и до Anthropic, и до OpenAI получилось около 1 миллисекунды. Не 80, не 100, а единицы. Потому что API больших провайдеров спрятаны не в одном далёком датацентре, а за глобальной сетью доставки, у которой точка присутствия стоит прямо в Амстердаме, на том же сетевом хабе, что и мой сервер.
Вывод из этого замера: сетевой задержки до провайдера у меня практически нет, и она не зависела бы от выбора между Амстердамом и, скажем, Франкфуртом. Этот фактор из уравнения можно вычеркнуть. А вот что в уравнении осталось — расстояние до пользователя.
А вот это уже важно: расстояние до пользователя
Пинг от моего сервера до Москвы я замерил — 42 миллисекунды, стабильно. Для веб-интерфейса это «отвечает сразу»: на такой задержке человек не замечает, что между ним и сервером лежит пол-Европы.
Теперь контрпример. Если поставить тот же бэкенд в США (соблазн «ближе к моделям» как раз туда и тянет), каждый обмен между московским пользователем и сервером начинает прыгать через Атлантику. Я замерил с Амстердама пинг до узла на западном побережье США — 151 миллисекунда. Каждое действие в интерфейсе — это не одна поездка туда-обратно, а несколько. Разница между 42 и 150 миллисекундами — это разница между «интерфейс живой» и «интерфейс подлагивает на каждый клик», и пользователь это чувствует, даже если не может объяснить словами.
Туннель наружу: один прокси на все инструменты
С сервера и с локальных машин мне нужно ходить во внешние API: вызывать модели, тянуть пакеты, гонять автономных CLI-агентов. Казалось бы, просто. Но инструментов десяток — curl, скрипты на Python, Node, сами агенты — и у каждого свой способ прописать прокси. Настроишь в одном месте, забудешь в пяти других, при обновлении всё слетит. Мне нужен был один перехват на всю машину, чтобы новый инструмент не требовал вообще никакой настройки.
Собирается это из трёх кирпичей: туннель, перехватчик и подмена сетевых вызовов.
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
-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 подсовывает программе свою версию этих функций до того, как она доберётся до системных. Программа уверена, что звонит напрямую, а звонок на деле уходит в трубу. Менять код программы не нужно вообще.Три грабли, на которых это молча ломается
Сам рецепт короткий, а вот что в нём неочевидно и где он подводит без предупреждения:
proxy_dns в конфиге заставляет резолвить имя на дальнем конце трубы, а не дома. Без него труба есть, а адрес сервиса вы всё равно спрашиваете в открытую.LD_PRELOAD работает только для программ с динамической линковкой. Бинарь, собранный статически (типичный случай — утилиты на Go без C-зависимостей), подменить нельзя: он пойдёт в сеть напрямую и упрётся в таймаут. Лечится либо явным запуском через proxychains4 <бинарь>, либо настройкой прокси внутри самого инструмента.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 до автономного агента, выходит наружу через одну трубу. Новый инструмент не требует ни строчки настройки: он просто наследует общий перехват.Туннели внутрь: дотянуться до закрытого
Бэкенд в Амстердаме, а часть мира осталась дома. Два разных требования тянут трафик обратно в Россию, и оба надо закрыть.
Первое: некоторые российские сервисы просто не отвечают на соединение из-за рубежа — для них европейский IP «чужой», и они молча отбрасывают запрос. Чтобы передавать данные в такой сервис (а это бывает нужно, когда из ЕС он недоступен), запрос должен прийти будто бы изнутри страны. Второе: персональные данные пользователей по закону о локализации обязаны храниться на территории России. Бэкенд в Европе — а база с этими данными должна стоять дома.
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 и обычный прокси — вся география спрятана в туннелях, и приложение про неё не знает.Биллинг-прокси: встать между фронтом и платным API
Третье направление трафика — к платным моделям. Здесь типичная ситуация: я беру готовый чат-интерфейс (их сейчас много, написаны под прямой вызов LLM-API) и хочу пустить его в работу, но с двумя жёсткими условиями. Ключ провайдера нельзя отдавать на клиент: любой откроет консоль браузера и заберёт его за минуту. И пользователю нельзя позволить жечь мой баланс без счёта: платный API биллится по токенам, один человек за ночь высадит всё.
Решение — поставить бэкенд «человеком посередине». Фронт настраивается слать запросы не провайдеру напрямую, а на мой сервер. Сервер проверяет пользователя и баланс, подставляет настоящий ключ, пересылает запрос провайдеру и на ходу читает из ответа расход. Сам вызов наружу уходит, кстати, через ту самую трубу из раздела 02 — вот вам и связь всех трёх направлений.
flowchart TD
FE["Готовый чат-фронт"] --> BE["Биллинг-прокси на бэкенде"]
BE --> AUTH{"Пользователь и баланс в порядке?"}
AUTH -->|"нет"| STOP["Отказ, запрос наружу не уходит"]
AUTH -->|"да"| KEY["Подставляем настоящий ключ"]
KEY --> PROV["Запрос провайдеру, через трубу наружу"]
PROV --> STREAM["Читаем поток, вынимаем расход"]
STREAM --> BILL["Списываем с баланса"]
STREAM --> CLIENT["Поток идёт клиенту без разрыва"]
Звучит просто: «принял, переслал, посчитал». Вся сложность — в том, что ответ модели идёт потоком, и деньги надо посчитать, не разрывая этот поток для клиента. Вот четыре места, где это больно.
1. Считать токены на лету, не ломая поток
Модель отдаёт ответ маленькими кусками (Server-Sent Events). Чтобы списать деньги, надо выловить из потока служебный кусок с расходом (usage) и при этом отдать клиенту всё остальное без задержки. Два подводных камня:
aiter_lines): библиотека сама дожидается конца строки и только потом отдаёт её дальше.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. Для асинхронных генераций (видео, картинки) холдируй деньги на старте и делай идемпотентный возврат при статусе провала.
Сам вызов провайдера направь через исходящий прокси из прошлого шага.
Одна мысль на всё
Если убрать частности, вся эта конструкция — ответ на один вопрос: куда физически уходит каждый запрос и кто это контролирует. Наружу, к глобальным моделям, — через трубу из нейтральной точки. Внутрь, к тому, что закрыто из Европы и обязано остаться дома, — через трубы в Россию. К платному API — через свой прокси-перехват, чтобы не отдать ключи и не потерять деньги. Сервер в Амстердаме — не самоцель, а просто удачно выбранная точка, из которой все три направления тянутся коротко.
Цифры, которые держат это решение: 42 миллисекунды до пользователя, около 1 миллисекунды до края сети провайдеров, 151 миллисекунда — цена ошибки, если уехать ближе к моделям в США. Ключи — только на сервере, персональные данные — дома. Туннель здесь оказался универсальным инструментом: один и тот же приём, развёрнутый в три стороны, закрывает и доступ к моделям, и доступ к закрытому, и безопасную доставку платного трафика. А сам вызов модели, как обычно, — самая короткая часть всей работы.
// Обсуждение
Можно писать анонимно. Укажите email, чтобы получать уведомления об ответах.