RE:NODE

Приложения12 мин чтения

Производительность ASP.NET Core на небольших серверах

Как сделать ASP.NET Core быстрым на 1-2 vCPU и паре GB RAM: лимиты Kestrel, сжатие ответов, output caching, голодание пула потоков и аллокации.

0 прочтений

ASP.NET Core - один из самых быстрых веб-стеков, какие есть, и всё же на небольшом сервере его легко сделать медленным. Проблема редко во фреймворке. На половине ядра и гигабайте памяти вам дорого обходятся блокировки на async-коде, мегабайты аллокаций на каждый запрос, несжатый JSON, повторное вычисление одного и того же ответа тысячу раз в минуту и неограниченная очередь запросов, которой позволяют копиться вместо того, чтобы отказывать лишним. Исправьте эти пять вещей, и один небольшой экземпляр выдержит больше трафика, чем большинство проектов вообще когда-либо увидят. Эта статья проходит каждую из них с настройками, их значениями по умолчанию и способом понять, какая из них действительно вам вредит, прежде чем что-то менять.

Что меняет небольшой сервер#

Приложение на .NET, которое подбиралось по размеру на ноутбуке разработчика, в контейнере упирается в два жёстких ограничения: квоту CPU и лимит памяти. Оба меняют поведение рантайма.

Рантайм читает квоту CPU из cgroup контейнера и выставляет по ней Environment.ProcessorCount, округляя вверх. На тарифе с половиной vCPU приложение считает, что у него один процессор, и от этого зависит, сколько куч создаёт сборщик мусора, со сколькими потоками стартует пул потоков и сколько параллелизма библиотеки выбирают по умолчанию. При этом квота остаётся жёстким ограничителем: как только процесс израсходовал свою долю периода планирования, он ждёт следующего, и это проявляется как всплески задержки, а не как ошибка.

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

Размер тарифаЧто на нём спокойно работаетГде кончается в первую очередь
0.5 vCPU, 1 GBAPI или небольшой сайт, десятки запросов в секундуCPU во время всплесков и GC
1 vCPU, 2 GBНагруженный API, Blazor Server на несколько десятков пользователейПамять на соединение, CPU на сериализацию
1.5-2 vCPU, 4 GBПродукт с реальным трафиком и фоновыми задачамиОбращения к базе данных, а не приложение
3 vCPU, 8 GBНесколько приложений или тяжёлое кэширование в процессеОбычно к этому моменту у вас проблема в архитектуре

Эти строки приблизительны. Приложение, которое рендерит страницы на сервере с большими моделями или держит в памяти большие кэши, сдвигается на строку вверх; тонкий JSON API поверх хорошо проиндексированной базы - на строку вниз.

Сначала измерьте, потом настраивайте#

У каждого шага настройки ниже есть цена, и большая часть работы над производительностью небольших приложений тратится не на тот слой. Сначала получите три цифры:

  1. Куда уходит время в одном медленном запросе? Логируйте затраченное время на запрос (логирование запросов в Serilog делает это одной строкой) и для медленных - сколько заняла часть с базой данных. EF Core логирует длительность каждой команды на уровне Information в категории Microsoft.EntityFrameworkCore.Database.Command; включите это ненадолго.
  2. Упирается ли CPU в лимит под нагрузкой? На это отвечает график CPU в панели относительно лимита тарифа. Ровная линия на потолке означает упор в CPU; низкий CPU при высокой задержке означает, что вы чего-то ждёте - базу данных, внешний API или пул потоков.
  3. Как оно ведёт себя при конкурентной нагрузке? Запустите нагрузочный тест с другой машины через k6, bombardier или wrk с реалистичной смесью запросов и следите за перцентилями задержки, а не за средним.

На вашей собственной машине dotnet-counters показывает взгляд рантайма во время нагрузочного теста:

bash
$ dotnet tool install --global dotnet-counters$ dotnet-counters monitor --name MyApp System.Runtime Microsoft.AspNetCore.Hosting

Важны такие счётчики: длина очереди пула потоков, число потоков в пуле, размер кучи GC и время, проведённое в GC, а также запросы в секунду. Растущая очередь пула потоков при низком CPU - характерный признак самого частого бага производительности в .NET, о котором дальше. Точные имена счётчиков поменялись между .NET 8 и .NET 9, когда рантайм перешёл на новый meter System.Runtime, так что берите их из вывода инструмента, а не из старой статьи в блоге.

Голодание пула потоков и sync-over-async#

ASP.NET Core обслуживает запросы на потоках пула. Метод async, ожидающий I/O, отдаёт свой поток на время ожидания, поэтому горстка потоков может обслуживать тысячи одновременных запросов. Код, который блокируется на async-работе - .Result, .Wait(), .GetAwaiter().GetResult() или синхронный вызов базы данных или HTTP, - держит поток всё время ожидания.

csharp
// Blocks a thread pool thread for the full round tripvar user = db.Users.FirstOrDefaultAsync(u => u.Id == id).Result;// Hands the thread back while waitingvar user = await db.Users.FirstOrDefaultAsync(u => u.Id == id);

Когда заблокировано достаточно потоков, новые запросы встают в очередь. Пул добавляет потоки, но намеренно медленно - его эвристика добавления рассчитана на то, чтобы не реагировать слишком резко, а нескольких потоков в секунду совершенно недостаточно, когда приходит всплеск. Результат - классическая картина голодания: CPU низкий, задержка растёт до секунд, затем таймауты, затем восстановление, когда всплеск проходит. На тарифе с одним ядром пул стартует с очень небольшим числом потоков, поэтому для срабатывания нужно удивительно мало блокировок.

Лечится это тем, что путь делается асинхронным до самого дна. Обычные виновники:

  • Синхронные вызовы EF Core (ToList(), SaveChanges(), First()) в обработчиках запросов. Используйте версии Async.
  • Вызовы HttpClient, обёрнутые в .Result, потому что вызывающий код не был асинхронным. Сделайте вызывающий код асинхронным.
  • Синхронное чтение тела запроса. Kestrel по умолчанию отклоняет такое (AllowSynchronousIO равно false) с InvalidOperationException, и если включить эту настройку, чтобы заглушить ошибку, вы снова вернёте голодание.
  • Блокировки вокруг async-работы. Используйте SemaphoreSlim.WaitAsync() вместо lock, внутри которого await всё равно быть не может.

ThreadPool.SetMinThreads поднимает стартовое число потоков и вполне законен как временная мера, пока вы убираете блокирующие вызовы, но это именно временная мера: больше заблокированных потоков - это всё равно больше памяти и больше переключений контекста на одном ядре.

Лимиты Kestrel, которые защищают небольшой сервер#

Значения Kestrel по умолчанию щедрые, потому что они рассчитаны на серверы любого размера. Небольшому серверу выгоднее рано говорить «нет», чем принимать работу, которую он не сможет закончить.

НастройкаПо умолчаниюЗачем её менять
MaxConcurrentConnectionsбез ограниченияОграничить открытые соединения, чтобы наплыв копился в очереди на прокси, а не в вашей памяти
MaxConcurrentUpgradedConnectionsбез ограниченияТо же для WebSockets и SignalR
MaxRequestBodySize30,000,000 байтПонизить для API, который никогда не принимает загрузки
KeepAliveTimeout130 секундЕсли короче, простаивающие соединения освобождаются раньше
RequestHeadersTimeout30 секундЕсли короче, ограничивает атаки медленными заголовками
MinRequestBodyDataRate240 байт/с после 5 с отсрочкиОтключает клиентов, по капле отдающих тело, чтобы держать соединение
MaxRequestHeadersTotalSize32 KBМенять приходится редко
csharp
builder.WebHost.ConfigureKestrel(options =>{    options.Limits.MaxConcurrentConnections = 500;    options.Limits.MaxConcurrentUpgradedConnections = 200;    options.Limits.MaxRequestBodySize = 2 * 1024 * 1024;    options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);});

Те же значения могут жить в конфигурации под Kestrel:Limits, и тогда их можно менять переменной окружения вроде Kestrel__Limits__MaxConcurrentConnections=500 вместо пересборки. Отдельный эндпоинт, который всё же принимает загрузки, может поднять лимит тела для себя через [RequestSizeLimit] или IHttpMaxRequestBodySizeFeature.

Однако лимиты соединений не ограничивают объём работы в процессе выполнения. Для этого в middleware ограничения частоты (встроено начиная с .NET 7) есть ограничитель конкурентности - самая полезная одиночная защита для небольшого экземпляра: он позволяет выполняться одновременно фиксированному числу запросов, ставит в очередь ещё несколько, а остальные отклоняет с 503, вместо того чтобы позволять задержке расти без предела.

csharp
builder.Services.AddRateLimiter(options =>{    options.RejectionStatusCode = StatusCodes.Status503ServiceUnavailable;    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>        RateLimitPartition.GetConcurrencyLimiter("global", _ =>            new ConcurrencyLimiterOptions { PermitLimit = 64, QueueLimit = 32 }));});app.UseRateLimiter();

Ограничения на клиента (fixed window, sliding window, token bucket) живут в том же middleware и являются правильным инструментом против одного шумного клиента - при условии, что forwarded headers настроены и ключ партиции - это настоящий адрес клиента. В статье Лимиты частоты и злоупотребления объясняется, как выбирать числа.

Сжатие ответов#

JSON и HTML сжимаются до малой доли своего размера, а на небольшом сервере ограничением редко бывает пропускная способность - скорее задержка для клиента. Сжатие меняет немного CPU на гораздо меньшее время передачи.

csharp
builder.Services.AddResponseCompression(options =>{    options.EnableForHttps = true;    options.Providers.Add<BrotliCompressionProvider>();    options.Providers.Add<GzipCompressionProvider>();});builder.Services.Configure<BrotliCompressionProviderOptions>(o =>    o.Level = CompressionLevel.Fastest);app.UseResponseCompression();

Три предостережения. EnableForHttps по умолчанию выключен, потому что сжатие ответов, в которых секреты смешаны с контролируемым атакующим вводом поверх TLS, - это почва для атак семейства CRIME и BREACH; для публичного JSON и страниц без секретов это безопасно, а прежде чем включать его для ответов с токенами, стоит подумать. CompressionLevel.Fastest - правильный уровень для динамических ответов на слабом CPU; уровень optimal может стоить в несколько раз больше CPU ради выигрыша в несколько процентов размера. И если обратный прокси перед приложением уже сжимает, не сжимайте дважды: это пустая трата CPU, а прокси обычно всё равно пропустит ваше сжатое тело без изменений. Проверьте заголовки ответа - Content-Encoding: br или gzip - при выключенном в приложении сжатии, и вы узнаете, делает ли это прокси.

Статические ресурсы лучше сжать один раз при сборке, чем на каждом запросе. MapStaticAssets() в .NET 9 и новее делает это для файлов, известных на этапе сборки, отдавая заранее сжатые версии с fingerprint-именами и долгими сроками кэширования.

Output caching и response caching#

Самый дешёвый запрос - тот, который вы не вычисляете. В ASP.NET Core есть два middleware кэширования, и их часто путают:

  • Response caching (AddResponseCaching) следует HTTP-заголовкам кэширования. Он кэширует только то, что позволяют заголовки, а браузер, присылающий Cache-Control: no-cache, его обходит. Он полезен в основном для того, чтобы правильно выставить заголовки для кэшей ниже по цепочке.
  • Output caching (AddOutputCache, .NET 7 и новее) - кэш на стороне сервера, которым управляете вы. Клиент не может его обойти, записи можно помечать тегами и вытеснять, а одновременные запросы к одной и той же некэшированной записи схлопываются в одно вычисление, что защищает небольшой сервер от лавины запросов.
csharp
builder.Services.AddOutputCache(options =>{    options.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(10)));    options.AddPolicy("Products", b => b.Expire(TimeSpan.FromMinutes(5)).Tag("products"));});app.UseOutputCache();app.MapGet("/products", GetProducts).CacheOutput("Products");app.MapPost("/products", async (Product p, IOutputCacheStore cache, CancellationToken ct) =>{    // ... save    await cache.EvictByTagAsync("products", ct);});

По умолчанию output caching хранит только ответы на GET и HEAD со статусом 200 и пропускает аутентифицированные запросы и ответы, устанавливающие cookies, - именно это не даёт отдать страницу одного пользователя другому. Хранилище по умолчанию - в памяти с лимитом размера 100 MB; на тарифе с 1 GB задайте SizeLimit поменьше. Для нескольких экземпляров с общим кэшем в .NET 8 появилось хранилище на протоколе Redis (Microsoft.AspNetCore.OutputCaching.StackExchangeRedis, регистрируется через AddStackExchangeRedisOutputCache), которое работает и с Valkey. Паттерны кэширования с Valkey разбирает, что кэшировать и как это инвалидировать.

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

Аллокации, сериализация и база данных#

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

  • Загрузка из базы большего, чем нужно. ToListAsync() на нефильтрованном запросе или сущности со всеми колонками, когда страница показывает три. Проецируйте через Select в небольшой record, разбивайте на страницы через Skip и Take и используйте AsNoTracking() для чтения - отслеживание изменений хранит снимок каждой загруженной сущности.
  • Запросы N+1. Цикл, который лениво подгружает связь для каждой строки, превращает один запрос в сотню обращений к базе. Используйте Include или проекцию и читайте SQL, который EF Core на самом деле генерирует.
  • Буферизация больших ответов. Возврат List<T> из пятидесяти тысяч строк строит в памяти весь список, а потом весь JSON. Разбейте на страницы или возвращайте IAsyncEnumerable<T>, который System.Text.Json отдаёт потоком.
  • Новый `HttpClient` на каждый запрос. Используйте IHttpClientFactory или один долгоживущий клиент. Создание клиентов на каждый запрос исчерпывает сокеты и аллоцирует впустую.
  • Сериализация на рефлексии в горячих путях. Генерация кода в System.Text.Json (JsonSerializerContext) убирает рефлексию и часть аллокаций, а для trimmed и Native AOT сборок она всё равно обязательна.
  • Сборка строк в циклах и большие буферы `byte[]`. Для этого существуют StringBuilder, ArrayPool<T>.Shared и Span<T>.

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

Запуск, режим GC и параметры публикации#

Несколько настроек сборки и рантайма важны именно на небольших машинах:

MyApp.csproj
<PropertyGroup>  <InvariantGlobalization>true</InvariantGlobalization>  <TieredPGO>true</TieredPGO>  <PublishReadyToRun>true</PublishReadyToRun></PropertyGroup>

InvariantGlobalization отказывается от данных культур ICU, экономя память и время запуска ценой форматирования и сортировки с учётом культуры - для большинства API нормально, для приложения, которое форматирует даты и валюты под каждого пользователя, неправильно. TieredPGO и так включён по умолчанию начиная с .NET 8, так что его указание лишь документирует намерение. PublishReadyToRun заранее компилирует код, чтобы первые запросы после перезапуска не тормозились JIT, - это важно, когда деплой перезапускает приложение под трафиком; при публикации ему нужен runtime identifier, и результат получается больше. dotnet publish и параметры рантайма разбирает RID, self-contained сборки и trimming.

Приложения ASP.NET Core по умолчанию используют Server GC. При одном видимом процессоре рантайм всё равно использует workstation GC, а начиная с .NET 9 Server GC включает DATAS, который подстраивает число куч под нагрузку и держит память гораздо ниже, чем старый Server GC. Если приложение на тарифе с 2 ядрами держит базовое потребление памяти выше, чем вы можете себе позволить, <ServerGarbageCollector>false</ServerGarbageCollector> меняет часть пропускной способности на меньший след в памяти; измерьте оба варианта, прежде чем решать.

FAQ#

Сколько запросов в секунду выдержит небольшой сервер на ASP.NET Core?

Для простых JSON-эндпоинтов, которые попадают в кэш или в быстрый запрос, - тысячи на одном ядре. Для эндпоинтов, которые каждый раз ходят в базу, число задаёт база, обычно это сотни. Фреймворк редко бывает пределом; предел - ваша самая медленная зависимость и скорость аллокаций.

Безопасно ли выставлять Kestrel наружу без обратного прокси?

Kestrel - поддерживаемый пограничный сервер, и он сам справляется с TLS и HTTP/2. Прокси перед ним всё равно полезен ради сертификатов, стабильного домена и обработки заголовков, к тому же он поглощает медленных клиентов, прежде чем они дойдут до приложения. Если выставляете Kestrel напрямую, осознанно задайте лимиты из этой статьи.

Включать сжатие ответов в приложении или на прокси?

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

Почему приложение под нагрузкой тормозит, а CPU остаётся низким?

Почти всегда это голодание пула потоков из-за блокировок на async-коде или ожидание переполненного пула соединений с базой. Растущая очередь пула потоков в dotnet-counters подтверждает первое; логи медленных запросов или ошибки таймаута пула - второе.

Имеет ли смысл Native AOT на небольшом сервере?

Он даёт более быстрый запуск и меньший след в памяти, но не все библиотеки его поддерживают, а в .NET 8 и новее совместима лишь часть ASP.NET Core - minimal APIs и gRPC, но не MVC и не Blazor Server. Для небольшого самодостаточного API это может окупиться; для типичного приложения с EF Core и MVC лучший компромисс - ReadyToRun плюс исправления выше.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000