柴锋(Odd-e)

在现代软件工程的复杂世界里,“依赖管理”这个词汇对开发者来说再熟悉不过。我们习惯于在 package.jsonpom.xml 中审视那些明确列出的软件包,认为这就是依赖管理的全部。然而,这仅仅是冰山一角。一个真实的、贯穿始终的故事,将揭示冰山之下那片更广阔、也更危险的水域,它最终将引向一个更深层次的命题:在大规模软件系统中,“信任”究竟从何而来?

一场关于“信任”的三重考验

这个故事,我是亲历者,也是全程的参与者。2018年,一家通信设备公司面临某国政府公开对其产品存在“后门”的尖锐质疑。为了自证清白,公司开启了一场长达两年的马拉松式技术验证,这期间经历了三轮不断升级的严苛考验。这段经历不仅是本文探讨问题的缘起,更是一次宝贵的契机——我与客户方一位核心技术管理者在共同应对挑战时,因对工程哲学的深刻共鸣而开启了此后几年的深度合作研究。故事,就从这里开始。

第一重考验:源码审计

要证明产品安全,最直接的办法就是审查源码。于是,公司将产品的全部源码提交给对方政府,由其联合多家顶级安全与软件公司进行长达半年的逐行审计。最终的结论是:源码是干净的,未发现任何后门。

然而,第一个问题随之而来:源码是安全的,产品就一定安全吗?听起来答案显而易见,但事实远非如此。

第二重考验:构建复现

质疑方很快提出了一个更深入骨髓的问题:“我们承认源码是干净的,但我们部署在网络里的产品,真的是用这份源码构建出来的吗?”

这是一个极为合理的疑问,直指“阴阳源码”的可能性。为了打消顾虑,公司必须在对方完全控制的基础设施中,重现整个构建过程,且最终产物必须与线上部署的产品做到“比特级别一致”,就算有差异,也必须是可以解释的,比如由时间戳、文件路径、排序等引起的差异。这是一个近乎疯狂的任务,因为复现一个规模庞大、技术栈复杂的系统本就困难重重。但经过又一个半年的努力,构建成功复现,证明了产品与源码的一致性。

第三重考验:终极质询

源码可信,产品由源码构建,这下总该放心了。但对方抛出了第三个,也是最致命的问题:“你们怎么证明,在如此复杂的构建过程中,每一个步骤都是安全、可审计的?没有任何机会可以悄无声息地引入风险和后门?”

这个问题直指现代软件工程的核心——生产过程。它不再纠结于可见的源码,而是拷问那些看不见的环节:构建脚本里有没有隐藏任务?构建过程中用到的所有工具,有没有可能故意修改源码或结果?这并非杞人忧天,2024年的 “xz 后门事件” 就是一个血淋淋的教训:攻击者没有修改源码,而是通过修改构建脚本注入了漏洞,成功绕过了源码审查。如果不是被意外发现,全世界大量的网络服务器都将门户大开。

为了回答这个终极问题,公司又投入了约一年的时间,最终证明了其构建流程的每一步都是安全的。故事的结局出人意料,但也在意料之中,尽管公司证明了一切,对方政府依然决定在未来几年内全面替换其产品。

从“信任”到“工程化信任”

看似两年的巨大投入付诸东流,但该公司高层却认为这是一次宝贵的学习机会,是一场深刻的公司内部革命。他们甚至想感谢当初提出这些问题的审查机构负责人,因为正是这些问题,帮助公司照见了自己工程体系中最深层次的挑战和不足。

为此,他们在前后5年里累计投入约20亿美元,专门用于提升软件工程能力。他们得出一个核心结论:在大规模、长周期的复杂系统中,“信任”早已不是一种感觉或承诺。信任,本身必须被设计和实现出来。信任,是一种工程能力。

而构建这种“工程化的信任”,其核心,就是本文真正要讨论的主题:依赖管理

重新定义问题:看见依赖的整座冰山

一提到依赖管理,我们脑海里浮现的通常是 package.json 里的 dependencies,或是 pom.xml 里的 dependency 标签。我们知道,这些依赖的变更会影响产品的功能和构建,它们的错误或缺失会导致功能异常或无法构建。

但这,就是依赖管理的全部了吗?难道只有这些变化才会影响软件制品及其构建过程吗?

让我们想一想,你用的哪个版本的编译器(这是 工具链依赖 )、一个细微差异的环境变量(这是 环境依赖 )、Makefile 里的编译选项(这是 配置依赖 ),甚至用于代码生成的模板文件(这是 数据依赖 )。这些看不见的“隐性”依赖,和代码变更一样,都能导致产品失败,或者引入致命的漏洞。它们的变化对产品功能和构建产生的影响,与那些软件包是一样的。既然随着研发的进行,这些要素也会变更、升级,那为什么不能也叫它们是依赖呢?

因此,我们必须将视角拉高,站在软件“生产”的全过程来看,并引入一个更为宏大的概念——“广义依赖”

“广义依赖”是指所有会影响软件生产过程和最终结果的要素。这不仅包括我们熟知的代码依赖(可称之为“狭义依赖”),更涵盖了以上提到的所有“隐性”依赖。

这个“广义依赖”网络,在真实世界里到底有多复杂?在一个真实的、拥有超过8400万行代码、由1500多名开发者维护超过10年的大型通信产品中,其“广义依赖”网络错综复杂,仅仅是其构建视图的15%就足以让人望而生畏。

之所以要从这样一个巨大规模的案例来看,是因为随着规模的增长,很多原本细小琐碎、经常被我们无视的问题,就会被指数级放大。大规模研发场景就像一个放大镜,帮助我们看清问题背后的本质。

当这个“广义依赖”网络变得如此庞大且大部分不可见时,我们日常工作中的那些痛点就来了。这些看似混乱的现象,可以归结为三大核心痛点:

  1. 变更扩散路径不清晰:底层一个库的小改动,到底会影响谁?影响范围有多大?没人能完全说清楚,精确评估影响范围成了一个不可能的任务。至于开发环境、构建环境的变更所带来的影响,则常常被忽视。一次看似平常的工具链升级,就可能让整条流水线停摆,故障不断。
  2. 集成滞后且脆弱:我们的CI/CD充满了不确定性,“本地能跑,CI挂了”或“CI出错了,本地无法复现”的场景屡见不鲜。随着规模增加,我们不得不在“快但不完整”和“完整但很慢”之间痛苦地选择。我们的集成过程,充满了大量的人工干预,以及……祈祷。
  3. 构建效率低下:一次完整的发布构建,需要7到8个小时。这意味着开发者提交代码后,要到第二天才能知道结果。为了提速,我们只好退而求其次,每次提交代码只做局部的集成和测试。但这样会把更大范围的集成问题,推迟到每个月发布版本的时候。每次整体集成时,团队都要花上一周甚至更久的时间来解决这些累积的集成问题。

必然的演进:从“黑盒遍历”到“显性工程化治理”

面对这样的困境,我们必须寻求演进。在小规模时,我们依赖构建工具的“黑盒遍历”模式。比如我们运行 make,它会自己扫描文件,计算和发现应该做什么。这很直接,也很有效。但随着规模增长,哪怕你一行代码都没改,仅仅是“全局扫描”这个动作本身,就成了巨大的浪费,是研发流程的最大瓶颈。

在一个有大约2000名开发者、使用 Bazel 构建的有数百万行代码的项目中,“黑盒遍历”的瓶颈也暴露无遗。这里有两个巨大的时间开销:首先,哪怕你一行代码都没改,仅仅是启动构建后,Bazel 对整个代码仓进行扫描分析,这个“全量扫描”阶段,就需要6到8分钟。除此之外,为了利用缓存,系统还需要下载约40G由大量小文件组成的中间制品,光是下载就需要30多分钟。大家可以想象一下,在CI流水线上,有上千个构建节点同时运行,并发地进行着全量扫描,又同时去下载这40G的缓存,这常常会引发网络风暴,直接拖垮网络,让所有人的构建时间都变得更长,而且极不稳定。

所以,新的模式是必然的选择,那就是“显性工程化治理”。简单来说就是“分而治之”。它的核心思想非常简单:我们不能再被动地依赖工具去“发现”了,我们必须主动地、显式地去定义和管理 我们的工程体系。我们要把过去隐性的东西,全部显性化。

这种“分而治之”,和我们过去为了提速而做的“局部集成”,有着本质区别。过去的局部集成,往往依赖开发者的直觉和手动选择,但这很容易造成影响范围的错误评估,也很容易遗漏掉隐藏的依赖,最终把风险和问题都推迟到最后的大集成阶段。而这里所说的“分而治之”,是基于精确、显性的广义依赖网络 ,进行系统性的、可预测的拆分。它的目标不是简单地“少构建一点”,而是精准地“只构建必要的部分”

这样带来的改变是极为明显的:系统能够自动、快速地计算出任何变更的精确影响范围,然后只针对这个范围进行完整的构建和测试,而其他所有未受影响的部分则可以直接复用缓存。并且这种复用是以构建单元为粒度进行打包和下载的,避免了过去下载海量小文件的低效方式,效率也更高。对于开发者来说,体验就从“为了快,我只测一小块,然后祈祷别出问题”变成了“我每次都运行完整的全局构建,但系统能让它在几分钟内就完成”。这就在速度和完整性之间找到了完美的平衡。

显性治理的三大支柱:拆解、封装、验证

如何实现“显性工程化治理”?其答案可以归结为三大核心实践支柱,它们环环相扣,共同构建起一个可靠的软件生产体系。

支柱一:拆解 (Chunking) —— 定义清晰的边界与输入

“拆解”是“分而治之”的前提,其核心是“小而有界” (Make it small and bounded)。它要求我们将庞大的系统,拆解成一系列具有清晰边界、可独立管理的构建单元。然而,这项工作之所以困难,其根源在于我们用来指导拆解的“地图”——那张广义依赖全景图,本身就是不可见和不完整的

要有效拆解,我们必须首先解决这张“地图”本身的三大缺陷,并采取相应的实践方法:

  1. 挑战:边界不清晰为了方便维护,我们常常会把源码各部分对环境的依赖合并后集中管理。这带来了好处,但恰恰是这种好处,模糊了每个构建单元的真实广义依赖边界。声明的依赖冗余了,就会在无关变更时重复构建,浪费资源;声明的依赖遗漏了,就会错误地命中缓存,造成难以排查的集成问题,最终只能靠“清空缓存再试一次”来解决。
  2. 挑战:存在“隐含依赖”CI服务器上的一个环境变量,基础镜像里的一个系统类库版本,这些真实地影响着构建结果的要素,却从未被正式地声明和管理,导致我们的依赖地图是不完整的。
  3. 挑战:连接被“切断”一个C++库怎么影响到一个Java服务?这种关系往往隐藏在某个CI脚本里,从任何一个语言的依赖工具看,这条连接都是不可见的。还有更糟糕的“幽灵依赖”,比如通过相对路径直接引用文件,这完全绕开了构建系统的感知。

为了绘制一幅精准的地图,我们的实践核心是将所有隐性要素显性化

  • 精准拆解构建上下文:我们必须明白,相同的源码并不能确保得到相同的制品。因此,我们拆的不仅仅是源码,更是它背后的完整构建上下文。每一个构建单元都必须拥有一个独立的、精准且完备的构建上下文定义,完整地捕获这个单元所有的广义依赖:工具链、库、配置等等。而且,拆解的过程也可能包括合并,比如把几个小单元合并成一个更大的构建单元。在这个过程中,就必须显式地解决它们各自广义依赖之间的冲突。
  • 发现并固化隐式依赖:让所有“不可见”都变得“可见”。这就要把CI环境、基础镜像、外部工具这些过去被认为是“理所应当”的生产要素,全部进行版本化和声明化管理。虽然我们已经实践了基础设施即代码(IaaC),但很多时候,它也仅仅是把配置用代码的形式保存在了代码仓里。如果我们真的认为这些是代码,那就应该更进一步:不仅要声明,还要像代码一样建立起与业务代码的依赖关系。一旦建立了这种关系,被依赖的要素,就应该被自动化地创建和提供出来。
  • 用声明式关系修复连接断裂:单元与单元之间的依赖关系,必须被显式地声明出来,避免通过命令、相对路径或者一段胶水脚本来连接。

支柱二:封装 (Fabrication) —— 打造可靠且确定性的生产过程

在通过“拆解”定义了清晰的输入单元后,“封装”的目标是“完整可交” (Make it whole and shippable)。它要求依据单元所声明的全部广义依赖,通过一个确定性的生产过程,像一个现代化的工厂一样,可靠地将其构建并组装成可交付的产品。其黄金标准是实现可复现构建 (Reproducible Build)

然而,这一过程面临两大核心挑战:输入的“非确定性”与过程的“不可靠性”

  1. 挑战:输入的非确定性当一套代码需要支持跨平台、多硬件以及多样的用户定制化需求时,有效的产品配置组合便会呈指数级增长,形成“产品配置组合爆炸”。每一个差异化的产品,都是源码、编译选项、功能开关、定制化配置等一系列输入的特定组合。这些组合中的细微差异,都可能通过复杂的构建逻辑判断引入未被预期的行为。更棘手的是,许多无法复现的问题,其根源还隐藏在那些未被版本化管理的“幽灵依赖”中,例如特定的运行时环境变量,或调试时手工指定的命令行参数。问题的复现之所以变得极为困难,正是因为我们难以精确还原导致问题发生的那一组特定的、非确定性的完整快照。
  2. 挑战:过程的不可靠性构建过程中访问了网络,或者依赖了当前的时间戳,这些都会引入未声明的输入,污染我们的广义依赖集。更常见的是为了应急的手工热修复,绕过了标准流程,引入了无法追踪的变更,比如在构建过程中动态修改文件、打补丁等。随着规模增加,构建和发布过程会演变成一个由大量任务组成的复杂依赖图,这又会把不稳定的影响放大,降低整体的可靠性。

为了应对这些挑战,打造一条可靠的生产线,必须遵循三大实践原则:

  • 管理配置组合爆炸 (Managing Configuration Explosion):要解决输入的非确定性,必须从“命令式”转向“声明式”,将不同的产品配置(如功能开关)本身也作为一种显性的依赖来管理。这能极大简化构建逻辑,让复杂的配置组合变得可追踪、可审计,而不是隐藏在脚本的 if-else 判断里。
  • 建立封闭的构建行为 (Hermetic Builds):为了确保过程的可靠,必须将构建过程隔离在“沙箱”中。这能确保输入源的纯净,杜绝一切未声明的外部干扰,如网络访问、随机文件读写、未定义的环境变量等。
  • 保障过程的幂等性 (Idempotency):这是可靠性的核心保证,即“相同的输入,永远得到相同的结果”。更进一步说,一个幂等的构建任务,在成功执行一次之后,立刻再次执行,结果也必须完全相同,不能对环境或产物造成任何累加的副作用。这不仅是为了消除时间戳、随机数等不确定性,更是为了防止构建过程本身污染工作区和环境。只有保证了幂等性,我们才能确保每一次构建都是一次“干净”的执行,从而放心地依赖缓存,也为最终实现可复现构建打下了坚实的基础。

支柱三:验证 (Verification) —— 建立完整且不可篡改的信任链

通过“拆解”我们看清了输入,通过“封装”我们规范了过程。现在,我们来到了最后,也是最关键的一环:“验证 (Verification)”。其目标是“可证可溯” (Prove it and trace it),即为我们每一个交付的制品,提供一份无可辩驳的“身份证明”,让它的全部广义依赖和整个生产过程,都变得透明、可追溯、可审计。

这项工作的核心挑战在于,传统模式下我们缺乏一条统一的、覆盖所有广义依赖的证据链。开篇故事中的三重质询,就完美地揭示了这条证据链是如何层层断裂的:

  1. “源码可信吗?” —— 这仅仅验证了广义依赖中很小的一部分,证据链在此刚刚开始。
  2. “产品是用这份源码构建的吗?” —— 这开始挑战从“源码”到“产品”的转换过程,这是证据链上的第一处关键断裂。
  3. “构建过程本身安全吗?” —— 这实际上是在拷问,构建过程所依赖的其它所有广义依赖,比如基础镜像、工具链,它们都可信吗?这是更深层次、也更隐蔽的断裂。

因此,“验证”的核心实践,就是要为每一个制品,建立一条从所有广义依赖源头,到最终产物,完整、统一、不可篡改的证据链。这条证据链必须是完整的,要超越源码版本,记录所有影响构建的广义依赖——工具链、脚本解释器、环境变量、运行时参数配置、构建中用到的系统类库等等。它也必须是准确的,最终产物的任何一个字节级的差异,都必须能在这条证据链里,找到对应的某个广义依赖输入的变化。

可复现构建 (Reproducible Build),正是对整条广义依赖证据链最强有力的物理保证。它将“信任”,从一句主观的“相信我”,变成了一项客观的、任何人都可以去验证的事实:“你可以自己去验证”。

结论:构建数据驱动的自验证闭环

“拆解(Chunking)”、“封装(Fabrication)”和“验证(Verification)”这三大支柱,并非孤立存在,而是共同构成了一个数据驱动的、自验证的工程闭环,这正是整套体系的核心精髓所在。

首先,它们形成了一条清晰的价值链:精确的“拆解”,为我们定义了清晰、有边界的单元及其广义依赖,这是所有信任的起点。这些清晰的输入,驱动了可靠的“封装”生产过程。可靠的过程,产出了带有完整广义依赖证据链的制品,从而可以用于“验证”。

更重要的是,“验证”的结果为这个闭环提供了强大的反馈机制。一方面,像一次不可复现的构建失败,会立刻暴露我们“拆解”时的不足,驱动我们去发现并固化新的隐式依赖,从而完善我们的依赖地图。另一方面,也是最关键的一点:既然一条完整的证据链能够解释产物的任何差异,那么它本身就构成了一份经过事实检验的、最权威的广义依赖清单。这意味着,我们可以从最终的证据链中反向推导出构建这个产物所需要的一切输入,从而用最终的结果,来验证我们最初对输入的定义是否精准、完备。

至此,一个自验证的闭环便形成了。我们不再是单向地“定义输入 -> 执行过程 -> 得到输出”,而是让输出的结果反过来证明我们对输入的定义是正确的。

回顾我们日常工作中遇到的构建效率、可靠性、漏洞追溯、架构治理等看似孤立、分散的问题,其实它们拥有一个共同的根源——广义依赖管理的缺失。通过“拆解-封装-验证”这套显性治理框架,我们可以在一个统一的视角下,系统性地解决这些问题。最终目标,是建立一个数据驱动的、可追溯、可自验证的,真正值得信赖的现代化软件生产体系。

(END)