CloudPub 2.x: что под капотом
За время работы над второй версией мы переписали много кода и добавили кучу интересных штук. Сегодня расскажем о самых крутых технических решениях, которые делают CloudPub 2.x быстрее и стабильнее.
О чём поговорим
Хотим поделиться четырьмя вещами, которые нас особенно радуют:
ProtoBuf вместо Serde Вместо Serde для описания протокола теперь используется ProtoBuf. Главное преимущество — обратная и прямая совместимость версий протокола. Плюс трафика стало меньше, скорость выросла.
Мультиплексирование TCP Теперь один TCP канал обслуживает много соединений сразу. Меньше накладных расходов, меньше задержек.
Back pressure Когда много данных идёт через один канал, важно не допустить коллапса. Сделали умную систему управления нагрузкой.
Справедливые очереди Один жадный клиент больше не может забрать всю пропускную способность. Ресурсы делятся честно между всеми.
От структур к схемам
В первой версии CloudPub мы наивно полагали, что достаточно описать протокол в виде структур, а типизация языка и механизм сериализации сделают всю грязную работу.
Мы выбрали JSON для сериализации — он читаемый, упрощает отладку и обеспечивает совместимость между Rust, TypeScript и Python. При небольшом количестве событий накладные расходы были приемлемыми.
Но в реальности оказалось иначе. Некоторые пользователи создавали множество TCP соединений, и каждое требовало передачи минимум двух сообщений — установка канала и его закрытие. При сотнях соединений JSON начинал заметно тормозить.
Кроме того, поддержка старых версий превратилась в боль.
Использование serde даже с дополнительной аннотацией превратило код в настоящее минное поле. Несмотря на строгую типизацию Rust, приходилось постоянно помнить, какие поля могут отсутствовать, какие имеют значения по умолчанию, а какие вообще не сериализуются.
Осторожно, внутри код на Rust
// JSON сериализация с проблемами
use serde::{Deserialize, Serialize};
use serde_json;
// Структура для приёма данных
#[derive(Debug, Serialize, Deserialize)]
struct Msg {
// 1) ОШИБКА: нет #[serde(default)] → если в данных пропущено "id", десериализация упадёт
id: u32,
// 2) ОШИБКА: нет Option<String> → если в данных пропущено "name", десериализация упадёт
name: String,
// 3) enum-поле: неизвестный вариант также вызовет ошибку
status: Status,
}
#[derive(Debug, Serialize, Deserialize)]
enum Status {
Ok,
Error,
}
fn main() {
// Пример 1: пропущено "id"
let j1 = r#"{ "name":"Alice", "status":"Ok" }"#;
let r1 = serde_json::from_str::<Msg>(j1);
assert!(r1.is_err());
// ← Ошибка: missing field `id`
// Пример 2: пропущено "name"
let j2 = r#"{ "id":1, "status":"Error" }"#;
let r2 = serde_json::from_str::<Msg>(j2);
assert!(r2.is_err());
// ← Ошибка: missing field `name`
// Пример 3: неизвестный вариант enum
let j3 = r#"{ "id":5, "name":"Bob", "status":"Unknown" }"#;
let r3 = serde_json::from_str::<Msg>(j3);
assert!(r3.is_err());
// ← Ошибка: unknown variant `Unknown`, expected `Ok` or `Error`
}
С ProtoBuf эти проблемы ушли. Схема явно описана в .proto файлах, backward и forward compatibility работают из коробки. Размер сообщений уменьшился в 2-3 раза, скорость парсинга выросла значительно по сравнению с serde.
Мультиплексирование TCP
Наша первая версия основывалась на RatHole. Но там каждый канал данных требовал отдельного TCP соединения — как между клиентом и сервером, так и между сервером и целевым сервисом.
Всё работало хорошо, пока было мало клиентов. И даже много клиентов не проблема, пока они ведут себя прилично. Но потом мы словили несколько L3/L7 DDoS атак.
Представьте: злоумышленник открывает тысячи HTTP соединений к вашему сервису через CloudPub, но не отправляет данные. Каждое "зависшее" соединение держит открытыми два TCP канала — от клиента к серверу и от сервера к целевому сервису. При тысячах таких соединений сервер просто задыхается от количества файловых дескрипторов и контекстов соединений.
DDoS атака на CloudPub (нажмите, чтобы увидеть)
Плюс операционная система начинает тратить кучу времени на переключение между контекстами соединений. А сетевые буферы разбухают до гигабайтов, память заканчивается.
Мультиплексирование радикально изме нило ситуацию. Теперь все каналы данных идут через одно TCP соединение между клиентом и сервером. Внутри этого соединения мы создаём виртуальные потоки — каждый со своим идентификатором.
Когда пользователь открывает HTTP соединение, мы не создаём новый TCP канал. Вместо этого отправляем пакет "открыть поток №1234" по существующему соединению. Данные помечаются идентификатором потока и передаются пачками.
Результат? Тысяча HTTP соединений = один TCP канал + тысяча лёгких виртуальных потоков. Файловых дескрипторов нужно в сотни раз меньше, памяти тоже. Переключение контекста почти отсутствует.
DDoS атаки стали гораздо менее болезненными. Злоумышленник может открыть тысячи соединений, но сервер видит это как один TCP канал с множеством потоков внутри.
«Задний напор»
Как только мы реализовали первую версию мультиплексирования, сразу же столкнулись с новой проблемой. TCP имеет встроенный механизм контроля перегрузки (backpressure), а наш механизм его не имел.
Суть проблемы: агент передаёт данные быстро, а клиент принимает их медленно. Данные накапливаются в очередях в оперативной памяти сервера, а клиенты начинают вести себя самым странным образом — например, зависают или отпадают по таймауту.
Первой мыслью было использовать HTTP/2 протокол, который имеет этот механизм. Но отказались — только закончили оптимизацию WebSocket соединения, и пилить новый (уже четвертый по счёту) транспорт было выше наших сил.
А главное, нам нужен контроль не только внутри одного TCP соединения но и балансировка канала между разными агентами. Так что, раскурив спеки, мы реализовали свою версию Window-Based Flow Control, которая сразу себя неплохо показала.
Как это работает
Представьте, что вы стримите 4K видео через интернет с ограниченной скоростью. Если пытаться отправлять данные быстрее, чем позволяет канал, буферы переполнятся, начнутся потери пакетов и задержки.
Поэтому мы используем "скользящее окно" — отправляем только определённый объём данных (например, 1 МБ), а затем ждём подтверждения, что эти данные успешно получены и обработаны. Только после этого отправляем следующую порцию.
Если получатель обрабатывает данные медленно (например, из-за слабого процессора или загруженной сети), он не отправляет подтверждение, и мы приостанавливаем передачу. Как только он справится с текущей порцией и будет готов принимать дальше — отправит сигнал, и окно "проскользит" вперёд для новой порции данных.
Справедливые очереди
Первая реализация flow control показала себя неплохо, но недостаточно хорошо. Она работала по принципу "кто первый встал, того и тапки" — обычная очередь FIFO (First In, First Out). Получается, агрессивные потоки просто забирали всю пропускную способность, оставляя остальных ни с чем.
Особенно это заметили пользователи с множеством одновременных соединений. Запускают десяток загрузок файлов — и всё, обычный веб-сёрфинг пр евращается в мучение. Пинг подскакивает до небес, страницы грузятся вечность.
В итоге пришлось добавлять механизм справедливости:
- Приоритеты для сигнального канала — служебные сообщения всегда идут первыми
- Fair queuing для каналов данных — каждый поток получает равную долю пропускной способности, независимо от того, сколько данных он хочет передать
Теперь один жадный поток не может монополизировать весь канал. Ресурсы делятся честно между всеми активными потоками.
Что дальше
Все эти технологии работают сообща и делают CloudPub 2.x значительно более стабильным и производительным решением.
Конечно, это только вершина айсберга. В CloudPub 2.x ещё много интересных решений — расскажем, как мы вместо nginx взяли pingora, компилировали wirefilter в WASM, и запускали агент на странном железе. Но об этом в следующих постах.
Кстати, код клиента открыт и доступен на https://github.com/ermak-dev/cloudpub — можете посмотреть, как всё это работает изнутри.
Если у вас есть вопросы или идеи, как улучшить CloudPub, присоединяйтесь к нашей группе поддержки — всегда рады обсудить!