Привет, Хабр, это моя первая статья. Меня зовут Константин, я системный инженер в компании ГНИВЦ. Здесь я хотел бы вам рассказать, что такое Envoy и как с его помощью можно упростить жизнь разработчикам и повысить надёжность взаимодействия микросервисов, минуя инфраструктуру для кого-то страшного и непонятного Kubernetes, а используя простой и старомодный Docker. Также эта статья поможет познакомиться с Envoy поближе и узнать, как он шагает в ногу с таким проектом как Istio.

Что это такое?

Envoy — это L4-L7-балансировщик, написанный на C++ и ориентированный на высокую производительность и доступность. Он обладает отличной observability, в отличие от обычного Nginx, где по метрикам всё скудно. Интереснее разве что Nginx+, но сегодня мы рассматриваем open-source решения. Envoy включает множество настроек для проксирования, они же фильтры, и возможностей для обеспечения безопасности.

Что будем рассматривать?

В статье хотелось бы рассказать про различные варианты настройки Envoy с примерами — для тех, кто не хочет читать официальную документацию, которая была автосгенерирована по .proto файлам, а ознакомиться и понять: нужно ли нам это вообще? В основном берём статическую конфигурацию. В этой статье я не буду затрагивать динамическую конфигурацию и протокол xDS — это уже отдельная история, которую я опишу позже. Технологии, которые затронем: observability, circuit breaking, authentication, authorization, outlier detection, healt check, retries, mtls и, конечно же, паттерны отказоустойчивости.

Как envoy может помочь при разработке?

Здесь относительно всё просто. Разработчики, когда пишут REST API, много времени могут тратить не на бизнес-код, а на различную логику, которую можно заменить одним только прокси. Так вот вопрос: а чем же мы, как инженеры, можем им помочь? Например, такие задачи может решить Envoy:

  1. Изоляция инфраструктурных задач — сюда могут входить retries и таймауты на выполнение различных запросов, балансировка, роутинг. Под роутингом мы можем деплоить как A/B-тестирование, canary, blue/green — смысл, я думаю, понятен.

  2. Снятие нагрузки с бизнес-логики — Envoy умеет аутентифицировать и авторизировать запросы, а именно через OAuth2, JWT, RBAC. И с точки зрения безопасности есть что покрутить — SSL/TLS и лимитирование запросов (rate limit).

  3. Мониторинг и трассировка — сюда входят логирование, метрики в формате OpenMetrics для Prometheus, а также поддержка трассировки (Jaeger, Zipkin, OpenTelemetry). Если один сервис вызывает другой, Envoy может автоматически вставлять Trace ID для отслеживания потока данных.

  4. Обеспечение надежности — сюда можно отнести Circuit Breakers, Outlier Detection, Health Check

На выходе мы должны получить меньшее количество багов (но это не точно) и большее количество фичей в продакшн.

Паттерны отказоустойчивости

  1. Circuit Breaker (прерывание цепи) — предотвращение перегрузки зависимых сервисов. Envoy поддерживает механизм circuit breaking, который отключает отправку запросов к сервису, если он становится недоступным или превышает заданные лимиты (например, по количеству активных соединений, максимальному количеству соединений, количеству ожидающих запросов и тд).

  2. Retries and Timeouts (повторы и таймауты) — повторение запросов в случае временных сбоев или задержек. Envoy может автоматически повторять запросы в случае ошибок (например, 5xx, 4xx, таймаутов). Вы также можете задать стратегию ограничений на повторы.

  3. Outlier Detection (выявление "проблемных" хостов) — автоматическое исключение хостов из кластера, если они ведут себя нестабильно (например, медленные ответы или высокая частота ошибок).Этот механизм позволяет исключать проблемные узлы из пула доступных конечных точек. Поддерживает проверки: HTTP, gRPC, Redis и Thrift

  4. Load Balancing (балансировка нагрузки) — распределение запросов по доступным узлам.Envoy поддерживает несколько стратегий балансировки нагрузки а именно Round Robin, Least Request, Random, Maglev, Ring Hash. Кому интересно почитать дальше то вам сюда

  5. Rate Limiting (ограничение скорости запросов) — защита от перегрузки путем ограничения частоты запросов. Envoy поддерживает локальные и глобальные ограничения скорости с помощью Rate Limit Service (RLS). Можно настроить ограничения на уровне маршрутов.

  6. Health Checks (проверки работоспособности) — здесь много говорить не стоит, все и так знают, что такое активные проверки здоровья на endpoint. Поддерживает проверки: HTTP, gRPC, L3/L4, Redis и Thrift

  7. Failover (переключение на резервные хосты) — автоматическое переключение трафика на резервные хосты в случае недоступности основного. Envoy поддерживает управление приоритетами в кластерах.

  8. Traffic Shadowing (теневое копирование трафика) — клонирование трафика для тестирования на резервных сервисах. Позволяет отправлять копию реального трафика на тестовый сервис без влияния на основной

  9. Fault Injection (имитация сбоев) — тестирование устойчивости системы при отказах. Вы можете настроить Envoy для имитации задержек или ошибок.

Весь этот список поддерживает envoy и, конечно же, мы его можем реализовать в нашей инфраструктуре. Разберем мы сегодня не все, но попытаемся охватить, что реально может понадобиться в боевой среде.

Установка как docker контейнер

Начнем установку docker pull envoyproxy/envoy:v1.31.3 — звучит просто, согласитесь? Что нам нужно дальше? Конечно, запустить его, либо как docker-compose, либо как обычный контейнер. Но я покажу вариант с docker-compose. Здесь всё обычно: открываем порты под listener на 10000 (он по умолчанию слушает Envoy) и админский интерфейс, откуда мы будем брать метрики. Иначе зачем иметь большую наблюдаемость и не следить за ней?

version: '3.8'

services:
  envoy:
    image: envoyproxy/envoy:v1.31.3
    container_name: envoy
    ports:
      - "9901:9901" 
      - "10000:10000" 
    volumes:
      - ./envoy.yaml:/etc/envoy/envoy.yaml:ro 
    restart: always

Что нам ещё нужно сделать: создать статический файл конфигурации, поместить его на сервер и через volume подкинуть в контейнер. На этом с запуском контейнера мы закончили.

Ниже — статическая конфигурация по умолчанию. Но есть нюанс: метрики публикуются по пути ip:9901/stats/prometheus, API для управления Envoy доступно на порту 9901. Если к этому порту будет доступ у любого желающего, то он сможет делать с вашим Envoy всё, что захочет.

Поэтому лучше позаботиться об этом заранее и запустить интерфейс администратора на 127.0.0.1:9901. На эту тему есть issue.

envoy-demo.yaml
admin:
  # Административный интерфейс для метрик и диагностики
  access_log_path: "/dev/null"
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 10000
        protocol: TCP 
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          access_log:
          - name: envoy.access_loggers.stdout
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  host_rewrite_literal: www.envoyproxy.io
                  cluster: service_envoyproxy_io

  clusters:
  - name: service_envoyproxy_io
    type: LOGICAL_DNS
    # Comment out the following line to test on v6 networks
    dns_lookup_family: V4_ONLY
    load_assignment:
      cluster_name: service_envoyproxy_io
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: www.envoyproxy.io
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: www.envoyproxy.io

Маршрутизация и mTLS между Envoy-контейнерами

Наглядное изображение, как будет бегать наш трафик между контейнерами
Наглядное изображение, как будет бегать наш трафик между контейнерами

Условно наши два сервиса service A и service B общаются между собой по TCP. Что мы можем сделать? Например, для безопасного соединения между сервисами подкинуть mTLS.

static_resources:
  listeners:
  - name: tcp_listener
    address:
      socket_address: { address: 0.0.0.0, port_value: 10000, protocol: TCP }
    filter_chains:
    - filters:
      - name: envoy.filters.network.tcp_proxy
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
          stat_prefix: ingress_tcp
          cluster: backend_envoy
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
         require_client_certificate: true
         common_tls_context:
            validation_context:
              trusted_ca:
                filename: certs/cacert.pem
              match_typed_subject_alt_names:
              - san_type: DNS
                matcher:
                  exact: serviceA
          tls_certificates:
          - certificate_chain: { filename: "certs/serverkey.pem" }
            private_key: { filename: "certs/servercert.pem" }

  clusters:
  - name: backend_envoy
    connect_timeout: 0.5s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: backend_envoy
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: backend_envoy, port_value: 9001 }
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: serviceB
        common_tls_context:
          validation_context:
            trusted_ca:
              filename: certs/cacert.pem
            match_typed_subject_alt_names:
            - san_type: DNS
              matcher:
                exact: "*.proxy-example"
          tls_certificates:
          - certificate_chain: { filename: "certs/clientcert.pem" }
            private_key: { filename: "/certs/clientkey.pem" }

Что у нас здесь происходит? Мы сначала объявляем listener 10000 и tcp-фильтр, с помощью которого мы будем управлять нашим трафиком, настраиваем так, чтобы наш Envoy принимал upstream и downstream. DownstreamTlsContext — это контекст TLS, когда подключаются к нам. UpstreamTlsContext — это контекст, когда мы роутим трафик на восходящий сервис. Далее происходит следующее, мы говорим Envoy: «проверяй все клиентские сертификаты на входе. Убедись, что ты знаешь об общем центре сертификации и что указан SAN (например, serviceA). Если что-то не совпадает — сбрасывай соединение. Если всё окей, то передай трафик на наш backend_envoy и также проверь у него SAN(*.proxy-example), SNI(serviceB) и общий CA».

Итого, у нас есть два контейнера. Первый работает на upstream, Второй — на downstream. С более детальными настройками можно ознакомиться здесь и здесь. Мы получили безопасное mTLS-взаимодействие, но немного потеряли в latency — примерно на 2.5 ms.

Балансировка HTTP/2 с фильтрами

В основе конфигурации лежит пример балансировки трафика HTTP/2, но мы можем использовать и другие версии протокола — HTTP/1.1 или HTTP/3. Выбор зависит от возможностей серверов в upstream-кластере и их поддержки определённых протоколов.

static_resources:
  listeners:
  - name: http_listener
    address:
      socket_address: 
        address: 0.0.0.0
        port_value: 10000
        protocol: TCP
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          access_log:
          - name: envoy.access_loggers.stdout
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: http_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: 
                  timeout: 15s
                  cluster: http_backend
                  retry_policy:
                    retry_on: "5xx,retriable-4xx"
                    num_retries: 3
                    per_try_timeout: 2s
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
  - name: http_backend
    connect_timeout: 0.25s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        common_http_protocol_options:
          idle_timeout: 1h
        auto_config:
          http_protocol_options: {}
          http2_protocol_options: 
            allow_connect: true
            connection_keepalive:
              interval: 1s
              timeout: 2s
          http3_protocol_options:
            idle_timeout: 300000ms
            quic_protocol_options:
              max_concurrent_streams: 100
              connection_keepalive:
                max_interval: 4s
                initial_interval: 150000ms
            allow_extended_connect: true
    circuit_breakers:
      thresholds:
      - priority: "DEFAULT"
        max_connections: 1024
        max_pending_requests: 1024
        max_requests: 1024
        max_retries: 6 
        max_connection_pools: 1024
        retry_budget:
          min_retry_concurrency: 3
    outlier_detection:
      consecutive_5xx: 5
      interval: 10s
      base_ejection_time: 30s
      max_ejection_time: 300s
      max_ejection_percent: 50
      successful_active_health_check_uneject_host: false
      split_external_local_origin_errors: false
      failure_percentage_minimum_hosts: 3
      success_rate_minimum_hosts: 3
      consecutive_local_origin_failure: 5
      enforcing_consecutive_local_origin_failure: 100
      enforcing_local_origin_success_rate: 100
    health_checks:
    - timeout: 2s
      interval: 30s
      unhealthy_threshold: 3
      healthy_threshold: 2
      method: "GET"
      http_health_check:
        path: "/health"
        request_headers_to_add:
        - header:
            key: "Host"
            value: "backend_service"
    load_assignment:
      cluster_name: http_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: 
                address: backend_service
                port_value: 80
        - endpoint:
            address:
              socket_address: 
                address: backend_service_2
                port_value: 80
        - endpoint:
            address:
              socket_address: 
                address: backend_service_3
                port_value: 80

Что из этого конфига мы можем понять? Мы слушаем все сетевые интерфейсы на порту 10000 по TCP. Для всего трафика, который идет по HttpConnectionManager добавляется префикс ingress_http к метрикам. Логи пишем в stdout. Логи можно записывать как в stdout, так и в stderr, в формате JSON или TEXT. Вывод можно настраивать под любые потребности, но по умолчанию мы видим примерно следующее:

stdout log
[2016-04-15T20:17:00.310Z] "POST /api/v1/locations HTTP/2" 204 - 154 0 226 100 "10.0.35.28" "nsq2http" "cc21d9b0-cf5c-432b-8c7e-98aeb7988cd2" "locations" "tcp://10.0.2.1:80" 

За подробностями как обычно сюда

Дальше по фильтру: всё, что пришло на /, отправляется в cluster http_backend, который имеет в себе 3 конечные точки для балансировки. Установлен глобальный таймаут на upstream — 15-и секунд. Если прилетают ошибки 5xx (p.s. тут тоже можно настраивать, на какие именно типы ошибок выполнять повторы), то выполняется 3 повтора с таймаутом 2 секунды на каждый. Если в течение 2-ух секунд начнётся ответ, счётчик повторов сбрасывается.

Cluster в Envoy — это одновременно список наших конечных точек и конфигурация отказоустойчивости. Таймаут на подключение к конечным точкам установлен в 0.25 секунды. Важно: у каждой системы свои настройки. Здесь мы приводим пример, как это можно сделать. Установлен STRICT_DNS (подробнее можно почитать по ссылке) и метод балансировки round robin. Также включён наш размыкатель Circuit Breakers, который при превышении заданных лимитов позволяет бэкенду «отдохнуть». По дефолту имеет неплохие настройки для всего upstream, но все зависит от того, насколько нагружена наша система. Почитать можно по линку.

Circuit Breakers включён по умолчанию. Если хотите его отключить, установите везде значение 1000000000.

Пример из документации
circuit_breakers:
  thresholds:
    - priority: DEFAULT
      max_connections: 1000000000
      max_pending_requests: 1000000000
      max_requests: 1000000000
      max_retries: 1000000000
    - priority: HIGH
      max_connections: 1000000000
      max_pending_requests: 1000000000
      max_requests: 1000000000
      max_retries: 1000000000

В Envoy блок typed_extension_protocol_options предоставляет возможность настраивать протоколы HTTP/1.1, HTTP/2 и HTTP/3 для upstream-соединений, адаптируя их под различные сценарии использования. При использовании AutoHttpConfig кластер автоматически выбирает протокол через ALPN (Application-Layer Protocol Negotiation): HTTP/2 будет использоваться, если он поддерживается, в противном случае — HTTP/1.1. Если upstream-серверы не поддерживают ALPN, Envoy перейдёт на HTTP/1.1. Однако важно, чтобы транспортные сокеты поддерживали ALPN, иначе конфигурация завершится ошибкой. При наличии нестандартных ALPN-настроек Envoy сначала попробует их, но в случае их недоступности переключится на стандартные протоколы HTTP/2 и HTTP/1.1

Общие параметры соединений задаются через common_http_protocol_options. Например, idle_timeout: 1h определяет время бездействия, после которого соединение закрывается. Раздел auto_config позволяет детально настроить параметры для каждого протокола. Для HTTP/1.1 используются стандартные настройки без изменений. В HTTP/2 включён режим allow_connect, а также настроены интервалы keep-alive: сигнал каждые 1 секунду и таймаут на ответ 2 секунды. Для HTTP/3 добавлены параметры протокола QUIC: ограничение на количество потоков (max_concurrent_streams: 100), интервалы keep-alive с начальным значением 150000 миллисекунд и максимальным 4 секунды, а также таймаут бездействия 300000 миллисекунд. Включена поддержка расширенного CONNECT, что полезно для проксирования.

Также у нас включены такие фичи как: Outlier detection, пассивная проверка, и Health checking, активная. С их помощью мы понимаем, кто из хостов жив, а кто "бьётся в конвульсиях" и ему надо дать полежать, подумать над своим поведением. Split_external_local_origin_errors — очень важная фича. По умолчанию она отключена (false). Когда она выключена, сбросы TCP, таймауты и 5xx ошибки от бэкенда валятся в одну кучу, что условно всё приравнивается к 5xx для Envoy. Если включить (true), то ошибки на уровне L4 и L7 начинают считаться отдельно. Таймауты, сбросы TCP и ошибки ICMP идут в одну группу, HTTP-ответы от бэкенда — в другую. Для TCP-роутинга всё немного проще: любая ошибка от TCP-фильтра приравнивается к 5xx как HTTP. successful_active_health_check_uneject_host — по умолчанию true. Это значит: если активная проверка показала, что хост здоров, то Envoy игнорирует все выбросы и считает его рабочим. Это довольно жёсткое поведение, поэтому принимайте решение, нужно ли вам это, или лучше отключить. Про алгоритм выброса можно почитать подробнее, но если вкратце: сначала он смотрит список доступных хостов и %, ниже которого нельзя опускаться и после начинает выкидывать "плохие" хосты на период 30s с максимальным значением выброса 5m. После того как хост показывает, что он здоров и готов работать, то он возвращает его в строй, и отсчитывается время в обратном порядке до значения, которое указано в yaml конфиге.

Panic mode — ключевой механизм балансировки трафика. По умолчанию он активируется, если процент здоровых хостов падает ниже 50%. В этом случае Envoy предполагает, что произошел сбой, и автоматически возвращает в строй все хосты.

У нас есть два управляющих тумблера:

  1. Отключение паники — можно установить порог в 0%, чтобы механизм паники не срабатывал, и все хосты считались доступными

  2. Активация паники — если процент доступных серверов опускается ниже 50%, Envoy заблокирует все хосты и вернет ошибку "503 — no healthy upstream".

Подробнее о настройке panic mode читайте здесь.

Health check: здесь всё просто. Отправляем GET запрос на /health, добавляем, как пример, заголовок Host: backend_service и ждём ответа 200. Получили — хорошо, не получили — плохо. Можно настроить проверку как для всего cluster, так и для каждой конечной точки индивидуально. На этом заканчиваем с HTTP и переходим к gRPC, где расскажу поменьше, так как основные фишки вы уже освоили, но добавлю новых, чтобы не повторяться.

Локальный health check на endpoints
load_assignment:
  endpoints:
  - lb_endpoints:
    - endpoint:
        health_check_config:
          port_value: 8080
          address:
            socket_address:
              address: 127.0.0.1
              port_value: 80
        address:
          socket_address:
            address: localhost
            port_value: 80

Балансировка gRPC с фильтрами

gRPC (Remote Procedure Calls) — это система удалённого вызова процедур (RPC) с открытым исходным кодом, первоначально разработанная в Google в 2015 году
gRPC (Remote Procedure Calls) — это система удалённого вызова процедур (RPC) с открытым исходным кодом, первоначально разработанная в Google в 2015 году
static_resources:
  listeners:
  - name: grpc_listener
    address:
      socket_address: { address: 0.0.0.0, port_value: 10000, protocol: TCP }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: grpc
          codec_type: AUTO
          route_config:
            name: grpc_route
            virtual_hosts:
            - name: grpc_services
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: grpc_backend
                  retry_policy:
                    retry_on: "cancelled,internal,deadline-exceeded"
                    num_retries: 3
                    per_try_timeout: 2s
                  request_mirror_policies:
                    - cluster: shadow_backend
                      runtime_fraction:
                        default_value:
                          numerator: 100
                          denominator: HUNDRED
          http_filters:
          - name: envoy.filters.http.grpc_web
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
          - name: envoy.filters.http.grpc_http1_bridge
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_http1_bridge.v3.Config
               upgrade_protobuf_to_grpc: true
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
  - name: grpc_backend
    connect_timeout: 1s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN 
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options:
            allow_connect: true
            connection_keepalive:
              interval: 1s
              timeout: 2s
    load_assignment:
      cluster_name: grpc_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: 192.168.1.1, port_value: 50051 }
        - endpoint:
            address:
              socket_address: { address: 192.168.1.2, port_value: 50051 }
        - endpoint:
            address:
              socket_address: { address: 192.168.1.3, port_value: 50051 }
  - name: shadow_backend
    connect_timeout: 1s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    http2_protocol_options: {}
    load_assignment:
      cluster_name: shadow_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: 192.168.2.1, port_value: 50052 }

Здесь у нас немного поменялось, если сравнивать с HTTP: добавили ошибки, свойственные для gRPC-ответов ("cancelled", "internal", "deadline-exceeded"), для политики обработки повторов. Также сделали зеркалирование 100% трафика на кластер shadow_backend, чтобы проверить, как ведёт себя условно новая версия приложения. Можно было поставить split, чтобы 80% уходило на grpc_backend, а 20% — на shadow_backend, но я решил показать именно зеркалирование. Добавили два новых фильтра: grpc_web и grpc_http1_bridge. Фильтры в envoy обрабатываются по порядку — сверху вниз, за этим нужно обязательно следить.

gRPC-Web — фильтр предназначен для преобразования HTTP/1.1 запросов, совместимых с gRPC-Web, в стандартные HTTP/2 gRPC запросы. Если у вас есть клиент, использующий протокол gRPC-Web (например, веб-приложение в браузере), который не поддерживает полноценный HTTP/2, этот фильтр позволяет преобразовать его запросы так, чтобы они работали с сервером gRPC.

gRPC HTTP1 bridge — фильтр предназначен для преобразования стандартных HTTP/1.1 REST запросов в gRPC-запросы. Если у вас есть клиенты, использующие обычные HTTP/1.1 REST запросы (например, JSON), но сервер поддерживает только gRPC, этот фильтр позволяет использовать эти REST запросы для вызова gRPC серверов. Фича upgrade_protobuf_to_grpc остается везде в положении true, а заголовки application/x-protobuf будут автоматически преобразованы в gRPC. В этом случае фильтр добавит к телу кадр gRPC, описанный выше, и обновит заголовок content-type до отправки запроса на сервер application/grpc

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

Тело ответа, возвращаемое клиенту, не будет содержать кадр заголовка gRPC для запросов, обновленных таким образом, т.е. тело будет содержать только закодированный Protobuf.

Аутентификация и авторизация запросов

Итак, Envoy поддерживает JWT Authentication и OAuth2. Мы не будем разбирать весь конфигурационный файл, а сосредоточимся на отдельных фрагментах. Как строится конфигурация, думаю, уже стало ясно, а если возникнут вопросы, всегда можно обратиться к официальной документации. Я расскажу, что можно настроить, а что — нет.

Поддерживаемые jwt алгоритмы

ES256, ES384, ES512, HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, EdDSA

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address:
          protocol: TCP
          address: 0.0.0.0
          port_value: 10000
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: backend
                      domains: ["*"]
                      routes:
                        - match:
                            path: "/"
                          route:
                            cluster: service1
                        - match:
                            path: "/api"
                          route:
                            cluster: service1
                        - match:
                            path: "/health"
                          route:
                            cluster: service1
                http_filters:
                  - name: envoy.filters.http.jwt_authn
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
                      providers:
                        provider1:
                          issuer: "https://auth.example.com/provider1"
                          remote_jwks:
                            http_uri:
                              uri: "https://auth.example.com/provider1/.well-known/jwks.json"
                              cluster: auth_cluster
                              timeout: 5s
                          forward: true
                          forward_payload_header: x-jwt-payload
                          require_expiration: true
                          cache_duration:
                            seconds: 300
                      rules:
                        - match:
                            prefix: "/"
                          requires:
                            provider_name: provider1
                        - match:
                            prefix: "/api"
                          requires:
                            provider_name: provider1
                        - match:
                            prefix: "/health"
                - name: envoy.filters.http.router
                  typed_config:
                    "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

Самое интересное нас ждет в фильтрах, правилах и маршрутах.

Первый фильтр — JwtAuthentication. Здесь у нас указан issuer (издатель токена), поле, которое требует обязательного указания времени жизни токена (require_expiration), и удаленный JWKS, который проверяет наши токены на валидность. JWKS может быть локальным или удаленным. Также в конфигурации указан upstream-кластер, куда будут уходить запросы.

Дополнительно в конфигурации задано, что после проверки токена его полезную нагрузку необходимо передать в заголовке x-jwt-payload на upstream. Однако это опционально, и данное поведение можно отключить. Тумблеров для настройки здесь много, в конце оставлю все ссылки.

По умолчанию JWT-токен ищется в заголовке Authorization: Bearer <token> или в GET-параметре /path?access_token=<JWT>. Но, конечно же, это Envoy, поэтому здесь можно настроить все как угодно: указать кастомный заголовок для поиска токена, задать аудитории, которые будут приниматься или отклоняться. Если запрос содержит два токена: один из заголовка и другой из GET-параметра, то оба должны быть валидными.

И самое главное — это маршруты: на каких требуется аутентификация, а куда можно пройти без нее. Например, для /health аутентификация не требуется, а для остальных маршрутов потребуется предоставить JWT. На каждый маршрут можно указать провайдера, который будет за него ответственным, поскольку их может быть несколько. Для примера я описал только одного провайдера.

Теперь рассмотрим, как мы можем авторизировать запросы. Наш фильтр называется envoy.filters.http.oauth2

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          http_filters:
          - name: envoy.filters.http.oauth2
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3.OAuth2
              config:
                token_endpoint:
                  cluster: oauth
                  uri: "https://<keycloak-host>/auth/realms/<realm>/protocol/openid-connect/auth"
                  timeout: 3s
                forward_bearer_token: true
                use_refresh_token: true
                authorization_endpoint: "https://<keycloak-host>/auth/realms/<realm>/protocol/openid-connect/auth"
                redirect_uri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/callback"
                redirect_path_matcher:
                  path:
                    exact: /callback
                signout_path:
                  path:
                    exact: /logout
                credentials:
                  client_id: "<client-id>"
                  token_secret:
                    name: token
                auth_scopes:
                - user
                - openid
                - email
          - name: envoy.filters.http.csrf
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.csrf.v3.CsrfPolicy
              filter_enabled:
                default_value: true
              additional_origins:
               - exact: "https://<frontend-domain>"
          - name: envoy.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          codec_type: "AUTO"
          stat_prefix: ingress_http
          route_config:
            virtual_hosts:
            - name: service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: service
                  timeout: 5s

  clusters:
  - name: service
    connect_timeout: 5s
    type: STATIC
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: service
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: 127.0.0.1
                port_value: 8080
  - name: oauth
    connect_timeout: 5s
    type: LOGICAL_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: oauth
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: auth.example.com
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: auth.example.com

Когда пользователь отправляет запрос к защищённому ресурсу через Envoy, процесс авторизации начинается с проверки токена в запросе. Если токен отсутствует или недействителен, Envoy перенаправляет пользователя на страницу авторизации Keycloak, URL которой указан в параметре authorization_endpoint. Этот запрос включает необходимые параметры, такие как client_id, указанный в секции credentials, и запрашиваемые области (auth_scopes), включая openid, email и user, которые необходимы для выполнения OpenID Connect (OIDC)-аутентификации.

После успешной авторизации Keycloak перенаправляет пользователя на URI, указанный в redirect_uri. Этот параметр динамически формируется на основе входящего заголовка x-forwarded-proto и текущего имени хоста (:authority), что делает систему гибкой для работы в средах с изменяющимися протоколами (HTTP/HTTPS) и доменами. Также указана точка входа для обработки ответа на авторизацию через параметр redirect_path_matcher, который ожидает, что Keycloak вернёт пользователя на путь /callback с кодом авторизации.

Когда Envoy получает этот код, он использует его для запроса токена у Keycloak через указанный token_endpoint. Этот запрос происходит на фоне, где Envoy аутентифицируется перед сервером с помощью идентификатора клиента (client_id) и секретного ключа (token_secret). Эти данные обеспечивают безопасное взаимодействие между Envoy и Keycloak.

После получения токена Envoy либо сохраняет его в cookie, либо использует для создания нового запроса к защищённому ресурсу, добавляя токен в заголовок Authorization: Bearer. Этот токен валидируется при каждом запросе. Для сценариев, где требуется выйти из системы, используется путь signout_path, который привязан к URI /logout, позволяя завершить пользовательскую сессию. Когда сервер проверяет клиента и возвращает токен авторизации обратно в фильтр OAuth, независимо от формата этого токена, если forward_bearer_token установлен в значение true, фильтр отправит cookie с именем BearerToken в upstream. Кроме того, Authorization заголовок будет заполнен тем же значением.

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

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

Из недостатков: для корректной работы фильтра служба должна функционировать по протоколу HTTPS, поскольку файлы cookie используют атрибут ;secure. Без HTTPS authorization_endpoint, скорее всего, отклонит входящий запрос, а файлы cookie доступа не будут кешироваться, что помешает обходу будущих повторных входов в систему

Ссылки для чтения: oauth2 и jwt.


Envoy — это высокопроизводительный прокси-сервер, созданный для обслуживания современных распределенных систем. Он обеспечивает широкий спектр возможностей, включая маршрутизацию запросов, балансировку нагрузки, управление аутентификацией, обработку JWT-токенов, защиту от атак CSRF, а также интеграцию с внешними системами авторизации, мониторинга, трассировки и еще ОЧЕНЬ много других фильтров.

Одной из ключевых особенностей Envoy является гибкость его настройки. Благодаря модульной архитектуре и множеству фильтров, вы можете адаптировать его под практически любые задачи: от простого маршрутизатора до сложного API-шлюза или элемента mesh-сети. Envoy активно используется в микросервисной архитектуре и служит основой для системы Istio в sidecar mode он же service mesh.

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

Комментарии (4)


  1. whocoulditbe
    06.12.2024 13:28

    Разработчики, когда пишут REST API, очень много времени могут тратить не на бизнес-код, а на различную логику, которую можно заменить одним только прокси.

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

    Кстати, внедрение фич теперь тоже занимает больше человеко-часов. Предположим, добавляется новый роут, в котором требуется авторизация/аутентификация пользователя и запрещены retry (предположим, нельзя повторять платёж). Эту информацию разработчик доносит до девопса, который раздувает свой конфиг на ещё полэкрана ямла, потом они вместе это тестируют... В конце концов разработчику быстрее самому написать этот конфиг, но он же мог сделать это и в коде приложения.

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


    1. leshoi Автор
      06.12.2024 13:28

      Большинство сетевых проблем, а также типичные ответы HTTP (особенно ошибки 5xx), поддаются стандартным подходам к обработке. Пытаться добавлять сложную логику на стороне приложения для таких ситуаций – это не только увеличивает вероятность багов, но и нарушает принципы разделения ответственности.

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

      Разработчику же, на мой взгляд, нет смысла углубляться в тонкости работы TCP-стека – это задача инфраструктуры и сетевых решений. Его фокус должен оставаться на бизнес-логике, а не на решении транспортных проблем.


      1. whocoulditbe
        06.12.2024 13:28

        Мне кажется, что мы друг друга не совсем поняли.

        Моя позиция - бизнес-логика на стороне сервера, предоставляющего REST API, включает в себя всё, что не относится к TCP/IP и HTTP, а именно: балансировка нагрузки, SSL/TLS, маршрутизация между сервисами. Это разработчику действительно необязательно, может ему неплохо знать, как доставать значения из http-заголовков. Но вот всё, что идёт дальше, возлагается на плечи именно разработчика. У каждого сервиса есть своя зона ответственности, почему её надо дробить и вытаскивать на слой выше?

        Предположим, что есть сервис для доступа к личному кабинету, доступ к которому (для пользователя) осуществляется путём предоставления http-заголовка Authorization: Bearer <токен>. С envoy обработка токена и решение пустить/запретить произойдёт на балансировщике, приложение даже не узнает о том, что к нему кто-то хотел сходить, верно? А раз слоя авторизации в сервисе нет, то внутри вашего контура (за envoy) к нему может ходить кто угодно как угодно и эти запросы будут считаться легитимными, так? Такой вариант нарушает принцип zero trust, создаёт дыру в безопасности и позволяет разработчикам с честными глазами говорить - "а мы ничего не знаем, к нам ничего не ходило, идите к девопсу". Ну или "нас защищает кто-то выше, мы только отдаём данные". Разве сервис не должен защищать свои данные?

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

        Вот кстати хороший пример: платёжный шлюз пытается донести в вашу систему информацию о проведённом платёже - делает POST {json} на ваш /api/payment и получает 403. Заметьте, 403 может отдавать как envoy, так и сервис payment (по причине бизнес-логики или опечатки). Пользователь получает ошибку от платёжного шлюза "что-то пошло не так, обратитесь в вашу систему". Пользователь пишет вам в техподдержку, техподдержка идёт к разработчику сервиса payment, разработчик идёт к девопсу... чтобы что? Чтобы они вместе перелопатили сначала конфиги envoy, а потом и сервиса /api/payment? Три-четыре инцидента и сэкономленные два дня обнулятся, ещё парочка и вы заплатите больше за рабочие часы разраба+девопса.

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

        Вы вынесли "лишнюю логику" на уровень выше, да ещё и в одно место, которое через пару лет превратится в монолит на 1к+ строчек yaml-кода с регулярками и прочими синтаксическими ужасами, которые ещё и проверить нельзя. При этом логика не отвязалась от приложения, а просто скрылась до поры до времени.

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

        По статье: спрячьте, пожалуйста, конфиги под спойлер, например,envoy.filters.http.oauth2 занимает два экрана.


        1. leshoi Автор
          06.12.2024 13:28

          Здесь я хотел больше сосредоточиться на использовании Envoy как Service mesh или Ambient Mesh в legacy инфраструктуре, а не превращать его в огромный gateway с кучей логики на борту. Конечно, часть функционала нужно будет вынести в приложение, но акцент я делал именно на TCP-стек и его взаимодействие. Я бы не стал добавлять JWT-аутентификацию в Envoy, а перенес её в приложение, если это действительно необходимо. В этом вопросе есть нюанс: где-то это может быть полезно, а где-то — нет. Каждый решает для себя, нужно ли это вообще. Поэтому я и не приводил конкретных примеров, их может быть слишком много, и каждый проект требует индивидуального подхода