ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Azure Pipelines YAML管道:从CI到CD的自动化编排实践

Azure Pipelines YAML管道:从CI到CD的自动化编排实践 简介《Azure DevOps Pipelines 操作手册》是一份面向开发运维工程师、DevOps从业者及中小型团队的中文参考文档系统讲解 Azure Pipelines 中的 CI/CD 落地方法。内容覆盖持续集成、持续交付、持续测试三大主线从免费注册、创建第一条管道开始逐步展开 YAML 编排、代理与条件、环境、作业、阶段、任务、模板、触发器、运行时参数等核心概念并针对 .NET Core、Java、Node.js、Python、PHP、Android、AKS 等常用应用栈给出配置示例能够帮助读者快速掌握多分支构建、多环境部署、测试集成及与 GitHub、Azure Repos 等仓库的联动。资源包为单个 PDF 文件共 106.6MB目录结构完整内容接近官方文档的最新整理适合作为日常查询手册或系统学习资料。目前已有 409 人学习下载读者可借助其中的部署到 Azure SQL、AKS、Key Vault 的指引以及自托管代理配置、Slack/Teams 集成等章节直接解决管道搭建与排错中的实际问题。1. 从 push 到上线Azure Pipelines 把持续集成与持续交付变成一条 YAML做持续集成和持续交付CI/CD的团队基本都绕不开 Azure DevOps Pipeline 这道题。我第一次用它是在接一个多语言项目的模拟仓Node.js 后端加 Python 数据脚本还要分别在 Windows 和 Linux 两条路上跑测试。手动打包部署到第三个环境的时候我就意识到如果继续靠人肉触发构建、登录服务器部署一天几十个 commit 根本盯不住。Azure Pipelines 就是 Azure DevOps 里专门干这件事的引擎把代码合入之后的构建、测试、打包、部署全部串成一条自动执行的管道源码一 push后续流程不再需要人守着。它适合两种人一种是正在找 CI/CD 工具的开发者另一种是已经把项目迁进 Azure DevOps、但管道还停在能跑就行状态的团队。2. 把管道写成代码YAML 三要素与多阶段编排2.1 YAML 与经典编辑器为什么我选 YAMLAzure Pipelines 提供两种定义管道的方式YAML 管道和经典编辑器。我刚接触时也觉得经典编辑器更友好毕竟界面上拖拖拽拽每一步看得清清楚楚。但用了一段时间后发现经典编辑器最大的问题是管道本身没有版本化——它存在 Azure DevOps 服务端改了谁改的、什么时候改的只能翻审计日志做不到和代码一起评审。YAML 管道的思路是把 azure-pipelines.yml 当作仓库里的普通文件随代码走同一个分支、同一个 PR 流程。改管道就是改代码评审管道就是评审代码。官方文档里有一张功能支持表我提炼几个关键差异能力YAML经典生成经典发布模板复用支持不支持不支持用任务组部署作业与审批支持不支持部分支持环境Environment支持不支持不支持容器作业支持否否缓存支持否否条件表达式支持有限有限发布 Gates不支持不支持支持这表基本决定了选型新项目一律 YAML经典编辑器只用来接管那些年份久远、已经跑稳定了的老管道。我见过某开发者把经典发布管道用了三年换人维护时谁也说不清环境审批是怎么配的最后整个发布流程推倒重写。管道一旦没版本化就是个黑匣子。YAML 管道的核心结构其实就三层Stage阶段→ Job作业→ Step步骤。Stage 对应环境或大的逻辑分组比如构建、部署到测试、部署到生产Job 是 Stage 里并行或串行的执行单元Step 是具体的命令或任务。理解这三层后面所有编排都不难。2.2 最小可用管道触发器、代理池与四个步骤第一个管道不用贪多。以 Python 项目为例一份最小可运行的 azure-pipelines.yml 长这样# azure-pipelines.ymlPython 项目的最小 CI 管道 trigger: branches: include: - main - develop pool: vmImage: ubuntu-latest steps: - checkout: self - task: UsePythonVersion0 inputs: versionSpec: 3.11 addToPath: true - script: | python -m pip install --upgrade pip pip install -r requirements.txt displayName: Install dependencies - script: | python -m pytest tests/ --junitxmlresults.xml displayName: Run tests - task: PublishTestResults2 inputs: testResultsFormat: JUnit testResultsFiles: results.xml failTaskOnFailedTests: true condition: succeededOrFailed()逻辑说明checkout 拉取当前仓库代码UsePythonVersion 指定 Python 版本并加入 PATH两个 script 分别装依赖和跑测试PublishTestResults 把 JUnit 格式的测试报告发布到 Azure DevOps这样 PR 页面上能直接看到测试结果。参数说明vmImage 是 Microsoft 托管代理的镜像名ubuntu-latest 表示最新版 UbuntuversionSpec 写具体 Python 版本号别用裸的 3.x镜像里未必有你预期的版本failTaskOnFailedTests 设为 true 时测试有失败用例会让整个任务失败阻断合入condition: succeededOrFailed() 是这文件里最容易被忽略的一行——不加它测试任务失败时后面发布结果的任务根本不会执行测试报告也就传不上去。把这份文件推到仓库根目录Azure Pipelines 默认会在分支触发 CI。跑通这个最小管道之后再一点点往里面加东西比一开始堆几十个步骤要稳得多。2.3 阶段、作业、步骤的编排dependsOn、condition 与运行时参数项目一复杂单段管道就不够用了。构建、部署到 test、部署到 staging、部署到 production这些应该拆成 Stage而不是在一个 job 里写一串部署脚本。多阶段编排的好处是每个阶段可以独立看日志、独立审批、独立跳过。trigger: branches: include: [ main ] pool: vmImage: ubuntu-latest variables: buildConfiguration: Release stages: - stage: Build jobs: - job: BuildJob steps: - script: echo building $(buildConfiguration) - publish: $(Build.ArtifactStagingDirectory) artifact: drop - stage: DeployStaging dependsOn: Build condition: succeeded() jobs: - deployment: DeployWeb environment: staging strategy: runOnce: deploy: steps: - download: current artifact: drop - script: echo deploy to staging逻辑说明第一个 Stage 里用 publish 把构建产物发布成名为 drop 的管道工件第二个 Stage 用 dependsOn: Build 声明依赖condition: succeeded() 保证只有构建成功才继续deployment job 绑定 environment 为 staging后续可以在这个环境上配置审批和资源这是普通 job 做不到的。参数说明artifact 名字 drop 是习惯命名可以改成你自己易识别的名字environment 对应 Azure DevOps 里的 Environment 实体可以在页面里绑定虚拟机或 Kubernetes 集群strategy 的 runOnce 是部署策略的一种还有 rolling 和 canary分别对应滚动发布和金丝雀发布。再加一层运行时控制。YAML 里的求值时机是个玄学点简单记${{ }} 是编译期展开$() 是运行期读取$[ ] 是运行期表达式。参数用 ${{ }}因为它在管道编译时就要定下来parameters: - name: environment displayName: 部署目标 type: string default: staging values: - staging - production variables: isMain: $[eq(variables[Build.SourceBranch], refs/heads/main)] steps: - script: echo deploy to ${{ parameters.environment }} - script: echo production 需要额外审批 condition: and(succeeded(), eq(variables[Build.Reason], IndividualCI))逻辑说明parameters 块定义了 environment 这个运行时参数手动运行管道时可以下拉选择默认 stagingisMain 用运行时表达式判断当前分支condition 里叠加了上一步成功和触发来源是 CI 提交两个条件。参数说明type 支持 string、number、boolean、object 等values 限定可选值防止有人手动输入一个不存在的环境名Build.Reason 是预定义变量取值包括 IndividualCI、PullRequest、Schedule 等后面排坑章还会用到它。这里建议把哪些分支能部署到生产这个判断写到 condition 里而不是写进脚本脚本里写 if 分支日志里看不出来为什么走了这条路condition 则一目了然。3. 代理、触发器与仓库集成决定管道在哪跑、何时跑3.1 Microsoft 托管代理与自托管代理选型对比与注册管道是编排层真正干活的是代理Agent。代理分两种Microsoft 托管代理和自托管代理。托管代理的开箱即用官方镜像里预装了主流语言和工具链点选 vmImage 就能跑自托管代理则是把代理程序装到你自己控制的机器上注册进一个代理池。对比项Microsoft 托管代理自托管代理维护成本零镜像由官方维护自己装系统、配环境、定期更新预装软件Node、Python、Java、.NET 等常用工具链只装你需要的网络位置云上访问内网资源要额外配置可以在内网直接连数据库和内部服务计费有免费额度如 1800 分钟/月超出按并行作业计费只要机器资源足够无分钟计费适合场景开源项目、标准技术栈、快速起跑有大型私有依赖、需要固定缓存、合规要求数据不出内网我的一般做法是公共镜像能解决的用托管代理一旦遇到构建要拉内网私有包要连测试数据库构建缓存大到每次冷启动都要五分钟这些情况果断切自托管。自托管代理的注册流程不复杂Linux 上一条条命令来# Linux 自托管代理下载、解压、配置、运行 mkdir agent cd agent # 替换版本号为最新 LTS 版本 curl -O https://vstsagentpackage.azureedge.net/agent/2.228.0/vsts-agent-linux-x64-2.228.0.tar.gz tar zxvf vsts-agent-linux-x64-2.228.0.tar.gz # 交互式配置按提示输入组织 URL、认证方式、代理池名称 ./config.sh # 后台运行代理 sudo ./svc.sh install sudo ./svc.sh start逻辑说明下载的压缩包是 Azure Pipelines 官方发布的代理程序解压后 config.sh 负责把代理注册到指定组织下的指定池svc.sh 把代理装成系统服务保证机器重启后代理自动拉起。参数说明URL 格式是 https://dev.azure.com/你的组织名认证方式推荐 PAT个人访问令牌注意令牌要勾选 Agent Pools 的读写权限代理池建议按用途拆比如 Linux-Pool 和 Windows-Pool 分开避免两种系统在同一个池里互相干扰。注册完成后管道的 pool 字段写成池名pool: name: Linux-Pool这里有个隐藏坑如果池里既有 Windows 代理又有 Linux 代理而你的任务要求 shellAzure Pipelines 会挑选它认为合适的代理但结果经常不如预期。代理池按系统类型拆分是为了让池子语义单一。3.2 CI、PR、定时触发器五种触发方式与路径过滤触发器的职责是回答什么时候跑管道。Azure Pipelines 的触发方式大致有五种CI 提交触发、PR 验证触发、定时计划触发、管道资源触发、手动运行。新手阶段只需要把前三种搞明白后面两种在需要跨管道联动时再补。# 一、CI 触发代码推送到指定分支时运行 trigger: branches: include: - main - releases/* exclude: - releases/old-* paths: include: - src/* - azure-pipelines.yml # 二、PR 触发针对目标分支的 PR 创建/更新时运行 pr: branches: include: - main paths: include: - src/* # 三、定时触发按 cron 计划运行 schedules: - cron: 0 2 * * * displayName: 每天凌晨 2 点构建 branches: include: - main逻辑说明trigger 是 CI 触发器代码合入 main 或 releases 分支时触发构建paths 做路径过滤只改文档时不会白白跑一遍管道pr 是 PR 验证触发器开发者提 PR 时自动运行用来做合入前检查schedules 是 cron 计划适合每天定时跑全量测试或夜间构建。参数说明branches 的 include 和 exclude 同时存在时exclude 优先级更高paths 的过滤规则是路径下任何文件变化都触发这里需要注意 include 了 azure-pipelines.yml 本身否则改了管道文件反而不会触发重建cron 表达式默认时区是 UTC国内团队要跑北京时间凌晨两点写 0 18 * * * 才对这块翻过车的人不少。再多说一句 PR 触发和 CI 触发的边界很多团队把 pr 和 trigger 都配在同一个分支上结果 PR 跑一次、合入后再跑一次同一份代码连续验证两遍。如果仓库有严格 PR 策略合入前已经全量验证过CI 触发可以只留 mainPR 触发负责验证这样资源消耗直接减半。3.3 多仓库集成GitHub、Azure Repos 与服务连接Azure Pipelines 本身支持多类代码仓库GitHub、Azure Repos、Bitbucket Cloud、Subversion、通用 Git 都能连。Azure Repos 是亲儿子项目里建管道直接选仓库就行GitHub 集成需要先走一遍授权流程本质是创建一个 OAuth 连接或者用 PAT 配服务连接。管道里引用其他仓库的代码靠 resources 关键字resources: repositories: - repository: shared type: github name: yourorg/shared-templates endpoint: github-service-connection ref: refs/heads/main trigger: branches: include: [ main ] steps: - checkout: self - checkout: shared - script: | ls $(Pipeline.Workspace)/shared displayName: 查看共享仓库内容逻辑说明resources 里声明一个名为 shared 的外部仓库type 指定仓库类型 githubendpoint 填服务连接的名字这个连接在项目设置里的 Service Connections 页面创建steps 里 checkout shared 把它拉到工作目录之后所有步骤都能引用其中的模板或脚本。参数说明name 的格式是拥有者/仓库名ref 指向分支或 tag指定后管道从这个仓库拉取的是固定分支适合锁模板版本服务连接建议一个仓库一个连接不要共用一个全局 PAT这样某个仓库的 token 泄露时可以单独吊销。多仓库集成最容易忽略的是权限自托管代理跑管道时代理所用的服务账户需要有访问这些仓库的权限否则 checkout 那一步直接报权限错误。我做过的处理是给代理池单独建一个服务账户只授予代码读取权限最小权限原则。4. 避坑五个把构建和发布搞挂的常见坑4.1 代理排队不运行池名与标签对不上现象管道创建好后推代码触发构建一直停在队列状态页面上看不到任何代理接管也没有报错。原因YAML 里 pool 写的池名和实际代理注册的池名不一致或者代理注册了但管道任务里 demands 指定的标签如系统类型、自定义能力代理上不存在。解决先确认池名下有没有在线代理。代理机器上运行 ./run.sh 会打印注册信息也可以在 Azure DevOps 项目设置的 Agent Pools 里看代理状态。检查 YAML 的 pool 字段是否精确匹配池名云和本地是否同一个组织。有 demands 时去代理的 Capabilities 里比对标签。从那以后我习惯在池名后面加环境后缀比如 Linux-Pool-CI从名字上杜绝认错池子。4.2 变量跨作业丢失求值时机与作用域现象在一个 job 里用 script 设置变量下一个 job 里读取为空。同一个 Job 内写在两个 step 之间偶尔又能读到行为很玄学。原因变量有三层作用域和两种求值机制。$() 在运行时解析但默认只在当前 job 内生效跨 job 必须显式声明输出${{ }} 在编译期展开一旦编译完成就是固定值之后改不掉$[ ] 是运行期表达式但用于变量定义。很多人把这三者混着用出了诡异问题。解决跨 job 传变量用 isOutput 语法或者干脆把数据写成文件。用 isOutput 的修复方式jobs: - job: A steps: - script: echo ##vso[task.setvariable variableversion;isOutputtrue]1.0.0 name: setVersion - job: B dependsOn: A variables: versionFromA: $[ dependencies.A.outputs[setVersion.version] ] steps: - script: echo version is $(versionFromA)逻辑说明第一个 job 用日志命令 setvariable 声明了一个名为 version 的输出变量isOutputtrue 表示允许其他 job 读取第二个 job 的 variables 里用依赖语法从 job A 的 setVersion 步骤里取到值赋给新变量。参数说明dependencies.A 是固定写法A 是 job 名outputs 后面跟的键名格式是步骤名.变量名。这里最容易写错的是步骤名和变量名的顺序写成变量名.步骤名就取不到。跨 Stage 同理用 stageDependencies。4.3 PR 触发了生产部署trigger 与 pr 的边界现象开发者提了一个 PR管道跑完测试之后直接开始往生产环境部署生产被一个未合入的分支代码覆盖。原因trigger 和 pr 都配置了 main 分支而部署阶段没有区分触发来源。PR 触发时Build.Reason 的值是 PullRequestCI 合入触发时是 IndividualCI管道没做判断就一路跑到底。另一个叠加因素是生产环境没有配审批。解决部署到生产的 Stage 加触发条件并且把生产环境配上手动审批- stage: DeployProduction condition: eq(variables[Build.Reason], IndividualCI) jobs: - deployment: DeployProd environment: production strategy: runOnce: deploy: steps: - script: echo deploy to production逻辑说明Build.Reason 是预定义变量PullRequest 类型触发的次数对不上 IndividualCI条件不成立就整段跳过。Environment 的审批在页面配置任务完成后环境会停在等待审批状态而不是直接执行部署。参数说明如果想保留PR 合入后自动部署生产把 condition 去掉、环境审批保留也行但建议 CI 合入和 PR 触发的管道分开定义PR 管道只做构建和测试语义更干净。这个坑属于血泪经验某次模拟项目里我亲眼看着 PR 分支把测试环境数据清了一遍。4.4 缓存命中率接近零缓存键设计错了现象加了 Cache 任务但每次构建还是全量还原依赖构建时间一点没降。日志里缓存状态一直是 CacheMiss。原因缓存键设计太宽或太窄。键太宽任何文件变动都导致缓存失效键太窄没有锁定文件参与内容更新了键却没变命中一堆过期缓存。另一个常见原因是缓存只在同一个代理池、同一分支内生效自托管代理换了机器或路径缓存也找不回。解决缓存键务必要包含依赖锁定文件的内容哈希。npm 项目这样配比较稳variables: - name: npmCache value: $(Pipeline.Workspace)/.npm steps: - task: Cache2 inputs: key: npm | $(Agent.OS) | package-lock.json path: $(npmCache) restoreKeys: npm | $(Agent.OS) cacheHitVar: CACHE_RESTORED - script: npm ci displayName: Install dependencies逻辑说明key 由三段组成分隔符是竖线package-lock.json 直接写文件名Azure 会自动计算文件内容哈希lock 文件没变就命中缓存变了就自动重建缓存。参数说明restoreKeys 是降级匹配的键主键没命中时尝试用它恢复部分缓存常见做法是去掉 lock 文件那一段这样即使依赖有更新也能复用上一版缓存只增量下载变化的部分cacheHitVar 输出一个变量后续步骤可以用它判断是否命中。缓存目录要固定npm 通过 .npmrc 把缓存指到 Pipeline.Workspace 下否则缓存任务看不到 npm 的缓存文件。4.5 改了 YAML 不生效默认分支与路径过滤现象改了 azure-pipelines.yml 推上去管道行为完全没变有时还出现改了等于白改的错觉。原因管道定义在 Azure DevOps 里指向的 YAML 文件路径和实际仓库路径不一致或者 trigger 的 paths 过滤条件把 azure-pipelines.yml 排除在外了还有一种情况是管道设置的默认分支还指在旧分支YAML 拉的是旧版本。解决逐项排查。先看管道设置页面里 YAML 文件路径是不是仓库根目录再看 trigger 的 paths.include 里有没有包含 azure-pipelines.yml 自己最后看默认分支设置确保指向当前活跃分支。这个坑最阴险的地方在于管道触发时的 YAML 是从默认分支读取的不是从你 push 的分支读取的。如果你在 feature 分支改了 YAML 而不动代码管道可能压根不触发。判断方式手动点击 Run pipeline运行时选择分支然后观察日志最上面几行会打印出它实际使用的 YAML 文件名和仓库 commit。看到实际文件路径和预期不一致基本就是上述三个原因之一。5. 把管道沉淀成模板复用、验证与排错动作5.1 模板与参数化一份管道吃遍多环境管道跑稳定之后下一步就是消除重复。多个服务共用同一套构建逻辑时把公共步骤抽成模板主管道用 extends 继承。模板和复制粘贴的区别在于改一处全生效且参数校验在编译期。# templates/python-build.yml parameters: - name: pythonVersion type: string default: 3.11 - name: workingDirectory type: string default: . steps: - task: UsePythonVersion0 inputs: versionSpec: ${{ parameters.pythonVersion }} - script: | pip install -r requirements.txt workingDirectory: ${{ parameters.workingDirectory }} - script: | pytest tests/ workingDirectory: ${{ parameters.workingDirectory }}# 主管道 azure-pipelines.yml trigger: branches: include: [ main ] pool: vmImage: ubuntu-latest extends: template: templates/python-build.yml parameters: pythonVersion: 3.12 workingDirectory: services/api逻辑说明模板文件里用 parameters 声明两个入参主管道用 extends 引用模板并传入参数。这样 services/api 和 services/worker 各自的主管道只有几行公共逻辑统一收在模板里。参数说明extends 的 template 路径是相对于仓库根目录的模板内部仍然可以定义 trigger、pool 等但主管道里已经定义的部分会覆盖模板里的同名设置这个覆盖规则要心里有数。验证管道配置有一个低成本做法先跑一个空跑把每一步的日志输出都看一遍尤其是解析后的 YAML 全文。Azure Pipelines 在运行日志第一逻辑段会显示展开后的 YAML 结构很多 ${{ }} 展开不对的问题在第一段日志里就能发现。另一个验证手腕是故意在一个分支上改坏管道文件看管道是否按预期失败失败时错误信息会直接指出 YAML 语法错误在第几行比猜故障点高效得多。从那以后我每次新建管道都强制走一遍固定动作先确认触发器边界PR 和 CI 分开再核对变量求值时机跨 job 用 isOutput然后检查部署阶段的环境审批最后看一眼缓存键有没有锁文件。这套动作帮我挡掉了至少五次生产环境的翻车。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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