从零搭建Azure DevOps Pipeline:持续集成入门实战指南
最开始逼我动手搞持续集成的是一连串让人头皮发麻的发布事故。那会儿我们做个内部工具项目代码在本地每次发版靠同事A在服务器上手工复制文件、重启服务。结果他休假那天其他人连生产环境的目录在哪都要翻聊天记录。真正决定切换到正规军流程的是又一次发布后线上白屏核心定位花了两小时最后发现只是有个配置文件漏同步。那天晚上我就下了决心一定要把从代码提交到可部署产物这条流程变成机器自动执行的东西。这就是我接触 Azure DevOps 持续集成的起因。这篇是系列的第一篇目标是初步。我会把我从零开始搭建一个可跑的 Azure DevOps Pipeline 的完整过程、选择理由和踩坑记录都写下来。内容适合三类人一是团队还没有任何 CI想找个托管方案省去维护 Jenkins 的人二是已经在 Azure DevOps 里管理代码但一直没搞懂 Pipeline 怎么从界面上变出一份 YAML 的人三是看过不少文档但一动手就被 Agent、Artifact、Trigger 这些概念绕晕的新手。读完这篇你至少能建出第一个流水线让每次 push 代码时自动完成依赖安装、构建、测试和制品发布。1. 从手动构建到持续集成为什么我先选了Azure DevOps1.1 持续集成到底解决了什么问题持续集成这个概念听起来高大上落到日常就是一件事每次有人把代码合入主干分支系统就自动帮你把能不能跑、能不能构建、测试过不过全部验证一遍。它最直接的价值不是消灭 Bug而是把发现问题的时间从发布之后提前到提交之后。Bug 发现得越晚修复成本越高这个账谁都算得过来。我见过很多小团队对 CI 的态度是等我们项目稳定了再上。这个想法看起来合理实际上反了。项目越不稳定越需要 CI 来兜底等稳定了再上往往意味着服务和构建环境已经复杂到不敢动的程度到时候引入 CI 的成本远高于一开始就顺手加上。就像健身最需要的时候永远不是状态最好的时候。当我们说初步指的就是先搭一个最原始的闭环代码提交 → 自动触发 → 装依赖 → 构建 → 跑测试 → 产出构建产物。不急着做发布、不做环境审批、不做多环境区分先把这条最核心的自动化链路打通。后面的文章我再逐步展开。1.2 为什么先选Azure DevOps而不是自建Jenkins或直接用GitHub Actions长期以来CI 工具的主流选项无非三类自己搭 Jenkins 这类老牌工具、用 GitHub Actions、用 Azure DevOps。我给几个内部项目做过选型对比自己的结论是如果团队不打算专门养一个运维角色去维护 CI 系统优先选托管的 SaaS 服务如果在微软生态里Azure DevOps 的集成度会比 GitHub Actions 更顺。对比一下你就明白了选型上手成本维护负担典型问题自建 Jenkins中等Plugin 熟悉后能做很多事高要管 JVM、插件升级、Master/Slave 节点跑一段时间就开始这是我的机器只有我能碰GitHub Actions低Marketplace 插件丰富低私有仓库分钟数要算着用和 Azure Boards/Repos 集成不如自家产品顺Azure DevOps中低界面引导 YAML极低托管 Agent 免维护概念名词多第一次容易在面板里迷路Azure DevOps 有一个非常实在的优点它是一套完整研发工具链不是只有 CI。同一个组织下面代码仓库用 Repos、任务管理用 Boards、CI/CD 用 Pipelines、包管理用 Artifacts权限模型完全一致。如果你想在一个地方把需求卡片 → 代码提交 → 流水线状态全部串起来这套体系的体验是分开买几个 SaaS 再拼起来没法比的。我个人还有一个很私人的理由微软托管的 Agent 不需要我操心补丁和安全加固跑完就销毁。我只要写清楚 YAML剩下的脏活累活都外包了。对于只有两三个开发、没有专职运维的团队这一点几乎可以一票定生死。1.3 我在选型时踩过的摇摆当然选型不是一锤子买卖。我最早也想过自建 Jenkins因为网上资料量大任何奇怪的问题都能搜到答案。但后来算了一笔账装一个 Jenkins 本身不难难的是它成了团队里唯一的公共设施升级插件可能挂、磁盘满了要清理、Agent 节点要装环境。我们团队没有那个精力去当机房管理员。后来又试过 GitHub Actions体验确实流畅但公司代码托管在 Azure Repos 上从 GitHub 那边拉私有仓库要配 App 授权多一层中间环节总让人觉得别扭。最终切回 Azure DevOps反而顺畅很多。这个经验可以分享给正在摇摆的人先看你的代码和任务现在放哪再选 CI而不是选了 CI 再反过来折腾代码仓库。把迁移动机降到最低流水线才更容易活下去。2. 环境准备创建组织、项目与代码仓库时的几个前置细节2.1 组织Organization和项目Project的关系Azure DevOps 的层级关系很多第一次接触的同学会搞混。最顶上是 Organization组织组织下面可以建多个 Project项目。一个 Project 相当于一个独立的工作间里面包含了 Repos、Pipelines、Boards 等所有模块。权限也基本以 Project 为单位隔离。我第一次建的时候直接把组织名当项目名用后来项目多了才发现组织是全局共享的一旦命名不合适很难改。建议组织名起得稳定一点比如公司或团队的统一品牌项目名倒是可以随时调。另一个容易忽略的点是创建 Project 时有个Visibility选项如果只是内部代码一定选 Private。这一步选错等于把你的源码对全网公开了。别问我怎么知道的。2.2 免费额度到底够不够用Azure DevOps 的免费层对小型团队相当友好。私有项目提供 5 个免费用户以及每月 1800 分钟的微软托管 Agent 运行时间。公共项目更是直接不限分钟数。1800 分钟听着抽象换算一下就是每天大约能跑 1 小时的流水线。对于一个小项目一天十几次提交每次流水线三四分钟完全够用。对初步来说你几乎不需要开任何付费项。真正的隐性成本是托管 Agent 虽然有免费额度但如果你用 Windows 镜像或大内存任务消耗会更快另外同一时间并发数量有限制团队大了之后可能要排队。但这是后话第一篇别让这些细节拦住你。2.3 把代码放Azure Repos还是先用GitHub我用的是 Azure Repos原因是流程最简单在 Repos 里建好仓库Pipeline 直接引用不需要授权。如果你已经在 GitHub 上维护代码Azure Pipelines 也可以连接外部仓库但需要安装一个 App 并给仓库授权。这里有个坑从 GitHub 拉取代码时Pipelines 里的Build Service账号必须在仓库里有读取权限。很多人第一次连不上 GitHub全是卡在授权范围只勾了某个仓库而没勾目标仓库。所以我的建议是如果你的代码已经在 GitHub 且团队习惯很成熟没必要为了用 Azure DevOps 硬搬家如果是从头开始的新项目直接放 Repos 会省掉大量后续配置摩擦。2.4 权限和连接方式的前置准备创建完组织和项目最好先把两层权限想清楚。第一层是谁能改流水线——默认 Project Administrators 组可以编辑 Pipeline普通成员如果只改了 YAML 而没权限PR 合并后流水线不会跑。第二层是流水线运行时的身份——默认流水线以 Build Service 账号运行这个账号是自动创建的大多数情况下不用管但一旦涉及从一个仓库读取代码到另一个仓库部署时就需要显式给这个虚拟账号配权限。还有一个很现实的建议把仓库的默认分支从 master 改成 main 这种事尽量在创建仓库时就做。因为很多 YAML 的 trigger 示例仍写 master如果你仓库默认分支是 main照着抄就踩坑。后面第五章我会专门讲这个 trigger 问题。3. 第一个Pipeline跑通之前必须先理解的五个核心概念3.1 Pipeline与YAML的关系Azure DevOps 里的 Pipeline 有两种定义方式经典编辑器和 YAML。2024 年的今天除非你是维护老项目否则新项目一律建议直接写 YAML。YAML 不是一个抽象概念它就是躺在仓库根目录下的一个文件通常叫azure-pipelines.yml。这个文件是流水线的唯一事实来源改动它并提交就等同于改动流水线本身。好处是版本可追溯、Review 可以走 PR 流程坏处是第一次接触有门槛——我也是从界面上点半天才转到写文件的转过来之后就没再回过界面。3.2 Trigger什么事件触发流水线Trigger 是流水线的闹钟。没有 Trigger流水线就只是你手动点运行才干活有了它push 代码、合并 PR 都会自动启动。初步至少要懂两种CI Trigger 和 PR Trigger。CI Trigger 应对代码推送到某个分支PR Trigger 应对有人创建或更新 Pull Request。这里有一个很多人没意识到的细节YAML 里的 trigger 是有默认行为的。如果你完全不写 trigger 关键字那么任何分支的 push 都会触发流水线如果你写了trigger: branches: include: [main]那就只有 main 分支触发。很多人以为不写等于不触发其实是不写等于全触发。要按预期工作最好显式写清楚。3.3 Agent、Agent Pool与虚拟机镜像Agent 是真正执行任务的工人。它有两种微软托管的 Hosted Agent 和自己装在机器上的 Self-hosted Agent。微软托管的 Agent 其实是一台临时开的虚拟机Azure DevOps 按需启动用完销毁常见的操作系统镜像有 ubuntu-latest、windows-latest、macos-latest。你不需要关心背后哪台机器在跑只需要在 YAML 里指定pool: vmImage: ubuntu-latestAgent Pool 则是这些工人的集合。默认有一个Azure Pipelines池它会根据你的 vmImage 设置自动从里面挑选合适的虚拟机。后续想用自托管机器跑特殊构建比如需要连内网数据库就需要创建自定义 Pool 并把机器注册进去。初步阶段直接用vmImage: ubuntu-latest就够了。记住一个原则托管 Agent 上每次运行的机器都是全新的你上次在机器上手动装的东西下次运行一概不存在一切环境准备必须在 YAML 里表达。3.4 Job、Step与Task流水线的三层结构流水线自上而下的结构可以这样理解Pipeline 是一场完整的演出Stage阶段是演出里的幕比如构建一幕、部署一幕Job作业是幕里的一组演员在一台 Agent 上按顺序表演Step步骤是每个演员的动作比如安装依赖运行构建两步Task任务是 Step 的具体实现通常是官方或第三方预先封装好的动作初步阶段可以只关注 Job 和 Step。Job 默认就是在这台 Agent 上把所有 Step 跑完而 Step 常见的写法有两种script直接跑 shell 命令或者task调用预封装好的功能模块。很多人理解不了为什么要有 Task。打个比方script相当于自己写配方做菜task相当于用商家做好的调料包比如NodeTool0这个 Task 知道怎么下载并配置指定版本的 Node.js你不需要自己想去哪里下载、怎么解压、怎么配 PATH。3.5 Artifact流水线真正的产出一个 CI 流水线跑完构建产物不能散落在临时虚拟机里因为虚拟机很快会被销毁。Artifact制品就是把产物从 Agent 里打包带走的机制。你可以把它理解成流水线产出的快递箱。跑完流水线后在运行记录页面的 Artifacts 页签里就能下载。这里最容易犯的错是把构建目录和制品目录混为一谈。源码工作目录是$(System.DefaultWorkingDirectory)而正式发布制品建议统一放到$(Build.ArtifactStagingDirectory)。这两个变量搞混后果就是明明构建出了东西却找不到在哪下载。4. 手写第一份azure-pipelines.yml从空仓库到产出构建产物4.1 准备一个最小Node项目在动手写流水线之前先准备一个最小的 Node.js 项目。假设仓库根目录下有package.json里面至少包含 build 和 test 两个脚本比如{ name: demo-ci-project, scripts: { build: tsc, test: jest } }大致的项目结构是源码放src/构建输出到dist/。如果你的项目还没有测试test 脚本可以先写一个简单的 true 命令但强烈建议空测试也先留着后面补测试时不用改流水线。4.2 完整示例与逐步拆解在仓库根目录新建azure-pipelines.yml粘贴下面这份最小可用版本trigger: branches: include: - main pool: vmImage: ubuntu-latest variables: nodeVersion: 20.x steps: - task: NodeTool0 inputs: versionSpec: $(nodeVersion) displayName: Install Node.js - script: npm ci displayName: Install dependencies - script: npm run build displayName: Build project - script: npm test displayName: Run tests - task: CopyFiles2 inputs: sourceFolder: $(System.DefaultWorkingDirectory)/dist contents: **/* targetFolder: $(Build.ArtifactStagingDirectory) displayName: Copy dist to staging - task: PublishPipelineArtifact1 inputs: targetPath: $(Build.ArtifactStagingDirectory) artifactName: drop displayName: Publish artifact这份文件共做了六件事我逐个说明第一段 trigger只有推送到 main 分支时流水线才会启动。这里有个小细节如果要兼容老仓库默认分支是 master就把 master 也加进去trigger: branches: include: - main - master第二段 pool指定使用 ubuntu-latest 镜像。Node 项目在 Linux 上跑最省心因为几乎所有构建工具对 Linux 的支持都最完整。Windows 镜像在跑 shell 脚本时要多考虑跨平台问题初次跑通用 Linux 是最稳的。第三段 variables定义一个变量nodeVersion值为20.x。后面步骤里用$(nodeVersion)引用。变量的好处是改一处全线生效等以后要在多套环境复制流水线时你会感谢自己当初用了变量而不是裸写版本号。第四段 NodeTool0这个 Task 负责安装指定版本的 Node.js。这里用 Task 比用apt-get install nodejs靠谱得多因为它会帮你下载、解压、配置 PATH。你并不需要知道细节只需把 versionSpec 设置好。第五到第七段 script依次执行依赖安装、构建、测试。注意我用的是npm ci而不是npm install。这两个命令对新手来说区别不明显但行为差异很大npm ci严格按照package-lock.json安装依赖如果 lock 文件与 package.json 不一致就直接报错。对流水线来说这能保证这次构建的环境和上次构建的环境尽可能一致这是 CI 的一个重要原则——可复现。而npm install会自动修 lock 文件掩盖依赖漂移的问题。所以在 CI 里请默认使用npm ci。第八段 CopyFiles2把构建产物从dist目录复制到制品暂存目录。有的教程会把 targetPath 直接指向 dist省掉复制这步。我建议还是保留一个显式的 Copy 动作因为制品暂存目录可以汇总多份产出将来要发布的东西不会只来自一个目录。最后一段 PublishPipelineArtifact1把暂存目录里的所有内容打包成名为 drop 的 Artifact。这样你在流水线运行完成后的页面里就能下载产物。drop这个名字是沿用经典的命名习惯叫什么都可以但要保持全仓库统一命名方便后续部署阶段引用。4.3 从界面创建Pipeline并绑定仓库YAML 写好了还需要让 Azure DevOps 知道去哪个仓库找它。在 Pipelines 页面点击New Pipeline选择你的代码仓库Azure Repos 就直接选GitHub 要先授权随后界面会找仓库里所有的 YAML 文件让你选择azure-pipelines.yml。确认后点击 Run。第一次运行时它还会问你是否给流水线创建所需的初始权限和资源授权。这一步不要想太多直接同意因为第一次跑默认就是要打通基础链路。如果之后遇到权限报错再进来做精细化调整。4.4 验证产物与判断是否真正跑通跑到最后一步Publish artifact成功后进入这次运行记录的详情页往下拉可以看到 Artifacts 页签里面有一个名为 drop 的压缩包。点进去能看到dist目录的文件。看到它就意味着代码 → 构建 → 产物这条链路已经完整打通。我判断一个 CI初步可用有三个标准缺一不可推代码到 main 分支后流水线自动触发而不是需要手动点 Run流水线里的测试任务真的执行了测试代码不是空跑构建产物能从 Artifacts 里下载并且内容正确。5. 第一次运行后最常见的五个坑以及我推荐的日志排查顺序5.1 坑一YAML缩进报错和Tab冲突YAML 对缩进极其敏感而且严禁使用 Tab 缩进。你在本地编辑器里看着对齐挺好一旦混入 Tab上传后流水线可能直接报invalid YAML。还有一个隐形问题YAML 里的多行脚本比如用|块时块内的所有行必须比关键字多缩进一层。不少第一次写的人会在script: |下面写 shell 命令时把命令和script对齐结果报错。我的解决方式是装一个 VS Code 的 Azure Pipelines 扩展它会在本地对 YAML 做 schema 校验很多低级语法错误在提交前就暴露了。另外养成一个好习惯——写完 YAML 后先跑一次git diff仔细检查缩进是否统一用空格。5.2 坑二Trigger不触发或者无限触发Trigger 的问题是初步阶段最隐蔽的坑因为它报错方式极其安静——流水线就是不跑页面上一丝红色都没有。最常见的两种情况第一种是分支名对不上。仓库默认分支是 maintrigger 里却写 master或者反过来。提交后流水线完全没反应。排查方法很简单打开仓库的分支列表确认实际分支名再看 YAML 里的 include 是否一致。第二种更烦你建了一个 PR期待它触发一个验证流水线结果它没触发然后你手动点了 Run它又跑在了错误的分支上。原因通常是只写了trigger而没有写pr块。在 YAML 中PR 触发和 CI 触发是两套独立配置。如果想要 PR 也触发需要加pr: branches: include: - main它的作用是任何目标分支为 main 的 PR 被创建或更新时都会自动跑一遍流水线。把 PR 验证和 CI 分开理解能省掉很多为什么没跑的困惑。还有一个反向问题如果 trigger 配置不当可能会出现 push 一个 tag 就触发一堆流水线的情况。初步阶段建议先只用分支触发Tag 触发等熟悉了再玩。5.3 坑三本地能过流水线上挂了这是 CI 新手最崩溃的场景本地跑npm run build一切正常一到 Azure DevOps 就报错。绝大多数原因是环境差异。本地可能是 macOS 或 Windows流水线用的是 ubuntu-latest本地 Node 版本和流水线装的版本不同本地早就装好了某个系统级依赖而托管 Agent 上啥都没有。理解托管 Agent 每次运行都是全新虚拟机这个事实就能明白你必须在流水线里把环境准备做完整比如用 NodeTool0 固定 Node 版本、显式安装依赖不要再依赖运行环境里碰巧存在的东西。这也是为什么我在示例里使用npm ci而不是npm install——前者对环境差异更敏感遇到问题只会在 build 阶段暴露不会在部署上线之后才爆雷。更严谨的做法是在 YAML 开头固定 Node 版本variables: nodeVersion: 20.x所有环境相关的 Key 版本都通过 variables 统一管理后面升级 Node 只需改一处。5.4 坑四构建产物下载不了或找不到我已经不止一次看到有人明明跑完流水线Artifacts 页签里却是空的。最常见原因是只构建到了dist目录但没有把内容复制到$(Build.ArtifactStagingDirectory)也就没有可发布的制品。还有一种情况是CopyFiles2的 contents 写成**/*但 sourceFolder 指向了一个不存在的目录Task 虽不会报错但复制了个寂寞。排查建议是在流水线里加一个displayName: List dist contents的ls -la dist步骤先确认构建产物确实存在再检查复制和目标路径。Artifact 的下载入口在运行详情页的 Artifacts 页签不是在 Pipeline 列表页面上这个位置很多人也会找半天。5.5 坑五任务失败了但日志看不懂日志是整个排查过程中的主角。点进失败的 Job 后展开对应的 Step就能看到该步骤的完整日志。Azure DevOps 的日志系统里隐藏着两个非常有用的标记##[error]和##[warning]。如果你下载完整日志后用编辑器搜索##[error]通常能找到最核心的失败原因这比在人眼滚动几千行日志里找线索高效得多。具体我推荐的排查顺序是先看失败的 Step 名字是哪个动作挂了点进该 Step先滚动到日志底部因为错误通常在最下方如果日志很长下载完整日志搜索##[error]Bug 修复后再提交一次代码观察新的一次运行不要反复手动点 Run。这样每轮排查都有据可依而不是凭感觉改 YAML改完再看运气。6. 初步跑通之后我应该怎么用这个流水线6.1 把测试和代码覆盖率变成硬门槛当前这个流水线虽然跑了 test但默认即使测试全挂流水线也只是报一个红色状态不会中断后面的步骤。如果你希望测试失败就停下不再继续复制产物可以给测试步骤加上continueOnError: false。写入方式- script: npm test displayName: Run tests continueOnError: false然后再进一步把覆盖率报告发布到流水线页面上让每一次提交都能直观看到测试覆盖变化。这一步不属于初步但它是第一个值得做的升级。跑通基本链路后你自然会开始想如果测试挂了是不是不该出产物如果覆盖率下降了是不是该提醒团队这些都是系列后面展开的话题。6.2 用变量和变量组消除硬编码现在的 YAML 里nodeVersion 已经用变量管理了。下一步可以把一些经常要改的值抽到变量组Variable Groups。变量组可以在多个流水线之间共享特别适合存测试环境地址账号这类所有流水线都在用的东西。注意一点机密类型的变量不要直接明文写在 YAML 里而是放到变量组中并勾选secret标志。初步阶段至少要养成不把密码、token 直接写进 YAML 的习惯。6.3 缓存依赖把10分钟压到3分钟Node 项目每次npm ci都要重新下载全部依赖托管 Agent 每次都全新启动这个成本相当高。Azure DevOps 提供了缓存任务可以按 lock 文件的哈希值缓存node_modules目录。一个打通 CI 后立刻能见效的优化- task: Cache2 inputs: key: npm | $(Agent.OS) | package-lock.json path: $(npm_config_cache) restoreKeys: | npm | $(Agent.OS) displayName: Cache npm dependencies这段代码把 npm 缓存目录按操作系统和 lock 文件的哈希值做键缓存。只要 lock 文件不变后续运行直接从缓存恢复依赖大幅缩短构建时间。对小型团队来说流水线跑得快开发者的反馈循环就短大家才更愿意频繁推送代码CI 的价值才能持续放大。6.4 从构建走向发布的路线图初步跑通后最自然的下一步是往持续交付走。比如把构建产物自动发布到一个测试环境在 main 分支构建完成后由一个部署 Stage 把 Artifact 拉下来通过环境审批机制逐步推到测试、预发、生产。Azure DevOps 里的 Environment、Approval 和 Deployment Job 就是专门干这个的。它带来的好处是发布不再依赖某个人本地执行命令而是走同一条被验证过的流水线每一步都有记录、有审批、可回滚。我个人建议的顺序是先加测试覆盖率门槛再做缓存优化然后接一个测试环境自动部署最后才考虑生产环境。节奏要慢一点不要一口气把 CI/CD 全堆上那样出了问题排查起来非常头大。6.5 一个真正让我省下大量时间的经验这篇初步写到最后分享一个我最真切的体会刚开始调流水线时不要急着在界面上反复点 Run因为每次点击都在消耗一份耐心而且问题不会自己消失。我的做法是每次改完 YAML先在本地用类似的命令把关键步骤手动跑一遍比如npm ci、npm run build、npm test全在本地执行成功再推到仓库触发流水线。这样一来流水线上暴露的几乎只有环境差异和配置问题排查范围小了一大半。另外不要怕你的第一份 YAML 长得丑。这份文件是从能跑到跑得稳定再到跑得优雅逐步演化的它对应的是团队协作流程的成熟度。先把初步链路跑通比追求一份完美的 YAML 重要得多。下一篇我会继续记录如何给这条流水线加上多阶段部署和测试环境自动发布那时你会发现今天跑通的这个最小闭环就是后面所有进阶内容的地基。