托管生命周期与部署

系统讲解 .NET 通用主机 IHost 的托管生命周期,深入 BackgroundService 后台任务、启动与优雅关闭机制、Kestrel 配置调优,以及 Docker 容器化与发布部署的完整流程。

1. 通用主机与 IHost

一句话总结: IHost 是 .NET 应用的运行时外壳,统一管理依赖注入、配置、日志与后台任务,Web 与 Worker 应用共用同一套托管模型。

从 .NET Core 3.0 起,IHost 成为所有应用的托管基础:它持有 DI 容器、配置系统、日志管道与生命周期,WebApplication 是它在 Web 场景的便利封装。理解 IHost 就理解了「应用是怎么被启动、编排、停止的」。

// 通用主机最小示例(Worker Service 模板本质)
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();

var host = builder.Build();
await host.RunAsync();
// WebApplication 也是建立在 IHost 之上
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
await app.RunAsync();

// 访问底层 IHost 的能力
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
组件职责
IHost应用生命周期外壳
IServiceProvider依赖注入容器
IConfiguration配置系统
IHostedService随主机启停的后台服务
IHostApplicationLifetime启动/停止通知

避坑: Host.CreateApplicationBuilder 默认不带 Web 能力;Web 场景要用 WebApplication.CreateBuilder。两者都注册了同一个 IHost,所以 Hosted Service、配置、日志在两种模板里的行为完全一致——这是「通用主机」的含义。

2. BackgroundService 与 IHostedService

一句话总结: IHostedService 让代码随主机启动与停止,BackgroundService 提供了基于 Task 的简洁基类,是定时任务与消息消费的标准宿主。

后台任务(定时清理、消息轮询、指标采集)不该塞进请求处理线程。IHostedService.StartAsync 在应用启动时被调用,BackgroundService 进一步把 ExecuteAsync 抽象成无限循环,配合 CancellationToken 实现优雅停止。

public class DailyCleanupService : BackgroundService
{
    private readonly ILogger<DailyCleanupService> _logger;
    private readonly IServiceScopeFactory _scopeFactory;

    public DailyCleanupService(ILogger<DailyCleanupService> logger,
                               IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("清理服务已启动");
        using var timer = new PeriodicTimer(TimeSpan.FromHours(24));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            // 每次创建作用域:注入 Scoped 服务
            using var scope = _scopeFactory.CreateScope();
            var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
            var deleted = await repo.CleanExpiredAsync(DateTime.UtcNow.AddDays(-90));
            _logger.LogInformation("清理过期订单 {Count} 条", deleted);
        }
    }
}

// 注册
builder.Services.AddHostedService<DailyCleanupService>();
类型特点
IHostedService最底层,手动实现 Start/Stop
BackgroundService基类,抽象 ExecuteAsync 循环
PeriodicTimer周期性触发,可取消
IServiceScopeFactory后台任务创建 DI 作用域

避坑: 后台服务是单例宿主,直接注入 Scoped 服务(如 DbContext)会异常。必须用 IServiceScopeFactory.CreateScope() 在循环体内创建作用域。另外 ExecuteAsync 抛出的未处理异常会悄悄终止该服务——要在循环里捕获并记录,或用 IHostedService 实现失败即停机的策略。

3. 启动顺序与状态感知

一句话总结: 多个 Hosted Service 按注册顺序启动,HostedService 与请求管线存在竞态,IHostApplicationLifetime 事件与健康检查用于协调启动时序。

应用启动不是一瞬间:配置加载、服务注册、Hosted Service 启动、Kestrel 监听、请求处理逐层展开。IHostApplicationLifetime.ApplicationStarted 在所有服务启动完成后触发,HealthChecks 让负载均衡器知道「真的准备好了」而非「端口开了」。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHostedService<CacheWarmer>();       // 先启动
builder.Services.AddHostedService<MessageConsumer>();    // 后启动

// 健康检查
builder.Services.AddHealthChecks()
    .AddDbContextCheck<ShopDbContext>()
    .AddCheck<ReadyCheck>("ready");

var app = builder.Build();
app.MapHealthChecks("/healthz");

var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStarted.Register(() =>
{
    Console.WriteLine("所有服务启动完成,应用可服务流量");
});
// 就绪探针:依赖预热完成
public class ReadyCheck : IHealthCheck
{
    private readonly IOptionsMonitor<CacheWarmerState> _state;
    public ReadyCheck(IOptionsMonitor<CacheWarmerState> state) => _state = state;

    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct) =>
        Task.FromResult(_state.CurrentValue.Warmed
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Unhealthy("缓存尚未预热完成"));
}
阶段事件/信号
服务注册完成ApplicationStarted
停止开始ApplicationStopping
停止完成ApplicationStopped
存活/healthz(进程活着)
就绪/ready(可接流量)

避坑: 部署平台(K8s/编排器)同时用两种探针——存活探针判断进程是否假死,就绪探针判断能否接流量。把「数据库连不上」写进就绪探针会导致 Pod 反复重建,写成存活探针则可能掩盖依赖故障——探针语义选错,故障定位就难了。

4. 优雅关闭与资源释放

一句话总结: 优雅关闭让进程在退出前停止接收新工作、把在途工作跑完并释放资源,Kestrel 的连接关闭与 Hosted Service 的 CancellationToken 是关键两环。

收到 SIGTERM(K8s 停止 Pod、Ctrl+C)时,IHost 触发 ApplicationStopping,Kestrel 停止接受新连接,CancellationToken 通知所有 Hosted Service 收尾。ShutdownTimeout 限制总关闭时长,超时则强制退出。

var builder = WebApplication.CreateBuilder(args);

// 默认 30 秒,按需调整
builder.Host.ConfigureHostOptions(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));

var app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() =>
{
    Console.WriteLine("正在优雅关闭,等待在途请求完成...");
});

await app.RunAsync();
// Hosted Service 响应取消,主动收尾
public class MessageConsumer : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            // 处理一批消息后检查取消
        }

        // 退出前刷新缓冲
        Console.WriteLine("消费服务已停止,剩余消息留待下次处理");
    }
}
资源关闭动作
Kestrel停止接受连接,等待在途请求
Hosted ServiceCancellationToken 触发,循环退出
DbContext/连接池随 DI 容器释放
ILogger刷写日志缓冲
总时长ShutdownTimeout(默认 30s)

避坑: Task.Delay(..., token) 与 WaitForNextTickAsync(token) 都必须传入 CancellationToken——否则取消只停止你的循环检查,阻塞点不响应,优雅关闭形同虚设。另外容器里优雅关闭靠 SIGTERM,Windows 上 Ctrl+C 是 SIGINT,两种信号都要处理。

5. Kestrel 配置与调优

一句话总结: Kestrel 是 ASP.NET Core 的高性能内置服务器,端点、并发、超时与 HTTP/2 都由 ConfigureKestrel 精确控制,反向代理常在前方终结 TLS。

Kestrel 是跨平台的 libuv/Kestrel 传输层,性能优异且无需 IIS。生产部署最常见的拓扑是 Nginx/网关终结 TLS + 转发 HTTP 到 Kestrel,此时 Kestrel 只监听内网端口。端点、请求体上限、超时、连接并发都是常见调优项。

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    // 显式端点(跳过 UseUrls 的环境变量)
    options.Listen(IPAddress.Any, 8080);

    // HTTPS 端点由反向代理终结时不需要
    // 直接终结 TLS 的场合:
    options.Listen(IPAddress.Any, 8443, listen =>
        listen.UseHttps("cert.pfx", "password"));

    // 请求体上限
    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;

    // 超时控制
    options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(120);
    options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);

    // 并发连接
    options.Limits.MaxConcurrentConnections = 10_000;
});
配置默认说明
MaxRequestBodySize30MB请求体上限
KeepAliveTimeout130s空闲连接保持
MaxConcurrentConnections无限并发连接数
MinRequestBodyDataRate240 B/s慢速攻击防护

避坑: 反向代理模式下不要把 HTTPS 也交给 Kestrel——代理终结 TLS、内网 HTTP 转发,性能和证书管理都更优。此时设置 ForwardedHeaders 中间件让应用信任 X-Forwarded-*,否则重定向与客户端 IP 会全错。

6. Docker 容器化

一句话总结: 多阶段构建把 SDK 镜像编译与运行时镜像分离,ASPNET 基础镜像内置非 root 用户与生产优化,暴露端口 + 健康检查让容器进入编排体系。

.NET 官方镜像分 sdk(构建)与 aspnet/runtime(运行),多阶段构建能显著减小镜像体积。ASPNET 镜像已包含非 root 用户、时区与生产环境变量默认值,安全与合规基线更稳。

# 构建阶段
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish "src/Shop.Api/Shop.Api.csproj" -c Release -o /app/publish

# 运行阶段
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "Shop.Api.dll"]
# docker-compose.yml 片段
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__Default=Server=db;Database=shop;...
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
      interval: 10s
      timeout: 5s
      retries: 3
    depends_on:
      db:
        condition: service_healthy
环节要点
基础镜像sdk 构建 / aspnet 运行
端口显式 EXPOSE + ASPNETCORE_URLS
用户运行阶段用非 root 用户
健康检查/healthz 交给编排器
配置全部走环境变量注入

避坑: 容器里运行默认会以 root 身份——aspnet 镜像提供了 app 用户,建议 USER $APP_UID 切换。另外不要在镜像里打日志文件,日志走 stdout 由编排平台收集;PID 1 由 dotnet 直接担任,否则收不到 SIGTERM 优雅关闭会失效。

7. 发布与部署拓扑

一句话总结: dotnet publish 生成可部署产物,部署拓扑决定运维方式——单机 systemd、容器编排、Azure App Service 各有侧重,健康检查与滚动更新是共同底线。

发布产物是自包含的 dll + 依赖,配合 dotnet publish 的优化选项(裁剪、单文件、ReadyToRun)可以调整体积与启动速度。部署拓扑的选择影响配置注入、日志收集与伸缩方式。

# 基础发布
dotnet publish -c Release -o out

# 单文件 + 裁剪(减小体积,牺牲 JIT 灵活性)
dotnet publish -c Release -r linux-x64 \
  --self-contained true /p:PublishSingleFile=true /p:PublishTrimmed=true

# 预览发布内容
ls out
# Linux systemd 单元(单机部署)
[Unit]
Description=Shop API
After=network.target

[Service]
WorkingDirectory=/opt/shop
ExecStart=/usr/bin/dotnet /opt/shop/Shop.Api.dll
Environment=ASPNETCORE_ENVIRONMENT=Production
Restart=always
RestartSec=10
User=www-data

[Install]
WantedBy=multi-user.target
部署方式优点注意
systemd简单、自控手动滚动更新
Docker + Compose环境一致单机编排
Kubernetes伸缩、自愈运维复杂度高
Azure App Service免运维平台锁定

避坑: 容器/编排环境里永远不要用 Restart=always 以外的自写守护,也不要让应用自行 fork 子进程——编排器管理进程生命周期。发布前务必用 dotnet publish 而不是 build 后的 bin 目录直接拷,后者缺依赖且路径硬编码。

8. 总结

环节要点
IHost统一托管外壳,Web 与 Worker 共用
BackgroundService定时/消费任务的标准宿主,DI 作用域隔离
启动时序Hosted Service 顺序 + 健康检查协调就绪
优雅关闭CancellationToken 贯穿,ShutdownTimeout 兜底
Kestrel反向代理终结 TLS,内网 HTTP 转发
Docker多阶段构建,非 root 用户,健康检查
发布publish 产物 + 部署拓扑与滚动更新

托管与部署是 .NET 应用「从代码到可用」的最后一段路:生命周期决定资源何时创建与释放,部署拓扑决定运维如何介入。把优雅关闭、健康检查、配置注入这三件事在第一天就做对,后续的伸缩、灰度、故障恢复都会顺理成章——它们是云原生 .NET 服务的及格线。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 消息与后台任务
  2. 缓存与并发控制
  3. 测试体系:xUnit 与 Moq