Каталог оптимизаций logical airgap: что, зачем и в каком порядке ускорять
Прежде чем собирать MVP, я провёл предварительный анализ: разобрал паттерн logical airgap на компоненты и по каждому из них выписал, что именно можно оптимизировать, каким механизмом и какого эффекта ждать. Получился каталог примерно из сорока приёмов — от write_vectored до kernel bypass, — часть из которых была тут же проверена на маленьких Rust-прототипах.
Тогда это был анализ «вслепую»: цифр ещё не было, приоритеты расставлялись по интуиции. Сейчас цифры есть, и сопоставление предварительного каталога с результатами MVP оказалось поучительнее, чем сам каталог. Спойлер: самые красивые оптимизации из списка не дали бы ничего.
Какой вариант разрыва разбираем
logical airgap — это не одна конкретная реализация, а семейство подходов и прямого взаимодействия между сегментами нет ни в одном из них, но механика разрыва у каждого своя. Полный разбор — от аппаратного диода до экзотики — я вынес в отдельную статью: «Анализ вариантов реализации паттерна logical airgap». Здесь — короткая выжимка тех четырёх вариантов, из которых выбирался MVP:
| Вариант | Как устроен разрыв | Цена |
|---|---|---|
| Очередь / брокер (Redis, RabbitMQ, Kafka) | Одна сторона кладёт запрос, другая забирает и возвращает ответ по correlation ID | Ручная склейка запроса и ответа, TTL, брокер как узкое место |
| Контролируемый RPC-канал (gRPC + mTLS) | Единственный регламентированный вызов с валидацией схемы и взаимной аутентификацией | Проектирование и версионирование контракта, управление сертификатами |
| Файловый обмен / data diode | Источник пишет пакет в промежуточное хранилище или в однонаправленный канал | Высокая задержка, синхронный ответ даётся тяжело |
| Протокольный прокси с инспекцией | Соединение терминируется и пересобирается: внутрь идёт новый «чистый» запрос | Прокси должен понимать протокол и сам становится узким местом |
Дальнейший разбор построен на первом варианте — очереди на Redis. Причин две. Во-первых, предварительный анализ делался именно под него: он самый простой в сборке и потому даёт честную нижнюю границу цены разрыва. Во-вторых, на нём же затем был собран MVP, а значит, каталог приёмов и измерения относятся к одной и той же реализации и их можно сопоставлять напрямую.
Конкретно разбирается такая цепочка: клиент → producer (HTTP-точка входа) → Redis-очередь → consumer → целевой сервис и ответ обратно по персональной очереди с correlation ID. Насколько выводы переносятся на боевой gRPC-канал — отдельный разговор в конце статьи.
Где вообще возникают накладные расходы
logical airgap убирает прямой сетевой маршрут между внешним и внутренним сегментом. Вместо одного соединения «клиент → цель» появляется цепочка из четырёх участников, и на каждом есть своя цена:
Предварительный анализ строился ровно по этой карте: для каждой стрелки — свой список приёмов. Ниже я собрал их так, как они в итоге сгруппировались.
Шесть сквозных принципов
Через все материалы анализа проходят одни и те же шесть идей. Конкретные приёмы — это их частные случаи, и полезнее держать в голове именно принципы.
| Принцип | Что он означает на практике |
|---|---|
| Не копируй | Данные, которые нужно просто перебросить, не должны попадать в память приложения: write_vectored, splice, sendfile, передача владения буфером по цепочке |
| Не аллоцируй в hot path | Пул буферов, arena-аллокаторы, Vec::with_capacity, неинициализированная память вместо vec![0; n] |
| Амортизируй системные вызовы | Batching, pipelining, vectored I/O, io_uring — один syscall на пачку работы вместо одного на единицу работы |
| Не блокируй | Lock-free каналы, атомарные счётчики вместо Mutex, SO_REUSEPORT вместо общего акцептора, логирование в отдельный поток |
| Держи данные рядом с ядром CPU | CPU affinity, NUMA-awareness, RPS/RFS, HugePages — чтобы не терять L1/L2-кэш на переключениях |
| Предсказуемость важнее пика | Backpressure, ограниченные каналы, circuit breaker, DLQ — система должна деградировать управляемо, а не падать по OOM |
Последний принцип — единственный, который не про скорость. Но он делает остальные пять осмысленными: разгонять контур, у которого нет ограничителя, — способ красиво доехать до отказа.
Слой 1. Точка входа: producer
Наивная реализация упирается в три вещи: стоимость установки соединения, единственный поток-акцептор и аллокации на каждый принятый пакет.
Keep-alive вместо соединения на запрос. Самая дешёвая оптимизация из всех: одно TCP-соединение обслуживает много запросов, и хендшейк перестаёт быть частью стоимости запроса.
fn handle_http_client(mut stream: TcpStream) {
let mut buffer = [0; 4096];
// Цикл позволяет обрабатывать много запросов в одном TCP-соединении
loop {
match stream.read(&mut buffer) {
Ok(0) => break, // Клиент закрыл соединение
Ok(_) => {
let response = b"HTTP/1.1 200 OK\r\n\
Content-Type: application/json\r\n\
Content-Length: 24\r\n\
Connection: keep-alive\r\n\r\n\
{\"x\": 7, \"y\": 8, \"z\": 9}";
if stream.write_all(response).is_err() { break; }
}
Err(_) => break,
}
}
}
Масштабирование по ядрам через SO_REUSEPORT. Классическая схема Master-Worker — один поток принимает соединения и раздаёт их воркерам через канал — упирается в акцептор. SO_REUSEPORT убирает эту точку: каждый воркер сам слушает тот же порт, распределением занимается ядро.
use net2::unix::UnixTcpBuilderExt;
let listener = TcpBuilder::new_v4()?
.reuse_port(true)? // Несколько потоков слушают один порт
.bind("0.0.0.0:8080")?
.listen(1024)?;
Итоговая схема точки входа из анализа выглядела так: N потоков по числу ядер, в каждом — свой mio::Poll и свой пул буферов, чтение в буфер из пула, отправка в брокер через write_vectored, возврат буфера в пул.
Пул буферов вместо аллокаций. Правило было сформулировано жёстко: никогда не делать vec![0; len] внутри цикла обработки событий.
struct BufferPool {
pool: VecDeque<Vec<u8>>,
buf_size: usize,
}
impl BufferPool {
fn get(&mut self) -> Vec<u8> {
self.pool.pop_front().unwrap_or_else(|| vec![0u8; self.buf_size])
}
fn release(&mut self, mut buf: Vec<u8>) {
buf.clear(); // Очищаем, но сохраняем capacity
self.pool.push_back(buf);
}
}
Развитие той же идеи — arena-аллокатор на запрос: выделить блок в начале обработки, раздавать мелкие объекты сдвигом указателя, в конце «освободить» всё сбросом указателя. Плюс парсинг заголовков без аллокаций: искать нужные байты (Content-Length) прямо в буфере чтения и передавать дальше срез, а не строку. Для тяжёлого JSON - simd-json с ожидаемым ускорением парсинга в 3–5 раз.
Слой 2. Запись в брокер
Здесь сосредоточена самая плотная часть каталога, потому что именно тут запрос пересекает границу сегментов.
Прямая генерация протокола + vectored write. Вместо того чтобы склеивать заголовок команды и полезную нагрузку в новый буфер, ядру передаётся список указателей:
fn send_to_redis_vectored(stream: &mut TcpStream, queue: &str, data: &[u8])
-> std::io::Result<usize>
{
let prefix = format!("*3\r\n$5\r\nLPUSH\r\n${}\r\n{}\r\n${}\r\n",
queue.len(), queue, data.len());
let bufs = [
IoSlice::new(prefix.as_bytes()), // Заголовок команды
IoSlice::new(data), // Данные — без копирования
IoSlice::new(b"\r\n"),
];
stream.write_vectored(&bufs) // Один syscall вместо трёх
}
Ровно тот же приём применим и на выходе consumer'а: заголовки исходящего HTTP-запроса и тело отправляются во внутренний сервис двумя IoSlice без склейки в памяти.
Батчинг записи. Протокол RESP позволяет передавать переменное число аргументов, поэтому сотня сообщений уходит одной командой:
fn format_batch_lpush(queue: &str, items: &[Vec<u8>]) -> Vec<u8> {
let mut cmd = Vec::new();
cmd.extend_from_slice(format!("*{}\r\n", items.len() + 2).as_bytes());
cmd.extend_from_slice(b"$5\r\nLPUSH\r\n");
cmd.extend_from_slice(format!("${}\r\n{}\r\n", queue.len(), queue).as_bytes());
for item in items {
cmd.extend_from_slice(format!("${}\r\n", item.len()).as_bytes());
cmd.extend_from_slice(item);
cmd.extend_from_slice(b"\r\n");
}
cmd
}
Отдельный вариант — адаптивный батчинг: producer ждёт несколько микросекунд, чтобы собрать запросы от разных клиентов в одну команду. По сути алгоритм Нагла на уровне приложения — размен небольшой задержки на пропускную способность.
Ограниченный канал как встроенный backpressure. Между сетевыми воркерами и выделенным потоком записи ставится sync_channel. Если брокер не успевает, канал заполняется и приём новых данных сам собой притормаживает — вместо неограниченного роста памяти.
// Внутренний пре-буфер перед брокером
let (tx, rx) = sync_channel::<Vec<u8>>(10_000);
thread::spawn(move || {
let mut redis = TcpStream::connect("127.0.0.1:6379").expect("Redis down");
redis.set_nodelay(true).unwrap();
for data in rx {
// ... формирование RESP и write_all
}
});
Долгоживущие соединения. Каждый инстанс держит одно постоянное соединение с брокером: нет хендшейков на каждый запрос. Обратная сторона — при тысячах инстансов упираемся в maxclients, так что приём не бесплатный.
Unix domain sockets вместо loopback. Если приложение и брокер на одной машине, TCP через loopback — это лишний проход через сетевой стек ядра. Предварительная оценка выигрыша в +20–25% пропускной способности:
# redis.conf
unixsocket /tmp/redis.sock
unixsocketperm 700
В коде TcpStream::connect меняется на UnixStream::connect.
Конфигурация самого брокера. Очередь — это не хранилище, поэтому персистентность отключается, а поведение при переполнении делается явным:
save "" # Без RDB-снимков
appendonly no # Без AOF
maxmemory 2gb
maxmemory-policy noeviction # Лучше вернуть ошибку продюсеру,
# чем молча потерять данные
tcp-backlog 65535
timeout 0
tcp-keepalive 300
lazyfree-lazy-eviction yes # Фоновое освобождение памяти
lazyfree-lazy-expire yes
Выбор noeviction здесь принципиален: для logical airgap молчаливая потеря сообщения хуже явной ошибки на входе.
Слой 3. Чтение и обработка: consumer
Надёжное чтение без опроса. LPOP теряет данные, если воркер упал между чтением и обработкой. BRPOPLPUSH атомарно перекладывает элемент в очередь обработки, и при падении сообщение остаётся видимым:
// BRPOPLPUSH gap1 gap1_processing 0 — ждать бесконечно
let cmd = format!(
"*4\r\n$10\r\nBRPOPLPUSH\r\n${0}\r\n{1}\r\n${2}\r\n{3}\r\n$1\r\n0\r\n",
self.main_queue.len(), self.main_queue,
self.proc_queue.len(), self.proc_queue
);
self.stream.write_all(cmd.as_bytes())?;
Побочный эффект тут важнее основного: поток «спит» внутри системного вызова чтения, а не крутит цикл опроса. Это нулевая загрузка CPU в простое — и, что существеннее, отсутствие паразитной нагрузки на брокер.
Watcher для потерянных задач. Отдельный поток раз в 30 секунд возвращает зависшие элементы из очереди обработки обратно в основную:
// RPOPLPUSH gap1_processing gap1
let cmd = format!(
"*3\r\n$10\r\nRPOPLPUSH\r\n${0}\r\n{1}\r\n${2}\r\n{3}\r\n",
proc_q.len(), proc_q, main_q.len(), main_q
);
Цена этого механизма — возможная повторная обработка: если воркер просто тормозил, задача уйдёт в работу дважды. Отсюда требование идемпотентности и приём с ключами идемпотентности — хранить хеш успешно обработанного запроса 5–10 минут и проверять его перед обработкой.
Batch-ACK. Подтверждение каждого сообщения отдельной командой при высоком RPS само становится нагрузкой. Вместо этого воркер накапливает сотню идентификаторов и удаляет их из очереди обработки одной командой.
Circuit breaker и адаптивные таймауты. Если внутренний сервис лёг, воркеры начинают бесконечно ретраить, забивая очередь и логи. Предохранитель размыкает цепь после серии таймаутов — например, пять подряд упавших запросов выключают направление на 30 секунд, — и защищает внутренний контур от «грохочущего стада» при восстановлении. Динамический таймаут дополняет это: consumer считает скользящее среднее по времени ответа последних запросов и сокращает таймаут, когда сеть начинает деградировать, чтобы быстрее возвращать задачи в очередь.
Dead Letter Queue. «Битые» или неотправляемые пакеты уезжают в отдельный список, чтобы не блокировать основную очередь и остаться доступными для разбора.
Zero-copy при простом транзите. Если consumer не заглядывает внутрь данных, а просто перебрасывает их между сокетами, данные вообще не обязаны попадать в память процесса:
let bytes_moved = unsafe {
splice(
client_fd, // откуда
std::ptr::null_mut(),
pipe_write_fd, // в промежуточный пайп (требование Linux)
std::ptr::null_mut(),
len,
SPLICE_F_MOVE | SPLICE_F_NONBLOCK
)
};
Тот же приём в обратную сторону — sendfile / copy_file_range для больших ответов внутреннего сервиса.
Важная оговорка: splice работает ровно до тех пор, пока нам не нужно содержимое. Как только на границе появляется валидация схемы, фильтрация заголовков или проверка подписи — данные обязаны быть в памяти, и zero-copy на этом участке отменяется. Для logical airgap это не мелочь: инспекция трафика и есть смысл существования шлюза.
Слой 4. Транспорт и TLS
Внутри контура связь идёт по mTLS, снаружи — по TLS 1.3. Оптимизации здесь двух видов.
Сокращение хендшейков. TCP Fast Open отправляет данные вместе с SYN, TLS 1.3 0-RTT позволяет клиенту получить ответ на один RTT быстрее. Приём рискованный (0-RTT не защищён от повторов), поэтому применим только к идемпотентным запросам.
Горячая ротация сертификатов. Перезапуск процесса ради нового сертификата рвёт тысячи активных сессий. Вместо этого конфигурация подменяется атомарно, новые соединения берут новые ключи, старые доживают на прежних:
impl TlsProvider {
fn get_config(&self) -> Arc<ClientConfig> {
self.config.read().unwrap().clone() // Быстрое клонирование Arc
}
fn reload(&self) {
let new_cfg = create_mtls_config(/* новые данные */);
let mut w = self.config.write().unwrap();
*w = new_cfg; // Атомарная подмена
}
}
Это оптимизация не скорости, а доступности: она снимает необходимость в окне обслуживания. И она же делает возможной другую практику из анализа — выпускать сертификаты на 24 часа: украденный ключ протухает быстрее, чем им успевают воспользоваться.
Слой 5. Логи и метрики
Логирование — типичная ловушка высоконагруженного асинхронного сервиса: одна синхронная запись в медленный диск останавливает весь цикл событий. Решение — выделенный поток-писатель и ограниченный канал:
// Bounded channel: при переполнении продюсеры притормозят (backpressure)
let (tx, rx): (SyncSender<LogMessage>, Receiver<LogMessage>) = sync_channel(10_000);
thread::spawn(move || {
let mut file = OpenOptions::new().append(true).create(true)
.open("airgap.log").unwrap();
for msg in rx {
let line = format!("[{}] [{}] {}\n", prefix(msg.level), msg.module, msg.body);
let _ = file.write_all(line.as_bytes());
}
});
Воркер «стреляет» сообщением через try_send и немедленно возвращается к сетевым событиям. Ограниченный размер канала защищает от сценария, когда приложение начинает генерировать миллионы ошибок и съедает всю память.
Метрики — по тому же принципу «без блокировок»: атомарные счётчики и отдельный поток, отдающий их в формате Prometheus.
struct Metrics {
total_requests: AtomicU64,
active_connections: AtomicU64,
}
// В горячем пути
metrics.total_requests.fetch_add(1, Ordering::Relaxed);
Ключевые сигналы, которые считались обязательными:
- длина очереди — главная метрика здоровья: растёт — значит consumer не успевает за producer;
slowlogброкера — если запись в очередь занимает больше 1 мс, проблема в сети или в CPU;- число активных соединений — рост может означать как DDoS, так и утечку дескрипторов.
Пороги для алертов оттуда же: длина очереди больше 10 000 дольше пяти минут, активных соединений больше 5 000, сервис недоступен.
Слой 6. Сборка и рантайм
Самые дешёвые в реализации оптимизации: их стоимость — несколько строк конфигурации.
[profile.release]
lto = true # Оптимизация между модулями на этапе линковки
codegen-units = 1 # Максимальная оптимизация кода
panic = 'abort' # Меньше размер бинарника, нет раскрутки стека
Дальше — PGO: собрать инструментированную версию, прогнать через неё реальный профиль нагрузки, пересобрать с учётом собранной статистики. Ожидаемый прирост в анализе — до 15% за счёт того, что компилятор перестаёт гадать, какие ветки горячие.
Рантайм-часть — сведение образа к одному статическому бинарнику:
FROM scratch
COPY --from=builder /app/target/release/airgap-bridge /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
Это одновременно и оптимизация (быстрый старт, минимальный размер), и hardening: в образе нет шелла, пакетного менеджера и вообще ничего, чем можно воспользоваться после компрометации.
Слой 7. Ядро и железо
Приёмы, которые дают наибольшие цифры и требуют наибольших усилий.
| Приём | Механизм | Ожидание из анализа |
|---|---|---|
io_uring вместо epoll | Разделяемые кольцевые буферы с ядром, без syscall на событие | −20–40% нагрузки на CPU |
CPU pinning (sched_setaffinity) | Поток жёстко привязан к ядру, не теряет L1/L2-кэш | +15–25% |
| HugePages (2 МБ / 1 ГБ) | Меньше промахов TLB при работе с большими буферами | +5–10% к скорости работы с памятью |
| RPS/RFS | Пакеты одной TCP-сессии обрабатываются тем же ядром, что и поток | Попадание в кэш вместо его «размывания» |
Interrupt coalescing (ethtool -C) | Карта прерывает CPU не на каждый пакет, а по накоплении N пакетов или T мкс | Меньше переключений контекста ценой задержки |
SO_BUSY_POLL | Поток опрашивает карту вместо ухода в сон | Минимальная задержка ценой ядра, занятого на 100% |
| AF_XDP / DPDK | Пакеты из драйвера попадают прямо в память приложения, минуя стек ядра | Задержки уровня микросекунд |
Shared memory (/dev/shm) | Брокер хранит только оффсеты, данные лежат в общем кольцевом буфере | Нулевое копирование между процессами |
Здесь важно видеть не только цифры в правой колонке, но и характер платы. Три последних строки — это отказ от значительной части того, что даёт ядро: свой TCP/IP-стек через smoltcp, ядро, всегда загруженное на 100%, собственный протокол поверх разделяемой памяти. Для logical airgap это ещё и рост поверхности того, что придётся самостоятельно верифицировать на предмет безопасности.
Что показало сопоставление с MVP
Каталог составлялся до прототипа. MVP дал измерения — и они переставили приоритеты.
| Что предполагал анализ | Что показал MVP |
|---|---|
| Основная борьба идёт за микрооптимизации в Rust-коде | Потолок определяет однопоточность брокера, а не код приложения |
| Zero-copy и kernel bypass — «высшая лига», к которой надо стремиться | При потолке в одно ядро брокера они не сдвинули бы сквозной RPS вообще |
| Асинхронность — одна из оптимизаций в списке | Асинхронность оказалась не оптимизацией, а условием входа: 27–28k RPS против 142k |
| Лимиты ОС — фоновая гигиена | Лимит файловых дескрипторов и listen backlog были буквально первым потолком |
| Переиспользование соединений — приятная мелочь | Тюнинг потоков и переиспользование соединений дали рост с ~2200 до ~8700 RPS (+295%) |
| Опрос ответа в цикле — приемлемый компромисс прототипа | Оказался прямым источником паразитной нагрузки на CPU и брокер |
Общий вывод простой: оптимизировать имеет смысл только тот слой, в котором находится текущий потолок. Между «наивная синхронная реализация» и «AF_XDP» лежат три-четыре порядка усилий, и почти весь реальный выигрыш MVP получил на самых дешёвых ступенях — асинхронный стек, keep-alive, переиспользование соединений, снятие лимитов ОС.
Отсюда — порядок применения каталога оптимизаций, который я считаю правильным:
- Снять ограничения ОС. Дескрипторы, backlog, keep-alive,
TCP_NODELAY. Стоимость — конфигурация, эффект — кратный. - Убрать блокировки в архитектуре. Асинхронный I/O,
SO_REUSEPORT, логи и метрики вне горячего пути, блокирующее чтение вместо опроса. - Убрать аллокации и копии. Пул буферов,
write_vectored, передача владения буфером. - Амортизировать обращения к брокеру. Батчинг, pipelining, batch-ACK, долгоживущие соединения, UDS для локального случая.
- Расшить сам брокер. Шардирование или несколько инстансов — то самое место, где в MVP стоит потолок.
- Собрать бинарник правильно. LTO,
codegen-units = 1,panic = abort, при наличии профиля — PGO. - И только потом — affinity, HugePages,
io_uring,splice, kernel bypass.
Первые четыре пункта — это несколько дней работы. Последний — отдельный проект со своей моделью безопасности.
Оптимизации, которые нельзя делать в airgap
Отдельный результат анализа — список приёмов, которые технически работают, но противоречат самому смыслу разрыва:
spliceдля транзита в обе стороны. Отменяет инспекцию содержимого. Если данные не проходят через память, их нельзя ни валидировать по схеме, ни проверить по HMAC, ни очистить от опасных заголовков.maxmemory-policy allkeys-lruдля очереди. Даёт устойчивость к OOM ценой молчаливой потери сообщений — неприемлемо для контура, где потеря байта эквивалентна нарушению доставки.- Неограниченные очереди и каналы ради пиковой пропускной способности. Без backpressure всплеск нагрузки превращается в отказ по памяти.
- 0-RTT для всех запросов подряд. Экономит RTT, но открывает возможность повторного проигрывания запроса.
Эти же материалы дали и «обязательный минимум», который не оптимизация, а условие: разделение прав в брокере (producer может только писать в очередь запросов, но не читать и не удалять), подпись полезной нагрузки HMAC-SHA256, жёсткая валидация на стороне consumer, лимит на размер тела, TTL на ключи ответов, запуск не от root.
# Разделение прав: producer физически не может прочитать чужие данные
user producer on >pass123 ~req_queue ~res:* +lpush +subscribe +ping
user consumer on >pass456 ~req_queue ~req_queue_processing ~res:* +brpoplpush +lpop +publish +ping
Смысл в том, что даже полностью скомпрометированный внешний шлюз не может ни очистить очередь, ни вычитать чужие сообщения.

