引言
在 Jenkins Pipeline 的日常开发中,你是否遇到过这样的场景:一段在普通 Groovy 环境中运行正常的代码,放到 Jenkins Pipeline 中却抛出 RejectedAccessException 或 NotSerializableException?例如,lines.take(50) 被沙箱拒绝,而 lines.subList(0, 50) 却能顺利执行。
这背后的原因涉及 Jenkins Pipeline 的三大核心设计:Groovy 沙箱(Sandbox) 、CPS(Continuation-Passing Style)变换 以及 脚本安全(Script Security)机制 。本文将深入剖析这些机制,并通过 take() 和 subList() 的具体实例,帮你彻底理解沙箱与非沙箱的应用场景与限制。
一、Groovy 沙箱:默认的安全防线
1.1 什么是 Groovy 沙箱?
Jenkins Pipeline 默认在 Groovy 沙箱 中运行。沙箱由 Script Security 插件提供,其核心思想是:只允许执行一个被预先审核过的、被认为是"足够安全"的 Groovy 方法子集。
沙箱的设计目标是减少管理员的人工干预,让普通用户编写的 Pipeline 脚本能够在无需管理员事先批准的情况下安全执行。
1.2 沙箱模式 vs 非沙箱模式
| 维度 | 沙箱模式(Sandbox) | 非沙箱模式(Non-Sandbox) |
|---|---|---|
| 审批方式 | 每个方法调用需在白名单中,否则逐条审批 | 整个脚本需管理员一次性审批 |
| 权限粒度 | 方法级(细粒度) | 脚本级(粗粒度) |
| 适用场景 | 多分支 Pipeline、来自 SCM 的 Jenkinsfile | 管理员编写的、需要高级 Groovy 特性的脚本 |
| 默认状态 | ✅ 默认启用 | ❌ 需手动禁用沙箱 |
关键事实 :使用 Groovy 沙箱的脚本都 受到相同的限制------管理员编写的 Pipeline 和非管理员用户编写的 Pipeline 受到的限制完全相同。
1.3 白名单机制
沙箱通过 白名单(whitelist) 和 黑名单(blacklist) 来控制哪些方法可以被调用。Script Security 插件内置了一份默认的白名单,位于:
src/main/resources/org/jenkinsci/plugins/scriptsecurity/sandbox/whitelists/
当脚本调用了一个不在白名单中的方法时,Jenkins 会立即抛出 RejectedAccessException 并停止执行。此时,管理员可以通过 Manage Jenkins → In-process Script Approval 页面来批准该方法签名。
二、CPS 变换:Pipeline 可恢复执行的核心
2.1 什么是 CPS?
Jenkins Pipeline 使用一个名为 Groovy CPS 的库来运行 Pipeline 脚本。CPS(Continuation-Passing Style,延续传递风格)变换将代码转换为一个可以随时保存当前状态到磁盘 的版本(保存在构建目录的 program.dat 文件中),使得 Pipeline 即使在 Jenkins 重启后也能从中断处继续执行。
2.2 哪些代码会被 CPS 变换?
根据 Jenkins 官方文档,以下内容会被 CPS 变换:
- ✅ 你编写的几乎所有 Pipeline 脚本(包括库中的脚本)
- ✅ 大多数 Pipeline 步骤,包括所有带代码块的步骤
以下内容不会被 CPS 变换:
- ❌ 编译的 Java 字节码(包括 Java 平台、Jenkins 核心和插件)
- ❌ Groovy 语言的运行时
- ❌ Pipeline 脚本中的构造函数体
- ❌ 标记了
@NonCPS注解的方法 - ❌ 少数不带块且即时执行的步骤(如
echo、properties)
2.3 CPS 的调用规则
理解 CPS 的调用规则对避免序列化问题至关重要:
- CPS 变换的代码 → 可以调用 → CPS 变换的代码
- CPS 变换的代码 → 可以调用 → 非 CPS 变换的代码
- 非 CPS 变换的代码 → 可以调用 → 非 CPS 变换的代码
- ❌ 非 CPS 变换的代码 → 不可以调用 → CPS 变换的代码
违反最后一条规则会导致 CPS 解释器无法正确运行,产生不正确且往往令人困惑的结果。
三、为什么 take() 被禁止?
3.1 根本原因:双重限制
list.take(n) 被沙箱拒绝,是 CPS 变换 和 沙箱白名单 双重作用的结果。
第一层:CPS 序列化问题
take() 是 Groovy 为 List 添加的扩展方法 (来自 DefaultGroovyMethods)。在 CPS 环境下,这类扩展方法的执行可能涉及非序列化的中间状态,导致 NotSerializableException。
Jenkins Pipeline 的设计要求所有在 CPS 上下文中流转的对象都必须是 Serializable 的。Groovy 的扩展方法(如 take()、drop()、collect() 等)在 CPS 变换下可能产生不可序列化的闭包或迭代器,从而引发运行时异常。
第二层:沙箱白名单未包含
即使 take() 在序列化层面没有问题,它仍然不在沙箱的默认白名单中 。沙箱只允许被明确列入白名单的方法执行。take() 作为 Groovy 的扩展方法,并未被 Script Security 插件预设为"足够安全"的方法。
因此,当你在沙箱模式的 Pipeline 中调用 list.take(2) 时,会直接触发 RejectedAccessException。
3.2 take() 方法详解与正确使用场景
take(int n) 是 Groovy 在 List 上添加的扩展方法,用于获取列表的前 n 个元素,返回一个新的列表。它是纯读取操作,不会修改原列表。
典型用法(在沙箱外或经过审批后):
groovy
// 读取 CSV 文件,取前 50 行(含表头)
def csvContent = readFile('data.csv').trim()
def lines = csvContent.split('\n')
def displayLines = lines.take(Math.min(lines.size(), 51)) // 取前 51 行(含表头)
但在沙箱模式 下,这个调用会失败。如果你想继续使用 take(),有以下几种途径:
| 方案 | 适用场景 | 注意事项 |
|---|---|---|
| 管理员审批 | 生产环境 | 管理员在 In-process Script Approval 中批准该签名 |
| 使用 Java API 替代 | 推荐 | 用 list.subList(0, n) 替代 list.take(n) |
| 禁用沙箱 | ⚠️ 仅测试环境 | 整个脚本需管理员审批,安全风险高 |
使用 @NonCPS |
特定场景 | 标记方法绕过 CPS,但不能在其中调用 Pipeline 步骤 |
四、为什么 subList() 是安全的?
4.1 白名单已包含
subList() 是 java.util.List 接口的标准方法,而非 Groovy 的扩展方法。它已被明确添加到 Script Security 插件的默认白名单中。
在 JENKINS-54614 中,社区专门讨论了将 java.util.List subList(int, int) 添加到白名单的需求。随后,该签名被正式加入白名单。
4.2 为什么 subList 不会产生序列化问题?
subList() 返回的是原列表的一个视图(view),而不是复制数据。它不涉及 Groovy 扩展方法那样的闭包或动态代理,因此:
- 没有额外的不可序列化对象被引入 CPS 上下文
- 没有闭包需要序列化 (而
take()等 Groovy 扩展方法内部可能涉及) - 返回的
SubList对象本身是可序列化的(因为原始 List 必须是可序列化的)
4.3 subList() 方法详解与正确调用方式
subList(int fromIndex, int toIndex) 是 Java 标准 API,用于获取原列表从 fromIndex(含)到 toIndex(不含)的子列表视图 。它需要两个参数,而不是一个。
✅ 正确用法(替代 take(n)):
groovy
// 读取 CSV 文件,取前 50 行(含表头)
def csvContent = readFile('data.csv').trim()
def lines = csvContent.split('\n')
int maxDisplayRows = 50
def displayLines = lines.subList(0, Math.min(lines.size(), maxDisplayRows + 1))
注意:subList(0, n) 等价于 take(n)。第一个参数是起始索引(通常为 0),第二个参数是结束索引(不包含)。
❌ 错误用法(只传一个参数):
groovy
// 错误!subList 需要两个参数
def displayLines = lines.subList(Math.min(lines.size(), maxDisplayRows + 1))
// 这会导致 Groovy 异常:MissingMethodException
如果你看到 lines.subList(50) 之类的写法,那是错误的,因为 List 的 subList 方法签名是 subList(int fromIndex, int toIndex)。
4.4 使用 subList 的注意事项
⚠️ 重要 :subList 返回的是原列表的视图,修改子列表会影响原列表。
groovy
def list = ['a', 'b', 'c', 'd']
def sub = list.subList(0, 2) // ['a', 'b']
sub.set(0, 'x') // 修改子列表
// list 现在变为 ['x', 'b', 'c', 'd']
如果需要一个独立的副本 ,可以使用 new ArrayList(list.subList(0, 2))。但需要注意:new ArrayList(Collection) 构造函数可能 受到沙箱限制------历史上曾有相关限制记录。如果仅用于读取操作(如生成表格、日志输出),直接使用 subList 即可,无需创建副本。
五、实战对比:从 CSV 读取到表格生成
以下是一个典型的 Pipeline 场景:读取 CSV 文件,截取前 N 行用于显示,并判断数据量是否满足条件。
5.1 使用 take()(在沙箱中会失败)
groovy
def csvContent = readFile(csvAbs).trim()
def lines = csvContent.split('\n')
totalLines = lines.size()
dataLines = Math.max(0, totalLines - 1)
shouldSend = (dataLines > 2)
issueCount = dataLines
echo "✅ CSV 存在,总行数: ${totalLines},数据行数: ${dataLines},需发送: ${shouldSend}"
// 生成表格(限制显示行数)
int maxDisplayRows = 50
def displayLines = lines.take(Math.min(lines.size(), maxDisplayRows + 1)) // ❌ 沙箱拒绝
if (displayLines.size() > 0) {
csvTable = generatePlainTextTable(displayLines)
if (lines.size() > maxDisplayRows + 1) {
csvTable += "\n...(仅显示前 ${maxDisplayRows} 行,共 ${dataLines} 条记录,完整数据见附件)"
}
}
运行时会抛出 RejectedAccessException,因为 take() 不在白名单中。
5.2 改用 subList()(沙箱兼容)
groovy
def csvContent = readFile(csvAbs).trim()
def lines = csvContent.split('\n')
totalLines = lines.size()
dataLines = Math.max(0, totalLines - 1)
shouldSend = (dataLines > 2)
issueCount = dataLines
echo "✅ CSV 存在,总行数: ${totalLines},数据行数: ${dataLines},需发送: ${shouldSend}"
// 生成表格(限制显示行数)
int maxDisplayRows = 50
def displayLines = lines.subList(0, Math.min(lines.size(), maxDisplayRows + 1)) // ✅ 沙箱允许
if (displayLines.size() > 0) {
csvTable = generatePlainTextTable(displayLines)
if (lines.size() > maxDisplayRows + 1) {
csvTable += "\n...(仅显示前 ${maxDisplayRows} 行,共 ${dataLines} 条记录,完整数据见附件)"
}
}
subList() 是 Java 标准方法,已在白名单中,且不涉及 Groovy 扩展方法,因此执行顺利。
六、@NonCPS 注解:双刃剑
6.1 作用
@NonCPS 注解告诉 Jenkins 不要对该方法进行 CPS 变换,让它以普通 Groovy 代码的方式执行。这可以用于:
- 绕过 Groovy 语言覆盖范围的限制
- 获得更好的性能(CPS 解释器有显著开销)
6.2 使用限制(极易踩坑)
@NonCPS 标记的方法不能调用 CPS 变换的代码 (如 Pipeline 步骤 node、sh、stage 等)。
❌ 错误示例:
groovy
@NonCPS
def compileOnPlatforms() {
['linux', 'windows'].each { arch ->
node(arch) { sh 'make' } // ❌ 非 CPS 方法调用了 CPS 步骤
}
}
运行时会抛出警告:expected to call WorkflowScript.compileOnPlatforms but wound up catching node。
✅ 正确做法 :直接移除 @NonCPS 注解------大多数情况下它并不是必需的。
七、总结:最佳实践建议
7.1 选择沙箱还是非沙箱?
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 多分支 Pipeline / Jenkinsfile | ✅ 沙箱 | SCM 中的 Jenkinsfile 只能在沙箱中运行 |
| 管理员编写的复杂脚本 | ✅ 沙箱 + 必要审批 | 管理员也受同样限制,但可审批必要方法 |
| 需要大量 Groovy 高级特性 | ⚠️ 非沙箱(需审批) | 整个脚本一次性审批,但安全风险高 |
| 生产环境 | ✅ 沙箱 | 安全第一,细粒度控制 |
7.2 方法选择速查表
| 需求 | ❌ 不推荐(沙箱拒绝) | ✅ 推荐(沙箱允许) |
|---|---|---|
| 取前 N 个元素 | list.take(n) |
list.subList(0, n) |
| 跳过前 N 个元素 | list.drop(n) |
list.subList(n, list.size()) |
| 取后 N 个元素 | list.takeRight(n) |
可用 list.subList(list.size()-n, list.size())(需保证 n ≥ 0) |
| 创建独立副本 | 依赖 Groovy 扩展 | new ArrayList(list.subList(0, n))(需测试沙箱兼容性) |
7.3 核心要点
- 沙箱是默认的安全机制,管理员和非管理员受到相同限制
- CPS 变换是 Pipeline 可恢复执行的基础,但带来了序列化约束
- Groovy 扩展方法 (如
take()、drop())在 CPS 下可能产生序列化问题,且不在沙箱白名单中 subList()是 Java 标准方法,已在白名单中,且不引入额外的序列化风险subList()需要两个参数(起始索引和结束索引),不能只传一个@NonCPS是双刃剑 ------绕过 CPS 的同时,不能调用 Pipeline 步骤
理解这些底层机制,你就能在 Jenkins Pipeline 开发中游刃有余,避免踩坑,写出既安全又高效的 Pipeline 代码。