许可证兼容性与依赖传染分析

本文讲许可证兼容性与依赖传染分析,回答 GPL 与 Apache 2.0 能否混用、AGPL 是否会波及 SaaS、静态链接与动态链接有何区别。覆盖兼容性矩阵、传染边界判定、双许可与例外条款、独立进程与插件架构的隔离手法,给出依赖图分析命令与替换策略,并总结法律与工程协同的判定流程。

引言

一个中等规模的 Java 或 Node 服务,锁文件里通常躺着 600~1500 个包,其中 80% 以上是传递依赖——你没有主动选过它们,但它们同样受许可证约束。许可证风险不是「用了 GPL 就完蛋」这种二元判断,而是一张带方向的兼容性图:MIT 代码可以并入 GPLv3 项目,反过来不行;Apache-2.0 代码可以并入 GPLv3 项目,却进不了 GPLv2-only 项目。方向搞错,结论就完全反过来。

工程上真正的难点有两层。第一层是法律层:判断「哪些代码合起来构成一个衍生作品(derivative work)」,这一步没有代码可以替你回答,只有 FSF、SPDX、以及少数判例提供参考坐标。第二层是工程层:把法律判断落成可执行的边界——链接方式、进程边界、插件加载机制、API 调用形态,每一项都会改变结论。绝大多数踩坑都不是因为选错了许可证,而是因为团队默认「动态链接就等于安全」「AGPL 只要不改源码就没事」这类似是而非的经验。

更麻烦的是义务叠加。当 A 许可证的代码进入 B 许可证的项目,如果两者兼容,最终产物的义务是两个许可证义务的并集,而不是更宽松的那个;如果不兼容,唯一的出路是把它们拆到不同的进程或不同的仓库里。很多团队在引入依赖时只看了「这个库是什么许可证」,却没问「这个库的义务会不会污染我整个分发物」。

本文按「原理 → 矩阵 → 边界 → 场景 → 工具 → 处置 → 流程 → 复盘」的顺序展开:先讲判定三要素,再给可直接查的兼容矩阵,然后深入传染边界的工程判据,接着处理 AGPL/SaaS、双许可与例外条款这两个高频场景,最后给出依赖图分析命令、冲突消解策略、法务工程协同流程和三个真实案例。全站治理层面的配套内容可先看 开源治理全景 。

目录

  1. 兼容性判定的基本原理(许可方向性、附加限制、义务叠加)
  2. 主流许可证兼容矩阵(MIT/Apache-2.0/BSD/GPLv2/GPLv3/AGPL/LGPL/MPL/EPL)
  3. 传染边界:链接方式、进程隔离、插件与 IPC
  4. AGPL 与 SaaS 的边界(网络交互条款、修改的定义)
  5. 双许可与例外条款(dual license、linking exception、Classpath exception)
  6. 依赖图分析与冲突定位(mvn / go mod / npm / pip / cargo)
  7. 冲突消解:替换依赖、进程隔离、重写、商业授权
  8. 法务与工程的协同判定流程(判定表、留痕)
  9. 典型案例复盘(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 描述里回答的问题,能过滤掉九成以上的低级错误:

  1. 这段代码会进入我的分发物吗? 会 → 走完整判定;只在构建期或测试期使用 → 通常只需确认工具本身的使用许可,不触发 copyleft。
  2. 它和我的代码构成单一作品吗? 静态链接、紧耦合动态链接、双向插件调用 → 是;独立进程 + 窄接口 → 否。这一问决定义务是否扩散到整个产品。
  3. 义务并集能全部满足吗? 逐条列出源许可证的分发义务(署名、NOTICE、变更声明、源码提供、许可证全文),检查发布流程里每一项都有对应的自动化步骤。

三个问题里任何一个答不上来,就不该合并这个依赖——把不确定性推给「以后再清理」,成本会以数量级增长:PR 阶段拦一个依赖是 10 分钟,产品发布后替换一个深度耦合的 GPL 库是两周到两个月。

为什么「更宽松」不是判据

工程师常问「哪个许可证更宽松」,然后用「更宽松的那个」来推理兼容性。这个直觉错在:宽松(permissive)描述的是义务多少,兼容性描述的是能否合并,两者不是同一个维度。Apache-2.0 比 GPLv2 宽松得多,但恰恰因为它「多」了几条 GPLv2 不允许的条款而与之不兼容。反过来,MPL-2.0 比 Apache-2.0 严格(有文件级 copyleft),却因为提供了二级许可机制而与 GPL 兼容。

正确的推理顺序是:先看方向(谁并入谁),再看源许可证有没有追加 GPL 不接受的条件(附加限制),最后才看义务多少决定履行成本。

2. 主流许可证兼容矩阵

下表回答一个具体问题:「行」许可证的代码,能否并入「列」许可证的项目并按列许可证分发。Y 表示可以,N 表示不可以,Y* 表示有条件成立(需要满足例外条款或版本选择)。

并入方 ↓ / 接收方 →MITApache-2.0GPLv2-onlyGPLv3AGPL-3.0LGPL-3.0MPL-2.0EPL-2.0
MITYYYYYYYY
BSD-2/3YYYYYYYY
Apache-2.0YYNYYYYY
GPLv2-onlyYNYNNNNN
GPLv2-or-laterYNYYYYNN
GPLv3YYNYYYYN
AGPL-3.0YYNYYYYN
LGPL-2.1/3.0YYY*YYYYY
MPL-2.0YYNY*Y*Y*YY
EPL-2.0YYNNNNNY

几条需要单独记忆的规则:

  • 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 SourceGrafana、MinIO、Metabase
SSPL-1.0否把程序作为服务提供给他人服务化所需的全部代码,含管理/监控/备份层MongoDB 4.0+
BSL-1.1否生产用途(除「额外使用许可」外)无 copyleft,仅限制使用,到期转 Apache-2.0HashiCorp Terraform、Vault
Elastic License 2.0否提供托管服务或规避功能限制无 copyleft,仅限制用途Elasticsearch 7.11~8.15
RSALv2否与许可方产品竞争无 copyleftRedis 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 ExceptionGPL-3.0把 libgcc/libstdc++ 链接进任意程序GCC 运行时
Classpath ExceptionGPL-2.0把 Java 类库链接进非 GPL 程序OpenJDK
Autoconf Configure Script ExceptionGPL-3.0分发 configure 生成的脚本不触发 GPLAutoconf
FOSS ExceptionGPL-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" },
]

读结果的三个要点:

  1. 区分 direct 与 transitive。直接依赖是你选的,可以换;传递依赖要先找到引入者(cargo tree -i、npm why、mvn dependency:tree -Dincludes)才能评估替换成本。
  2. 注意 test/optional 作用域。只在测试期使用的 GPL 工具通常不进入分发物,但「不分发」的前提是你真的不把它打进产物——很多构建脚本会把测试依赖误打包。
  3. 区分「依赖它的二进制」和「依赖它的库」。依赖 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法务

流程上设三道闸:

  1. 引入闸(PR 阶段)。CI 自动跑许可证扫描,命中非允许列表即 fail,要求提交人在 PR 里填写上表的工程字段,法务异步出结论。低风险的 MIT/Apache-2.0 自动放行,避免法务成为瓶颈。
  2. 季度全量复核。锁文件变化会让依赖图漂移——上游升个版本就可能从 MIT 变 BSL。每季度重跑一次全量扫描,与上一季度做 diff,只审新增与变更项。
  3. 发布闸(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,其余走审批。

常见坑清单

  1. 把 Apache-2.0 当 GPLv2 兼容:误以为「都是宽松/都开源就兼容」,实际因专利与 NOTICE 条款不兼容;引入前用矩阵核对方向。
  2. 把「动态链接一定安全」当规则:紧耦合的动态链接仍可能被认定为衍生作品;按耦合强度而非链接方式判断。
  3. 以为 AGPL 不改就没事:链接自有代码、打补丁都算 modified version;未修改也应提供源码获取入口。
  4. 只看直接依赖:GPL 往往在第 4~6 层传递依赖里;用 cargo tree -i、npm why、mvn dependency:tree -Dincludes 定位引入者。
  5. 把测试依赖打进产物:scope 写错导致 GPL 测试工具进了发布包;构建后核对产物内的依赖清单。
  6. 忽略 LICENSE 与源码头不一致:仓库根是 GPLv2 文本但文件头写「or later」;以文件头 SPDX 为准,冲突时逐文件确认。
  7. 上游换许可证没跟进:依赖升版后从 MIT 变 BSL/SSPL;季度扫描 + 锁文件 diff 是唯一可靠的发现手段。
  8. NOTICE 文件没有合并:用了 Apache-2.0 依赖但发布包只放了 LICENSE;构建阶段自动聚合 NOTICE 并纳入发布 checklist。
  9. 双许可选错路径:生产用社区版、文档写商业版,或反之;授权路径要写进依赖登记表并与采购记录对齐。
  10. 签名机制导致 LGPL 无法替换:合规要求「用户能重新链接」,但应用签名/沙箱阻止替换;上架前评估平台的替换可行性。
  11. 插件架构双向耦合:插件调用宿主内部 API 并共享数据结构,隔离主张站不住;把插件接口设计成窄的、类协议的契约。
  12. 例外审批过期未复核:两年前的审批覆盖的是旧版本,当前版本已换许可证;审批记录必须带复核日期并进入季度扫描。

小结

许可证兼容性是一个有向图加一套义务并集运算,工程侧的抓手是「依赖图 + 边界判定 + 留痕」三件套:用依赖图找到所有组件与引入路径,用链接方式/进程边界判定作品边界,用判定表与 SBOM 记录结论和审批。记住三条反直觉的结论——Apache-2.0 与 GPLv2 不兼容、动态链接不等于安全、AGPL 的触发条件比「是否修改」更宽——就能避开大部分坑。

落地时不要追求一次性判对所有依赖,而是先建立门禁:CI 里一份允许列表,季度一次全量 diff,发布前一次 checklist。让新引入的组件在 PR 阶段就被拦住,比事后审计上千个存量依赖便宜得多。存量依赖则按「高价值 + 高风险」优先排序,先处理那些既在核心链路又带 copyleft 的组件。

下一步建议按三条线深入:许可证的声明与扫描实务(SPDX 标识规范、扫描器接入、例外登记表模板)见本专题的许可证合规一文;全站治理框架与角色分工(治理委员会、评审流程、度量指标)见开源治理全景;把合规结论转成商业模式设计(社区版边界怎么划、商业版授权按什么维度计价)见 Open Core 商业化一文。三条线合起来,才是从「不出事」到「用开源做出商业价值」的完整闭环。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「开源生态」更多文章

  1. 开源商标与品牌治理
  2. 开源度量与分析
  3. 企业参与开源与 OSPO