ASP.NET Core의 속도 제한 미들웨어

아르빈 카바지, 마틴 발리오우, 릭 앤더슨

Microsoft.AspNetCore.RateLimiting 미들웨어는 속도 제한 미들웨어를 제공합니다. 앱은 속도 제한 정책을 구성한 다음, 정책을 엔드포인트에 연결합니다. 속도 제한을 사용하는 앱은 배포하기 전에 신중하게 부하를 테스트하고 검토해야 합니다. 자세한 내용은 이 문서의 속도 제한을 사용한 엔드포인트 테스트를 참조하세요.

속도 제한에 대한 소개는 속도 제한 미들웨어를 참조 하세요.

속도 제한기 알고리즘

RateLimiterOptionsExtensions 클래스는 속도 제한에 대해 다음과 같은 확장 메서드를 제공합니다.

고정 창 제한기

AddFixedWindowLimiter 메서드는 고정된 시간 창을 사용하여 요청을 제한합니다. 창이 만료되면 새 시간 창이 시작되고 요청 제한이 다시 설정됩니다.

다음 코드를 생각해 봅시다.

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: "fixed", options =>
    {
        options.PermitLimit = 4;
        options.Window = TimeSpan.FromSeconds(12);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = 2;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"))
                           .RequireRateLimiting("fixed");

app.Run();

앞의 코드가 하는 역할은 다음과 같습니다.

  • AddRateLimiter를 호출하여 서비스 컬렉션에 속도 제한 서비스를 추가합니다.
  • AddFixedWindowLimiter를 호출하여 정책 이름이 "fixed"인 고정 창 제한기를 만들고 다음을 설정합니다.
  • PermitLimit을 4로, 시간 Window를 12로 지정합니다. 12초 창마다 최대 4개의 요청이 허용됩니다.
  • QueueProcessingOrderOldestFirst로 변경되었습니다.
  • QueueLimit을 2로 지정합니다.
  • UseRateLimiter를 호출하여 속도 제한을 사용하도록 설정합니다.

제한기 옵션을 설정하려면 앱이 구성을 사용해야 합니다. 다음 코드는 구성에 MyRateLimitOptions를 사용하여 이전 코드를 업데이트합니다.

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Fixed Window Limiter {GetTicks()}"))
                           .RequireRateLimiting(fixedPolicy);

app.Run();

UseRateLimiter 는 속도 제한 엔드포인트 특정 API를 사용하는 경우 호출 UseRouting 해야 합니다. 예를 들어 특성을 사용하는 UseRateLimiter 경우 [EnableRateLimiting] 다음을 UseRouting호출해야 합니다. 전역 리미터 UseRateLimiter 만 호출하는 경우 이전에 UseRouting호출할 수 있습니다.

슬라이딩 창 제한기

슬라이딩 창 알고리즘:

  • 고정 창 제한기와 비슷하지만 창당 세그먼트를 추가합니다. 창은 각 세그먼트 간격마다 세그먼트 하나를 슬라이드합니다. 세그먼트 간격은 (창 시간)/(창당 세그먼트)입니다.
  • 요청을 permitLimit하도록 창에 대한 요청을 제한합니다.
  • 각 시간 창은 창당 n 세그먼트로 나뉩니다.
  • 만료된 시간 세그먼트에서 가져온 요청은 한 창 뒤로(n 현재 세그먼트 이전의 세그먼트)가 현재 세그먼트에 추가됩니다. 창 한 개 전의 가장 만료된 시간 세그먼트를 만료된 세그먼트라고 합니다.

30초 창, 창당 3개의 세그먼트 및 100개의 요청 제한이 있는 슬라이딩 윈도우 리미터를 보여 주는 다음 표를 고려하세요.

  • 맨 위 행과 첫 번째 열에는 시간 세그먼트가 표시됩니다.
  • 두 번째 행에는 사용 가능한 나머지 요청이 표시됩니다. 다시 기본 요청은 처리된 요청과 재활용된 요청을 뺀 사용 가능한 요청으로 계산됩니다.
  • 요청은 매번 대각선 파란색 선을 따라 이동합니다.
  • 시간 30부터, 만료된 시간 세그먼트에서 가져온 요청은 빨간색 선으로 표시한 부분에서 알 수 있듯 요청 제한에 다시 추가됩니다.

Table showing requests, limits, and recycled slots

다음 표는 이전 그래프의 데이터를 다른 서식으로 보여줍니다. 사용 가능한 열은 이전 세그먼트에서 사용할 수 있는 요청(이전 행의 이월)을 보여 줍니다. 첫 번째 행에는 이전 세그먼트가 없으므로 100개의 사용 가능한 요청이 표시됩니다.

Time 사용 가능 가져옴 만료된 후 재활용됨 이월
0 100 20 0 80
10 80 30 0 50
20 50 40 0 10
30 10 30 20 0
40 0 10 30 20
50 20 10 40 50
60 50 35 30 45

다음 코드는 슬라이딩 윈도우 속도 제한기를 사용합니다.

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Sliding Window Limiter {GetTicks()}"))
                           .RequireRateLimiting(slidingPolicy);

app.Run();

토큰 버킷 제한기

토큰 버킷 제한기는 슬라이딩 윈도우 제한기와 비슷하지만 만료된 세그먼트에서 가져온 요청을 다시 추가하지 않고 각 보충 기간마다 고정된 수의 토큰을 추가합니다. 각 세그먼트에 추가된 토큰은 사용 가능한 토큰을 토큰 버킷 제한보다 더 높게 늘릴 수 없습니다. 다음 표에서는 토큰이 100개 제한되고 10초의 보충 기간이 있는 토큰 버킷 리미더를 보여 줍니다.

Time 사용 가능 가져옴 추가됨 이월
0 100 20 0 80
10 80 10 20 90
20 90 5 15 100
30 100 30 20 90
40 90 6 16 100
50 100 40 20 80
60 80 50 20 50

다음 코드는 토큰 버킷 제한기를 사용합니다.

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var tokenPolicy = "token";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddTokenBucketLimiter(policyName: tokenPolicy, options =>
    {
        options.TokenLimit = myOptions.TokenLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
        options.ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod);
        options.TokensPerPeriod = myOptions.TokensPerPeriod;
        options.AutoReplenishment = myOptions.AutoReplenishment;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Token Limiter {GetTicks()}"))
                           .RequireRateLimiting(tokenPolicy);

app.Run();

AutoReplenishmenttrue로 설정되면 내부 타이머는 ReplenishmentPeriod 때마다 토큰을 보충합니다. false로 설정된 경우 앱이 제한기에서 TryReplenish를 호출해야 합니다.

동시성 제한기

동시성 제한은 동시 요청 수를 제한합니다. 각 요청은 동시성 제한을 1씩 줄입니다. 요청이 완료되면 제한이 1씩 증가합니다. 지정된 기간 동안 총 요청 수를 제한하는 다른 요청 제한기와 달리 동시성 제한기는 동시 요청 수만 제한하고 지정된 기간의 요청 수를 제한하지 않습니다.

다음 코드는 동시성 제한기를 사용합니다.

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var concurrencyPolicy = "Concurrency";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddConcurrencyLimiter(policyName: concurrencyPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", async () =>
{
    await Task.Delay(500);
    return Results.Ok($"Concurrency Limiter {GetTicks()}");
                              
}).RequireRateLimiting(concurrencyPolicy);

app.Run();

연결된 리미터 만들기

CreateChained API를 사용하면 하나로 PartitionedRateLimiter결합된 여러 PartitionedRateLimiter 값을 전달할 수 있습니다. 결합된 리미터는 모든 입력 리미터를 순서대로 실행합니다.

다음 코드는 CreateChained를 사용합니다.

using System.Globalization;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(_ =>
{
    _.OnRejected = (context, _) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");

        return new ValueTask();
    };
    _.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();

            return RateLimitPartition.GetFixedWindowLimiter
            (userAgent, _ =>
                new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 4,
                    Window = TimeSpan.FromSeconds(2)
                });
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();
            
            return RateLimitPartition.GetFixedWindowLimiter
            (userAgent, _ =>
                new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 20,    
                    Window = TimeSpan.FromSeconds(30)
                });
        }));
});

var app = builder.Build();
app.UseRateLimiter();

static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"));

app.Run();

자세한 내용은 CreateChained 소스 코드를 참조 하세요.

EnableRateLimitingDisableRateLimiting 특성

[EnableRateLimiting][DisableRateLimiting] 특성은 컨트롤러, 작업 메서드 또는 Razor 페이지에 적용할 수 있습니다. Pages의 경우 Razor 페이지 처리기가 아닌 페이지에 특성을 적용 Razor 해야 합니다. 예를 들어 [EnableRateLimiting]OnGet, OnPost 또는 다른 페이지 처리기에 적용할 수 없습니다.

[DisableRateLimiting] 특성은 명명된 속도 제한기 또는 전역 제한기 적용과 관계없이 컨트롤러, 작업 메서드 또는 Razor Page에 대한 속도 제한을 사용하지 않도록 설정합니다. 예를 들어 RequireRateLimiting을 호출하여 모든 컨트롤러 엔드포인트에 fixedPolicy 속도 제한을 적용하는 다음 코드를 살펴 봅시다.

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.SlidingPermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();
app.UseRateLimiter();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapRazorPages().RequireRateLimiting(slidingPolicy);
app.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy);

app.Run();

다음 코드에서 [DisableRateLimiting]은 속도 제한을 사용하지 않도록 설정하고 Program.cs에서 호출된 app.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy)Home2Controller에 적용된 [EnableRateLimiting("fixed")]을 재정의합니다.

[EnableRateLimiting("fixed")]
public class Home2Controller : Controller
{
    private readonly ILogger<Home2Controller> _logger;

    public Home2Controller(ILogger<Home2Controller> logger)
    {
        _logger = logger;
    }

    public ActionResult Index()
    {
        return View();
    }

    [EnableRateLimiting("sliding")]
    public ActionResult Privacy()
    {
        return View();
    }

    [DisableRateLimiting]
    public ActionResult NoLimit()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

앞의 코드에서 Program.csapp.MapDefaultControllerRoute().RequireRateLimiting(fixedPolicy)을 호출했기 때문에 [EnableRateLimiting("sliding")]Privacy 작업 메서드에 적용되지 않습니다.

MapRazorPages 또는 MapDefaultControllerRoute에서 RequireRateLimiting을 호출하지 않는 다음 코드를 살펴봅시다.

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var fixedPolicy = "fixed";

builder.Services.AddRateLimiter(_ => _
    .AddFixedWindowLimiter(policyName: fixedPolicy, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var slidingPolicy = "sliding";

builder.Services.AddRateLimiter(_ => _
    .AddSlidingWindowLimiter(policyName: slidingPolicy, options =>
    {
        options.PermitLimit = myOptions.SlidingPermitLimit;
        options.Window = TimeSpan.FromSeconds(myOptions.Window);
        options.SegmentsPerWindow = myOptions.SegmentsPerWindow;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    }));

var app = builder.Build();

app.UseRateLimiter();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapRazorPages();
app.MapDefaultControllerRoute();  // RequireRateLimiting not called

app.Run();

다음 컨트롤러를 고려해보세요.

[EnableRateLimiting("fixed")]
public class Home2Controller : Controller
{
    private readonly ILogger<Home2Controller> _logger;

    public Home2Controller(ILogger<Home2Controller> logger)
    {
        _logger = logger;
    }

    public ActionResult Index()
    {
        return View();
    }

    [EnableRateLimiting("sliding")]
    public ActionResult Privacy()
    {
        return View();
    }

    [DisableRateLimiting]
    public ActionResult NoLimit()
    {
        return View();
    }

    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

이전 컨트롤러에서 다음을 수행합니다.

  • "fixed" 정책 속도 제한기는 EnableRateLimitingDisableRateLimiting 특성이 없는 모든 작업 메서드에 적용됩니다.
  • "sliding" 정책 속도 제한기는 Privacy 작업에 적용됩니다.
  • NoLimit 작업 메서드에서 속도 제한을 사용할 수 없습니다.

Pages에 Razor 특성 적용

Pages의 경우 Razor 페이지 처리기가 아닌 페이지에 특성을 적용 Razor 해야 합니다. 예를 들어 [EnableRateLimiting]OnGet, OnPost 또는 다른 페이지 처리기에 적용할 수 없습니다.

DisableRateLimiting 특성은 Razor 페이지에서 속도 제한을 사용하지 않도록 설정합니다. MapRazorPages().RequireRateLimiting(Policy)이 호출되지 않은 경우에만 EnableRateLimiting이 Razor 페이지에 적용됩니다.

제한기 알고리즘 비교

고정, 슬라이딩 및 토큰 제한기는 모두 지정된 기간의 최대 요청 수를 제한합니다. 동시성 제한기는 동시 요청 수만 제한하고 지정된 기간의 요청 수를 제한하지 않습니다. 제한기를 선택할 때 엔드포인트 비용을 고려해야 합니다. 엔드포인트 비용에는 사용된 리소스(예: 시간, 데이터 액세스, CPU 및 I/O)가 포함됩니다.

속도 제한기 샘플

다음 샘플은 프로덕션 코드에 대한 것이 아니라 제한기를 사용하는 방법에 대한 예제입니다.

OnRejected, RetryAfterGlobalLimiter가 있는 제한기

다음 샘플에서는:

  • 요청이 지정된 제한을 초과할 때 호출되는 RateLimiterOptions.OnRejected 콜백을 만듭니다. retryAfterTokenBucketRateLimiter, FixedWindowLimiterSlidingWindowLimiter와 함께 사용할 수 있습니다. 이러한 알고리즘은 허가가 언제 더 많이 추가될지 예측할 수 있기 때문입니다. ConcurrencyLimiter에는 허가를 받을 수 있는 시기를 계산할 방법이 없습니다.

  • 다음 제한기를 추가합니다.

    • IRateLimiterPolicy<TPartitionKey> 인터페이스를 구현하는 SampleRateLimiterPolicy. SampleRateLimiterPolicy 클래스는 이 문서의 뒷부분에 나와 있습니다.
    • A SlidingWindowLimiter:
      • 인증된 각 사용자에 대한 파티션이 있는 제한기.
      • 모든 익명 사용자에 대한 하나의 공유 파티션이 있는 제한기.
    • 모든 요청에 적용되는 GlobalLimiter. 전역 제한기가 먼저 실행되고 엔드포인트별 제한기(있는 경우)가 실행됩니다. GlobalLimiter는 각 IPAddress에 대한 파티션을 만듭니다.
// Preceding code removed for brevity.
using System.Globalization;
using System.Net;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebRateLimitAuth;
using WebRateLimitAuth.Data;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
    throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.Configure<MyRateLimitOptions>(
    builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit));

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var userPolicyName = "user";
var helloPolicy = "hello";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int) retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.RequestServices.GetService<ILoggerFactory>()?
            .CreateLogger("Microsoft.AspNetCore.RateLimitingMiddleware")
            .LogWarning("OnRejected: {GetUserEndPoint}", GetUserEndPoint(context.HttpContext));

        return new ValueTask();
    };

    limiterOptions.AddPolicy<string, SampleRateLimiterPolicy>(helloPolicy);
    limiterOptions.AddPolicy(userPolicyName, context =>
    {
        var username = "anonymous user";
        if (context.User.Identity?.IsAuthenticated is true)
        {
            username = context.User.ToString()!;
        }

        return RateLimitPartition.GetSlidingWindowLimiter(username,
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = myOptions.PermitLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                Window = TimeSpan.FromSeconds(myOptions.Window),
                SegmentsPerWindow = myOptions.SegmentsPerWindow
            });

    });
    
    limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(context =>
    {
        IPAddress? remoteIpAddress = context.Connection.RemoteIpAddress;

        if (!IPAddress.IsLoopback(remoteIpAddress!))
        {
            return RateLimitPartition.GetTokenBucketLimiter
            (remoteIpAddress!, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetNoLimiter(IPAddress.Loopback);
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseRateLimiter();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages().RequireRateLimiting(userPolicyName);
app.MapDefaultControllerRoute();

static string GetUserEndPoint(HttpContext context) =>
   $"User {context.User.Identity?.Name ?? "Anonymous"} endpoint:{context.Request.Path}"
   + $" {context.Connection.RemoteIpAddress}";
static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");

app.MapGet("/a", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
    .RequireRateLimiting(userPolicyName);

app.MapGet("/b", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
    .RequireRateLimiting(helloPolicy);

app.MapGet("/c", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}");

app.Run();

Warning

클라이언트 IP 주소에 파티션을 만들면 앱이 IP 원본 주소 스푸핑을 사용하는 서비스 거부 공격에 취약해집니다. 자세한 내용은 BCP 38 RFC 2827 네트워크 수신 필터링: IP 원본 주소 스푸핑을 사용하는 서비스 거부 공격을 참조하세요.

전체 Program.cs에 대한 샘플 리포지토리 파일을 참조하세요.

SampleRateLimiterPolicy 클래스

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using WebRateLimitAuth.Models;

namespace WebRateLimitAuth;

public class SampleRateLimiterPolicy : IRateLimiterPolicy<string>
{
    private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;
    private readonly MyRateLimitOptions _options;

    public SampleRateLimiterPolicy(ILogger<SampleRateLimiterPolicy> logger,
                                   IOptions<MyRateLimitOptions> options)
    {
        _onRejected = (ctx, token) =>
        {
            ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            logger.LogWarning($"Request rejected by {nameof(SampleRateLimiterPolicy)}");
            return ValueTask.CompletedTask;
        };
        _options = options.Value;
    }

    public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => _onRejected;

    public RateLimitPartition<string> GetPartition(HttpContext httpContext)
    {
        return RateLimitPartition.GetSlidingWindowLimiter(string.Empty,
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = _options.PermitLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = _options.QueueLimit,
                Window = TimeSpan.FromSeconds(_options.Window),
                SegmentsPerWindow = _options.SegmentsPerWindow
            });
    }
}

이전 코드에서 OnRejectedOnRejectedContext를 사용하여 응답 상태를 429 너무 많은 요청으로 설정합니다. 거부된 기본 상태는 503 서비스를 사용할 수 없음입니다.

권한 부여를 사용한 제한기

다음 샘플에서는 JSON JWT(웹 토큰)를 사용하며 JWT 액세스 토큰을 사용하여 파티션을 만듭니다. 프로덕션 앱에서 JWT는 일반적으로 STS(보안 토큰 서비스) 역할을 하는 서버에서 제공됩니다. 로컬 개발의 경우 dotnet user-jwts 명령줄 도구를 사용하여 앱별 로컬 JWT를 만들고 관리할 수 있습니다.

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Primitives;
using WebRateLimitAuth.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer").AddJwtBearer();

var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);
var jwtPolicyName = "jwt";

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    limiterOptions.AddPolicy(policyName: jwtPolicyName, partitioner: httpContext =>
    {
        var accessToken = httpContext.Features.Get<IAuthenticateResultFeature>()?
                              .AuthenticateResult?.Properties?.GetTokenValue("access_token")?.ToString()
                          ?? string.Empty;

        if (!StringValues.IsNullOrEmpty(accessToken))
        {
            return RateLimitPartition.GetTokenBucketLimiter(accessToken, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetTokenBucketLimiter("Anon", _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = myOptions.TokenLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                TokensPerPeriod = myOptions.TokensPerPeriod,
                AutoReplenishment = true
            });
    });
});

var app = builder.Build();

app.UseAuthorization();
app.UseRateLimiter();

app.MapGet("/", () => "Hello, World!");

app.MapGet("/jwt", (HttpContext context) => $"Hello {GetUserEndPointMethod(context)}")
    .RequireRateLimiting(jwtPolicyName)
    .RequireAuthorization();

app.MapPost("/post", (HttpContext context) => $"Hello {GetUserEndPointMethod(context)}")
    .RequireRateLimiting(jwtPolicyName)
    .RequireAuthorization();

app.Run();

static string GetUserEndPointMethod(HttpContext context) =>
    $"Hello {context.User.Identity?.Name ?? "Anonymous"} " +
    $"Endpoint:{context.Request.Path} Method: {context.Request.Method}";

ConcurrencyLimiter, TokenBucketRateLimiter 및 권한 부여가 있는 제한기

다음 샘플에서는:

  • Razor Pages에 사용되는 "get"의 정책 이름을 가진 ConcurrencyLimiter를 추가합니다.
  • 권한이 부여된 각 사용자에 대한 파티션과 모든 익명 사용자에 대한 파티션이 있는 TokenBucketRateLimiter를 추가합니다.
  • RateLimiterOptions.RejectionStatusCode429 너무 많은 요청으로 설정합니다.
var getPolicyName = "get";
var postPolicyName = "post";
var myOptions = new MyRateLimitOptions();
builder.Configuration.GetSection(MyRateLimitOptions.MyRateLimit).Bind(myOptions);

builder.Services.AddRateLimiter(_ => _
    .AddConcurrencyLimiter(policyName: getPolicyName, options =>
    {
        options.PermitLimit = myOptions.PermitLimit;
        options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        options.QueueLimit = myOptions.QueueLimit;
    })
    .AddPolicy(policyName: postPolicyName, partitioner: httpContext =>
    {
        string userName = httpContext.User.Identity?.Name ?? string.Empty;

        if (!StringValues.IsNullOrEmpty(userName))
        {
            return RateLimitPartition.GetTokenBucketLimiter(userName, _ =>
                new TokenBucketRateLimiterOptions
                {
                    TokenLimit = myOptions.TokenLimit2,
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit = myOptions.QueueLimit,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                    TokensPerPeriod = myOptions.TokensPerPeriod,
                    AutoReplenishment = myOptions.AutoReplenishment
                });
        }

        return RateLimitPartition.GetTokenBucketLimiter("Anon", _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = myOptions.TokenLimit,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = myOptions.QueueLimit,
                ReplenishmentPeriod = TimeSpan.FromSeconds(myOptions.ReplenishmentPeriod),
                TokensPerPeriod = myOptions.TokensPerPeriod,
                AutoReplenishment = true
            });
    }));

전체 Program.cs에 대한 샘플 리포지토리 파일을 참조하세요.

속도 제한을 사용하여 엔드포인트 테스트하기

프로덕션에 속도 제한을 사용하여 앱을 배포하기 전에 앱을 스트레스 테스트하여 사용된 속도 제한 및 옵션의 유효성을 검사합니다. 예를 들어 BlazeMeter 또는 apache JMeter HTTP(S) 테스트 스크립트 레코더 같은 도구를 사용하여 JMeter 스크립트를 만들고 Azure Load Testing에 스크립트를 로드하세요.

사용자 입력을 사용하여 파티션을 만들면 앱이 DoS(서비스 거부) 공격에 취약해집니다. 예를 들어 클라이언트 IP 주소에 파티션을 만들면 앱이 IP 원본 주소 스푸핑을 사용하는 서비스 거부 공격에 취약해집니다. 자세한 내용은 BCP 38 RFC 2827 네트워크 수신 필터링: IP 원본 주소 스푸핑을 사용하는 서비스 거부 공격을 참조하세요.

추가 리소스