Вводная часть
Сегодня мы хотим рассказать о Pingora, новом HTTP-прокси, который мы создали своими силами, используя Rust, и который обслуживает более 1 триллиона запросов в день, повышает нашу производительность и предоставляет множество новых возможностей для клиентов Cloudflare, требуя при этом лишь треть ресурсов процессора и памяти от объема ресурсов нашей предыдущей инфраструктуры прокси.
По мере роста Cloudflare мы переросли NGINX. Он был прекрасен в течение многих лет, но со временем его ограничения в наших масштабах привели к тому, что нам захотелось создать что-то новое. Мы больше не могли обеспечивать необходимую производительность, и NGINX не обладал функциями, необходимыми для нашей очень сложной среды.
Многие клиенты и пользователи Cloudflare используют глобальную сеть Cloudflare в качестве прокси между клиентами HTTP (такими как веб-браузеры, приложения, IoT-устройства и т. д.) и серверами. В прошлом мы много говорили о том, как браузеры и другие пользовательские агенты подключаются к нашей сети, и мы разработали множество технологий и внедрили новые протоколы (см. QUIC и оптимизации для http2), чтобы сделать этот участок соединения более эффективным.
Сегодня мы сосредоточимся на другой части уравнения: сервисе, который проксирует трафик между нашей сетью и серверами в Интернете. Этот прокси-сервис обеспечивает работу нашей CDN, Workers fetch, Tunnel, Stream, R2 и многих, многих других функций и продуктов.
Давайте разберемся, почему мы решили заменить наш старый сервис и как мы разработали Pingora, нашу новую систему, созданную специально для потребностей и масштабов запросов клиентов Cloudflare.
Зачем создавать еще один прокси-сервер
За годы использования NGINX мы столкнулись с ограничениями. Некоторые ограничения мы оптимизировали или обходили. Но другие было гораздо сложнее преодолеть.
Ограничения архитектуры снижают производительность
Архитектура воркеров (рабочих процессов) NGINX в нашем случае имеет эксплуатационные недостатки, которые снижают производительность и эффективность.
Во-первых, в NGINX каждый запрос может обслуживаться только одним рабочим процессом. Это приводит к несбалансированной нагрузке между всеми процессорнами ядрами, что приводит к замедлению работы.
Из-за этого эффекта привязки запросов к процессу, запросы, которые выполняют задачи, тяжелые для процессора, либо блокирующие ввод-вывод, могут замедлять другие запросы. Как показывают эти записи в блоге, мы потратили много времени на решение этих проблем.
Наиболее критической проблемой для наших сценариев использования является недостаточное повторное использование соединений. Наши машины устанавливают TCP-соединения с исходными серверами для проксирования HTTP-запросов. Повторное использование соединений ускоряет TTFB (время до первого байта) запросов за счет повторного использования ранее установленных соединений из пула соединений, пропуская TCP и TLS хендшейки, необходимые для нового соединения.
Однако в NGINX пул соединений создается для каждого рабочего процесса. Когда запрос попадает на определенный рабочий процесс, тот может повторно использовать соединения только этого процесса. Когда мы добавляем больше рабочих процессов NGINX для увеличения масштаба, коэффициент повторного использования соединений ухудшается, поскольку соединения разбросаны по изолированным пулам всех процессов. Это приводит к более долгому TTFB и большему количеству соединений, которые необходимо поддерживать, что отнимает ресурсы (и деньги) как у нас, так и у наших клиентов.
Как уже упоминалось в прошлых статьях блога, у нас есть обходные пути для решения некоторых из этих проблем. Но если мы справимся с фундаментальной проблемой в виде модели воркеров/процессов, мы решим все эти проблемы естественным образом.
Некоторые виды функциональности трудно добавить
NGINX - это очень хороший веб-сервер, балансировщик нагрузки или простой шлюз. Но Cloudflare делает гораздо больше. Мы привыкли строить всю необходимую нам функциональность вокруг NGINX, что нелегко сделать, стараясь не слишком расходиться с кодовой базой NGINX upstream.
Например, при повторной попытке/отказе запроса иногда мы хотим отправить запрос на другой сервер с контентом, с другим набором заголовков запроса. Но это не то, что NGINX позволяет нам сделать. В таких случаях мы тратим время и усилия на обход ограничений NGINX.
Между тем, языки программирования, с которыми нам приходилось работать, не способствовали смягчению трудностей. NGINX написан исключительно на языке C, который по своему устройству не является безопасным для памяти. Работа с такой сторонней кодовой базой весьма чревата ошибками. Попасть в проблемы с безопасностью памяти довольно легко даже опытным инженерам, и мы хотели избежать их, насколько это возможно.
Другой язык, который мы использовали в дополнение к C, был Lua. Он менее подвержен риску, но и менее производителен. Кроме того, при работе со сложным кодом и бизнес-логикой на Lua нам часто не хватало статической типизации.
А сообщество NGINX не очень активно, и разработка, как правило, ведется "за закрытыми дверями".
Выбор в пользу собственной разработки
За последние несколько лет, по мере роста клиентской базы и набора функций, мы постоянно оценивали три варианта:
-
Продолжать инвестировать в NGINX и, возможно, доработать его, чтобы он на 100% соответствовал нашим потребностям. У нас был необходимый опыт, но, учитывая ограничения архитектуры, упомянутые выше, потребовались бы значительные усилия, чтобы перестроить его таким образом, чтобы он полностью соответствовал нашим потребностям.
-
Мигрировать на другую кодовую базу прокси сторонних производителей. Безусловно, есть хорошие проекты, такие как envoy и другие. Но этот путь означает, что тот же цикл может повториться через несколько лет.
-
Начать с чистого листа, создав собственную платформу и фреймворк. Этот выбор требует наибольших предварительных инвестиций с точки зрения инженерных усилий.
Мы оценивали каждый из этих вариантов каждый квартал в течение последних нескольких лет. Не существует очевидной формулы, позволяющей определить, какой вариант лучше. В течение нескольких лет мы шли по пути наименьшего сопротивления, продолжая расширять NGINX. Однако в какой-то момент окупаемость собственного прокси-сервера показалась нам оправданной. Мы решили построить прокси с нуля и начали проектировать приложение прокси нашей мечты.
Проект Pingora
Проектные решения
Чтобы сделать прокси-сервер, обслуживающий миллионы запросов в секунду, быстрым, эффективным и безопасным, мы должны сначала принять несколько важных проектных решений.
Мы выбрали Rust в качестве языка проекта, потому что он может делать то, что может делать C, с безопасным для памяти способом без ущерба для производительности.
Хотя существует несколько отличных готовых библиотек HTTP сторонних производителей, таких как hyper, мы решили создать свою собственную, потому что хотим обеспечить максимальную гибкость в обработке HTTP-трафика и быть уверенными, что сможем внедрять инновации в своем собственном темпе.
В Cloudflare мы обрабатываем трафик во всем Интернете. У нас много случаев странного и не соответствующего требованиям RFC HTTP-трафика, который нам приходится поддерживать. Это общая дилемма для сообщества HTTP и Интернета, где существует противоречие между строгим следованием спецификациям HTTP и учетом нюансов широкой экосистемы потенциально устаревших клиентов и серверов. Выбрать одну из сторон может быть непросто.
Коды состояния HTTP определены в RFC 9110 как трехзначное целое число, и обычно ожидается, что они будут находиться в диапазоне от 100 до 599. Hyper был одной из таких реализаций. Однако многие серверы поддерживают использование кодов состояния от 599 до 999. Для этой функции был создан issue, в котором рассматривались различные стороны дебатов. Хотя команда hyper в конечном итоге приняла это изменение, у них были веские причины отклонить такой запрос, и это был лишь один из многих случаев несоответствующего поведения, которое нам нужно было поддержать.
Чтобы удовлетворить требования Cloudflare в экосистеме HTTP, нам нужна была надежная, пермиссивная, настраиваемая библиотека HTTP, способная выжить в условиях дикой природы Интернета и поддерживать различные случаи несовместимого использования. Лучший способ гарантировать это - реализовать собственную библиотеку.
Следующее проектное решение было связано с системой планирования рабочей нагрузки. Мы выбрали многопоточность вместо многопроцессности, чтобы легче разделять ресурсы, особенно пулы соединений. Мы также решили, что разделение работы (work stealing) будет необходимо для того, чтобы избежать некоторых классов проблем с производительностью, упомянутых выше. Асинхронный runtime Tokio оказался прекрасно подходящим для наших нужд.
Наконец, мы хотели, чтобы наш проект был интуитивно понятным и удобным для разработчиков. То, что мы создаем, не является конечным продуктом, и должно быть расширяемой платформой, поскольку на ее основе создаются новые возможности. Мы решили реализовать программируемый интерфейс на основе событий "жизни запроса", подобный NGINX/OpenResty. Например, фаза "фильтр запроса" позволяет разработчикам запускать код для изменения или отклонения запроса при получении заголовка запроса. Благодаря такому дизайну мы можем четко разделить нашу бизнес-логику и общую логику прокси. Разработчики, которые ранее работали на NGINX, могут легко переключиться на Pingora и быстро начать продуктивную работу.
Pingora быстрее в работе
Давайте перенесемся в настоящее время. Pingora обрабатывает почти каждый HTTP-запрос, которому необходимо взаимодействовать с исходным сервером (например, при промахе кэша), и в процессе работы мы собрали много данных о производительности.
Сначала давайте посмотрим, как Pingora ускоряет трафик нашего клиента. Общий трафик на Pingora показывает снижение на 5 мс при медианном TTFB и на 80 мс при 95-м процентиле. Это не потому, что мы выполняем код быстрее. Даже наш старый сервис мог обрабатывать запросы в субмиллисекундном диапазоне.
Экономия происходит благодаря нашей новой архитектуре, которая может разделять соединения между всеми потоками. Это означает лучший коэффициент повторного использования соединений, что позволяет тратить меньше времени на хендшейки TCP и TLS.
Для всех клиентов Pingora создает лишь на треть больше новых соединений в секунду по сравнению со старым сервисом. Для одного крупного клиента она увеличила коэффициент повторного использования соединений с 87,1% до 99,92%, что позволило сократить количество новых соединений до их начала в 160 раз. Чтобы представить цифру более интуитивно, можно сказать, что благодаря переходу на Pingora мы ежедневно экономим нашим клиентам и пользователям 434 года времени рукопожатия.
Больше возможностей
Наличие удобного для разработчиков интерфейса, с которым инженеры знакомы, и устранение прежних ограничений позволяет нам быстрее разрабатывать больше функций. Основные функциональные возможности, такие как новые протоколы, служат строительными блоками для новых продуктов, которые мы можем предложить клиентам.
Например, мы смогли добавить поддержку HTTP/2 в Pingora без серьезных препятствий. Это позволило нам вскоре предложить gRPC нашим клиентам. Добавление такой же функциональности в NGINX потребовало бы значительно больших инженерных усилий и, возможно, не было бы реализовано.
Совсем недавно мы анонсировали Cache Reserve, где Pingora использует хранилище R2 в качестве слоя кэширования. По мере расширения функциональности Pingora мы можем предлагать новые продукты, которые раньше были неосуществимы.
Больше эффективности
При работе Pingora потребляет на 70% меньше CPU и на 67% меньше памяти по сравнению с нашим старым сервисом при той же нагрузке. Экономия обусловлена несколькими факторами.
Наш код на Rust работает более эффективно по сравнению с нашим старым кодом на Lua. Кроме того, разница в эффективности обусловлена их архитектурами. Например, в NGINX/OpenResty, когда код Lua хочет получить доступ к заголовку HTTP, он должен прочитать его из структуры NGINX C, выделить строку Lua и затем скопировать ее в строку Lua. После этого Lua также должен очистить от мусора свою новую строку. В Pingora это будет просто прямой доступ к строке.
Многопоточная модель также делает обмен данными между запросами более эффективным. NGINX также имеет общую память, но из-за ограничений реализации каждый доступ к общей памяти должен использовать блокировку мьютекса, а в общую память можно помещать только строки и числа. В Pingora доступ к большинству общих элементов можно получить напрямую через общие ссылки, защищенные атомарными счетчиками ссылок.
Другая значительная часть экономии тактов процессора, как уже упоминалось выше, связана с меньшим количеством новых соединений. TLS-хендшейки являются дорогостоящими по сравнению с простой отправкой и получением данных через установленные соединения.
Безопасность
Быстрое и безопасное внедрение функций - сложная задача, особенно в наших масштабах. Трудно предсказать все побочные ситуации, которые могут возникнуть в распределенной среде, обрабатывающей миллионы запросов в секунду. Фаззинг и статический анализ могут смягчить многое, но не всё. Семантика Rust, безопасная для памяти, защищает нас от неопределенного поведения и дает нам уверенность в том, что наш сервис будет работать правильно.
С такими гарантиями мы можем больше сосредоточиться на том, как изменения в нашем сервисе будут взаимодействовать с другими сервисами или происхождением клиента. Мы можем внедрять функции с большей частотой и не быть обремененными проблемами безопасности памяти и трудно диагностируемыми сбоями.
Когда сбои происходят, инженеру приходится тратить время на диагностику того, как они произошли и что их вызвало. С момента создания Pingora мы обслужили несколько сотен триллионов запросов, и до сих пор не произошло ни одного сбоя из-за кода нашего сервиса.
На самом деле, сбои Pingora происходят настолько редко, что мы обычно находим несвязанные проблемы, когда сталкиваемся с ними. Недавно мы обнаружили ошибку ядра вскоре после того, как наш сервис начал падать. Мы также обнаружили аппаратные проблемы на нескольких машинах; в прошлом исключить редкие ошибки памяти, вызванные нашим программным обеспечением, даже после значительной отладки было практически невозможно.
Заключение
Подводя итог: мы создали собственный прокси-сервер, который быстрее, эффективнее и универсальнее в качестве платформы для наших текущих и будущих продуктов.
Мы еще вернемся к более подробной технической информации о проблемах, с которыми мы столкнулись, об оптимизациях, которые мы применили, и об уроках, которые мы извлекли из создания Pingora и ее развертывания для обеспечения работы значительной части Интернета. Мы также вернемся к нашему плану по открытому исходному коду.
Pingora - это наша крайняя попытка переписать нашу систему, но - не последная. Она также явится лишь одним из строительных блоков в перестройке архитектуры наших систем.
Автор: Александр Чекалин