Хостинг і конвеєр
Шлях запиту в ASP.NET Core: який сервер його приймає, як він проходить конвеєр middleware, як маршрутизація обирає обробник і як хост запускає та зупиняє застосунок. З цих питань видно, чи розуміє кандидат, що відбувається між мережею та його кодом.
Перед цією темою: Асинхронність.
Що таке Kestrel і навіщо перед ним IIS або reverse proxy?
Section titled “Що таке Kestrel і навіщо перед ним IIS або reverse proxy?”Що це
- Kestrel — вебсервер, вбудований в ASP.NET Core. Він слухає порт і перетворює HTTP-запит на
HttpContextдля конвеєра middleware. - Reverse proxy — сервер перед застосунком, який пересилає запити з мережі в Kestrel.
Навіщо проксі
- Один порт 443 на кілька сайтів: Kestrel не ділить порт з іншими процесами.
- Сертифікати й HTTPS налаштовуються в одному місці.
- Балансування між екземплярами застосунку.
- Ще один рівень захисту.
Як користуватися
WebApplication.CreateBuilder вмикає Kestrel сам. Без налаштувань він слухає http://localhost:5000. За проксі першим у конвеєрі викликають app.UseForwardedHeaders().
Типова помилка
Забути про forwarded headers. Застосунок бачить IP проксі замість IP клієнта і http замість https, а UseHttpsRedirection зациклює редиректи.
Як влаштовано
- Транспорт приймає з’єднання: TCP-сокет, Unix-сокет за Nginx або, з .NET 8, іменований канал Windows.
- Якщо ввімкнено HTTPS, з’єднання проходить TLS через
SslStream. - Парсер HTTP/1.1, HTTP/2 або HTTP/3 читає байти з
System.IO.Pipelinesі створюєHttpContext. - Запит іде в конвеєр middleware на потоці пулу.
Коли що обирати
| Середовище | Варіант |
|---|---|
| Linux, віртуальна машина | Kestrel за Nginx, YARP або Apache |
| Kubernetes | Kestrel за ingress або балансувальником |
| Windows Server з IIS | IIS і ASP.NET Core Module |
| Windows без IIS, потрібен спільний порт | HTTP.sys |
Пастки
- Forwarded headers за замовчуванням приймаються лише від loopback-адрес. Адресу свого проксі додають у
KnownProxies, інакше заголовки мовчки ігноруються. ForwardLimitза замовчуванням дорівнює 1: з ланцюжка проксі читається лише останнє значенняX-Forwarded-For.- Ліміти Kestrel (
MaxRequestBodySize— 30 000 000 байтів, таймаути заголовків) і ліміти проксі діють одночасно. Спрацьовує менший.
Правило
Один застосунок і TLS більше ніде розшифрувати — Kestrel може дивитися в мережу сам. Кілька сайтів на одному IP або централізований TLS — ставте проксі й налаштовуйте forwarded headers.
Під капотом
- Kestrel читає й пише через
System.IO.Pipelinesз буферами з власного пулу пам’яті, тому заголовки розбираються майже без алокацій. З .NET 10 пули Kestrel, IIS і HTTP.sys самі повертають пам’ять під час простою. - Сокети обслуговують IOCP або epoll, а код застосунку працює в пулі потоків. Блокувальний виклик в обробнику забирає потік у всіх з’єднань.
- В IIS in-process Kestrel немає: запит іде з HTTP.sys через IIS просто в
IISHttpServerвсерединіw3wp.exe.
Ціна
- Проксі — додатковий перехід і повторний розбір HTTP. Зазвичай це частки мілісекунди, але під навантаженням це ще й з’єднання, буфери та CPU другого сервера.
- Буферизація відповідей у Nginx ламає стримінг і Server-Sent Events, а таймаути проксі рвуть довгі WebSocket, якщо їх не налаштувати.
- TLS на Kestrel витрачає CPU застосунку. TLS на проксі розвантажує застосунок, але всередині мережі трафік іде відкритим.
Компроміси
- Довіра до
X-Forwarded-For— межа безпеки. ШирокийKnownIPNetworks(до .NET 10 —KnownNetworks) абоForwardLimit = nullдають клієнтові змогу підробити свій IP і обійти ліміти за IP. ASPNETCORE_FORWARDEDHEADERS_ENABLED=trueвмикає forwarded headers без обмеження за адресами проксі. Це зручно за хмарним балансувальником і небезпечно, якщо застосунок доступний напряму.
Як перевірити в продакшені
dotnet-counters monitorз метриками .NET 8+ зMicrosoft.AspNetCore.Server.Kestrel:kestrel.active_connections,kestrel.queued_connections,kestrel.rejected_connections,kestrel.tls_handshake.duration.- У логах замість IP клієнтів IP проксі — увімкніть рівень Debug для forwarded headers: middleware пише «Unknown proxy» з адресою, якої немає в списку довірених.
Підсумок: проксі переносить TLS, ліміти й довіру на край мережі, а платять за це затримкою, буферизацією та акуратним налаштуванням forwarded headers.
using System.Net;using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
// Ліміти самого Kestrelbuilder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 10 * 1024 * 1024); // 10 МБ
// Проксі (Nginx, YARP) передає IP клієнта і схему в X-Forwarded-*builder.Services.Configure<ForwardedHeadersOptions>(options =>{ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownProxies.Add(IPAddress.Parse("10.0.0.10")); // довіряємо лише своєму проксі});
var app = builder.Build();
app.UseForwardedHeaders(); // першим: решта побачать справжні схему та IPapp.UseHttpsRedirection();
app.MapGet("/", (HttpContext ctx) => $"{ctx.Request.Scheme} {ctx.Connection.RemoteIpAddress}"); // https 203.0.113.5
app.Run();- Чи є Kestrel під час хостингу в IIS? — В out-of-process так, в in-process ні: там працює
IISHttpServerвсерединіw3wp.exe. - Чому
UseForwardedHeadersставлять першим? — Решта middleware мають бачити справжні схему та IP клієнта. - Чим HTTP.sys відрізняється від Kestrel? — Працює лише на Windows поверх драйвера ядра, вміє спільний порт і Windows-автентифікацію, з IIS не поєднується.
- Чи можна виставити Kestrel просто в інтернет? — Так, це підтримувана конфігурація, але порт не ділиться з іншими процесами.
- Чому за Nginx зациклюється редирект на HTTPS? — Проксі розшифрував TLS, і без forwarded headers застосунок вважає кожен запит HTTP.
Що таке middleware і чому важливий їхній порядок?
Section titled “Що таке middleware і чому важливий їхній порядок?”Що це
Middleware — ланка конвеєра. Кожна ланка отримує HttpContext і вирішує:
- обробити запит і передати його далі через
next; - відповісти самій і не викликати
next.
Навіщо
Наскрізні задачі — помилки, HTTPS, статика, автентифікація, стиснення — живуть в окремих ланках і не змішуються з бізнес-кодом.
Як користуватися
| Метод | Що робить |
|---|---|
app.Use(...) |
ланка з викликом next |
app.Run(...) |
кінцева ланка, next немає |
app.Map("/path", ...) |
гілка конвеєра за префіксом шляху |
app.UseXxx() |
вбудований middleware: UseExceptionHandler, UseStaticFiles |
Типова помилка
Поставити UseExceptionHandler після інших middleware. Винятків з ланок, які стоять вище за нього, він не перехопить.
Як влаштовано
- Код ланки до
await next(context)виконується на шляху запиту «всередину». - Код після
nextвиконується на зворотному шляху, коли нижні ланки вже сформували відповідь. - Ланка, яка не викликає
next, замикає конвеєр: нижні ланки не виконуються. Mapпереносить префікс, що збігся, зPathуPathBase.MapWhenрозгалужує за умовою, аUseWhenпісля гілки повертається в основний конвеєр.
Рекомендований порядок
UseForwardedHeaders, якщо застосунок за проксі.UseExceptionHandlerіUseHsts; у Development — сторінка помилок розробника.UseHttpsRedirection.UseStaticFiles— віддає файл і замикає конвеєр.UseRouting.UseRateLimiter,UseCors.UseAuthentication,UseAuthorization.- Власні middleware, потім endpoint’и:
MapControllers,MapGet.
Пастки
- Після початку відповіді заголовки й статус змінювати не можна — буде виняток. Запис у тіло після
nextможе порушитиContent-Length. UseRateLimiterз політиками на endpoint’ах ([EnableRateLimiting]) працює лише післяUseRouting.- Автентифікація не відхиляє анонімні запити, вона лише визначає користувача. Відмову дає авторизація.
Правило
Спочатку те, що має бачити всі запити й перехоплювати всі помилки, потім те, що дешево відсікає зайве, потім безпека, потім бізнес-логіка.
Під капотом
Useдодає вApplicationBuilderфабрикуFunc<RequestDelegate, RequestDelegate>. Під час старту хостаBuild()проходить список з кінця й обгортає кожну ланку навколо наступної.- Результат — один
RequestDelegate, який сервер викликає на кожен запит. Порядок «заморожений» до перезапуску. MapіMapWhenбудують вкладений конвеєр теж під час старту, а не на кожен запит.
Ціна
- Ланка — це виклик делегата, а async-ланка — ще й власна state machine. Десяток легких middleware майже безкоштовні.
- Повільна ланка на початку конвеєра сповільнює всі запити, зокрема статику й health checks. Дороге логування до
UseStaticFilesоплачується за кожну картинку.
Компроміси
- Middleware проти фільтрів MVC: middleware не знає про модель і action, зате працює для будь-яких endpoint’ів і статики. Логіку, прив’язану до контролерів, зручніше тримати у фільтрах.
- Ланку, якій треба знати вибраний endpoint, ставлять після
UseRouting. Тоді рішення ухвалюється за метаданими endpoint’а, а не за рядком шляху.
Як перевірити в продакшені
- Повільну ланку шукають, вставляючи між middleware замір часу через
Stopwatch— так радить документація з маршрутизації. - Метрика
http.server.request.duration(.NET 8+) проти часу action у логах показує, скільки з’їдає конвеєр навколо endpoint’а. - Порядок закріплюють інтеграційними тестами на
WebApplicationFactory: запит без токена отримує 401, статика віддається без автентифікації.
Підсумок: конвеєр — ланцюжок делегатів, побудований один раз під час старту, і порядок ланок — частина архітектури, а не стиль коду.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.Use(async (context, next) =>{ Console.WriteLine("A: before"); await next(context); // далі конвеєром Console.WriteLine("A: after"); // на зворотному шляху});
app.Map("/health", health => // гілка: /health відповідає тут health.Run(ctx => ctx.Response.WriteAsync("OK")));
app.Use(async (context, next) =>{ if (!context.Request.Headers.ContainsKey("X-Api-Key")) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; // коротке замикання: next не викликаємо } await next(context);});
app.Run(ctx => ctx.Response.WriteAsync("Hello")); // термінальна ланка
app.Run();// GET /health -> 200 OK// GET / -> 401// GET / з X-Api-Key -> 200 Hello; у консолі завжди A: before, A: after- Чим
Useвідрізняється відRun? —Useотримуєnextі може передати запит далі,Runзавжди кінцевий. - Чим
Mapвідрізняється відUseWhen? —Mapвідводить запит в окрему гілку,UseWhenпісля гілки повертається в основний конвеєр. - Де ставити
UseExceptionHandler? — На початку: він перехоплює винятки лише ланок, які стоять після нього. - Чи можна змінити заголовок після
await next(context)? — Лише якщо відповідь ще не почалася (Response.HasStarted); надійніше підписатися заздалегідь черезResponse.OnStarting. - Чому
UseCorsстоїть доUseAuthentication? — Так вимагає документація: CORS має відпрацювати до перевірок доступу, інакше відмова може піти без CORS-заголовків, і браузер покаже помилку CORS замість 401.
Як запускається і зупиняється застосунок: Generic Host і WebApplicationBuilder?
Section titled “Як запускається і зупиняється застосунок: Generic Host і WebApplicationBuilder?”Що це
Хост (Generic Host) володіє всім, що потрібно застосунку:
- конфігурацією та логуванням;
- контейнером залежностей;
- фоновими сервісами (
IHostedService) і вебсервером.
Як користуватися
WebApplication.CreateBuilder(args)— builder з налаштуваннями за замовчуванням.builder.Services.Add...— реєстрація сервісів.builder.Build()— побудова застосунку.app.Use...іapp.Map...— опис конвеєра.app.Run()— запуск і очікування зупинки.
Життєвий цикл
IHostApplicationLifetime повідомляє про три події: ApplicationStarted, ApplicationStopping, ApplicationStopped. Метод StopApplication() зупиняє застосунок з коду.
Типова помилка
Реєструвати сервіси після builder.Build(). Контейнер уже побудовано, і виклик кине InvalidOperationException: колекція сервісів стала лише для читання.
Як влаштовано
CreateBuilderзадає content root і читаєappsettings.json,appsettings.{Environment}.json, user secrets (у Development), змінні середовища й аргументи командного рядка. Він же додає логування та сервер: Kestrel та інтеграцію з IIS.Build()створює контейнер. У Development увімкнено перевірки scope і залежностей.Run()викликаєStartAsync: hosted services стартують по черзі в порядку реєстрації, потім будується конвеєр, стартує сервер і спрацьовуєApplicationStarted.- Зупинка:
ApplicationStopping→ сервер перестає приймати з’єднання й чекає на поточні запити →StopAsyncрешти hosted services у зворотному порядку →ApplicationStopped.
Коли що обирати
| Спосіб | Коли |
|---|---|
WebApplication.CreateBuilder |
звичайний вебзастосунок, .NET 6+ |
WebApplication.CreateSlimBuilder |
Native AOT і мінімум функцій, .NET 8+ |
Host.CreateApplicationBuilder |
worker-сервіс без HTTP |
Host.CreateDefaultBuilder і Startup |
старий код .NET Core 3.x–5 |
Пастки
- Запити, які не вклалися в
ShutdownTimeout(за замовчуванням 30 секунд), обриваються. - Колбеки
ApplicationStoppingвиконуються синхронно: довгий колбек затримує всю зупинку. - Довгий
StartAsyncу hosted service затримує старт сервера — порт ще не слухається.
Правило
Довгу ініціалізацію робіть через await до app.Run() або в BackgroundService, а не в StartAsync. Якщо запити бувають довшими за 30 секунд, задайте ShutdownTimeout явно.
Під капотом
- Вебсервер — теж hosted service (
GenericWebHostService). Під час старту він будує конвеєр і запускаєIServer: Kestrel,IISHttpServerабо HTTP.sys. IHostLifetimeвирішує, коли починати й закінчувати:ConsoleLifetime(Ctrl+C, SIGINT, SIGTERM, SIGQUIT),WindowsServiceLifetime,SystemdLifetime. З .NET 6 SIGTERM обробляється через POSIX-сигнали, а не черезAppDomain.ProcessExit.- З .NET 8
IHostedLifecycleServiceдодаєStartingAsync,StartedAsync,StoppingAsyncіStoppedAsync.HostOptions.ServicesStartConcurrentlyіServicesStopConcurrentlyвмикають паралельні старт і зупинку.
Ціна
Бюджет зупинки задає середовище, і застосунок має в нього вкластися:
| Середовище | Чекає до примусового завершення |
|---|---|
docker stop |
10 секунд |
| Kubernetes | terminationGracePeriodSeconds, за замовчуванням 30 |
| Пул IIS | Shutdown Time Limit, за замовчуванням 90 секунд |
Якщо ShutdownTimeout більший за бюджет середовища, процес уб’ють посеред зупинки, і ApplicationStopped не виконається.
Компроміси
- Довгий
ShutdownTimeoutбереже запити, але сповільнює деплой і масштабування. - У Kubernetes под прибирається з балансування паралельно із SIGTERM. Тому додають коротку паузу перед зупинкою (
preStop), доки нові запити не перестануть надходити. WebHostBuilderіWebHostу .NET 10 застаріли (попередження ASPDEPR004 і ASPDEPR008): новий код — лише на Generic Host.
Як перевірити в продакшені
- Категорія логів
Microsoft.Hosting.Lifetimeпише «Application started» і «Application is shutting down…». За ними видно, скільки тривала зупинка. - Помилки 502 і 503 у клієнтів під час деплою та
OperationCanceledExceptionу логах — ознака, що бюджет середовища менший за потрібний.
Підсумок: коректна зупинка — це узгоджені таймаути хоста, сервера й середовища, а не одне налаштування ShutdownTimeout.
var builder = WebApplication.CreateBuilder(args);
// Поточним запитам під час зупинки даємо до 20 секундbuilder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));
var app = builder.Build();
IHostApplicationLifetime lifetime = app.Lifetime;lifetime.ApplicationStarted.Register(() => app.Logger.LogInformation("Started"));lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Stopping: draining requests"));lifetime.ApplicationStopped.Register(() => app.Logger.LogInformation("Stopped"));
app.MapGet("/slow", async (CancellationToken ct) =>{ await Task.Delay(TimeSpan.FromSeconds(10), ct); // при Ctrl+C встигне завершитися return "done";});
app.Run(); // блокує потік до зупинки хоста// Ctrl+C під час /slow: Stopping → відповідь "done" → Stopped- Що раніше:
StartAsynchosted service чи старт Kestrel? — СпочаткуStartAsyncусіх hosted services, потім сервер іApplicationStarted. - Що буде із запитом, який триває довше за
ShutdownTimeout? — Його обірвуть, клієнт отримає розрив з’єднання. - Чим
WebApplicationBuilderвідрізняється відHost.CreateDefaultBuilderзіStartup? — Це той самий Generic Host, але налаштування лінійне, без колбеків і класуStartup. - Чому застосунок у Docker не встигає коректно зупинитися? —
docker stopчекає 10 секунд і надсилає SIGKILL;ShutdownTimeoutмає бути меншим. - Як зупинити застосунок з коду? —
IHostApplicationLifetime.StopApplication().
Як написати власний middleware і чим відрізняються способи за часом життя?
Section titled “Як написати власний middleware і чим відрізняються способи за часом життя?”Що це
Власний middleware — ланка конвеєра з вашою логікою: замір часу, ідентифікатор кореляції, перевірка заголовка.
Як користуватися
- Лямбда:
app.Use(async (context, next) => { ...; await next(context); }). Підходить для пари рядків. - Клас за угодою:
- конструктор приймає
RequestDelegate next; - публічний метод
InvokeAsync(HttpContext context)повертаєTask; - підключення —
app.UseMiddleware<MyMiddleware>()або власний метод розширенняapp.UseMy().
- конструктор приймає
IMiddleware: клас реалізує методInvokeAsync(HttpContext, RequestDelegate)і реєструється в DI.
Типова помилка
Забути await next(context). Запит далі не піде, і клієнт отримає порожню відповідь 200.
Як влаштовано
| Спосіб | Коли створюється | Scoped-залежності |
|---|---|---|
Лямбда в app.Use |
один раз під час побудови конвеєра | через context.RequestServices |
| Клас за угодою | один раз під час побудови конвеєра | параметрами InvokeAsync |
IMiddleware |
на кожен запит, з DI | у конструкторі |
Коли що обирати
- Клас за угодою — за замовчуванням. Немає зайвої роботи на запит, налаштування передаються через
UseMiddleware<T>(args)абоIOptions<T>. IMiddleware— коли потрібен конструктор зі scoped-залежностями й сигнатура, яку перевіряє компілятор.
Пастки
- Поля класу за угодою спільні для всіх запитів. Стан запиту в полі — гонка між потоками.
- Scoped-сервіс у конструкторі класу за угодою: у Development — виняток «Cannot resolve scoped service … from root provider» під час старту, у продакшені — один екземпляр на весь застосунок.
- Незареєстрований
IMiddlewareне заважає старту, але перший же запит отримає 500. - Аргументи в
UseMiddleware<T>(...)дляIMiddlewareпередати не можна —NotSupportedException.
Правило
Конструктор — для синглтонів і налаштувань, InvokeAsync — для всього, що живе в межах запиту.
Під капотом
UseMiddleware<T>працює під час побудови конвеєра. Для класу за угодою він знаходитьInvokeабоInvokeAsyncі створює екземпляр черезActivatorUtilitiesз кореневого контейнера.- Якщо в
InvokeAsyncлише параметрHttpContext, виклик іде прямим делегатом. Додаткові параметри розв’язуються на кожен запит ізcontext.RequestServices. - Для
IMiddlewareу конвеєр стає обгортка. На кожен запит вона береIMiddlewareFactoryзі scope запиту, отримує екземпляр, викликає його й повертає фабриці черезRelease. Звільняє екземпляр контейнер разом зі scope.
Ціна
IMiddlewareз реєстрацією Transient — алокація й розв’язання залежностей на кожен запит.- Перевантаження
app.UseзFunc<Task>замістьRequestDelegateстворює на кожен запит два зайві об’єкти. З .NET 6 є перевантаженняapp.Use((context, next) => next(context)), деnext—RequestDelegate, і цих алокацій немає.
Компроміси
- Клас за угодою швидший, але його контракт не перевіряє компілятор: помилка в імені
InvokeAsyncспливе не під час збирання проєкту, а винятком під час старту. IMiddlewareпростіше тестувати й декорувати. ВласнийIMiddlewareFactoryдає змогу брати middleware зі стороннього контейнера.
Як перевірити в продакшені
- Захоплений scoped-сервіс ловить перевірка scope: тримайте її ввімкненою в Development та в інтеграційних тестах.
- Алокації на запит видно в
dotnet-counters(швидкість алокацій GC) і в навантажувальному тесті до та після зміни. - Незареєстрований
IMiddlewareловить smoke-тест наWebApplicationFactory, який робить хоча б один запит.
Підсумок: вибір способу — це вибір часу життя: синглтон із залежностями запиту в параметрах або об’єкт на кожен запит.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddScoped<RequestInfo>();builder.Services.AddScoped<TimingMiddleware>(); // IMiddleware має бути в DI
var app = builder.Build();app.UseMiddleware<CorrelationMiddleware>("X-Correlation-Id"); // аргумент конструктораapp.UseMiddleware<TimingMiddleware>();app.MapGet("/", (RequestInfo info) => info.Id); // той самий scoped-об'єкт, що й у middlewareapp.Run();
// За угодою: один екземпляр на весь застосунокpublic class CorrelationMiddleware(RequestDelegate next, string header){ public Task InvokeAsync(HttpContext context, RequestInfo info) // scoped — параметром { info.Id = context.Request.Headers[header].FirstOrDefault() ?? Guid.NewGuid().ToString(); return next(context); }}
// IMiddleware: свій екземпляр на кожен запит, scoped — у конструкторіpublic class TimingMiddleware(RequestInfo info, ILogger<TimingMiddleware> logger) : IMiddleware{ public async Task InvokeAsync(HttpContext context, RequestDelegate next) { long start = Environment.TickCount64; await next(context); logger.LogInformation("{Id}: {Ms} ms", info.Id, Environment.TickCount64 - start); }}
public class RequestInfo { public string Id { get; set; } = ""; }- Чому
DbContextне можна приймати в конструкторі звичайного middleware? — Конструктор викликається один раз, і контекст стане спільним для всіх запитів і потоків. - Як передати налаштування в middleware? — Аргументами
UseMiddleware<T>(...)або черезIOptions<T>у конструкторі; дляIMiddleware— лише через DI. - Що буде, якщо не зареєструвати
IMiddlewareу DI? — Застосунок стартує, але кожен запит через цю ланку отримає 500. - Навіщо перевантаження
app.UseзRequestDelegate? — З .NET 6 воно заощаджує дві алокації на запит порівняно зFunc<Task>. - Як протестувати middleware? — Через
WebApplicationFactoryабоTestServer, або викликатиInvokeAsyncзDefaultHttpContext.
Як працює endpoint routing і як обирається маршрут?
Section titled “Як працює endpoint routing і як обирається маршрут?”Що це
- Endpoint — обробник запиту: делегат
MapGet, action контролера, Razor Page, хаб SignalR. - Маршрут — шаблон URL, за яким обирається endpoint.
Як користуватися
| Шаблон | Що означає |
|---|---|
/orders/{id} |
параметр id |
/orders/{id:int} |
лише ціле число |
/orders/{id?} |
необов’язковий параметр |
/{page=Home} |
значення за замовчуванням |
/files/{**path} |
catch-all: увесь залишок шляху |
Значення параметрів приходять в аргументи обробника: (int id) => ....
Типова помилка
Перевіряти введення обмеженнями маршруту. Неправильне значення дасть 404, хоча клієнтові потрібен 400 із зрозумілим повідомленням.
Як влаштовано
UseRoutingзіставляє запит з усіма endpoint’ами й зберігає переможця:context.GetEndpoint(), значення параметрів — уRequest.RouteValues.- Middleware між
UseRoutingіUseEndpointsчитають метадані endpoint’а:[Authorize], політику CORS, ліміти. UseEndpointsвикликає делегат endpoint’а.
WebApplication (.NET 6+) додає UseRouting на початок, якщо ви не викликали його самі, і UseEndpoints у кінець. Хоч би де стояв app.MapGet у коді, endpoint виконується в кінці конвеєра.
Як обирається маршрут
Порядок реєстрації не важливий, перемагає конкретніший шаблон:
- літерал сильніший за параметр:
/orders/newперемагає/orders/{id}; - параметр з обмеженням сильніший за параметр без нього;
- catch-all найслабший.
Якщо два endpoint’и рівні за пріоритетом, запит падає з AmbiguousMatchException. Явний Order — крайній захід.
Пастки
- Middleware перед явним
UseRoutingне бачать endpoint:GetEndpoint()повертаєnull. UseCorsіUseRateLimiterз політиками endpoint’ів ставлять післяUseRouting.- Неоднозначність не видно під час старту:
/{x:alpha}і/{x:int}рівні за пріоритетом, але не перетинаються, тому маршрутизація не може перевірити це заздалегідь.
Правило
Спільний префікс і спільні метадані виносьте в MapGroup (.NET 7+). Рішення «пускати чи ні» ухвалюйте за метаданими endpoint’а, а не за рядком шляху.
Під капотом
- Під час першого запиту маршрутизація будує з усіх шаблонів автомат (DFA) і перебудовує його, коли змінюються джерела endpoint’ів. Час зіставлення залежить від довжини шляху, а не від кількості маршрутів.
- Кандидатів з автомата відсіюють обмеження та
MatcherPolicy: HTTP-метод, хост, тип вмісту.EndpointSelectorобирає найкращого заOrderі пріоритетом шаблону. - Endpoint —
RequestDelegateплюсEndpointMetadataCollection. Метадані — будь-які об’єкти: атрибути,RequireAuthorization(),WithMetadata(...).
Ціна
- Regex-обмеження та складені сегменти на кшталт
{x}-{y}помітно дорожчі за звичайні й створюють більше рядків. - Тисячі маршрутів з параметром у перших сегментах (
{tenant}/...) роздувають автомат: довгий перший запит і безліч об’єктівDfaNodeу дампі. Допомагають обмеження на кшталт{tenant:int}і перенесення параметрів ближче до кінця шаблону.
Компроміси
ShortCircuit()іMapShortCircuit(.NET 8+) виконують endpoint одразу після маршрутизації й пропускають решту middleware. Це заощаджує роботу на health checks іrobots.txt, але несумісне з[Authorize]і CORS.- Власний термінальний middleware замість endpoint’а позбавляє авторизацію та CORS метаданих: інтеграцію доведеться писати вручну.
Як перевірити в продакшені
- Логи категорії
Microsoft.AspNetCore.Routingна рівні Debug показують кандидатів і вибраний endpoint. - Метрики .NET 8+:
aspnetcore.routing.match_attemptsз ознакою успіху й тегhttp.routeуhttp.server.request.duration— затримка за шаблоном маршруту, а не за сирим URL. - Повний список маршрутів віддає
EndpointDataSourceз DI.
Підсумок: маршрутизація — єдина точка рішень: швидкий автомат, передбачуваний пріоритет і метадані, на які спираються політики.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.Use(async (context, next) =>{ // Неявний UseRouting стоїть на початку: endpoint уже вибрано var endpoint = context.GetEndpoint(); if (endpoint?.Metadata.GetMetadata<AuditAttribute>() is not null) app.Logger.LogInformation("Audit: {Endpoint}", endpoint.DisplayName); await next(context);});
var orders = app.MapGroup("/orders");orders.MapGet("/{id:int}", (int id) => $"by id {id}"); // /orders/5orders.MapGet("/{name}", (string name) => $"by name {name}"); // /orders/abcorders.MapGet("/new", () => "new order form") // літерал сильніший за параметри .WithMetadata(new AuditAttribute());
app.MapGet("/robots.txt", () => "User-agent: *").ShortCircuit(); // middleware вище не виконається
app.Run();
sealed class AuditAttribute : Attribute { }- Що поверне
GetEndpoint()у middleware доUseRouting? —null: endpoint ще не вибрано. - Чому
/orders/newне потрапляє в/orders/{id}? — У літерального сегмента пріоритет вищий, ніж у параметра. - Що буде, якщо два маршрути рівні за пріоритетом? —
AmbiguousMatchExceptionпід час запиту, а не під час старту. - Навіщо
MapGroup? — Спільний префікс і спільні метадані (авторизація, фільтри) для групи endpoint’ів, .NET 7+. - Чим
ShortCircuit()відрізняється від термінального middleware? — Це endpoint з метаданими, який виконується одразу після маршрутизації й пропускає решту конвеєра.
In-process і out-of-process в IIS: як працює ASP.NET Core Module і що обрати?
Section titled “In-process і out-of-process в IIS: як працює ASP.NET Core Module і що обрати?”Що це
ASP.NET Core Module — модуль IIS з .NET Hosting Bundle. Він пов’язує IIS і застосунок одним із двох способів:
- in-process — застосунок завантажується просто в
w3wp.exe, замість Kestrel працюєIISHttpServer; - out-of-process — ANCM запускає
dotnet.exeабоMyApp.exeз Kestrel і пересилає туди запити.
Як користуватися
- Установіть на сервер .NET Hosting Bundle: рантайм і ANCM.
- Створіть пул з .NET CLR version = No Managed Code.
- Задайте модель у
.csproj:<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>(за замовчуванням) абоOutOfProcess. Публікація перенесе її вweb.config.
Типова помилка
Покласти два in-process-застосунки в один пул. Другий не запуститься з помилкою 500.35: кожному застосунку потрібен свій пул.
Як влаштовано
| In-process | Out-of-process | |
|---|---|---|
| Процес | w3wp.exe |
dotnet.exe або MyApp.exe |
| Сервер | IISHttpServer |
Kestrel на випадковому порту |
| Шлях запиту | HTTP.sys → IIS → застосунок | HTTP.sys → IIS → loopback → Kestrel |
| Падіння застосунку | падає w3wp, IIS запускає новий |
ANCM перезапускає процес |
| Помилка старту | 500.30 | 502.5 |
Коли що обирати
- In-process — за замовчуванням: швидше, немає зайвого процесу й порту.
- Out-of-process — публікація в один файл (in-process її не підтримує, помилка 500.38) або однакова поведінка Kestrel в IIS та інших середовищах.
Пастки
- Розрядність пулу (Enable 32-Bit Applications) має збігатися з розрядністю застосунку й рантайму.
- Ліміт IIS
maxAllowedContentLength(30 000 000 байтів за замовчуванням) перевіряється раніше, ніжMaxRequestBodySizeзастосунку. requestTimeout(за замовчуванням 2 хвилини) діє лише в out-of-process: довше ANCM на відповідь не чекає.- DLL застосунку, що працює, заблоковані. Для деплою кладуть
app_offline.htmабо вмикають shadow copy (.NET 7+).
Правило
In-process за замовчуванням і один пул на застосунок. Out-of-process — лише з конкретною причиною.
Під капотом
- ANCM — це прошарок
aspnetcorev2.dllу конвеєрі IIS та обробник для вибраної моделі. - In-process: обробник завантажує CoreCLR у
w3wp.exeі викликаєMainзастосунку.UseIISреєструєIISHttpServer, який отримує запити з нативного конвеєра IIS без сокетів. - Out-of-process:
aspnetcorev2_outofprocess.dllзапускає процес зASPNETCORE_PORTіASPNETCORE_TOKEN. Кожен запит несе заголовокMS-ASPNETCORE-TOKEN, а middleware IIS-інтеграції відхиляє запити без нього й вмикає forwarded headers.
Ціна
- Out-of-process: другий розбір HTTP, з’єднання через loopback і окремий процес із власною пам’яттю.
- In-process: застосунок ділить процес з IIS, і його падіння — це падіння
w3wp. - Рециклінг пулу (за замовчуванням раз на 1740 хвилин) і зупинка після 20 хвилин простою означають холодний старт: JIT, прогрівання кешів, нові з’єднання.
Компроміси
- Overlapped recycle піднімає новий
w3wpдо зупинки старого. Дві копії застосунку працюють одночасно, і фонові задачі, блокування файлів та in-memory-кеші мають це переживати. - Під час рециклінгу ANCM чекає
shutdownDelay(за замовчуванням 1 секунда), потім хост витрачає доShutdownTimeoutна поточні запити. Нульовий простій не гарантують ні overlapped recycle, ні shadow copy — для цього потрібен blue-green. - Холодний старт прибирають Application Initialization з
preloadEnabled,startMode="AlwaysRunning"і idle timeout, що дорівнює 0.
Як перевірити в продакшені
- Журнал подій Windows, джерело «IIS AspNetCore Module V2».
- Тимчасово
stdoutLogEnabled="true"або налагоджувальний лог ANCM:debugLevelіdebugFileуhandlerSettings. - Дамп знімають з
w3wp.exeдля in-process і зdotnet.exeдля out-of-process; до того самого процесу підключаютьdotnet-counters.
Підсумок: in-process дає максимум продуктивності ціною спільної з IIS долі процесу, тому рециклінг і деплой планують під це.
var builder = WebApplication.CreateBuilder(args);
// In-process: ліміти IISHttpServer (ліміт IIS maxAllowedContentLength перевіряється раніше)builder.Services.Configure<IISServerOptions>(o => o.MaxRequestBodySize = 50_000_000);
var app = builder.Build();
// w3wp — in-process, dotnet або ім'я застосунку — out-of-processapp.MapGet("/host", () => System.Diagnostics.Process.GetCurrentProcess().ProcessName);
app.Run();<!-- .csproj: модель хостингу (InProcess — за замовчуванням) --><PropertyGroup> <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel></PropertyGroup>
<!-- web.config, який створює публікація --><aspNetCore processPath="dotnet" arguments=".\MyApp.dll" hostingModel="inprocess" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout"> <handlerSettings> <!-- пауза перед зупинкою старого екземпляра під час рециклінгу, мс --> <handlerSetting name="shutdownDelay" value="5000" /> <!-- деплой без блокування DLL, .NET 7+ --> <handlerSetting name="enableShadowCopy" value="true" /> <handlerSetting name="shadowCopyDirectory" value="../ShadowCopy/" /> </handlerSettings></aspNetCore>- Чому in-process швидший? — Немає пересилання через loopback і другого HTTP-сервера: запит іде з IIS просто в
IISHttpServer. - Що означають 500.30 і 502.5? — Помилку старту застосунку в in-process і out-of-process; причину шукають у журналі подій і stdout-лозі.
- Чи можна тримати два застосунки в одному пулі? — Два in-process — ні (500.35), змішувати моделі в одному пулі теж не можна (500.34).
- Як оновити застосунок без блокування DLL? —
app_offline.htmабо shadow copy (.NET 7+); для нульового простою — blue-green. - Чому перший запит уранці повільний? — Пул зупинився через idle timeout або після рециклінгу; допомагають Application Initialization і
AlwaysRunning.