引言
一个中等规模的 Java 或 Node 服务,锁文件里通常躺着 600~1500 个包,其中 80% 以上是传递依赖——你没有主动选过它们,但它们同样受许可证约束。许可证风险不是「用了 GPL 就完蛋」这种二元判断,而是一张带方向的兼容性图:MIT 代码可以并入 GPLv3 项目,反过来不行;Apache-2.0 代码可以并入 GPLv3 项目,却进不了 GPLv2-only 项目。方向搞错,结论就完全反过来。
工程上真正的难点有两层。第一层是法律层:判断「哪些代码合起来构成一个衍生作品(derivative work)」,这一步没有代码可以替你回答,只有 FSF、SPDX、以及少数判例提供参考坐标。第二层是工程层:把法律判断落成可执行的边界——链接方式、进程边界、插件加载机制、API 调用形态,每一项都会改变结论。绝大多数踩坑都不是因为选错了许可证,而是因为团队默认「动态链接就等于安全」「AGPL 只要不改源码就没事」这类似是而非的经验。
更麻烦的是义务叠加。当 A 许可证的代码进入 B 许可证的项目,如果两者兼容,最终产物的义务是两个许可证义务的并集,而不是更宽松的那个;如果不兼容,唯一的出路是把它们拆到不同的进程或不同的仓库里。很多团队在引入依赖时只看了「这个库是什么许可证」,却没问「这个库的义务会不会污染我整个分发物」。
本文按「原理 → 矩阵 → 边界 → 场景 → 工具 → 处置 → 流程 → 复盘」的顺序展开:先讲判定三要素,再给可直接查的兼容矩阵,然后深入传染边界的工程判据,接着处理 AGPL/SaaS、双许可与例外条款这两个高频场景,最后给出依赖图分析命令、冲突消解策略、法务工程协同流程和三个真实案例。全站治理层面的配套内容可先看 开源治理全景 。
目录
- 兼容性判定的基本原理(许可方向性、附加限制、义务叠加)
- 主流许可证兼容矩阵(MIT/Apache-2.0/BSD/GPLv2/GPLv3/AGPL/LGPL/MPL/EPL)
- 传染边界:链接方式、进程隔离、插件与 IPC
- AGPL 与 SaaS 的边界(网络交互条款、修改的定义)
- 双许可与例外条款(dual license、linking exception、Classpath exception)
- 依赖图分析与冲突定位(mvn / go mod / npm / pip / cargo)
- 冲突消解:替换依赖、进程隔离、重写、商业授权
- 法务与工程的协同判定流程(判定表、留痕)
- 典型案例复盘(GPL 与 Apache-2.0 混用、LGPL 动态链接、AGPL 服务化)
1. 兼容性判定的基本原理
兼容性判断有三个要素,缺一个都会得出错误结论。
方向性。兼容关系是有向边:MIT → GPLv3 成立(可以把 MIT 代码放进 GPLv3 项目并整体按 GPLv3 分发),GPLv3 → MIT 不成立(不能把 GPLv3 代码放进 MIT 项目然后按 MIT 分发)。判定的方向永远是「被并入方(inbound)的许可证 → 接收方(outbound)的许可证」,问的是「我能不能把这段代码拿进来,然后按我想要的许可证发出去」。
附加限制(additional restrictions)。GPLv2 第 6 条明确禁止在分发时施加额外限制。任何在原许可证之上追加条件的条款,都会让该许可证无法与 GPLv2 兼容。Apache-2.0 就是典型:它的第 4 条要求保留 NOTICE 文件,第 3 条含专利授权与专利诉讼终止条款,FSF 认定这些构成 additional restrictions,因此 Apache-2.0 与 GPLv2 不兼容,但与 GPLv3 兼容(GPLv3 第 7 条允许特定类型的附加条款)。这是工程师最容易记反的一条。
义务叠加。若方向可行,最终分发物的义务是并集的严格超集。举例:项目本身是 MIT,引入了一个 Apache-2.0 库和一个 BSD-3-Clause 库,那么分发时必须同时满足 MIT 的署名、Apache-2.0 的 NOTICE 保留与变更声明、BSD-3-Clause 的免责声明,并且不能在宣传中使用 BSD 项目名背书。若方向不可行,唯一合法路径是让两份代码处于不同的「作品」中——这就是第 3 节要展开的传染边界。
| 判定要素 | 提问方式 | 常见误判 |
|---|---|---|
| 方向性 | 谁并入谁?产物按哪个许可证分发? | 把「GPL 项目能用 MIT 库」反推成「MIT 项目能用 GPL 库」 |
| 附加限制 | 源许可证是否追加了 GPL 未允许的条件? | 认为 Apache-2.0 与 GPLv2 兼容 |
| 义务叠加 | 并集义务是否都能在分发流程里满足? | 只满足更宽松的一方,漏掉 NOTICE 与变更声明 |
| 作品边界 | 两份代码是否构成单一衍生作品? | 默认「不同 jar 包就是不同作品」 |
引入任何依赖前的三问
把上表压成三个可以在 PR 描述里回答的问题,能过滤掉九成以上的低级错误:
- 这段代码会进入我的分发物吗? 会 → 走完整判定;只在构建期或测试期使用 → 通常只需确认工具本身的使用许可,不触发 copyleft。
- 它和我的代码构成单一作品吗? 静态链接、紧耦合动态链接、双向插件调用 → 是;独立进程 + 窄接口 → 否。这一问决定义务是否扩散到整个产品。
- 义务并集能全部满足吗? 逐条列出源许可证的分发义务(署名、NOTICE、变更声明、源码提供、许可证全文),检查发布流程里每一项都有对应的自动化步骤。
三个问题里任何一个答不上来,就不该合并这个依赖——把不确定性推给「以后再清理」,成本会以数量级增长:PR 阶段拦一个依赖是 10 分钟,产品发布后替换一个深度耦合的 GPL 库是两周到两个月。
为什么「更宽松」不是判据
工程师常问「哪个许可证更宽松」,然后用「更宽松的那个」来推理兼容性。这个直觉错在:宽松(permissive)描述的是义务多少,兼容性描述的是能否合并,两者不是同一个维度。Apache-2.0 比 GPLv2 宽松得多,但恰恰因为它「多」了几条 GPLv2 不允许的条款而与之不兼容。反过来,MPL-2.0 比 Apache-2.0 严格(有文件级 copyleft),却因为提供了二级许可机制而与 GPL 兼容。
正确的推理顺序是:先看方向(谁并入谁),再看源许可证有没有追加 GPL 不接受的条件(附加限制),最后才看义务多少决定履行成本。
2. 主流许可证兼容矩阵
下表回答一个具体问题:「行」许可证的代码,能否并入「列」许可证的项目并按列许可证分发。Y 表示可以,N 表示不可以,Y* 表示有条件成立(需要满足例外条款或版本选择)。
| 并入方 ↓ / 接收方 → | MIT | Apache-2.0 | GPLv2-only | GPLv3 | AGPL-3.0 | LGPL-3.0 | MPL-2.0 | EPL-2.0 |
|---|---|---|---|---|---|---|---|---|
| MIT | Y | Y | Y | Y | Y | Y | Y | Y |
| BSD-2/3 | Y | Y | Y | Y | Y | Y | Y | Y |
| Apache-2.0 | Y | Y | N | Y | Y | Y | Y | Y |
| GPLv2-only | Y | N | Y | N | N | N | N | N |
| GPLv2-or-later | Y | N | Y | Y | Y | Y | N | N |
| GPLv3 | Y | Y | N | Y | Y | Y | Y | N |
| AGPL-3.0 | Y | Y | N | Y | Y | Y | Y | N |
| LGPL-2.1/3.0 | Y | Y | Y* | Y | Y | Y | Y | Y |
| MPL-2.0 | Y | Y | N | Y* | Y* | Y* | Y | Y |
| EPL-2.0 | Y | Y | N | N | N | N | N | Y |
几条需要单独记忆的规则:
- GPLv2 与 GPLv3 互不兼容。GPLv2-only 代码不能进 GPLv3 项目;GPLv2-or-later 可以升级到 GPLv3 使用。判断一个组件是不是
-only,看它的 SPDX 标识与源码头注释,不能只看仓库里的 LICENSE 文件——很多项目 LICENSE 是 GPLv2 文本但源码头写的是either version 2 of the License, or (at your option) any later version。 - LGPL 可以升级为 GPL(LGPL 第 3 条允许),因此 LGPL 组件可以并入 GPL 项目;反向不成立。
- MPL-2.0 与 GPL 兼容,因为 MPL-2.0 第 3.3 条提供「二级许可证」(Secondary License)机制,明确允许把 MPL 代码并入 GPL/AGPL/LGPL。但 MPL 的 copyleft 是文件级的:修改过的 MPL 文件必须继续以 MPL 发布,未修改的文件可以按你的许可证发布。
- EPL-2.0 与 GPL 系不兼容,EPL 的专利报复条款与商业分发条款构成 GPL 不允许的附加限制。EPL-1.0 更严格,连 EPL-2.0 的二级许可选项都没有。
- AGPL-3.0 可以并入 GPLv3 项目(AGPL 第 13 条允许,且两者文本高度重合),但 GPLv3 代码不能进 AGPL 项目以外的方向要按 §13 处理。
矩阵之外的三个变量
矩阵是二维的,真实世界还要乘上三个变量:
- 许可证版本。同一个项目的不同版本可能换过许可证。Redis 7.4 之后在 RSALv2/SSPLv1 下发布,而 Redis 7.2 及以前是 BSD-3-Clause;Elasticsearch 在 7.11 从 Apache-2.0 切到 SSPL,8.16 又加回 AGPL。因此判定表必须记录版本号,只写组件名没有意义。
- 发行版打包。操作系统发行版(如 Debian main)只收录符合 DFSG 的许可证,从发行版仓库取包时风险较低,但发行版可能对某些组件做了拆分或打了补丁,仍需看实际的 SPDX 标识。
- 贡献者协议。项目是否要求 CLA、是否允许贡献者保留版权,会影响「谁能给你商业授权」。如果项目是「无 CLA、版权分散在几百个贡献者手里」,即便你想买商业许可也找不到能签字的单一主体——这一条直接决定第 7 节「商业授权」路径是否可行。
常被问到的四个组合
| 组合 | 结论 | 理由 |
|---|---|---|
| MIT 项目引入 GPL 库 | 不可 | 方向反了,产物会被要求按 GPL 分发 |
| GPLv2-only 项目引入 Apache-2.0 库 | 不可 | Apache-2.0 构成附加限制 |
| Apache-2.0 项目引入 MPL-2.0 库 | 可 | MPL 文件级 copyleft,未修改文件可按 Apache 分发 |
| AGPL 服务调用 GPLv3 命令行工具 | 可 | 独立进程 + 仅调用,不构成单一作品 |
3. 传染边界:链接方式、进程隔离、插件与 IPC
传染性的载体是「衍生作品」这个法律概念,而链接方式是业界最常用的工程近似判据。它不精确,但可操作。
静态链接。目标文件被合并进同一个可执行文件,符号表融合,通常被视为形成单一衍生作品。GPL 组件静态链接进闭源程序 = 整个可执行文件必须按 GPL 分发。LGPL 的静态链接是「有条件允许」:LGPL-3.0 第 4 条 (d)(0) 要求你提供足以让接收者重新链接(relink)到修改版 LGPL 库的目标文件或源代码,实践中通过提供 .o 文件或使用 shared library 满足。
动态链接。FSF 的立场是「动态链接不自动免疫」。GPL FAQ 明确说:如果程序与 GPL 库通过共享内存传递复杂数据结构、紧密耦合,仍可能构成单一作品。工程上的经验判据是耦合强度:只调用少量公开 API、数据通过序列化格式传递,倾向于独立作品;共享复杂数据结构、回调密集、需要同一 ABI,倾向于衍生作品。把「动态链接一定安全」当成规则,是踩坑的第一大来源。
进程隔离。fork + exec 启动独立可执行文件,通过命令行参数、stdin/stdout、管道、Unix domain socket、gRPC 或普通网络协议通信,这是最强的工程隔离。因为它满足三个条件:无共享地址空间、无编译期链接、接口是稳定的数据契约。GPL 程序调用你的闭源程序(或反向),只要双方都是独立进程,通常各自的作品边界成立。
插件与 IPC。这是灰区。dlopen 动态加载插件,如果插件调用宿主的大量内部 API、共享宿主的数据结构,FSF 倾向于认为插件是宿主的衍生作品——WordPress 主题/插件之争就是这一条。反过来,宿主只暴露一个窄接口、插件自行实现全部逻辑,隔离性更好。设计插件架构时,把接口做得「像网络协议」而不是「像内部函数调用」,是降低风险的实操手法。
| 隔离手法 | 共享地址空间 | 编译期链接 | 传染风险 | 性能代价 |
|---|---|---|---|---|
| 静态链接 | 是 | 是 | 高 | 无 |
| 动态链接(紧耦合) | 是 | 是(运行期) | 中高 | 极低 |
| 动态链接(窄 API) | 是 | 是(运行期) | 中 | 极低 |
| 插件 dlopen(窄接口) | 是 | 否 | 中低 | 低 |
| 独立进程 + 管道/socket | 否 | 否 | 低 | 中(序列化 + IPC) |
| 独立服务 + 网络 API | 否 | 否 | 低 | 高(网络 + 运维) |
工程上可观测的耦合信号
法律上没有「链接方式测试」,但工程上有若干可观测信号,可以拿来给隔离主张做证据。按可疑程度从高到低:
- 头文件/类型定义交叉包含。你的代码
include了 GPL 组件的内部头文件(不是公开安装的 API 头),或反过来,说明编译期已融合。 - 共享内存中的复杂数据结构。双方读写同一个结构体、同一个对象图,而不是交换序列化字节流。这是 FSF 在 FAQ 里点名的情形。
- 回调注册密集。宿主把内部事件回调注册进库、库在回调里访问宿主内部状态,调用图高度交织。
- 构建产物同体。
ldd或otool -L看到 GPL 组件的.so与你的模块出现在同一个二进制/同一个容器镜像里,并且通过符号解析直接调用。 - 符号可见性。
nm -D能看到大量跨边界的内部符号(非_init/公开 API 前缀),说明耦合面很宽。
反过来,如果接口只有几十个方法、参数与返回值都是基本类型或序列化格式、双方可以独立编译和升级,隔离主张就站得住。把接口设计得「像网络协议」而不是「像内部函数调用」,是插件架构里最划算的一条纪律。
LGPL 为什么是特例
LGPL 的整个设计目的就是「允许闭源使用,但保证库本身自由」。它通过两条路径实现:
- 动态链接(LGPL-3.0 §4 (d)(1)):使用共享库机制,接收者可以自行替换库版本,只需提供许可证声明与替换说明。
- 静态链接(LGPL-3.0 §4 (d)(0)):必须提供足以让接收者把修改后的库重新链接进你的程序的材料,通常是目标文件(
.o)或源码,且不得限制接收者对库的修改。
因此 LGPL 的合规检查清单是「四件套」:动态链接、随包附许可证全文与版权声明、保证库可被替换、修改库则回馈修改。缺任何一条,LGPL 的「闭源可用」就退化成 GPL 级别的义务。
4. AGPL 与 SaaS 的边界
AGPL-3.0 相对 GPLv3 只多了一条第 13 条「远程网络交互」(Remote Network Interaction):如果你运行的是修改过的程序版本,并且用户通过网络与它交互,你必须向这些用户提供 Corresponding Source。这条把 copyleft 从「分发触发」扩展到了「网络服务触发」,是 SaaS 场景最需要读清楚的条款。
先澄清两个高频误解。
误解一:「AGPL 只要不改源码就没事」。严格按 §13 文本,触发条件是「修改过的版本」。但「修改」的判定比直觉宽:把 AGPL 库与你的自有代码链接、把 AGPL 组件编进你的服务二进制、给 AGPL 程序打补丁,都会让整体成为 modified version。实务中即使真的未修改,稳妥做法也是在产品里提供源码获取入口(通常指向上游 release 的固定 commit),因为用户无法访问你的服务器内部,你无法证明「未修改」。
误解二:「AGPL 只影响开源项目,商业 SaaS 用不得」。反例很多:MongoDB 在 2018 年从 AGPL 切到 SSPL,正是因为云厂商以 AGPL 方式提供托管服务而不回馈;而 Grafana、Metabase、MinIO 等仍用 AGPL,商业公司通过「AGPL 社区版 + 商业版」双许可运营。AGPL 不等于不能商用,等于你修改后必须回馈。相关商业模式讨论见 Open Core 商业化 。
边界判定要点:
- 判定核心是「AGPL 组件与自有代码是否构成单一作品」。构成 → 整个服务受 AGPL;不构成 → 只需对 AGPL 组件本身履行义务。
- 若选择隔离,把 AGPL 组件做成独立进程/独立服务,自有代码通过 API 调用,且不传递内部数据结构,是可行路径(第 3 节的隔离判据同样适用)。
- 注意 SSPL、Elastic License 2.0、BSL 1.1 这类「非 OSI 许可证」。它们不是开源许可证,使用前需要单独法务评审,不能套用本文的兼容矩阵。
- AGPL 与 GPLv3 之间可以互相并入,但你的闭源代码不在这个图里。
「源码可用」许可证与 AGPL 的分界
AGPL 之后出现的一批许可证(SSPL、BSL、Elastic License 2.0、RSALv2)常被混为一谈,它们的差别恰好落在「什么行为触发回馈义务」上:
| 许可证 | OSI 认证 | 触发条件 | 回馈范围 | 典型项目 |
|---|---|---|---|---|
| AGPL-3.0 | 是 | 修改后通过网络提供服务 | 整个衍生作品的 Corresponding Source | Grafana、MinIO、Metabase |
| SSPL-1.0 | 否 | 把程序作为服务提供给他人 | 服务化所需的全部代码,含管理/监控/备份层 | MongoDB 4.0+ |
| BSL-1.1 | 否 | 生产用途(除「额外使用许可」外) | 无 copyleft,仅限制使用,到期转 Apache-2.0 | HashiCorp Terraform、Vault |
| Elastic License 2.0 | 否 | 提供托管服务或规避功能限制 | 无 copyleft,仅限制用途 | Elasticsearch 7.11~8.15 |
| RSALv2 | 否 | 与许可方产品竞争 | 无 copyleft | Redis 7.4+ |
对工程的直接影响:这四种都不是开源许可证,不能进本文的兼容矩阵,也不能假设「它能和 Apache-2.0 混用」——它们的限制是使用场景限制而非版权许可条件,混用后你的产品可能整体落入受限范围。BSL 的「变更日期」(Change Date)机制还要单独记录:到点自动转 Apache-2.0,但转的是该版本,后续版本可能仍是 BSL。
AGPL 服务化的三种落地形态
同样是「SaaS 里用 AGPL 组件」,三种形态的风险差异很大:
- 形态一:内嵌链接。AGPL 组件编进服务二进制,与自有代码共享数据结构。风险最高,几乎必然被认定为 modified version,触发 §13 的源码提供义务,且义务范围是整个服务。
- 形态二:独立进程/边车。AGPL 组件作为独立进程或 sidecar 运行,自有代码通过 API 调用。风险中等,取决于接口宽度;需要留存接口文档与调用日志作为隔离证据。
- 形态三:未修改的独立服务。部署上游发布的标准镜像,不做任何修改,仅通过配置与 API 使用。风险最低,通常只需在文档中提供上游源码位置。注意「仅通过配置使用」是关键词——写插件、改配置模板到改变程序行为的程度,都可能越过界线。
修改的定义与配置边界
「修改」在版权法上指创作衍生作品,而不仅是编辑文件。判断标准是是否产生了新的表达:改一行日志级别、调一个端口,通常不构成新的表达;替换实现、增删功能、修改算法逻辑,构成。中间地带(写一个上游未提供的插件、给上游模块打补丁)需要逐案判断,判断结果记入判定表。
留痕建议:把对 AGPL 组件的所有改动集中成可枚举的 patch 文件(而不是直接在容器里改),既方便升级,也让「改了什么」一目了然。
5. 双许可与例外条款
双许可(dual license) 是版权持有者同时提供两条路径:社区版走 copyleft,商业客户走付费的宽松许可。典型:MySQL(GPLv2 + 商业)、Qt(LGPLv3 + 商业)、MongoDB(SSPL + 商业)、iText(AGPL + 商业)。使用方必须在两条路径中明确选一条并留下记录——最危险的状态是「以为自己在用社区版,实际生产环境按商业条款应当付费」。选择商业许可前要确认授权维度:按开发者数、按部署实例数、还是按营收分成。
链接例外(linking exception) 是在 copyleft 许可证上打的一个洞。GCC 的 Runtime Library Exception 允许把 libgcc、libstdc++ 链接进任意程序而不触发 GPL;这是 C/C++ 闭源程序能正常使用 GCC 工具链的前提。
Classpath 例外(Classpath Exception) 是 OpenJDK 的做法:GPLv2 + Classpath Exception 允许把 Java 类库链接进非 GPL 程序,只要求对 JDK 自身的修改回馈。这就是「Java 生态可以放心用 OpenJDK 跑闭源服务」的法律基础。注意例外条款只覆盖被声明的那部分文件,JDK 里某些组件(历史上如部分 JAXB、CORBA 模块)有单独的许可证,需要逐个确认。
版本选择(or later) 也是一种策略工具。如果你的项目声明 GPL-2.0-or-later,你可以主动选择以 GPLv3 分发,从而兼容 Apache-2.0 依赖;如果声明的是 GPL-2.0-only,就堵死了这条路。
SPDX 标识要写清楚。在每个源文件头部用两行注释声明,比只在仓库根放 LICENSE 更可靠,也让自动化扫描能正确识别:
SPDX-FileCopyrightText: 2026 Example Corp
SPDX-License-Identifier: Apache-2.0
遇到 MIT OR Apache-2.0 这种 OR 表达式(Rust 生态极常见),意味着你可以任选其一履行;遇到 AND 则必须同时满足两者,通常意味着这个组件内部混合了两种许可的代码。
例外条款的三种典型形态
| 例外 | 挂在哪个许可证上 | 允许什么 | 典型项目 |
|---|---|---|---|
| GCC Runtime Library Exception | GPL-3.0 | 把 libgcc/libstdc++ 链接进任意程序 | GCC 运行时 |
| Classpath Exception | GPL-2.0 | 把 Java 类库链接进非 GPL 程序 | OpenJDK |
| Autoconf Configure Script Exception | GPL-3.0 | 分发 configure 生成的脚本不触发 GPL | Autoconf |
| FOSS Exception | GPL-2.0 | 允许特定开源项目使用而不触发(仅限列举项目) | MySQL Connector/J |
使用例外条款时的三个陷阱:
- 例外只覆盖被声明的文件。OpenJDK 的 Classpath Exception 声明在具体文件的头部,某些历史模块(如早期的 JAXB、CORBA 实现)不在覆盖范围内,必须逐文件确认而非按「整个 JDK」处理。
- 例外不传递。你的项目基于带例外的库做二次封装后对外分发,例外是否随你的封装继续生效取决于例外的措辞;多数例外只对「直接链接该库」的场景生效。
- FOSS Exception 是白名单制。MySQL Connector/J 的 FOSS Exception 明确列举了允许的开源项目与许可证,不在列表内的项目不享受例外——用之前要核对你的项目是否在列表里。
双许可的谈判要点
走到「买商业授权」这一步时,先确认三件事:谁有权授权(版权是否集中)、授权覆盖什么(版本范围、部署形态、开发者规模)、授权排除什么(是否允许再分发、是否覆盖你的客户)。常见坑是把「按开发者数」的授权用在 CI 机器人上——很多厂商把自动化构建代理也算作使用者,签约前要写清楚口径。
6. 依赖图分析与冲突定位
目标是回答两个问题:谁把 GPL 拉进来了,以及它离我的分发物有多远(直接依赖还是第 5 层传递依赖)。下面按生态给出命令。
mvn dependency:tree -Dincludes=org.gnu:* # 定位 GPL 组件的引入路径
mvn dependency:tree -Dverbose -DoutputFile=tree.txt # 含被 omitted 的冲突分支
mvn org.codehaus.mojo:license-maven-plugin:2.4.0:add-third-party # 全量许可证报告
gradle dependencies --configuration runtimeClasspath # 运行期依赖树
gradle licenseReport # 需 com.github.jk1.dependency-license-report 插件
go mod graph | grep -i "gpl\|agpl" | head -50 # 模块图里筛 copyleft
go list -m -json all | jq -r '.Path' | sort -u # 全量模块清单去重
go-licenses csv ./... # 输出 包路径,许可证,置信度
npm ls --all 2>/dev/null | grep -i "gpl" # 全量树里筛 copyleft
npm why some-package # 谁把某个包拉进来的
npx license-checker --summary --production # 仅生产依赖的许可证汇总
pnpm licenses list --prod
pipdeptree --warn silence # Python 依赖树
pip-licenses --format=csv --with-urls=false # 已安装包的许可证元数据
cargo tree -i some-crate # 反向查询:谁依赖了它
cargo deny check licenses # 按 deny.toml 的 allow/deny 列表判定
cargo deny 的策略文件很能说明「策略即代码」的形态,允许列表写死、其余一律拒绝:
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "MPL-2.0", "Unicode-3.0"]
confidence-threshold = 0.9
exceptions = [
{ allow = ["OpenSSL"], crate = "ring" },
{ allow = ["Zlib"], crate = "miniz_oxide" },
]
读结果的三个要点:
- 区分 direct 与 transitive。直接依赖是你选的,可以换;传递依赖要先找到引入者(
cargo tree -i、npm why、mvn dependency:tree -Dincludes)才能评估替换成本。 - 注意 test/optional 作用域。只在测试期使用的 GPL 工具通常不进入分发物,但「不分发」的前提是你真的不把它打进产物——很多构建脚本会把测试依赖误打包。
- 区分「依赖它的二进制」和「依赖它的库」。依赖 GPL 的命令行工具(如构建期调用
gpl-tool)通常只是使用,不构成衍生作品;依赖 GPL 的库并链接才是传染风险。
CI 里可以把策略固化成门禁:允许列表放 MIT/BSD/Apache-2.0/ISC/MPL-2.0,其余一律 fail 并要求提交例外审批。这属于策略即代码的范畴,可参考 策略即代码治理 。
7. 冲突消解:替换依赖、进程隔离、重写、商业授权
发现冲突后,按成本从低到高依次尝试以下四条路径,不要一上来就谈商业授权。
一、替换依赖。优先找同类宽松许可证替代品。常见替换对:GNU Readline(GPL-3.0)→ libedit(BSD)或 linenoise(BSD);MySQL Connector/J(GPLv2 + FOSS Exception)→ mariadb-java-client(LGPL-2.1)或 pgjdbc(BSD);ffmpeg 默认构建带 --enable-gpl,若只需解码可改用不带 GPL 组件的构建;iText 5/7(AGPL)→ Apache PDFBox(Apache-2.0)。替换前必须核对 API 覆盖度与行为差异,尤其是排序、字符集、错误码这类容易静默出错的地方。
二、进程隔离。把 GPL/AGPL 组件拆成独立进程或独立服务,自有代码通过 CLI 参数、stdin/stdout、Unix socket 或 gRPC 通信。要点是接口要「窄」:只传序列化数据,不传文件描述符以外的东西,不共享内存,不回调。隔离的代价是部署复杂度与延迟(通常增加 0.5~5 ms 每次调用),适合调用频次低、数据量可控的场景。
三、重写。当组件功能边界清晰且体量不大时,做清洁室(clean room)实现:一组人读 GPL 源码写规格说明,另一组人只根据规格写代码,避免代码与设计结构相似。重写的隐性成本在测试与边界条件,一个 3000 行的库往往需要同等规模的测试用例才能保证行为一致。
四、商业授权。找版权持有者购买商业许可,或购买第三方提供的替代商业库。谈判前准备好用量数据(开发者数、部署实例数、营收区间),并确认授权是否覆盖历史版本与未来版本。注意双许可模式下通常要求签署的授权是非排他的,且不得用于再分发。
| 策略 | 一次性成本 | 长期成本 | 风险 | 适用场景 |
|---|---|---|---|---|
| 替换依赖 | 低~中(API 适配 + 回归测试) | 低 | 行为差异导致的静默 bug | 存在成熟替代品 |
| 进程隔离 | 中(接口设计 + 部署改造) | 中(延迟 + 运维) | 隔离强度被法务质疑 | 调用频次低、组件边界清晰 |
| 重写 | 高(规格 + 实现 + 测试) | 中(维护自有实现) | 结构相似被认定抄袭 | 功能小、无替代品 |
| 商业授权 | 中(采购流程) | 高(订阅/分成) | 用量口径争议 | 商用价值高、预算充足 |
| 整体开源 | 低 | 高(放弃闭源优势) | 商业模式重构 | 产品本就适合开源 |
进程隔离的最小实现骨架
隔离的关键不是「换个进程」,而是接口足够窄。下面这个骨架用「一行 JSON 请求 → 一行 JSON 响应」的方式包住一个 GPL 组件,双方不共享任何内存与类型定义:
import json, subprocess
def gpl_worker(request: dict) -> dict:
"""每次调用起一个短生命周期子进程,只通过 stdin/stdout 交换 JSON。"""
proc = subprocess.run(
["/usr/local/bin/gpl-tool", "--json"],
input=json.dumps(request),
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip())
return json.loads(proc.stdout)
result = gpl_worker({"op": "normalize", "text": " Hello "}) # 调用侧只认识 dict,不认识 GPL 组件的类型
print(result["text"])
这个骨架满足隔离的三个硬条件:无编译期链接(运行期 exec)、无共享地址空间(独立进程)、接口是数据契约(JSON)。如果换成「把 GPL 库编进同一个二进制、用 FFI 传结构体指针」,三个条件全部不成立。生产环境建议把短生命周期进程换成常驻 worker + Unix domain socket,避免每次调用 30 ms 级的进程启动开销。
替换依赖的评估清单
选替代品时不要只看许可证,逐项打勾:
- API 覆盖度:目标库覆盖了你用到的全部功能,还是只覆盖 80%?剩下 20% 是否需要自己实现。
- 行为差异:排序稳定性、默认字符集、时区处理、错误码语义、边界值处理——这些是最容易静默出错的点。
- 性能量级:吞吐/延迟差异是否在可接受范围,是否需要重新压测。
- 维护活跃度:最近 12 个月的提交数、issue 响应时间、是否有企业背书。
- 传递依赖:替代品自己会不会引入新的许可证问题(换一个 GPL 库治不了病)。
- 迁移成本:调用点数量、是否有测试覆盖、是否需要数据格式迁移。
8. 法务与工程的协同判定流程
许可证判定不能只在法务做,也不能只在工程做:工程提供「事实」(依赖路径、链接方式、是否修改、分发形式),法务提供「结论」(是否构成衍生作品、义务如何履行)。把两者接起来的方式是一张结构化的判定表,每个引入的组件一行。
| 字段 | 示例 | 谁填 |
|---|---|---|
| 组件名 / 版本 | libfoo / 2.3.1 | 工程(自动) |
| SPDX 标识 | GPL-2.0-only | 工程(自动) |
| 依赖路径 | app → bar → libfoo | 工程(自动) |
| 链接方式 | 动态链接 / 独立进程 / 仅构建期 | 工程 |
| 是否修改 | 否 / 打补丁 patch-001 | 工程 |
| 分发形式 | 二进制分发 / 仅 SaaS / 内部使用 | 产品 |
| 判定结论 | 兼容 / 需隔离 / 需替换 / 需授权 | 法务 |
| 例外审批号 | EX-2026-041 | 法务 |
| 复核日期 | 2027-03-31 | 法务 |
流程上设三道闸:
- 引入闸(PR 阶段)。CI 自动跑许可证扫描,命中非允许列表即 fail,要求提交人在 PR 里填写上表的工程字段,法务异步出结论。低风险的 MIT/Apache-2.0 自动放行,避免法务成为瓶颈。
- 季度全量复核。锁文件变化会让依赖图漂移——上游升个版本就可能从 MIT 变 BSL。每季度重跑一次全量扫描,与上一季度做 diff,只审新增与变更项。
- 发布闸(release checklist)。分发前确认:NOTICE 文件已合并、许可证全文已随包、变更声明已写、SBOM 已归档、例外审批未过期。SBOM 的作用在这里是「留痕」而不是「扫描」,生成与归档流程可参考 许可证合规实务 。
留痕的另一个价值是自证善意。若未来发生争议,完整的判定表、审批记录、SBOM 历史能证明你尽到了合理注意义务,这在补救与和解谈判中权重很高。
引入闸的 CI 骨架
门禁的核心是「允许列表 + 例外登记」,未登记的许可证一律 fail:
name: license-gate
on:
pull_request:
paths:
- "package.json"
- "package-lock.json"
- "go.mod"
- "go.sum"
- "pom.xml"
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 生成许可证清单
run: npx license-checker --production --json > licenses.json
- name: 与允许列表比对
run: python3 scripts/check_licenses.py --allow-list .licenses/allow.txt --input licenses.json
- name: 上传清单留痕
if: always()
uses: actions/upload-artifact@v4
with:
name: license-report
path: licenses.json
retention-days: 365
几个实操要点:允许列表文件本身要走 PR 评审(改允许列表等于改策略);例外登记文件单独存放并带过期时间,CI 检查过期即 fail;清单作为构建产物上传并保留一年以上,与 SBOM 归档放在同一处,方便事后追溯「某个版本当时用了什么」。
三道闸的分工
| 闸口 | 触发时机 | 检查内容 | 阻断方式 |
|---|---|---|---|
| 引入闸 | PR 修改依赖清单 | 新增依赖的许可证是否在允许列表 | CI fail,需例外审批 |
| 季度闸 | 定时(每季度) | 全量依赖 diff、上游换证、审批过期 | 生成待办,不阻断发布 |
| 发布闸 | 每次对外发布 | NOTICE 合并、许可证全文、SBOM 归档、审批有效性 | release checklist 未勾选不放行 |
引入闸追求「快而自动」,只处理增量;季度闸追求「全而慢」,处理漂移;发布闸是最后一道兜底,因为它直接对应「分发」这个法律触发点。三道闸合起来,才能覆盖「新增、变更、分发」三种风险来源。
9. 典型案例复盘
案例 A:GPLv2-only 与 Apache-2.0 混用。某设备厂商把 Apache-2.0 的库编进声明为 GPLv2-only 的固件。由于 Apache-2.0 的专利条款与 NOTICE 要求在 GPLv2 下构成附加限制,这个组合在法律上不可分发;厂商的补救方式是给固件加上「or later」声明升到 GPLv3,从而容纳 Apache-2.0。教训:许可证声明写 -only 之前,先扫一遍依赖图里有没有 Apache-2.0。
案例 B:LGPL 动态链接。某闭源桌面软件使用 Qt 的 LGPLv3 版本。合规做法有四条:动态链接(不静态链接 Qt)、随包提供 Qt 的许可证全文与版权声明、允许用户替换 Qt 库(不锁定签名、不阻止替换)、若修改过 Qt 则回馈修改。踩坑点在于「允许替换」——某些平台的应用签名与沙箱机制会让替换在事实上不可能,这需要单独评估。教训:LGPL 的合规点不在链接方式本身,而在用户能否重新链接。
案例 C:AGPL 服务化与 GPL 的可执行性。Artifex 诉 Hancom 案(Ghostscript,AGPL/GPL 系)确立了一个重要事实:开源许可证是可执行的合同/版权许可,违反许可条款可以直接构成版权侵权与违约,法院支持了赔偿与禁令。同期 MongoDB 从 AGPL 迁到 SSPL,也是「云厂商以托管方式使用而不回馈」这一模式的直接反应。教训:AGPL 与 SSPL 的区别不是文本细节,而是「托管服务是否触发回馈」这一商业模式问题,选型时要连商业模型一起评估。
案例 D:内核模块与 GPL 符号。Linux 内核以 GPLv2 发布,闭源驱动若使用 EXPORT_SYMBOL_GPL 导出的符号,通常被认为与内核构成单一作品。部分厂商通过把所有 GPL-only 符号的调用集中到一个薄层来降低风险,但这一手法并未被法院检验。教训:未经判例检验的「隔离技巧」只能降低风险,不能消除风险,重大产品决策应留出替换余地。
三个案例的共同点:出问题的从来不是「用了 GPL」,而是「依赖图里悄悄多了一个 GPL 组件而没人发现」,或者「把工程近似判据当成了法律结论」。
案例的共同模式与自查
把这四个案例抽象成可复用的自查问题:
- 案例 A 类(版本兼容):我的项目许可证声明是
-only还是-or-later?依赖图里有没有 Apache-2.0? - 案例 B 类(LGPL 闭源使用):用户能否真正替换掉这个 LGPL 库?签名、沙箱、打包格式是否构成事实障碍?
- 案例 C 类(服务化 copyleft):这个组件是通过网络提供服务的吗?如果是,我修改过它吗?修改的定义是否被记录在案?
- 案例 D 类(内核/插件耦合):我依赖的是公开稳定 API 还是内部符号?隔离层是否只是「薄薄一层」而内部调用仍然密集?
值得强调的一个判例要点:开源许可证在美国法院被认定为可执行的许可条件(Jacobsen 诉 Katzer 案确立了这一点,Artifex 诉 Hancom 案进一步支持了违约赔偿与禁令)。这意味着违反许可证不只是「社区声誉问题」,而是可以直接进入诉讼并产生金钱赔偿的法律风险。反过来说,这也是为什么「完整的判定表 + SBOM 留痕」在争议中有实际价值:它能证明你并非故意侵权,从而影响赔偿计算与和解条件。
最后一个观察:四个案例里,三个的根因都是信息缺失而非决策错误。团队并不是明知有 GPL 还硬用,而是根本不知道依赖图里有 GPL。因此治理投入的第一优先级永远是「可见性」——先把依赖图、SPDX 标识、分发形态看清楚,再谈策略与隔离。
权衡取舍
| 策略 | 合规确定性 | 工程成本 | 交付速度 | 适用判断 |
|---|---|---|---|---|
| 全部只用 MIT/Apache-2.0 | 最高 | 中(可选库变少) | 慢(替代品适配) | 商业闭源产品、对外分发 |
| 允许 LGPL 动态链接 | 高 | 低 | 快 | 桌面/客户端,可保证可替换 |
| 允许 GPL 用于独立工具 | 高 | 低 | 快 | 构建期、运维期工具 |
| AGPL 组件独立进程 | 中高 | 中高 | 中 | 调用频次低的服务 |
| AGPL 组件内嵌进服务 | 低(须整体开源) | 低 | 快 | 产品本就开源 |
| 双许可购买商业授权 | 最高 | 低(工程) | 快 | 预算充足、组件核心 |
| 清洁室重写 | 高 | 很高 | 很慢 | 功能小、无替代品 |
| 全部开源 | 最高 | 低 | 快 | 商业模式不依赖闭源 |
核心权衡是「合规确定性」与「交付速度」:越靠前的策略越安全但越慢,越靠后的越快要付出开源或金钱的代价。多数团队的合理区间是「允许列表 + 例外审批」,即默认只放 MIT/BSD/Apache-2.0/ISC/MPL-2.0,其余走审批。
常见坑清单
- 把 Apache-2.0 当 GPLv2 兼容:误以为「都是宽松/都开源就兼容」,实际因专利与 NOTICE 条款不兼容;引入前用矩阵核对方向。
- 把「动态链接一定安全」当规则:紧耦合的动态链接仍可能被认定为衍生作品;按耦合强度而非链接方式判断。
- 以为 AGPL 不改就没事:链接自有代码、打补丁都算 modified version;未修改也应提供源码获取入口。
- 只看直接依赖:GPL 往往在第 4~6 层传递依赖里;用
cargo tree -i、npm why、mvn dependency:tree -Dincludes定位引入者。 - 把测试依赖打进产物:scope 写错导致 GPL 测试工具进了发布包;构建后核对产物内的依赖清单。
- 忽略 LICENSE 与源码头不一致:仓库根是 GPLv2 文本但文件头写「or later」;以文件头 SPDX 为准,冲突时逐文件确认。
- 上游换许可证没跟进:依赖升版后从 MIT 变 BSL/SSPL;季度扫描 + 锁文件 diff 是唯一可靠的发现手段。
- NOTICE 文件没有合并:用了 Apache-2.0 依赖但发布包只放了 LICENSE;构建阶段自动聚合 NOTICE 并纳入发布 checklist。
- 双许可选错路径:生产用社区版、文档写商业版,或反之;授权路径要写进依赖登记表并与采购记录对齐。
- 签名机制导致 LGPL 无法替换:合规要求「用户能重新链接」,但应用签名/沙箱阻止替换;上架前评估平台的替换可行性。
- 插件架构双向耦合:插件调用宿主内部 API 并共享数据结构,隔离主张站不住;把插件接口设计成窄的、类协议的契约。
- 例外审批过期未复核:两年前的审批覆盖的是旧版本,当前版本已换许可证;审批记录必须带复核日期并进入季度扫描。
小结
许可证兼容性是一个有向图加一套义务并集运算,工程侧的抓手是「依赖图 + 边界判定 + 留痕」三件套:用依赖图找到所有组件与引入路径,用链接方式/进程边界判定作品边界,用判定表与 SBOM 记录结论和审批。记住三条反直觉的结论——Apache-2.0 与 GPLv2 不兼容、动态链接不等于安全、AGPL 的触发条件比「是否修改」更宽——就能避开大部分坑。
落地时不要追求一次性判对所有依赖,而是先建立门禁:CI 里一份允许列表,季度一次全量 diff,发布前一次 checklist。让新引入的组件在 PR 阶段就被拦住,比事后审计上千个存量依赖便宜得多。存量依赖则按「高价值 + 高风险」优先排序,先处理那些既在核心链路又带 copyleft 的组件。
下一步建议按三条线深入:许可证的声明与扫描实务(SPDX 标识规范、扫描器接入、例外登记表模板)见本专题的许可证合规一文;全站治理框架与角色分工(治理委员会、评审流程、度量指标)见开源治理全景;把合规结论转成商业模式设计(社区版边界怎么划、商业版授权按什么维度计价)见 Open Core 商业化一文。三条线合起来,才是从「不出事」到「用开源做出商业价值」的完整闭环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。