[Перевод] Как мы в Dropbox перешли с Nginx на Envoy

В этой статье мы будем говорить о нашей старой инфраструктуре, основанной на Nginx, ее болячках, а также выгоде, которую мы получили после миграции на Envoy. Мы сравним Nginx и Envoy различными способами. Также кратко коснемся процесса миграции, текущего состояния, а также проблем, возникших при переходе.

leidpm-2qoc22talxmn70rzkdbo.png

Когда мы переключили большую часть трафика на Envoy — у нас получилось бесшовно мигрировать систему, обрабатывающую десятки миллионов открытых соединений, миллионы запросов в секунду и терабиты пропускной способности. По факту мы стали одним из крупнейших пользователей Envoy в мире.

Отказ от ответственности: мы пытаемся оставаться объективными, достаточно много сравнений относятся только к Dropbox и нашим принципам разработки ПО: мы ставим на Bazel, gRPC, С++ и Golang.

Просим обратить внимание, что мы рассмотрим версию Nginx с открытым исходным кодом, а не коммерческую с дополнительными функциями.


Наша старая инфраструктура, основанная на Nginx

Наши настройки Nginx были статичными, обновлялись с помощью комбинации Python2, Jinja и YAML. Любое изменение требовало полной раскатки с нуля. Все динамические части, к примеру управление upstream и экспорт статистики, были написаны на Lua. Любая достаточно сложная логика была перемещена на следующий уровень проксирования, написанный на Go. Наша статья также имеет раздел, посвященный нашей старой инфраструктуре на Nginx.

Nginx служил нам верой и правдой уже порядка десяти лет. Но он перестал устраивать наши лучшие практики по разработке:


  • Наши внутренние и (закрытые) внешние API постепенно переходили с REST на gRPC, который требует кучу различных перекодировок от прокси.
  • Protocol buffers стали стандартом de facto для определения сервисов и настроек
  • Все программное обеспечение, независимо от языка программирования, собирается и тестируется с помощью Bazel.
  • Большая вовлеченность наших инженеров ключевых инфраструктурных проектов в качестве участников сообществ ПО с открытым исходным кодом.

Также Nginx был достаточно сложным в плане обслуживания:


  • Сборка конфигурационных файлов была слишком гибкой и была разбита между YAML, Jinja2 и Python.
  • Мониторинг был смесью Lua, анализа журналов, и мониторингом системного уровня
  • Повышенная зависимость от сторонних модулей влияла на стабильность, производительность и стоимость последующих обновлений.
  • Раскатка и управление процессом сильно отличался от остальных сервисов, поскольку он достаточно сильно зависел от настройки других систем: syslog, logrotate и прочих, в отличие от того, чтобы быть независимым от основной системы.

При этом мы в первый раз начали искать потенциальную замену Nginx.


Почему на Bandaid?

Мы часто говорим, что внутри инфраструктуры мы значительно полагаемся на Bandaid, прокси-сервер, написанный на Go. Он прекрасно внедрен в инфраструктуру Dropbox, поскольку основан на обширнейней экосистеме внутренних библиотек Go: мониторинг, обнаружение сервисов, ограничение скорости и т.п. Мы рассматривали подобный переход с Nginx, однако есть несколько проблем, мешающих нам это сделать:


  • ПО на Golang требуется больше ресурсов, чем ПО на C++. Низкое потребление ресурсов особенно важно для нашего Edge, поскольку мы не можем легко «автоматически масштабировать» там наши сервисы.
    • дополнительное потребление процессорного времени в основном связано с сборщиком мусора (GC), парсером HTTP и TLS, причем последнее менее оптимизировано, чем BoringSSL, используемый Nginx\Envoy.
    • Модель «goroutine-per-request» и издержки GC значительно увеличивают требования к оперативной памяти в сервисах с большим числом соединений, как у нас.
  • Нет поддержки FIPS для Golang TLS
  • У Bandaid нет поддержки сообществом вне Dropbox, что означает, что мы сможем полагаться только на себя при разработке.

С учетом вышенаписанного мы решили начать перенос нашей транспортной инфраструктуры на Envoy.


Наша новая инфраструктура, основанная на Envoy

Давайте посмотрим на основные характерные черты разработки и сопровождения подробнее, чтобы увидеть, почему мы думаем, что Envoy лучший выбор для нас, а также что мы получим при переходе с Nginx на Envoy.


Производительность

Архитектура Nginx является событийной и многопроцессной. Она поддерживает SO_REUSEPORT, EPOLLEXCLUSIVE, а также привязку обработчиков к процессорным ядрам. Однако несмотря на то, что она событийная, она не полностью неблокирующая, что означает, что некоторые операции, например открытие файла или журналирование, потенциально может вызвать приостановку обслуживания (даже с aio, aio_write, включенными thread pools). Это приведет к увеличению задержек, которые могут достигать несколько секунд на дисках с шпинделями.

у Envoy схожая событийная архитектура, но вместо процессов используются потоки. Также есть поддержка SO_REUSEPORT (с поддержкой фильтрации BPF) и зависимость от libevent для обработки цикла событий (другими словами — нету крутых фишек epoll(2), к примеру EPOLLEXCLUSIVE). В цикле событий Envoy нет каких-либо блокирующих операций ввода-вывода. Даже журналирование сделано неблокирующим, так что оно не может вызвать подвисание.

Похоже, что теоретически по производительности Nginx и Envoy будут близки. Надеятся не в наших правилах, поэтому в первую очередь мы выполнили набор различных тестов на аналогично настроенных Nginx и Envoy. Если вы интересуетесь настройкой производительности, мы описали наши типовые руководства по настройке здесь, начиная с подбора оборудования и заканчивая оптимизацией операционной системы, выбора библиотек и настроек вебсервера.

Тесты показали схожую производительность на большинстве тестовых нагрузок: высокий RPS, высокая пропускная способность, и смешанное проксирование gRPC с низкими задержками и высокой пропускной способностью. Достаточно сложно сделать хороший тест производительности. у Nginx есть руководства, но они беспорядочные. У Envoy также есть руководство по нагрузочному тестированию, а также инструменты в проекте envoy-perf, но они, к сожалению, выглядят неподдерживаемыми. Мы стали использовать наш внутренний инструмент по имени «hulk», потому что он «ломать» наши сервисы. (Дима, найди пожалуйста крик Халка, когда он ломает что-то, у меня фантазия пока что не работает…)

Тем не менее были и заметные различия в результатах:


  • Nginx показал бОльшие задержки на долгоживущих соединениях. По большей части это связано с подвисанием цикла обработки событий при интенсивном вводе-выводе, особенно было заметно при работе с SO_REUSEPORT, поскольку в этом случае соединения могут приниматься от заблокированного в данный момент обработчика.
  • Производительность Nginx без сборщика статистики схожа с таковой для Envoy, активация сборщика на Lua замедлила Nginx в тесте с высоким RPS в три раза. Предсказуемо, с учетом зависимости от lua_shared_dict, который синхронизируется между обработчиками с помощью mutex. Мы понимаем, насколько неэффективным был наш сбор статистики. Мы попробовали сделать что-то подобное на counter(9) из FreeBSD, но только в пространстве пользователя: привязка к процессорному ядру, а также счетчики без блокировки на каждый обработчик вместе с функцией сбора данных, которая проходит по всем обработчикам и возвращает объединенную статистику. Но мы отказались от этой затеи, поскольку если бы мы завязались на внутренности Nginx (включая обработку ошибок, например), то нам пришлось бы поддерживать огроменнный патч, превращающий последующие обновления в настоящий ад.

У Envoy не было ни одной из этих проблем, а после перехода на него мы бы смогли освободить до 60% серверов, ранее занятых только под Nginx.


Наблюдаемость

Наблюдаемость одна из наиболее основных нужд при сопровождении любого продукта, а особенно лдя такой основной части инфраструктуры, как прокси. Это еще более важно в период перехода, чтобы любая проблема была обнаружена системой мониторинга, а не сообщена разочарованными пользователями.

Некоммерческий Nginx имеет модуль «stub status», в котором есть семь характеристик:

Active connections: 291
server accepts handled requests
 16630948 16630948 31070465
Reading: 6 Writing: 179 Waiting: 106

Этого однозначно не достаточно для нас, так что мы добавили простой обработчик log_by_lua, который добавляет статистику по каждому запросу, основанному на заголовках ипеременных, доступных в Lua: код статуса, размеры, попадание в кэш и т.п. Здесь пример простейшей функции, которая выдает характеристики:

function _M.cache_hit_stats(stat)
    if _var.upstream_cache_status then
        if _var.upstream_cache_status == "HIT" then
            stat:add("upstream_cache_hit")
        else
            stat:add("upstream_cache_miss")
        end
    end
end

В довесок к характеристикам, собираемым с каждого запроса, мы добавили очень хрупкий анализатор error.log, который отвечает классификацию ошибок upstream, http, Lua и TLS.

Поверх всего у нас работал отдельный обработчик для сбора внутреннего состояния Nginx: время с момента последней перезагрузки, число обработчиков, размеры RSS\VMS, сроки жизни сертификатов TLS и проч.

Типовая установка Envoy предоставляет нам тысячи отдельных метрик (в формате Prometheus), описывая как проксируемый трафик, так и внутреннее состояние сервера:

$ curl -s http://localhost:3990/stats/prometheus | wc -l
14819

Тут включено множество статистики с различной агрегацией:


  • Статистика по кластеру\по upstream\по виртуальному хосту, включая информацию о пуле соединений и различные временные ряды.
  • Статистика для каждого слушающего сокета: TCP\HTTP\TLS
  • Различная внутренняя статистика, начиная от версии и времени работы, и заканчивая статистикой выделения памяти и устаревшей функцией счетчиков использования.

Особый привет интерфейсу администратора Envoy. Он не только предоставляет отдельную статистику через /certs, /clusters и config_dump, но также имеет важнейшие для сопровождения особенности:


  • Способность менять на лету уровень журналирования через /logging, что позволило нам решать достаточно сложные проблемы за считанные минуты.
  • Точки входа /cpuprofiler, /heapprofiler, /contention, которые безусловно были весьма нужными при устранении проблем с производительностью
  • /runtime_modify, с которой можно изменять параметры конфигурации без применения новой конфигурации, что можно использовать при подборе функций и т.п.

В дополнение к статистике Envoy также поддерживает подключаемые механизмы отладки. Это используется не только нашей командой, ответственной за трафик и работающей с несколькими уровнями балансировки нагрузки, но и разработчиками приложений, желающими проверить сквозные задержки между Edge и серверами приложений. Технически у Nginx есть поддержка отладки через стороннюю программу OpenTracing, однако она вяло развивается.

Вишенкой на торте у Envoy идет способность отправлять журналы по gRPC, что позволило убрать мосты syslog-to-hive с команды по трафику. Кроме прочего на боевых серверах гораздо удобнее (и безопаснее!) добавить еще один общий сервис gRPC, чем добавлять отдельный слушающий сокет TCP\UDP.

Настройка журналирования доступа в Envoy, как и прочие вещи, производится через сервис управления gRPС: Access Log Service (ALS). Сервисы управления являются стандартным способом интеграции Envoy data plane с различными сервисами, что подводит нас к следующей тема.


Интеграция

Способность Nginx к интеграции проще всего описать как «юниксовая». Конфигурация очень статичная. Есть сильная зависимость от файлов (собственно конфигурационный файл, сертификаты, белые\черные списки и т.п.) и широко известных производственных стандартов (журналирование в syslog, авторизация подзапросов через HTTP). Простота и обратная совместимость хорошая штука для небольших установок, потому что Nginx может быть достаточно просто автоматизирован парой shell-скриптов. Но по мере роста масштаба системы тестируемость и стандартизация становятся более важными.

Envoy гораздо более продвинут в том, как data plane трафика должен быть интегрирован с control plane, а, следовательно, и с остальной инфраструктурой. У него есть поддержка protobuf и gRPC, предоставляемая через стабильный API, называемый xDS. Envoy производит обнаружение своих динамических ресурсов запрашивая свои (одну или несколько) службы xDS. В настоящее время эти интерфейсы развиваются за пределы Envoy, перед UDPA (universal data plane interface) разработчики ставит амбициозную цель: стать стандартом «de facto» в мире балансировщиков L4\L7. Из нашего опыта — это работает. Мы уже используем ORCA (Open Request Cost Agregation) для внутреннего тестирования нагрузки и рассматриваем возможности UDPA для наших балансировщиков, не связанных с Envoy, например на основе Katran, балансировщика eBPF\XDP L4.

Это весьма хорошо для Dropbox, поскольку все сервисы взаимодействуют между собой через API на основе gRPC. Мы внедрили собственную xDS control plane, которая объединяет Envoy с нашей системой управления конфигурацией, обнаружением сервисов, управлением секретами и информацией о маршрутах. Если хотите знать больше о Dropbox RPC — можете почитать здесь, мы подробно описали интеграцию обнаружения сервисов, управление секретами, статистику, отладку и прочие вещи с gRPC.

Опишем несколько доступных сервисов xDS, их альтернативы для Nginx, а также примеры того, как мы их используем:


  • Access Log Service (ALS), как уже писали выше, позволяет нам динамически настраивать цели для журналов доступа, кодировки и форматы. Попробуйте представить себе динамическую версию log_format и access_log для Nginx
  • Endpoint discovery service (EDS), предоставляет информацию о членах кластера. Это аналог динамически обновляемого списка блоков upstream для секций server (например для Lua это будет balancer_by_lua_block) в конфигурационном файле Nginx. В нашем случае мы проксируем это на наш внутренний сервис обнаружения.
  • Secret discovery service (SDS), предоставляет различную информацию о TLS, которая в Nginx работает по директивам ssl_* (ну и ssl_*_by_lua_block соответственно). Мы взяли его вкачестве нашего сервиса распостранения секретов.
  • Runtime discovery service (RTDS), предоставляет runtime флаги. Наша реализация этой функции на Nginx была довольно топорной, основанной на проверке существования различных файлов в Lua. С таким подходом сервера быстро стали несовместимыми по настройкам. В Envoy это также реализовано на файлах по-умолчанию, однако мы вместо этого подключили сервис с RTDS API к нашему распределенному хранилищу конфигурации, так что мы можем управлять целыми кластерами (инструментом, с интерфейсом, похожим на sysctl), а случайные несоотвествия между различными серверами исключены.
  • Route discovery service (RDS): выполняет сопоставление маршрутов с виртуальными хостами и позволяет выполнять дополнительную настройку заголовков и фильтров. В терминах Nginx это аналог динамического блока location c set_header```proxy_set_header\proxy_pass```. На нижних уровнях проксирования мы автоматически создаем их из наших конфигураионных файлов для определения сервисов.

В качестве примера интеграции Envoy в существующей боевой системе обычно приводят этот. Есть также и другие реализации control plane для Envoy, например Istio и менее сложная go-control-plane. Наша собственная платформа управления Envoy реализует все больше интерфейсов API xDS. Она развернута как обычный gRPC сервис и выступает в качестве промежуточного звена для наших инфраструктурных строительных блоков. Все это делается с помощью общих библиотек Golang для общения с внутренними сервисами и их предоставления для Envoy через интерфейсы API xDS. Сам процесс не включает вызовы файловой системы, подачу сигналов, обработку cron\logrotate\syslog\парсеров и т.п.


Настройка

У Nginx есть неоспоримое преимущество в виде простой и удобочитаемой конфигурации. Но это все становится неважным, как только конфигурационный файл становится сложным и начинает создаваться программными средствами. Как мы уже писали — наша конфигурация создается с помощью Python2, Jinja2 и YAML. Некоторые из вас возможно видели, или даже писали такое для erb, pug, Text: Template или даже m4 (А то! прим. переводчика):

{% for server in servers %}
server {
    {% for error_page in server.error_pages %}
    error_page {{ error_page.statuses|join(' ') }} {{ error_page.file }};
    {% endfor %}
    ...
    {% for route in service.routes %}
    {% if route.regex or route.prefix or route.exact_path %}
    location {% if route.regex %}~ {{route.regex}}{%
            elif route.exact_path %}= {{ route.exact_path }}{%
            else %}{{ route.prefix }}{% endif %} {
        {% if route.brotli_level %}
        brotli on;
        brotli_comp_level {{ route.brotli_level }};
        {% endif %}
        ...

С таким подходом создания конфигурации Nginx была огромная проблема: все используемые инструменты позволяли подстановку и\или логику. У YAML есть anchors, у Jinja2 — циклы, условия и макросы, ну, а Python вообще обладает полнотой по Тьюрингу. Без чистой модели данных сложность быстро расползлась по всем трем инструментам. Это можно было бы решить, но дополнительно было еще:


  • Нет декларативного описания формата настроек. Если бы мы хотели программно создавать и проверять конфигурацию — нам пришлось бы его изобрести.
  • Синтаксически правильная конфигурация может все еще быть недействительной с точки зрения кода на C. Например, некоторые связанные с буфером переменные имеют ограничения по значениям, ограничения по выравниванию и взаимозависимости с другими переменными. Для проверки конфигурации нам нужно было бы запустить ее через nginx -t.

Envoy наоборот имеет унифицированную модель данных для настройки: всё определяется через Protocol Buffers. Это не только решает вопрос с моделированием, но также добавляет информацию о типе к значениям конфигурации. С учетом этого, а также того, что мы уже давно работаем с protobuf в других сервисах, а также общего способа описания\настройки сервисов — значительно упрощается интеграция.

Наш новый генератор конфигурации для Envoy основан на protobuf и Python3. Моделирование выполняется в файлах proto, а вся логика реализована на Python. Например:

from dropbox.proto.envoy.extensions.filters.http.gzip.v3.gzip_pb2 import Gzip
from dropbox.proto.envoy.extensions.filters.http.compressor.v3.compressor_pb2 import Compressor

def default_gzip_config(
    compression_level: Gzip.CompressionLevel.Enum = Gzip.CompressionLevel.DEFAULT,
    ) -> Gzip:
        return Gzip(
            # Envoy's default is 6 (Z_DEFAULT_COMPRESSION).
            compression_level=compression_level,
            # Envoy's default is 4k (12 bits). Nginx uses 32k (MAX_WBITS, 15 bits).
            window_bits=UInt32Value(value=12),
            # Envoy's default is 5. Nginx uses 8 (MAX_MEM_LEVEL - 1).
            memory_level=UInt32Value(value=5),
            compressor=Compressor(
                content_length=UInt32Value(value=1024),
                remove_accept_encoding_header=True,
                content_type=default_compressible_mime_types(),
            ),
        )

Обратите внимание на [аннотации типов Python3](Обратите внимание на [аннотации типов Python3]() в этом кусочке кода!) в этом кусочке кода! В сочетании с расширением mypy-protobuf обеспечивается сквозная типизация внутри генератора. Любая совместимая IDE сразу же выявит проблемы при несоотвествии. Все еще есть варианты, когда проверяемый тип в protobuf может быть неверным. В примере выше window_bits для Gzip может принимать значения от 9 до 15. Этот тип ограничений легко может быть задан с помощью расширения protoc-gen-validate:

google.protobuf.UInt32Value window_bits = 9 [(validate.rules).uint32 = {lte: 15 gte: 9}];

Ну и наконец неявное преимущество использования формально определенной модели конфигурации — она естественным способом сопоставляется с документацией, например:

// Value from 1 to 9 that controls the amount of internal memory used by zlib. Higher values.
// use more memory, but are faster and produce better compression results. The default value is 5.
google.protobuf.UInt32Value memory_level = 1 [(validate.rules).uint32 = {lte: 9 gte: 1}];

Тем, кто думает об использовании protobuf на боевых серверах, но беспокоится, что может не хватить представления без схемы, стоит ознакомиться с статьей от Harvey Tuch, ключевого разработчика Envoy.


Расширяемость

Расширение Nginx куда-либо дальше, чем разрешают настройки, обычно возможно путем написания модуля на C. Руководство разработчика предоставляет наиболее полное введение для доступных строительных блоков. Там же сказано, что это относительно тяжелый способ. На практике потребуется наличие серьезного сеньора для безопасного написания модуля Nginx. С точки зрения инфраструктуры, доступной для разработчиков модулей, есть наличие базовых контейнеров: hash-таблицы, очереди, красно-черные деревья, управление памятью (не RAII), а также перехват всех этапов обработки запроса HTTP. Есть и внешние библиотеки, pcre, zlib, openssl и конечно же libc.

Для более легковесных расширений в Nginx есть интерфейсы на Perl и Javascript. К сожалению оба серьезно ограничены по способностям, по большей части могут делать только обработку запросов.

Наиболее часто используемый способ расширения от сообщества основан на стороннем lua-nginx-module и различных библиотеках от OpenResty. Так можно подключиться на любом этапе обработки запроса. Мы использовали log_by_lua для сбора статистики и balancer_by_luaдля динамической перенастройки backend.

В теории Nginx предоставляет способность разработки модулей на C++, на практике же отсутствуют нужные интерфейсы и обертки над всеми примитивами, чтобы сделать это стоящим делом. Но тем не менее сообщество пытается что-то делать. Конечно же, они все еще не дошли до внедрения на боевых системах.

Основной механизм расширения для Envoy — написание расширений на C++. Процесс не так хорошо описан как в случае с Nginx, однако тут все проще. Это частично из-за того, что:


  • Чистие и хорошо прокомментированные интерфейсы. Классы — точки расширения и документирования.
  • Стандартная библиотека и язык C++14. Начиная от базовых языковых функций, например шаблонов и лямбда-функций, заканчивая типобезопасными контейнерами и алгоритмами. В целом писать на C++14 так же просто как на Golang или с небольшой натяжкой — Python. (провокационно! прим. переводчика)
  • Расширения C++14 и его стандартной библиотеки. Предоставляются библиотекой abseil, в которой собраны из более новых стандартов C++, например mutex с встроенным обнаружением взаимоблокировки, поддержка отладки, дополнительные\более эффективные контейнеры, и многое другое.

Мы смогли объединить Envoy вместе с Vortex2 (наш framework для мониторинга) написав всего 200 строчек кода для реализации интерфейса stats.

Envoy также поддерживает Lua через moonjit, форк LuaJIT c улучшенной поддержкой Lua 5.2. Однако по сравнению с сторонней интеграцией Lua в Nginx у нее гораздо меньше возможностей и преимуществ, что делает Lua в Envoy гораздо менее привлекательным из-за дополнительных сложностей в разработке, тестировании и отладке интерпретируемого кода. Компании, специализирующиеся на Lua, могут не согласиться, но в нашем случае проще было избежать Lua и использовать исключительно C++ для написания расширений для Envoy.

чем Envoy отличается от других вебсерверов, так это появившейся поддержкой WebAssembly (WASM) — быстрого, переносимого и безопасного механизма для расширений. WASM предназначен не для непосредственного использования., а в качестве цели компиляции любого языка программирования общего назначения. Envoy реализует спецификацию WebAssembly for Proxies (включая эталонные SDK для C++ и Rust), которая описывает границы между кодом WASM и универсальным прокси L4\L7. Такой способ разделения между прокси и кодом расширения обеспечивает безопасную изолированную среду, а низкоуровневый компактный бинарный формат WASM обеспечивает производительность практически близкую к оборудованию. Кроме того, расширения proxy-wasm уже интегрированы в xDS, что позволяет динамические обновления и даже потенциально A\B тестирование. В презентации с Kubecon'19 (вы таки помните, что были не виртуальные конференции?) есть хороший обзор WASM в Envoy и его потенциальных применениях. Также на ней было сказано об производительности порядка 60–70% от кода на C++.

Вместе с WASM поставщики услуг получают безопасный и эффективный способ выполнения кода клиентов на своей стороне. Клиенты получают переносимость, поскольку их расширения смогут работать в любом облаке, реализующем proxy-wasm ABI. Кроме прочего он позволяет использовать вашим пользователям любой язык, компилирующийся в WebAssembly. Это позволяет им безопасно и эффективно использовать большой набор библиотек, не связанных с C++.

Разработчики Istio также вкладывают кучу ресурсов в разработку WebAssembly, у них уже есть экспериментальная версия расширения телеметрии и сообщество WebAssemblyHub для обмена расширениями. Можно почитать об этом подробнее здесь.

Мы в Dropbox в настоящее время не используем WebAssembly, но все может поменяться, когда станедо доступен proxy-wasm SDK для Go.


Сборка и тестирование

Для сборки Nginx применяется особая система конфигурации на основе shell-скриптов, а также система сборки на основе make. Просто и утонченно, но заняло слишком много времени для интеграции его в монорепо Bazel для получения преимуществ в виде инкрементных, распределенных, герметичных и воспроизводимых сборок. Google открыл исходный код своей версии Nginx для Bazel, которая состоит из Nginx, BoringSSL, PCRE, ZLIB и Brotli.

Что касается тестирования, то у Nginx есть набор интеграционных тестов в отдельном репозитории и нет unit-тестов.

С учетом интенсивного использования Lua и отсутствия встроенной инфраструктуры модульного тестирования мы перешли к тестированию на основе макетных настроек и простого тестового драйвера на Python:

class ProtocolCountersTest(NginxTestCase):
    @classmethod
    def setUpClass(cls):
        super(ProtocolCountersTest, cls).setUpClass()
        cls.nginx_a = cls.add_nginx(
            nginx_CONFIG_PATH, endpoint=["in"], upstream=["out"],
        )
        cls.start_nginxes()

    @assert_delta(lambda d: d == 0, get_stat("request_protocol_http2"))
    @assert_delta(lambda d: d == 1, get_stat("request_protocol_http1"))
    def test_http(self):
        r = requests.get(self.nginx_a.endpoint["in"].url("/"))
        assert r.status_code == requests.codes.ok

Также мы проверяем синтаксическую правильность всех созданных конфигурационных файлов с их предварительной обработкой (например меняем ip-адреса на 127.0.0.1/8, переключаем на самоподписанные сертификаты ну и т.п.) запуская nginx -c.

Если смотреть на Envoy, то его система сборки уже Bazel, так что интеграция в наш монорепо была простейшей: Bazel позволяет легко добавлять внешние зависимости. Мы также использовали скрипты copybara для синхронизации protobuf как для Envoy, так и для UDPA. Это удобо, если нужно сделать простые преобразования без необходимости поддержки большого набора патчей.

Для Envoy есть возможность использовать либо unit-тесты (на основе gtest\gmock) с предварительно написанными макетами, либо интегрированный фреймворк для тестирования, либо оба варианта сразу. Больше не нужно полагаться на медленные сквозные интеграционные тесты, запускаемые на каждое мелкое изменение.

При разработке Envoy с открытым исходным кодом требуется 100% покрытие unit-тестами. Тесты запускаются автоматически через конвейер CI в Azure при каждом запросе на слияние.

Кроме этого обычной практикой является обработка чувствительного к скорости работы кода с помощью google\benchmark:

$ bazel run --compilation_mode=opt test/common/upstream:load_balancer_benchmark -- --benchmark_filter=".*LeastRequestLoadBalancerChooseHost.*"
BM_LeastRequestLoadBalancerChooseHost/100/1/1000000          848 ms          449 ms            2 mean_hits=10k relative_stddev_hits=0.0102051 stddev_hits=102.051
...

После перехода на Envoy мы стали полагаться исключительно на unit-тесты при разработке наших внутренних модулей:

TEST_F(CourierClientIdFilterTest, IdentityParsing) {
  struct TestCase {
    std::vector uris;
    Identity expected;
  };
  std::vector tests = {
    {{"spiffe://prod.dropbox.com/service/foo"}, {"spiffe://prod.dropbox.com/service/foo", "foo"}},
    {{"spiffe://prod.dropbox.com/user/boo"}, {"spiffe://prod.dropbox.com/user/boo", "user.boo"}},
    {{"spiffe://prod.dropbox.com/host/strange"}, {"spiffe://prod.dropbox.com/host/strange", "host.strange"}},
    {{"spiffe://corp.dropbox.com/user/bad-prefix"}, {"", ""}},
  };
  for (auto& test : tests) {
    EXPECT_CALL(*ssl_, uriSanPeerCertificate()).WillOnce(testing::Return(test.uris));
    EXPECT_EQ(GetIdentity(ssl_), test.expected);
  }
}

Наличие двухсекундных тестовых циклов оказывает комплексное влияние на производительность. Это дает нам возможность приложить больше усилий для увеличения охвата тестами. Возможность выбора между unit-тестами и интеграционными тестами позволяет нам сбалансировать охват, скорость и стоимость тестов Envoy.

Bazel — одна из лучших вещей, которые когда-либо случались с нашими разработчиками. У него очень крутая кривая обучения и большие первоначальные вложения, но у него также очень высокая отдача: инкрементные сборки, удаленное кэширование, распределенные сборки / тесты и т.д.

Одним из менее обсуждаемых преимуществ Bazel является то, что он дает нам возможность запрашивать и даже расширять граф зависимостей. Программный интерфейс к графу зависимостей в сочетании с общей системой сборки для всех языков является очень мощной функцией. Его можно использовать в качестве основного строительного блока линтеров, генерации кода, отслеживания уязвимостей, системы развертывания и т.д.


Безопасность

Кодовая база Nginx весьма небольшая, с минимальными внешними зависимостями. Обычно можно увидеть только три внешних зависимости полученного бинарного файла: zlib (или более быстрый вариант), какая-либо библиотека TLS и PCRE. В Nginx реализованы все парсеры протоколов, библиотеки для работы с событиями., а также разработчики дошли до того, что повторно написали некоторые функции из libc.

Некоторое время Nginx считался настолько безопасным, что использовался в качестве вебсервера по умолчанию в OpenBSD. Однако после конфликта двух сообществ разработчики OpenBSD начали разработку httpd. Более подробно можно почитать в докладе с BSDCon.

Минимализм окупился на практике, у Nginx было всего 30 известных уязвимостей за последниее 11 лет.

У Envoy кода гораздо больше, особенно если учитывать, что код C++ более плотный, чем код на C, используемый в Nginx. Также сюда включены миллионы строк из внешних зависимостей. Все, начиная от уведомлений от событий, до парсеров протоколов выделяется в сторонние библиотеки. Это увеличивает плоскость атаки и раздувает результирующий бинарный файл.

Для противостояния Envoy в значительной степени опирается на современные методы обеспечения безопасности. Для этого используются AddressSanitizer, ThreadSanitizer и MemorySanitizer. Также разработчики пошли дальше и стали использовать fuzzing.

Любой проект с открытым исходным кодом, который имеет решающее значение для глобальной инфраструктуры IT, может быть принят в OSS-Fuzz, бесплатную платформу для автоматического fuzzing. Узнать больше можно здесь.

На практике, однако, все эти меры предосторожности не в полной мере успевают за ростом кодовой базы. Так за последние два года было найдено 22 уязвимости.

Для Envoy подробно описана политика безопасности для выпусков, также есть описание и для отдельных уязвимостей. Envoy является участником Google’s Vulnerability Reward Program (VRP). Google по этой программе, открытой для всех исследователей, предоставляет вознаграждения за обнаруженные уязвимости и сообщает о них в соотвествии с их правилами.

Примером того, как некоторые уязвимости потенциально могут быть использованы, служит CVE-2019–18801

Для противодействия рискам уязвимостей мы применяем лучшие методы защиты от наших поставщиков операционных систем Ubuntu и Debian, а именно специальный hardened профиль для всех наших бинарных файлов, работающих на Edge. Он включает в себя ASLR, защиту стека и таблицы символов:

build:hardened --force_pic
build:hardened --copt=-fstack-clash-protection
build:hardened --copt=-fstack-protector-strong
build:hardened --linkopt=-Wl,-z,relro,-z,now

Вебсервера с использованием fork, такие как Nginx, в большинстве окружений имеют проблемы с защитой стека, поскольку основной и рабочие процессы разделяют одно и то же значение для переменной-канарейки в стеке, а поскольку при проверке этой переменной сбойный рабочий процесс убивается — значение этой переменной можно перебрать бит за битом примерно за 1000 попыток. У Envoy, который работает с потоками, не подвержен этой атаке.

Аналогично мы усиливаем сторонние зависимости при сборке, где это возможно. Так мы применяем BoringSSL в режиме FIPS, который включает в себя самопроверку при запуске и проверку целостности бинарного файла. Мы также рассматриваем возможность запуска бинарников с поддержкой ASAN на некоторых наших канареечных серверах на Edge.


Возможности

Наиболее спорная часть этой статьи, держите себя в руках.

Nginx начинался как вебсервер, который специализируется на обслуживании статических файлов с минимальным потреблением ресурсов. Его функции по порядку: работа с статикой, кэширование (включая защиту при скачках трафика), кэширование диапазона.

Со стороны прокси у Nginx отсутствуют функции, необходимые современной инфраструктуре. Например нету поддержки HTTP/2 для бэкэндов, проксирование gRPC есть, но без мультиплексирования соединений. Отсутствует поддержка транскодирования gRPC. Кроме того при использовании модели «открытое ядро» есть ограничения по возможностям, которые входят в версию с открытым исходным кодом. В результате некоторые из важных функций, например статистика, недоступны в версии, поддерживаемой сообществом.

Envoy наоборот развивался как ingress\egress прокси, чаще всего используемый для окружений с использованием gRPC и в хвост и в гриву. Его функции в качестве вебсервера рудиментарная: нету раздачи файлов, кэширование все еще не реализовано до конца, нету поддержки сжатия. Для подобных случаев мы все еще держим запасные Nginx, которые используются Envoy в качестве upstream кластера.

Когда кэширование в Envoy будет реализовано, мы сможем перенести на Envoy большинство сценариев раздачи статических файлов с использованием S3 вместо файловой системы в качестве хранилища. Можно почитать больше про eCache, кэш HTTP с несколькими бэкэндами для Envoy.

Также у Envoy уже есть встроенная поддержка многих возможностей, связанных с gRPC:


  • Проксирование gRPC, базовая способность, которая позволила нам использовать gRPC в наших приложениях (например клиент Dropbox для настольных компьютеров)
  • Поддержка HTTP/2 для бэкэндов, позволила нам значительно сократить число соединений TCP между уровнями трафика, уменьшая потребление памяти и поддерживающий трафик.
  • Мосты gRPC→HTTP (и обратно), с помощью которых мы можем публиковать старые приложения HTTP/1 с использованием современного стека gRPC.
  • gRPC→WEB, с помощью которого мы применяем сквозной gRPC там, где промежуточные узлы (firewall, IDS и прочие) еще не умеют работать с HTTP/2.
  • gRPC JSON transcoder, с которым мы смогли перекодировать весь входящий трафик, включая публичные API Dropbox, из REST в gRPC.

Кроме прочего Envoy может работать и как исходящий прокси, так мы его используем в дополнение в следующих случаях:


  • Egress прокси, поскольку Envoy умеет в метод HTTP CONNECT — его можно использовать в 

    © Habrahabr.ru