Привет, Хабр, это моя первая статья. Меня зовут Константин, я системный инженер в компании ГНИВЦ. Здесь я хотел бы вам рассказать, что такое 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:
Изоляция инфраструктурных задач — сюда могут входить retries и таймауты на выполнение различных запросов, балансировка, роутинг. Под роутингом мы можем деплоить как A/B-тестирование, canary, blue/green — смысл, я думаю, понятен.
Снятие нагрузки с бизнес-логики — Envoy умеет аутентифицировать и авторизировать запросы, а именно через OAuth2, JWT, RBAC. И с точки зрения безопасности есть что покрутить — SSL/TLS и лимитирование запросов (rate limit).
Мониторинг и трассировка — сюда входят логирование, метрики в формате OpenMetrics для Prometheus, а также поддержка трассировки (Jaeger, Zipkin, OpenTelemetry). Если один сервис вызывает другой, Envoy может автоматически вставлять Trace ID для отслеживания потока данных.
Обеспечение надежности — сюда можно отнести Circuit Breakers, Outlier Detection, Health Check
На выходе мы должны получить меньшее количество багов (но это не точно) и большее количество фичей в продакшн.
Паттерны отказоустойчивости
Circuit Breaker (прерывание цепи) — предотвращение перегрузки зависимых сервисов. Envoy поддерживает механизм circuit breaking, который отключает отправку запросов к сервису, если он становится недоступным или превышает заданные лимиты (например, по количеству активных соединений, максимальному количеству соединений, количеству ожидающих запросов и тд).
Retries and Timeouts (повторы и таймауты) — повторение запросов в случае временных сбоев или задержек. Envoy может автоматически повторять запросы в случае ошибок (например, 5xx, 4xx, таймаутов). Вы также можете задать стратегию ограничений на повторы.
Outlier Detection (выявление "проблемных" хостов) — автоматическое исключение хостов из кластера, если они ведут себя нестабильно (например, медленные ответы или высокая частота ошибок).Этот механизм позволяет исключать проблемные узлы из пула доступных конечных точек. Поддерживает проверки: HTTP, gRPC, Redis и Thrift
Load Balancing (балансировка нагрузки) — распределение запросов по доступным узлам.Envoy поддерживает несколько стратегий балансировки нагрузки а именно Round Robin, Least Request, Random, Maglev, Ring Hash. Кому интересно почитать дальше то вам сюда
Rate Limiting (ограничение скорости запросов) — защита от перегрузки путем ограничения частоты запросов. Envoy поддерживает локальные и глобальные ограничения скорости с помощью Rate Limit Service (RLS). Можно настроить ограничения на уровне маршрутов.
Health Checks (проверки работоспособности) — здесь много говорить не стоит, все и так знают, что такое активные проверки здоровья на endpoint. Поддерживает проверки: HTTP, gRPC, L3/L4, Redis и Thrift
Failover (переключение на резервные хосты) — автоматическое переключение трафика на резервные хосты в случае недоступности основного. Envoy поддерживает управление приоритетами в кластерах.
Traffic Shadowing (теневое копирование трафика) — клонирование трафика для тестирования на резервных сервисах. Позволяет отправлять копию реального трафика на тестовый сервис без влияния на основной
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 предполагает, что произошел сбой, и автоматически возвращает в строй все хосты.
У нас есть два управляющих тумблера:
Отключение паники — можно установить порог в 0%, чтобы механизм паники не срабатывал, и все хосты считались доступными
Активация паники — если процент доступных серверов опускается ниже 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 с фильтрами
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 мощным инструментом для построения надежной инфраструктуры в условиях современных требований.
whocoulditbe
На практике логика размазывается со слоя разработки на ещё и прокси, что приводит к увеличению когнитивной нагрузки. Всё прекрасно до первых инцедентов, в которых придётся лезть в код сначала прокси, а потом приложения.
Кстати, внедрение фич теперь тоже занимает больше человеко-часов. Предположим, добавляется новый роут, в котором требуется авторизация/аутентификация пользователя и запрещены retry (предположим, нельзя повторять платёж). Эту информацию разработчик доносит до девопса, который раздувает свой конфиг на ещё полэкрана ямла, потом они вместе это тестируют... В конце концов разработчику быстрее самому написать этот конфиг, но он же мог сделать это и в коде приложения.
На мой взгляд, вынос бизнес-логики на уровень балансировщика является плохой идеей, хотя и выглядит красиво. Нагрузку распределить - пожалуйста, а вот что-то сложнее пусть будет там, где ему и место - в приложении.
leshoi Автор
Большинство сетевых проблем, а также типичные ответы HTTP (особенно ошибки 5xx), поддаются стандартным подходам к обработке. Пытаться добавлять сложную логику на стороне приложения для таких ситуаций – это не только увеличивает вероятность багов, но и нарушает принципы разделения ответственности.
Лучше избегать превращения микросервисов в раздутые сущности или монолиты, нагруженные лишней логикой. Для обработки сетевых сбоев и ошибок 5xx на транспортном уровне существуют проверенные решения.
Разработчику же, на мой взгляд, нет смысла углубляться в тонкости работы TCP-стека – это задача инфраструктуры и сетевых решений. Его фокус должен оставаться на бизнес-логике, а не на решении транспортных проблем.
whocoulditbe
Мне кажется, что мы друг друга не совсем поняли.
Моя позиция - бизнес-логика на стороне сервера, предоставляющего 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
занимает два экрана.leshoi Автор
Здесь я хотел больше сосредоточиться на использовании Envoy как Service mesh или Ambient Mesh в legacy инфраструктуре, а не превращать его в огромный gateway с кучей логики на борту. Конечно, часть функционала нужно будет вынести в приложение, но акцент я делал именно на TCP-стек и его взаимодействие. Я бы не стал добавлять JWT-аутентификацию в Envoy, а перенес её в приложение, если это действительно необходимо. В этом вопросе есть нюанс: где-то это может быть полезно, а где-то — нет. Каждый решает для себя, нужно ли это вообще. Поэтому я и не приводил конкретных примеров, их может быть слишком много, и каждый проект требует индивидуального подхода