fxTunnel

· Часть 8 серии «Пишу свой ngrok на Go: дневник разработки»

Пишу свой ngrok на Go: выжимаем скорость

Безопасность есть, тесты есть, баги починены. Теперь — скорость. fxTunnel работает, но каждое соединение через туннель добавляет задержку.

Контекст

29-30 января. Безопасность есть, тесты есть, баги починены. Теперь — скорость. fxTunnel работает, но каждое соединение через туннель добавляет задержку. Сколько именно и можно ли меньше? Продукт в продакшене, профиль нагрузки понятен — самое время оптимизировать.

sync.Pool — переиспользование буферов

При каждом соединении выделяется буфер для копирования данных (256KB). При сотнях одновременных соединений — это сотни мегабайт, которые создаются и сразу отправляются в сборщик мусора. sync.Pool — встроенный пул объектов в Go. Создал буфер → использовал → вернул в пул. Следующее соединение берёт готовый вместо создания нового.

go
var bufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 256*1024)
        return &buf
    },
}

bufPtr := bufPool.Get().(*[]byte)
defer bufPool.Put(bufPtr)
io.CopyBuffer(dst, src, *bufPtr)

Когда это полезно: много одинаковых объектов с коротким временем жизни. Буферы, временные структуры, кодеки. Для долгоживущих объектов — не подходит, пул может их выбросить при GC. Применил sync.Pool для кодека протокола и UDP-фреймов тоже. Zero-allocation на горячем пути.

Пул yamux-стримов

Когда браузер загружает страницу, он открывает 12+ параллельных запросов (HTML, CSS, JS, картинки). Каждый запрос = открытие нового yamux-стрима = round-trip к клиенту. 12 round-trip'ов = заметная задержка. Решение: при подключении клиента заранее открываю 8 стримов. Когда приходит запрос — беру готовый из пула. Нет round-trip, нет задержки. Пул пуст — открываю новый как fallback. Burst-загрузка страницы стала ощутимо быстрее.

TCP-тюнинг

  • TCP_NODELAY — отключает алгоритм Nagle, который копит мелкие пакеты. Для веба и SSH — критично.
  • Буферы сокетов 512KB — увеличил с дефолтных для лучшей пропускной способности.
  • yamux window 4MB — увеличил с 1MB. Больше данных «в полёте» = лучше утилизация канала.

Happy Eyeballs

Помните IPv4/IPv6 из третьей статьи? Тогда был sequential fallback: сначала IPv4, потом IPv6. Работает, но если IPv4 не отвечает — ждём таймаут (секунды!) прежде чем попробовать IPv6. Теперь — Happy Eyeballs: запускаем IPv4 сразу, IPv6 через 50ms. Кто первый ответит — того и берём. Максимум 50ms задержки вместо секунд. Плюс кэш адресов: при создании туннеля один раз проверяем, на каком адресе слушает сервис, и запоминаем. Дальше все соединения идут напрямую.

Мелочи, которые складываются

  • Atomic counter вместо crypto/rand для ID соединений. crypto/rand дорогой — обращается к ОС за энтропией. Для внутренних ID хватит счётчика.
  • io.CopyBuffer вместо io.Copy — с pooled-буферами. io.Copy выделяет 32KB каждый раз. io.CopyBuffer использует наш буфер из пула.

Prometheus: мониторинг

Как понять, что оптимизации работают? Метрики.

go
r.Handle("/metrics", promhttp.Handler())

Одна строка — и есть endpoint /metrics с десятками метрик: количество запросов, латентность (гистограмма), горутины, память. Добавил кастомные: активные туннели, активные соединения, латентность по роуту. Подключил Grafana — видно всё в реальном времени.

Install-скрипт

Бонус: bash-скрипт для установки CLI-клиента. Определяет ОС и архитектуру, скачивает правильный бинарник:

bash
curl -fsSL https://myserver/install.sh | sh

Одна команда — клиент установлен.

Итог

29-30 января: - sync.Pool для буферов — zero-allocation - Пул yamux-стримов — быстрая burst-загрузка - TCP_NODELAY + большие буферы — минимальная латентность - Happy Eyeballs — 50ms fallback вместо секунд - Prometheus — мониторинг в реальном времени - Install-скрипт — установка одной командой

3e87fcb perf(proxy): optimize tunnel proxying for minimal overhead
d16564a perf(server): add yamux stream pool
74511e7 feat: add Prometheus metrics endpoint
f282bf7 perf(client): cache resolved local address
875df70 perf(client): race IPv4/IPv6 in parallel
a0f220a feat(web): add install script with platform picker

В следующей части: инспекция трафика — делаем свой мини-Charles. RingBuffer, SSE-стриминг и каждый HTTP-запрос в реальном времени.

Все части дневника разработки