.NET 5 开发 Windows 服务完整指南:从 Worker 到部署排障
2026/9/23 11:44:16 网站建设 项目流程

简介: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.CreateDefaultBuilderUseWindowsService从 .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.1

dotnet 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.AddHostedService<Worker>(); // 在这里注册你的业务服务 // services.AddSingleton<ISyncService, 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 ILogger<Worker> _logger; public Worker(ILogger<Worker> 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已经把StartAsyncStopAsync的异步模型封装好了,你只需要在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:PublishSingleFile=true

输出位置在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 DemoWinService

sc 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_NAME=DemoWinService set BIN_PATH=%~dp0WinServiceDemo.exe sc stop %SERVICE_NAME% >nul 2>&1 sc delete %SERVICE_NAME% >nul 2>&1 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 deletesc create,是为了让脚本可重复执行。直接更新的场景下,如果服务还在运行,sc delete会失败,exe 文件也会被占用无法覆盖。先停再删再建,虽然会有一小段空窗期,但胜在稳定。

uninstall.bat更简单:

@echo off set SERVICE_NAME=DemoWinService sc stop %SERVICE_NAME% >nul 2>&1 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里注册服务时依赖没解析成功。排查姿势是:

  1. 打开事件查看器,Windows 日志 -> 应用程序,找.NET Runtime或服务名相关的错误记录。
  2. ExecuteAsync里加日志,或者用try/catch包住最外层,把异常写进文件。
  3. 如果是在开发环境复现,直接用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 deletesc 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_ENVIRONMENT=Development 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里那段示例日志删掉,替换成自己的真实任务。别嫌改名字麻烦,等服务装到服务器上,你就知道一个能对得上业务含义的服务名有多重要了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询