聊到持续集成,很多团队第一反应是Jenkins,第二反应是GitLab CI。但我这几年在几个不同规模的团队里实践下来的感受是,如果项目本身准备深度使用Azure生态,或者团队已经是.NET、Java、Python混着写,那Azure DevOps的Pipeline其实是被很多人低估的那一个。
Azure DevOps不仅仅是一个CI工具,它把代码仓库、工作项、构建产物、测试计划和发布管道全都串在同一个平台里。最舒服的地方不在于某一个环节多强,而是端到端的时候,你不需要在系统之间来回切换。一个PR从创建到合并,再到自动拉起构建、跑测试、产出制品,整个过程可以被一条流水线完整承载。而这里的“持续集成”,是你把代码合并到主干之后,能够快速、自动、可重复地验证代码是否健康的关键一环。
这篇博文是系列的第一篇,打算从零讲起:为什么要用Azure DevOps做CI,环境怎么搭,第一条流水线怎么写,遇到过哪些必须避开的坑。适合正在选型CI工具、或者刚接触Azure DevOps却不知道从哪里下手的团队和技术同学。看完之后,你至少能亲手跑通一条真正在用的管道,而不是停留在概念层面。
1. 为什么我会在团队里推Azure DevOps做CI
1.1 它真正解决的痛点
先聊一个更本质的问题:持续集成到底在解决什么?
最直白的答案是,不让问题在晚些时候集中爆发。在没有CI的团队里,开发者各自在本地跑通了模块,合并到主干之后才发现接口签名对不上、某个依赖没有签入、测试在别人的机器上跑不过。这些东西凑到一起时,排查成本会成倍上升。持续集成做的事情,就是把“能否合并、能否运行、能否交付”这些反馈从几天缩短到几分钟,让每一次代码变更都在受控的验证环境中跑一遍。
Azure DevOps Pipeline在解决这个问题时,比传统工具更顺手的一点是:它与工作项、PR、分支策略天然联动。比如我在配置PR策略时,可以直接要求“持续集成构建必须先通过才能合并”。这个策略在管理后台就能配置,不需要额外写一堆钩子脚本。对于规范工程流程来说,这比单纯在CI里跑任务要管用得多,因为它把质量门禁嵌入了团队日常的开发节奏。
另一个痛点是环境的标准化。本地开发环境千差万别,而托管代理(Microsoft-hosted agent)提供一个干净、预装了常见工具的虚拟机来跑构建。无论谁提交代码,每一次构建都从同一个基线开始。这省掉了很多“在我机器上是好的”这类沟通成本。
1.2 什么时候适合选它,什么时候慎选
Azure DevOps适合的团队,按照我的观察,基本有这三个特征:
- 重度使用微软体系,比如Windows/.NET/Visual Studio、Office、SharePoint等周边,或者云端已经落在Azure。
- 需要在一个系统内完成需求、代码、构建、发布的闭环,不想把需求管理系统、代码仓库、CI工具拆成三四个平台去维护。
- 团队已有一定的Azure AD/企业账号管理基础,权限可控。
不适合的情况也有。比如团队整个技术栈都在容器化和完整云原生的道路上前进,且对开源工具生态极度依赖,那GitLab CI或者GitHub Actions可能更容易契合一些。Azure DevOps也有容器支持,但切换到云原生体验时,中间会有不少需要自己缝合的地方。另外,如果团队成员对YAML、流水线概念完全陌生,又没有时间投入学习,那无论选什么工具都会痛苦。工具选型这件事,从来不是单纯比功能,而是比团队愿意付出多少学习成本。
2. 从零搭建CI环境
2.1 创建组织、项目,并接好代码仓库
第一步是注册一个Azure DevOps组织。如果你过去用Visual Studio或者Azure账号登录过,可能直接用账号就能创建。组织名会成为访问地址的一部分,建议想好再填,因为后续改起来比较麻烦。
组织下面创建Project时,有一个visibility选项:public还是private。企业项目肯定选private,除非你刻意要做开源。创建好项目之后,Azure DevOps会提供五种服务的默认启用状态:Repos、Pipelines、Boards、Test Plans、Artifacts。如果只做CI,至少要把Pipelines保留,Repos如果代码已经放在别处也可以关闭,后续用服务连接的方式关联外部仓库。
把代码接进来有两种主要方式:直接使用Azure Repos,或者连接GitHub、Bitbucket等外部Git仓库。使用Azure Repos最省事,因为同一个项目内,流水线读取代码不需要额外配置凭证;使用外部仓库时,需要创建Service Connection来授权。我的建议是,如果是从零起步的团队,直接用Azure Repos就好;如果代码已经在GitHub上沉淀了很久,就通过Service Connection连接,别搬家。
Service Connection是个容易被忽略但很重要的概念。你可以把它理解成一把受管制的钥匙,让Azure DevOps代表你去访问外部资源。创建时需要选择连接类型,填入目标仓库地址和一个有权限的令牌。令牌过期是常见问题,建议创建连接的时候就把访问范围控制到最小,并在运维日历里给令牌设置一个过期提醒。
2.2 托管代理还是自托管代理
Pipeline要跑在“代理”上。这个名词初看难懂,实际上它就是一台执行构建任务的机器,或者在云端,或者在你自己的服务器上。
托管代理的好处是零维护。系统为我们预配置了常见工具,比如最新版本的Node.js、Python、.NET SDK、Java等。对于大多数标准技术栈的CI流水线,用托管代理是最快上手的方案。它按分钟计费,每个账号在一个月内有一定量的免费额度,对于小团队或者个人练手完全够用。
自托管代理适合需要控制网络环境、构建时长较长、有特殊工具链的团队。常见理由包括:构建过程依赖公司内网资源、需要复用大量本地缓存以减少恢复依赖的时间、或者构建任务对机器配置有特殊要求,比如需要高性能GPU。
我的实际建议是第一优先用托管代理。如果跑了几个星期之后发现每次构建都好几分钟都花在恢复依赖或者在特定网络上传下载包,再考虑自托管。不要一上来就搭,避免把维护成本带进门。
2.3 自托管代理的安装与配置
如果确实要搭自托管代理,流程不复杂,但有几个细节要留心。
去组织设置的Agent Pools页面新建一个池,然后下载代理安装包。Windows和Linux的安装流程不太一样,核心步骤是解压之后执行配置脚本。
以Linux为例:
mkdir myagent && cd myagent tar zxvf vsts-agent-linux-x64-*.tar.gz ./config.sh配置过程中会让你输入Server URL、Personal Access Token和代理池名称。这里要注意,PAT必须设置足够的权限范围,至少要包含Agent Pools(读/管理)的权限。授权范围选择Project Collection级别即可,不必给到整个组织所有权限。
另外有两点经验:
- 自托管代理机器必须能访问目标代码仓库和需要下载依赖的远程源。否则跑起来以后,代理会卡在网络请求上,让整个流水线非常慢。
- 如果代理挂在公司内网,请确保出网策略允许访问Azure DevOps服务。很多团队第一次搭自托管代理,都会在代理无法通信这个问题上折腾半天。
配置完代理后,建议先用一个最小流水线验证连通性。不要着急把所有任务都放上去,否则出问题时要排查的范围会很大。
3. 写出第一条真正能用的Pipeline
3.1 我为什么推荐直接用YAML
Azure DevOps提供了两种定义流水线的方式:经典编辑器和YAML。
经典编辑器用图形界面拖拽任务,适合完全不想碰代码的团队,但是缺点很明显:配置不容易评审,改动历史不透明,复杂场景下点鼠标的效率远低于写YAML。我自己的判断是,只要团队里任何一个人愿意花半天时间,YAML就是更值得走的路线。因为流水线本身也是代码,可以被评审、可版本化、可复用。
YAML流水线的定义放在仓库里,路径通常是azure-pipelines.yml。每次修改这个文件,提交到仓库之后,对应的流水线就会根据新的定义运行。这带来一个管理优势:流水线的任何变更都有迹可循,出问题可以回滚和对比。
需要说明的是,YAML语法看起来简单,实际在Azure DevOps里有一套自己的表达式语言。很多第一次接触的人会在条件、变量、模板这几个地方栽跟头。我们接下来把最基础的部分拆透。
3.2 YAML流水线的核心结构拆解
一个最基本的YAML流水线可以分为这几个层次的配置:触发条件、代理池、变量、阶段/作业、步骤/任务。
先看一个最简示例:
trigger: - main pool: vmImage: 'ubuntu-latest' steps: - script: echo "Hello, Azure DevOps!"这个例子里:
trigger指定在提交到哪个分支时触发构建,这里是main。pool指定运行代理池,vmImage: ubuntu-latest表示使用微软托管代理的Ubuntu环境。steps是执行步骤列表,这里是执行一段shell命令。
看起来很简单,但它是理解整个流水线的起点。实际项目里,steps会变成多个task,每个task负责一件具体的事情,比如还原依赖、编译、跑测试、发布产物。
一个比较典型的.NET项目流水线长这样:
trigger: - main pool: vmImage: 'windows-latest' variables: solution: '**/*.sln' buildPlatform: 'Any CPU' buildConfiguration: 'Release' steps: - task: NuGetToolInstaller@1 - task: NuGetCommand@2 inputs: restoreSolution: '$(solution)' - task: VSBuild@1 inputs: solution: '$(solution)' platform: '$(buildPlatform)' configuration: '$(buildConfiguration)' - task: PublishBuildArtifacts@1 inputs: PathtoPublish: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'drop'这里有几个值得展开的地方。
variables区段定义变量,后续引用时用$(变量名)。如果把编译配置、平台架构、解决方案路径抽成变量,换分支或者要扩构建矩阵时,就不需要改一堆任务配置,只改变量值就行。这是最基础的工程化意识。
task是内置的构建任务。每个task通过名称@版本号来引用。不过要注意,同名task在不同版本下,输入参数可能不同。比如NuGetCommand@2和VSBuild@1,参数结构差异很大。写YAML之前,最好先查一下当前版本对应的参数说明,照搬旧示例很容易遇到“任务接受这个输入吗”的报错。
PublishBuildArtifacts@1的作用是把指定目录上传为构建产物。$(Build.ArtifactStagingDirectory)是系统提供的一个临时目录,专门用来暂存构建产出。制品可以供后续阶段下载,也可以直接推到制品仓库。
3.3 变量、触发、条件和依赖控制
入门之后,你需要掌握四个高频功能:分支过滤触发、定时触发、路径过滤、条件执行。
分支过滤触发和路径过滤举个例子。有些团队仓库里同时有代码和文档目录,文档更新就没必要触发CI。可以这样配置:
trigger: branches: include: - main - release/* paths: exclude: - docs/*这样main分支和release分支下的变更会触发构建,同时排除docs目录里的变更。这个做法对于项目初期很受用,可以减少无意义的构建。但别滥用路径过滤,因为一旦过滤条件写得太激进,会出现代码改动没触发构建,把问题拖到集成阶段才发现的情况。建议只过滤明确无代码变化的路径。
条件执行在日常构建里也很常见,比如只想在PR触发的构建里跑静态分析,不想每次主干构建都跑。可以这样:
steps: - task: PowerShell@2 condition: eq(variables['Build.Reason'], 'PullRequest') inputs: targetType: 'inline' script: | Write-Host "Only execute in PR builds"Build.Reason是系统预定义变量,值可能是IndividualCI、BatchedCI、PullRequest等。用eq()表达式做条件判断时,最容易犯的错是字符串大小写不一致。别写成Pullrequest,条件会一直为false且不报错。
依赖控制主要涉及多Stage和分析任务。一个流程可能是:构建阶段 → 测试阶段 → 部署验证阶段。Stage之间可以有依赖关系,后一个stage默认等待前一个成功结束。如果想要更细致的控制,可以写:
stages: - stage: Build jobs: - job: BuildJob steps: - script: echo "Building..." - stage: Test dependsOn: Build jobs: - job: TestJob steps: - script: echo "Testing..."依赖控制对于初学阶段来说不是必须的,但理解了dependsOn之后,你才能把复杂流水线切成多个可独立判断的阶段。
3.4 缓存与制品保存,别让每次构建都从零开始
托管代理的问题在于每次分配的机器都是新的,这会导致一个痛点:每次构建都要重新下载依赖包。如果项目依赖多,几分钟时间就浪费在恢复依赖上了。
解决方法之一是开启流水线缓存。Azure DevOps支持任务级别的缓存,可以把某个目录在构建结束后保存起来,下次构建时直接恢复。例如.NET的NuGet缓存:
steps: - task: Cache@2 inputs: key: 'nuget | "$(Agent.OS)" | $(Build.SourcesDirectory)/**/packages.lock.json' path: $(NUGET_PACKAGES) restoreKeys: | nuget | "$(Agent.OS)" displayName: 'Cache NuGet packages'这里key不要配置得太宽泛。最好是基于锁文件等信息来生成,锁文件变化时缓存自然失效。如果key写得过于粗糙,比如只有操作系统类型,那会出现一种尴尬:新依赖跟缓存不对版,但对于代理而言又命中缓存,最后构建出来的结果不是预期的。
制品保存方面,刚刚提到了PublishBuildArtifacts@1。除了这种经典的构建产物目录,还有另一种选择是发布到Azure Artifacts的Feed,变成带版本号的包。两者定位不同:Build Artifacts适合提供一次构建的原始输出,Artifacts Feed适合管理可被多个项目复用的软件包。持续集成阶段的产物,我建议先保存为Build Artifacts,后续发布管道再从其中下载分发。如果你发现同一个制品要被多个流水线或团队引用,再考虑推Feed。
4. 我踩过的坑和排查思路
4.1 托管代理常见故障速查
用托管代理时,最常遇到的几类问题我很清楚。整理成了一张表方便查阅:
| 现象 | 原因分析 | 处理办法 |
|---|---|---|
| 构建一直卡在“Queued”状态 | 并发额度用完,或代理池被业务高峰期占满 | 查看限额;可临时切换较小的vmImage来缩短排队 |
| 拉取代码失败 | 服务连接凭据失效 | 重新在Service Connection中更新令牌 |
| 任务明明有输出却整体失败 | 部分task会在非零退出码时判定失败 | 检查脚本退出码,可能需要在脚本尾部显式处理错误 |
| 构建速度越来越慢 | 依赖版本范围过宽或缓存配置失效 | 使用锁文件;检查Cache@2的key是否稳定 |
| 下载依赖超时 | 网络策略限制或远端源不稳定 | 换源;自托管代理并配置内部源缓存 |
这里最值得展开的是“卡在Queued”这个问题。很多人第一次遇到,以为是Azure DevOps故障了,实际上多数是并行任务额度耗尽。免费账号默认只有一定数量的并行度。你可以去组织设置里的Billing和Parallel jobs页面查看。如果额度不够,也要考虑把构建拆得更细,减少一个流水线里跑太多冗余步骤。
4.2 YAML里“对但没效果”的常见写法
有一种报错很麻烦,那就是YAML语法合法性没问题,运行时却完全没有达到预期。这类问题在CI里最隐蔽。举几个我实际遇到过的例子。
第一个是trigger写错了层级。比如把trigger写成:
trigger: - main steps:这个写法在恢复versioning之后确实能通过,但如果你还同时定义了pr触发,那么PR合并后可能触发两次构建。问题不在于触发两次本身,而是团队策略里对“什么时候跑CI”会产生混乱。另一个常见错误是:
trigger: branches: include: - 'main'这段看起来正确,但如果我在另一个分支提交了新文件,而这个分支前缀是feature/,又没有显式include,那么构建确实不会触发。这就是预期内行为,但很多新手把这个理解成“为什么只有main能构建”。所以要解释一下:trigger默认是任何分支都触发,还是必须包含该分支?实际上,在未指定branches.include时,默认包含所有分支;一旦指定了include,就只触发这些分支的构建。所以“代码推送后没触发CI”的第一步,永远是检查这个配置是否符合直觉。
第二个是“变量覆盖”问题。命令行的变量覆盖优先级高于YAML里的变量。假如你在UI界面的“Variables”里设置了同一个名字,但值不同,那么YAML里写的值会被UI值覆盖。这个优先级是:变量组 → YAML变量 → UI变量?不对,准确说在Azure DevOps里,UI定义变量的优先级高于YAML中的同名变量。于是会出现一种很恶心的情况:明明YAML里写着buildConfiguration: Debug,构建日志里却是Release,怎么改YAML都没用,最后发现是管道编辑页面里还有一个同名变量。排查时一定要检查所有可能定义同一变量的地方。
第三个是任务版本导致参数变化。前面提到过YAML里引用的task是带版本的,比如DotNetCoreCLI@2。如果之前的代码示例是基于旧版任务的参数写的,直接复制粘贴到新版任务上可能不生效。比如有些任务的command参数从字符串变成枚举让你疑惑,还有人写过把inputs里的字段名改了十几分钟排查不出来。最靠谱的办法是不要盲写,在流水线编辑页面里选择“YAML”模式,打开对应的任务文档,或者在本地写完之后先用“验证”功能做一次dry-run,但那个验证更多是语法检查而不是业务正确性。
4.3 自托管代理的维护心得
如果你最终选择了自托管代理,分享几个心得。
第一,不要把代理装在共享开发机上。有人觉得省事,把agent装在自己日常开发的笔记本上,这样每次构建都在自己机器上跑。短期看很爽,长期看极其危险:构建过程会和开发环境互相污染,某个本地环境变量影响了构建结果,你就完全失去可复现性。
第二,定期更新代理版本。Agent本身的更新是自动的,但代理主机的安全补丁、工具链版本,需要团队主动维护。托管代理不操心这些事,自托管则是“你拥有环境,同时也得负责环境”。
第三,代理池建议按用途拆分。比如把.NET构建代理和前端构建代理分开,或者把Release环境的部署代理和Daily Build代理分开。这样某类构建并发激增时,不会挤占关键发布链路上的资源。不要一个池子装一切,太容易互相拖累。
5. 从第一条流水线到常用工程实践
5.1 分支策略与CI的配合
CI的效果很大程度取决于分支策略。Azure DevOps里比较经典的做法是:
- main分支是最主要的集成和发布基线。
- 开发分支从main切出去,功能完成后提PR。
- PR策略里打开“持续集成”要求,让PR过程中自动跑一次CI。
这样CI就不只是对主干提交做验证,而是在代码合并前就进行验证。它的最大意义在于,把明显的集成问题挡在合并之前。实际操作中,如果你的Pipeline特别长,PR触发的构建会影响开发效率,那么可以考虑让PR构建只跑快速检查和增量测试,而merge到主干后再跑完整流水线。这个“双轨”思路目前很多团队在用。
5.2 把常用配置沉淀成模板
当项目多起来之后,你会发现很多流水线之间大量重复。这时就该接触模板了。Azure DevOps的YAML模板有两种:模板文件可以被其他YAML文件引入。
比如建立一个templates/ci-steps.yml:
parameters: buildConfiguration: 'Release' steps: - task: DotNetCoreCLI@2 inputs: command: 'restore' - task: DotNetCoreCLI@2 inputs: command: 'build' arguments: '--configuration ${{ parameters.buildConfiguration }}'然后在具体流水线里引入:
steps: - template: templates/ci-steps.yml parameters: buildConfiguration: 'Debug'模板的好处是减少复制粘贴。但有一个代价:间接层多了之后,流水线的阅读成本会上升。所以我建议在团队人数不多、管道数量不超过10条时,先别过度模板化。先把一些明显重复的步骤抽出来就好,等数量膨胀了再做全量模板化。
5.3 从CI延伸到交付闭环
一篇标题是“持续集成(一)”的博文,最终还是要给后续内容留一个口子。基础CI跑通以后,最自然的扩展方向是:
- 增加质量门禁:跑单元测试覆盖率,低于阈值直接失败。
- 增加制品版本化:把构建号嵌入制品名,让每个包都可追溯。
- 增加发布流水线:把CI产出的制品部署到测试环境或生产环境。
- 增加环境审批:部署到某些环境前,必须有指定人员审批。
Azure DevOps里这些都是Pipelines同一套系统可以完成的。CI跑得越稳,后面做CD(持续交付)就越省力。
实际做的时候,我个人建议永远先追求“快”而不是“全”。CI反馈时间建议控制在10分钟以内,超过这个时间的流水线会让开发者下意识忽略构建结果。先把构建环境、依赖恢复、缓存这些基础问题弄好,再往上堆各种检查。很多团队CI最终沦为形式,不是因为工具不行,而是因为构建太慢,导致大家不再看构建结果。
5.4 关于持续集成的一个容易被忽略的认知
聊回本质。持续集成不是一个工具功能,而是一个团队习惯。Azure DevOps只是把这个习惯用平台固定下来。工具能帮你把每次提交的验证自动化,但无法替团队定义“什么样的提交算是健康”。你必须自己摸索出质量基线:哪些告警级别会导致构建失败,哪些测试是必须跑的核心集,哪个阶段的制品才是可以交付的版本。
我见过不少团队,流水线配得很豪华,却因为构建太容易被“看一眼”就觉得没事,最终退化成摆设。与其这样,不如一开始就少配几个门禁,但让每一个门禁都被认真对待。宁可只有“编译通过+单元测试通过”,也要确保每次合并失败都有人立即跟进。工程实践从来都是先窄后宽,一步一脚印。
这篇文章讲到的内容,基本就是我从零搭建Azure DevOps CI时的完整记录。照着走一遍,你就能把“持续集成”这个概念落到实处。下一篇我会继续拆解自托管代理的细节,以及如何把构建产物推送到自己的私有源,让团队内部的包复用真正跑起来。先写到这,欢迎在实际操作中遇到问题时回头对照着排查。