1. 先说说我把C++项目搬上自动化构建的真实动机
前两年带个小团队维护几个C++服务端程序,发布基本靠人肉链路:开发在自己机器上编好,压缩包传到服务器,手动停服务、替换文件、再启动。版本一多就开始乱,有一次漏拷了配置文件,线上直接报警,大家查了半小时才发现是部署目录里的 ini 是旧版。那次之后我下定决心,把 C++ 项目的自动化构建与主机部署整套流程接到 Arbess + GitLab 上,用两天时间搭完,之后只要代码推上去,剩下的编译、测试、打包、部署全部自动跑。这套组合到现在用了很久,今天把搭建思路和踩过的坑完整写出来,给同样维护C++项目的朋友做个参考。
1.1 手工人肉构建的日子
在没有自动化流水线之前,我们的流程看起来很“规范”:开发在本地拉分支、改代码、本地编译验证,然后手动合并到 develop,部署时挑一个“信得过”的人远程登服务器操作。问题不在某一次操作,而在于这套流程每次都要靠人记住所有细节。
C++项目不像Web前端那种打完包传上去就行,它涉及编译环境、第三方依赖、运行库、配置文件、服务重启顺序,漏掉任何一步都可能在线上暴雷。我统计过,当时发布一次平均要 40 分钟,其中真正拷贝文件不到 5 分钟,大部分时间花在“确认改了哪个模块、要不要重新编译依赖、本地和服务器环境是否一致”这些事上。最难受的是换人,新同事第一次做发布,光教他“先备份、再停服务、拷贝哪些目录、改哪个配置”就讲了半天。
后来我给自己定了个目标:不管谁来维护,只要代码进了 GitLab 的 master 分支,就自动完成“构建 -> 测试 -> 打包 -> 部署到指定主机 -> 重启服务 -> 通知结果”。人只负责 push 代码,机器负责干剩下的活。
1.2 GitLab管代码、Arbess管调度、C++管产出
先说清楚这套组合里每个工具负责什么,很多人一开始就把角色搞混了。
GitLab 在这里的核心作用是代码托管和事件源头,它不负责编译,也不负责部署。我们看中的是它的 Merge Request 审查、分支权限、Tag 管理和 Webhook 能力。当有代码 push 上去,或者有人点了“创建 Release Tag”,GitLab 会把这些事件通过 Webhook 推给下游工具。
Arbess 是 CI/CD 调度中心,负责接收 GitLab 推过来的事件,按照预先配置好的流水线编排去执行构建、测试、部署步骤。你可以理解为它是个“导演”,GitLab 只是通知它“有新剧本了”,接下来演员怎么走位全是 Arbess 在调度。
C++ 项目本身要做的,是配合这套流程改造成“能在任意干净机器上编译”的标准化工程。这个改造很关键,后面专门说。
1.3 这套组合适合谁
如果你维护的是以下这类项目,这套方案的参考价值最大:
- 用 C++ 写的 Windows 服务、桌面工具、内部中间件,需要频繁迭代。
- 项目托管在 GitLab,但不想被绑定到某一家 CI 平台。
- 部署目标就是普通 Windows/Linux 主机,不是容器集群。
- 团队规模不大,希望用尽量少的维护成本把发布这件事稳住。
我当时之所以没选 GitLab CI 自带的 Runner 直接跑,而是引入 Arbess,是因为我们除了构建之外还有很多主机部署动作:远程拷贝文件、备份目录、停服务、改配置、起服务、探测健康状态。Arbess 把这些步骤可视化地编排出来,比写一堆 .gitlab-ci.yml 更直观,而且不同项目之间可以复用同一套部署脚本。
2. 从零搭环境:GitLab、Arbess、构建机、部署机四件套
搭建这套环境之前,我建议你先画一张网络拓扑图,把各个组件的部署位置和访问关系写清楚。别嫌这一步麻烦,后面排查 Webhook 不通、部署超时时,这张图能帮你省很多时间。
2.1 各组件的角色和网络关系
我当时的部署形态是这样的:
| 组件 | 角色 | 关键点 |
|---|---|---|
| GitLab CE | 代码仓库和事件源 | Merge Request、Push、Tag 都会触发 Webhook |
| Arbess | CI/CD 调度中心 | 接收 Webhook,编排流水线,调度执行器 |
| 构建机 | 执行编译打包 | Windows Server 2019 + VS Build Tools + CMake + Git |
| 部署机 | 承载 C++ 业务服务 | Windows Server,预装 VC++ 运行库 |
构建机和部署机可以是同一台,也可以分开。我强烈建议分开,哪怕只是两台虚拟机。因为构建机经常装各种 SDK、更新编译工具链,而部署机上跑着线上服务,最忌讳的就是环境变动。曾经有人为了省机器,把构建工具直接装在生产服务器上,结果一次 CMake 升级把系统 PATH 搞乱了,线上程序起不来,教训很深。
GitLab 和 Arbess 我装在两台 Linux 服务器上,构建机和部署机是 Windows。Arbess 通过执行器(不同版本里叫 Agent 或 Runner)来连接 Windows 机器,执行器需要在构建机和部署机上各装一个。构建机的执行器负责拉代码、跑编译脚本,部署机的执行器负责跑部署脚本。
2.2 GitLab与Arbess打通:Token、Webhook、执行器
打通分三步,顺序不能乱。
第一步,在 GitLab 里创建一个 Access Token,用于 Arbess 拉取代码。建议只给 read_repository 权限,不要图省事用管理员 token。Token 过期时间要设置好,我们踩过 token 过期导致流水线突然失败的坑,后面细说。
第二步,在 Arbess 里创建项目,绑定 GitLab 仓库地址,填入 Token。Arbess 会生成一个 Webhook URL 和 Secret Token。
第三步,回到 GitLab 项目设置里的 Webhooks 页面,把 Arbess 给的 URL 和 Secret 填进去,勾选 Push events、Merge request events、Tag push events。保存后 GitLab 会显示一个“Test”按钮,点一下测试,如果 Arbess 那边收到事件,说明链路通了。
执行器的安装相对简单。在 Windows 机器上下载 Arbess 执行器安装包,装完会要求填 Arbess 服务端地址和一段注册凭据,凭据在 Arbess 的“执行器”页面生成。有一点要特别注意:执行器服务默认可能是以本地系统账号运行的,它拿不到你交互登录时那些环境变量,后面编译失败大多和这个有关。
2.3 C++构建机上最容易被忽略的MSVC环境
构建机上要装的东西比你想的多一点:Git for Windows、CMake(建议 3.21 以上)、Visual Studio Build Tools,以及项目第三方依赖。VS Build Tools 不用装完整版 Visual Studio,体积小很多,而且不会有 VS 自动更新把环境搞坏的风险。
很多刚搭 CI 的人在这里都会卡一道:本地能编译,到了构建机上就报“找不到 MSBuild”或者“无法识别 C++ 编译器”。原因是执行器进程不是交互式登录,没有加载 Visual Studio 开发者环境。普通命令行里能用的 cl、msbuild、环境变量,在服务环境下不一定有。
解决办法有两个。一是安装 Visual Studio Build Tools 之后,在流水线脚本里先调用 VsDevCmd.bat 初始化环境;二是直接把完整路径写死到脚本里。我采用的是前者,因为 VsDevCmd 会自动把当前架构的编译工具链加进来,省心。
另外一个细节:CMake 的生成器要显式指定。我用的命令是cmake -S . -B build -A x64,-A x64表示目标平台,不要只依赖默认值,否则有时候会生成 32 位版本,部署到 64 位系统上虽然能跑,但性能和兼容性都受影响。
3. 把C++工程改造成“可以在任意机器上编”的标准化工程
这一步是整个自动化能不能成立的前提。如果你的项目还依赖某个同事机器上的某个路径下的第三方库,那永远谈不上自动化。
3.1 统一编译入口:CMakePresets
我们的项目有多个子模块,原来每个开发者的编译方式都不一样,有人用 VS 打开 sln 点生成,有人用命令行敲 NMake。为了让流水线有一个统一入口,我引入了 CMakePresets.json。这个文件放在仓库根目录,内容类似:
{ "version": 3, "configurePresets": [ { "name": "windows-x64-release", "generator": "Visual Studio 17 2022", "architecture": "x64", "binaryDir": "${sourceDir}/build", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } } ], "buildPresets": [ { "name": "release", "configurePreset": "windows-x64-release", "configuration": "Release" } ] }有了它,不管在哪个环境,只要执行cmake --preset windows-x64-release就能完成配置,执行cmake --build --preset release就能编译。这比每个人都记一长串参数可靠得多。CMakePresets 还有一个好处:团队里其他人也用同一套入口,本地开发和流水线行为一致,大大减少了“我这能编,机器上不能编”的扯皮。
3.2 构建、测试、打包一条命令跑完
构建脚本我放在仓库的scripts\build.ps1里,核心逻辑是:
$ErrorActionPreference = "Stop" # 定位 Visual Studio 安装路径并进入开发者环境 $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" $vsPath = & $vswhere -latest -property installationPath if (-not $vsPath) { throw "No Visual Studio installation found" } Import-Module "$vsPath\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" Enter-VsDevShell -VsInstallPath $vsPath -SkipAutomaticLocation -DevCmdArguments "-arch=x64" # 配置、编译、测试 cmake --preset windows-x64-release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cmake --build --preset release --parallel if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ctest --test-dir build -C Release --output-on-failure if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # 打包 cpack --config build/CPackConfig.cmake -B artifacts注意$LASTEXITCODE检查,这是很多 PowerShell 脚本容易漏的地方。PowerShell 不像 bash 那样命令失败会自动中断,如果$ErrorActionPreference设成 Stop 只对 cmdlet 生效,外部 exe 的返回码不会触发异常,必须手动检查。
CMakeLists.txt 里需要加上安装规则和 CPack 配置:
install(TARGETS myapp RUNTIME DESTINATION bin) install(DIRECTORY config/ DESTINATION config) set(CPACK_GENERATOR "ZIP") include(CPack)这样cpack就能生成一个 zip 包,里面包含 exe、dll、配置文件,整个产物是一个自包含目录,部署时解压直接用。
3.3 制品的目录约定与版本命名
制品打包出来之后,要有一个清晰的名字和目录约定。我用的命名格式是myapp-<版本号>-<构建号>.zip。版本号从代码里读取,构建号用 Arbess 每次执行流水线自动生成的编号。
Arbess 构建完成后,制品会被归档到服务端的一个目录,比如C:\ArbessArtifacts\myapp\123\,123 是构建号。部署时从指定构建号目录取包,这样每一次发布对应哪一次代码提交都能查。
有人可能会问,为什么不用 Git commit SHA 当版本号?我的做法是两者都保留:zip 文件名带可读版本号,制品目录里放一个build-info.txt记录 Git SHA、构建时间、触发人。出问题的时候能快速定位到具体提交。
4. Arbess流水线里的三个阶段到底怎么编排
流水线我在 Arbess 里定义了三个阶段:构建、测试、部署。名字听起来很普通,但每个阶段里的细节才是关键。
4.1 阶段划分:构建->测试->部署
三个阶段的依赖关系是:测试必须等构建成功,部署必须等测试成功。Arbess 里把阶段配置好之后,默认就是这种串行依赖。如果你的项目测试很多、跑得慢,可以把测试阶段拆分到多台机器上并行,但我们当时的单元测试不到 5 分钟,没必要。
构建阶段做的事情上面已经写了:拉代码、初始化环境、CMake 配置、编译、打包、把 zip 归档。测试阶段除了跑 ctest,我还会额外跑一个参数冒烟测试,就是启动程序跑几个典型场景检查退出码。
部署阶段是这套流水线的核心价值。我的做法不是让 Arbess 直接远程执行 PowerShell,而是通过部署机上的执行器来执行。部署机上放了一个deploy.ps1,流水线调用它,传入制品路径和部署目录参数。
4.2 关键脚本:Windows主机部署部分
部署脚本的逻辑很简单,但每一步都要稳。核心代码:
param( [string]$SourceDir, [string]$TargetDir, [string]$ServiceName ) $ErrorActionPreference = "Stop" $backupDir = "$TargetDir\backup_$(Get-Date -Format 'yyyyMMdd_HHmmss')" # 1. 停服务并等待进程退出 if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { Stop-Service -Name $ServiceName -Force Start-Sleep -Seconds 3 } $proc = Get-Process -Name "myapp" -ErrorAction SilentlyContinue if ($proc) { Wait-Process -Id $proc.Id -Timeout 30 -ErrorAction SilentlyContinue } # 2. 备份当前目录 if (Test-Path $TargetDir) { Move-Item -Path $TargetDir -Destination $backupDir -Force } New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null # 3. 拷贝新制品 Copy-Item -Path "$SourceDir\*" -Destination $TargetDir -Recurse -Force # 4. 如果备份里有配置目录,还原(配置不随版本走) if (Test-Path "$backupDir\config") { Copy-Item -Path "$backupDir\config" -Destination $TargetDir -Recurse -Force } # 5. 启动服务并做健康检查 if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { Start-Service -Name $ServiceName Start-Sleep -Seconds 10 if ((Get-Service $ServiceName).Status -ne "Running") { throw "Service $ServiceName failed to start" } }这里有两个设计原则值得借鉴。
第一,程序目录和配置目录分离。部署时新代码整体替换,配置从备份还原,这样数据库连接串、端口号这些环境相关配置不会因为更新被冲掉。一开始我们没想到这个,后来有过一次配置被覆盖的线上事故,才改成现在这样。
第二,部署失败要立刻抛出异常。Arbess 拿到非零退出码就会把流水线标成失败,然后触发通知,而不是静默继续。我见过很多部署脚本不管结果,"看起来执行成功了",实际上目标目录是空的。
4.3 触发方式与失败通知
流水线触发方式我配了三种:Push 到 develop 分支时触发,但不自动部署,只做构建和测试;Push 到 master 分支时触发完整的三阶段;创建 Tag(比如v1.2.3)时也触发完整流程,用来发布正式版本。这样日常开发提交不会动不动就自动发到生产环境。
失败通知我们接的是钉钉机器人。Arbess 流水线失败时往群里的 Webhook 发一条消息,包含项目名、构建号、失败阶段、日志链接。消息不要只给一句“构建失败”,那样维护的人还得去翻日志。我把关键错误日志片段直接截取出来发群里,虽然有一定信息泄露风险,但团队内部工具可以接受。你也可以用邮件或者直接查 Arbess 后台,看团队习惯。
5. 跑通流水线后我踩过的四个真实坑
自动化流程刚跑通的前两周,几乎每天都有新问题。这些坑我逐一记录下来,排查思路比答案本身更值得参考。
5.1 MSBuild找不到,因为VS开发者环境没初始化
这个坑在前面提到过,这里说完整链路。现象是流水线日志里出现CMake Error: Could not find any instance of Visual Studio,或者编到一半报MSBuild is not found。
我当时的排查步骤:
- 在构建机上手动跑跟流水线一样的命令,发现能通过。这就说明代码和脚本本身没问题。
- 对比手动环境和执行器环境的差异,发现执行器服务运行时没有加载 VsDevCmd 的环境变量。
- 手动跑
cmd /k "C:\Program Files\Microsoft Visual Studio\2022\BuildTools\Common7\Tools\VsDevCmd.bat" -arch=x64之后,再执行 cmake,成功。 - 最终修改脚本,在每次构建前主动进入 VS 开发者环境。
这个问题本质上是“环境漂移”。本机能编,是因为你登录时已经初始化了环境;自动化任务跑在服务上下文里,环境是干净的。解决之后我养成了一个习惯:任何构建脚本的第一条命令一定是环境初始化,绝不依赖机器的默认配置。
5.2 离线C++程序在目标主机上双击没反应
有一次流水线全绿,部署也显示成功,但目标主机上的程序双击后毫无反应,进程列表里也没有。第一反应是拷贝的文件不全,后来发现根本不是。
排查过程是这样的:
- 到部署机上把制品目录里所有文件列出来,exe、dll、配置文件都在。
- 在部署机上直接命令行启动 exe,没有输出,也没报错。
- 打开 Windows 事件查看器,在应用程序日志里看到
无法找到 VCRUNTIME140.dll。罪魁祸首是部署机没有安装 Microsoft Visual C++ Redistributable。
这个坑非常典型。C++ 程序如果是动态链接到运行时库(/MD),目标机器就必须装对应版本的 VC++ 运行库。构建机上因为有 VS Build Tools,运行库齐全,所以编译和本地测试都正常;但干净的目标主机上没有,程序自然起不来。
我的处理方式是两种手段并用:内部工具优先在 CMake 里设置CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded,也就是静态链接运行库,这样 exe 不依赖 VC++ 运行库,拷过去就能跑;对外发布的程序则保留动态链接,但在部署脚本里增加一步检测,如果没有运行库就先静默安装对应的 Redistributable 包。检测运行库的小工具网上有现成的,自己用dumpbin /dependents也能查依赖。
5.3 制品被进程占用导致部署步骤失败
这个问题出现得很隐蔽。部署时报Copy-Item : 进程无法访问文件,因为另一个程序正在使用。理论上我们先停了服务,怎么会占用?
实际排查结果有几种可能:
- 服务停止了,但程序启动时又拉起了一个子进程,服务管理器和子进程没绑定。
- 程序通过 Windows 计划任务定时被唤起,不在服务管理器控制范围内。
- Windows Defender 实时扫描正在读 exe 文件,导致短暂锁定。
我当时把 Stop-Service 之后又加了Get-Process -Name myapp | Stop-Process -Force,并且等待几秒,就解决了一大半。对于杀毒软件锁文件,我把部署目录加入了 Defender 排除列表。注意别为了省事直接停掉 Defender,不值得。
另外,进程占用往往意味着程序里还有文件句柄没有释放。尤其 C++ 程序如果启动了后台线程写日志,日志文件一直打开,替换目录就会失败。所以部署脚本里的日志文件建议单独放在一个固定目录,不要跟程序目录混在一起。程序目录只管替换二进制文件,日志目录永远不动,问题一下少很多。
5.4 Webhook时灵时不灵,排查链路是怎样的
还有一次比较抓狂:GitLab 上点一下 push,流水线偶尔触发,偶尔不触发。查 Arbess 服务端日志,什么都没收到。
这个问题的根因是 GitLab 出于安全考虑,默认会阻止 Webhook 发送到本地网络地址。如果 GitLab 和 Arbess 部署在同一台内网机器,Webhook URL 是http://192.168.x.x:port或者http://127.0.0.1:port,GitLab 会把它当成 SSRF 风险直接拦掉。
排查链路:
- GitLab 项目 -> Settings -> Webhooks -> 找到对应 Webhook,点开 Recent deliveries,能看到每次投递记录和响应码。
- 如果一直显示 500 或者 connection failure,说明请求可能根本没到 Arbess。
- 查看 GitLab 管理后台的 Network -> Outbound requests 选项,把“Allow requests to the local network from webhooks and integrations”打开,再把 Arbess 地址加入白名单。
- 保存后再测,流水线马上就通了。
另一种“时灵时不灵”是 Secret Token 不一致。Arbess 生成的 token 和 GitLab Webhook 里填的如果对不上,GitLab 投递记录里会显示 403。我后来把 token 统一放在团队的密码管理工具里,避免每次配置都重新生成导致不一致。
6. 现在的固定玩法和还能继续扩展的事
这套体系稳定运行之后,我基本不需要再手动发布程序了。每次上线只需要做一件事:在 GitLab 上创建 Release Tag,剩下全自动。
6.1 我当前流水线的最终形态
以我们其中一个 Windows 服务为例,完整流程是:
- 开发推送代码到 develop 分支;
- GitLab 触发 Webhook,Arbess 开始跑构建+测试;
- 如果测试通过,流水线结束,不部署;
- 版本要发布时,维护者在 GitLab 上创建
v1.2.3的 Tag; - Arbess 收到 Tag 事件,跑完整流水线:构建 -> 测试 -> 制品归档 -> 部署机执行 deploy.ps1 -> 重启服务 -> 健康检查 -> 钉钉通知;
- 如果任一步骤失败,流水线停止,钉钉群里收到失败详情和日志链接。
这套流程最大的价值不是“快”,而是稳定。人可能会忘步骤,机器不会。
6.2 给C++项目加质量门槛的扩展思路
自动化跑顺之后,我下一个阶段做的是加质量门槛,而不是继续加功能。
第一个是静态代码分析。在构建阶段里加一步 Cppcheck,扫描新提交的代码有没有明显的空指针、数组越界、资源泄漏。这一步不阻断发布,只把报告存档并通知到群里。刚开始报告里会有一些历史遗留问题,我会在 Cppcheck 配置里对老文件加抑制,只关注增量问题。
第二个是单元测试覆盖率。C++ 项目做覆盖率比 Java、Python 麻烦,工具链不统一。我们用的是 OpenCppCoverage,测试阶段跑完之后生成覆盖率报告,低于某个阈值就不允许进入部署阶段。这个门槛刚开始设得很低,比如 60%,后面慢慢往上抬。
第三个是制品自动清理。Arbess 制品目录只保留最近 20 次,旧的自动删,避免磁盘被占满。部署机上的备份目录保留最近 5 份,再多也会清理。这个不写脚本的话,半年之后真的会占几个 GB 的空间。
6.3 几点个人体会
如果你也准备给 C++ 项目上自动化构建,我的建议是:先别急着把测试、覆盖率、通知全堆上去,先把最简单的“push 代码 -> 自动编译 -> 自动打包 -> 自动部署 -> 自动重启”这条链路跑通,哪怕前期只有一个编译脚本也行。链路通了,后面加什么都是增量改进;链路不通,配置再花哨也是白搭。
另外,自动化环境本身也要纳入维护。我每个月会在构建机上手动执行一次完整构建,确认工具链没有因为系统更新或者软件自动升级出问题。这个动作不花时间,但能预防那种“流水线前一天还好好的,第二天莫名其妙失败”的情况。
最后想提醒一点:不要把部署脚本写成一次性脚本。它应该像代码一样有版本、有注释、有测试。我们后来把 deploy.ps1 也放进了 GitLab 仓库,谁改了部署逻辑都有记录,方便回溯。这套环境能长期稳定跑下来,靠的不是某一个工具,而是所有细节都有人盯着。