Современные реалии таковы, что не каждый бизнес может себе позволить сайт, особенно стартующий бизнес. Поэтому выгодно воспользоваться конструктором сайтов, например filandor.com . Запуск сайта через несколько минут.
В Cloudflare мы создаем прокси-приложения поверх Oxy, которые должны иметь возможность обрабатывать огромный объем трафика. Помимо высоких требований к производительности, приложения также должны быть устойчивыми к сбоям или перезагрузкам. По мере развития фреймворка сложность также увеличивается. При переносе WARP для поддержки мягкой одноадресной рассылки (серверы Cloudflare больше не владеют IP-адресами) нам нужно было добавить различные функции в нашу прокси-инфраструктуру. Эти дополнения увеличили не только размер кода, но и использование ресурсов, а также состояния, которые необходимо сохранять между обновлениями процессов.
Чтобы решить эти проблемы, мы решили разделить большой прокси-процесс на более мелкие специализированные службы. Следуя философии Unix, каждая служба должна нести единственную ответственность и делать это хорошо. В этом сообщении блога мы поговорим о том, как наш прокси-сервер взаимодействует с тремя различными службами — Splicer (который передает данные между сокетами), Bumblebee (который обновляет поток IP до сокета TCP) и Fish (который обрабатывает выход уровня 3 с помощью программных средств). -индивидуальные IP-адреса). Эти три службы помогают нам повысить надежность и эффективность системы, поскольку мы мигрировали WARP для поддержки мягкой одноадресной рассылки.

Сплайсер
Большинство туннелей передачи в нашем прокси-сервере пересылают пакеты без внесения каких-либо изменений. Другими словами, при наличии двух сокетов прокси просто передает данные между ними: читает из одного сокета и записывает в другой. Это распространенный шаблон в Cloudflare, и мы повторно реализуем очень похожие функции в отдельных проектах. Эти проекты часто имеют свои собственные настройки для буферизации, сброса и завершения соединений, но они также должны координировать длительные задачи прокси с их перезапуском или обработкой обновления процесса.
Превращение этого в службу позволяет другим приложениям отправлять длительную задачу проксирования в Splicer. Приложения передают два сокета Splicer, и им не нужно беспокоиться о сохранении соединения при перезапуске. После завершения задачи Splicer вернет два исходных сокета и исходные метаданные, прикрепленные к запросу, чтобы исходное приложение могло проверить окончательное состояние сокетов — например, с помощью TCP_INFO — и при необходимости завершить ведение журнала аудита.
шмель
Многие из подключений Cloudflare основаны на IP (уровень 3), но большинство наших сервисов работают на сокетах TCP или UDP (уровень 4). Для обработки завершения TCP мы хотим создать ядро Сокет TCP из IP-пакетов, полученных от клиента (позже мы можем перенаправить этот сокет и восходящий сокет на Splicer для передачи данных между глазным яблоком и источником). Bumblebee выполняет обновления, порождая поток в анонимном сетевом пространстве имен с системным вызовом unshare, транслируя IP-пакеты через NAT и используя там tun-устройство для выполнения трехэтапных рукопожатий TCP с прослушивателем. Вы можете найти более подробную статью о том, как мы обновляем IP-потоки до TCP-потоков здесь.
Короче говоря, другим службам просто нужно передать сокет, несущий поток IP, и Bumblebee обновит его до сокета TCP, без участия стека TCP пользовательского пространства! После создания сокета Bumblebee вернет сокет приложению, запрашивающему обновление. Опять же, прокси-сервер может перезапуститься без разрыва соединения, так как Bumblebee передает IP-сокет, а Splicer обрабатывает TCP-сокеты.
Рыба
Fish пересылает IP-пакеты, используя мягкое одноадресное IP-пространство, не обновляя их до сокетов уровня 4. Ранее мы реализовали пересылку пакетов в общем IP-пространстве с помощью iptables и conntrack. Однако управление сопоставлением IP/портов не является простым, когда у вас есть много возможных IP-адресов для выхода и различные назначения портов. Conntrack легко настраивается, но применение конфигурации с помощью правил iptables требует тщательной координации, а отладка выполнения iptables может быть сложной задачей. Кроме того, зависимость от конфигурации при отправке пакета через сетевой стек приводит к загадочным режимам сбоя, когда conntrack не может перезаписать пакет в соответствии с точным IP-адресом или диапазоном портов.
Fish пытается решить эту проблему, переписывая пакеты и настраивая conntrack с использованием протокола netlink. Иными словами, прокси-приложение отправляет сокет, содержащий IP-пакеты от клиента вместе с желаемым программным одноадресным IP-адресом и диапазоном портов, в Fish. Затем Fish обеспечит пересылку этих пакетов к месту назначения. Выбор клиентом IP-адреса не имеет значения; Fish гарантирует, что исходящие IP-пакеты имеют уникальный набор из пяти кортежей в пространстве имен корневой сети, и выполняет необходимую перезапись пакетов для поддержания этой изоляции. Внутреннее состояние Фиша также сохраняется при перезапусках.
Философия Unix, манифест
Подводя итог тому, что мы имеем на данный момент: вместо того, чтобы добавлять функциональные возможности непосредственно в прокси-приложение, мы создаем меньшие по размеру и повторно используемые сервисы. Становится возможным понять случаи сбоев, присутствующие в небольшой системе, и спроектировать ее так, чтобы она продемонстрировала надежное поведение. Затем, если мы сможем удалить подсистемы более крупной системы, мы сможем применить эту логику к этим подсистемам. Сосредоточив внимание на правильной работе меньшего сервиса, мы повышаем надежность всей системы и гибкость разработки.
Хотя бизнес-логика этих трех служб различна, вы можете заметить, что они общего: получают сокеты или дескрипторы файлов от других приложений, чтобы позволить им перезапуститься. Эти службы можно перезапустить, не разрывая соединения. Давайте посмотрим, как изящный перезапуск и передача дескриптора файла работают в наших случаях.
Передача дескриптора файла
Мы используем доменные сокеты Unix для межпроцессного взаимодействия. Это общий шаблон для межпроцессного взаимодействия. Помимо отправки необработанных данных, сокеты unix также позволяют передавать файловые дескрипторы между различными процессами. Это важно для нашей архитектуры, а также для плавного перезапуска.

Существует два основных способа передачи дескриптора файла: использование системного вызова pid_getfd или SCM_RIGHTS. Последнее является для нас лучшим выбором, поскольку варианты использования ориентированы на то, чтобы прокси-приложение «давало» сокеты, а не микросервисы «забирали» их. Более того, для первого метода потребуется специальное разрешение и способ для прокси-сервера указать, какой файловый дескриптор следует использовать.
В настоящее время у нас есть собственная внутренняя библиотека hot-potato для передачи файловых дескрипторов, поскольку мы используем стабильный Rust в производстве. Если вас устраивает использование nightly Rust, вы можете рассмотреть функцию unix_socket_ancillary_data. Связанный выше пост в блоге о SCM_RIGHTS также объясняет, как это можно реализовать. Тем не менее, мы также хотим добавить некоторые «интересные» детали, которые вы, возможно, захотите узнать, прежде чем использовать SCM_RIGHTS в производственной среде:
- Существует максимальное количество файловых дескрипторов, которые вы можете передать в одном сообщении.
Предел определяется константой SCM_MAX_FD в ядре. Это значение равно 253, начиная с версии ядра 2.6.38. - Получение одноранговых учетных данных сокета может быть весьма полезным для наблюдения в многопользовательских настройках.
- Вспомогательные данные SCM_RIGHTS образуют границу сообщения.
- Можно отправлять любые файловые дескрипторы, не только сокеты
Мы используем этот трюк вместе с memfd_create, чтобы обойти максимальный размер буфера без реализации чего-то вроде кодирования длины кадров. Это также делает возможным передачу сообщений с нулевым копированием.
Изящный перезапуск
Мы рассмотрели общую стратегию плавного перезапуска в блоге «Oxy: путь изящных перезапусков». Давайте углубимся в то, как мы используем tokio и передачу файловых дескрипторов для переноса всех важных состояний в старом процессе в новый. Мы можем завершить старый процесс почти мгновенно, не оставив после себя никакого соединения.
Передача состояний и файловых дескрипторов
Такие приложения, как NGINX, можно перезагружать без простоев. Однако если есть ожидающие запросы, то будут зависшие процессы, которые обрабатывают эти соединения до того, как они завершатся. Это не идеально для наблюдения. Это также может привести к снижению производительности, когда старые процессы начинают накапливаться после последовательных перезапусков.
В трех микросервисах в этом сообщении блога мы используем концепцию передачи состояния, при которой ожидающие запросы будут приостановлены и переданы новому процессу. Новый процесс будет принимать как новые запросы, так и старые сразу при запуске. Этот метод действительно требует большей сложности, чем поддержание работы старого процесса. На высоком уровне у нас есть следующие дополнительные шаги, когда приложение получает запрос на обновление (обычно SIGHUP): приостановить все задачи, дождаться, пока все задачи (в группах) будут приостановлены, и отправить их в новый процесс.

Группа ожидания с помощью JoinSet
Постановка задачи: мы динамически порождаем разные параллельные задачи, и каждая задача может порождать новые дочерние задачи. Мы должны дождаться завершения некоторых из них, прежде чем продолжить.
Другими словами, задачами можно управлять как группами. В Go ожидание завершения набора задач — это решаемая проблема с помощью WaitGroup. Мы обсуждали способ реализации WaitGroup в Rust с использованием каналов в предыдущем блоге. Также существуют крейты, такие как группа ожидания, которые просто используют AtomicWaker. Другой подход заключается в использовании JoinSet, который может сделать код более читабельным. В приведенном ниже примере мы группируем запросы с помощью JoinSet.
let mut task_group = JoinSet::new();
loop {
// Receive the request from a listener
let Some(request) = listener.recv().await else {
println!("There is no more request");
break;
};
// Spawn a task that will process request.
// This returns immediately
task_group.spawn(process_request(request));
}
// Wait for all requests to be completed before continue
while task_group.join_next().await.is_some() {}
Однако очевидная проблема заключается в том, что если мы получаем много запросов, то JoinSet должен будет сохранять результаты для всех из них. Давайте изменим код, чтобы очищать JoinSet по мере того, как приложение обрабатывает новые запросы, чтобы снизить нагрузку на память.
loop {
tokio::select! {
biased; // This is optional
// Clean up the JoinSet as we go
// Note: checking for is_empty is important 😉
_task_result = task_group.join_next(), if !task_group.is_empty() => {}
req = listener.recv() => {
let Some(request) = req else {
println!("There is no more request");
break;
};
task_group.spawn(process_request(request));
}
}
}
while task_group.join_next().await.is_some() {}
Отмена
Мы хотим передать ожидающие запросы новому процессу как можно скорее после получения сигнала обновления. Это требует от нас приостановить все запросы, которые мы обрабатываем. Другими словами, чтобы иметь возможность реализовать плавный перезапуск, нам нужно реализовать плавное завершение работы. В официальном туториале tokio уже рассказывалось, как этого можно добиться с помощью каналов. Конечно, мы должны гарантировать, что задачи, которые мы приостанавливаем, безопасны для отмены. Приостановленные результаты будут собраны в JoinSet, и нам просто нужно передать их новому процессу, используя передачу файлового дескриптора.
Например, в Bumblebee состояние паузы будет включать файловые дескрипторы среды, клиентский сокет и IP-поток проксирования сокета. Нам также необходимо передать в новый процесс текущую таблицу NAT, которая может быть больше, чем буфер сокета. Таким образом, состояние таблицы NAT кодируется в анонимный файловый дескриптор, и нам просто нужно передать файловый дескриптор новому процессу.
Заключение
Мы рассмотрели, как сложное прокси-приложение можно разделить на более мелкие компоненты. Эти компоненты могут работать как новые процессы, что обеспечивает разное время жизни. Тем не менее, этот тип архитектуры требует дополнительных затрат: распределенная трассировка и взаимодействие между процессами. Тем не менее, затраты приемлемы, учитывая повышение производительности, ремонтопригодности и надежности. В следующих сообщениях блога мы поговорим о различных приемах отладки, которым мы научились при работе с большой кодовой базой со сложным взаимодействием служб с использованием таких инструментов, как strace и eBPF.