[Перевод] Courier: мигрирование Dropbox на gRPC

eup8y-m8hpxc9w2qhvo1qofrmog.jpeg
Большинство современных программных продуктов не являются монолитными, а состоят из множества частей, которые взаимодействуют друг с другом. При таком положении дел необходимо, чтобы общение взаимодействующих частей системы происходило на одном языке (притом что сами эти части могут быть написаны на разных языках программирования и выполняться на разных машинах). Упростить решение этой задачи помогает gRPC — open-source-фреймворк от Google, выпущенный в 2015 году. Он решает сразу ряд проблем, позволяя:

  • использовать язык Protocol Buffers для описания взаимодействия сервисов;
  • генерировать программный код на основании описанного протокола для 11 разных языков как для клиентской части, так и для серверной;
  • реализовать авторизацию между взаимодействующими компонентами;
  • использовать как синхронное, так и асинхронное взаимодействие.

gRPC показался мне довольно интересным фреймворком, и мне было интересно узнать про реальный опыт компании Dropbox по построению системы на его основе. В статье есть масса деталей, связанных с использованием шифрования, построением надёжной, наблюдаемой и производительной системы, процессом миграции со старого RPC-решения на новое.

Дисклеймер
Оригинальная статья не содержит описания gRPC, и некоторые моменты могут показаться вам непонятными. Если вы не знакомы с gRPC или другими подобными фреймворками (например, Apache Thrift), рекомендую предварительно ознакомиться с основными идеями (достаточно будет прочитать две небольшие статьи с официального сайта: «What is gRPC?» и «gRPC Concepts»).

Спасибо Алексею Иванову aka SaveTheRbtz за написание оригинальной статьи и помощь с переводом трудных мест.


Dropbox управляет множеством сервисов, написанных на разных языках и обслуживающих миллионы запросов в секунду. В центре нашей сервис-ориентированной архитектуры находится Courier, RPC-фреймворк на основе gRPC. В процессе его разработки мы многое узнали о расширяемости gRPC, оптимизации быстродействия и переходе с прежней RPC-системы.

Примечание: в посте содержатся фрагменты кода на Python и Go. Мы также используем Rust и Java.


Courier — не первый RPC-фреймворк Dropbox. Ещё до того как мы начали дробить монолитную Python-систему на отдельные службы, нам требовалась надёжная основа для обмена данными между службами — тем более что выбор фреймворка будет иметь отдалённые последствия.

До этого в Dropbox экспериментировали с разными RPC-фреймворками. Сначала у нас был индивидуальный протокол для ручной сериализации и десериализации. Некоторые сервисы, например, средство ведения журнала на основе Scribe, использовали Apache Thrift. При этом наш основной RPC-фреймворк представлял собой HTTP/1.1-протокол с сообщениями, сериализованными при помощи Protobuf.

Создавая фреймворк, мы выбирали из нескольких вариантов. Мы могли в старый RPC-фреймворк ввести Swagger (ныне известный как OpenAPI), ввести новый стандарт или построить фреймворк на базе Thrift или gRPC. Главным аргументом в пользу gRPC стала возможность использования уже существовавших protobuf«ов. Также для наших задач были полезны мультиплексный HTTP/2 и двусторонняя передача данных.

Примечание: если бы в тот момент существовал fbthrift, мы, возможно, присмотрелись бы к Thrift-решениям повнимательнее.


Courier — это не RPC-протокол; это средство интеграции gRPC в существовавшую инфраструктуру. Фреймворк должен был быть совместим с нашими инструментами аутентификации, авторизации и обнаружения службы, а также сбора статистики, журналирования и отслеживания. Так мы создали Courier.

Хотя в некоторых случаях мы и используем Bandaid в качестве gRPC-прокси, большинство наших сервисов взаимодействуют друг с другом напрямую, чтобы минимизировать влияние RPC на время ожидания.

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

Безопасность: идентификация сервиса и взаимная аутентификация TLS


Courier реализует наш стандартный механизм идентификации сервисов. Каждому серверу и клиенту присвоен индивидуальный TLS-сертификат, выданный нашим собственным удостоверяющим центром. В сертификате закодирован личный идентификатор, который используется для взаимной аутентификации — сервер верифицирует клиента, клиент верифицирует сервер.

В TLS, где мы контролируем обе стороны соединения, мы ввели жёсткие ограничения. Для всех внутренних RPC обязательно PFS-шифрование. Требуемая версия TLS — 1.2 и выше. Мы также ограничили число симметричных и асимметричных алгоритмов, отдав предпочтение ECDHE-ECDSA-AES128-GCM-SHA256.

После прохождения идентификации и дешифровки запроса сервер проверяет, есть ли у клиента необходимые полномочия. Списки контроля доступа (ACL) и лимиты скорости можно настроить как для сервисов в целом, так и для отдельных методов. Их параметры также можно изменить через нашу распределённую файловую систему (AFS). Благодаря этому владельцы сервисов могут сбрасывать нагрузку за считанные секунды, даже не перезапуская процессы. О подписке на уведомления и обновлении конфигурации позаботится Courier.

Служба Identity — глобальный идентификатор для ACL, лимитов скорости, статистики и т. д. Плюс ко всему она криптографически безопасна.

Вот пример конфигурации ACL и лимита скорости, используемой в нашем сервисе оптического распознавания образов:

limits:
  dropbox_engine_ocr:
    # All RPC methods.
    default:
      max_concurrency: 32
      queue_timeout_ms: 1000

      rate_acls:
        # OCR clients are unlimited.
        ocr: -1
        # Nobody else gets to talk to us.
        authenticated: 0
        unauthenticated: 0

llasl_0osxrxbboxrbjqaalb8ti.png

Мы рассматриваем возможность перехода на формат SVID (криптографически верифицируемый документ SPIFFE), что поможет совместить наш фреймворк со многими проектами с открытым кодом.

Наблюдаемость: статистика и отслеживание


При помощи одного лишь идентификатора вы с лёгкостью можете находить логи, статистику, файлы трассировки и другие данные о Courier.

t_253ogfq1bizretdswvdrb_7g0.png

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

g16g_1z72oxaxwwvasrjhznfltk.png

Статистика Courier включает в себя данные о доступности и величине задержки со стороны клиента, а также о количестве запросов и размере очереди со стороны сервера. Есть и другие полезные графики, в частности гистограммы времени ожидания ответа для каждого метода и времени TLS-рукопожатий для каждого клиента.

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

p76-fvobtszsgtu2g2tlmwfwlyk.png

Прежняя RPC-система только распространяла request_id по API. Это давало возможность объединения данных из логов разных сервисов. В Courier мы представили API, основанный на подмножестве спецификаций OpenTracing. Мы написали собственные библиотеки на стороне клиента, а на стороне сервера внедрили решение на базе Cassandra и Jaeger.

dkhviqgdiem3itipi7x87tri_i8.png

Трассировка позволяет нам генерировать диаграммы зависимостей сервиса во время исполнения. Это помогает инженерам видеть все транзитивные зависимости конкретного сервиса. Кроме того, функция полезна для отслеживания нежелательных зависимостей после развёртывания.

Надёжность: дедлайны и разрыв соединения


Courier предоставляет центральное место для реализации на разных языках общих клиентских функций (например, тайм-аутов). Мы постепенно добавляли различные возможности, часто по итогам «посмертного» анализа возникающих проблем.

Дедлайны


У каждого gRPC-запроса есть дедлайн, обозначающий время ожидания клиента. Поскольку стабы Courier автоматически распространяют известные метаданные, дедлайн запроса передаётся даже за пределы API. Внутри процесса дедлайны получают нативное отображение. Например, в Go они представлены результатом context.Context из метода WithDeadline.

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

Данный подход выходит даже за пределы RPC. Например, наша ORM MySQL сериализует контекст RPC вместе с дедлайном в комментарии SQL-запроса. Наш SQL-прокси может парсить комментарии и «убивать» запросы при наступлении дедлайна. И в качестве бонуса при отладке обращений к базе данных у нас есть привязка SQL-запроса к конкретному RPC-запросу.

Разрыв соединения


Ещё одна распространённая проблема, с которой сталкивались клиенты предыдущей RPC-системы, — реализация алгоритма индивидуальной экспоненциальной задержки и колебаний при повторном запросе.

Мы постарались найти интеллектуальное решение проблемы разрыва соединения в Courier, начав с внедрения буфера LIFO (last in, first out) между сервисом и пулом задач.

thsrt0kbgxgnexrr9figp9ymg_a.png

В случае перегрузки LIFO автоматически разорвёт соединение. Очередь, что важно, ограничивается не только по размеру, но и по времени (запрос может провести в очереди лишь определённое время).

Минус LIFO — изменение порядка обработки запросов. Если вы хотите сохранить исходный порядок, используйте CoDel. Там тоже есть возможность разрыва соединения, а порядок обработки запросов останется прежним.

7gw_udkbrsesseak2xrxaucw7fi.png

Интроспекция: отладочные эндпойнты


Хотя отладочные эндпойнты не являются непосредственно частью Courier, они широко используются по всему Dropbox и слишком полезны, чтобы о них не упомянуть.

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

Исполнение


Возможность проанализировать состояние сервиса во время его работы очень полезна для отладки. Например, профили динамической памяти и CPU могут быть доступны через HTTP- или gRPC-эндпойнты.

Мы планируем пользоваться этой возможностью при канареечной процедуре верификации — для автоматизации поиска разницы между старой и новой версиями кода.

Эндпойнты дают возможность модифицировать состояние сервиса во время исполнения. В частности, сервисы на основе Golang могут динамически настраивать GCPercent.

Библиотека


Функция автоматического экспорта данных, относящихся к конкретной библиотеке, в виде RPC-эндпойнта может оказаться полезной для разработчиков библиотек. Например, библиотека malloc может выгружать в дамп внутреннюю статистику. Другой пример: отладочный эндпойнт может изменять уровень логирования сервиса на лету.

RPC


Безусловно, устранение неисправностей в зашифрованных и закодированных протоколах — дело нелёгкое. Следовательно, внедрение как можно большего числа инструментов на RPC-уровне — хорошая идея. Одним из примеров подобного интроспективного API служит решение Channelz.

Уровень приложения


Возможность изучить параметры уровня приложения также может быть полезна. Удачный пример — эндпойнт с общей информацией о приложении (с хешем файлов исходников или сборки, командной строкой и т. д.). Он может быть использован системой оркестрации для проверки целостности при развёртывании сервиса.
Разворачивая наш gRPC-фреймворк до необходимого масштаба, мы обнаружили несколько узких мест, специфичных для Dropbox.

Ресурсопотребление TLS-рукопожатий


В сервисах, обслуживающих множество взаимосвязей, в результате TLS-рукопожатий совокупная загрузка CPU может быть достаточно серьёзной (в особенности при перезагрузке популярного сервиса).

Для того чтобы улучшить быстродействие при осуществлении подписи, мы заменили пары ключей RSA-2048 на ECDSA P-256. Вот примеры их производительности (обратите внимание: с RSA верификация подписи проходит быстрее).

RSA:

~/c0d3/boringssl bazel run -- //:bssl speed -filter 'RSA 2048'
Did ... RSA 2048 signing operations in ..............  (1527.9 ops/sec)
Did ... RSA 2048 verify (same key) operations in .... (37066.4 ops/sec)
Did ... RSA 2048 verify (fresh key) operations in ... (25887.6 ops/sec)

ECDSA:

~/c0d3/boringssl bazel run -- //:bssl speed -filter 'ECDSA P-256'
Did ... ECDSA P-256 signing operations in ... (40410.9 ops/sec)
Did ... ECDSA P-256 verify operations in .... (17037.5 ops/sec)


Поскольку верификация с RSA-2048 проходит примерно в три раза быстрее, чем с ECDSA P-256, для увеличения скорости работы вы можете выбрать RSA для корневых и конечных сертификатов. Но с точки зрения безопасности не всё так однозначно: вы будете выстраивать цепочки различных криптографических примитивов, а следовательно, уровень получившихся параметров безопасности будет самым низким. И если вы хотите улучшить быстродействие, не советуем использовать сертификаты версии RSA-4096 (и выше) в качестве корневых и конечных.

Также мы установили, что выбор TLS-библиотеки (и флагов компиляции) оказывает существенное влияние как на производительность, так и на безопасность. Сравним, например, билд LibreSSL на macOS X Mojave с самописной OpenSSL на том же железе.

LibreSSL 2.6.4:

~ openssl speed rsa2048
LibreSSL 2.6.4
...
                  sign    verify    sign/s verify/s
rsa 2048 bits 0.032491s 0.001505s     30.8    664.3

OpenSSL 1.1.1a:

 ~ openssl speed rsa2048
OpenSSL 1.1.1a  20 Nov 2018
...
                  sign    verify    sign/s verify/s
rsa 2048 bits 0.000992s 0.000029s   1208.0  34454.8

Впрочем, самый быстрый способ создания TLS-рукопожатия — не создавать его совсем! Мы включили в gRPC-core и gRPC-python поддержку возобновления сессии, снизив тем самым нагрузку на CPU при развёртывании.

Шифрование — это недорого


Многие ошибочно полагают, что шифрование — это дорого. На самом же деле даже самые простые современные компьютеры осуществляют симметричное шифрование практически молниеносно. Стандартный процессор способен шифровать и удостоверять подлинность данных со скоростью 40 Гб/с на каждое ядро:

~/c0d3/boringssl bazel run -- //:bssl speed -filter 'AES'
Did ... AES-128-GCM (8192 bytes) seal operations in ... 4534.4 MB/s

Тем не менее нам всё же пришлось настраивать gRPC под наши блоки памяти, работающие со скоростью 50 Гб/с. Мы установили, что если скорость шифрования примерно равна скорости копирования, то важно максимально сократить число операций memcpy. Кроме того, мы внесли некоторые изменения и в сам gRPC.

Аутентифицированные и зашифрованные протоколы позволили избежать многих неприятных проблем (например, повреждения данных процессором, DMA или в сети). Даже если вы не пользуетесь gRPC, советуем пользоваться TLS для внутренних контактов.

Каналы передачи данных с высокой задержкой (BDP)


Примечание переводчика: в оригинальном подзаголовке использовался термин bandwidth-delay product, у которого нет устоявшегося перевода на русский язык.

Магистральная сеть Dropbox включает в себя множество дата-центров. Иногда узлам, находящимся в разных регионах, приходится коммуницировать посредством RPC, например, для репликации. При использовании TCP ядро системы отвечает за ограничение количества данных, передающихся в конкретном соединении (в пределах /proc/sys/net/ipv4/tcp_{r, w}mem), хотя у gRPC, основанного на HTTP/2, имеется и собственное средство управления потоками. Верхняя граница BDP в grpc-go строго ограничена 16 Мб, что может спровоцировать появление узкого места.

net.Server Golang или grpc.Server


Изначально в своём коде на Go мы поддерживали HTTP/1.1 и gRPC с помощью одного net.Server. Решение имело смысл с точки зрения сопровождения программного кода, но работало отнюдь не идеально. Распределение HTTP/1.1 и gRPC по разным серверам и переход gRPC на grpc.Server значительно улучшили пропускную способность и использование памяти сервисами Courier.

golang/protobuf или gogo/protobuf


Переход на gRPC может увеличить стоимость маршалинга и демаршалинга. Для кода на Go мы смогли заметно снизить загрузку CPU на серверах Courier, перейдя на gogo/protobuf.

Как и всегда, переход на gogo/protobuf сопровождался некоторыми опасениями, но, если вы разумно ограничите функциональность, проблем быть не должно.


В данном разделе мы глубже проникнем в устройство Courier, рассмотрим protobuf-схемы и примеры стабов из различных языков. Все примеры взяты из сервиса Test, который мы использовали во время интеграционного тестирования Courier.

Описание сервиса


Взгляните на отрывок из определения сервиса Test:

service Test {
    option (rpc_core.service_default_deadline_ms) = 1000;

    rpc UnaryUnary(TestRequest) returns (TestResponse) {
        option (rpc_core.method_default_deadline_ms) = 5000;
    }

    rpc UnaryStream(TestRequest) returns (stream TestResponse) {
        option (rpc_core.method_no_deadline) = true;
    }
    ...
}

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

option (rpc_core.service_default_deadline_ms) = 1000;

При этом для каждого метода может быть задан собственный дедлайн, отменяющий дедлайн всего сервиса (если таковой имеется):

option (rpc_core.method_default_deadline_ms) = 5000;

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

option (rpc_core.method_no_deadline) = true;

В дополнение к этому описание сервиса должно содержать подробную документацию API, возможно, с примерами использования.

Генерация стабов


Для обеспечения большей гибкости Courier генерирует собственные стабы, не полагаясь на функционал перехватчиков, предоставляемый gRPC (за исключением Java, API перехватчиков в котором обладает достаточной мощью). Давайте сравним наши стабы со стандартными стабами Golang.

Вот так выглядят стабы gRPC-сервера по умолчанию:

func _Test_UnaryUnary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
        in := new(TestRequest)
        if err := dec(in); err != nil {
                return nil, err
        }
        if interceptor == nil {
                return srv.(TestServer).UnaryUnary(ctx, in)
        }
        info := &grpc.UnaryServerInfo{
                Server:     srv,
                FullMethod: "/test.Test/UnaryUnary",
        }
        handler := func(ctx context.Context, req interface{}) (interface{}, error) {
                return srv.(TestServer).UnaryUnary(ctx, req.(*TestRequest))
        }
        return interceptor(ctx, in, info, handler)
}

Вся обработка происходит внутри: декодирование protobuf«а, запуск перехватчиков (см. переменную interceptor в коде), запуск обработчика UnaryUnary.

Теперь взгляните на стабы Courier:

func _Test_UnaryUnary_dbxHandler(
        srv interface{},
        ctx context.Context,
        dec func(interface{}) error,
        interceptor grpc.UnaryServerInterceptor) (
        interface{},
        error) {

        defer processor.PanicHandler()

        impl := srv.(*dbxTestServerImpl)
        metadata := impl.testUnaryUnaryMetadata

        ctx = metadata.SetupContext(ctx)
        clientId = client_info.ClientId(ctx)
        stats := metadata.StatsMap.GetOrCreatePerClientStats(clientId)
        stats.TotalCount.Inc()

        req := &processor.UnaryUnaryRequest{
                Srv:            srv,
                Ctx:            ctx,
                Dec:            dec,
                Interceptor:    interceptor,
                RpcStats:       stats,
                Metadata:       metadata,
                FullMethodPath: "/test.Test/UnaryUnary",
                Req:            &test.TestRequest{},
                Handler:        impl._UnaryUnary_internalHandler,
                ClientId:       clientId,
                EnqueueTime:    time.Now(),
        }

        metadata.WorkPool.Process(req).Wait()
        return req.Resp, req.Err
}

Тут довольно много кода, так что давайте разберём его.

Сначала мы откладываем вызов обработчика panic, который отвечает за автоматический сбор ошибок. Это позволит нам собрать все непойманные исключения в центральном хранилище для последующей агрегации и создания отчётов:

defer processor.PanicHandler()

Ещё одна причина, по которой мы запускаем собственный обработчик panic, — желание удостовериться в том, что в случае ошибки произойдёт аварийное завершение приложения. Стандартный обработчик golang/net HTTP в таком случае будет игнорировать проблему и продолжит обслуживать новые запросы (даже повреждённые и рассогласованные).

Затем мы передаём контекст дальше, переопределяя значения на основе метаданных входящего запроса:

ctx = metadata.SetupContext(ctx)
clientId = client_info.ClientId(ctx)

Мы также создаём (и кешируем для большей эффективности) статистику по клиентам на стороне сервера для более детальной агрегации:

stats := metadata.StatsMap.GetOrCreatePerClientStats(clientId)


Эта строчка создаёт статистику по каждому клиенту (то есть TLS-идентификатору) в ходе выполнения. Также у нас имеется статистика по всем методам для каждого сервиса. Поскольку у генератора стабов есть доступ ко всем методам в ходе кодогенерации, мы можем предварительно статически их создавать, тем самым избегая замедления выполнения программы.

После этого мы создаём структуру запроса, передаём её в пул задач и ждём выполнения:

req := &processor.UnaryUnaryRequest{
        Srv:            srv,
        Ctx:            ctx,
        Dec:            dec,
        Interceptor:    interceptor,
        RpcStats:       stats,
        Metadata:       metadata,
        ...
}
metadata.WorkPool.Process(req).Wait()

Обратите внимание, что к этому моменту мы не произвели ни декодирование protobuf«а, ни запуск перехватчика. Перед этим внутри пула задач должны пройти проверка доступа, приоритизация и ограничение количества выполняемых запросов.

Заметим, что gRPC-библиотека поддерживает интерфейс TAP, что позволяет перехватывать запросы с огромной скоростью. Интерфейс предоставляет инфраструктуру для построения эффективных ограничителей скорости с минимальным ресурсопотреблением.

Специфичные коды ошибок для разных приложений


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

enum ErrorCode {
  option (rpc_core.rpc_error) = true;

  UNKNOWN = 0;
  NOT_FOUND = 1 [(rpc_core.grpc_code)="NOT_FOUND"];
  ALREADY_EXISTS = 2 [(rpc_core.grpc_code)="ALREADY_EXISTS"];
  ...
  STALE_READ = 7 [(rpc_core.grpc_code)="UNAVAILABLE"];
  SHUTTING_DOWN = 8 [(rpc_core.grpc_code)="CANCELLED"];
}

В пределах сервиса распространяются ошибки и gRPC, и приложения, а на границе API все ошибки заменяются на UNKNOWN. Благодаря этому мы можем избежать переноса проблемы на другие сервисы, что может вылиться в изменение их семантики.

Изменения, касающиеся Python


Стабы Python добавляют явный параметр контекста во все обработчики Courier:

from dropbox.context import Context
from dropbox.proto.test.service_pb2 import (
        TestRequest,
        TestResponse,
)
from typing_extensions import Protocol

class TestCourierClient(Protocol):
    def UnaryUnary(
            self,
            ctx,      # type: Context
            request,  # type: TestRequest
            ):
        # type: (...) -> TestResponse
        ...

Поначалу это выглядело странновато, но со временем разработчики привыкли к явному ctx так же, как раньше привыкли к self.

Обратите внимание, что наши стабы полностью типизированы для mypy, что компенсируется во время крупного рефакторинга. Помимо этого, упрощается интеграция с некоторыми IDE (например, PyCharm).

Продолжая следовать тренду на статическую типизацию, мы добавляем mypy-аннотации к самим протоколам:

class TestMessage(Message):
    field: int

    def __init__(self,
        field : Optional[int] = ...,
        ) -> None: ...
    @staticmethod
    def FromString(s: bytes) -> TestMessage: ...

Эти аннотации позволят избежать множества распространённых багов, таких как присвоение значения None полю типа string, например.

Этот код доступен по ссылке.


Создание нового RPC-стека — задача непростая, но она и рядом не стоит с процессом полноценного перехода на него, если смотреть с точки зрения сложности эксплуатации. Поэтому мы постарались максимально упростить для разработчиков переход со старого RPC на Courier. Поскольку миграции нередко сопутствуют ошибки, мы решили осуществлять её поэтапно.

Шаг 0: замораживаем старый RPC


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

Шаг 1: общий интерфейс для старого RPC и Courier


Мы начали с того, что задали общий интерфейс для старого RPC и Courier. Наша кодогенерация должна была гарантировать, что обе версии стабов соответствуют этому интерфейсу:

type TestServer interface {
   UnaryUnary(
      ctx context.Context,
      req *test.TestRequest) (
      *test.TestResponse,
      error)
   ...
}

Шаг 2: миграция на новый интерфейс


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

Простые сервисы с небольшим количеством методов и правом на ошибку можно мигрировать одномоментно, не обращая внимания на наши предостережения.

Шаг 3: переход клиентов на RPC Courier


В процессе миграции мы стали одновременно запускать и старые, и новые серверы на разных портах одной и той же машины. Переключение реализации RPC на стороне клиента делалось путём изменения одной строки:

class MyClient(object):
  def __init__(self):
-   self.client = LegacyRPCClient('myservice')
+   self.client = CourierRPCClient('myservice')

Обратите внимание на то, что с такой моделью можно переносить по одному клиенту за раз, начиная с тех, у кого более низкий уровень SLA.

Шаг 4: чистка


После того как все клиенты были перенесены, пришло время удостовериться, что старый RPC больше не используется (это можно сделать статически с помощью инспекции кода и во время выполнения с помощью серверной статистики). После этого разработчики могут начать чистку — удаление устаревшего кода.
Итак, Courier — унифицированный RPC-фреймворк, ускоряющий разработку сервисов, упрощающий эксплуатацию и повышающий надёжность Dropbox.

Вот какие выводы мы сделали, разрабатывая и развёртывая Courier:

  1. Наблюдаемость — огромный плюс. Наличие всей необходимой статистики окажется незаменимым для диагностики и устранения неполадок.
  2. Стандартизация и однородность очень важны — они снижают когнитивную нагрузку, упрощают эксплуатацию и сопровождение кода.
  3. Постарайтесь минимизировать количество рутинного кода, создаваемого разработчиками. В этом вам поможет Codegen.
  4. Максимально упростите процесс миграции. Возможно, он займёт больше времени, чем сама разработка. Кроме того, запомните: миграция завершается после окончания удаления старого кода.
  5. В RPC-фреймворк можно включить улучшения для всей инфраструктуры — обязательные дедлайны, защиту от перегрузки и т. д. Общие проблемы с надёжностью можно выявить в ходе изучения квартальных отчётов.


Courier, как и gRPC в целом, не стоит на месте, поэтому мы закончим статью планами команд, отвечающих за среду выполнения и надёжность.

В ближайшем будущем мы хотим внедрить в код gRPC на Python механизм разрешения конфликтов, перейти на связывания на C++ в Python и Rust и добавить полную поддержку шаблона разрыва соединения и внесения неисправностей. Ближе к концу года мы планируем изучить протокол ALTS и вынести TLS-рукопожатие в отдельный процесс (возможно, даже за пределы контейнера сервисов).

© Habrahabr.ru