# Jenkins 必知必会：从跑通 Hello World 到看懂 CI/CD

适合刚完成本项目 Hello World 的你。建议先读第 1～6 节，再打开配套 [HTML 图解](jenkins-basics.html)，最后做第 12 节的小练习。本文解释现有案例；新增第 13 节对应真实服务器部署，可以在理解本地案例后阅读。

## 1. Jenkins 到底帮我们做什么？

假设每次修改 Go 应用，你都要手动做这些事：拿到代码、执行测试、构建镜像、启动新容器、检查接口。Jenkins 可以把这份操作清单保存起来，收到触发后按顺序执行，并记录过程和结果。

把它想成一个“带日志的自动化调度员”：你告诉它什么时候做、让谁做、按什么顺序做，它负责安排和记录。实际编译由 Go 工具完成，镜像由 Docker 构建，部署由你写的命令完成。Jenkins 不会凭空知道怎么发布你的应用。

你刚做的 Hello World 就是一份很短的清单：

```text
点击立即构建 → 打印问候 → 执行 Shell → 生成并归档 hello.txt → 记录结果
```

**先记住三个问题：什么时候运行？在哪里运行？运行什么？** 以后看任何 Jenkinsfile，都可以从这里入手。

## 2. Job、Build、Pipeline、Stage、Step：五个最容易混的词

| 概念 | 通俗解释 | 你的 Hello World |
| --- | --- | --- |
| Job（任务） | 保存下来、可以反复执行的一项工作 | 首页的 `hello-world` 任务 |
| Build / Run（一次运行） | 任务的一次具体执行，有编号和结果 | `hello-world #1`、`#2` |
| Pipeline（流水线） | 这项工作要执行的整个流程 | 从打印问候到归档文件的流程 |
| Stage（阶段） | 流程里的一个大步骤，便于理解和定位 | `Hello`、`Shell`、`Artifact` |
| Step（步骤） | 阶段里的一条具体操作 | `echo`、`sh`、`writeFile` |

它们的关系可以这样读：**一个 Job 使用一份 Pipeline 定义；每次触发产生一次 Build；这次 Build 按定义执行各个 Stage 中的 Step。** Jenkins 页面里的 Pipeline 也是一种任务类型。

Jenkins 所说的“Build”不一定包含编译。你的 Hello World 没有编译程序，仍然会产生 `#1` 这样的 Build 记录。构建编号属于各自的 Job，不是整个 Jenkins 共用一个编号。

对照 [Jenkins 术语表](https://www.jenkins.io/doc/book/glossary/) 和 [Pipeline 概念](https://www.jenkins.io/doc/book/pipeline/) 阅读时，把英文名和这里的案例对应起来即可。

## 3. Jenkinsfile 是什么？它和 Makefile 有什么区别？

`Jenkinsfile` 是描述流水线的文本文件。当前案例采用 **Declarative Pipeline（声明式流水线）**，也就是 `pipeline { ... }` 这种结构。先掌握这一种即可，暂时不用深入 Groovy 或另一种 Scripted Pipeline 写法。

这几个文件各司其职：

| 文件 | 负责回答 | 本项目里的例子 |
| --- | --- | --- |
| Jenkinsfile | 哪台节点按什么顺序执行？失败怎么办？ | 先测试，再构建，再部署 |
| Makefile | 一项具体操作对应哪些命令？ | `make build` 调用 `docker build` |
| Dockerfile | 如何制作镜像？ | 编译 Go，把程序放入运行镜像 |
| compose.yaml | 如何运行容器？ | 使用哪个镜像、映射哪个端口 |

文件本身不会自动运行。你执行 `make up`，Make 才会读 Makefile；Jenkins 任务加载流水线定义后，才会安排流水线执行。

当前 Hello World 在网页里粘贴了脚本，**Jenkins 执行的是网页里保存的那份内容**。修改本机 Jenkinsfile 不会自动更新任务，必须重新粘贴、保存。以后改用 **Pipeline script from SCM**，Jenkins 才会从 Git 等源码管理系统获取定义。参见 [使用 Jenkinsfile](https://www.jenkins.io/doc/book/pipeline/jenkinsfile/)。

## 4. 谁负责调度？谁真正执行命令？

| 概念 | 可以这样理解 | 需要知道的边界 |
| --- | --- | --- |
| Controller（控制器） | 总调度台：提供网页、管理任务、协调执行 | 不必承担实际编译工作 |
| Node（节点） | Jenkins 登记的一个执行环境 | 可以是机器、虚拟机或容器环境 |
| Agent（代理） | 连接控制器、执行分配工作的程序 | 节点机器需要运行它；日常也常用 Agent 指代该执行环境 |
| Executor（执行槽） | 节点可同时承接工作的“工位” | 1 个执行槽意味着相关工作要排队使用它，不等于 1 个 CPU 核心 |
| Label（标签） | 用来选择节点的标记 | `mac-local` 是匹配条件，不会自动创建或连接 Mac |

`agent any` 的意思是申请一个允许承接该任务的可用节点；不是“在我打开浏览器的电脑上运行”，也不是“永远在控制器运行”。

如果你按 Hello World 教程还没添加其他 Agent，任务会使用控制器的内置节点，因此 `sh 'whoami'` 在 **Jenkins 容器里**执行。浏览器只是遥控器。

后面的 Go 案例写的是 `agent { label 'mac-local' }`，它要求标签匹配的 Mac Agent 在线。此时 `sh 'make build'` 从 Mac Agent 的工作区启动，再由 Docker 执行镜像构建。仅把标签写进文件、没有配置 Agent，会一直等待节点。

正式使用通常把控制器和执行任务的 Agent 分开。相关术语与节点配置见 [官方 Agent 文档](https://www.jenkins.io/doc/book/using/using-agents/)。

## 5. 命令与文件到底在哪里？

这是从 Hello World 走向真实部署时最值得弄清的部分。

### 5.1 Workspace：本次工作的操作目录

Workspace（工作区）位于执行节点上，供流水线检出源码、生成文件、运行命令。它不是浏览器电脑上的目录，也不保证每次都是一张白纸：旧文件可能留下，清理和并发也可能改变目录情况。

在当前 Hello World 中，`writeFile file: 'hello.txt'` 将文件写入 Jenkins 容器内的工作区。在 Mac Agent 上运行同样的步骤，文件就会写入 Mac 的 Agent 工作区。

### 5.2 Artifact：给某次构建保存的结果文件

`archiveArtifacts artifacts: 'hello.txt'` 会把匹配文件保存为本次构建的归档产物，让你在 `#1` 等构建详情页下载。工作区里的原文件和归档产物不是同一份生命周期。

例如 `#1` 的 NAME 是 Jenkins，`#2` 的 NAME 是 Luca；两次归档的 `hello.txt` 可以有不同内容。清理工作区不等于删除归档，但删除构建记录或保留策略清理历史可能连同产物一起清理。`fingerprint: true` 用于文件指纹追踪，不负责部署，也不提供永久备份。

### 5.3 JENKINS_HOME：Jenkins 自身的数据目录

本项目把容器的 `/var/jenkins_home` 挂载到命名卷 `jenkins-lab_jenkins_home`。这里保存 Jenkins 配置、插件、任务与构建记录等数据；当前内置节点的工作区通常也在这个目录下。

所以在 Hello World 中，`make down` 删除容器后，数据卷还在，再 `make up` 可以继续使用。`docker compose down -v` 会删除该 Compose 管理的数据卷，含义完全不同。远端或 Mac Agent 的工作区不因此自动包含在控制器数据卷里。参见 [Jenkins Docker 安装文档](https://www.jenkins.io/doc/book/installing/docker/)。

### 5.4 用四条命令确定自己身在何处

临时在 Hello World 的 `Shell` 阶段加入以下内容，重新保存并构建：

```groovy
sh '''
    echo "当前操作系统："
    uname -s
    echo "执行用户："
    whoami
    echo "当前目录："
    pwd
    echo "目录内的文件："
    ls -la
'''
```

按当前案例，`uname -s` 应显示 Linux，因为命令在容器内。以后切到原生 Mac Agent，通常会显示 Darwin。判断执行环境时看日志，不要凭浏览器运行在哪台电脑上猜测。

## 6. 读懂你已经运行过的 Hello World

打开 [现有 Jenkinsfile](helloworld/Jenkinsfile)，按下面的顺序看。这里只解释已经使用的语法。

| 代码 | 它在回答什么？ |
| --- | --- |
| `pipeline { ... }` | 整个流程是什么？ |
| `agent any` | 去哪里申请执行位置？ |
| `parameters { string(...) }` | 运行前允许用户输入什么？此处是 NAME |
| `stages { ... }` | 要依次经过哪些阶段？ |
| `stage('Hello')` | 这个阶段叫什么？名字由你决定 |
| `steps { echo ... }` | 这个阶段实际执行什么？ |
| `params.NAME` | 读取本次构建的用户参数 |
| `env.BUILD_NUMBER` | 读取当前 Job 的构建编号 |
| `sh '...'` | 在执行节点的 Shell 中运行命令 |
| `writeFile` | 在工作区写文件 |
| `archiveArtifacts` | 把文件归档到构建记录 |
| `post { success { ... } }` | 成功时做收尾操作 |
| `post { failure { ... } }` | 失败时做收尾操作 |

再看 `options` 中三行：

- `timestamps()`：给日志加时间，方便判断哪一步耗时，需要 Timestamper 插件。
- `timeout(time: 5, unit: 'MINUTES')`：限制这里的执行时长；顶层与阶段级 timeout 对等待 Agent 的计时规则不同，进阶时再查语法文档。
- `buildDiscarder(logRotator(numToKeepStr: '10'))`：限制历史构建保留数量，避免无限增长；需要长期保留的结果应另行管理。

另外有两个常见块：`environment` 给命令设置环境变量，例如 Go 案例的 `IMAGE_TAG`；`post { always { ... } }` 用于无论结果如何都尝试执行的收尾，例如清理临时文件。普通异常会进入相应的收尾逻辑，但不能指望断电或节点失联时收尾命令立即执行。

参数是“这次运行由用户选择的输入”；环境变量是“步骤和子进程可以读取的运行环境”。不要把密码当普通字符串参数保存。完整规则可查 [Pipeline 语法](https://www.jenkins.io/doc/book/pipeline/syntax/)。

## 7. 一次点击 Build Now 后发生了什么？

对当前普通顺序流水线，可以按以下顺序理解：

1. **接收触发**：你点击 Build Now，或者其他已配置的触发器发起运行。
2. **等待资源**：需要执行节点的工作进入等待，匹配节点必须在线且有空闲执行槽。
3. **分配工作区**：在选中的节点准备执行位置。
4. **运行阶段**：Hello → Shell → Artifact，日志随执行过程输出。
5. **收尾与记录**：根据结果执行 `post`，保留构建状态、日志和已归档产物。

`sh` 中的命令通常以退出码判断成败：0 表示成功，非 0 通常令该步骤失败。普通、未捕获的失败会让后续正常阶段跳过；`catchError` 等错误处理可以改变这个行为。当前脚本里的 `set -eu` 帮助 Shell 在命令失败或使用未设置变量时尽早退出。

常见结果要看文字，不必死记图标颜色：

| 状态 | 该如何理解 |
| --- | --- |
| SUCCESS | 按流水线配置的检查，这次运行成功 |
| FAILURE | 某一步执行失败，或流程被判定失败 |
| UNSTABLE | 完成了部分工作，但测试报告等将结果标为“不稳定”；不保证后续阶段一定跳过 |
| ABORTED | 运行被中止，例如点击停止或超时中断 |

“Pending / 等待执行”通常不是构建失败。先检查节点、标签和执行槽。失败排查时，打开具体编号的 **Console Output**，找到最早的关键错误，再看其前后上下文；最后的 `Finished: FAILURE` 只是结果。参见 [运行 Pipeline](https://www.jenkins.io/doc/book/pipeline/running-pipelines/)。

## 8. 什么叫自动触发？

| 方式 | 谁发起？ | 适合先怎么理解 |
| --- | --- | --- |
| 手动构建 | 你点击按钮 | 最容易观察输入和结果 |
| 参数化构建 | 你选择参数后点击按钮 | 同一流程接受不同输入 |
| 定时构建（cron） | Jenkins 按时间表运行 | 到时间就跑，不要求代码变化 |
| SCM 轮询（pollSCM） | Jenkins 定期检查代码仓库 | 检测到相关源码变化才触发 |
| Webhook | Git 服务向 Jenkins 发通知 | 配好连接与插件后，由事件触发 |

`H/2 * * * *` 中的 `H` 用来分散任务调度时间，放在 `pollSCM` 中可理解为每约两分钟检查一次仓库。它不是每两分钟必定部署一次，也不等于监控 Mac 上的本地文件。

当前 Go 案例的 LOCAL 模式需要手动点击。切到 SCM 模式并按教程配置 Git 后，才具备代码变化触发构建的条件。仅在项目里放一个 Jenkinsfile，不会自动创建 Jenkins 任务或 Git webhook。

## 9. CI、CD、构建、部署：分别完成了什么？

**CI（持续集成）** 可以先理解为：代码频繁合入时，自动执行构建和检查，尽早发现问题。**持续交付（Continuous Delivery）** 强调让通过验证的软件处于可发布状态，正式发布可以保留人工决定；**持续部署（Continuous Deployment）** 则把通过检查的变更自动发布到目标环境。CD 可能指后两者，讨论时要说清。

你的 Go 案例展示这些环节中的一次自动化执行：

```text
源码 → 测试 → 构建镜像 → 启动/更新应用容器 → 验证接口和版本
       CI 的检查与打包       部署                  验证
```

几个非常实用的区分：

- **测试通过**：只说明已编写的测试通过，不代表覆盖所有业务问题。
- **镜像构建成功**：得到了一份可运行的软件包，还没说明服务已更新。
- **容器启动成功**：进程开始运行，还要检查健康状态和业务响应。
- **部署验证成功**：确认目标环境确实在运行预期版本。Go 示例因此会检查响应中的 `version`。

Jenkins 的 Build 编号、Git commit、Docker 镜像标签是三种身份：分别标识某次执行、某份代码和某个镜像引用。Go 示例把 `BUILD_NUMBER` 拼成 `build-1` 这样的镜像标签，方便学习；不同任务可以有相同编号，镜像标签也可能被覆盖。未来正式发布要建立可追溯的版本对应关系。

Hello World 的 `archiveArtifacts` 保存的是文件；Go 本地案例的镜像存放在 Mac Docker 引擎中；独立服务器案例通过可配置私有仓库 push/pull 分发镜像。Jenkins 归档文件不等于把镜像推送到仓库。

## 10. 插件、凭据与权限：先掌握够用的部分

插件用于扩展 Jenkins 能力：当前 Pipeline 提供流水线能力，Timestamper 给日志加时间，接入 Git 时会使用 Git 插件。遇到“不认识某个步骤”，先检查拼写、插件和版本，再决定怎么处理。

安装 Jenkins 插件不等于在执行节点安装了同名命令。例如 Go 案例通过 `sh 'make build'` 调用 Docker，需要 Mac Agent 的 PATH 中能找到 Make 和 Docker，还要能连接 Docker 引擎；这种调用本身不要求 Docker Pipeline 插件。

Credentials（凭据）保存访问外部系统需要的秘密，例如 Git token、镜像仓库密码、SSH 私钥。流水线通常使用凭据 ID 引用它们，ID 是引用名，不是密码。先记住：秘密放凭据系统，源码只保存引用；不要为了排错把秘密打印到日志。参见 [Jenkins 凭据文档](https://www.jenkins.io/doc/book/using/using-credentials/)。

Hello World 的 **Groovy Sandbox** 是对流水线 Groovy 脚本能力的限制，不是 Docker 容器隔离，也不会让所有 `sh` 命令自动变得安全。Shell 命令仍按执行节点上的系统用户权限工作，因此 Agent 能访问哪些文件、Docker 和服务器，决定了流水线的实际能力。

## 11. 遇到问题，按这个顺序找

| 现象 | 优先检查什么？ |
| --- | --- |
| 点击构建后一直等 | Agent 在线吗？label 匹配吗？有空闲 executor 吗？ |
| `command not found` | 命令在哪个节点执行？那里的工具与 PATH 正确吗？ |
| `No such file` | 当前 `pwd` 是哪里？代码是否已检出或复制？ |
| `Permission denied` | `whoami` 是谁？文件和 Docker 的访问权限是否足够？ |
| 本机改了 Jenkinsfile，行为没变 | 任务是否仍在执行网页中粘贴的脚本？ |
| 构建成功，服务还是旧版本 | 有没有实际部署？目标机器、镜像标签和响应版本是否一致？ |
| 应用容器里访问 localhost 失败 | localhost 指向当前环境本身；容器内不等于 Mac 宿主机 |
| Go 案例没有随本地编辑自动运行 | LOCAL 模式没有文件监听；先手动触发或按教程接入 SCM |
| Jenkins 重建后数据丢了 | 是否删除或换了数据卷、Compose 项目名？ |

**最有效的排查信息是：哪个 Job 的哪次 Build，在什么节点、哪个阶段、哪条命令报了什么错。**

## 12. 三个小练习：确认自己真正理解了

### 练习一：观察同一任务的两次运行

用两个不同 NAME 分别运行 Hello World，观察构建编号、日志和各自下载的 `hello.txt`。

你应能解释：Job 没变，Build 变了；参数影响这次运行，归档产物属于对应构建记录。

### 练习二：找到真正执行命令的地方

使用第 5 节的诊断代码，观察操作系统、用户、工作目录。

你应能解释：为什么浏览器在 Mac 上，Shell 却显示 Linux；为什么本地源码文件不会自动出现在容器工作区。

### 练习三：主动制造一次普通失败

在 Hello 阶段的 `steps` 最后临时加入 `sh 'exit 1'`，保存后构建。观察 Shell、Artifact 是否跳过，以及 `post.failure` 的输出。看完后删除这一行，保存并重新运行。

你应能解释：失败发生在步骤；后续正常阶段未继续；失败收尾与成功收尾不同。因为归档阶段未执行，这次失败运行不会凭空获得新的 `hello.txt` 归档。

### 自测：不看上文，能回答这六个问题吗？

1. 一个 Job 可以有多少次 Build？
2. `agent any` 能保证在 Mac 上执行吗？
3. `writeFile` 和 `archiveArtifacts` 有什么区别？
4. 编辑本机 Jenkinsfile 后，网页粘贴模式会自动更新吗？
5. Docker 镜像构建成功，就代表部署成功吗？
6. `mac-local` 一直排队时，先查业务代码还是节点状态？

答案：①可以反复运行，历史可按保留策略清理；②不能；③前者写工作区文件，后者保存构建产物；④不会；⑤不代表；⑥先查节点在线状态、标签和执行槽。

到这里再进入 [Go 应用本地案例](goapp/README.md)，理解 Mac Agent 和镜像构建、部署、验证；跑通后进入 [真实服务器案例](goapp-server/README.md)。不必同时学习 Kubernetes 或复杂 Groovy。


## 13. Step 3：在独立案例中学习真实服务器部署

教程按 `helloworld → goapp → goapp-server` 递进。Step 3 的源码、Dockerfile、Makefile、Jenkinsfile 和配置都放在 `goapp-server/`，不会修改 Step 2 的 Mac 本地案例；Mac Agent 可以复用。以下跨架构构建、私有仓库 push/pull、SSH 和回退仅对应 Step 3。

### 13.1 构建机、部署机和 Agent 不是同一个角色

本例中，Mac 是构建机：运行 Jenkins Agent，执行测试与镜像构建。Linux 服务器是部署机：运行发布后的 Go 应用。Mac Agent 通过 SSH 请求服务器执行 Docker 命令；因此服务器不必再安装 Jenkins Agent，也不必安装 Go。

```text
Jenkins 控制器 → Mac Agent：构建指定架构镜像
                    ↓ push
                可配置私有镜像仓库
                    ↓ pull 固定 digest
                Linux 服务器：Compose / HTTP 验证
Mac Agent 通过 SSH 向服务器传递部署配置并执行部署命令
```

`agent { label 'mac-local' }` 仍表示流水线从 Mac 执行，但其中的 `ssh ...` 会在服务器启动远程命令。判断命令所在位置，要继续看是否调用了远程工具，不能只看 agent 标签。

### 13.2 两台机器，不共享镜像与 localhost

Mac 上的镜像不会自动出现在服务器。服务器案例由 Mac push 到配置的私有仓库，再由服务器 pull。`REGISTRY_HOST` 指定仓库地址，`REGISTRY_REPOSITORY` 指定项目/镜像路径。SSH 只传配置并触发远程命令，镜像不经过 SSH 分发。

服务器请求 `127.0.0.1:8081` 是请求服务器上的应用；Mac 请求相同地址却是请求 Mac。案例通过 SSH 隧道把 Mac 的 18081 转发到服务器的 8081，让浏览器访问真实部署。远端自检成功不代表公网域名、HTTPS 或防火墙已配置。

### 13.3 架构决定镜像能在哪里运行

Apple Silicon 是 arm64，许多云服务器是 amd64。`TARGET_PLATFORM` 要按服务器 Docker 架构选 `linux/arm64` 或 `linux/amd64`。服务器脚本检查平台，防止把不兼容的二进制部署过去。这个纯 Go 示例在构建机平台运行单元测试，通过 `GOOS` / `GOARCH` 交叉编译目标程序。Buildx 的 `--push` 将构建结果推送仓库，元数据提供这次推送的 digest；最终仍需目标环境实际验证。

### 13.4 SSH 的两种身份校验

私钥向服务器证明“我是谁”；known_hosts 中的主机公钥帮助客户端确认“连到的是谁”。Jenkins 的 `goapp-server-ssh` 凭据提供前者，`goapp-server-known-hosts` 文件凭据提供后者。SSH Agent 插件提供 SSH 登录身份，不等同于执行任务的 Jenkins Agent。

仓库身份独立于 SSH：Mac/Jenkins 使用可 push 的账号，服务器部署用户预先 `docker login` 一个只读账号。Jenkins 的 `goapp-registry-push` 凭据由 Docker Pipeline 插件使用；不要把仓库密码写进普通参数或配置文件。

### 13.5 发布版本、并发和回退

每个新版本使用唯一标签，推送后记录 `仓库路径@sha256:digest`。标签可能被覆盖，digest 固定到一份内容。服务器发布目录保存这个引用、Compose 和端口配置，不保存镜像包。`current` 记录最后一次验证成功的发布，不能代替实时容器状态检查。若新部署替换了容器却验证失败，current 仍可能指向旧版本。

`disableConcurrentBuilds()` 只限制同一个 Jenkins Job；远端 `flock` 还防止同一用户部署目录下的多个发布同时更新应用。回退用 `make server-rollback IMAGE_TAG=旧标签`，重新从仓库 pull 已成功版本的 digest，恢复配置并验证。要回退的镜像必须仍被仓库保留，并且服务器仍有读取权限。本例没有自动回滚、无停机发布或数据库回退。

实际操作见 [服务器部署教程](goapp-server/README.md)。推荐顺序：预检查 → 手工发布 → SSH 隧道查看 → Jenkins 发布 → 修改代码发新版本 → 回退。先把私有仓库 push/pull 链路跑通，再加入公网访问入口。

---

配套图解：[Jenkins 基础关系图](jenkins-basics.html)。文档基于当前目录内两个示例编写；页面菜单会随 Jenkins 版本、插件和语言设置略有变化。
