Всем привет!


Я работаю бэкенд-разработчиком в компании Tinkoff, где участвую в разработке платформы CRM-системы для обслуживания физических и юридических лиц.


Использование edge proxy и балансировщика в частности — это почти мастхэв при построении современных систем. Сегодня на рынке представлено большое количество разнообразных решений, у каждого из которых есть преимущества и недостатки. Мы остановимся на одном из самых свежих — Envoy.


Envoy — это высокопроизводительный балансировщик, реализованный на C++. Его разработала компания Lyft — сервис заказа такси в Штатах, прямой конкурент Uber — для использования как с отдельными сервисами, так и в качестве связующего звена в сложных микросервисных системах. В том числе для реализации относительно свежего архитектурного явления — service mesh.


Формируя основной фундамент нашей платформы, он реализует cors, access-control, rate limiting, outlier detection, проверку jwt и многое другое.


На Хабре есть отличная статья, которая разбирает его основные отличия от ближайших соседей и проливает свет на внутреннее устройство. Мы же сфокусируемся больше на прикладных моментах, разберемся с запуском и настройкой, попробуем сразу несколько видов балансировки трафика. Поехали!


Для начала нам понадобится upstream-сервис-заглушка, обрабатывающий http-запросы и позволяющий в ответе идентифицировать каждый свой инстанс. Вы можете реализовать этот функционал сами либо, как и я, использовать доступный echo-server на Go. Удобнее всего развернуть несколько инстансов сразу в docker, назначив каждому свой порт. Я поднял три инстанса: на 8081, 8082, 8083 портах, запрос на каждый из которых в том числе возвращает container id.


Например, запрос:


curl -v localhost:8081

Возвращает:


Request served by a29f0fba3451

HTTP/1.1 GET /

Host: localhost:8081
User-Agent: curl/7.64.1
Accept: */*

Где a29f0fba3451 — идентификатор контейнера.


Теперь перейдем непосредственно к настройке Envoy. Он распространяется так же, как docker — образ с уже готовым примером конфигурации.


Конфигурация по-умолчанию
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 127.0.0.1, port_value: 9901 }

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 10000 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { host_rewrite: www.google.com, cluster: service_google }
          http_filters:
          - name: envoy.router
  clusters:
  - name: service_google
    connect_timeout: 0.25s
    type: LOGICAL_DNS
    # Comment out the following line to test on v6 networks
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    hosts: [{ socket_address: { address: google.com, port_value: 443 }}]
    tls_context: { sni: www.google.com }

Здесь несколько основных моментов:


  1. Порт, на котором запускается listener.
  2. Virtual host по имени домена.
  3. Конфигурация фильтров. Главная точка расширения для нас — настройка routes, обработка входных и выходных запросов.
  4. Cluster — логически объединенные upstream-инстансы с параметрами балансировщика.
  5. Endpoint — непосредственно upstream-инстанс с адресом и метаданными.

Я выделил основные сущности, настраиваемые с помощью сервисов LDS, VHDS, RDS, CDS и EDS соответственно.


Стоит добавить, что кроме статической конфигурации из yaml-файла существует и динамическая. В этом случае нам необходимо реализовать control-plane-сервис и API для передачи конфигурации envoy по gRPС-протоколу. В репозитории Envoy есть уже готовые реализации на Go и Java. Динамический подход будет крайне полезен в случае сложной конфигурации вашей системы и необходимости реагировать на ее изменения в рантайме.


Вернемся к конфигу и адаптируем под наши нужды.


Для начала добавим новый кластер echo_cluster и настроим список действующих эндпоинтов (апстрим-сервисов), заполнив поле load_assignment.endpoints адресами наших инстансов echo-server.


Конфигурация кластера с тремя инстансами echo-server
clusters:
    - name: echo_cluster
      connect_timeout: 3s
      type: STRICT_DNS
      dns_lookup_family: V4_ONLY
      load_assignment:
        cluster_name: echo_cluster
        endpoints:
        - lb_endpoints:
          - endpoint:
              address:
                socket_address:
                  address: docker.for.mac.localhost
                  port_value: 8081
          - endpoint:
              address:
                socket_address:
                  address: docker.for.mac.localhost
                  port_value: 8082
          - endpoint:
              address:
                socket_address:
                  address: docker.for.mac.localhost
                  port_value: 8083

Осталось настроить только матчинг входящих запросов. Для этого в фильтре envoy.http_connection_manager необходимо задекларировать роуты наших апстрим-сервисов. Согласно официальной документации, есть несколько способов сделать это. Например, можно отправлять все запросы с префиксом "/echo" на соответствующий кластер.


Конфигурация фильтра
    - filters:
      - name: envoy.http_connection_manager
          typed_config:
            "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
            stat_prefix: echo
            codec_type: AUTO
            route_config:
              name: local_route
              virtual_hosts:
              - name: local_service
                domains: ["*"]
                routes:
                - match: { prefix: "/echo" }
                  route: { cluster: echo_cluster }
            http_filters:
            - name: envoy.router

Теперь положим dockerfile:


FROM envoyproxy/envoy:v1.13.0
COPY envoy.yaml /etc/envoy/envoy.yaml

рядом с этим конфигом, соберем и запустим:


docker build -t envoy:v1 .
docker run -p 8080:8080 --rm envoy:v1

И это всё! Теперь все http-запросы на балансировщик с префиксом "/echo" Envoy будет распределять между тремя инстансами echo-server.


curl localhost:8080/echo

Request served by a29f0fba3451

HTTP/1.1 GET /echo

Host: localhost:8080
User-Agent: curl/7.64.1
Accept: */*
X-Forwarded-Proto: http
X-Request-Id: dd4b850c-9b4e-45e5-a411-4b76293b1e33
X-Envoy-Expected-Rq-Timeout-Ms: 15000
Content-Length: 0

Все остальные запросы будут возвращать 404.


Результирующая схема системы

image


В качестве алгоритма балансировки по умолчанию используется round robin (перебор по круговому циклу), который закрывает потребности большинства современных систем.


Я написал небольшой скрипт для проверки распределения запросов между апстрим-нодами. Ссылка на исходники с примерами будет в заключении.


Гистограмма распределения 500 запросов GET '/echo' по round-robin


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


Балансировка на основе consistent hashing


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


Добавим 'lb_policy: RING_HASH' в конфигурацию нашего кластера
  clusters:
    - name: echo_cluster
      lb_policy: RING_HASH

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


Используем id_key в качестве ключа
    - match: { prefix: "/echo" }
      route: {
         cluster: echo_cluster, 
           hash_policy: { 
             header: { 
               header_name: id_key
            }
          }
        }

Но что, если нам неудобно передавать ключ балансировки в хедере и мы хотим использовать переменную самого урла. Одна из возможных точек расширения функционала Envoy — использование http фильтров.


Есть неплохая документация по представленным фильтрам и их настройке. С их помощью, например, мы можем реализовать cors, внешнюю авторизацию, проверку jwt-токенов и многое другое. Нам же понадобится фильтр envoy.lua. С его помощью можно встраиваться в обработку как запроса, так и ответа, расширять ее с помощью скрипта на языке Lua.
Просто распарсим путь запроса с помощью регулярного выражения и вручную добавим хедер 'id_key'.


Lua скрипт
    - name: envoy.lua
      typed_config:
        "@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua
        inline_code: |
          function envoy_on_request(request)

            hasIdKey = "/echo/key/(.+)/?.*"
            path = request:headers():get(":path")
            key = path:match(hasIdKey)

            if key ~= nil then
              request:headers():add("id_key", key)
            end
          end

Теперь запросы с одним и тем же ключом в урле будут попадать на одну и ту же ноду echo-server:


Распределение 500 вызовов GET '/echo/key/2570e384-5fc0-11ea-bc55-0242ac130003'


Стоит учитывать, что при использовании алгоритма RING_HASH могут возникать мисы. Это накладывает ряд ограничений на его применение.


Subset-балансировка


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


Заполняем lb_subset_config, где указываем разделение по instance_id
- name: echo_cluster
      lb_policy: ROUND_ROBIN
      lb_subset_config:
        fallback_policy: ANY_ENDPOINT
        subset_selectors:
        - keys:
          - instance_id

Заполняем instance_id в мете каждого инстанса
endpoints:
        - lb_endpoints:
          - endpoint:
              address:
                socket_address:
                  address: docker.for.mac.localhost
                  port_value: 8081
            metadata:
              filter_metadata: { "envoy.lb" : { "instance_id": "a29f0fba3451"}}
          - endpoint:
              address:
                socket_address:
                  address: docker.for.mac.localhost
                  port_value: 8082
            metadata:
              filter_metadata: { "envoy.lb" : { "instance_id": "d6325ed590c0"}}
          - endpoint:
              address:
                socket_address:
                  address: docker.for.mac.localhost
                  port_value: 8083
            metadata:
              filter_metadata: { "envoy.lb" : { "instance_id": "6e2f60a09101"}}

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


Воспользуемся еще одним http-фильтром envoy.filters.http.header_to_metadata, который будет заполнять необходимую мету при наличии хедера instance-id.
При этом, если мы все-таки хотим передавать это значение в пути запроса, нам придется повторить трюк из предыдущего пункта.


Lua скрипт для использования instance-id в пути запроса
    - name: envoy.lua
      typed_config:
        "@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua
        inline_code: |
          function envoy_on_request(request)
            hasInstanceId = "/echo/instance/(.+)/?.*"

            path = request:headers():get(":path")
            key = path:match(hasInstanceId)

            if key ~= nil then
              request:headers():add("instance-id", key)
            end
          end

Теперь, выполнив любой запрос, мы получим instance-id (container id), передав который во все следующие вызовы, будем гарантированно попадать на необходимую ноду.


Распределение 500 вызовов GET '/echo/instance/a29f0fba3451'


Вместо вывода


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


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


Ну а в следующий раз мы поговорим о балансировке с весами и зеркалировании трафика.


Репозиторий с примерами.