第 21 章 构建与制品
副题:复现——同一份源码,任何时候构建出同样的制品
第 20 章的发布事故(互相踩)之后,团队开始建发布流程——第一步,是构建。代码在"开发机"上能跑,不等于在"生产环境"能部署:本地跑得好好的,到了服务器上为什么起不来?本章回答:开发环境到生产环境的转换——源码怎么变成"可部署的制品",以及为什么"构建一次、处处运行"是目标。
本章要建立的认知
- "在我机器上是好的"是复现性破产的症状——构建的目标:同一份源码,任何时候构建出同样的制品;
- 构建把源码变成不可变制品:制品是部署的单位、回滚的单位——"构建一次、处处运行";
- 依赖锁定(lockfile)是复现的基石:依赖不锁,构建就是碰运气。
21.1 在我机器上是好的
阶段 4 的发布流程建设,从一次"在我机器上是好的"开始。
新同事小李部署测试环境,服务起不来,报错信息他没见过。排查半天,他憋出第一句话:"我本地跑得好好的。"这句话在联调时就听过,现在在部署时又听到了。"在我机器上是好的",是环境问题的标准开场白。这次的原因有三个,正好是构建要解决的三个问题。
先把排查过程走一遍,这是"环境问题定位"的实景。小李把报错贴给开发者。开发者的第一反应不是读报错,是问三个问题:本地能跑吗?能。测试环境 Node 版本多少?14。本地呢?18。三个问题,定位到第一个原因。修好之后,前端白屏——第二个原因。再修好之后,功能又不对——第三个原因,依赖版本差异导致行为不同。这次排查给环境问题的定位定了一个顺序:先比对环境(版本、依赖、配置),再读代码。环境出问题时,问题不在代码本身,而在代码运行的那层环境——所以先比对环境,永远是第一步。
顺便补一笔"在我机器上是好的"的心理学,它解释了这句话为什么总在深夜出现:开发者对"我的机器"有完全的控制,对"服务器"没有。本地能跑,是"我环境里的事实";线上不能跑,是"另一个环境的事实"。两个事实之间的桥,就是构建。
现在逐个看原因。**原因一:Node 版本不一样。**本地是 Node 18,测试环境是 Node 14——服务起不来就是它。版本漂移是环境问题里最隐蔽的一种:多数时候它不报错、不提示,只是行为不一样;这次运气好,直接起不来,反而一查就中。修好之后,开发者问了一个关键问题:"下次怎么保证两边版本一样?"答案是依赖锁定(21.5)。**原因二:前端没构建。**测试环境直接放了前端源码,浏览器打开页面,白屏。原因是:源码不是浏览器能跑的东西。JSX、TypeScript 类型、ES 新语法,浏览器都不认识。源码要经过构建,变成浏览器能跑的产物。**原因三:依赖没锁。**同一个项目,小李本地的 node_modules 和原作者本地的版本不一样——因为没有 lockfile。锁版本是保证环境一致性的保险:npm install 每次都装"当时最新的",两个人装了两次,就装出两份不同的依赖。两个保险原本应该是双重的——环境一致、依赖一致——现在一层都没有。
三个原因摆在一起,有一个共同点:开发环境和生产环境之间的"转换",没有机制——本地是怎么跑起来的,生产不知道。构建就是把这个转换变成机制:让"能跑"从一个环境的现象,变成一个可复制的产物。
三个原因该先解决哪个?伤害排个序。依赖漂移最隐蔽:不报错,只行为不同,线上出了怪问题要查半天。前端没构建最直接:白屏一眼看到,但"为什么白屏"对新手不显然。这里要区分一下,前面遇到过两种白屏:一种是性能问题导致的,一种就是这里的——源码没构建。判别的方法很简单:打开浏览器控制台,看不到加载报错、页面却是空的,多半就是产物没构建出来。依赖没锁最持久:今天修好了,明天新人再来一遍。三个原因,对应三个修复:锁版本、建构建、进流程。
为什么要建构建体系?收益清单就是答案:
- 可复现:同一源码,任何时候构建出同样的制品——"在我机器上"从此不再重要;
- 可回滚:制品有版本,部署能指回去——出事故时"先回滚"这条原则,在制品侧有了抓手;
- 可审计:谁构建的、从哪个提交、何时——交付侧也要记账,出了问题查得到源头。
三笔收益,正好对应三个"没有构建"的痛点:环境事故、无法回滚、部署不可查。
最后交代一句构建体系的演进:手搓部署——阶段 4 的起点,谁改完谁部署,构建也是手搓的:本地跑一遍打包命令,把产物传上去;到构建脚本——把打包命令写成脚本,少敲几行;到构建体系——本章的依赖锁定、制品仓库、版本管理;再到下一步的自动化。每一步,都是"原方案无法承受"逼出来的:手搓在 8 人团队时崩了——每个人搓得不一样;脚本在"要回滚"时崩了——没有版本怎么回。演进的路数只有一个:当前方案承受不住了,就升级到下一层。
21.2 构建:从源码到制品
构建(build)是指把源码(人写的、需要加工的东西)变成制品(机器能直接跑的东西)的过程。源码是给人看的,制品是给机器跑的,中间隔着编译、打包、转换。前面讲过网页是三层分工的产物,构建就是这三层的生产工序:三层各写各的源码,构建负责把它们加工成浏览器和服务器能直接用的东西。
构建的目标用一句话说,就是"构建一次、处处运行":同一个制品,在任何环境——测试也好、生产也好——跑出同样的行为。因为依赖、版本这些环境差异,在构建时已经被锁进制品,配置由部署按环境注入——运行时不再有"我的机器"和"你的机器"的差别。本地跑的是源码,测试和生产跑的是同一个制品,两边环境不同,但跑的东西一样。所以"在我机器上是好的"这句话,在构建体系建立之后应该绝迹。
"源码 vs 制品"的直觉,可以靠一个类比立稳:菜谱和做好的菜。源码是菜谱——人读的,可以讨论、可以改;制品是做好的菜——直接上桌,不能回锅再改;构建是烹饪。同一个菜谱、同一个厨房,做出来的菜应该一样——反之,烹饪环境不一样,菜就不一样,这正是要锁死构建环境的原因。"构建一次、处处运行",翻译成菜谱的语言就是:同一道菜,端上所有的桌子。
构建还暗合了一个前面反复用到的思想:分层。写代码时按职责分层——表现、业务、数据——交付链路同样按阶段分层:源码、制品、部署。层与层之间是多对多的关系:同一份源码可以产出多个制品,比如前端制品加后端制品;同一个制品可以部署到多个环境。可以说,"层"的思想从代码内部延伸到了交付链路,工程的地图上,这一站有了具体的形态。
"构建好不好"要可判断,就立三个验收标准:
- 可复现:同一源码、同一份锁、同一个构建环境,产出同一个制品;
- 可回滚:每个制品有版本,部署随时能指回上一个;
- 可追溯:生产上跑的制品,能查到它来自源码的哪个提交——源码到制品的链上,每一步都有记录。
三个标准合起来,构建体系就是"源码到生产"的一条可信通道:每一步都可验证、可回退、可追溯。
制品有两个性质,它们决定了构建体系怎么设计。第一,不可变性:制品一旦构建出来,就不能改——要改,就重新构建一个新版本。制品是只读的快照,所谓"改制品",正确做法是改源码、再构建一次。制品的版本号就是它的身份证,改了内容就该换新证。第二,可回滚性:正因为制品不可变,部署可以随时回到上一个制品。事故处置的第一原则是"先回滚",而回滚的单位就是制品——"回到版本 N-1",而不是"改几行代码再上线"。制品因此是部署的单位,也是回滚的单位。
制品和源码之间还有一个数学味道的关系:制品 = 源码 + 锁 + 环境的确定性函数。同一输入,同一输出,这就是可复现的严格表述。正因为是确定性函数,"生产跑的制品"才能反推出"它来自哪个源码提交"——制品的元数据里记着来源。可以说,制品是源码在交付链上的投影:源码是真相,制品是投影,而投影是可以验证的。
最后一句:前端和后端的构建,形态不同,因为各自要解决的环境差异不同。前端构建把源码变成静态文件——浏览器直接下载运行;后端构建把源码变成可执行制品——服务器直接启动运行。同一个"构建"词,两种产物形态,接下来的两节分别展开。
21.3 前端构建:打包的艺术
前端构建要解决的问题,一句话:源码——JSX、TypeScript、新语法——变成浏览器能跑的静态文件。这个"源码变制品"的过程,拆开来是四步:
源码(JSX/TS/多个文件)
→ ① 依赖解析(import 关系图:这个文件用了哪些模块)
→ ② 编译(JSX → JS、TS → JS、新语法 → 兼容语法)
→ ③ 打包(合并成一个/几个文件,减少浏览器请求数)
→ ④ 产物(静态文件:app.a1b2c3.js、style.css…)
四步里的"打包",是前面优化章节的构建侧兑现。之前讲的是"为什么优化",这里讲的是"构建时怎么做到"。两个例子。第一个,CSS 拆分:一个 2MB 的 CSS 文件不能全塞给首屏,正确做法是把首屏用到的内联进 HTML,其余的按页面拆分、按需加载——这个拆分不是运行时做的,是构建工具在打包时做的。之前说过的"首屏 CSS 内联",执行者就是构建工具。第二个,版本号哈希:app.a1b2c3.js 这样的文件名,是构建时自动生成的内容指纹——文件内容变了,哈希就变,文件名就变,浏览器和 CDN 把它当作全新资源。缓存策略里有一条"URL 就是缓存键",而 URL 长什么样,恰恰是构建决定的——所谓"版本号放 URL 里",那个"放"的动作就是构建。
打包的进阶是代码分割,它回应的是"下载慢是因为内容大"这个问题。思路不是把代码变小——那是压缩——而是把"不急着用的代码"从首屏包里拿出去。最常见的形态是路由懒加载:前端框架的路由在访问某个路径时才下载对应页面的代码,不访问就不下载。"少下载"一直是下载段优化的一半手段,代码分割就是它在构建侧的实现。
让包变小的还有两个机制,各一句定位。压缩:去掉空白、缩短变量名,体积能小 30-50%。Tree-shaking,直译"摇树":把没被用到的代码从包里摇掉——没人再调用的路径就是死代码,tree-shaking 是构建时的死代码清理。打包体积,本质上是源码体积经过这两个机制压缩后的结果——源码里写了多少,不等于用户要下载多少。
构建把代码变形了,调试怎么办?这就是 sourcemap 存在的理由。构建后的代码被压缩混淆,报错行号对着压缩后的文件根本没法看;sourcemap 是源码与产物的映射文件,让浏览器报错时显示的是源码的行号。它是"调试产物"和"看源码"之间的桥——上线后看错误日志排障,靠的就是它。
产物多大算合格?要有预算。首屏包体积就是预算的对象:构建产物的大小直接决定下载时间,1MB 的 JS,大致就是首屏多一到两秒。所以构建时要看大小报告——构建工具会输出每个包的体积——超预算的包就拆,手段就是前面说的代码分割和按需加载。这和之前"拿预算逐段量"的思维是同一个:构建时量体积,和运行时量延迟,都是把预算落成数字。
产物本身长什么样?一个静态文件目录:HTML、CSS、JS、图片,可以原样放到任何静态服务器或 CDN 上。这正是前端构建"处处运行"最容易实现的原因——前端"构建一次"的产物和运行环境无关,任何服务器都托管得了静态文件。
这个目录还藏着构建的设计,值得会看。典型的结构是:index.html 是入口,引用带哈希的 JS 和 CSS;assets/ 目录放哈希命名的文件;外加按需拆分的 chunk 文件。所以产物就是构建的体检报告——构建配置对不对,看产物就知道:有哈希文件名,说明版本策略在;有 chunk 文件,说明代码分割在;看 index.html 的大小,能猜出内联策略。
产物和 CDN 的关系也要说清楚:前端产物直接发布到 CDN——"静态资源走 CDN 短路"那份被缓存的资源,就是构建出来的产物。所以构建是 CDN 的内容来源,链条是这样的:源码变了,构建出新产物,新哈希文件名,上传 CDN,浏览器拉到新版本。之前说的"长 TTL 加版本号"策略,依赖的正是构建的哈希文件名机制——构建、CDN、缓存策略,是同一条链上的三个环节。
工具和框架什么关系?前端框架的官方脚手架自带构建工具,打包、编译、热更新全都内建。构建工具是框架生态的一部分,换框架往往意味着换一套工具链。但机制不变——"源码变制品"这件事,工具只是细节。
前端构建有没有自己的环境差异?有,但要小:构建机上的 Node 版本会影响产物,构建工具的版本差异可能编译出不同的代码。所以构建要在锁定的环境里进行——依赖要锁定,环境要固定,同一环境构建,产物才可复现。
最后,"构建一次"还有一层时间含义,值得单独说:同一份源码,只构建一次。产物构建出来,直接进入测试和生产,不在测试环境"再构建一次"——测试环境重新构建,等于换了构建环境,产物可能不同,可复现就破了。"构建一次、处处运行"的"一次",就是字面的一次。这也意味着,在完整的流水线里,构建只应该出现一次,后面的环节全是"用制品"——这是自动化的流水线会兑现的原则。
21.4 后端构建:可执行制品
后端构建要解决的问题,一句话:源码变成服务器能直接启动的东西。案例用的是 Node.js——选型时定下的语言。Node 的构建和 Java、Go 这类编译型语言不同,它主要是"依赖就位加启动准备"三件事:
源码(JS + package.json)
→ ① 依赖安装(按 lockfile 装,版本精确锁定)
→ ② 产物打包(代码 + 依赖,打成可部署的制品)
→ ③ 启动校验(构建后能启动吗?启动一次验证——"能启动的制品"才算制品)
为什么后端构建对"锁依赖"更敏感?因为后端代码运行在服务器上,依赖版本的差异直接改变行为——前面那个"测试环境 Node 14、本地 Node 18"的事故就是这么来的。前后端在这里有一个关键差别:前端构建把依赖打包进产物,浏览器端没有"装依赖"这回事;后端构建要把依赖锁定并装对,服务器上的 node_modules 是运行环境的一部分。所以后端的复现,一半靠 lockfile——装什么版本——一半靠构建环境——在什么环境装。
把编译型语言拉进来对照一句,更能看清 Node 的特殊性。Java 和 Go 的构建是真正的编译:源码变成字节码或二进制,产物是单一的可执行文件,一个文件,拷到哪都能跑,很"制品"。Node 的制品是"代码加 node_modules 目录",对环境的依赖更多——Node 版本、系统库,缺一不可。这也是容器技术登场的背景之一:把代码、依赖、环境一起打包,正为了解决"制品对环境依赖太多"的问题。
构建还有一个现实问题:时长。构建要花时间——前端打包几十秒到几分钟,后端装依赖加校验更久。而构建时长决定了"改完代码到能部署"要等多久,所以构建必须快——增量构建、缓存依赖,都是常见的加速手段。发布节奏决定工作流,而构建时长决定发布节奏。
三步里的"启动校验",是构建和测试的交界。构建完成后启动一次:连上测试数据库,跑一遍健康检查——这是联调清单的最小版。为什么要多这一步?因为"构建成功"不等于"能启动"。构建的验收标准就一句话:产物能启动。这也是测试思想在构建侧的形态——至少保证"能跑起来"。
那构建和测试怎么分工?构建管"能启动",启动校验,能跑起来就行;测试管"行为对",单元、集成、端到端,验证跑得对。所以构建时至少跑"能启动",完整的测试留给 CI——提交代码后自动跑全量测试。构建的启动校验是 CI 的入口检查,"能跑"和"跑对"是两个验收,分别在构建和 CI 完成,串成一条流水线。
启动校验靠什么形态执行?靠健康检查接口。后端制品暴露一个 /health 接口,返回"服务活着",外加关键依赖的状态——数据库连得上吗。这个接口一个顶三个用途:构建校验调它,启动时确认服务活了;高可用的探活调它,判断实例该不该摘出流量;监控系统的"服务存活"指标也看它。测试环境是生产的彩排,健康检查就是彩排的第一幕。
构建失败怎么处理?一条行为准则:构建失败是红灯,不是意外。构建红了,修复它优先于继续开发——这和主干纪律"主干红了先修"是同一个道理,这里是它的构建版。"构建通过"是提交代码后的第一道自动关卡,将来流水线上它就是第一步。构建失败最常见的原因无非是依赖问题、环境问题、代码问题,排障顺序和环境排查一样:先比对环境,再看代码。
配置什么时候注入?这是构建的一个关键边界,两种时机。一种是构建时注入:构建时把环境变量写进制品。缺点很明显——每个环境要构建一个制品,"同一制品处处运行"就破产了。另一种是运行时注入:制品里留出配置的坑,部署时填——环境变量、配置文件。显然,"处处运行"要求配置在运行时注入:同一份制品,测试环境填测试配置,生产填生产配置。制品不含环境,环境由部署注入——构建和部署的边界,在这里有了执行细节。
数据库迁移在什么时候跑?这是发布流程里最容易漏的一步。表结构要改,就要写迁移脚本,这个前面立过规矩。但迁移的时机有讲究:发布时跑,而且先跑迁移,再部署新制品。顺序反了,新代码连旧表结构,直接报错。所以发布的认知从这里立住:发布等于迁移加制品,两个动作,顺序不能乱。
最后是一个最容易被忽略的角落:回滚时,迁移怎么办?代码可以指回旧制品,但数据库已经迁移过了,不能"指回"——表结构改了就改了。所以迁移的回滚靠的是向前兼容:迁移脚本要设计成新代码兼容旧结构的过渡形态,或者迁移本身可逆。这就形成了回滚体系的第一个不对称:制品可回滚,数据库只能前进。完整的回滚策略,到部署那一节再展开。
21.5 依赖锁定:复现的基石
先给依赖锁定下一个定义:把每个依赖的精确版本锁进一个文件——package-lock.json 一类——安装时照单安装,"装当时最新的"变成"装锁定的版本"。它是可复现的第一块基石。
为什么要锁?因为"当时最新的"是复现的敌人。包管理工具的默认行为,是装"当时最新的兼容版本"——而今天的"最新"和下周的"最新"不一样。没锁的依赖,构建时间不同,依赖就不同,制品就不同。前面那个事故里,两个人各装一次,装出两份不同的依赖,就是这么来的。lockfile 的作用,是把"时间"从构建的变量里剔除:构建不再依赖"当时",只依赖"锁里"——如果用"制品是源码、锁、环境的确定性函数"这个比喻来说,lockfile 就是让函数少一个输入变量。
"不可复现"一共有三种来源,恰好对应那场事故的三个原因:
- 依赖漂移:没锁版本,两次安装装出两份不同的依赖。lockfile 消灭它——照单安装,全世界装出一样的。
- 环境差异:构建机、开发机、测试机的 Node 版本、系统不同。靠锁定构建环境消灭,下面展开。
- 构建不纯净:构建过程依赖了"当时的状态"——某次构建改了文件、手工改了产物。靠不可变制品消灭——制品只读,改制品等于重新构建。
三种来源里,"环境差异"要靠锁构建环境来解,具体怎么做?三条。第一,专用构建机:一台固定的机器或容器跑构建,不是"谁的电脑都能构建"。第二,构建环境版本化:构建机的 Node 版本、系统版本固定并记录——决策记录的习惯用在这里:环境也要记录。第三,构建脚本化:构建命令写进脚本和配置文件,不靠记忆。一句话收束:构建环境和源码一样要版本管理。环境变了,复现就破——后来出现的容器技术,正是"环境也打包"的终极形态。
锁有了,还要有使用纪律,否则锁等于没锁。三条。第一,依赖升级是显式动作:改配置文件里的版本,重新生成 lockfile,和源码一起提交——升级是"故意"的,不是"碰巧"的。第二,lockfile 要进仓库:和源码一起版本管理,它的变更也要有评审——它和代码一样,是项目的成员。第三,CI 里校验 lockfile:构建时用 lockfile 安装,装出来的依赖和记录不一致就报错——"照单安装"是 CI 的第一条检查。
锁和测试什么关系?测试也要"照单"跑。本地测试用本地依赖,CI 测试用 lockfile 依赖,两边依赖不一致,测试结果就不可复现。"我本地过了,CI 红了"——这种扯皮的根源还是依赖。所以说,lockfile 是测试可复现的前提:测试是对行为的承诺,承诺要可复现才有效,锁依赖让"测试通过"从环境事实变成代码事实。
lockfile 是什么时候更新的?看它的生成机制。它是安装时自动生成的:第一次 install 生成,之后照它装。升级依赖时重新生成:改版本,重新安装,lockfile 更新,提交。所以 lockfile 的更新只来自"显式升级"——日常安装不会动它,动它一定是因为有人故意升了版本。锁的语义就是:日常不动,动必有因。
回到那场事故,修复要闭环。修好之后,开发者做了三件事:lockfile 提交进仓库——下次谁装都是同一份依赖;构建脚本写进项目——打包命令不再靠记忆;列了第一版部署清单——装依赖、构建、启动校验,这也是发布单的雏形。事故的修复价值就一句话:把"这次怎么修的"变成"下次自动这么修"。事故意味着漏掉了某个承诺,而这里的承诺,就是构建体系。
锁不是一劳永逸,依赖还要升级,节奏怎么把握?分三类。安全补丁及时升:依赖是攻击面,安全更新不能拖。大版本谨慎升:破坏性变更要测试兜底,测试是升级的安全带。升级和功能开发分开:升级 PR 里只升级,不夹带功能——"PR 要小"的纪律,升级 PR 是它的典型场景。
最后补一句 lockfile 里锁的到底是什么。依赖不是平铺的,是树:A 依赖 B,B 依赖 C,node_modules 里套着 node_modules。lockfile 锁的是整棵树的精确版本——不只直接依赖,还有间接依赖:"B 用的 C 是哪个版本"也锁住。两次安装要真正一样,直接依赖和间接依赖必须都锁住。前面说的"依赖版本漂移",漂移往往就出在间接依赖上:直接依赖升级时,间接依赖被悄悄换掉——这正是锁要锁全树的原因。
21.6 制品仓库与不可变性
构建产出了制品,制品接着要存放——放进制品仓库,也就是放制品的地方,像代码仓库放代码一样——还要管理:版本和保留策略。这一节把这两件事展开。
先看版本。每个制品有一个唯一版本,比如 build-20260815-a1b2c3,由构建时间加哈希组成。版本是制品的身份证:部署时记录"现在跑的是哪个版本",回滚时才知道"回到哪个版本"。所以一句话:没有版本号的制品,是没法回滚的制品。
版本之外还要记一个状态:"哪个版本正在跑"。当前测试环境是哪个制品、生产是哪个制品,这个部署状态要有记录——因为它是部署问题的第一问:出了事先问"跑的是哪个版本",再查"为什么不对"。排查顺序永远是从已知出发,先知道跑的是什么,才谈得上查它。
制品存进来之前,构建本身要先过一道自检,这就是幂等性。同一份源码、同一个 lockfile、同一个构建环境,构建两次,产物应该一样——哈希文件名相同,就是内容指纹一致的直接检验。用前面的比喻说,构建是"函数":输入是源码加锁加环境,输出是制品,输入相同,输出必须相同——这正是"复现"的定义。如果两次构建的哈希不同,说明构建环境不纯净,也就是前面说的三类不可复现来源之一。幂等性是可复现性的自检:不用等到部署出问题,构建时就能发现。
制品仓库长什么样?一句话定位:就是"按版本存放制品的地方",形态上是对象存储或制品库服务,支持随时"指回某个版本"。它和代码仓库的分工是:代码仓库放源码,是人改的;制品仓库放制品,是机器产的。两个仓库,一条链:源码、构建、制品。
制品和实例是什么关系?将来系统会有多台服务器,而所有实例部署同一个制品——"处处运行"在多实例时代,就是"同一制品跑在 N 台机器上"。所以制品是部署的原子单位:一个制品,N 个实例,行为一致。
不可变性在制品仓库里怎么执行?一条铁律:制品一旦发布,内容不可改。想改?重新构建一个新版本。这里要分清"改制品"和"改源码"是两件事:改源码、再构建,出来的是新制品,干干净净;直接改制品,是破坏复现——部署了一个"和源码对不上"的东西,前面说的"构建不纯净"就是这么发生的。所以不可变性的意义在于:部署的东西,等于构建过的东西,等于源码的准确产物——从源码到生产的整条链,每一步都可追溯。
制品上还带着元数据:源码提交号、构建时间、构建环境、依赖锁版本。元数据是"可追溯"的载体,它让"生产跑的制品对应源码的哪个提交"一键可查。排障时问"这个 bug 是哪个版本引入的",查的就是它——和代码历史里查"这行是谁改的"是同一个动作,只是查的对象从源码换成了制品。制品不只是代码,是"代码加出处"。
仓库里的制品不能无限堆。保留最近 N 个版本——回滚需要它们——更老的归档或删除。制品是要占存储的,每个版本都是成本,保留策略就是成本管理,复杂度预算的原则在这里同样适用。
两条延伸,各说一句。第一条,架构将来要演进:系统拆成多个服务之后,每个服务有自己的制品和构建链——内容服务一个制品,订单服务一个制品。本章的"构建一次、处处运行",到那时候就变成"每个服务各自构建、各自部署"。构建体系的复杂度跟着架构长:先是一个制品,再是多个制品。第二条,制品仓库本身是供应链攻击的目标:攻击者可能污染仓库,让"官方制品"带后门。所以制品也要有信任模型:仓库的访问控制——谁能上传制品;制品校验——下载的制品和构建时的一致吗。签名验签的思想,从网络通信延伸到了制品环节。
最后收一笔,回到回滚。事故处置的第一步是回滚,这句话在制品侧怎么落地?把部署目标指回上一个制品。注意,不是重新部署源码,而是指回制品——这让回滚从"重新上线"变成"指回版本",秒级和小时级的差别。完整的回滚机制,到部署环节再展开,这里先把坐标系立住:版本、状态、可追溯。
21.7 构建与部署的边界
构建讲完了,立一条边界:构建管什么,部署管什么。这条边界是认知基础,能避免"构建部署混在一起"的糊涂账。构建管:源码到制品——依赖、编译、打包、版本,本章讲的全部内容。部署管:制品到运行环境——放到哪台服务器、怎么启动、环境变量、流量切换。两者的关系是:构建的产物是部署的输入。构建说"这个制品可以跑",部署说"这个环境跑它"。职责分开的意义在排查时能兑现:构建出问题查构建——那是复现问题;部署出问题查部署——那是环境问题。那句老话"测试环境是生产的彩排",在这里有了更准的类比:构建就是彩排的剧本——同一份制品,彩排和生产,用的是同一个。
边界立好了,交接要清点。部署之前,构建侧要确认三件事:制品版本——部署哪个制品;配置——这个环境的环境变量、域名;迁移——数据库迁移跑了没有,顺序是先迁移再部署。这三项,就是发布单的第一版——当年那份联调清单的部署版,从这里正式长大。
顺着彩排再说透一层。同一份制品的完整旅程是:先部署到测试环境——彩排;然后验证——联调、测试;最后部署到生产——正式演出。构建体系让彩排的意义更清楚了:彩排和生产用的是同一个制品,彩排验证的,就是生产将要跑的东西。所以"彩排"不再是"模拟",而是"同一出戏的预演"——不是本地环境的复制品,是正式演出本身的提前上演。
部署侧有两个方式,先混个脸熟,后面会展开。蓝绿部署:两套环境并存,新版本在旁边验证好,再把流量切过去。金丝雀:先放 1% 的流量试试水,没问题再全量。这两个花样能成立,前提正是本章的不可变制品:制品是切换的单位——切到新制品,切回旧制品,都是一个动作。可以说,部署的所有花样,都建立在制品的不可变性上。
构建和协作怎么衔接?一句话:构建配置也是代码。构建脚本、流水线配置,进仓库、走 PR、被评审——和源码、测试一起提交。构建体系这条链,和代码走的轨道是同一个:两个仓库,一条链,链上跑的是同一套协作流程。
"构建一次、处处运行"这句口号,也要立一条诚实的边界。制品是同一个,但环境配置不同——测试库和生产库、测试域名和生产域名,都不一样。配置不在制品里,前面讲配置时机时立过规矩:制品留坑,部署时注入。"处处运行"指的是代码行为一致,配置由部署注入——正因为制品不含配置,处处运行才成立;配置进了制品,每个环境就得构建一个制品,复现就破产了。
最后一句,防止误解。构建体系建起来之后,本地开发仍然是跑源码的——热更新、断点调试,都需要源码态,这是框架开发模式的设计初衷。"构建"只发生在交付链路上:本地开发用源码,测试和生产用制品。环境分层里那句"本地是开发的自留地",在构建体系下依然成立。一句话收尾:"处处运行"是交付侧的承诺,不是开发侧的束缚——本地怎么方便怎么来,交付怎么一致怎么来。
21.8 本章对应表
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 本地能跑线上不行 | 构建(源码 → 制品) | 环境差异在构建时锁进制品 | 构建系统要维护 |
| 前端源码浏览器不认 | 前端构建(编译/打包/哈希) | JSX/TS 要变成浏览器能跑的 | 打包体积要优化(8/16 章) |
| 后端依赖版本不一致 | 后端构建(锁依赖+启动校验) | 依赖差异直接改变行为 | 对环境依赖仍多(23 章容器) |
| 构建不可复现 | 依赖锁定(lockfile)+ 锁定构建环境 | 照单安装,两次构建一样 | 升级是显式动作(要流程) |
| 部署能回滚 | 不可变制品 + 版本号 | 制品只读,回滚=指回版本 | 制品要存(保留策略) |
| 构建和部署谁管什么 | 职责划分(构建出制品/部署放环境) | 出问题知道查哪边 | 配置注入归部署(23 章) |
本章小结
- "在我机器上是好的"是复现性破产的症状——构建的目标:同一份源码,任何时候构建出同样的制品;
- 构建把源码变成不可变制品:部署的单位、回滚的单位——"构建一次、处处运行";
- 依赖锁定是复现的基石:依赖不锁,构建就是碰运气;
- 阶段 4 的发布流程第一步落地:下一步,把构建自动化——整条链路能不能自动。
下一章
构建体系建起来了:源码变成制品,制品可以部署。但新问题出现了:每次发布,构建要人跑、测试要人跑、检查要人做——人跑的构建,会忘、会跳、会偷懒。
第 22 章,CI/CD:从代码提交到自动上线——把"人跑流程"变成"机器跑流程":提交代码 → 自动构建 → 自动测试 → 自动检查 → 自动部署。