NET

Круговорот технологий в tech-индустрии не прекращается. При помощи таких современных инструментов, как Docker Engine и Kubernetes масштабировать приложения стало проще, чем когда-либо. К сожалению, эти приложения не допускают горизонтальное масштабирование изначально, требуя некоторой настройки. В этой статье мы рассмотрим и осуществим данный процесс с помощью платформы .NET. 

Начнем мы с определения разницы между горизонтальным и вертикальным масштабированием, иначе называемым как масштабирование в ширину или вверх. Затем разберем отличия между приложениями с поддержкой сохранения состояния и без нее. А в завершении рассмотрим решение проблем, возникающих с конкретными протоколами и технологиями, при масштабировании сохраняющих состояния приложений ASP.NET Core.

Горизонтальное и вертикальное масштабирование

При выборе принципа масштабирования необходимо понимать, что это вообще такое. Масштабирование приложения  —  это наращивание инфраструктуры его компонентов (вычислительных ресурсов, хранилища, сети), чтобы оно могло единовременно обслуживать больше запросов. Сделать это можно двумя способами.

Вертикальное масштабирование

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

Горизонтальное масштабирование

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

При вертикальном масштабировании компьютер или web-приложение могут задействовать только имеющиеся ограниченные ресурсы. К примеру, операционная система может использовать только определенное количество памяти/места на диске, или вся мощность CPU может быть занята, не позволяя выполнить расширение. 

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

Приложение с сохранением состояния и без

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

Представьте, что нагрузка на приложение бэк-офиса, созданное при помощи .NET Core MVC, настолько возросла, что команда его разработчиков решает масштабировать его до нескольких экземпляров. В этом случае для обмена информацией между запросами разработчики используют сессии на стороне сервера.

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

Таким образом приложение становится stateful, т.е. поддерживающим сохранение состояния. 

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

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

Решение проблем с помощью привязки к серверу

Один из подходов к решению проблем горизонтального масштабирования  —  это применение привязки к серверу. Привязка к серверу означает способность направлять все связанные запросы от конкретного клиента к одному определенному экземпляру через балансировщик нагрузки. 

Принцип балансировщика нагрузки

С помощью функционала sticky sessions (“прилипающих” сессий), использующихся в балансировщике нагрузки, все эти запросы можно отправлять конкретному вышестоящему экземпляру приложения.

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

Поэтому в идеале обработка каждого запроса должна быть доступна всем экземплярам. 

Централизованное распределенное хранилище

Более действенным решением данной проблемы будет реализация на уровне приложения централизованного хранилища данных для общего доступа.

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

Сессии: хранение сессии с помощью распределенного кэша

Microsoft разработали функциональность распределенного кэша, предоставив возможность нескольким экземплярам приложения обмениваться данными о состоянии сессии. С помощью реализаций для SQL Server, Redis и Ncache распределенный кэш легко поддается настройке. Доступ же к нему осуществляется через интерфейс IDistributedCache. Кроме того, его можно объединить с аутентификацией куки.

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

Давайте начнем с запуска экземпляра Redis. Проще всего это сделать с помощью официального Docker-образа.

Нижеприведенная команда запустит в памяти новый экземпляр Redis на порту 6379.

docker run -p 6379:6379 --name some-redis -d redis

Теперь в своем приложении ASP.NET Core MVC вы сможете установить необходимый для распределенного кэша Redis пакет NuGet. Этот пакет содержит методы расширения, внедряющие нужные зависимости в коллекцию сервисов приложения. Выполнить это можно, добавив следующий код в функцию Startup.ConfigureServices класса Startup.cs:

services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";
});

В свойстве Configuration можно указать строку подключения. Если же вас интересуют нестандартные настройки, можете обратиться к соответствующему разделу документации (англ.). Поскольку экземпляр Redis выполняется на предустановленном порту локального хоста, текущего значения будет достаточно.

Затем конфигурацию сессии нужно добавить в коллекцию сервисов. Для этого включите метод расширения AddSession в метод Startup.ConfigureServices. Его нужно добавить к внесенному ранее методу расширения, как показано ниже:

services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";
});

services.AddSession(options =>
{
  options.IdleTimeout = TimeSpan.FromMinutes(20);
  options.Cookie.HttpOnly = true;
  options.Cookie.IsEssential = true;
});
  • Чтобы сессии заработали, в их промежуточном ПО используются куки с уникальным идентификатором. Завершение же сессии происходит либо спустя определенное время простоя, либо при закрытии браузера. Настроить это можно через свойство IdleTimeout.
  • Поскольку эти куки используются только для идентификации клиента, нужно указать свойство HttpOnly, что исключит добавление в нее лишней информации. 
  • И, наконец, куки указываются как необходимые через свойство IsEssential. Это гарантирует ее обход функцией согласия на использование куки, доступной для задач GPDR (общего регламента по защите данных).

Для заключительной активации промежуточного ПО сессии, нужно добавить в конвейер запроса UseSessions. При этом важен момент вызова промежуточного ПО. Он должен происходить после UseRouting и перед UseEndpoints. В итоге класс Startup будет выглядеть примерно так:

Copy
public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddStackExchangeRedisCache(options =>
        {
            options.Configuration = "localhost";
        });

        services.AddSession(options =>
        {
            options.IdleTimeout = TimeSpan.FromMinutes(20);
            options.Cookie.HttpOnly = true;
            options.Cookie.IsEssential = true;
        });

        services.AddControllersWithViews();
        services.AddRazorPages();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();

        app.UseSession();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapDefaultControllerRoute();
            endpoints.MapRazorPages();
        });
    }
}

На этом все. Сессии можно устанавливать и извлекать в свойстве HttpContext метода MVC-контроллера. Описанная реализация распределенного кэша отвечает за сохранение сессии в экземпляре Redis.

// Устанавливает в текущей сессии строковое значение с ключом 
HttpContext.Session.SetString(SessionKeyName, “value”);

// Получает из текущей сессии строковое значение по его ключу
var value = HttpContext.Session.GetString(SessionKeyName);

Куки: создание ключей защиты с помощью API защиты данных

При использовании куки-аутентификации или CSRF-куки web-приложению нужно сохранять чувствительную информацию на клиентской стороне. Поскольку клиенты небезопасны из-за возможного раскрытия информации куки, Microsoft реализовали функциональность Data Protection API, чтобы иметь возможность обезопасить информацию куки. С помощью определенного поставщика и ключей защиты эту информацию можно делать как защищенной, так и наоборот.

Data Protection API по умолчанию сохраняет ключи защиты в специальном каталоге операционной системы. Если же выполнение происходит в контейнере Docker или в Kubernetes, ключи не сохраняются. 

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

В Microsoft решили эту проблему, создав несколько реализаций поставщиков хранилища ключей для изменения места их хранения. К этим поставщикам относятся Azure Storage, Windows Registry и Redis. Поскольку у нас уже есть экземпляр Redis, для демонстрации реализации используем его же.

Чтобы использовать необходимый метод расширения потребуется пакет NuGet

Пакет Microsoft.AspNetCore.DataProtection.StackExchangeRedis использует тот же клиент Redis, что и пакет распределенного кэша. После включения пакета NuGet реализация выполняется добавлением следующего кода в метод Startup.ConfigureServices:

var redis = ConnectionMultiplexer.Connect("<URI>");
services.AddDataProtection()
  .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys");

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

Заключение

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

При масштабировании приложения вертикально в существующие компоненты инфраструктуры добавляются дополнительные ресурсы. При горизонтальном же масштабировании создается новый экземпляр с собственными выделенными ресурсами. Этот процесс выполняется параллельно с существующим экземпляром и сложнее поддается настройке.

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

IDistributedCache является актуальным решением передачи серверной информации между разными экземплярами приложения. 

Централизованный обмен ключами защиты данных решает проблему масштабируемости для аутентификации и CSRF-куки.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Mattias te Wierik: Building Horizontal Scalable Stateful Applications With ASP.NET Core

Предыдущая статьяСравнение Go и Rust через написание CLI-инструмента
Следующая статьяГенерируем образы Docker с помощью Spring Boot