Привет, Хабр!
Сегодня поговорим о том, как на практике построить высоконагруженный обратный прокси-сервер на основе YARP, отличной библиотеки от Microsoft для .NET.
Зачем вообще свой прокси на .NET?
Скорее всего, многие привыкли ставить перед своими сервисами Nginx или HAProxy. Это проверенные решения, способные на миллионы запросов в секунду. Зачем же нам Yet Another Reverse Proxy?
Ответ простой: интеграция и гибкость.
YARP пишется прямо внутри вашего .NET-приложения, поэтому его легко расширять на C# под свои нужды, вставлять нужные промежуточные обработки, использовать общие с сервисами модели конфигурации и логирования. YARP – это обратный прокси, который органично встраивается в экосистему ASP.NET Core. Он задуман как настраиваемая библиотека.
При этом YARP достаточно мощный. Microsoft разработала его для собственных сервисов, которым требовались кастомные прокси с поддержкой новейших протоколов (HTTP/2, gRPC) и высокой производительностью. Получилась гибкая и масштабируемая платформа. YARP умеет всё, что ожидаешь от современного обратного прокси: маршрутизацию по URL и хосту, балансировку нагрузки, проверку здоровья бэкендов, поддержку sticky session, трансформацию URL и заголовков, терминирование TLS, авторизацию, кэширование, в общем, полный фар.. И все это средствами .NET.
Конечно, первый вопрос, а потянет ли .NET-прокси высокую нагрузку? Да, еще как. YARP построен на базе высокопроизводительного сервера Kestrel и асинхронного HTTP-клиента HttpClient. Он эффективно использует pooling соединений: принимая запрос, прокси разрывает входящее соединение и сам устанавливает новое соединение до нужного сервиса, причем и клиентские, и серверные соединения переиспользуются из пулов.
Это значит, что на каждый новый запрос не тратится время на новый TCP Handshake, обратный прокси гоняет десятки тысяч запросов по паре постоянно открытых сокетов. Плюс, вся обработка в YARP неблокирующая и масштабируется на ядра.
Разворачиваем простой reverse proxy на YARP
Раз уж YARP поставляется как библиотека, начнем с создания минимального веб-приложения на ASP.NET Core и подключения YARP.
Предположим, у нас уже установлен .NET 6/7/8 (YARP требует Core, не старее .NET 6). Создаем новый проект типа Empty (или с помощью шаблона dotnet new web). Затем добавляем пакет Yarp.ReverseProxy из NuGet. После этого весь минимальный код настройки прокси выглядит буквально в несколько строк в Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Регистрируем сервис обратного прокси и загружаем конфиг из настроек
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
// Подключаем middleware YARP для обработки входящих запросов
app.MapReverseProxy();
app.Run();
Мы просто добавили YARP как middleware в конвейер ASP.NET. Метод LoadFromConfig(...) подтянет конфигурацию маршрутов и кластеров из конфигурационного файла (обычно appsettings.json).
Осталось этот конфиг туда прописать. Структура конфигурации YARP состоит из двух основных разделов: Routes (маршруты, описывают, какие входящие запросы куда отправлять) и Clusters (кластеры бэкендов, описывают сами группы конечных серверов).
Пропишем самый простой вариант: ловим любые запросы и пересылаем их на один-единственный адрес. В appsettings.json добавляем:
{
"ReverseProxy": {
"Routes": {
"all": {
"ClusterId": "myCluster",
"Match": {
"Path": "{**catch-all}"
}
}
},
"Clusters": {
"myCluster": {
"Destinations": {
"dest1": {
"Address": "https://httpbin.org/"
}
}
}
}
}
}
Определили один маршрут с ключом "all", который срабатывает на любом пути ({**catch-all} это шаблон, совпадающий с любым URL) и отправляет запросы в кластер "myCluster". А кластер "myCluster" у нас содержит единственный destination dest1 с адресом https://httpbin.org/. Таким образом, любой запрос, пришедший на наш прокси (хоть GET /api/foo, хоть POST /favicon.ico) уйдет на https://httpbin.org/ с тем же методом и путем. Если запустить приложение, прокси начнет слушать по умолчанию на http://localhost:5000. Можем проверить: запрос к http://localhost:5000/get должен проксироваться на httpbin и вернуть нам ответ сервиса httpbin.
Конечно, прокси редко бывает таким статичным. Обычно маршрутов несколько, и каждый ведет на свой кластер микросервисов. YARP поддерживает богатый язык соответствия маршрутов: можно матчить не только по пути, но и по хосту (поддержка SNI для разных доменов), HTTP-методу, заголовкам и даже значениям параметров запросов. Например, можно сделать маршруты: все /api/catalog/* -> кластер CatalogService, все /api/user/* -> кластер UserService, а остальные 404 или на какую-то страничку по умолчанию. Настройка через JSON весьма проста, но на любителя: для крупных конфигов можно допускать опечатки. Альтернативно можно задать конфигурацию кодом, используя сильные стороны C#.
Конфигурация кодом и динамические обновления
В высоконагруженных системах конфигурацию прокси часто приходится менять на ходу: добавлять и убирать узлы кластеров, менять маршрутизацию в зависимости от состояний сервисов и т.п. YARP из коробки умеет подхватывать изменения конфигурации из JSON-файла, но можно пойти дальше и хранить конфиг вообще где угодно, в базе, в Service Discovery (Consul, etcd) или генерировать программно.
Для этого достаточно реализовать свой провайдер конфигурации и зарегистрировать его вместо стандартного. YARP имеет интерфейс IProxyConfigProvider и примеры реализации, суть которых – возвращать актуальный набор маршрутов и кластеров. Можно даже полностью отказаться от декларативных маршрутов и вызывать YARP как библиотеку: напрямую передавать запросы на нужный адрес через вызов IHttpForwarder (так работает Azure App Service, он сам решает, на какой инстанс слать запрос, и вызывает forwarder в обход общего роутера). Но для большинства случаев достаточно либо конфиг-файла, либо небольших хуков в процессе.
Например, добавим небольшое расширение: хотим, чтобы наш прокси автоматически добавлял префикс к пути для всех запросов или дописывал заголовок. Вместо того чтобы дублировать это в каждом маршруте, удобно воспользоваться Transforms, механизмом трансформации запросов/ответов. Про трансформации чуть ниже расскажу подробнее, а сейчас покажу, как их можно глобально подключить в коде. Метод AddReverseProxy() возвращает нам билдер, и у него есть метод AddTransforms, который принимает лямбду.
Этот код выполнится при построении каждого маршрута и позволяет вписать дополнительные правила. Добавим, скажем, префикс X-Proxy: YARP ко всем запросам, а для демонстрации условной логики, если маршрут имеет в метаданных признак AuthRequired, будем добавлять еще заголовок авторизации:
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(context =>
{
// Добавляем ко всем исходящим запросам заголовок с именем прокси
context.AddRequestHeader("X-Proxy", "YARP");
// Пример условной трансформации: для маршрутов с меткой AuthRequired добавим заголовок
if (context.RouteConfig.Metadata?.ContainsKey("AuthRequired") == true)
{
context.AddRequestHeader("X-Auth", "required");
}
});
Здесь я, конечно, исхитрился, свойства Metadata у маршрута могут задаваться разработчиком, и мы можем таким образом влиять на логику трансформации. В данном случае, если в конфигурации маршрута прописать "Metadata": { "AuthRequired": "true" }, то мы программно добавим спецзаголовок. Таким способом можно подстроить поведение прокси под разные кейсы, не копируя код. Transforms позволяют очень многое: переписать путь (например, убрать или добавить префикс), изменить хост, прокинуть клиентский сертификат, добавить/удалить заголовки, поменять метод (да-да, можно даже POST превратить в PUT по пути) – все это настраивается парой строк в JSON или через методы расширения в C#. По дефолту YARP уже добавляет важные заголовки X-Forwarded-* (как внешний адрес клиента, оригинальный хост и пр.) к каждому запросу, так что большинство сценариев работают из коробки.
Балансировка нагрузки и sticky-сессии
Высоконагруженный прокси не может быть однолюбом, он должен уметь раскидывать трафик по нескольким серверам. YARP поддерживает различные алгоритмы балансировки из коробки. По умолчанию, если явно не указать, используется алгоритм PowerOfTwoChoices, суть его в том, что берутся два случайных сервера из кластера и выбирается из них тот, на котором сейчас меньше активных запросов. Это дает более равномерную нагрузку без необходимости каждый раз опрашивать все серверы (как делал бы алгоритм LeastRequests). Помимо этого, доступны следующие политики балансировки:
FirstAlphabetical – просто выбрать первый по алфавиту доступный узел (применимо разве что для резервного фолловера из двух серверов).
Random – случайный выбор узла.
RoundRobin – классический обход по кругу, циклически распределяя запросы.
LeastRequests – отправлять на узел с наименьшим числом текущих запросов (учитывает все узлы, дает максимально равномерно, но дороже в вычислении).
Нужную политику можно указать в конфиге кластера через свойство LoadBalancingPolicy.
Давайте расширим наш пример: предположим, у нас появился второй и третий экземпляр сервиса, и мы хотим балансировать между ними по кругу. Добавим их в конфигурацию и пропишем RoundRobin:
"ReverseProxy": {
"Routes": {
"all": {
"ClusterId": "myCluster",
"Match": { "Path": "{**catch-all}" }
}
},
"Clusters": {
"myCluster": {
"LoadBalancingPolicy": "RoundRobin",
"Destinations": {
"srv1": { "Address": "http://api1.example.com" },
"srv2": { "Address": "http://api2.example.com" },
"srv3": { "Address": "http://api3.example.com" }
}
}
}
}
Теперь наш прокси будет равномерно отправлять запросы на три адреса (например, три копии бэкенд-сервиса). В YARP очень легко добавить или убрать узлы: конфиг можно менять на лету, либо через hot reload JSON, либо через свой код-провайдер. Балансировка происходит на уровне запросов: для каждого нового входящего запроса YARP выберет узел по заданной политике. Если узел недоступен или отказал, прокси попробует другой (фактически вернет ошибку клиенту, но пометит узел неисправным, об этом далее).
Стоит упомянуть session affinity (привязку сессии к серверу). Это когда нужно, чтобы все запросы от конкретного пользователя (например, по куке) шли всегда на один и тот же бэкенд, бывает необходимо для состояния, которое хранится в памяти узла. YARP поддерживает сессионную аффинность: достаточно включить ее в конфиге кластера (SessionAffinity.Enabled = true) и выбрать метод: по Cookie (YARP может сам ставить специальную cookie клиенту) или по хэшированию адреса клиента. По умолчанию (в YARP 2.x) стоит HashCookie – прокси генерирует хеш на основе специальной куки, что позволяет даже при масштабировании самого прокси сохранять консистентность (при условии одинакового секретного ключа на всех инстансах). Если же куки нет, YARP сам отправит первый запрос на случайный узел и прикрепит куку. В общем, если нужна sticky-сессия – включаем пару флагов, и прокси сам будет “липким”.
Проверка здоровья бэкендов
Когда прокси раздает трафик на десятки сервисов, высока вероятность, что иногда какой-то из них упадет или начнет тормозить. Чтобы не слать трафик в пустоту, YARP умеет активно и пассивно мониторить здоровье узлов. Активный health-check – это когда прокси периодически шлет фоновые запросы на специальные URL (например /health) каждого узла и помечает узлы здоровыми или нет в зависимости от ответа. В конфигурации кластера можно включить Active Health Check и задать интервал (Interval), таймаут и путь.
Например:
"HealthCheck": {
"Active": {
"Enabled": "true",
"Interval": "00:00:10",
"Timeout": "00:00:10",
"Policy": "ConsecutiveFailures",
"Path": "/health"
}
}
Эта настройка заставит YARP раз в 10 секунд дергать /health на каждом адресе. Политика ConsecutiveFailures означает пометить узел неработоспособным, если подряд получено N неудачных ответов (число N настраивается в Cluster.Metadata, по умолчанию 2).
Как только узел помечен нездоровым, прокси исключит его из балансировки, запросы на него идти не будут, пока он не поправится (для этого достаточно один успешный health-чек, тогда счетчик сбоев сбросится и узел снова здоров). Кроме активной проверки, есть еще пассивная: прокси следит за реальными проксируемыми запросами. Если с узла посыпались ошибки (условно, 50x статусы или сетевые ошибки), YARP автоматически пометит его нездоровым. Политика по умолчанию называется TransportFailureRate, она вычисляет процент неуспешных запросов и сравнивает с порогом.
Например, можно задать в метаданных TransportFailureRateHealthPolicy.FailureRateThreshold: 0.5 (50%). Тогда если половина запросов за окно времени упала, узел выводится из ротации на заданный период. Пассивный мониторинг не требует отдельного сетевого трафика, он работает на основе самих же запросов, и включается тоже флагом в конфиге (Passive.Enabled = true). Замечу, что для пассивного мониторинга важно, чтобы прокси понимал, что запрос завершился неудачей – например, по коду ответа или исключению. По умолчанию он считает 5xx ошибки от бэкенда или сетевые таймауты за сбойные.
Итак, благодаря health-check’ам наш прокси сам обходит проблемные узлы. Это критично в высоконагруженной системе: без автоматической реакции даже кратковременный даун тайр-2 сервиса может вызвать лавину ошибок. YARP же увидит, что, скажем, 3 проверки подряд не проходят, и перестанет слать запросы на этот адрес, перераспределив нагрузку на здоровые. Через какое-то время можно настроить автоматическое возвращение узла в строй (passive check это делает по истечении ReactivationPeriod, а active сам увидит успешный ответ и вернет узел).
Трансформации запросов и ответов
Часто на уровне прокси нужно чуть подправить запрос или ответ, чтобы склеить фронт и бэк. Пример: есть внутренний сервис, у которого все пути начинаются с /api, но внешне вы хотите проксировать его без этого префикса. Можно, конечно, заставить клиентов слать сразу с /api – но тогда при замене сервиса придется менять и клиентов. Гораздо красивее прокси, который примет запрос на /orders/42 и перешлет на внутренний http://internal/api/orders/42. YARP решает это через Transforms, о которых мы уже говорили. В конфиге можно указать трансформацию пути:
"Transforms": [
{ "PathPrefix": "/api" }
]
Эта простая строчка означает: при пересылке допиши префикс /api перед путем. Соответственно, входящий /orders/42 превратится в /api/orders/42 к бэкенду. Есть и обратная операция PathRemovePrefix, как в конфиге выше (там убирался префикс /App1 и т.д., приводя внешний /App1/foo к внутреннему /foo). Помимо пути, YARP умеет добавлять, заменять или убирать заголовки. Например:
"Transforms": [
{ "RequestHeader": "X-MyHeader", "Append": "123" }
]
Добавит ко всем проксируемым запросам заголовок X-MyHeader: 123. Можно настроить, когда именно добавлять, параметр "When": "Always" или, например, только при отсутствии уже такого заголовка. Аналогично можно играть с заголовками ответа, с cookies, менять код ответа (скажем, превращать 404 от бэкенда в 200 с заглушкой) и многое другое. По сути, трансформации – это такой конструктор, который покрывает 90% типовых задач, возникающих при интеграции сервисов через прокси. Если же и этого мало, всегда можно написать свою реализацию трансформации через ITransformProvider, то есть встроиться со своим кодом в процесс модификации запросов.
Безопасность, авторизация, CORS
Раз уж мы работаем внутри ASP.NET Core, грех не воспользоваться его возможностями по безопасности. YARP позволяет задать на уровне маршрута требования авторизации и политики CORS. Например, можно потребовать аутентификацию JWT для определенного пути и прокси сначала проверит токен с помощью стандартного middleware UseAuthentication, а только затем передаст запрос дальше. Фактически, мы можем навесить любые ASP.NET Core middleware перед вызовом MapReverseProxy(). Хотите лимитировать скорость запросов? Пожалуйста, добавьте Polly или кастомный rate limiter middleware. Хотите логировать тело запросов для аудита? Можно вклиниться перед прокси и считать стрим. В этом и прелесть YARP: раз он живет внутри вашего приложения, вы полностью контролируете pipeline.
YARP умеет сам завершать TLS-соединения на входе, т.Е бэкенды могут работать по обычному HTTP. Шифрование можно разгрузить на прокси, он будет дешифровать входящий TLS, а до сервисов слать в незашифрованном виде (или тоже по TLS – как угодно). Терминация SSL на прокси упрощает управление сертификатами и разгружает CPU сервисов. На выходе же, при общении с клиентом, YARP поддерживает HTTP/2 и даже HTTP/3 (в новых версиях .NET), так что ваши пользователи могут установить современное соединение, а прокси при этом ходит к бэкендам, например, по HTTP/1.1, все это работает прозрачно. Также YARP прекрасно дружит с WebSocket и gRPC, данные протоколы он проксирует в стриминговом режиме без потери функциональности. То есть можно через него пробрасывать долговременные соединения (SignalR, веб-сокеты) или двунаправленный gRPC-стриминг – никакой проблемы.
Производительность и масштабирование
Многих волнует, способен ли YARP тягаться с легковесными C++ серверами. Практика показывает, что способен, но, конечно, многое зависит от сценария. Если ваш прокси просто раздает статику, Nginx с оптимизированным C-кодом окажется эффективнее по пиковым показателям. Однако приложениях прокси чаще ограничен вовсе не своими внутренними накладными расходами, а производительностью бэкендов или внешними задержками (DB, сеть и т.п.).
Если же нужна еще большая пропускная способность, прокси тоже можно масштабировать горизонтально. Никто не мешает запустить несколько экземпляров YARP (например, в Kubernetes или просто на нескольких машинах) и повесить перед ними сетевой балансировщик. Они не будут мешать друг другу; важно лишь учесть нюансы sticky-сессий – предпочтительно использовать режим, основанный на хеше (HashCookie), чтобы запросы одного пользователя попадали на один бэкенд даже при разных инстансах прокси. С активными health-check тоже все в порядке: каждый прокси будет опрашивать бэкенды независимо.
YARP – очень молодая технология, но она активно развивается. Код открытый, при желании можно взглянуть, как он реализован внутри. За ним стоит команда ASP.NET, так что доверия к качеству хватает, Тем более YARP уже используется в Azure (например, в Azure App Service для внутренних маршрутизаций) и других крупных системах.
Надеюсь, этот рассказ получился для вас полезным и практичным. YARP, на мой взгляд, достоин внимания каждого, кто сталкивается с задачами маршрутизации и балансировки нагрузки.
Спасибо за внимание, и пусть ваши сервисы всегда будут здоровы, а пользователи довольны быстрыми откликами!
Если хочется выйти за рамки одного прокси и посмотреть на всю инфраструктуру высоких нагрузок, в OTUS есть практический курс «Инфраструктура высоконагруженных систем». На нём на стендах отрабатывают виртуализацию (Proxmox, KVM), кластеризацию и оркестрацию сервисов (pacemaker, Kubernetes, Nomad), дисковые кластеры (Ceph, Gluster, Linstore) и построение отказоустойчивых систем на nginx.
Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.
А еще приходите сегодня (20 ноября) в 19:00 на бесплатный демо-урок «Настройка Nginx/Angie для высоких нагрузок и защиты от DoS-атак». На нем разберем реальные практики настройки Nginx и Angie для высокой производительности, стабильности и защиты от DoS-атак. Записаться