Skip to content

Jenkins Shared Library加载、信任与版本治理

Shared Library既是流水线复用工具,也是供应链代码。几十个项目调用同一个 deployProduction() 时,一次库更新可能同时改变所有发布行为;如果它被配置为Trusted Library,提交者甚至可能获得Controller级能力。因此共享库必须像框架和生产平台一样做API、版本、测试、权限和灰度治理。

学习目标

  1. 解释 vars/src/resources/和根目录文件的职责。
  2. 区分 @Library编译期加载、隐式加载和 library运行期加载。
  3. 解释Global Variable、classpath、CPS和序列化边界。
  4. 区分Trusted、Untrusted和Folder-scoped Library的安全能力。
  5. 设计无状态、显式参数、可兼容演进的共享库API。
  6. 避免Shell注入、凭据泄露和高权限Agent滥用。
  7. 使用固定Tag/Commit、语义版本和变更日志治理升级。
  8. 区分单元测试、Pipeline模拟测试和Jenkins集成测试。
  9. 完成灰度项目升级、批量迁移、失败回退和影响审计。

一、什么时候应该使用Shared Library

适合抽取:

  • 多项目一致的Maven/JDK 7/8/17构建规范。
  • 镜像标签、SBOM、签名和推送。
  • 测试报告和质量门。
  • 标准灰度发布、指标验证和通知。
  • 凭据作用域和安全检查。

不适合抽取:

  • 只出现一次的项目特殊逻辑。
  • 仍在快速变化且没有稳定契约的步骤。
  • 复杂业务判断。
  • 每个项目差异巨大却强行用几十个Boolean参数统一。

共享库目标是统一稳定能力,不是让Jenkinsfile变短到看不出实际发布流程。

二、目录结构

text
company-jenkins-library/
├── vars/
│   ├── javaServicePipeline.groovy
│   ├── javaServicePipeline.txt
│   ├── buildContainerImage.groovy
│   └── notifyRelease.groovy
├── src/
│   └── com/company/jenkins/
│       ├── ImageRef.groovy
│       ├── PipelineConfig.groovy
│       └── MavenBuilder.groovy
├── resources/
│   ├── com/company/jenkins/default-values.yaml
│   └── com/company/jenkins/scripts/verify-release.sh
├── test/
├── README.md
└── CHANGELOG.md

三、vars目录

vars/foo.groovy通常暴露为Jenkinsfile可直接访问的全局变量 foo

groovy
// vars/sayHello.groovy
def call(String name) {
    echo "hello ${name}"
}

调用:

groovy
sayHello('order-service')

call()让变量像函数调用。也可以暴露多个方法:

groovy
def success(String message) { echo "SUCCESS: ${message}" }
def failure(String message) { echo "FAILURE: ${message}" }

vars/foo.txt可为全局变量提供帮助文档,具体渲染与生成时机依赖Jenkins加载和使用情况。

四、vars为什么应尽量无状态

Global Variable脚本在一次Pipeline中可能以共享实例方式使用。若使用字段保存可变状态:

groovy
@groovy.transform.Field
int counter = 0

多个Stage或调用可能互相影响,Controller重启时还涉及序列化。共享库方法应优先:

  • 参数显式传入。
  • 返回结果显式传出。
  • 不依赖调用顺序。
  • 不把环境和凭据存字段。
  • 不保存Stream、Matcher等复杂对象。

五、src目录

src/按包结构放类,进入共享库classpath:

groovy
package com.company.jenkins

class ImageRef implements Serializable {
    String registry
    String repository
    String tag

    String fullName() {
        "${registry}/${repository}:${tag}"
    }
}

Jenkinsfile在 @Library编译期加载后可以import:

groovy
@Library('company-lib@v2.3.1') _
import com.company.jenkins.ImageRef

类若跨Pipeline暂停点存活,字段需要可序列化。不要把Jenkins内部对象、文件流或网络连接长期保存在类字段。

六、类如何调用Pipeline Step

普通类没有自动的 shecho等全局Step上下文。常见做法是注入steps/script:

groovy
package com.company.jenkins

class MavenBuilder implements Serializable {
    private final def steps

    MavenBuilder(steps) {
        this.steps = steps
    }

    void verify() {
        steps.sh 'mvn --batch-mode clean verify'
    }
}

vars中创建:

groovy
def call() {
    new com.company.jenkins.MavenBuilder(this).verify()
}

注入CPS Script后仍要注意序列化和生命周期。类负责小型领域封装,不能变成持有大量Pipeline运行状态的对象树。

七、resources目录

资源可通过 libraryResource读取:

groovy
def scriptText = libraryResource(
    'com/company/jenkins/scripts/verify-release.sh'
)
writeFile file: 'verify-release.sh', text: scriptText
sh 'chmod 700 verify-release.sh && ./verify-release.sh'

资源适合模板、默认配置和短脚本。注意:

  • 资源跟随共享库版本。
  • 写入Workspace后可能被修改。
  • 不能包含Secret。
  • 执行脚本前应固定版本、检查内容和权限。
  • 大型二进制不应塞进Git共享库。

八、根目录文件

README、CHANGELOG、测试配置和依赖描述属于库工程治理,不会像vars自动变成Pipeline全局变量。文档应包含:

  • 支持的Jenkins/插件版本。
  • API示例。
  • 参数与默认值。
  • 破坏性变更。
  • 升级/回退指南。
  • 安全权限要求。

九、@Library编译期加载

groovy
@Library('company-lib@v2.3.1') _
import com.company.jenkins.ImageRef

大致过程:

mermaid
flowchart TD
    A["读取Jenkinsfile"] --> B["解析@Library名称与版本"]
    B --> C["按SCM Retriever拉取库版本"]
    C --> D["把src加入classpath并准备vars"]
    D --> E["编译/CPS转换Jenkinsfile"]
    E --> F["运行时调用全局变量和类"]

因为在脚本编译前加载,所以可以import src类。

十、隐式加载

管理员可配置“Load implicitly”,让所有Pipeline自动加载某库。优点是Jenkinsfile简短,缺点是依赖不可见、升级影响范围巨大。

生产建议:

  • 基础安全库可以谨慎隐式加载。
  • 业务发布库优先显式声明版本。
  • 文档和Build日志记录最终库版本。
  • 不让默认分支漂移悄悄改变所有项目。

十一、library运行期加载

Pipeline library Step可在运行时根据变量加载库:

groovy
def lib = library("company-lib@${params.LIB_VERSION}")

但Jenkinsfile已经完成编译,不能像 @Library一样在源码顶部静态import刚动态加载的类。通常通过返回的库对象/动态访问方式使用,具体API随插件版本。

运行期版本参数必须白名单,不能允许用户传任意SCM Ref加载未评审代码。

十二、SCM Retriever与版本

共享库可使用Modern SCM或Legacy SCM等检出方式。应记录:

  • 仓库URL。
  • Credentials。
  • 默认版本。
  • 是否允许调用方覆盖版本。
  • Tag/Branch/Commit解析方式。
  • 缓存与刷新策略。

Legacy变量替换路径更容易配置错误,优先使用当前插件支持的Modern SCM能力。

十三、为什么不能默认跟main

groovy
@Library('company-lib@main') _

main每次构建可指向不同Commit:

text
项目代码没变
→ 共享库main变化
→ 流水线行为变化
→ Build不可复现

生产项目应固定经过验证的Tag或Commit:

groovy
@Library('company-lib@v2.3.1') _

Tag还应保护、签名或由受控发布流程创建,防止被移动覆盖。

十四、Library版本何时确定

@Library版本在Jenkinsfile编译/加载阶段决定;运行中即使远程Tag被恶意移动,当前已检出内容通常不会自动替换,但后续Build会受影响。应在日志记录库Commit SHA,而不只记录Tag。

十五、Trusted Library是什么

Trusted Library中的代码可获得比Sandbox脚本更高的能力,可能调用Jenkins内部API、访问Controller文件和敏感对象。能向Trusted Library仓库提交代码的人,实际上可能接近Jenkins管理员权限。

因此必须:

  • 独立受保护仓库。
  • 强制评审和CODEOWNERS。
  • 禁止普通项目开发者直接写入。
  • 固定版本与签名。
  • 安全扫描。
  • 审计每次库版本使用。
  • 最小化Trusted代码量。

十六、Untrusted与Folder Library

Untrusted Library通常受Groovy Sandbox和Script Approval约束;Folder级共享库通常用于团队范围并保持不可信边界,具体能力依赖Jenkins和相关插件版本。

Sandbox不是业务权限模型。即使脚本API受限,只要它能在高权限Agent执行Shell,仍可能读取生产凭据或Docker Socket。还必须隔离Agent、Credential和网络。

十七、Trusted Library供应链攻击

mermaid
flowchart TD
    A["攻击者修改Trusted Library"] --> B["项目下次Build加载恶意版本"]
    B --> C["读取Controller/凭据或调用内部API"]
    C --> D["控制Agent、制品或生产部署"]

共享库仓库、SCM凭据、Tag和发布账号都属于生产供应链资产。

十八、CPS对共享库的影响

vars和Groovy Pipeline逻辑通常也参与CPS转换。共享库不是逃离CPS的普通Groovy环境。

同样要遵守:

  • 复杂对象不跨Step。
  • @NonCPS不调用Pipeline Step。
  • 类字段可序列化。
  • 不持有Stream/Socket/Matcher。
  • Controller重启恢复需要Step支持。

十九、不要把所有方法加@NonCPS

错误做法:为避免序列化错误把构建、部署整个方法标记 @NonCPS,内部仍调用sh。正确做法是让流程方法保持CPS,把短小纯计算函数标记NonCPS,或把复杂工作交给外部CLI。

二十、共享库API设计

不推荐:

groovy
deploy(true, false, true, 'x', null, 3)

推荐命名配置:

groovy
deployService(
    environment: 'staging',
    imageDigest: 'registry.example.com/order-api@sha256:...',
    canaryPercent: 10,
    verifyBusinessMetrics: true
)

然后进行必填、类型、范围和组合约束验证。

二十一、配置对象与默认值

groovy
package com.company.jenkins

class DeployConfig implements Serializable {
    String environment
    String imageDigest
    int canaryPercent = 1

    void validate() {
        if (!(environment in ['test', 'staging', 'prod'])) {
            throw new IllegalArgumentException('environment不允许')
        }
        if (!(imageDigest ==~ /registry\.example\.com\/[a-z0-9-]+@sha256:[a-f0-9]{64}/)) {
            throw new IllegalArgumentException('imageDigest格式或仓库不允许')
        }
        if (canaryPercent < 1 || canaryPercent > 100) {
            throw new IllegalArgumentException('canaryPercent必须在1到100')
        }
    }
}

默认值变化也可能是破坏性变更。例如默认 canaryPercent 从1改为100,会让未显式传参的项目直接全量发布。

二十二、避免隐藏环境依赖

共享库内部不应悄悄读取几十个 env.*和全局凭据。应把项目差异显式传入,并在开始阶段输出脱敏后的最终配置。

隐藏依赖会导致:同一Jenkinsfile在不同Folder、Agent或Controller行为不同,难以复现。

二十三、Shell参数注入风险

旧式危险写法:

groovy
sh "docker build -f ${config.dockerfile} -t ${config.image}:${config.tag} ."

如果参数包含Shell元字符,可能执行额外命令。Groovy插值还会把值放进Step参数。

治理:

  • 对仓库、文件相对路径、Tag做严格白名单。
  • 不允许 ..、换行和Shell元字符。
  • 使用环境变量与单引号Groovy脚本。
  • 更复杂时写受测CLI,使用参数数组。
  • 不让不可信PR调用生产发布API。

二十四、安全buildContainerImage Demo

vars/buildContainerImage.groovy

groovy
def call(Map raw = [:]) {
    String repository = raw.repository as String
    String tag = raw.tag as String
    String dockerfile = (raw.dockerfile ?: 'Dockerfile') as String

    if (!(repository ==~ /registry\.example\.com\/[a-z0-9-]+/)) {
        error 'repository不在允许Registry或格式错误'
    }
    if (!(tag ==~ /[A-Za-z0-9][A-Za-z0-9_.-]{0,127}/)) {
        error 'tag格式错误'
    }
    List<String> pathSegments = dockerfile.tokenize('/')
    boolean safeRelativePath = dockerfile ==~ /(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+/
    boolean hasTraversalSegment = pathSegments.any { it == '.' || it == '..' }

    if (!safeRelativePath || hasTraversalSegment) {
        error 'dockerfile路径不安全'
    }

    withEnv([
        "SAFE_REPOSITORY=${repository}",
        "SAFE_TAG=${tag}",
        "SAFE_DOCKERFILE=${dockerfile}"
    ]) {
        sh '''
            set -eu
            docker build \
              -f "$SAFE_DOCKERFILE" \
              -t "$SAFE_REPOSITORY:$SAFE_TAG" \
              .
        '''
    }

    return "${repository}:${tag}"
}

这里的 ==~ 是 Groovy 的“整串匹配”,不是只要找到一个合法片段就通过。路径规则只接受由字母、数字、点、下划线和短横线组成的相对路径段,因此 /etc/passwdC:\\secret、空路径、换行和 Shell 元字符都会失败;随后再按路径段拒绝单独的 ...,避免工作区路径穿越。仅仅判断字符串“不包含 ..”仍不够,因为还必须拒绝绝对路径和平台分隔符差异。

白名单只能约束字符串形态。若 Agent 工作区中存在指向外部目录的符号链接,还应在受测 CLI 中调用 toRealPath(),确认解析后的 Dockerfile 仍位于 Workspace 根目录下。共享库脚本本身不应依赖一组容易写错的 Shell 字符串判断来完成最终文件系统授权。

该方法只 Build,不自动 Push 和部署,遵循单一职责。这样 Build 失败不会触发半完成发布,调用方也能分别对镜像构建、制品准入、凭据使用和生产部署设置权限与重试边界。

二十五、Publish与Credentials分离

groovy
def publishContainerImage(String image, String credentialsId) {
    // 还需验证image和允许的credentialsId映射
    withCredentials([usernamePassword(
        credentialsId: credentialsId,
        usernameVariable: 'REGISTRY_USER',
        passwordVariable: 'REGISTRY_PASSWORD'
    )]) {
        withEnv(["SAFE_IMAGE=${image}"]) {
            sh '''
                set +x
                echo "$REGISTRY_PASSWORD" | \
                  docker login registry.example.com \
                    -u "$REGISTRY_USER" --password-stdin
                docker push "$SAFE_IMAGE"
                docker logout registry.example.com
            '''
        }
    }
}

调用方不能任意传生产Credentials ID。共享库应按环境和Folder策略映射允许凭据,生产发布还要使用隔离Agent。

二十六、模板型总Pipeline

vars/javaServicePipeline.groovy

groovy
def call(Map config = [:]) {
    pipeline {
        agent none

        options {
            timestamps()
            disableConcurrentBuilds()
        }

        stages {
            stage('Build and Test') {
                agent { label config.buildLabel ?: 'linux && jdk8' }
                steps {
                    checkout scm
                    sh 'mvn --batch-mode clean verify'
                }
                post {
                    always {
                        junit allowEmptyResults: true,
                              testResults: 'target/surefire-reports/*.xml'
                    }
                }
            }

            stage('Build Image') {
                agent { label 'linux && docker' }
                steps {
                    script {
                        buildContainerImage(
                            repository: config.repository,
                            tag: env.GIT_COMMIT
                        )
                    }
                }
            }
        }
    }
}

项目Jenkinsfile:

groovy
@Library('company-lib@v2.3.1') _

javaServicePipeline(
    repository: 'registry.example.com/order-api',
    buildLabel: 'linux && jdk8'
)

模板型Pipeline统一能力强,但过度封装会让项目无法扩展。应提供受控Hook或组合式Step,而不是无限参数。

二十七、组合优于巨型模板

可提供小型步骤:

text
mavenVerify()
buildContainerImage()
scanImage()
publishImage()
deployCanary()
verifyRelease()

Jenkinsfile显式组合,既复用又能看懂流程。对高度统一的服务再提供模板入口。

二十八、API向后兼容

破坏性变更包括:

  • 删除参数。
  • 改参数类型。
  • 改默认值。
  • 改返回值。
  • 改异常/Build Result。
  • 改Stage名称影响监控。
  • 改凭据或Agent要求。

兼容策略:

  • 新增可选参数。
  • 旧参数保留并输出Deprecated警告。
  • 提供迁移期。
  • 重大变化发布新Major版本。
  • 不在同一Tag覆盖代码。

二十九、语义版本

示意:

text
v2.3.1
  • Patch:Bug修复,行为应兼容。
  • Minor:向后兼容的新能力。
  • Major:允许破坏性变化,并提供迁移指南。

流水线行为和安全默认值变化是否兼容,需要平台团队评审,不能只按代码编译是否通过判断。

三十、测试金字塔

mermaid
flowchart TD
    A["纯Groovy单元测试:校验/格式化"] --> B["Pipeline模拟测试:Step调用与分支"]
    B --> C["Jenkins Test Harness/临时Controller"]
    C --> D["真实Agent、Registry、K8s集成测试"]
    D --> E["少量金丝雀项目"]

三十一、纯单元测试

DeployConfig.validate()、镜像引用解析和版本比较使用普通JUnit/Spock测试,不依赖Jenkins,速度最快。

重点覆盖:

  • 正常值。
  • 边界值。
  • 空值。
  • 路径穿越。
  • Shell元字符。
  • 不允许Registry。
  • Digest长度。

三十二、Pipeline模拟测试

社区常用JenkinsPipelineUnit等工具模拟Step、绑定变量并断言调用。它不是Jenkins官方运行时的完整替代,无法证明插件、CPS、Sandbox和Agent行为完全一致。

适合验证:

  • 是否调用sh。
  • 参数是否正确。
  • 失败分支。
  • 凭据作用域是否被调用。
  • Stage和通知逻辑。

三十三、Jenkins Test Harness与集成测试

Jenkins Test Harness可启动更接近真实Controller的测试环境,验证插件、Shared Library和Pipeline交互;维护成本更高。还应有临时Jenkins/Agent执行真实Maven、Docker和kubectl集成测试。

不要只做Mock后就直接推生产Trusted Library。

三十四、测试CPS与重启

共享库中包含input、sleep、sh、parallel和复杂对象时,在测试Controller执行安全重启和异常恢复,检查:

  • Pipeline是否恢复。
  • 是否NotSerializable。
  • Step是否重复执行。
  • Workspace/stash是否存在。
  • post是否执行。

三十五、库发布流程

mermaid
flowchart TD
    A["PR与CODEOWNERS评审"] --> B["单元与Pipeline测试"]
    B --> C["安全扫描与兼容测试"]
    C --> D["创建不可变Tag并记录Commit"]
    D --> E["少量非生产项目升级"]
    E --> F["生产金丝雀项目"]
    F --> G{"构建、发布指标是否正常"}
    G -- "否" --> H["项目固定回旧版本并修复"]
    G -- "是" --> I["分批升级项目"]

三十六、如何批量升级几十个项目

不要一次改所有仓库。分组:

  1. 共享库自身测试项目。
  2. 非生产内部服务。
  3. 低风险生产服务。
  4. 核心服务少量金丝雀。
  5. 分批扩散。

观察Queue、构建成功率、时长、制品一致性、部署失败率和凭据异常。

三十七、默认版本升级风险

管理员把默认版本从v2改v3,所有未显式版本的Job可能在下次Build自动变化。应先扫描调用方、建立兼容矩阵、通知负责人并提供迁移工具。

更推荐项目显式版本,由自动化PR逐批升级。

三十八、回退共享库

项目显式固定:

groovy
@Library('company-lib@v2.3.1') _

升级失败可改回v2.3.0。但回退库只能恢复后续流水线逻辑,不能撤销已执行的生产部署、DDL和消息。仍需按发布Runbook处理副作用。

三十九、删除旧API的条件

  • 已统计没有项目调用。
  • 所有受支持版本已迁移。
  • 动态加载和隐式库已检查。
  • 文档与示例更新。
  • 经过Deprecated周期。
  • 提供Major版本说明。

不能只在代码搜索不到时删除,因为Jenkins Job配置、分支和旧Tag中仍可能调用。

四十、可观测性

共享步骤应记录:

  • Library名称、版本和Commit。
  • API名称与非敏感参数。
  • Stage耗时和结果。
  • Agent Label。
  • 制品Digest。
  • 失败分类。

不要记录Secret、完整环境变量和医疗/个人数据。

四十一、商业场景一:main更新导致全公司构建失败

隐式Trusted Library跟随main,某提交把默认JDK从8改17;几十个JDK 8项目下一次Build编译失败。

治理:固定版本、兼容参数、金丝雀项目、语义版本、自动化PR升级和快速回退。

四十二、商业场景二:Trusted Library被植入凭据窃取

攻击者获得共享库仓库写权限,在通知方法中读取Controller/凭据并外传。由于库受信,Sandbox没有阻止。

治理:把Trusted仓库视为管理员代码,强制多方评审、签名Tag、最小权限、网络出站控制、审计和凭据轮换。

四十三、商业场景三:默认参数变更直接全量发布

库把 canaryPercent默认值从1改成100,项目未显式传参,升级后绕过灰度。

治理:安全默认值变化视为Major Breaking Change;生产参数强制显式填写并加上限,库内拒绝100%作为首个阶段。

四十四、商业场景四:共享库Shell注入

项目参数 dockerfile被构造为带Shell元字符,旧库用Groovy插值直接拼入 sh,导致Agent执行额外命令。

治理:路径白名单、禁止 ..和控制字符、单引号Groovy脚本、受控环境变量、不可信PR隔离和最小权限Agent。

四十五、故障排查

Library无法检出

检查SCM URL、Credentials、版本Ref、代理、TLS、Retriever类型和Controller日志。

找不到vars方法

检查文件名大小写、目录、版本、隐式/显式加载、编译错误和 @Library位置。

找不到src类

检查包路径、import、是否使用运行期library导致编译期类不可见、类编译错误。

RejectedAccessException

脚本/库处于Sandbox且调用未批准API。不要为了通过直接把整个库改Trusted,应评估API、Script Approval和更安全Step。

NotSerializableException

检查共享库字段、返回值和跨Step变量;减少复杂对象,不无脑加NonCPS。

升级后大量Job失败

冻结继续扩散,按库版本、API和项目类型聚类;显式回退金丝雀项目,保留失败日志并修复兼容性。

四十六、面试标准回答

vars、src和resources区别

vars中的Groovy脚本暴露为Pipeline全局变量,call方法可像函数调用;src按包结构进入共享库classpath,适合可测试类;resources保存模板和脚本,通过libraryResource读取。三者都跟随库版本,资源不能包含Secret。

@Library和library Step区别

@Library在Jenkinsfile编译/CPS转换前加载,因此可静态import src类;library Step在运行期动态加载,脚本已编译,通常不能再用普通import引用新类,只能按动态API使用。运行期版本参数必须白名单。

Trusted Library为什么危险

Trusted Library可绕过Groovy Sandbox并调用Jenkins内部能力,能提交代码的人可能接近管理员权限。仓库必须受保护、强制评审、固定签名版本、最小Trusted代码和审计;不可信PR不能控制其版本。

为什么共享库不能跟随main

main是可变引用,项目代码不变时库变化也会改变Pipeline行为,Build不可复现。生产应固定不可变Tag/Commit并记录实际SHA,通过金丝雀和自动PR分批升级。

共享库怎样避免Shell注入

对仓库、Tag和路径做严格白名单,禁止Shell元字符和路径穿越;避免Groovy双引号直接拼参数,使用单引号脚本与受控环境变量;不可信PR在隔离Agent运行,生产凭据和Docker Socket不暴露。

Shared Library怎么测试和发布

纯逻辑做JUnit/Spock,Pipeline分支用模拟框架,CPS/插件用Test Harness或临时Jenkins,再由少量项目金丝雀。发布不可变语义版本和Changelog,项目显式固定版本,通过自动PR分批升级,失败固定回旧版本并处理已发生副作用。

四十七、学习验收

  1. 创建包含vars/src/resources和帮助文档的库。
  2. 分别用@Library、隐式加载和library Step验证加载时机。
  3. 写一个Serializable类调用steps并通过重启实验。
  4. 制造状态型vars串扰并改为无状态参数。
  5. 比较Trusted、Untrusted和Folder Library权限。
  6. 完成repository/tag/dockerfile注入测试。
  7. 为API新增参数并保持旧项目兼容。
  8. 使用纯单测、Pipeline模拟和临时Jenkins三层测试。
  9. 发布v2.3.1不可变Tag并记录Commit SHA。
  10. 用金丝雀项目升级,再分批迁移十个项目。
  11. 演练库版本回退,并说明外部部署副作用不会撤销。
  12. 完成Trusted Library仓库威胁模型和凭据隔离。

关联知识点