Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
*Gateway — шлюз
Azure Active Directory Gateway — это обратный прокси-сервер, который работает с сотнями служб, входящих в Azure Active Directory (Azure AD). Если вы пользовались такими службами, как office.com, outlook.com, azure.com или xbox.live.com, то вы использовали шлюз Azure AD. Шлюз присутствует в более чем 53 центрах обработки данных Azure по всему миру и обслуживает ~115 миллиардов запросов ежедневно. До недавнего времени шлюз Azure AD работал на платформе .NET Framework 4.6.2. С сентября 2020 года он работает на .NET Core 3.1.
Мотивация для перехода на .NET Core
Масштаб результатов шлюза приводит к значительному потреблению вычислительных ресурсов, что, в свою очередь, стоит денег. Поиск путей снижения стоимости работы сервиса был ключевой целью для команды, стоящей за ней. Шумиха вокруг производительности .NET Core привлекла наше внимание, тем более что TechEmpower назвал ASP.NET Core одним из самых быстрых веб-фреймворков на планете. Мы провели собственные тесты прототипов шлюза на .NET Core, и результаты позволили нам принять очень простое решение: мы должны перенести наш сервис на .NET Core.
Обеспечивает ли .NET Core реальную экономию средств?
Безусловно, обеспечивает. Благодаря шлюзу Azure AD мы смогли сократить затраты на CPU (ЦП) на 50%.
Раньше шлюз работал на IIS с .NET Framework 4.6.2. Сегодня он работает на IIS с .NET Core 3.1. На изображении ниже показано, что использование ЦП сократилось вдвое на .NET Core 3.1 по сравнению с .NET Framework 4.6.2 (фактически удвоив пропускную способность).
В результате увеличения пропускной способности мы смогли уменьшить размер нашего сервера с ~40 тыс. до ~20 тыс. ядер (сокращение на 50%).
Как произошел переход к .NET Core?
Он происходил в 3 этапа.
Этап 1: Выбор пограничного сервера
Когда мы начали работу по переходу, первый вопрос, который мы должны были задать себе, был: какой из трех серверов в .NET Core мы выберем?
Мы запустили наши сценарии на всех трех серверах и поняли, что все сводится к поддержке TLS. Учитывая, что шлюз является обратным прокси, поддержка широкого спектра сценариев TLS очень важна.
Kestrel:
Когда мы инициировали наш переход (ноябрь 2019 года), Kestrel не поддерживал ни согласование клиентских сертификатов, ни их аннулирование для каждого имени хоста. В .NET 5.0 поддержка этих функций была добавлена.
Как и в .NET 5.0, Kestrel (благодаря использованию SslStream) не поддерживает CTL-хранилища для каждого имени хоста. Поддержка ожидается в .NET 6.0.
HTTP.sys:
Сервер HTTP.sys столкнулся с несоответствием между конфигурацией TLS на Http.Sys и имплементацией .NET: Даже если привязка настроена так, чтобы не согласовывать клиентские сертификаты, обращение к свойству Client certificate в .NET Core вызывает нежелательное повторное согласование TLS.
Например, выполнение простого null check в C# приводит к повторному согласованию TLS handshake (рукопожатия TLS):
if (HttpContext.Connection.ClientCertificate != null)
Это показано в https://github.com/dotnet/aspnetcore/issues/14806 в ASP.NET Core 3.1. В то время, когда мы делали переход в ноябре 2019 года, мы были на ASP.NET Core 2.2 и поэтому не выбрали этот сервер.
IIS:
IIS отвечал всем нашим требованиям к TLS, поэтому мы выбрали именно этот сервер.
Этап 2: Миграция приложения и зависимостей
Как и многие крупные службы и приложения, шлюз Azure AD имеет множество зависимостей. Некоторые из них были написаны специально для этой службы, а некоторые другими специалистами внутри и вне Microsoft. В некоторых случаях эти библиотеки уже были нацелены на .NET Standard 2.0. В других случаях мы обновили их для поддержки .NET Standard 2.0 или нашли альтернативные имплементации, например, удалили нашу устаревшую библиотеку Dependency Injection и вместо нее использовали встроенную в .NET Core поддержку для внедрения зависимостей. На этом этапе большую помощь оказал .NET Portability Analyzer.
Для самого приложения:
Шлюз Azure AD имел зависимость от IHttpModule и IHttpHandler из классического ASP.NET, которой нет в ASP.NET Core. Поэтому мы переработали приложение, используя конструкции промежуточного ПО в ASP.NET Core.
Одной из вещей, которая действительно помогла в процессе миграции, является Azure Profiler (служба, которая собирает трассировки производительности на виртуальных машинах Azure). Мы развертывали наши ночные сборки на тестовых площадках, использовали wrk2 в качестве агента нагрузки для тестирования сценариев под нагрузкой и собирали трассы Azure Profiler. Эти трассировки затем информировали нас о следующей настройке, необходимой для получения пиковой производительности нашего приложения.
Этап 3: Постепенное развертывание
Философия, которую мы придерживались во время развертывания, заключалась в том, чтобы обнаружить как можно больше проблем с минимальным или нулевым влиянием на работу.
Мы развернули наши первоначальные версии в тестовом, интеграционном и DogFood средах. Это привело к раннему обнаружению ошибок и помогло исправить их до того, как они попали в работу.
После завершения кода мы развернули сборку .NET Core на одной производственной системе в единице масштабирования. Единица масштабирования — это пул виртуальных машин с балансировкой нагрузки.
В единице масштабирования ~100 машин, где на 99 машинах все еще работала наша существующая сборка .NET Framework и только на 1 машине была установлена новая сборка .NET Core.
Все ~100 машин в этом масштабном блоке получают точный тип и количество трафика. Затем мы сравнили коды состояния, количество ошибок, функциональные сценарии и производительность одной машины с остальными 99 машинами, чтобы обнаружить аномалии.
Мы хотели, чтобы эта единственная машина вела себя функционально так же, как и остальные 99 машин, но имела гораздо более высокую производительность/пропускную способность, что мы и наблюдали.
Мы также "перенаправили" трафик с действующих производственных устройств (работающих с .NET Framework build) на устройства с .NET Core, чтобы сравнить и сопоставить их, как указано выше.
Как только мы достигли функциональной эквивалентности, мы начали увеличивать количество единиц масштаба, работающих на .NET Core, и постепенно расширили их до целого центра данных.
После миграции всего центра обработки данных последним шагом было постепенное распространение по всему миру на все центры обработки данных Azure, где присутствует служба шлюза Azure AD. Миграция завершена!
Полученные знания
ASP.NET Core внимателен к RFC. Это очень хорошая особенность, так как она способствует распространению передовой практики. Однако классические ASP.NET и .NET Framework были более снисходительны, что вызывает некоторые проблемы с обратной совместимостью:
Веб-сервер по умолчанию разрешает только значения ASCII в заголовках HTTP. По нашей просьбе поддержка Latin1 была добавлена в IISHttpServer: https://github.com/dotnet/aspnetcore/pull/22798
HttpClient
на .NET Core раньше поддерживал только значения ASCII в HTTP-заголовках.
Команда .NET Core добавила поддержку Latin1 в .NET Core 3.1: https://github.com/dotnet/corefx/pull/42978
Возможность выбора схемы кодировки добавлена в .NET 5.0: https://github.com/dotnet/runtime/issues/38711.
Формы и cookies, не соответствующие RFC, приводят к исключениям при проверке. Поэтому мы создали "запасные" парсеры, используя классический исходный код ASP.NET, чтобы сохранить обратную совместимость для клиентов.
В методе
FileBufferingReadStream
‘sCopyToAsync()
наблюдалось снижение производительности из-за нескольких 1-байтовых копий n-байтового потока. Эта проблема решена в .NET 5.0 путем выбора размера буфера по умолчанию в 4K: https://github.com/dotnet/aspnetcore/issues/24032.
Помните о классических причудах ASP.NET:
Автоматически тримятся (auto-trimmed) символы пробелов:
foo.com/oauth ?client=abc тримятся до foo.com/oauth?client=abc в классическом ASP.NET.
С течением времени клиенты/даунстрим сервисы стали зависеть от этих тримов, а ASP.NET Core не выполняет автоматическтий триминг.
Поэтому нам пришлось убрать пробельные символы (auto-trim), чтобы имитировать классическое поведение ASP.NET.
Заголовок
Content-Type
автоматически генерируется, если отсутствует тому:
Когда размер ответа больше нуля байт, но заголовок Content-Type
отсутствует, классический ASP.NET генерирует стандартный заголовок Content-Type:text/html
. ASP.NET Core не генерирует заголовок Content-Type
по умолчанию принудительно, и клиенты, которые полагают, что заголовок Content-Type
всегда появляется в ответе, начинают испытывать проблемы. Мы имитировали классическое поведение ASP.NET, добавив Content-Type по умолчанию, когда он отсутствует в нижестоящих службах.
Будущее
Переход на .NET Core привел к удвоению пропускной способности нашего сервиса, и это было отличное решение. Наше путешествие по .NET Core не закончится после перехода. В будущем мы рассматриваем следующие варианты:
Обновление до .NET 5.0 для повышения производительности.
Переход на Kestrel, чтобы мы могли перехватывать соединения на уровне TLS для повышения отказоустойчивости.
Использовать компоненты/лучшие практики YARP (https://microsoft.github.io/reverse-proxy/) в нашем собственном обратном прокси и также внести свой вклад.
Перевод материала подготовлен в рамках специализации "Network Engineer". Эта учебная программа подойдет тем, кто планирует с нуля освоить профессию сетевого инженера и подготовиться к прохождению промышленной сертификации CCNA.