
软件依赖管理:省下代码之后,代价才刚刚开始
你有没有见过这样的代码审查?
某位同事为了在 Go 服务里把字节数格式化成 64 GB,执行了一次 go get。业务代码只多出一行 import,看起来人畜无害。可等 go.mod 和 go.sum 提交上来,模块图里又多出十几个谁也没审查过的间接依赖。你问他为什么,他会很无辜地回答:「现成的轮子不用,难道还要自己造吗?」
话是没错。依赖当然可以省下开发时间,可咱也不能只看眼前少写了几行代码,却不看项目从此背上了什么。
现代后端几乎不可能摆脱依赖。Web 框架、数据库驱动、日志组件、RPC、配置中心、指标采集,哪一样都不是凭空长出来的。开源生态让我们站在巨人的肩膀上,只是巨人下面还站着另一个巨人,go mod graph 展开后又是一大片模块节点。层层叠叠,蔚为壮观。至于最下面那个模块是谁维护的、发布账号有没有被盗、项目是不是已经弃坑,通常没人知道。
一条 go get 命令可以把几天的实现工作压缩成几秒。复杂度却没有消失,只是从「我要自己写代码」,转移成了「我要长期信任、更新、审计并在必要时替换别人的代码」。今天省下的是实现成本,未来要支付的,则可能是安全、兼容、维护乃至事故成本。
所以我不反对依赖。我反对的是只计算引入时省下的工作,却把后续代价统统留给未来那个倒霉的自己。
依赖更像一份没有签字、没有期限,却默认自动续约的维护合同。每执行一次 go get,我们都在扩大服务的信任边界,也在认领一份未来的工作。更要命的是,这些代码最终会被编译进后端二进制,以服务进程的权限接触网络、数据库和用户数据。这篇文章要算的,就是这份合同在整个生命周期里究竟有多重。
一、依赖的代价从哪里来?
一行 import 为什么这么重?
从源代码看,依赖不过是一行 import。从系统边界看,它却是一段由别人设计、别人维护、别人发布,并且最终在你的进程里运行的代码。你省下了实现它的时间,也把一部分控制权交给了上游。
这里至少有四类东西需要分清:
- 直接依赖:源码直接
import,并由主模块在go.mod中直接要求的模块 - 间接依赖:通过其他模块进入构建列表,通常在
go.mod中标记为// indirect - 运行时依赖:相关代码被编译进服务二进制,直接影响性能、稳定性和安全性
- 测试与工具依赖:供测试、代码生成、静态检查或构建流程使用,同样会出现在模块关系中
很多人只盯着第一类。其实攻击者并不在乎你是不是亲手安装了某个包。只要那段代码能进入构建流程、读到环境变量,或者最终跑进生产进程,它就是你的风险。
后端的故障半径尤其大。Go 依赖通常不是待在浏览器沙箱里,而是直接编译进一个长期运行的服务:它可能持有数据库凭据,能够访问内网,处理来自公网的不可信输入,还承担整条业务链路的流量。一个解析漏洞可能泄露数据,一个失控的 goroutine 可能拖垮实例,一个被投毒的模块则可能继承服务拥有的全部权限。依赖越靠近入口、鉴权、存储与 RPC 边界,代价就越沉重。
这种代价也不只表现为安全漏洞。它至少会从五个方向找上门:
- 信任代价:你需要相信维护者、发布账号、包仓库和构建流程没有被攻破
- 变更代价:上游可以调整 API、默认行为、运行时要求乃至许可证
- 维护代价:团队要持续阅读公告、处理升级、补测试并响应漏洞
- 耦合代价:依赖的类型、异常和调用方式一旦渗进业务,替换就会牵一发而动全身
- 退出代价:项目弃坑或风险失控时,你得迁移、自行维护,或者接受它继续留在系统里
这几笔代价不会在安装完成时一起弹窗收款。它们被摊进未来数年的开发、运维与安全工作里,所以格外容易被低估。
更麻烦的是,依赖不是一个静止文件,而是一条生命周期:
引入只是最轻松的一步,轻轻敲下一条命令就结束了。真正沉重的是后面的固定、升级、排错、迁移与清理。倘若只算安装时省下的两小时,却不算未来五年里升级失败的五个下午,这笔账当然怎么看都划算——因为最贵的部分根本没被写进账本。
因此,我衡量依赖时很少问「这个包有多少行」。我更关心:
- 它进入了哪个信任边界?
- 它坏掉时会影响什么?
- 它停止维护后谁来接手?
- 它爆出漏洞时,我能多快替换?
代码量只是表象。一项依赖真正的重量,是它在整个生命周期里要求团队持续承担多少责任。
为什么要固定依赖项?
假设有人在 CI 里直接执行 go get example.com/logging@latest。今天解析到 v1.4.2,下个月重新构建时可能就变成 v1.6.0。业务代码一个字符没改,进入二进制的外部代码却已经换了一批。
于是就出现了经典场面:开发者说「我这里没问题」,CI 说「我不这么认为」,生产环境则干脆用一场事故表达自己的独立见解。
Go Modules 已经替我们解决了大半问题,但它的机制和传统锁文件并不一样。根据 Go Modules Reference,Go 使用最小版本选择 (Minimal Version Selection, MVS) 计算构建列表:依赖图上的每条边声明一个最低要求,Go 在这些要求中取最高者,得到满足整个模块图约束的最低选择。只要 go.mod、依赖模块的 go.mod 与工具链条件不变,选择结果就是确定的。
这里尤其要澄清一个常见误区:go.sum 不是版本锁文件。 它可能同时保留同一模块多个版本的哈希,也可能包含当前构建已经不需要的记录。go mod tidy 会补齐缺失项并清理多余项,但真正决定使用哪个版本的是模块图和 MVS,而不是 go.sum。
不过,下面三个概念不要搅成一锅粥:
go.mod:记录模块要求、Go 版本以及replace、exclude等选择规则- 构建列表:MVS 根据完整模块图计算出的实际版本集合,可用
go list -m all查看 go.sum:记录模块文件与源码压缩包的加密哈希,用来验证下载内容是否一致
go.sum 能发现代理或源站把同一版本偷偷换了内容,却不能证明那份内容本身没有恶意逻辑。默认的 Go 校验数据库还能提供公开模块哈希的全局一致性,但私有模块通常通过 GOPRIVATE 或 GONOSUMDB 绕过公共校验。因此,版本选择、内容完整性与代码可信度是三件事,谁也不能替谁上班。
固定依赖能带来四种非常实在的确定性:
- 构建可复现:昨天成功的依赖树,今天仍然能被还原
- 环境一致:本地、CI、预发布和生产不再各自抽奖
- 变更可审查:依赖变化会明确出现在
go.mod与go.sum的 diff 中 - 故障可回滚:出事时能退回一组已知可工作的版本
所以我赞成提交 go.mod 与 go.sum,在构建时使用经过审查的版本,不要把 @latest 当作 CI 配置。固定并不是把项目封进琥珀,而是把「某天悄悄发生」的变化,变成「在一张 PR 里公开发生」的变化。
固定的是当前状态,不是未来命运。
固定依赖项要付出什么成本?
既然固定这么好,能不能把所有版本锁住,然后十年不动?
当然能。古墓里的东西也很稳定,只是通常不适合继续运行。
固定依赖带来了确定性,也把后续责任留在了项目里。至少有五张账单会陆续寄来。
第一张是安全成本。旧版本里的已知漏洞不会因为 go.mod 很整齐就自行惭愧、原地消失。漏洞公开后,攻击者获得的信息和防守者一样多;你拖得越久,留给攻击者试错的时间越长。
第二张是兼容成本。依赖升级通常不是匀速的。你若持续从 v1.4.1 升到 v1.4.2、v1.5.0,修改可能很小;如果从 v1.4.1 一口气迁移到带 /v6 路径的 v6.0.0,import 路径、废弃 API、配置格式、工具链要求和默认行为会结伴上门。省下来的每一次小升级,最后都会合并成一张巨型账单。
第三张是生态成本。Go 模块可以提高 go 指令要求,框架、代码生成器与工具依赖也可能要求更新的 Go 工具链。一处长年不动,整条链都可能被卡住。最后不是「这个模块升不了」,而是「Go 版本升不了、其他依赖也跟着吃不到修复」。
第四张是维护成本。要有人看发布公告、处理更新 PR、读迁移指南、运行 go mod tidy、审查模块图并补回归测试。go.mod 让版本选择可控,却不会代替人做判断。把依赖固定下来而不安排维护,只是把随机故障改造成延期故障。
第五张是认知成本。这是最隐蔽的一张。系统每天都能构建,团队便很容易把「还能跑」误认为「仍然健康」。直到某次安全扫描拉出两百条告警,大家才发现不是岁月静好,而是监控器一直没通电。
版本固定和版本更新并不是敌人。它们一动一静,恰好组成完整策略:
用锁定获得确定性,用更新偿还确定性的成本。
只锁不更,叫僵化;只更不锁,叫漂流。两个极端都省脑子,也都很费项目。
二、更新依赖项为什么也有供应链风险?
既然依赖会不断变化,最稳妥的办法是不是把它锁死,能跑就再也别碰?
不行。固定版本只能阻止未经审查的变化,不能让旧版本停止衰老。可一旦决定更新,我们又要重新接纳一批外部代码。不更新会积累已知风险,更新则会引入未知变化,这正是依赖管理最棘手的地方。
新版本一定更安全吗?
安全扫描器说 example.com/parser v1.2.0 有漏洞,升级到 v1.2.1 即可修复。看起来很简单:改版本、跑测试、合并,下班。
但我们常常忘记一件事:更新依赖,本质上是在允许一批新的外部代码进入系统。
新版本可能修复旧漏洞,也可能带来新漏洞;可能由原维护者打上版本标签,也可能由刚接手仓库的陌生人发布;代码审查过的是一个提交,最终被 Go module proxy 缓存的却可能是另一个版本。版本号只说明它比旧版本新,绝不等于经过了安全公证。
Go 官方的供应链安全文章把「依赖发布了新的恶意版本」直接列为需要防范的场景。正常提交 go.mod、go.sum 并启用校验时,新版本不会在我们毫不知情时进入构建;但只要维护者账号或源码仓库已经失陷,我们又主动把 go.mod 升到那个版本,恶意代码仍会堂堂正正地被编译进服务。
go.sum 和校验数据库解决的是「同一个版本有没有被偷偷换货」。如果某个恶意版本从发布之初就是那副模样,哈希只会非常忠诚地证明:没错,你每次拿到的都是同一份恶意代码。完整性很重要,但完整性不等于可信。
这也解释了为什么「永远升级到最新版」不是安全策略,只是一种版本偏好。
风险从哪里进入项目?
软件供应链的入口比想象中多。只要从源码到制品之间有一环需要信任,就有一环可能出问题。
常见风险大致包括:
- 维护者账号被盗:攻击者取得发布权限,发布带后门的新版本
- 项目恶意接管:原作者无力维护,把项目交给了动机不明的人
- 模块路径失控:域名、仓库或组织发生转移,原有模块路径指向了新的控制者
- 私有模块泄露:
GOPRIVATE配置错误,把内部模块路径发给公共代理或校验服务 - 间接依赖投毒:不攻击热门模块本身,而是攻击它依赖的冷门模块
- 运行时自动执行:恶意逻辑藏在
init()、解析路径或异常分支里,随服务启动或特定输入触发 - 工具链执行代码:代码生成器、静态检查器和构建工具在 CI 中读取源码、凭据并修改制品
replace被滥用:主模块悄悄把可信模块路径替换成未经审查的 fork 或本地路径- CI 与构建链被污染:被引用的 Action、容器标签或在线脚本发生变化
这些风险有个共同点:攻击者总喜欢找成本最低的那一环。一个明星项目可能有严格的代码审查和分支保护,但它依赖的某个小工具,也许只有一位维护者,使用弱密码,没有双因素认证,发布流程还是在笔记本上手工执行。正面攻城太累,何不从下水道进来?
间接依赖因此格外麻烦。我们评估了模块 A,A 要求 B,B 又要求 C。某次升级改变了模块图,MVS 为 C 选择了更高版本。整个过程中,业务代码一行没变,最终编进二进制的代码和可信任边界却已经变了。
SLSA 对 Provenance 的定义很值得记住:来源证明描述制品在何处、何时、如何生成。它不能证明代码没有恶意逻辑,却能帮助我们回答「这包东西究竟是谁、用什么流程,从哪份源码构建出来的」。这就像食品标签不能保证好吃,但连生产日期和厂家都没有,我劝你还是别往嘴里塞。
哈希固定、签名验证、来源证明、可信仓库,它们解决的是不同环节的问题。安全不是找到一个银弹,然后对着供应链 biu 一枪;而是让攻击者每穿过一环,都要再付一次成本。
不更新与自动追新,应该选哪个?
长期不更新,风险很清楚:已知漏洞不断累积,生态逐渐脱节,最后连升级路径都失传。
完全自动追新,风险也很清楚:新版本发布几分钟后直接进入生产,相当于把包作者的发布按钮接到了你的部署按钮上。作者打个喷嚏,你的服务器都要跟着抖三抖。
二选一?我一个都不选。
更稳妥的做法是固定当前版本,自动发现更新,人工审查变更,分阶段完成验证。若风险不高,可以给新版本留几天观察期,让生态先帮忙踩坑;若是正在被利用的严重漏洞,则缩短甚至跳过观察期,快速升级并加强监控。
这里没有放之四海而皆准的等待天数。一个只在本地把 Markdown 排版的开发工具,与一个暴露在公网、负责解析不可信输入的运行时组件,显然不该享受同等待遇。安全工作的核心从来不是背诵固定答案,而是根据资产、权限、暴露面和威胁,决定哪件事先做。
所以,依赖更新的正确心态不是「同步一下版本」,而是:
这是一笔外部代码变更。请像审查业务代码一样审查它。
三、依赖项可以随意引入吗?
当然不可以。
原因现在应该很清楚了:每增加一个依赖,项目就多出一份需要持续支付的维护责任。go get 没有弹出红色警告,不代表这个模块值得引入。pkg.go.dev 文档齐全、GitHub 上星星很多,也都不构成免责条款。
我见过两种极端。
一种人对所有依赖深恶痛绝,恨不得从红黑树开始手搓数据库。另一种人则把 Go Module 当自助餐:判断切片是否包含元素,引一个工具库;包装一层错误,再引一个工具库;读两项环境变量,还得找个配置框架。前者容易造出只有自己能维护的残次轮子,后者会让一个本来清爽的 Go 服务长出盘根错节的模块图。
真正的问题不是「要不要造轮子」,而是复用它所创造的价值,能不能覆盖项目为它承担的全生命周期成本。若答案是否定的,哪怕 go get 只需一条命令,这个依赖也不该进来。
什么时候应该自己写?
如果功能只有十几行、规则稳定、测试容易写,而且引入第三方模块会附带更多间接依赖,我通常倾向自己写。比如简单的字符串裁剪、单位格式化、固定数据结构转换。代码放在自己仓库里,行为一目了然,改起来也无需等待上游。
但有些领域千万别逞强:
- 密码学算法与密钥处理
- 复杂协议与文件格式解析
- 数据库驱动
- Unicode、时区、国际化等边界条件密集的领域
- 需要长期跟随标准或安全公告更新的实现
这些代码表面上也许不长,坑却密如繁星。自己写一个「看起来能用」的加密算法,往往只是把安全问题从依赖列表搬进业务仓库,然后顺手关掉了外部审计。
我的判断标准很朴素:
| 情况 | 更倾向自研 | 更倾向引入 |
|---|---|---|
| 功能复杂度 | 小而稳定 | 复杂、边界条件多 |
| 专业门槛 | 团队能够完整验证 | 需要长期专业投入 |
| 依赖规模 | 引入成本明显大于功能 | 成熟依赖能省下大量工作 |
| 变化来源 | 完全由业务控制 | 跟随标准、平台或安全变化 |
| 可替换性 | 自有实现容易维护 | 外部方案接口稳定、替代充分 |
别拿「不要重复造轮子」当金科玉律。车轮值得复用,拴车轮的那根绳子未必值得下载一个包。
测试和工具依赖就没有风险吗?
「它只在测试和代码生成时使用,又不会被编进生产服务。」
这句话听起来很有道理,前提是开发者电脑、CI、签名密钥和发布令牌都不重要。
Go 没有一份与运行时模块图彻底分离的「开发依赖清单」,但测试包、代码生成器、Linter 和 Go 1.24 之后的 tool 依赖同样需要管理。它们经常拥有比运行时代码更夸张的权限:读取整个仓库、访问 CI 环境变量、使用云凭据、生成源码、修改构建产物,甚至参与发布。
一个恶意代码生成器不需要进入生产二进制。只要 CI 执行了 go generate,它就能在生成文件里留下后门,再由正常的 go build 把后门编进去。测试与工具依赖和生产依赖的执行位置不同,风险形态也不同,但绝不是没有风险。前者更偏向构建链、凭据和制品完整性;后者更偏向运行时攻击面、数据与可用性。
类似地,GitHub Actions 中的第三方 Action、Dockerfile 里的基础镜像、curl | bash 下载的在线脚本,也都是依赖。它们没有出现在应用的包清单里,不代表可以法外逍遥。
新增依赖应该由谁把关?
至少不能由执行 go get 的人独自决定。
新增依赖应该作为代码审查的一部分。PR 里不只提交 go.mod 和 go.sum,还要回答几个问题:
- 为什么现有能力不能解决?
- 为什么选择这个包,而不是其他候选?
- 它带来了多少直接与间接依赖,模块图发生了什么变化?
- 它会在哪里运行,拥有什么权限?
- 谁负责后续更新?
- 如果项目停止维护,准备怎么退出?
低风险小工具可以轻量审查,高权限运行时组件则应由安全、架构或对应领域负责人复核。门槛应当与风险匹配。给所有依赖开一场三小时听证会是形式主义;什么都不问就合并,则是把未来事故写成了盲盒。
我尤其不喜欢「先 go get 再说」。因为依赖一旦被大量调用,替换成本会飞速上涨。引入前多问十分钟,远比两年后迁移三百个调用点便宜。
四、一个依赖是否值得引入,应该如何评估?
评估依赖,不是在寻找一个「绝对安全」或「永远稳定」的包。这样的东西不存在。我们真正要做的是把收益和代价都摊在桌面上:它解决的问题有多难,节省了多少工作;它又会带来多少信任、维护、兼容、安全和退出成本。
只有收益明显大于这笔长期支出,并且最坏情况仍在团队承受范围内,引入才算合理。
它解决的问题值得引入依赖吗?
先别急着研究项目有多少 Star。第一个问题应该是:这个问题真的值得增加一个依赖吗?
我会依次问:
- 这是一个真实、持续存在的问题,还是为了某个一次性需求?
- 标准库、平台能力或项目已有依赖能不能完成?
- 自己实现并测试需要多少工作?
- 引入之后,未来维护、升级、审计和替换需要多少工作?
- 这个依赖提供的能力,我们究竟会用到多少?
有些库功能丰富得像瑞士军刀,项目却只用里面的开瓶器。附带能力越多,往往意味着更多代码、更多配置、更多攻击面,也意味着上游一次与你无关的改动,可能依然迫使你升级。
如果确实需要引入,我还会尽量把第三方 API 包在自己的适配层后面。业务代码依赖内部接口,第三方实现藏在边界处。这样做不是为了炫耀架构,而是给未来留一扇逃生门。否则依赖的类型、异常和调用习惯渗透到处,哪天想换,只能掘地三尺。
项目本身健康吗?
打开 GitHub 首页,看见十万 Star,安全感油然而生。可惜 Star 更像人气投票,不是体检报告。
评估项目健康度,我更关心这些信号:
- 最近是否仍有稳定发布,而不是突然诈尸
- Issue 与 PR 是否有人处理,处理方式是否专业
- 是否有多个活跃维护者,还是所有权限都系于一人
- 是否发布变更日志与迁移指南
- 是否有清晰的版本策略、弃用周期和支持范围
- 是否具备自动化测试、代码审查、分支保护和发布流程
- 是否提供安全策略与私下报告漏洞的渠道
活跃也不是提交越多越好。一个成熟底层库半年不改,可能因为它已经足够稳定;一个每周重写 API 的项目倒是很热闹,可用户活得像在追公交。我们要结合项目性质判断,而不是拿一把尺子量天下。
OpenSSF Scorecard 能自动检查危险工作流、分支保护、依赖固定、漏洞与维护状态等信号,是很好的筛查工具。可它仍然只是辅助。分数高不代表绝对安全,分数低也不等于项目有恶意。工具负责把疑点圈出来,人负责理解疑点意味着什么。
另外,维护者是否可持续也很重要。大量基础设施由少数志愿者维护。一个包被全世界使用,不代表维护者就有全世界的预算。若项目对业务至关重要,企业除了消费,也应该考虑赞助、贡献修复、参与维护。开源不是一口永不枯竭的许愿井。
安全与合规能接受吗?
接下来检查安全。
先看已知漏洞,但不要只看「漏洞数量」。一个存在漏洞、维护者当天响应并发布修复的项目,未必比一个从不发布安全公告的项目更差。零漏洞可能代表代码完美,也可能代表没人检查。重点是漏洞是否影响我们的使用方式、是否有修复版本、维护者如何响应。
再看模块图。一个模块本身写得很好,却携带上百个间接依赖,审计面仍然会扩大。特别注意 init()、cgo、代码生成器、工具依赖、网络访问和文件系统权限,以及是否需要读取敏感环境变量。
然后看制品来源。理想情况下,发布包能对应到公开源码、确定的提交和受控构建流程;更进一步,可以验证签名或来源证明。对高风险依赖,甚至可以尝试可复现构建,确认源码能产出相同制品。
最后别忘了许可证。MIT、Apache-2.0、GPL、AGPL 不是网页底部的装饰。不同许可证对署名、修改、分发、专利授权与源码开放有不同要求。一个技术上完美的依赖,如果许可证与产品分发方式冲突,仍然不能引入。
安全和合规都不是「法务或安全团队以后再看」。依赖进入代码库后再发现冲突,通常已经有大量调用。此时移除就像装修完才发现承重墙砌错了:不是不能改,只是每一锤都很贵。
引入之后能管得住吗?
有些依赖质量不错,却并不适合你的项目。
比如,一个库为了方便打包了整个浏览器内核,而你只想解析一个 URL;一个组件性能极好,却只支持 Linux;一个 SDK 每个月发布破坏性变更,而你的产品一年才有一次升级窗口。脱离自己的约束谈「好依赖」,意义不大。
至少要检查:
- 体积:客户端包体、容器镜像和冷启动是否能接受
- 性能:CPU、内存、I/O 与并发模型是否符合场景
- 兼容性:操作系统、架构、浏览器、运行时版本是否匹配
- API 稳定性:上游是否频繁破坏接口,是否有迁移窗口
- 可观测性:出错后能否定位,日志和指标是否足够
- 可替换性:是否存在替代品,迁移需要改多少业务代码
- 内部能力:团队是否有人理解它,关键时刻能否排错
我会特别检查退出方案。假设明天它停止维护、修改许可证、被披露严重漏洞,我们怎么办?能换包,能自己维护一个受控分支,还是整个系统都得推倒重来?
依赖负责人也应该在引入时明确。负责人不是要求某个人二十四小时盯着 GitHub,而是确保告警有人接、升级有人推、取舍有人解释。没有负责人,就意味着所有人都以为别人会管。
如何形成一张依赖准入清单?
把上面的判断整理成清单,团队就不必每次从哲学起源开始辩论。
| 维度 | 要回答的问题 | 常见证据 |
|---|---|---|
| 必要性 | 不引入是否有更简单的方案? | 方案对比、工作量估算 |
| 维护状态 | 项目是否持续、可靠地维护? | 发布记录、Issue、维护者数量 |
| 安全 | 已知漏洞与供应链措施如何? | 安全公告、Scorecard、来源证明 |
| 模块图 | 带来多少间接依赖与工具代码? | go.mod diff、go mod graph |
| 许可证 | 是否符合使用和分发方式? | SPDX 标识、许可证文本 |
| 权限 | 会在哪里执行,能访问什么? | CI 配置、运行时权限 |
| 兼容性 | 是否满足平台、性能和体积要求? | 基准测试、兼容矩阵 |
| 可替换性 | 出问题时能否退出? | 适配层、候选方案、迁移估算 |
| 责任人 | 谁处理更新和安全告警? | CODEOWNERS、依赖台账 |
清单最好按风险分级。不进入生产调用路径、没有 cgo 与生成器、容易替换的小工具,简要说明即可;处理不可信输入、运行在高权限环境、参与构建发布的模块,则需要更严格的审查。
有几类情况我会直接否决:
- 来源无法确认,发布者身份可疑
- 许可证与项目用途明确冲突
- 存在与使用场景相关的严重漏洞,且没有可行缓解措施
init()、cgo、生成器或工具模块的行为无法解释,或索取明显不必要的权限- 项目已经停止维护,同时又没有可接受的接管或替换方案
准入不是给依赖颁发终身荣誉证书。它只是说明:在当前证据和约束下,我们认为这项依赖创造的价值足以覆盖代价。项目健康、维护者、许可证和风险都会变化,这个结论自然也要定期复查。
五、如何定期更新依赖项?
定期更新不是为了让版本号看起来新,也不是每隔一段时间集体折腾一次。它是在持续偿还引入依赖时就已经产生的维护责任。
更新越规律,每次需要理解和验证的差异就越小;拖得越久,安全补丁、破坏性变更和生态迁移就越容易堆成一笔还不起的债。与其几年后做一次惊心动魄的大迁移,不如把代价拆小,按节奏处理。
先看见自己依赖了什么
管理依赖的第一步,不是更新,而是盘点。
你需要知道服务依赖哪些 Go 模块、哪些基础镜像、哪些 GitHub Actions、哪些系统组件,以及它们之间怎样传递。go list -m all 可以查看当前构建列表,go mod graph 可以展开模块关系,go mod why -m 则适合追问某个模块究竟为何而来。只扫描源码里的直接 import,就像只统计公司正式员工,却忘了外包、供应商和门口拿万能钥匙的保洁阿姨。
SBOM (Software Bill of Materials) 可以理解为软件的「配料表」。NIST 对 SBOM 的定义是:记录构建软件所用组件及其供应链关系的正式清单。它的价值不在于生成一个漂亮的 JSON 文件,而在于出事时能快速回答:
- 我们是否使用了这个组件?
- 使用的是哪个版本?
- 它通过哪条依赖链进入?
- 哪些产品和环境受到影响?
SBOM 也不是护身符。清单过期、组件标识不准确、生成后无人消费,一样只是仓库里又多了一份电子尘埃。它必须跟构建流程一起生成,并接入漏洞响应、资产管理和发布流程。
除了清单,还要为依赖分级。我通常会把公网入口、身份认证、反序列化、密码学、数据库驱动、构建发布链视为高风险;纯本地、低权限、容易替换的工具风险较低。告警来了先处理谁,不能靠谁在群里喊得响。
建立固定节奏
依赖更新最怕两件事:完全没有节奏,以及所有东西同一天有节奏。
我倾向于设置三个窗口:
- 每周:处理补丁版本、明确的安全修复和低风险更新
- 每月:整理普通次版本升级、清理积压 PR、复查例外项
- 每季度:规划大版本迁移、检查弃用组件、无人维护项目和许可证变化
这不是行业标准答案,只是一个可执行的起点。发布频繁的互联网服务可以更快,验证周期很长的嵌入式或受监管系统可能更慢。但无论周期多长,都要给紧急漏洞留一条快速通道。正在被利用的漏洞不会因为今天不是「依赖更新星期四」就礼貌离开。
更新应该小步、频繁、可回滚。一次升级一个关键依赖,失败时容易定位;把八十个包塞进同一张 PR,测试红了都不知道该找谁算账。
这也解释了为什么长期不更新会越来越难。差异越小,变更越容易审;差异越大,升级越像考古。你不仅要理解现在的 API,还得补完中间五年的文明断层。
让自动化提 PR,而不是自动上线
Dependabot、Renovate 一类工具很适合做发现和搬运工作,Go 自己也提供了 go list -m -u all 来查看可用更新。Go 官方依赖管理文档给出的流程同样克制:先发现新版本,再用 go get module@version 选择明确版本,最后运行 go mod tidy 同步依赖关系。发现更新和决定升级,从来都是两件事。
我喜欢自动化,但不喜欢把判断外包给自动化。比较稳妥的配置包括:
- 限制同时打开的更新 PR 数量
- 按生态、风险或相关组件分组
- 为 PR 自动分配负责人和标签
- 将安全更新与普通版本更新区分优先级
- 对低风险补丁允许自动合并,但前提是测试、策略和来源校验全部通过
- 对高风险依赖保留人工审查和分阶段发布
新发布版本可以设置观察期。原因很简单:恶意发布、打包错误和兼容性回归,往往会在最初几天暴露。让子弹飞一会儿,有时比冲上去用脸接住更有工程价值。
但观察期不能机械执行。若旧版本存在正在被利用的漏洞,继续等待本身就是风险。此时应结合暴露面、缓解措施和攻击活动做决定。策略是为了帮助判断,不是为了逃避判断。
此外,应统一开发机与 CI 的 GOPROXY、GOPRIVATE、GONOSUMDB 策略。企业可以使用受控的私有代理缓存并审核公共模块,私有模块路径则必须避免泄露给公共服务。不要为了图省事全局设置 GOSUMDB=off,那等于主动放弃公开模块的一致性校验。容器和 CI Action 也应尽量使用不可变摘要或完整提交哈希,而不是会漂移的 latest、main 标签。
OpenSSF Scorecard 将 Pinned-Dependencies 与 Dependency-Update-Tool 同时作为检查项,这个组合很有意思:既要让 Go 的构建列表保持确定,也要有机制定期审查和改变它。安全从来不是静止不动。
把每次升级当作代码变更
一个合格的依赖升级 PR,至少应经过下面几步。
第一,读变更日志、发布说明、安全公告和迁移指南。不要只看机器人写的「Bump example.com/parser from v1.2.0 to v1.2.1」。真正重要的是默认配置有没有变化、是否修复了与你相关的漏洞、是否提高 Go 工具链要求、是否新增遥测、是否弃用了接口。
第二,审查 go.mod、go.sum 与模块图。确认变化来自预期模块,检查有没有新增间接依赖、replace 指令、cgo 代码、生成器或工具依赖。go.sum 的 diff 很长,不代表可以闭眼;可以先用 go mod tidy -diff、go mod graph 和 go mod why -m 把变化还原成能理解的关系。
第三,测试并扫描。单元测试保证局部行为,集成测试验证组件协作,端到端测试检查关键用户路径。对解析器、认证库、数据库驱动等高风险组件,还要补针对性回归测试和恶意输入测试;再用 govulncheck 检查已知漏洞是否真的触达当前代码的调用路径。
第四,逐步发布。先进入预发布环境,再小流量、分批次推广。观察错误率、延迟、资源占用与业务指标。测试只能证明测过的东西,生产观察用来发现那些我们根本没想到要测的东西。
第五,准备回滚。旧版本、旧镜像、旧配置必须仍然可得,数据库或数据格式迁移则要提前确认能否逆转。没有回滚路径的更新,本质上是在拿生产环境参加一次不能交白卷的考试。
可以把流程收敛成一条简单流水线:
如果某一步失败,把原因记录下来。也许是测试不足,也许是上游破坏兼容,也许是内部适配层失控。别让经验只存在于某个已经离职同事的聊天记录里。
更新不动时怎么办?
总有一些依赖升不动。
可能是新版本不再支持旧运行时,可能是插件生态没跟上,也可能上游已经弃坑。此时先判断:这是暂时的兼容问题,还是依赖已经进入生命末期?
可选方案通常有五种:
- 临时固定:接受短期风险,但明确期限、负责人和缓解措施
- 本地打补丁:只修复必要问题,同时保留补丁来源和重放方式
- 维护受控分支:适合业务关键且团队确有维护能力的项目
- 迁移替代品:通过适配层逐步切换,避免一次性翻修
- 彻底移除:若价值已经小于维护成本,挥泪斩马谡
最糟糕的处理叫「先不升,之后再说」。没有日期的「之后」,通常等于永远;没有负责人的「我们」,通常等于没人。
每个例外都应该记录风险、缓解措施、责任人和复查日期。到期后重新判断,而不是自动续杯。技术债并不可耻,假装没借过钱才可怕。
六、依赖管理如何成为团队制度?
依赖的代价最终一定会被支付。区别只在于,是团队有计划地投入时间维护,还是等漏洞、构建失败和兼容事故替你强制结算。
如果新增依赖只算开发者个人的便利,后续升级却由「有空的人」处理,那么收益归当前任务,代价则被悄悄摊给整个团队。要纠正这种错配,依赖管理就不能依靠自觉,而要成为一套轻量、明确、可以长期执行的制度。
谁来负责依赖?
依赖管理不能只靠某位热心同事周五下班前随缘点几张 PR。
团队需要明确责任:
- 新增依赖由谁审批
- 高风险领域由谁复核
- 安全告警由谁接收和分级
- 普通升级由谁推进
- 升级失败由谁协调
- 停止维护或许可证变化由谁决定迁移
可以用 CODEOWNERS、标签和依赖台账把责任落到流程里。台账至少记录用途、当前版本、风险等级、负责人、版本策略、许可证和退出方案。不要追求一次填满所有字段,先确保关键依赖有人认领,再逐步补齐。
安全团队也不应该包办一切。业务团队最了解依赖如何被使用,平台团队最了解构建与发布,安全团队擅长威胁和策略。三者一起工作,才不会出现「安全说必须升,业务说升了就炸,最后谁也不动」的僵局。
哪些检查应该交给工具?
机器适合稳定、重复、规则清楚的工作:
- 检查
go.mod、go.sum是否提交,模块版本是否被意外改变 - 发现过期依赖与已知漏洞
- 生成并比对 SBOM
- 检查许可证与禁止清单
- 展示新增的间接依赖、
replace、cgo和工具模块 - 校验哈希、签名、来源与可信仓库
- 执行测试、构建和策略门禁
人则负责语义:
- 这个漏洞是否真的能在当前用法中触发
- 这个项目的维护状态是否可以接受
- 许可证风险是否符合商业模式
- 升级失败应该修、缓、换,还是删
- 为降低风险付出的成本是否值得
如果扫描器报了三千条告警,团队一条也处理不了,那不是安全能力强,而是噪声制造能力强。工具输出要能分级、能归属、能进入修复流程。指标也应观察趋势,例如严重漏洞平均修复时间、过期依赖比例、无人维护依赖数量和升级失败率,而不是只追求某天截图里的「零告警」。
NIST 的软件供应链建议把 SBOM、开源软件控制、供应商风险评估和漏洞管理放在同一套能力框架里。道理很直白:知道用了什么、判断它是否可信、持续处理变化,这三件事缺一不可。
如何让依赖有进也有出?
大多数团队有新增依赖流程,却没有删除依赖流程。于是依赖只进不出,像家里那个「以后肯定用得上」的纸箱,最终占满整个阳台。
定期清理这些东西:
- 业务代码已经不再引用的依赖
- 功能重复、可以合并的依赖
- 只为一个简单函数存在的依赖
- 已停止维护或长期不兼容新生态的依赖
- 许可证、权限或供应链风险已经不可接受的依赖
- 可以被标准库或平台能力替代的依赖
移除依赖也要跑完整测试,并检查打包产物和运行环境。有些包没有被源码直接引用,却被配置、插件发现机制或构建脚本动态加载。删得太豪迈,线上就会用沉默表达抗议。
对关键依赖,我建议定期做一次「退出演练」:如果它明天不可用,我们能否找到替代品?适配层是否真的隔离了第三方 API?团队里是否有人知道迁移步骤?这有点像消防演习——大多数时候显得多余,真正需要时却嫌练得太少。
依赖治理最终要形成闭环:
引入时说明理由,使用时限制边界,更新时验证变化,失去价值时果断移除。
这套制度不必庞大。小团队一张清单、一位负责人、每周半小时也能起步。最怕的是一上来设计二十张表、八级审批,折腾一个月后所有人为了赶进度开始复制粘贴「风险可接受」。制度的价值是帮助团队持续做正确判断,不是给错误判断盖一个更漂亮的章。
结语:省下代码,不等于省下责任
软件开发当然离不开依赖。我们今天能用几行代码搭起服务,能在成熟框架上处理认证、存储、网络和并发,正是因为无数人把自己的工作放进了开源生态。
复用这些成果没有错。真正危险的是只看到少写了多少代码,却看不到控制权去了哪里、信任边界扩大了多少,以及未来由谁来维护。
回到开头那个格式化字节数的 Go 模块。go get 也许真的省下了两小时,但从合并的那一刻起,项目便要关心它的维护者、间接依赖、模块路径、版本标签、源码归档、漏洞、许可证、升级路径和退出方案。那几十行没写的代码,并没有凭空消失,而是换成了一组更漫长、更分散,也更难被察觉的责任。
每一个依赖都意味着一段外部代码、一群陌生维护者、一条发布链路和一组未来变化。固定版本能让今天的构建可复现,却会产生持续更新的责任;升级能修复已知漏洞,却也会打开接纳新代码的入口;自己实现能减少外部信任,却可能把专业问题变成内部盲区。
没有一招鲜。只有取舍。
我的原则最后可以压缩成三句话:
- 克制地引入:先证明问题值得,再证明这个依赖值得
- 可复现地固定:提交
go.mod与go.sum,固定构建列表并验证模块内容 - 持续而审慎地更新:自动发现,小步验证,分批上线,随时能退
再加一句容易被遗忘的:不再值得的依赖,就让它离开。
成熟的依赖管理,不是比赛谁的依赖最少,也不是追求每个版本号都闪闪发亮。它是确保项目里的每一份外部代码,都有人知道它为何而来、创造了什么价值、要求团队承担什么,以及有一天该如何体面地离开。
所以,下次再敲下 go get 之前,别只问「它能替我省下多少代码」,还要问一句:「这份长期责任,项目真的准备好接了吗?」
