专业书籍精读 · 持续交付 · 第 6 章
Continuous Delivery · Ch 6 · Jez Humble & David Farley · 2010
你手机里的 App 每周更新一次。把工程师写完的代码变成你手机上那个能跑的新版本,中间隔着一连串动作:编译、打包、传到服务器、改配置、重启。《持续交付》第 6 章讲的就是这一串动作该怎么写成脚本——不是随手敲几行命令,而是像写正式产品那样写它。
把发布想成做菜。手工部署是大厨凭记忆现做:今天多放了勺盐,明天忘了关火。脚本化就是写成菜谱、让机器照做。但这一章的主张还要更进一步:菜谱不该写在贴冰箱门的便利贴上(想到哪写到哪、每个厨房一张、越改越乱),而要当成正式出版物来维护——有版本、有人审、出了事能查出是哪一版。
2012 年 8 月,美国券商骑士资本(Knight Capital)上线新代码,靠人手拷到 8 台生产服务器,漏了 1 台。那台上残留的旧代码被一个新开关意外唤醒,疯狂下错单,约 45 分钟内亏掉逾 4.6 亿美元,公司实质上就此消失。技术根因并不玄:部署靠人一台台做,没有任何东西保证这 8 台一模一样。
第一,同一套脚本。 开发、测试、生产,用同一份部署脚本,环境之间的差别只体现在外面那份配置文件里。因为一旦每个环境各一份脚本,它们就会各自长歪——你在测试环境验过二十遍的东西,上生产跑的其实是另一段代码。
第二,跑几遍都一样。 好脚本写的不是「依次做这 12 步」,而是「确保最后是这个样子」:发现装好了就跳过,装错了就改对。这样半路失败,重跑一次就行,不用先手工收拾残局。
第三,分层。 把「装操作系统和中间件」「放应用程序」「改配置」「迁移数据」拆成四层各自的脚本。因为它们变动的频率差着几个数量级:配置可能一天改三次,操作系统一年才动一次。混在一起,改一行配置就得把中间件重装一遍。
别一上来就想写全套。这一章的建议很实在:从最疼的那一步开始——每次发布你最烦、最容易错的是哪一步,就先把那一步脚本化,再一点点往两头长。
代价也得说清楚:脚本化不是免费的。这些脚本自己也会有 bug、也要人维护,等于多养了一套代码。它依然划算,是因为手工部署的错误不会在测试环境暴露,只会在生产上暴露。
把部署写成脚本还不够——要所有环境共用同一套脚本(差异只放配置里)、跑几遍结果都一样、并且像对待产品代码一样对待它:进版本库、被评审、能重构。
想看构建工具的两派分歧、幂等的机制和对比表? → 切到精读版
第 6 章处理的是流水线里的「体力活」:怎么把构建与部署写成脚本。它的核心命题只有一句——构建脚本和部署脚本是生产代码,不是一次性命令的堆积:要进版本库、要分层、要被重构、要能测试。由此推出三条硬规矩:所有环境共用同一套部署脚本(差异只在外部配置)、部署过程必须幂等、按变更频率把部署分层。
compile、test。本章属 Part II「部署流水线」,紧接 Ch5 之后:Ch5 画出关卡的形状,本章回答每个关卡里那个格子具体由什么执行。它向下承 Ch2「配置管理」(「同一套脚本 + 外部配置」的后半句靠 Ch2 落地),向上启 Ch7「提交阶段」与 Ch11「管理基础设施」。对应现实里的问题是:一个团队的 build.xml / Makefile / deploy.sh 演化三年之后,为什么没人敢碰。
流水线的图谁都画得出来,真正拖垮团队的是执行这张图的那堆脚本。它们的典型下场:一份三千行、只有作者看得懂的 build.xml;deploy-dev.sh / deploy-uat.sh / deploy-prod.sh 三份各自打补丁、早已互不相认;一句写在 wiki 上的「运行前请先手工停掉 A 服务」。
不解决会怎样?慢性的代价:脚本没人敢改,新需求只能往里塞 if,构建时间从 3 分钟涨到 25 分钟,越过 Ch7 给提交阶段定的十分钟预算——开发不再等结果,持续集成名存实亡。急性的代价:就是科普版里骑士资本那 45 分钟;美国证监会 2013 年的执法文件指其部署流程缺乏自动化与一致性校验。「8 台里有 1 台不一样」,正是脚本化 + 幂等要消灭的东西。
所有构建工具做的是同一件事:把构建拆成若干带依赖关系的目标,拓扑排序后依次执行。分歧在于节点是什么。
产物导向(product-oriented),代表是 Make:节点是要产出的文件。app 依赖 a.o、a.o 依赖 a.c,Make 比对文件时间戳,源比产物新才重做。好处是增量构建天然成立;代价是判据脆弱——时间戳在很多文件系统上只精确到秒,同一秒内的改动会被整个漏掉,而从版本库重新检出会把所有文件的时间戳刷成「现在」。于是团队最终养成「出问题就 clean build」的习惯,增量的好处全还了回去。
任务导向(task-oriented),代表是 Ant / NAnt:节点是一件要做的事。好处是想干什么写什么,不受「必须映射到某个文件」的约束;代价是工具不知道能不能跳过,跳过逻辑得你在 target 里自己写,默认行为往往是全跑。
书里还点出第三条路:约定优于配置(convention over configuration),代表是 Maven——它规定死目录结构与生命周期(src/main/java,compile → test → package → install),你不写脚本、只填配置。作者给它的评价是诚实的两面:新项目开箱即用、团队之间格式统一;一旦需要偏离约定,成本陡增,最后往往得写插件,而插件比原来那段脚本还难懂。
以及第四条:干脆用一门真正的编程语言——Rake(Ruby)、Psake(PowerShell),后来是 Gradle。构建逻辑复杂到需要条件、循环与抽象时,用 XML 表达是自虐。代价是没有约束的语言会让构建脚本长成第二个应用程序——所以这条路必须配上下一节的纪律。
这是本章的灵魂,也最常被跳过。构建与部署脚本要进版本库,和它服务的代码同仓同分支,走同样的评审。理由很直接:代码的某个版本,只能用同一版本的脚本正确地构建和部署。脚本放在别处(某台 CI 机器的「构建配置」界面、某人的 home 目录),你就永久失去了「回到三个月前那版并原样重现」的能力。
同规矩还派生出几条纪律:分层去重,公共逻辑抽成可复用模块,别复制粘贴;一律用相对路径,写死 /opt/app 的脚本只能在作者那台机器上跑;构建产物输出到源码树之外,否则 clean 只能靠 .gitignore 和运气;制品与修订号绑死,文件名或元数据里带上提交号,生产出事才能一路查回那一行代码。
还有一条容易反着做:「测试失败」必须让构建失败,但别一失败就立刻中止。把「有失败」记成标记,让这一轮跑完再在末尾整体失败——这样一次构建能看到全部失败,而不是修一个、发现下一个、再等十分钟。
这是全章最具操作性的一条:开发机、CI、预生产、生产,跑的是同一份部署脚本,环境之间的差异只以外部配置的形式存在(承 Ch2:配置也进版本库,只是和代码分开管)。
为什么这么硬?因为部署脚本本身就是被测对象。你在预生产把部署演练了二十遍,如果生产用的是另一份脚本,那二十遍一次也没测到真正要跑的那段代码。「只在发布日才第一次执行的代码」就是风险本身,而每个环境一份脚本,正是在系统性地制造这种代码。
代价要认:脚本得参数化,参数一多就长出自己的复杂度,得有一套配置管理方案(配置仓库、模板渲染、密钥托管)托住它。原则是让环境之间只差配置,不差行为——一旦某个环境需要「多做一步」,先问这一步能不能在所有环境都做。
幂等的定义是:不管目标机器现在处于什么状态,脚本跑完之后它都处在正确的状态;再跑一遍,什么也不变。
它和「把手工步骤录成 shell」的差别是根本性的。命令式脚本写的是「解压、建目录、改配置、启动服务」,一旦第三步崩了,机器停在中间状态——重跑会因目录已存在而失败,只能上机手工收拾。收敛式脚本写的是「这个目录应当存在、这个配置应当是这样、这个服务应当在运行」,每次先读当前状态、只改不一致的那部分。于是「重跑一次」成为一个永远安全的动作——这条性质正是自动化敢于无人值守的前提。
这也是 2010 年前后 Puppet / Chef(以及稍后 Ansible)那一代工具的共同设计。本章还配了一条同源纪律:按变更频率分层部署——操作系统与中间件、应用二进制、配置、数据与 schema 各一个脚本、各自的节奏。混在一起的后果是,改一行配置也要把中间件重装一遍。
书里有一条偏工程口味但很管用的建议:能用 RPM / deb / MSI 就别自己发明打包格式。它们自带依赖声明、安装 / 卸载 / 升级的标准动作,以及「这台机器上装了哪些版本」的可查询数据库——自己写脚本都得重造一遍,且大概率造得更差。代价是跨平台要维护多套包,包管理器的抽象在需要精细控制时也会碍事。
最后是集成冒烟测试:部署脚本的最后一步应当是一小段「它真的活了吗」的检查——进程在不在、端口通不通、数据库连得上。只花几秒,却把常被混为一谈的两件事分开:「脚本返回 0」不等于「服务能用」。
表 1 · 四类构建工具:各自买到什么、代价是什么
| 产物导向 Make | 任务导向 Ant/NAnt | 约定优先 Maven | 通用语言 Rake/Gradle | |
|---|---|---|---|---|
| 节点是什么 | 要产出的文件 | 要做的一件事 | 固定的生命周期阶段 | 任意代码 |
| 增量判据 | 文件时间戳 | 自己在 target 里写 | 各插件自行决定 | 自己写(现代实现按内容哈希) |
| 表达复杂逻辑 | 很差 | 差(在 XML 里写分支) | 要写插件 | 好 |
| 主要代价 | 时间戳判据不可靠,逼出 clean build | 默认全跑,慢 | 偏离约定后成本陡增 | 容易长成第二个应用 |
| 什么时候选 | C/C++ 这类文件到文件的编译链 | 存量项目、流程简单 | 结构标准的新 Java 项目 | 构建逻辑本身有复杂性 |
表 2 · 部署脚本:一套共用 vs 每环境一份
| 同一套 + 外部配置(本章主张) | 每个环境一份脚本 | |
|---|---|---|
| 生产上跑的那段代码测过吗 | 测过,在每个环境跑了几十遍 | 没有——它只在生产跑过 |
| 加一个新环境 | 加一份配置 | 复制一份脚本,从此多一个漂移源 |
| 改一处公共逻辑 | 改一个地方 | 改 N 处,漏一处就是事故 |
| 环境确实有差异 | 必须显式写进配置,可 diff 可评审 | 藏在脚本里,没人说得清差在哪 |
| 代价 | 需要配置管理方案,参数会变多 | 短期最省事,长期最贵 |
表 3 · 三种部署范式:命令式 / 幂等收敛 / 不可变替换
| 命令式步骤脚本 | 幂等收敛(Puppet/Chef/Ansible) | 不可变替换(镜像 / 容器,本书之后) | |
|---|---|---|---|
| 中途失败怎么办 | 卡在中间状态,要人上机清理 | 直接重跑 | 丢掉,换一个新的 |
| 配置漂移 | 会持续累积 | 每次收敛掉 | 不存在——机器不改,只换 |
| 上手成本 | 最低 | 中,要学声明式模型 | 中高,要有镜像构建与编排 |
| 主要代价 | 不可重跑,自动化不敢无人值守 | 收敛逻辑自身可能有 bug,边界难写对 | 有状态的部分(数据、缓存)仍要另想办法 |
| 什么时候选 | 一次性任务、脚手架 | 长生命周期的存量主机 | 云 / 容器上的无状态服务 |
这一章的三条规矩今天已沉淀成行业默认,只是换了名字:「构建脚本进版本库」变成 CI 配置即代码(Jenkinsfile、工作流 YAML 就躺在应用仓库里);「同一套脚本部署到每个环境」变成同一个容器镜像 + 每环境一份参数;「幂等」变成声明式基础设施的立身之本——Terraform 的 plan / apply、Kubernetes 的调谐循环,都是图 3 右边那个回路。
面试里常被追问的那句「为什么同一个制品要一路走到底」,答案的后半截——脚本也要一路走到底——就在本章。
{Fast, Correct} - Choose two,并主张「Bazel rebuilds only what is necessary. With advanced local and distributed caching, optimized dependency analysis and parallel execution, you get fast and incremental builds.」——把本章「依赖图 + 增量」的思路推到极致:判据从 Make 的时间戳换成对输入的精确建模。bazelbuild/bazel README ↗① 一句话:构建与部署脚本是生产代码——进版本库、分层、可重构、可测试;本章其余所有规矩都从这里长出来。
② 构建工具的本质是遍历依赖图;分歧在节点是文件(Make,靠时间戳,判据脆)还是任务(Ant,默认全跑)。
③ 约定优先(Maven)让标准项目开箱即用,代价是偏离约定后成本陡增;通用语言 DSL 表达力强,代价是构建脚本会长成第二个应用。
④ 最硬的一条:所有环境共用同一套部署脚本,差异只放外部配置——否则生产上跑的那段代码从未被测过。
⑤ 幂等:脚本描述的是目标状态而非步骤,跑 N 遍等于跑 1 遍;这是自动化敢于无人值守的前提。
⑥ 按变更频率分层部署:操作系统与中间件 / 应用 / 配置 / 数据,各一套脚本、各自的节奏。
⑦ 工程纪律:相对路径、产物出源码树、制品绑定修订号、测试失败要让构建失败但先跑完再报、能用系统原生包就别自己造。
⑧ 部署的最后一步是冒烟测试——「脚本返回 0」和「服务能用」是两回事。
⑨ 数据是幂等的例外:schema 迁移天生不可重复执行,靠版本化迁移脚本解决(Ch12)。
⑩ 落到今天:CI 配置即代码、同镜像多环境、Terraform / Kubernetes 的声明式调谐,都是本章的直系后代。