ASP.NET Core - один из самых быстрых веб-стеков, какие есть, и всё же на небольшом сервере его легко сделать медленным. Проблема редко во фреймворке. На половине ядра и гигабайте памяти вам дорого обходятся блокировки на async-коде, мегабайты аллокаций на каждый запрос, несжатый JSON, повторное вычисление одного и того же ответа тысячу раз в минуту и неограниченная очередь запросов, которой позволяют копиться вместо того, чтобы отказывать лишним. Исправьте эти пять вещей, и один небольшой экземпляр выдержит больше трафика, чем большинство проектов вообще когда-либо увидят. Эта статья проходит каждую из них с настройками, их значениями по умолчанию и способом понять, какая из них действительно вам вредит, прежде чем что-то менять.
Что меняет небольшой сервер#
Приложение на .NET, которое подбиралось по размеру на ноутбуке разработчика, в контейнере упирается в два жёстких ограничения: квоту CPU и лимит памяти. Оба меняют поведение рантайма.
Рантайм читает квоту CPU из cgroup контейнера и выставляет по ней Environment.ProcessorCount, округляя вверх. На тарифе с половиной vCPU приложение считает, что у него один процессор, и от этого зависит, сколько куч создаёт сборщик мусора, со сколькими потоками стартует пул потоков и сколько параллелизма библиотеки выбирают по умолчанию. При этом квота остаётся жёстким ограничителем: как только процесс израсходовал свою долю периода планирования, он ждёт следующего, и это проявляется как всплески задержки, а не как ошибка.
Память - второй потолок. Сборщик мусора читает лимит контейнера и рассчитывает бюджет кучи под него, так что приложение не будет расти бесконечно, но каждая аллокация всё равно потом стоит процессорного времени на сборки. В RE:NODE сервер, достигший лимита памяти, останавливается ядром и перезапускается начисто, а не уходит в swap, так что всплеск памяти - это простой, а не замедление. Память и сборка мусора в .NET подробно разбирает настройки сборщика; эта статья - о том, что делает само приложение, создавая это давление.
| Размер тарифа | Что на нём спокойно работает | Где кончается в первую очередь |
|---|---|---|
| 0.5 vCPU, 1 GB | API или небольшой сайт, десятки запросов в секунду | CPU во время всплесков и GC |
| 1 vCPU, 2 GB | Нагруженный API, Blazor Server на несколько десятков пользователей | Память на соединение, CPU на сериализацию |
| 1.5-2 vCPU, 4 GB | Продукт с реальным трафиком и фоновыми задачами | Обращения к базе данных, а не приложение |
| 3 vCPU, 8 GB | Несколько приложений или тяжёлое кэширование в процессе | Обычно к этому моменту у вас проблема в архитектуре |
Эти строки приблизительны. Приложение, которое рендерит страницы на сервере с большими моделями или держит в памяти большие кэши, сдвигается на строку вверх; тонкий JSON API поверх хорошо проиндексированной базы - на строку вниз.
Сначала измерьте, потом настраивайте#
У каждого шага настройки ниже есть цена, и большая часть работы над производительностью небольших приложений тратится не на тот слой. Сначала получите три цифры:
- Куда уходит время в одном медленном запросе? Логируйте затраченное время на запрос (логирование запросов в Serilog делает это одной строкой) и для медленных - сколько заняла часть с базой данных. EF Core логирует длительность каждой команды на уровне
Informationв категорииMicrosoft.EntityFrameworkCore.Database.Command; включите это ненадолго. - Упирается ли CPU в лимит под нагрузкой? На это отвечает график CPU в панели относительно лимита тарифа. Ровная линия на потолке означает упор в CPU; низкий CPU при высокой задержке означает, что вы чего-то ждёте - базу данных, внешний API или пул потоков.
- Как оно ведёт себя при конкурентной нагрузке? Запустите нагрузочный тест с другой машины через
k6,bombardierилиwrkс реалистичной смесью запросов и следите за перцентилями задержки, а не за средним.
На вашей собственной машине dotnet-counters показывает взгляд рантайма во время нагрузочного теста:
$ 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, - держит поток всё время ожидания.
// 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 |
MaxRequestBodySize | 30,000,000 байт | Понизить для API, который никогда не принимает загрузки |
KeepAliveTimeout | 130 секунд | Если короче, простаивающие соединения освобождаются раньше |
RequestHeadersTimeout | 30 секунд | Если короче, ограничивает атаки медленными заголовками |
MinRequestBodyDataRate | 240 байт/с после 5 с отсрочки | Отключает клиентов, по капле отдающих тело, чтобы держать соединение |
MaxRequestHeadersTotalSize | 32 KB | Менять приходится редко |
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, вместо того чтобы позволять задержке расти без предела.
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 на гораздо меньшее время передачи.
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 и новее) - кэш на стороне сервера, которым управляете вы. Клиент не может его обойти, записи можно помечать тегами и вытеснять, а одновременные запросы к одной и той же некэшированной записи схлопываются в одно вычисление, что защищает небольшой сервер от лавины запросов.
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 и параметры публикации#
Несколько настроек сборки и рантайма важны именно на небольших машинах:
<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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.