把 nginx 做成 Windows 服务:nssm、WinSW、srvany、ServiceBase、计划任务 ------ 五套方案的原理解剖与选型
摘要:nginx 在 Windows 上没有官方的服务支持,想让它开机自启就得"套一层包装器"。本文先把 Windows 服务的契约(SCM / ServiceMain / SetServiceStatus)讲清楚,解释为什么
sc create直挂nginx.exe注定失败;再逐个解剖 nssm、WinSW、srvany、.NET ServiceBase 自制包装器、计划任务/启动文件夹五种方案的工作原理、停止机制、日志能力与实测数据(含 PE 头实测);最后给一张选型决策表和三条通用铁律。全程用真实文件尺寸和导入表说话,不靠"听说"。
一、先搞懂对手:Windows 服务到底是个什么东西
1.1 nginx.exe 不是"服务程序"
Windows 服务的世界由 SCM(Service Control Manager,服务控制管理器) 统治。一个 exe 想成为服务,必须满足契约:
- 自己调用
StartServiceCtrlDispatcher,把 ServiceMain 函数指针注册给 SCM; - ServiceMain 里先调
RegisterServiceCtrlHandlerEx注册控制处理函数; - 然后调
SetServiceStatus报告状态:START_PENDING → RUNNING,停止时STOP_PENDING → STOPPED; - 全程能响应 SCM 发来的控制码:
SERVICE_CONTROL_STOP / PAUSE / SHUTDOWN / INTERROGATE。
nginx.exe 一个都不满足。 它是个普通的控制台程序,main() 起来就去监听端口了,从不理会 SCM。
所以 sc create nginx binPath= "D:\nginx\nginx.exe" 这种"直挂"写法看着聪明,实测必然失败:SCM 拉起进程后等半天等不到 SERVICE_RUNNING,30 秒后判超时,给你一个 错误 1053「服务没有及时响应启动或控制请求」 ,顺手在事件日志里留下 7000/7009。就算把 ServiceStartToKill=0 之类的注册表技巧用上,也还是没人转发停止信号、没人守进程 ------ 服务管理器里点"停止",nginx 照跑不误。
1.2 于是就有了"包装器"这个行业
包装器的思路统一而朴素:包装器自己是个合法的服务程序(满足 1.1 的契约),它再把真正的业务程序当子进程拉起来,并替 SCM 看着它。
scss
┌─────────────────────────────────────────┐
服务管理器 / net start │ SCM (services.exe) │
─────────────────▶│ 读注册表 → 拉起"服务进程"(= 包装器) │
└───────────────┬─────────────────────────┘
│ StartServiceCtrlDispatcher
▼
┌─────────────────────────────────────────┐
│ 包装器进程 (nssm.exe / nginx-service.exe)│
│ ① SetServiceStatus(START_PENDING) │
│ ② CreateProcess → nginx.exe │
│ ③ SetServiceStatus(RUNNING) │
│ ④ 循环监视子进程退出码 / 重定向输出 │
└───────────────┬─────────────────────────┘
│ 被托管进程
▼
nginx.exe (master) ── worker 进程
停止时:SCM ──ControlService(STOP)──▶ 包装器 ──┬─ 发停止命令 / Ctrl+C / 杀进程树
└─ SetServiceStatus(STOPPED) 回报
所有方案的差别,就藏在这张图的几个空格里 :谁来实现这个包装器(原生 C++ / .NET / 老古董工具)、配置怎么写(命令行 / XML / 注册表 / 代码)、停止时怎么通知子进程(Ctrl+C / 自定义停止命令 / 直接 TerminateProcess)、输出往哪落(重定向到文件 / 丢弃),以及目标机需要什么运行时。
二、五套方案逐个解剖
2.1 nssm ------ 零依赖的原生包装器(Windows 上的"标准答案")
原理 :单个 C++ 原生 exe,自己实现服务契约,用 CreateProcess 拉起被托管程序,并周期性 WaitForSingleObject 监视;被托管程序退出就按配置策略处理(重启 / 忽略 / 退出)。
实测数据(自写 Python 解析 PE,nssm 2.24 官方包):
| 检查项 | win32/nssm.exe | win64/nssm.exe |
|---|---|---|
| 大小 | 294,912 字节 | 331,264 字节 |
| 架构 | x86 (0x014c) | x64 (0x8664) |
| PE SubsystemVersion | 5.0 | 5.2(Win7 = 6.1,加载器不会拒绝) |
| 导入 DLL | SHLWAPI / KERNEL32 / USER32 / COMDLG32 / ADVAPI32 / SHELL32 | 同左 |
导入表里没有 MSVCR*.dll、没有 VCRUNTIME140.dll、没有 api-ms-win-crt-* ------ 它是静态链接 CRT(工程 RuntimeLibrary="0" = /MT)的零依赖单文件程序:不需要 VC++ 运行库、不需要 UCRT、不需要 .NET。这是 nssm 最大的价值,也是它在老系统(Windows 2000 起)上一直吃香的原因。
配置方式:命令行/图形界面写进服务注册表。
c
nssm install nginx D:\nginx\nginx.exe
nssm set nginx AppDirectory D:\nginx ← 工作目录(最高频的坑)
nssm set nginx AppStdout D:\nginx\logs\out.log ← 输出重定向(远程排障的生命线)
nssm set nginx AppStderr D:\nginx\logs\err.log
nssm set nginx AppExit Default Restart
nssm set nginx Start SERVICE_AUTO_START
nssm dump nginx ← 打印全部配置,排障第一条命令
停止机制 :按 AppStopMethod* 依次尝试(默认顺序:ConsoleCtrl 控制台 Ctrl+C 事件 → WindowMessage WM_CLOSE → Threads → Terminate 强杀),逐级升级。对 nginx 这种控制台程序,Ctrl+C 那一步通常就能优雅退出。
它独有的坑 ------ "服务卡在 Paused":官方 README 原文:
NSSM will pause an increasingly longer time between subsequent restart attempts if the service fails to start in a timely manner, up to a maximum of four minutes. ... you can use the Windows service console (where the service will be shown in Paused state) to send a continue signal
也就是说:被托管程序反复"起来就秒退"时,nssm 会逐步延长重试间隔(最长 4 分钟),服务在服务管理器里显示为"暂停" 。很多人(包括一些技术文章)把这个现象误判成"nssm 和系统不兼容""系统缺组件",实际性质是nginx 自己起不来触发了 nssm 的保护机制。
适用 :任何 Windows(含精简/老系统),只要不想在目标机上装运行时。不适合:需要复杂依赖编排、需要把配置纳入版本管理的场景(配置在注册表里,不便 diff)。
2.2 WinSW ------ 用 XML 描述服务的包装器
原理 :.NET 写的包装器,继承 ServiceBase,实现服务契约;被托管程序的路径、参数、日志、失败恢复等全部写在同名 XML 里 。改名成 myapp.exe 后,它会找同目录的 myapp.xml。
配置方式:XML(可进 Git、可代码生成,这是它比 nssm 更"工程化"的地方)。
xml
<service>
<id>nginx</id>
<name>nginx</name>
<description>nginx web server</description>
<executable>D:\nginx\nginx.exe</executable>
<workingdirectory>D:\nginx</workingdirectory>
<logpath>D:\nginx</logpath>
<logmode>roll</logmode>
<startmode>Automatic</startmode>
<depend>MySQL</depend>
<stopexecutable>D:\nginx\nginx.exe</stopexecutable>
<stoparguments>-s stop</stoparguments>
<onfailure action="restart" delay="10 sec"/>
<resetfailure>1 hour</resetfailure>
</service>
服务管理 用自带子命令:install / uninstall / start / stop / stopwait / restart / status(status 会打印 NonExistent / Started / Stopped)。
关键语义(官方 v2.12.0 文档,务必记牢):
<startmode>默认 Automatic;- 停止流程:默认先发 Ctrl+C ,等
<stoptimeout>(默认 15 秒),仍不退才TerminateProcess;一旦配了<stoparguments>,就改为先运行停止命令并等它退出; - 联动规则 :用了
<stoparguments>,启动参数就要用<startarguments>而不是<arguments>; - 失败恢复用
<onfailure action="restart|reboot|none" delay="..."/>,计数重置用<resetfailure>; - 还支持
<serviceaccount>(LocalSystem / LocalService / NetworkService / 域账号 / gMSA)、<delayedAutoStart/>、<priority>、<download>(启动前拉取文件,可做自更新)。
它最大的坑 ------ 目标机必须有 .NET 运行时,而且构建要挑对 。WinSW 的 .NET Framework 构建是纯托管程序 (实测 WinSW.NET4.exe 的导入表只有 mscoree.dll,内嵌 CLR 版本串 v4.0.30319),没有运行时就是一堆 IL 字节码,谁也跑不起来。
v2.12.0 的构建矩阵(尺寸实测,GitHub Releases):
| 构建 | 大小 | 运行时前提 | 备注 |
|---|---|---|---|
WinSW.NET2.exe |
860,672 B (841 KB) | .NET Framework 2.0/3.5 | 老系统友好 |
WinSW.NET4.exe |
852,480 B (833 KB) | .NET Framework 4.0 | 存量现场最常见的那个 |
WinSW.NET461.exe |
655,872 B (641 KB) | .NET Framework 4.6.1+ | Win7 需 SP1 + SHA-2 补丁 |
WinSW-x86.exe / WinSW-x64.exe |
≈16.5 MB / ≈17.4 MB | 自包含(v2 = .NET Core 3.1;v3 = .NET 7) | v3 的 .NET 7 自包含仅支持 Win10 1607+ |
为什么这点特别重要 :GitHub Releases 页面展示 2.12.0 稳定版和 3.x 预发布版两组资产,默认容易点到最新(3.x)那个 17 MB 的 exe;而它在 Win7/Server 2008 R2 上根本起不来。选错构建 → "WinSW 不支持 Win7"的传言就是这么来的。
2.3 srvany.exe + instsrv ------ 该进博物馆的老方案
原理 :微软 Windows Server 2003 Resource Kit 里的两个小工具。instsrv.exe 负责注册服务,srvany.exe 负责当那个"壳":启动时读注册表
ini
HKLM\SYSTEM\CurrentControlSet\Services\<服务名>\Parameters
Application = D:\nginx\nginx.exe
AppParameters = (启动参数)
AppDirectory = D:\nginx
然后 CreateProcess 拉起目标程序。
为什么不该再用:
- 不看不管:srvany 拉起进程后就基本撒手,进程崩了它不知道,也不会重启;服务状态和实际进程状态可以对不上;
- 不转发停止:点"停止服务",被托管进程不一定会跟着干净退出(往往只剩强杀一条路);
- 没有日志:被托管程序的 stdout/stderr 没有落盘机制,出问题只能靠猜;
- 依赖第三方包:需要下载 Resource Kit,很多现场是"某台机器上翻出来的 srvany.exe",来源与版本不可控。
后来的社区替代品(srvany-ng、Rust 写的 shawl)补了监控和日志,但选型逻辑上你已经不该在 2020 年代新项目里用 srvany 了。
2.4 自制 .NET ServiceBase 包装器("installutil 那一套")
原理 :自己写 C#:class MyService : ServiceBase + ServiceBase.Run(new MyService()),在 OnStart 里 Process.Start 拉起 nginx,在 OnStop 里杀掉。安装用 .NET SDK 自带工具:
bash
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\installutil.exe nginx-service.exe
installutil.exe /u nginx-service.exe ← 卸载
它的"标志性报错" ------ 直接双击或命令行运行这个 exe 时,会弹:
无法从命令行或调试程序启动服务。必须首先安装 Windows 服务(使用 installutil.exe),然后用 ServerExplorer、Windows 服务管理工具或 NET START 命令启动它。
这是 ServiceBase.Run 的标准提示 :它检测到父进程不是 SCM,于是拒绝执行。看到这句话,就等于看到"这是裸的 .NET ServiceBase 程序,不是 WinSW"(WinSW 双击时只会打印用法帮助)。这句话本身不代表 exe 坏了,但如果这台机器上服务从未被正确安装过,那"开机不启动"就有解释了。
什么时候它是对的:你需要极其定制化的行为(启动前跑一段业务逻辑、和自家系统深度集成)。
为什么大多数场景不该选它:监控进程、重定向日志、优雅停止、失败恢复、依赖顺序......这些 WinSW/nssm 都白送了,自写得写一遍还有 bug;换人维护时没有文档可查,只有一个 exe。
2.5 计划任务 / 启动文件夹 ------ 不是服务,不要混用
原理 :任务计划程序(taskschd.msc)在"计算机启动时"或"用户登录时"运行一个程序;启动文件夹(shell:startup)则是登录时执行。
为什么在交付场景是雷区:
- 不在 SCM 里 :
sc query、net start、"服务"面板全都看不到它,运维交接时等于隐形进程; - 依赖会话:启动文件夹里的东西必须有人登录才跑;任务计划虽然能配"不管用户是否登录",但需要保存密码,密码改了任务就静默失效;
- 权限错位 :nginx 以某个登录用户身份跑,读写
logs/、绑定 80 端口的权限都跟着这个用户走; - 无失败恢复:进程崩了没人管,开机顺序也无法和其他服务编排;
- 双开冲突:如果服务方式也配了,就会两个 nginx 抢 80 端口。
一句话:只有"给登录用户用的桌面程序"才用启动项;服务器上的常驻服务,一律做服务。
三、横向对比大表
| 维度 | nssm | WinSW | srvany | 自制 ServiceBase | 计划任务/启动项 |
|---|---|---|---|---|---|
| 实现语言 | C++ 原生 | .NET | C++ 原生(老) | .NET / 自选 | 系统组件 |
| 目标机依赖 | 无 | .NET Framework 4.0+ 或自包含构建 | 无(但要带 srvany.exe) | 对应 .NET 版本 | 无 |
| 体积 | ≈ 300 KB | 833 KB(NET4)/ 17 MB(自包含) | ≈ 30 KB + instsrv | 视写法 | --- |
| 配置形式 | 服务注册表(GUI/命令行) | XML(可进 Git) | 注册表 Parameters | 代码里硬编码 | 图形界面 |
| 进程监控/自动重启 | ✅ AppExit + throttle | ✅ onfailure/resetfailure | ❌ | 需自己写 | ❌ |
| 优雅停止 | ✅ Ctrl+C→WM_CLOSE→强杀 | ✅ stoparguments / stoptimeout | ❌ 基本靠强杀 | 需自己写 | ❌ |
| 输出落盘 | ✅ AppStdout/AppStderr | ✅ .out/.err/.wrapper 日志 + 轮转 | ❌ | 需自己写 | ❌ |
| 依赖顺序 | ✅ depend |
✅ <depend> |
❌ | 需自己写 | ⚠️ 触发器凑 |
| 最低系统 | Windows 2000+ | Win7 SP1(Framework 构建) | Win2000/2003 时代 | Win7+ | Win7+ |
| 许可证 | Public domain | MIT | 微软旧工具包 | 自己定 | --- |
| 最好的场景 | 老系统 / 零依赖要求 | 需要 XML 化、要进版本管理 | 已有历史包袱 | 深度定制 | 桌面程序 |
四、选型决策树
arduino
目标机能不能装运行时?
├─ 不能(老旧/精简系统、不允许装 .NET)
│ └─ ▶ nssm(零依赖,一个 300 KB 的 exe 搞定)
└─ 能
├─ 系统是 Win10 1607+ / Server 2016+
│ └─ ▶ WinSW(XML 配置、功能最全,选 2.12.0 的 WinSW.NET4/NET461,或 3.x 的自包含构建)
└─ 系统是 Win7 SP1 / Server 2008 R2
├─ 已装 .NET 4.x ▶ WinSW.NET4.exe(833 KB) 或 nssm
└─ 干净系统(只有 3.5.1)
├─ 可以装 .NET 4.x ▶ 装完再用 WinSW.NET4.exe
└─ 不想装 ▶ nssm
存量现场(已有包装器,正在跑)?
└─ ▶ 优先"修通现有方案":改配置、改启动类型、补日志,而不是换组件
(动一次配置要停机、要审批,换组件的收益往往覆盖不了成本)
关于"WinSW vs nssm"再多说一句,避免站队:两者没有绝对优劣,只有适配与否。 nssm 胜在原生、零依赖、单文件;WinSW 胜在配置可读可版本化、功能项更细(服务账户、gMSA、下载扩展)。新建环境按上面决策树挑;接手存量现场,先修通。
五、三条通用铁律(无论用哪套方案)
铁律一:工作目录必须显式设置
这是 nginx 服务化最高频 的翻车点。包装器由 SCM 拉起时,工作目录默认是 C:\Windows\System32,而 nginx 的 conf/nginx.conf、logs/、pid 都是相对路径 ------ 手动双击测试一切正常,做成服务就秒退。
- nssm:
nssm set nginx AppDirectory D:\nginx - WinSW:
<workingdirectory>D:\nginx</workingdirectory> - 保险做法:把
nginx.conf里的error_log、pid、access_log、root全写成绝对路径 (Windows 下用正斜杠/)。
铁律二:把输出落盘,别让服务"死了没遗言"
判断"服务为什么起不来",靠猜是猜不出来的。务必配好:
- nssm:
AppStdout/AppStderr; - WinSW:
<logpath>+<logmode>roll</logmode>,现场看xxx.wrapper.log(包装器自己的动作)、xxx.out.log/xxx.err.log(被托管程序的输出)。
补一个反直觉的点:nginx 正常工作时几乎不往 stdout/stderr 写东西,所以 .out/.err 是 0 KB 很正常 ,真正的线索在 wrapper 日志、logs\error.log 和 logs\nginx.pid。
铁律三:优雅停止,且必须验证
- Windows 上停 nginx 的正确姿势是
nginx.exe -s stop(重载是-s reload); - 直接
TerminateProcess强杀会留下 pid 残留、worker 进程孤儿等隐患; - 验证方法 :看日志里停止时到底执行了什么。如果只有一行
ProcessKill,说明你的"优雅停止"配置根本没生效(WinSW 的<stopexecutable>写成了带参数的一行路径,就是个经典陷阱)。
六、收尾
Windows 上把 nginx 做成服务,本质是补上 nginx 缺失的那部分"服务契约实现" 。理解了 SCM 与包装器之间的关系,五种方案的差异就退化成了四个具体问题:目标机有没有运行时、配置写在哪、停止怎么通知、日志往哪落。
把这四个问题问清楚,选型基本不会错;把这四条落实到位,服务"开机不启动""起来就退""停了留残留"这三类问题也就少了一大半。
本文的二进制尺寸、PE 头 SubsystemVersion、导入表数据均为实测(WinSW v2.12.0 取自 GitHub Releases API 与官方包,nssm 2.24 取自 Chocolatey 官方包);WinSW 的 XML 语义与 nssm 的 Paused 机制均引用官方文档原文。