.NET 5 开发 Windows 服务完整指南:从 Worker 到部署排障 📅 2026/8/27 1:26:35 简介Windows 服务本质上是由服务控制管理器SCM托管的常驻进程开发时往往面临安装、启动、稳定性等多重挑战。而 .NET 5 统一平台后借助 Worker Service 模板与通用主机开发者可以快速构建具备依赖注入、配置系统和结构化日志的服务程序。使用 UseWindowsService 能轻松接入 SCM 生命周期配合 sc 命令或脚本即可完成安装与恢复策略设置。无论是定时上报、数据同步还是后台轮询这类常驻型后台任务都适合采用该方案。文章围绕完整 Demo 项目详解从项目搭建、代码实现、发布安装到调试排障的每个环节并给出生产环境下的日志、优雅停机与故障自愈建议帮助读者避开服务开发中的典型坑点真正实现服务的稳定落地。 我最早正经接触 Windows 服务这玩意儿是被装完服务开机自启但总崩溃折腾到没脾气。后来 .NET Core 3.x 出了 Worker Service 模板我顺手把定时上报、数据同步这一堆活儿从任务计划程序迁到了服务里。等 .NET 5 把整个平台统一了以后这套玩法基本就成了我写 Windows 服务的默认套路。这篇文章就围绕我的 dotnet5-winservice-demo 完整版项目把从创建项目、改代码、发布、安装、调试到排障的完整流程捋一遍。如果你正在找用 .NET 5 写 Windows 服务的现成方案或者服务写好了却卡在装不上、起不来、跑不稳这三关这篇应该能直接帮你落地。1. 为什么我最终选了 .NET 5 写服务一次选型对比和取舍先说结论Windows 服务本质是一个被 SCM服务控制管理器托管的常驻进程实现方式其实很多。我最早是用控制台程序 开机启动脚本 任务计划程序这套组合结果就是程序一旦崩了没人知道日志散落各处更新逻辑还得先改计划任务。后来试过用 NSSM 把普通 exe 包装成服务虽然能解决以服务方式运行的问题但业务逻辑本身还是裸奔的日志、配置、依赖管理全部要自己搭。.NET 5 这个节点比较特殊。它前面有 .NET Core 3.1 把 Worker Service 模板做成熟了后面 .NET 6 又把 LTS 周期理顺了但 .NET 5 处于统一 .NET 平台的第一个版本。我在这个 Demo 里选择 .NET 5是因为当时公司不少老项目已经跑在 .NET Core 3.1 上往 .NET 5 迁移的成本极低。放到今天来看如果你是全新项目我建议直接用 .NET 6 或 .NET 8 这类 LTS 版本下面说的这套写法和 API 几乎不用改因为Host.CreateDefaultBuilder和UseWindowsService从 .NET Core 3.0 开始就稳定了。再对比一下其他方案。很多人问 nginx 怎么做成 Windows 服务那种需求和本文是两条路nginx 本身不是 Windows 服务程序得靠 NSSM 或 WinSW 这类包装器托管你不需要为它写业务代码。而如果你要做的是一段逻辑——比如定时拉数据、轮询目录、跑报表——那用 .NET 写一个真正的服务程序才合适因为它自带依赖注入、配置系统、结构化日志服务生命周期也由框架帮你接好。相比之下用 Python pywin32 写服务也能跑但打包部署到目标机器时要处理的运行时问题更多用 Go 写服务在交叉编译和内存占用上有优势但日志、配置、异常处理这些全得自己造轮子。选 .NET 的最大实际收益是把精力花在业务上而不是花在怎么让程序被系统识别成服务上。2. 项目骨架搭建Worker模板、Program.cs 和 Worker.cs 的每一行都讲明白2.1 创建项目和引用服务包如果你本机已经装了 .NET 5 SDK直接执行dotnet new worker -n WinServiceDemo --framework net5.0 cd WinServiceDemo dotnet add package Microsoft.Extensions.Hosting.WindowsServices --version 5.0.1dotnet new worker生成的是后台任务 通用主机模板。注意这时候项目还不能充当 Windows 服务必须加上Microsoft.Extensions.Hosting.WindowsServices这个包它提供了UseWindowsService()扩展方法让程序能够接入 SCM 的服务生命周期。2.2 Program.cs 是怎么把服务装进系统里的模板默认的 Program.cs 只有CreateHostBuilder我习惯改成直接用Host.CreateDefaultBuilder的写法using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace WinServiceDemo { public class Program { public static void Main(string[] args) { IHost host Host.CreateDefaultBuilder(args) .UseWindowsService(options { options.ServiceName DemoWinService; }) .ConfigureServices(services { services.AddHostedServiceWorker(); // 在这里注册你的业务服务 // services.AddSingletonISyncService, SyncService(); }) .Build(); host.Run(); } } }UseWindowsService的作用是当程序被 SCM 启动时以 Windows 服务的方式运行当你在命令行直接跑这个 exe或以dotnet run调试时它就退化成普通的控制台进程。这是一个非常关键的双模式设计,开发时不需要反复安装服务按 F5 就能断点调试。options.ServiceName DemoWinService这里设置的名字应该和后面sc create时用的服务名保持一致。如果不设置默认会用程序集名。建议显式写避免发布后程序集改名导致服务名对不上。2.3 Worker 类的正确打开方式模板里的 Worker 长这样using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace WinServiceDemo { public class Worker : BackgroundService { private readonly ILoggerWorker _logger; public Worker(ILoggerWorker logger) { _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation(Worker started at: {time}, DateTimeOffset.Now); while (!stoppingToken.IsCancellationRequested) { try { // 这里写你的核心业务比如定时上报、数据清洗 _logger.LogInformation(Worker executing at: {time}, DateTimeOffset.Now); await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, Worker encountered an error.); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } } _logger.LogInformation(Worker stopped at: {time}, DateTimeOffset.Now); } } }为什么用BackgroundService而不是直接实现IHostedService因为BackgroundService已经把StartAsync、StopAsync的异步模型封装好了你只需要在ExecuteAsync里写一个进入循环、监听 CancellationToken的逻辑。SCM 发出停止指令后host.Run()会触发stoppingToken的取消信号ExecuteAsync里的循环就会退出整个进程正常结束。这里有两个细节必须养成习惯Task.Delay一定要传入stoppingToken。如果不传服务停止时线程还会傻等几秒SCM 会认为服务停止超时甚至报服务没有响应停止控制功能。try/catch要放在循环里面。如果放在 while 外层一旦执行到未捕获异常整个宿主进程直接崩掉服务表现为启动后几秒自动停止。循环里不要做成同步死循环比如while(true) { DoWork(); }这种写法在服务模式下会让 CPU 直接拉满因为 SCM 不会强行限制服务进程的 CPU 占用率。2.4 发布成 single-file 还是 framework-dependent开发完成后发布我的标准命令是dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFiletrue输出位置在bin\Release\net5.0\win-x64\publish\你会得到一个单文件 exe。对服务场景我强烈建议做 self-contained 发布。因为目标机器不一定装了 .NET 5 运行时如果只发 framework-dependent 版本服务启动时会直接报找不到运行时排障路径又长一段。代价只是 exe 体积会膨胀到几十 MB但对服务器来说不算事。PublishSingleFile推荐开启但有一点要注意单文件发布后你的 appsettings.json 是从外部读取还是嵌入 exe取决于你是否额外配置。模板默认 appsettings.json 是照常复制到发布目录的这样运维还能在不重新编译的情况下改配置我一般保持这个默认行为。3. 把程序装进服务管理器的两种姿势sc命令和批处理脚本3.1 先搞懂服务的安装本质所谓安装一个 Windows 服务就是告诉 SCM 这三件事服务名字是什么、启动类型是什么、可执行文件路径在哪。SCM 会把它写进注册表HKLM\SYSTEM\CurrentControlSet\Services\服务名。注意这个服务名是全局唯一的你在安装时如果撞上一个重名的服务SCM 会直接报指定的服务已存在。我见过不少人在服务器上装 MySQL 时碰到mysql80名称被占用就是这个道理。3.2 sc.exe 手动安装用管理员权限打开命令行sc create DemoWinService binPath D:\WinServiceDemo\WinServiceDemo.exe start auto sc start DemoWinService sc query DemoWinService这里有个坑sc create的参数binPath和start后面必须有一个空格这是 sc.exe 的历史语法。写成binPath...不带空格命令看着像没问题实际会解析失败。查看服务状态和配置sc query DemoWinService sc qc DemoWinService sc queryex DemoWinServicesc queryex会输出服务的 PID排查 CPU 占用问题时会用到。3.3 用 PowerShell 安装PowerShell 的可读性好一些New-Service -Name DemoWinService -BinaryPathName D:\WinServiceDemo\WinServiceDemo.exe -StartupType Automatic Start-Service DemoWinService需要注意-BinaryPathName参数对应的是 exe 路径不是 dll 路径。以前 .NET Framework 时代有人会用InstallUtil去安装服务的 dll这套流程对 .NET 5 服务完全不适用别照搬。3.4 一键安装/卸载脚本我每次手动敲sc命令都容易错所以在 Demo 里放了一对 bat 脚本。install.bat内容如下echo off set SERVICE_NAMEDemoWinService set BIN_PATH%~dp0WinServiceDemo.exe sc stop %SERVICE_NAME% nul 21 sc delete %SERVICE_NAME% nul 21 sc create %SERVICE_NAME% binPath %BIN_PATH% start auto sc description %SERVICE_NAME% Demo service written in .NET 5 sc failure %SERVICE_NAME% reset 86400 actions restart/5000/restart/10000/restart/60000 sc start %SERVICE_NAME% sc query %SERVICE_NAME%脚本里我故意先sc stop | sc delete再sc create是为了让脚本可重复执行。直接更新的场景下如果服务还在运行sc delete会失败exe 文件也会被占用无法覆盖。先停再删再建虽然会有一小段空窗期但胜在稳定。uninstall.bat更简单echo off set SERVICE_NAMEDemoWinService sc stop %SERVICE_NAME% nul 21 sc delete %SERVICE_NAME%sc failure那一行值得单独说它配置了服务失败后的自动重启行为。reset 86400表示 24 小时内如果服务还没再次失败计数器归零actions restart/5000/restart/10000/restart/60000表示第一次失败等 5 秒重启第二次等 10 秒第三次等 60 秒。对常驻服务来说这比裸奔强太多。网上那些用批处理优化游戏性能的脚本本质也是在做服务启停和系统参数调整只是它操作的对象是别人家的服务。你写完自己的服务后同样可以用这种批处理一键管理启停、安装、更新原理相通。4. 服务启动失败与运行异常的排查实录2186、自动退出、CPU拉满4.1 服务没有响应控制功能别再盯着代码看先跑一下 exe这个报错很常见典型对话是这样的 net start DemoWinService 服务没有响应控制功能。 请键入 net helpmsg 2186 以获得更多的帮助。net helpmsg 2186的直译是服务没有及时响应启动请求。深层次原因只有一个SCM 向服务进程发送了启动控制请求但服务进程在指定时间内默认 30 秒没有返回我已经进入运行状态的信号。对应到 .NET 5 服务常见触发点有三个exe 根本起不来。比如目标机器没装 .NET 5 运行时或你发布的是 framework-dependent 版本进程一启动就崩SCM 等不到响应。binPath路径不对SCM 压根找不到可执行文件。手动直接运行时卡在前置初始化逻辑里比如Main里先去连数据库、拉远程配置导致host.Run()迟迟没被调用。排查第一步不是看代码而是打开命令行直接运行D:\WinServiceDemo\WinServiceDemo.exe如果程序能在控制台里正常跑起来说明运行时和依赖没问题问题大概率出在安装配置或服务账户权限上。这时候再看事件查看器Windows 日志 - 系统来源是Service Control Manager里面会写具体的错误信息。我在实际排查中有七成情况都是发布时忘了 self-contained 导致目标机缺运行时。4.2 服务启动后几秒就自动停止另一种常见症状sc start提示服务已经启动但过几秒你再sc query状态变成STOPPED。这种问题大概率是进程里某个未捕获异常把宿主打崩了。比如ExecuteAsync里 while 循环外抛了异常或者ConfigureServices里注册服务时依赖没解析成功。排查姿势是打开事件查看器Windows 日志 - 应用程序找.NET Runtime或服务名相关的错误记录。在ExecuteAsync里加日志或者用try/catch包住最外层把异常写进文件。如果是在开发环境复现直接用dotnet run前台跑看控制台输出比看日志更直接。不要一上来就怀疑 SCM 配置。SCM 只是负责拉起进程进程自己崩了SCM 只能记一条服务进程意外终止。4.3 服务 CPU 占到 100%代码写法的老问题前阵子有人问我Windows 11 上安全软件的服务进程 CPU 占比很高怎么办那是别人的服务。但如果你自己写的 .NET 服务出现 CPU 飙高排查思路完全一样先用sc queryex DemoWinService拿到 PID打开任务管理器看这个 PID 的 CPU 占用。确认是你的服务之后问题通常出在ExecuteAsync里while (!stoppingToken.IsCancellationRequested) { DoSomething(); // 同步方法且内部耗时长 // 忘记写 await Task.Delay循环空转 }或者写了个耗时同步方法阻塞了循环实际效果就是单核 CPU 被打满。解决方法是把耗时操作改成异步并给每次循环至少留一点间隔如果是纯 CPU 计算型任务就要考虑要不要拆到线程池里或者控制并发度。还要特别注意如果你在ConfigureServices里用AddSingleton注册了某个服务而这个服务的构造函数里做了重的初始化那么宿主启动时就会卡住也就是上面说的 2186 场景。正确做法是让构造函数只做赋值真正干活放到第一次调用时。4.4 工作目录诡异为什么相对路径找不到文件服务方式运行时进程当前工作目录是C:\Windows\System32不是 exe 所在目录。很多人在控制台调试时一切正常一装成服务就报找不到配置文件或目录不存在就是因为代码里用了相对路径。我自己踩过这个坑后定了两条规矩程序自己读写文件一律用AppContext.BaseDirectory拼绝对路径var baseDir AppContext.BaseDirectory; var dataPath Path.Combine(baseDir, data, cache.db);不要依赖Environment.CurrentDirectory。它在任务计划程序里有时是C:\Windows\System32在服务里也是在双击运行时又变成了 exe 目录行为太飘。模板自带appsettings.json没这个问题因为Host.CreateDefaultBuilder会基于内容根目录去读。但你自己额外加载的任何文件都得注意。4.5 服务名冲突和更新覆盖先停再删Windows 服务名全局唯一不分大小写。你如果要在一台机器上跑同一套服务的多个实例正确做法是给它们不同的服务名binPath指向同一个 exe。但注意多个实例如果都写同一个数据文件照样会打架。更新服务 exe 时如果服务还处于运行状态exe 文件会被占用覆盖会失败。所以我的 bat 脚本里sc delete前强制sc stop其实就是为了这个。更稳妥的更新流程是sc stop- 覆盖 exe -sc start而不是每次sc delete再sc create因为 delete/create 会把注册表里的服务配置比如sc failure设置重置掉。5. 从Demo到生产环境我长期在用的几个改造点5.1 日志别只靠 Console服务模式下Console输出是没人看的。Demo 里我用的是默认ILogger控制台调试用。生产环境我会换成 Serilog写文件并限制体积using Serilog; Log.Logger new LoggerConfiguration() .WriteTo.Console() .WriteTo.File( Path.Combine(AppContext.BaseDirectory, logs, service-.log), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14) .CreateLogger();然后在CreateDefaultBuilder后面加.UseSerilog()。文件路径一定要用AppContext.BaseDirectory拼别用相对路径。日志滚动的意义在于服务常年跑在服务器上单文件日志能撑爆磁盘按天滚动加保留数量是基本操作。5.2 环境配置Develop 和 Production 分离Host.CreateDefaultBuilder默认会加载appsettings.json再根据DOTNET_ENVIRONMENT加载appsettings.{Environment}.json。开发时你可以在项目属性里设置set DOTNET_ENVIRONMENTDevelopment dotnet run然后在项目里加一个appsettings.Development.json把数据库连接串、API Key 这类敏感信息放里面别提交到仓库。服务正式跑起来时环境变量是 Production就不会读到开发配置。这个设计看似简单实际对服务类程序特别重要因为你不能指望每次都改完配置重新发布。运维只需要在机器上改生产环境的 json然后sc stop/start重启服务。5.3 故障自愈把服务的恢复配置写进安装脚本我在第 3 节安装脚本里已经加了sc failure这是服务上线前必须做的一步。Windows 服务的恢复选项卡在图形界面里可以配置但手工点容易漏直接写进安装脚本才是可复制的做法。sc failure三个 restart action 的意思我解释过还有一个常用配置是reset。比如如果服务在 1 小时内连续崩溃 3 次就不要再自动重启了等人工介入这个可以这么配sc failure DemoWinService reset 3600 actions restart/5000/restart/10000/restart/60000这样既能避免服务反复崩溃反复拉起造成死循环又能覆盖绝大多数瞬时故障。5.4 优雅停机业务中有长任务时要注意什么默认host.Run()已经处理好了 SCM 的停止信号stoppingToken会触发ExecuteAsync的循环会退出。但如果你的业务在一个循环周期里处理任务超过 30 秒比如同步一批大数据SCM 的默认停止等待时间可能不够。这时有两种做法在ExecuteAsync里用return退出前主动调用业务服务的StopAsync方法等待当前批次处理完。如果确实需要更长停机时间可以在服务注册表项里加WaitToKillServiceTimeout但这属于压制症状我一般不用。更重要的是自己的业务逻辑要支持取消。比如 HttpClient 请求带上stoppingToken数据库操作支持CancellationToken这样才能在服务停止时尽快释放资源而不是硬等。5.5 业务逻辑别都堆在 Worker 里Demo 的 Worker 只是骨架我不会把真实业务写进去。我会定义一个业务接口public interface ISyncService { Task SyncAsync(CancellationToken cancellationToken); }然后在ConfigureServices里注册实现Worker 只负责按周期调用ISyncService.SyncAsync(stoppingToken)。这样做的直接好处是单元测试可以单独测业务类不需要真起服务以后换调度方式比如改成 Quartz.NET 或 Hangfire时业务代码一行不用动。我后来在多个生产环境跑这套服务发现最实用的反而不是那些炫酷的框架功能而是安装脚本里sc failure那行配置以及日志文件的按天滚动。服务这东西重在稳不在奇。一个能装、能停、能自动重启、崩溃了能查日志的服务就是好服务。如果你拿到的是这个完整版 Demo我建议你第一件事不是跑起来而是把Program.cs里的ServiceName改成实际业务的名字然后把Worker.ExecuteAsync里那段示例日志删掉替换成自己的真实任务。别嫌改名字麻烦等服务装到服务器上你就知道一个能对得上业务含义的服务名有多重要了。本文还有配套的精品资源点击获取