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

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


Round Robin (Циклический перебор)

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

Начнем с простого примера, 1 сервер с пропускной способностью 1000 RPS

В случае, когда количество запросов на сервис мал, достаточна 1 экземпляра сервиса для работоспособности системы.

Допустим, наш сервис берет слишком много ресурсов сервера. Для демонстрации этого ограничил пропускную способность до 5 RPS.

{
  "UpstreamPathTemplate": "/Limited/Service/Request",
  "UpstreamHttpMethod": [ "Get" ],
  "DownstreamPathTemplate": "/api/Service/Request",
  "DownstreamScheme": "http",
  "DownstreamHostAndPorts": [
    {
      "Host": "ratelimiting",
      "Port": 80
    }
  ],
  "RateLimitOptions": {
    "EnableRateLimiting": true,
    "Period": "1s",
    "PeriodTimespan": 2,
    "Limit": 5
  }
}

Тут шлюз Ocelot за Period 1s ограничивает все запросы на конечную точку до пяти запросов (Limit)

Часть запросов на сервер дают ошибку 429 (Слишком много запросов)

Мы можем добавить несколько серверов чтобы балансировщик распределял запросы между ними

Реализация:

Hidden text

Создаем ноду сервиса.

Создайте Web API проект, не забудьте поставить галочку "Добавить поддержку Docker".

 Создайте ServiceController:

[Route("api/[controller]")]
[ApiController]
[EnableRateLimiting("fixed")]
public class ServiceController : ControllerBase
{
    [HttpGet("Request")]
    public async Task<IActionResult> Request()
    {
        string request = HttpContext.Request.GetDisplayUrl();
        return Ok($"Success_from_{request}");
    }
}

В Program добавьте RateLimiter из пространства имен Microsoft.AspNetCore.RateLimiting.

...
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
   rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
   rateLimiterOptions.AddFixedWindowLimiter("fixed", options =>
   {
      options.PermitLimit = int.Parse(builder.Configuration["RateLimit"]!);
      options.Window = TimeSpan.FromSeconds(1);
   });
});
...
app.UseRateLimiter();
...

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

Создаем шлюз Ocelot

Создайте Web API проект

Добавляем nuget пакет Ocelot:

dotnet add package Ocelot

добавляем Ocelot в Program

...
builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
builder.Services.AddOcelot(builder.Configuration);
...
await app.UseOcelot();
...

Создаем конфигурацию ocelot.json

{
  "UpstreamPathTemplate": "/LimitedRoundRobin/Service/Request",
  "UpstreamHttpMethod": [ "Get" ],
  "DownstreamPathTemplate": "/api/Service/Request",
  "DownstreamScheme": "http",
  "DownstreamHostAndPorts": [
    {
      "Host": "limitedroundrobinnode1",
      "Port": 80
    },
    {
      "Host": "limitedroundrobinnode2",
      "Port": 80
    },
    {
      "Host": "limitedroundrobinnode3",
      "Port": 80
    }
  ],
  "LoadBalancerOptions": {
    "Type": "RoundRobin"
  }
}

В демонстрации мы видим, 3 сервера (слабый, средний, мощный). Часть запросов не обрабатываются из-за rate limiter middleware.

Мы можем это обойти, с помощью алгоритма Weighted Round Robin. Идея заключается в том, что для каждого сервера задается вес, и серверам с большим весом направляется больше запросов. Но проблема в том что, Ocelot не предоставляет встроенной поддержки weighted round robin, как это делает, например, Nginx. Я обошел это так:

Hidden text

{
"UpstreamPathTemplate": "/WeightedRoundRobin/Service/Request",
"UpstreamHttpMethod": [ "Get" ],
"DownstreamPathTemplate": "/api/Service/Request",
"DownstreamScheme": "http",
"DownstreamHostAndPorts": [
{
"Host": "limitedroundrobinnode1",
"Port": 80
},
{
"Host": "limitedroundrobinnode2",
"Port": 80
},
{
"Host": "limitedroundrobinnode2",
"Port": 80
},
{
"Host": "limitedroundrobinnode3",
"Port": 80
},
{
"Host": "limitedroundrobinnode3",
"Port": 80
},
{
"Host": "limitedroundrobinnode3",
"Port": 80
}
],
"LoadBalancerOptions": {
"Type": "RoundRobin"
}
}

Мы видим, что слабый сервер обрабатывает 1 запрос за цикл, средний сервер 2, сильный сервер 3

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

Least connections

Шлюз распределяет новые подключение к серверу с наименьшим количеством активных соединений.

В ocelot для реализации алгоритма просто нужно указать Type LeastConnection в LoadBalancerOptions

"LoadBalancerOptions": {
  "Type": "LeastConnection"
}

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

Все примеры из этой статьи я выложил на своем сайте, можете пройти по ссылке http://sandbox.codewithaman.net/LoadBalancerDemonstration и потыкать самим и сравнить

В Ocelot можно настроить кеширование на стороне шлюза (не на стороне сервиса) для конкретной конечной точки

"FileCacheOptions": {
    "TtlSeconds": 10
  }

Это значит, для API запрос кэшируется в течении 10 секунд, после чего кэш сбрасывается

Кроме того Ocelot позволяет настраивать обнаружение сервисов, с помощью которого ваши алгоритмы будут следить за работоспособностью ваших серверов