Хостинг и конвейер
Путь запроса в ASP.NET Core: какой сервер его принимает, как он проходит конвейер middleware, как маршрутизация выбирает обработчик и как хост запускает и останавливает приложение. По этим вопросам видно, понимает ли кандидат, что происходит между сетью и его кодом.
Перед этой темой: Асинхронность.
Что такое Kestrel и зачем перед ним IIS или reverse proxy?
Заголовок раздела «Что такое 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 и почему важен их порядок?
Заголовок раздела «Что такое 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?
Заголовок раздела «Как запускается и останавливается приложение: 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 и чем отличаются способы по времени жизни?
Заголовок раздела «Как написать свой 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 и как выбирается маршрут?
Заголовок раздела «Как работает 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 и что выбрать?
Заголовок раздела «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.