RE:NODE

Hosting aplikací12 min čtení

Výkon ASP.NET Core na malých serverech

Jak zrychlit ASP.NET Core na 1-2 vCPU a pár GB RAM: limity Kestrelu, komprese odpovědí, output caching, vyčerpání thread poolu a alokace.

2 čtenáři

ASP.NET Core patří mezi nejrychlejší webové stacky, a přesto se na malém serveru dá snadno zpomalit. Chyba je málokdy v samotném frameworku. Na půl jádra a gigabajtu paměti vás stojí výkon blokování v async kódu, alokace megabajtů na každý požadavek, odesílání nekomprimovaného JSONu, opakované počítání stejné odpovědi tisíckrát za minutu a neomezená fronta požadavků, která se hromadí místo toho, aby se přebytek odmítl. Když opravíte těchto pět věcí, zvládne jedna malá instance víc provozu, než většina projektů kdy uvidí. Tento článek projde každou z nich včetně nastavení, výchozích hodnot a toho, jak před jakoukoli úpravou poznat, která z nich vás opravdu trápí.

Co se na malém serveru mění#

Aplikace v .NET navržená na vývojářském notebooku narazí v kontejneru na dva pevné limity: kvótu CPU a limit paměti. Oba mění chování runtime.

Runtime čte kvótu CPU z cgroup kontejneru a podle ní nastaví Environment.ProcessorCount, zaokrouhlené nahoru. Na tarifu s půl vCPU si aplikace myslí, že má jeden procesor, což ovlivňuje, kolik heapů vytvoří garbage collector, s kolika vlákny thread poolu začne a jakou míru paralelismu si knihovny zvolí ve výchozím stavu. Kvóta je ale stále tvrdé omezení: jakmile proces vyčerpá svůj podíl v daném plánovacím období, čeká na další, což se projeví skoky latence, ne chybou.

Druhým stropem je paměť. Garbage collector čte limit kontejneru a podle něj dimenzuje rozpočet heapu, takže aplikace neroste donekonečna, ale každá alokace se později zaplatí CPU časem při sběru. Na RE:NODE se server, který dosáhne limitu paměti, zastaví jádrem a čistě restartuje, místo aby swapoval, takže špička v paměti znamená výpadek, ne zpomalení. .NET memory and garbage collection popisuje nastavení collectoru do hloubky; tento článek se věnuje tomu, co aplikace dělá, aby tlak vůbec vznikl.

Velikost tarifuCo pohodlně utáhneKde dojde dech jako první
0,5 vCPU, 1 GBAPI nebo malý web, desítky požadavků za sekunduCPU při špičkách a GC
1 vCPU, 2 GBVytížené API, Blazor Server pro pár desítek uživatelůPaměť na jedno spojení, CPU při serializaci
1,5-2 vCPU, 4 GBProdukt se skutečným provozem a úlohami na pozadíRound tripy do databáze, ne aplikace
3 vCPU, 8 GBNěkolik aplikací nebo hodně cachování v procesuTady už je to obvykle problém návrhu

Tyto řádky jsou orientační. Aplikace, která renderuje stránky na serveru s velkými modely nebo drží velké cache v paměti, se posune o řádek výš; tenké JSON API nad dobře indexovanou databází o řádek níž.

Měř, než začneš ladit#

Každý z následujících kroků něco stojí a většina optimalizací malých aplikací se plýtvá na špatné vrstvě. Nejdřív zjisti tři čísla:

  1. Kam jde čas v jednom pomalém požadavku? Loguj dobu trvání každého požadavku (request logging v Serilogu to zvládne jedním řádkem) a u pomalých i to, jak dlouho trvala databázová část. EF Core loguje dobu každého příkazu na úrovni Information v kategorii Microsoft.EntityFrameworkCore.Database.Command; zapni to na chvíli.
  2. Narazilo CPU při zátěži na strop? Odpoví graf CPU v panelu proti limitu tarifu. Rovná čára na stropu znamená, že jsi omezen CPU; nízké CPU při vysoké latenci znamená, že na něco čekáš - na databázi, externí API nebo thread pool.
  3. Jak se chová při souběhu? Spusť zátěžový test z jiného stroje pomocí k6, bombardier nebo wrk s realistickou směsí požadavků a sleduj percentily latence, ne průměr.

Na vlastním počítači ti dotnet-counters ukáže pohled runtime během zátěžového testu:

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

Důležité čítače jsou délka fronty thread poolu, počet vláken v poolu, velikost GC heapu a čas strávený v GC a počet požadavků za sekundu. Rostoucí fronta thread poolu při nízkém CPU je příznak nejčastější výkonnostní chyby v .NET, o které je řeč dál. Přesné názvy čítačů se mezi .NET 8 a .NET 9 změnily, když runtime přešel na nový meter System.Runtime, takže je čti z výstupu nástroje, ne ze starého blogového příspěvku.

Vyčerpání thread poolu a sync-over-async#

ASP.NET Core obsluhuje požadavky na vláknech thread poolu. Metoda async, která čeká na I/O, vrátí své vlákno, dokud čeká, takže hrstka vláken obslouží tisíce souběžných požadavků. Kód, který blokuje na async práci - .Result, .Wait(), .GetAwaiter().GetResult() nebo synchronní volání databáze či HTTP - drží vlákno po celou dobu čekání.

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);

Když je zablokováno dost vláken, nové požadavky se řadí do fronty. Pool vlákna přidává, ale záměrně pomalu - jeho heuristika je navržena tak, aby nereagovala přehnaně, a pár vláken za sekundu zdaleka nestačí, když přijde špička. Výsledkem je klasický obraz vyčerpání: nízké CPU, latence rostoucí do sekund, pak timeouty a následně zotavení, jakmile špička pomine. Na tarifu s jedním jádrem začíná pool s velmi malým počtem vláken, takže ke spuštění problému stačí překvapivě málo blokování.

Řešením je udělat celou cestu async až dolů. Obvyklí viníci:

  • Synchronní volání EF Core (ToList(), SaveChanges(), First()) v obslužných metodách požadavků. Použij verze s Async.
  • Volání HttpClient obalená do .Result, protože volající nebyl async. Udělej volajícího async.
  • Synchronní čtení těla požadavku. Kestrel je ve výchozím stavu odmítá (AllowSynchronousIO je false) s výjimkou InvalidOperationException a zapnutí tohoto nastavení jen proto, aby chyba zmizela, je způsob, jak vyčerpání vrátit zpět.
  • Zámky kolem async práce. Použij SemaphoreSlim.WaitAsync() místo lock, který stejně nemůže obsahovat await.

ThreadPool.SetMinThreads zvyšuje počáteční počet vláken a je legitimním provizorním řešením, než blokující volání odstraníš, ale zůstává provizoriem: víc zablokovaných vláken pořád znamená víc paměti a víc přepínání kontextu na jediném jádře.

Limity Kestrelu, které chrání malý server#

Výchozí hodnoty Kestrelu jsou velkorysé, protože jsou psané pro servery každé velikosti. Malému serveru prospěje říct ne včas, místo aby přijímal práci, kterou nedokončí.

NastaveníVýchozí hodnotaProč ho možná změníš
MaxConcurrentConnectionsneomezenoOmez počet otevřených spojení, aby se záplava řadila na proxy, ne v tvé paměti
MaxConcurrentUpgradedConnectionsneomezenoTotéž pro WebSockets a SignalR
MaxRequestBodySize30,000,000 bytesSniž ho u API, které nikdy nepřijímá uploady
KeepAliveTimeout130 secondsKratší hodnota dřív uvolní nečinná spojení
RequestHeadersTimeout30 secondsKratší hodnota omezuje útoky pomalými hlavičkami
MinRequestBodyDataRate240 bytes/s after 5 s graceOdpojí klienty, kteří tělo posílají po kapkách, aby udrželi spojení
MaxRequestHeadersTotalSize32 KBMálokdy je třeba měnit
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);});

Stejné hodnoty mohou žít v konfiguraci pod Kestrel:Limits, takže je změníš proměnnou prostředí, například Kestrel__Limits__MaxConcurrentConnections=500, bez nového buildu. Jednotlivý endpoint, který uploady přijímá, si může limit těla zvýšit sám pomocí [RequestSizeLimit] nebo IHttpMaxRequestBodySizeFeature.

Limity spojení ale neomezují rozpracovanou práci. K tomu slouží middleware pro rate limiting (součást frameworku od .NET 7) s concurrency limiterem, což je nejužitečnější jednotlivá ochrana malé instance: nechá běžet pevný počet požadavků najednou, pár dalších zařadí do fronty a zbytek odmítne s kódem 503, místo aby nechal latenci růst bez hranic.

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();

Limity na klienta (fixed window, sliding window, token bucket) žijí ve stejném middlewaru a jsou správným nástrojem proti jednomu hlučnému klientovi, pokud jsou nastavené forwarded headers, aby klíčem partition byla skutečná adresa klienta. Rate limits and abuse vysvětluje, jak vybírat čísla.

Komprese odpovědí#

JSON a HTML se komprimují na zlomek své velikosti a na malém serveru málokdy omezuje šířka pásma - spíš latence na straně klienta. Komprese vymění trochu CPU za mnohem kratší dobu na drátě.

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();

Tři upozornění. EnableForHttps je ve výchozím stavu vypnuté, protože komprese odpovědí, které přes TLS míchají tajné údaje s útočníkem ovlivnitelným vstupem, je přesně to, co využívá rodina útoků CRIME a BREACH; u veřejného JSONu a stránek bez tajných údajů je to bezpečné a u odpovědí s tokeny je dobré si to nejdřív rozmyslet. CompressionLevel.Fastest je správná úroveň pro dynamické odpovědi na malém CPU; optimální úroveň může stát několikanásobek CPU za pár procent menší výstup. A pokud reverzní proxy před aplikací už komprimuje, nekomprimuj dvakrát: plýtváš CPU a proxy tvé komprimované tělo stejně obvykle propustí beze změny. Zkontroluj hlavičky odpovědi - Content-Encoding: br nebo gzip - s vypnutou kompresí v aplikaci a budeš vědět, jestli ji dělá proxy.

Statické soubory je lepší komprimovat jednou při buildu než při každém požadavku. MapStaticAssets() v .NET 9 a novějších to dělá pro soubory známé při buildu: servíruje předkomprimované verze s názvy obsahujícími otisk (fingerprint) a dlouhou dobou cachování.

Output caching a response caching#

Nejlevnější požadavek je ten, který nemusíš počítat. ASP.NET Core má dva cachovací middleware a často se zaměňují:

  • Response caching (AddResponseCaching) se řídí HTTP hlavičkami pro cachování. Cachuje jen to, co hlavičky dovolí, a prohlížeč, který pošle Cache-Control: no-cache, ho obejde. Hodí se hlavně k tomu, aby se správně nastavily hlavičky pro cache dál v řetězci.
  • Output caching (AddOutputCache, .NET 7 a novější) je cache na straně serveru, kterou ovládáš ty. Klient ji obejít nemůže, položky lze označovat tagy a vyhazovat a souběžné požadavky na stejnou necachovanou položku se sloučí do jednoho výpočtu, což malý server chrání před stampede.
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);});

Ve výchozím stavu output caching ukládá jen odpovědi na GET a HEAD se stavem 200 a přeskakuje autentizované požadavky a odpovědi, které nastavují cookies, což brání tomu, aby se stránka jednoho uživatele zobrazila jinému. Výchozí úložiště je v paměti s limitem velikosti 100 MB; na tarifu s 1 GB nastav SizeLimit nižší. Pro více instancí sdílejících jednu cache přidal .NET 8 úložiště s protokolem Redis (Microsoft.AspNetCore.OutputCaching.StackExchangeRedis, registrované pomocí AddStackExchangeRedisOutputCache), které funguje i s Valkey. Valkey caching patterns popisuje, co cachovat a jak nechat položky expirovat.

I deset sekund output cachingu na vytíženém endpointu promění stovky databázových dotazů za minutu v šest. To je obvykle největší jednotlivé zrychlení, které máš k dispozici, a stojí pár řádků.

Alokace, serializace a databáze#

Na malém serveru soupeří garbage collector s tvými požadavky o stejné CPU, takže míra alokací je propustnost. Běžné zdroje, zhruba v pořadí podle toho, jak často záleží:

  • Načítání většího množství dat, než potřebuješ, z databáze. ToListAsync() na nefiltrovaném dotazu nebo entity se všemi sloupci, když stránka zobrazuje tři. Projektuj pomocí Select do malého recordu, stránkuj pomocí Skip a Take a pro čtení používej AsNoTracking() - sledování změn drží snapshot každé načtené entity.
  • Dotazy N+1. Smyčka, která líně načítá relaci pro každý řádek, změní jeden požadavek na sto round tripů. Použij Include nebo projekci a čti SQL, které EF Core skutečně generuje.
  • Bufferování velkých odpovědí. Vrácení List<T> s padesáti tisíci řádky vytvoří v paměti celý seznam a pak celý JSON. Stránkuj, nebo vrať IAsyncEnumerable<T>, které System.Text.Json streamuje.
  • Nový `HttpClient` na každý požadavek. Použij IHttpClientFactory nebo jednoho dlouho žijícího klienta. Vytváření klientů na každý požadavek vyčerpává sockety a zbytečně alokuje.
  • Serializace založená na reflexi na horkých cestách. Source generation v System.Text.Json (JsonSerializerContext) odstraní reflexi i část alokací a pro trimmed nebo Native AOT buildy je stejně nutná.
  • Skládání řetězců ve smyčkách a velké buffery `byte[]`. Pro to existují StringBuilder, ArrayPool<T>.Shared a Span<T>.

Databáze je v malém nasazení obvykle nejpomalejší komponenta a nic z tohoto článku nepomůže dotazu, kterému chybí index. Connection pool dimenzovaný daleko nad to, co databáze zvládne obsloužit, také víc škodí, než pomáhá; connection pools and limits vysvětluje, proč je správný pool menší, než lidé čekají.

Start, režim GC a možnosti publikování#

Několik nastavení buildu a runtime má význam právě na malých strojích:

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

InvariantGlobalization zahodí kulturní data ICU, čímž ušetří paměť i čas startu, za cenu formátování a řazení závislého na kultuře - pro většinu API v pořádku, pro aplikaci, která formátuje data a měny podle uživatele, špatně. TieredPGO je od .NET 8 zapnuté už ve výchozím stavu, takže jeho uvedení jen dokumentuje záměr. PublishReadyToRun předkompiluje kód, aby první požadavky po restartu nebrzdil JIT, což má význam, když deploy restartuje aplikaci pod provozem; při publikování vyžaduje runtime identifier a zvětšuje výstup. dotnet publish and runtime options popisuje RID, self-contained buildy a trimming.

Aplikace ASP.NET Core používají ve výchozím stavu Server GC. S jedním viditelným procesorem runtime stejně použije workstation GC a od .NET 9 zapíná Server GC režim DATAS, který přizpůsobuje počet heapů zátěži a drží paměť mnohem níž než starší Server GC. Pokud aplikace na tarifu se 2 jádry sedí na vyšší základní paměti, než si můžeš dovolit, <ServerGarbageCollector>false</ServerGarbageCollector> vymění část propustnosti za menší stopu; než se rozhodneš, změř obě varianty.

FAQ#

Kolik požadavků za sekundu zvládne malý server s ASP.NET Core?

U jednoduchých JSON endpointů, které zasáhnou cache nebo rychlý dotaz, tisíce na jednom jádru. U endpointů, které se při každém požadavku dotazují databáze, určuje číslo databáze, typicky stovky. Framework je málokdy limitem; limitem jsou tvoje nejpomalejší závislost a míra alokací.

Je bezpečné vystavit Kestrel bez reverzní proxy?

Kestrel je podporovaný edge server a TLS i HTTP/2 zvládá sám. Proxy před ním je přesto užitečná kvůli certifikátům, stabilní doméně a práci s hlavičkami a pohltí pomalé klienty dřív, než se dostanou k aplikaci. Pokud Kestrel vystavíš přímo, nastav limity z tohoto článku vědomě.

Mám zapnout kompresi odpovědí v aplikaci, nebo na proxy?

Na jednom z míst, ne na obou. Pokud komprimuje proxy před tebou, nech to na ní. Pokud ne, zapni kompresi v aplikaci s Brotli a gzip na nejrychlejší úrovni a u odpovědí obsahujících tajné údaje se zamysli nad EnableForHttps.

Proč je moje aplikace pod zátěží pomalá, i když je CPU nízké?

Téměř vždy jde o vyčerpání thread poolu kvůli blokování na async kódu, nebo o čekání na přetížený connection pool databáze. První potvrdí rostoucí fronta thread poolu v dotnet-counters, druhé logy pomalých dotazů nebo chyby timeoutu poolu.

Dává Native AOT smysl na malém serveru?

Přináší rychlejší start a menší paměťovou stopu, ale ne každá knihovna ho podporuje a v .NET 8 a novějších je kompatibilní jen část ASP.NET Core - minimal APIs a gRPC, ne MVC ani Blazor Server. Pro malé samostatné API se to může vyplatit; pro typickou aplikaci s EF Core a MVC je lepší volbou ReadyToRun plus opravy z tohoto článku.


Komentáře

Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.

0/2000