1. 为什么 Windows 服务的发布值得单独做一条流水线
做过 C# Windows 服务的人大概都有过这种体验:本地net stop、net start来回敲,服务跑起来了,但版本对不对、配置文件是不是最新的、日志有没有正常写,全靠手动确认。一旦服务部署到多台机器上,这种手工操作基本等于给自己埋雷。所以当我第一次把 Jenkins 引入到 Windows 服务的发布流程里时,最直接的感受不是“自动化真香”,而是“终于不用再远程桌面一台台点了”。
Windows 服务和普通的 Web 应用不太一样。Web 应用发布通常是把文件丢到 IIS 目录,回收一下应用程序池就完事了,出问题回滚也快。但 Windows 服务是常驻进程,它可能正在处理消息队列、正在跑定时任务、正在监听某个端口,你直接覆盖文件会提示“文件被占用”,强行停服务又可能丢正在处理的数据。这就决定了它的发布流程必须考虑几个额外问题:服务怎么优雅停止、文件怎么安全替换、配置怎么按环境区分、失败之后怎么快速回退。
Jenkins 在这里扮演的角色,其实就是一个“有记忆的调度员”。它记住每次发布的版本、参数、执行结果,把原本靠人脑记忆和手工敲命令的流程固化下来。我这次要拆解的,就是一条针对 C# Windows 服务的完整 Jenkins 发布流水线,从代码拉取、编译、打包,到停服务、替换文件、启服务、健康检查,每一步都尽量做到可复现、可追溯。这套东西适合谁看?如果你手头有一两个 C# 写的后台服务,部署频率不高但每次都很烦,或者你正准备把手工部署改成自动化,那这篇内容应该能帮你少走一些弯路。
2. 整体方案设计与关键选型考量
2.1 为什么选 Jenkins 而不是其他方式
市面上做 C# 持续集成的工具不少,Azure DevOps、GitLab CI、TeamCity 各有各的优势。我最终选 Jenkins,原因很实际:它足够老,老到网上能搜到几乎所有坑的解决方案;它足够灵活,Windows 服务这种“非标准”发布场景,用 Jenkins 的 Freestyle 项目或者 Pipeline 都能拼出来;最关键的是,它不挑代码仓库,也不挑构建机环境,一台 Windows Server 上装个 Jenkins 就能跑起来。
另一个考虑是构建机和目标机的分离。理想情况下,Jenkins 构建机负责编译打包,产出一个 zip 或者文件夹,然后通过某种方式传到目标服务器上执行部署。但很多中小团队没有专门的文件分发系统,这时候 Jenkins 的“节点”功能就派上用场了——把目标服务器配成 Jenkins 的一个 Agent,构建完直接在目标机上执行部署脚本。这样做的好处是网络传输这一步省了,坏处是 Agent 本身也需要维护。我这次采用的是折中方案:构建和部署在同一台机器上完成,因为服务数量不多,目标机就是构建机,简单直接。
2.2 发布流程的四个阶段拆解
整条流水线我把它拆成四个阶段,每个阶段有明确的输入和输出,这样出问题的时候能快速定位是哪一步挂了。
第一阶段是代码获取与编译。Jenkins 从代码仓库拉取指定分支的代码,用 MSBuild 或者 dotnet CLI 编译。这里有个细节:Windows 服务项目通常是 .NET Framework 或者 .NET Core/5+ 的 Worker Service,两者的编译命令不一样。.NET Framework 用 MSBuild,.NET Core 用dotnet publish。我这次以 .NET Framework 的 Windows 服务为例,因为存量项目里这种居多。
第二阶段是打包与版本标记。编译产物不能直接扔到部署目录,得先整理成一个干净的发布包。我会把 exe、dll、配置文件、安装脚本都放到一个以版本号命名的文件夹里,同时生成一个版本信息文件,记录这次构建的 Git Commit ID、构建时间、构建人。这个版本信息文件在回滚的时候特别有用,一眼就能看出当前跑的是哪个版本。
第三阶段是服务停止与文件替换。这是整个流程里最容易出问题的环节。服务停止不是简单执行net stop就完事,得先确认服务真的停了,再检查文件是否被占用。文件替换也不能直接覆盖,而是先把旧版本备份到指定目录,再把新版本复制过去。备份目录按时间戳命名,回滚的时候直接拿对应的备份覆盖回来就行。
第四阶段是服务启动与健康检查。服务启动后不能立刻认为发布成功,得等几秒,然后检查服务状态是否为 Running,再进一步检查日志文件有没有报错、端口有没有监听。只有这些都通过了,才标记这次发布成功。如果健康检查失败,自动触发回滚,把备份的文件恢复回去,重新启动旧版本服务。
2.3 参数化构建的设计思路
Jenkins 的参数化构建是这条流水线的灵魂。我定义了这么几个参数:BRANCH用来指定拉取哪个分支,默认是main;VERSION用来指定版本号,如果不填就自动用时间戳生成;ENVIRONMENT用来区分环境,比如Test和Production,不同环境加载不同的配置文件;SKIP_BACKUP是个布尔值,紧急情况下可以跳过备份直接发布,但默认是 false。
这些参数的意义在于,同一条流水线可以应对不同的发布场景。比如日常测试发布,用默认参数就行;生产发布,指定ENVIRONMENT=Production,流水线会自动多一步确认操作;回滚的时候,指定VERSION为之前备份的版本号,流水线就走回滚逻辑。参数化让一条流水线干了多条流水线的活,维护成本低了很多。
3. 核心细节解析与实操要点
3.1 编译环节的坑与应对
C# Windows 服务的编译看起来简单,但有几个地方容易翻车。第一个是 NuGet 包还原。如果构建机上没有配置好 NuGet 源,或者项目引用了私有源,编译就会失败。我的做法是在 Jenkins 的构建步骤里显式执行nuget restore,并且把 NuGet.config 一起放到项目根目录,确保还原行为一致。
第二个是编译配置。Debug 和 Release 编译出来的东西差别很大,发布必须用 Release。但有些项目在 Debug 下才会生成某些调试文件,Release 下反而缺东西。我遇到过一种情况:项目里有个#if DEBUG的条件编译,Release 下走的是另一套逻辑,结果发布后服务行为跟测试环境不一致。所以编译前一定要确认配置,最好在流水线里写死Configuration=Release。
第三个是平台目标。Windows 服务通常是 x86 或者 x64,如果项目里有 C++ 的依赖库,平台目标不匹配会直接报BadImageFormatException。我一般会在编译命令里明确指定/p:Platform=x64,避免默认值带来的不确定性。
# .NET Framework 项目的编译命令示例 msbuild MyService.sln /p:Configuration=Release /p:Platform=x64 /t:Rebuild注意:如果项目用的是 packages.config 管理依赖,编译前记得先还原;如果是 PackageReference,
msbuild /t:Restore可以一并完成还原和编译。
3.2 服务停止的正确姿势
net stop这个命令有个坑:它发送的是停止请求,但服务不一定立刻停。如果服务的OnStop方法里有耗时操作,比如等待队列处理完、关闭数据库连接,net stop会一直等到超时。默认超时是 30 秒,超时后 Windows 会强制杀掉进程。强制杀进程的后果是,正在处理的数据可能丢失,日志可能没写完。
我的做法是分两步:先net stop,然后轮询检查服务状态,直到状态变成 Stopped 或者超时。如果超时了,再执行taskkill强制结束进程,但这时候会在日志里记一笔警告,提醒人工介入检查。
# 停止服务并等待状态变更 $serviceName = "MyWindowsService" $timeout = 60 $elapsed = 0 Stop-Service -Name $serviceName -Force while ((Get-Service -Name $serviceName).Status -ne 'Stopped') { Start-Sleep -Seconds 2 $elapsed += 2 if ($elapsed -ge $timeout) { Write-Warning "服务停止超时,尝试强制结束进程" Get-Process -Name "MyWindowsService" | Stop-Process -Force break } }另一个细节是服务依赖。有些 Windows 服务依赖其他服务,比如 SQL Server 或者消息队列服务。停止的时候如果依赖服务还在跑,可能会报错。我一般会在停止前检查依赖关系,必要时先停依赖服务,启动的时候反过来。
3.3 文件替换的安全策略
文件替换最怕的是“替换了一半”。比如 exe 复制过去了,但 dll 还没复制完,这时候服务启动就会报错。为了避免这种情况,我采用“先备份、再替换、后校验”的策略。
备份的时候,把整个服务目录复制到一个带时间戳的备份文件夹里。复制用robocopy而不是xcopy,因为robocopy支持多线程、支持断点续传、支持排除特定文件,而且返回值更清晰。
# 备份服务目录 robocopy "C:\Services\MyService" "D:\Backups\MyService\20250101_120000" /MIR /R:2 /W:5替换的时候,先把新版本复制到一个临时目录,确认复制完整后,再删除旧目录里的文件,把新文件移过去。这样做的好处是,如果复制过程中出错,旧版本还在,不会出现“两边都不完整”的情况。
提示:如果服务目录里有日志文件或者运行时生成的文件,替换的时候要排除掉,否则会把有用的日志覆盖掉。
robocopy的/XF参数可以排除指定文件。
3.4 配置文件的环境差异处理
Windows 服务的配置文件通常是MyService.exe.config或者appsettings.json。不同环境的配置不一样,比如数据库连接字符串、日志级别、第三方接口地址。我的做法是把配置文件模板化,用 Jenkins 的参数化构建来生成最终配置。
具体来说,项目里放一个appsettings.template.json,里面用占位符表示可变部分,比如#{DatabaseConnectionString}#。Jenkins 构建的时候,根据ENVIRONMENT参数选择对应的配置值,用 PowerShell 脚本替换占位符,生成最终的appsettings.json。
# 根据环境替换配置占位符 $template = Get-Content "appsettings.template.json" -Raw $config = $template -replace '#{DatabaseConnectionString}#', $env:DB_CONNECTION $config = $config -replace '#{LogLevel}#', $env:LOG_LEVEL $config | Set-Content "appsettings.json" -Encoding UTF8这样做的好处是,配置文件不用提交到代码仓库,敏感信息通过 Jenkins 的凭据管理来注入,既安全又灵活。
4. 完整实操流程与关键环节实现
4.1 Jenkins 任务的创建与配置
在 Jenkins 里新建一个 Freestyle 项目,名字叫Deploy-MyWindowsService。在“General”里勾选“This project is parameterized”,然后添加前面提到的四个参数。BRANCH用 String Parameter,默认值main;VERSION用 String Parameter,默认值留空;ENVIRONMENT用 Choice Parameter,选项是Test和Production;SKIP_BACKUP用 Boolean Parameter,默认不勾选。
源码管理选 Git,填上仓库地址和凭据,分支填*/$BRANCH。构建触发器我一般配两个:一个是 SCM 轮询,每 5 分钟检查一次代码变更;另一个是手动触发,用于紧急发布。生产环境的发布我建议只保留手动触发,避免代码一提交就自动上生产。
构建环境里勾选“Delete workspace before build starts”,确保每次构建都是干净的。这个选项在编译环节特别重要,残留的 obj 和 bin 目录有时候会导致奇怪的编译错误。
4.2 构建步骤的详细配置
构建步骤我分成五个,按顺序执行。
第一个是PowerShell 脚本:准备构建环境。这个步骤负责清理旧的发布目录、创建新的版本文件夹、生成版本信息文件。
# 准备构建环境 $version = $env:VERSION if ([string]::IsNullOrEmpty($version)) { $version = Get-Date -Format "yyyyMMdd_HHmmss" } $publishDir = "C:\Publish\MyService\$version" New-Item -ItemType Directory -Path $publishDir -Force "Version: $version" | Out-File "$publishDir\version.txt" "Commit: $env:GIT_COMMIT" | Out-File "$publishDir\version.txt" -Append "BuildTime: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" | Out-File "$publishDir\version.txt" -Append第二个是MSBuild 构建步骤。在 Jenkins 里添加“Build a Visual Studio project or solution using MSBuild”,选择对应的 MSBuild 版本,填上解决方案文件路径,命令行参数填/p:Configuration=Release /p:Platform=x64 /t:Rebuild。
第三个是PowerShell 脚本:整理发布包。把编译产物复制到发布目录,排除掉不需要的文件。
# 整理发布包 $sourceDir = "MyService\bin\Release" $publishDir = "C:\Publish\MyService\$env:VERSION" robocopy $sourceDir $publishDir /MIR /XF *.pdb *.xml *.log第四个是PowerShell 脚本:停止服务并替换文件。这个步骤是核心,逻辑前面已经讲过,这里给出完整脚本。
# 停止服务并替换文件 $serviceName = "MyWindowsService" $serviceDir = "C:\Services\MyService" $publishDir = "C:\Publish\MyService\$env:VERSION" $backupRoot = "D:\Backups\MyService" # 备份 if (-not $env:SKIP_BACKUP) { $backupDir = "$backupRoot\$(Get-Date -Format 'yyyyMMdd_HHmmss')" robocopy $serviceDir $backupDir /MIR /XF *.log } # 停止服务 Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue $timeout = 60 $elapsed = 0 while ((Get-Service -Name $serviceName -ErrorAction SilentlyContinue).Status -ne 'Stopped') { Start-Sleep -Seconds 2 $elapsed += 2 if ($elapsed -ge $timeout) { Get-Process -Name $serviceName -ErrorAction SilentlyContinue | Stop-Process -Force break } } # 替换文件 robocopy $publishDir $serviceDir /MIR /XF *.log第五个是PowerShell 脚本:启动服务并健康检查。启动服务后等待几秒,检查服务状态和日志。
# 启动服务并健康检查 $serviceName = "MyWindowsService" Start-Service -Name $serviceName Start-Sleep -Seconds 10 $status = (Get-Service -Name $serviceName).Status if ($status -ne 'Running') { Write-Error "服务启动失败,当前状态:$status" exit 1 } # 检查日志文件是否有错误 $logFile = "C:\Services\MyService\logs\service.log" if (Test-Path $logFile) { $errors = Select-String -Path $logFile -Pattern "ERROR|FATAL" | Select-Object -Last 5 if ($errors) { Write-Warning "日志中发现错误:" $errors | ForEach-Object { Write-Warning $_.Line } } }4.3 回滚机制的实现
回滚的逻辑其实很简单:找到指定版本的备份目录,把文件复制回服务目录,重新启动服务。我在 Jenkins 里单独建了一个任务叫Rollback-MyWindowsService,参数只有一个BACKUP_VERSION,填备份目录的时间戳。
# 回滚脚本 $serviceName = "MyWindowsService" $serviceDir = "C:\Services\MyService" $backupDir = "D:\Backups\MyService\$env:BACKUP_VERSION" if (-not (Test-Path $backupDir)) { Write-Error "备份目录不存在:$backupDir" exit 1 } Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 5 robocopy $backupDir $serviceDir /MIR /XF *.log Start-Service -Name $serviceName回滚任务和发布任务分开的好处是,回滚的时候不需要重新编译,直接拿备份文件恢复,速度快很多。而且回滚任务可以设置更宽松的权限,让运维人员也能执行,不用每次都找开发。
4.4 发布通知与记录
每次发布完成后,我会让 Jenkins 发一封邮件通知相关人员,邮件内容包括版本号、环境、构建结果、构建日志链接。如果是生产环境发布,还会额外发一条消息到工作群,提醒大家注意观察。
邮件通知用 Jenkins 的 Email Extension 插件,配置 SMTP 服务器和收件人列表。邮件正文可以用 HTML 模板,把关键信息格式化输出。
// Jenkins Pipeline 中的邮件通知示例 post { success { emailext( subject: "发布成功:MyWindowsService ${env.VERSION}", body: "环境:${env.ENVIRONMENT}\n版本:${env.VERSION}\n构建日志:${env.BUILD_URL}", to: "team@example.com" ) } failure { emailext( subject: "发布失败:MyWindowsService ${env.VERSION}", body: "环境:${env.ENVIRONMENT}\n版本:${env.VERSION}\n请检查构建日志:${env.BUILD_URL}", to: "team@example.com" ) } }5. 常见问题与排查技巧实录
5.1 服务启动失败排查表
服务启动失败是发布过程中最常见的问题,原因五花八门。我整理了一个排查表,按出现频率从高到低排列。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 服务状态变为 Paused | OnStart 方法抛出异常 | 查看 Windows 事件查看器 | 检查配置文件、依赖服务、端口占用 |
| 服务启动后立即停止 | 主线程退出或未正确阻塞 | 检查 Main 方法 | 确保 ServiceBase.Run 被调用 |
| 报错“无法启动服务” | 权限不足 | 检查服务账户 | 改用 LocalSystem 或授予权限 |
| 报错“文件被占用” | 旧进程未完全退出 | 任务管理器查看进程 | 强制结束进程后重试 |
| 报错“配置节不存在” | 配置文件缺失或格式错误 | 检查 exe.config | 确认配置文件已复制且格式正确 |
5.2 文件替换失败的几种情况
文件替换失败通常是因为文件被占用。除了服务进程本身,还有几种情况会导致文件被占用:杀毒软件正在扫描、Windows 索引服务正在索引、某个调试工具还挂着进程。我遇到过一次,是因为 Visual Studio 的远程调试器没关,导致 dll 一直被锁着。
排查文件占用可以用handle.exe或者 PowerShell 的Get-Process | Where-Object {$_.Modules.FileName -like "*MyService*"}。找到占用进程后,要么结束进程,要么等它释放。
另一个坑是文件权限。如果服务目录的权限设置比较严格,Jenkins 的执行账户可能没有写入权限。这时候要么给 Jenkins 账户授权,要么用takeown和icacls命令修改权限。
# 查看文件占用 Get-Process | Where-Object { $_.Modules | Where-Object { $_.FileName -like "*MyService*" } } | Select-Object ProcessName, Id5.3 健康检查的误报与漏报
健康检查不是万能的。我遇到过服务状态是 Running,但实际已经死锁的情况。也遇到过日志里有 ERROR,但其实是可忽略的警告。所以健康检查的策略需要根据服务的特性来调整。
对于关键服务,我会加一个端口检查。如果服务监听某个端口,用Test-NetConnection检查端口是否可连接。这比单纯看服务状态更可靠。
# 端口检查 $port = 8080 $result = Test-NetConnection -ComputerName "localhost" -Port $port -WarningAction SilentlyContinue if (-not $result.TcpTestSucceeded) { Write-Error "端口 $port 未监听,服务可能未正常启动" exit 1 }对于日志检查,我建议不要一看到 ERROR 就失败,而是设置一个阈值。比如最近 100 行日志里 ERROR 超过 5 条才报警,避免偶发的网络超时导致误报。
5.4 实操心得与避坑建议
第一条心得:永远保留至少三个版本的备份。我见过有人只保留最新备份,结果新版本有问题,回滚回去发现备份也被覆盖了。备份目录按时间戳命名,定期清理旧的,但至少保留最近三次。
第二条心得:生产发布前先在测试环境跑一遍。Jenkins 的参数化构建让这件事变得很容易,同一条流水线,换个ENVIRONMENT参数就行。测试环境跑通了,生产环境出问题的概率会小很多。
第三条心得:服务停止超时时间要合理设置。默认 30 秒对于大多数服务够用,但如果服务有大量数据要处理,可能需要更长。我一般设置 60 秒,并且在服务的OnStop方法里加日志,记录停止过程的耗时,方便后续优化。
第四条心得:配置文件不要硬编码在代码里。我见过把数据库连接字符串写在代码里的项目,换环境要重新编译。用配置文件加占位符替换的方式,灵活性和安全性都好很多。
第五条心得:Jenkins 的构建日志要保留足够长时间。默认保留 30 天,我一般改成 90 天。出问题的时候,翻历史构建日志往往能找到线索,比如某个版本开始编译警告变多了,或者某个步骤耗时突然变长了。
6. 流水线优化与扩展思路
6.1 从 Freestyle 迁移到 Pipeline
Freestyle 项目配置简单,但不够灵活。当发布流程变复杂,比如需要并行执行、需要条件判断、需要动态生成步骤的时候,Pipeline 的优势就体现出来了。我后来把这条流水线改成了 Jenkinsfile,放在代码仓库里,跟代码一起版本管理。
pipeline { agent any parameters { string(name: 'BRANCH', defaultValue: 'main', description: '代码分支') string(name: 'VERSION', defaultValue: '', description: '版本号,留空自动生成') choice(name: 'ENVIRONMENT', choices: ['Test', 'Production'], description: '发布环境') booleanParam(name: 'SKIP_BACKUP', defaultValue: false, description: '跳过备份') } stages { stage('Checkout') { steps { git branch: "${params.BRANCH}", url: 'https://git.example.com/MyService.git' } } stage('Build') { steps { bat 'msbuild MyService.sln /p:Configuration=Release /p:Platform=x64 /t:Rebuild' } } stage('Package') { steps { powershell 'powershell -File scripts/package.ps1' } } stage('Deploy') { steps { powershell 'powershell -File scripts/deploy.ps1' } } stage('HealthCheck') { steps { powershell 'powershell -File scripts/healthcheck.ps1' } } } post { failure { powershell 'powershell -File scripts/rollback.ps1' } } }Pipeline 的好处是,整个流程一目了然,而且可以跟代码一起 review。新人接手的时候,看 Jenkinsfile 比看 Jenkins 界面上的配置快得多。
6.2 多服务批量发布的思路
如果一个项目里有多个 Windows 服务需要发布,可以做成并行执行。Jenkins Pipeline 的parallel指令可以同时跑多个服务的发布步骤,节省时间。
stage('Deploy All Services') { parallel { stage('Service A') { steps { powershell 'powershell -File scripts/deploy.ps1 -ServiceName ServiceA' } } stage('Service B') { steps { powershell 'powershell -File scripts/deploy.ps1 -ServiceName ServiceB' } } } }并行发布的前提是服务之间没有依赖关系。如果有依赖,比如 Service B 依赖 Service A,那就得串行执行,先发 A 再发 B。
6.3 发布审批与权限控制
生产环境的发布我加了一道审批环节。在 Jenkins Pipeline 里用input指令,发布到生产之前会暂停,等待指定人员点击确认。
stage('Approve Production') { when { expression { params.ENVIRONMENT == 'Production' } } steps { input message: '确认发布到生产环境?', ok: '确认发布' } }审批人可以在 Jenkins 的凭据里配置,只有特定用户组才能点击确认。这样既保证了发布的可控性,又不会因为等待审批而阻塞整个流程。
6.4 监控与告警的集成
发布完成不是终点,服务跑起来之后的监控同样重要。我会在发布脚本的最后一步,调用监控系统的 API,把这次发布的事件记录下来。这样在监控面板上就能看到“某时间点发生了发布”,如果之后出现异常,可以快速关联到发布操作。
如果服务有健康检查接口,还可以让监控系统定期调用,一旦发现异常就告警。告警信息里带上当前运行的版本号,方便判断是不是某个版本引入的问题。
# 上报发布事件到监控系统 $body = @{ service = "MyWindowsService" version = $env:VERSION environment = $env:ENVIRONMENT timestamp = (Get-Date -Format "o") } | ConvertTo-Json Invoke-RestMethod -Uri "https://monitor.example.com/api/events" -Method Post -Body $body -ContentType "application/json"这套东西搭起来之后,发布这件事从“每次都要紧张一下”变成了“点一下按钮然后等通知”。当然,前提是流水线本身足够稳定,而稳定是靠一次次踩坑和优化换来的。我到现在还记得第一次把生产服务发挂了的场景,就是因为忘了排除日志文件,备份的时候把日志覆盖了,回滚回去发现日志没了,排查问题多花了一个小时。所以现在我的脚本里,/XF *.log这个参数是标配,不管什么场景都加上。