ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Azure DevOps持续集成入门:用YAML构建自动化流水线

Azure DevOps持续集成入门:用YAML构建自动化流水线

1. 先把话说清楚:Azure DevOps 持续集成到底是干什么的

1.1 一个让人抓狂的手动打包日

先讲个场景。假设你所在的小团队还停留在“谁改完代码谁自己打包”的阶段:A 同学改了两个接口,本地编译通过,直接复制文件发给测试;B 同学又改了一个页面样式,同样手动打了一个包丢到共享文件夹;C 同学负责数据库脚本,把最新的变更压缩成 zip 发到群里。测试同事每天要确认“最新版本是谁的”“这个包是哪一天的”“配置连的是哪套环境”,一问一个不吱声。到了上线前一天,大家把所有手动合并的产物凑一起,结果某个 DLL 版本不对,页面一打开就报错,谁也说不清问题出在哪一步。

这样的日子我经历过不止一次,所以第一次接触 Azure DevOps 持续集成(CI)时,最大的感受不是“这个工具功能真多”,而是“终于可以把人从重复劳动里解放出来了”。Azure DevOps 是微软出的一套 DevOps 平台,持续集成(Continuous Integration,CI)是它最核心的能力之一:每次你提交代码到仓库,服务器自动拉取最新代码,自动还原依赖、编译、跑测试、打包,最后把构建产物(Artifact)存到一个统一的位置。整个过程不需要任何人手动参与,谁提交了代码、构建是成功还是失败、失败在哪一步、产物存在哪,全都有据可查。

1.2 持续集成解决的核心问题

持续集成这个词听起来有点大,本质就一句话:让代码在仓库里始终保持“可构建、可测试、可交付”的状态。手动打包最大的隐患不是累,而是“不可复现”。你本地能编译,不代表同事拉了最新代码也能编译;你本地装了某个依赖,不代表 CI 机器上也有;你记得要跑一下某个数据库迁移脚本,换个心情就忘了。CI 把这一整套动作固化到流水线里,每次构建都在一个干净的环境里重新来一遍,谁也别想“我机器上明明是好的”。

这是第一层价值。第二层价值是反馈速度。改动一行代码,提交后几分钟内就能看到编译和单测结果,而不是等到测试同学手动拿包测了半天才发现基础功能是坏的。第三层价值是追溯能力。Pipeline 里每一次构建都有编号,有日志,有产物,出问题时可以直接翻出对应提交记录和构建日志,定位出是哪一次提交引入了问题。这三层价值叠在一起,才真正改变了团队的协作方式。

1.3 为什么选 Azure DevOps 而不是 Jenkins

我知道很多人第一反应是:用 Jenkins 不好吗?我自己也用过 Jenkins,不是说它不行,而是两者在“维护成本”上的差別太明显了。Jenkins 需要你自己准备一台服务器(或容器),安装环境、配置插件、管理凭据、处理高可用。这一套下来,少说也要半天时间。Azure DevOps 的托管代理(Microsoft-hosted agent)开箱即用,你只管写流水线,代理环境由平台维护,用完了自动销毁,不需要你半夜爬起来处理构建机磁盘满的问题。

另外一个现实因素是,如果团队本身就在用微软生态——比如代码已经托管在 Azure Repos,工作项在用 Azure Boards,需求、缺陷和代码提交天然关联。就算不用微软生态,它也支持 GitHub、GitLab 甚至 Bitbucket 仓库,兼容性比你想象中要宽。所以我的建议是:小团队、想快速搭起 CI 的,优先看 Azure DevOps;已经有专门的运维团队和 Jenkins 基建的,才需要考虑迁移成本。这篇是“初步”,重点先把 Azure DevOps 本身的套路讲清楚。

2. 入门前的概念扫盲:这几个名词搞不懂会一直绕路

2.1 Organization、Project、Pipeline 的层级关系

Azure DevOps 第一眼看上去有点“乱”,其实层级非常清晰。最顶层是 Organization(组织),它是一个独立的租户空间,按企业或团队划分,网址通常是dev.azure.com/你的组织名。Organization 里面可以建多个 Project(项目),每个 Project 就是一个独立的工作空间,包含代码仓库、工作项、流水线、测试计划和制品库。Pipeline(流水线)则属于某个 Project,描述的是从代码到构建产物的一整套动作。

这个层级关系重要在哪呢?权限和隔离。不同项目之间的 Pipeline、仓库、变量组默认是隔离的,比如你不想让 A 项目的人看到 B 项目的构建日志,只要把他们的权限控制在项目层面就行。还有个容易忽略的点:Agent Pool(代理池)是组织层面的资源,也就是同一个组织下的多个项目可以共用一组构建代理,但代理池的权限又可以细化到具体项目,非常灵活。理解了这个结构,后面配置权限和查找资源时就不会一头雾水。

2.2 代理池选择:托管代理还是自建代理

Pipeline 的执行需要一台“代理人”(Agent)来跑任务。Azure DevOps 提供两种模式:Microsoft-hosted agents(托管代理)和 self-hosted agents(自建代理)。托管代理是平台自动分配的虚拟机,常见的 Windows、Ubuntu、macOS 镜像都有,内置了几十种常用工具(.NET SDK、Node.js、Python、Java、Docker CLI 等),你用的时候只需要在 YAML 里写一句pool: vmImage: 'ubuntu-latest',剩下的不用操心。构建结束后虚拟机自动销毁,环境每次都从干净状态开始,彻底杜绝“上一次构建留下的文件影响这一次结果”的问题。

自建代理则需要你自己准备一台机器(物理机或虚机),下载 agent 安装包,配置 PAT 令牌让它注册到组织。好处是可以用你自己定制好的环境、走内网访问公司内部依赖源,构建速度往往也更快,因为不需要每次重新下载工具。缺点是维护成本上来了:补丁、更新、容量规划,全都要管。

我的建议很简单:刚开始学、项目不大,先用托管代理跑通流程;等团队真正遇到“构建时间太长”“需要访问内网私有源”这些刚需,再上自建代理。不要一上来就折腾自建代理,那会把你的注意力从“流水线本身”拉到“维护一台构建机”上去。

2.3 服务连接:给 Pipeline 发一张“通行证”

Pipeline 要干活,往往需要访问各种外部资源:拉取 GitHub 仓库的代码、推送到容器注册表、发布到 Azure 应用服务、读取某个云存储上的文件。这些动作需要凭据。Azure DevOps 里用 Service Connection(服务连接)统一管理这类凭据,比如你用 GitHub 作为代码源,创建 Pipeline 时选择“GitHub”,然后授权一次,Azure DevOps 就会拿到一个访问令牌,之后流水线拉代码就不再需要你每次输账号密码。

这里有一个新手容易踩的坑:服务连接是 Azure DevOps 用来访问外部资源用的,而 Pipeline 运行时使用的“权限”是另一回事。比如你 YAML 里的某个任务要调用 Azure CLI,那流水线的服务主体(Service Principal)需要有对应的订阅权限,这不是在仓库设置里点两下就能搞定的,需要在云平台的服务主体访问控制里配置。我见过不少团队卡在这一步,现象是“流水线跑到了第三步就报权限不足”,查了半天发现是服务连接的授权范围不对。

3. 第一个 Pipeline 实战:从零到构建产物落地

3.1 动手前先准备这三样东西

开始写第一个 Pipeline 之前,不需要把整个平台的功能都搞懂,但有三样东西必须就位。第一,一个代码仓库:可以是 Azure Repos、GitHub 或 GitLab,里面至少有一个你能构建的项目。第二,一个明确的项目类型:比如 .NET Core、Node.js、Java 或 Python,因为你选的类型决定了 YAML 里该用哪些任务(tasks)。第三,访问权限:你需要是 Project 的成员,并且有权限创建 Pipeline。对个人学习来说,直接开一个免费层账号,注册到自己的组织就行,免费额度跑个人项目完全够用。

准备好这三样后,先在 Portal 上走一遍向导会更踏实。进到项目里的 Pipelines 页面,点“New pipeline”,选择代码源,这时候如果你用的是 GitHub,会弹出授权窗口;然后 Azure DevOps 会根据仓库根目录自动检测语言,生成一份初始 YAML。很多人这时候就直接点“Run”了,我建议你把向导当成一个“脚手架”,不要直接跑。先把生成的 YAML 打开,逐行读一遍,摸清楚它做了什么,再决定改什么,这对后续学习大有帮助。

3.2 第一份 YAML 模板逐行拆解

下面给一份非常标准的 .NET 项目 YAML,适合作为第一份“抄作业”模板。我加了一些注释,方便你理解每一段的意图。

# 触发条件:main 分支有推送时自动触发 trigger: - main # 使用托管代理,Ubuntu 最新 LTS pool: vmImage: 'ubuntu-latest' # 定义变量,后续通过 $(变量名) 引用 variables: buildConfiguration: 'Release' # 真正干活的部分,一个 step 就是一条指令 steps: - task: UseDotNet@2 displayName: '安装 .NET SDK' inputs: packageType: 'sdk' version: '6.x' - task: DotNetCoreCLI@2 displayName: '还原 NuGet 依赖' inputs: command: 'restore' projects: '**/*.csproj' - task: DotNetCoreCLI@2 displayName: '编译项目' inputs: command: 'build' projects: '**/*.csproj' arguments: '--configuration $(buildConfiguration)' - task: DotNetCoreCLI@2 displayName: '运行单元测试' inputs: command: 'test' projects: '**/*.csproj' arguments: '--configuration $(buildConfiguration)' - task: DotNetCoreCLI@2 displayName: '发布产物' inputs: command: 'publish' publishWebProjects: false projects: '**/*.csproj' arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)' - task: PublishBuildArtifacts@1 displayName: '上传构建产物' inputs: PathtoPublish: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'drop' publishLocation: 'Container'

这份 YAML 解决的是“从提交代码到拿到构建产物”的最小闭环。trigger定义了自动触发的分支,实际项目中往往还要加 PR 触发(pr:)和路径过滤;pool指定了代理环境,想要 Windows 就把vmImage换成windows-latest;variables是给整个流水线用的全局变量,后面$(buildConfiguration)会被替换为Release;关键路径藏在 Publish 这一步,$(Build.ArtifactStagingDirectory)是 Azure DevOps 预定义变量,表示发布阶段的标准输出目录,最后一步 PublishBuildArtifacts 会把整个目录打成名为drop的构建产物。这里“还原→编译→测试→发布→上传产物”的顺序是固定的,不能乱来,就像盖房子得先打地基再砌墙。

3.3 经典编辑器 vs YAML:我劝你选 YAML

Azure DevOps 支持两种编写流水线的方式:经典编辑器(Classic Editor)和 YAML。经典编辑器是在网页上拖拽任务、填表单,看起来对新手友好,但我真不建议你在新项目里用。原因很实际:经典编辑器的配置是“存在服务端”的,没法跟着代码走,就没法做代码评审。一个团队里如果有人手滑在网页上改了一个任务参数,这个改动的痕迹不会出现在代码提交记录里,出了问题你根本不知道是谁改的、为什么改。

YAML 流水线的配置文件就放在仓库里,比如azure-pipelines.yml,每次对流水线的改动就是一次正常的提交,有 diff、有评审、有历史记录。这是工程规范层面的大优势。至于学习曲线,YAML 的语法极其简单,核心就是键值对和列表。你只要记住:steps下面每一项是一个任务,任务里的inputs是它的参数,缩进用空格且必须一致。只要不写成 Tab,基本就成功了一半。

3.4 构建产物(Artifact)怎么收场

很多新手第一次跑通 Pipeline,看到任务全绿就以为完事了,结果发现“产物去哪了”不知道。产物路径在日志里是可见的,但更常用的方式是用 PublishBuildArtifacts 把产物上传到 Azure DevOps 的容器里。上传之后,你可以直接在那个构建记录的“Artifacts”标签页里下载。如果你用的是 YAML 示例里的ArtifactName: 'drop',那就是构建记录页面里名为 drop 的文件夹,里面放着 publish 出来的所有文件。

这个“上传产物”的步骤是后续进入持续交付(CD)的接口。Release Pipeline 可以从某个构建记录拉取 Artifact,再部署到测试环境或生产环境。所以你在初步阶段就要养成习惯:构建不只负责编译,还要把可部署的产物打包好、命名好、上传好。命名规范建议遵循项目名-日期-构建编号这类可辨识的模式,不要叫发布包最终版v2(3).zip,这种东西会害死未来的你。

4. 从“能跑”到“好用”:持续集成进阶打磨

4.1 变量、变量组和预定义变量

YAML 里的variables是最基础的变量定义方式,适合写一些不会经常变的配置,比如构建配置是 Release 还是 Debug、输出目录叫什么都行。但项目复杂之后,你会发现有些变量要在多个流水线之间共享,比如连接字符串、API 地址、版本号规则。这时候就该用变量组(Variable Groups)。变量组是在“Pipelines → Library”里创建的,可以被多个流水线引用,支持标记为“机密变量”,把真正的密码密钥放在变量组里,设成 Secret,大家在页面上看到的只有掩码后的内容,日志里也不会打印明文。

还有一种变量是 Azure DevOps 自动提供的,叫预定义变量(Predefined variables),直接用就行,不需要也不能重新赋值。最常用的几个:$(Build.BuildId)是唯一的构建编号;$(Build.SourceVersion)是本次触发构建的 commit SHA;$(Build.ArtifactStagingDirectory)是产物暂存目录;$(Agent.OS)是当前代理的操作系统。我经常用$(Build.BuildId)作为版本号的一部分,比如把 NuGet 包的版本设为1.0.0-$(Build.BuildId),这样每一个包都有唯一版本,不会出现重复覆盖的诡异问题。

4.2 分支策略与触发条件

trigger默认的含义是“指定分支的 Push 会触发构建”。如果你的仓库有 main、develop、feature 三种分支,只想在 main 和 develop 上触发完整构建,可以写:

trigger: branches: include: - main - develop

还可以做路径过滤:某个目录下的文件改动不需要触发整个构建,比如docs/目录。用paths配置即可。pr是另一个高频配置,它控制的是“当有人向目标分支提交 Pull Request 时,是否触发验证”。这一步尤其适合做 PR Check,比如某个 PR 只要往 main 合并,就必须先通过构建和测试,否则不允许 merge。这个能力配合分支保护策略,能很大程度避免烂代码合并进主干。

我说个常见误区:很多人把 trigger 和 pr 混在一起,实际上在 GitHub 场景下,PR 的验证默认是由 PR 配置控制的,如果没有单独配pr,Azure Pipelines 会使用 trigger 的配置来决定 PR 构建行为,行为可能和你预期不一样。建议一开始就写清楚 trigger 和 pr 两段,哪怕内容一样,也好过让它“自动推断”。

4.3 缓存依赖,把构建时间降下来

托管代理每次都是干净环境,有好处也有坏处——坏处就是每次构建都要重新下载所有依赖包。一个 .NET 项目如果引用了一百多个 NuGet 包,restore 可能要花几分钟;前端项目光一个 node_modules 就够你等的。Azure DevOps 提供了 Cache 任务,可以把依赖目录缓存起来,下次构建直接恢复,命中缓存时能省下一大半时间。

- task: Cache@2 displayName: '缓存 NuGet 包' inputs: key: 'nuget | "$(Agent.OS)" | **/packages.lock.json' path: '$(NUGET_PACKAGES)' restoreKeys: | nuget | "$(Agent.OS)" cacheHitVar: 'CACHE_RESTORED'

关键点是 key 的粒度。如果你把 key 写得太大(比如只固定成一个字符串),那每次都是从同一个缓存恢复,一旦依赖目录结构变了,可能出现缓存命中但内容错乱的情况。我建议 key 里至少包含 Agent.OS 和依赖锁定文件(如packages.lock.json、package-lock.json)的哈希,这样依赖变化时缓存自动失效,没有变化时又能稳定命中。缓存不是银弹,但一个构建从 8 分钟降到 3 分钟,是每个人都愿意看到的。

5. 常见问题与排查经验:这些坑我都踩过

5.1 代理离线或者没有权限

如果你用了自建代理,某个时间段 Pipeline 一直卡在“等待代理”或者状态是“离线”,大概率是 agent 服务和 Azure DevOps 之间的通信断了。先检查 install agent 的那台机器上服务是不是还活着,Windows 上看服务状态,Linux 上跑./svc.sh status。通信恢复后去 Agent Pools 页面确认代理状态变回 online 再重新排队。如果代理是 online 但任务不执行,多半是配额或权限问题,比如某个代理池被项目访问权限限制了。

托管代理很少出现离线问题,但它有自己的限制:作业队列时间。免费层的托管代理在高峰期可能要排队,任务超过一定时长就会被强制取消。我的经验是:如果你是个人使用,避开工作日上午的全球高峰期;如果是团队使用,直接考虑买并行作业配额,便宜且省心。

5.2 YAML 语法报错与缩进问题

YAML 报错是新手最常遇到的拦路虎。最常见的错误就是“映射值未正确缩进”或者while parsing a block mapping。原因十有八九是缩进混用了空格和 Tab,或者某个列表项少缩进了两个空格。我处理这类问题的习惯是一行一行看报错信息给出的行号,再检查那个位置的上一行和下一行缩进是否一致。强烈建议在 VSCode 里装一个 YAML 扩展,它能实时标出语法错误,还带 schema 校验,写 Azure Pipelines 的 YAML 时能直接提示哪些字段是合法的。

另一个隐蔽问题是特殊字符。比如 Windows 路径里的反斜杠\,如果你不加引号,YAML 可能把\t解析成 Tab 字符。我的原则是:路径、URL、含特殊符号的值,一律加单引号包住。输出日志里如果看到某个变量的值被“吃掉了”,先检查是不是引号问题。

5.3 构建产物找不到或路径不对

PublishBuildArtifacts 这一步报No files were found是仅次于 YAML 报错的高频问题。原因通常是PathtoPublish指向的目录压根是空的,比如你在 build 里没有指定正确的输出目录,或者 publish 命令没有把文件输出到$(Build.ArtifactStagingDirectory)。这时候不要慌,去构建日志里把相关任务的详细输出打开,看--output参数实际把文件写到了哪里。再不行,在报错步骤前临时加一个Bash或PowerShell任务,执行ls -R $(Build.ArtifactStagingDirectory),直接看目录内容,这样定位最快。

还有一个细节:**/*.csproj这种通配符是把当前仓库整个文件系统作为搜索范围。如果你的仓库里同时有 src 和 tests 多个 csproj,proj 路径写得太宽可能导致还原、编译了不相关的项目。我建议尽量缩小范围,比如src/MyApp/*.csproj,除非你刻意要一次性构建整个解决方案。

5.4 恢复慢、超时和配额问题

托管代理每次构建都是全新环境,所以首次构建拉取 NuGet 或 npm 依赖都会慢。遇到超时,先检查是不是依赖源本身太慢,比如国内环境拉 GitHub 上的某些依赖确实会抽风,这时候可以先不折腾 Azure DevOps,把团队自己的制品库或者镜像源接进来,再配合上面说的缓存任务,速度和稳定性都能好很多。Python 项目还能用PipCache这类任务缓存pip install的中间产物。

配额方面,免费层的单机并行作业数有限,当你同时触发很多条 Pipeline 时,后面的作业会排队。看到“No hosted parallelism has been purchased or granted”这类提示,别慌,去项目设置里看看是不是免费额度用完了,或者组织管理员还没给项目分配并行作业。这是一个纯运维配置问题,不是代码问题。

最后再分享一个我个人的习惯

大概跑了半年 Azure DevOps 流水线后,我最大的体会是:持续集成的好处,不是某一次构建成功了才显现出来的,而是在“某一次构建失败了”的时候才真正体现。失败不可怕,可怕的是失败之后你不知道是哪一次提交、哪一步、哪一个环境变量引起的。Azure DevOps 的构建记录、日志、产物三位一体,让“定位问题”变成了一个从日志里按图索骥的过程,这比对着本地环境猜来猜去要快得多。

最后给所有刚开始接触这个平台的朋友一个小建议:把你自己的第一个 Pipeline 当作“最小闭环”来设计,不要一上来就加部署、加通知、加代码扫描。先让代码能够自动编译、测试、出包,形成一个最基本的“提交-反馈”循环。这个圈子一旦转起来,后面的功能才有意义。下一步你可以试试把构建产物接到 Release Pipeline 里,做一个“一键部署到测试环境”的持续交付流程——那又是一片新天地了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进