目标:在一台 Windows 开发机 + 三台 CentOS 虚拟机上,把「写代码 → 提交 → 自动构建 → 自动部署」这条链路完整跑通。
读完可以独立完成:Spring Boot 项目、GitLab 私有仓库、Jenkins 三次构建、SSH 推送 jar、目标机 Docker 运行。
文章目录
-
- [一、先搞清楚概念:SDLC、瀑布、敏捷与 CI/CD](#一、先搞清楚概念:SDLC、瀑布、敏捷与 CI/CD)
- 二、实验环境规划
- [三、第一步:准备一个能跑起来的 Web 应用](#三、第一步:准备一个能跑起来的 Web 应用)
- [四、第二步:GitLab 私有代码仓库](#四、第二步:GitLab 私有代码仓库)
-
- [4.1 安装](#4.1 安装)
- [4.2 建组、建用户、分权限](#4.2 建组、建用户、分权限)
- [4.3 把本地代码推上去](#4.3 把本地代码推上去)
- [五、第三步:Jenkins 部署与初始化](#五、第三步:Jenkins 部署与初始化)
-
- [5.1 用 Docker 起 Jenkins](#5.1 用 Docker 起 Jenkins)
- [5.2 插件与全局工具](#5.2 插件与全局工具)
- [六、第四步:Publish over SSH 打通推送通道](#六、第四步:Publish over SSH 打通推送通道)
- 七、第五步:第一次构建------把代码拉下来
- [八、第六步:第二次构建------Maven 打成 jar](#八、第六步:第二次构建——Maven 打成 jar)
- [九、第七步:第三次构建------用 SSH 把 jar 送到 web](#九、第七步:第三次构建——用 SSH 把 jar 送到 web)
- [十、第八步:Docker 化并真正跑起来](#十、第八步:Docker 化并真正跑起来)
-
- [10.1 把 Docker 文件也纳入版本库](#10.1 把 Docker 文件也纳入版本库)
- [10.2 web 机器准备 Docker](#10.2 web 机器准备 Docker)
- [10.3 第 5 次构建:让目标机自己部署](#10.3 第 5 次构建:让目标机自己部署)
- [十一、第九步:版本迭代 v1.0 → v2.0 → v3.0](#十一、第九步:版本迭代 v1.0 → v2.0 → v3.0)
- 十二、踩坑清单
- 十三、总结
一、先搞清楚概念:SDLC、瀑布、敏捷与 CI/CD
软件开发生命周期(SDLC)是所有 DevOps 讨论的起点:需求分析 → 设计 → 实现 → 测试 → 进化,五个阶段首尾相接,形成闭环。

传统的瀑布模型 把这条链路做成单向流水:上一个阶段完全结束后才进入下一阶段。优点是文档齐全、节点清晰;缺点是用户要等到项目末期才能看到成果,风险后置、无法响应需求变化。
后来演化出敏捷开发,核心是两点:
- 迭代开发:把一年的大开发拆成多个小周期,每个周期走一遍完整流程;
- 增量开发:每个迭代交付一个用户可感知的完整功能,而不是所有功能都做一半。
在此基础上才有持续集成(CI):开发者频繁地把代码合并到主干,每一次合并都自动触发「构建 + 测试」,用例不通过就不许合并。CI 的完整链路是:
提交 → 测试(第一轮) → 构建 → 测试(第二轮) → 部署 → 回滚
而一个 CI 体系必须有三样东西:
| 组成要素 | 作用 | 本实验中的角色 |
|---|---|---|
| 自动构建过程 | 检出、编译、测试、记录结果全自动 | Jenkins + Maven |
| 代码存储库 | 版本控制 + 构建素材来源 | GitLab |
| 持续集成服务器 | 调度构建、展示结果 | Jenkins(Docker 运行) |
二、实验环境规划
整个实验用 4 台机器(1 台 Windows 开发机 + 3 台 CentOS 虚拟机,建议 2C4G 起):
| 主机名 | IP | 配置 | 软件 |
|---|---|---|---|
| gitlab | 192.168.100.153 | 2C2G/200G | gitlab-ce 12.4.2(端口 82) |
| jenkins | 192.168.100.154 | 2C4G/200G | JDK 1.8、Maven 3.6.2、Docker、Jenkins 2.346.3、SonarQube、Harbor |
| web | 192.168.100.155 | 2C4G/200G | Docker(部署目标机) |
| Windows | 192.168.100.132 | 8G | JDK 1.8、Maven 3.8.4、IDEA 2019.3.1、Git |
整体数据流:
#mermaid-svg-QDsUL33QqZvaTfYd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QDsUL33QqZvaTfYd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QDsUL33QqZvaTfYd .error-icon{fill:#552222;}#mermaid-svg-QDsUL33QqZvaTfYd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QDsUL33QqZvaTfYd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QDsUL33QqZvaTfYd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QDsUL33QqZvaTfYd .marker.cross{stroke:#333333;}#mermaid-svg-QDsUL33QqZvaTfYd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QDsUL33QqZvaTfYd p{margin:0;}#mermaid-svg-QDsUL33QqZvaTfYd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QDsUL33QqZvaTfYd .cluster-label text{fill:#333;}#mermaid-svg-QDsUL33QqZvaTfYd .cluster-label span{color:#333;}#mermaid-svg-QDsUL33QqZvaTfYd .cluster-label span p{background-color:transparent;}#mermaid-svg-QDsUL33QqZvaTfYd .label text,#mermaid-svg-QDsUL33QqZvaTfYd span{fill:#333;color:#333;}#mermaid-svg-QDsUL33QqZvaTfYd .node rect,#mermaid-svg-QDsUL33QqZvaTfYd .node circle,#mermaid-svg-QDsUL33QqZvaTfYd .node ellipse,#mermaid-svg-QDsUL33QqZvaTfYd .node polygon,#mermaid-svg-QDsUL33QqZvaTfYd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QDsUL33QqZvaTfYd .rough-node .label text,#mermaid-svg-QDsUL33QqZvaTfYd .node .label text,#mermaid-svg-QDsUL33QqZvaTfYd .image-shape .label,#mermaid-svg-QDsUL33QqZvaTfYd .icon-shape .label{text-anchor:middle;}#mermaid-svg-QDsUL33QqZvaTfYd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QDsUL33QqZvaTfYd .rough-node .label,#mermaid-svg-QDsUL33QqZvaTfYd .node .label,#mermaid-svg-QDsUL33QqZvaTfYd .image-shape .label,#mermaid-svg-QDsUL33QqZvaTfYd .icon-shape .label{text-align:center;}#mermaid-svg-QDsUL33QqZvaTfYd .node.clickable{cursor:pointer;}#mermaid-svg-QDsUL33QqZvaTfYd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QDsUL33QqZvaTfYd .arrowheadPath{fill:#333333;}#mermaid-svg-QDsUL33QqZvaTfYd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QDsUL33QqZvaTfYd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QDsUL33QqZvaTfYd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QDsUL33QqZvaTfYd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QDsUL33QqZvaTfYd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QDsUL33QqZvaTfYd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QDsUL33QqZvaTfYd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QDsUL33QqZvaTfYd .cluster text{fill:#333;}#mermaid-svg-QDsUL33QqZvaTfYd .cluster span{color:#333;}#mermaid-svg-QDsUL33QqZvaTfYd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QDsUL33QqZvaTfYd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QDsUL33QqZvaTfYd rect.text{fill:none;stroke-width:0;}#mermaid-svg-QDsUL33QqZvaTfYd .icon-shape,#mermaid-svg-QDsUL33QqZvaTfYd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QDsUL33QqZvaTfYd .icon-shape p,#mermaid-svg-QDsUL33QqZvaTfYd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QDsUL33QqZvaTfYd .icon-shape .label rect,#mermaid-svg-QDsUL33QqZvaTfYd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QDsUL33QqZvaTfYd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QDsUL33QqZvaTfYd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QDsUL33QqZvaTfYd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} git push
第1次构建 拉取
第2次构建 mvn package
第3次构建 Publish over SSH
第4/5次构建 docker-compose
Windows IDEA
demo_web
GitLab
192.168.100.153:82
Jenkins
192.168.100.154:8080
web
192.168.100.155
容器运行
:8080/test
三、第一步:准备一个能跑起来的 Web 应用
Windows 侧安装 JDK 1.8、Maven 3.8.4、IDEA 2019.3.1,并把 Maven 的 conf/settings.xml 换成阿里云镜像(放在 <mirrors> 里,注意把注释符号去掉):
xml
<mirror>
<id>aliyunmaven</id>
<mirrorOf>central</mirrorOf>
<name>aliyun maven</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
用 IDEA 的 Spring Initializr 建一个 Maven 项目,组名 com.chenyan,项目名 demo_web,依赖只选 Spring Web (注意不是 spring-boot-starter-mvc,没有这个坐标)。

然后在 com.chenyan.demo.controller 下写一个最简单的接口:
java
package com.chenyan.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/test")
public String test() {
return "云原生测试";
}
}

启动后浏览器访问 http://localhost:8080/test 能看到内容,说明应用本身没问题------这一步必须先本地跑通,否则后面全是玄学。
四、第二步:GitLab 私有代码仓库
4.1 安装
bash
# 依赖与服务
yum -y install policycoreutils openssh-server openssh-clients postfix
systemctl enable sshd --now
systemctl enable postfix --now
firewall-cmd --add-service=ssh --permanent
firewall-cmd --add-service=http --permanent
firewall-cmd --reload
# 安装 GitLab CE
wget https://mirrors.tuna.tsinghua.edu.cn/gitlab-ce/yum/el6/gitlab-ce-12.4.2-ce.0.el6.x86_64.rpm
rpm -ivh gitlab-ce-12.4.2-ce.0.el6.x86_64.rpm
改两处配置后重载(关键是端口,因为 8080 要留给 Jenkins):
ruby
# /etc/gitlab/gitlab.rb
external_url 'http://192.168.100.153:82' # 第 23 行
nginx['listen_port'] = 82 # 第 1112 行
bash
gitlab-ctl reconfigure
gitlab-ctl restart
firewall-cmd --zone=public --add-port=82/tcp --permanent
firewall-cmd --reload
首次访问会要求设置 root 密码(实验中设为 abcd1234)。
4.2 建组、建用户、分权限
生产环境的做法是不要用 root 推代码 :新建组(如 chen_group,可见性设为 Private),新建普通用户 chen,再把用户加入组。
GitLab 的组权限有 5 档,理解它们对后面配 Developer 很关键:
| 角色 | 能做什么 | 典型对象 |
|---|---|---|
| Guest | 提 issue、评论,不能读写仓库 | 外部协作 |
| Reporter | 可以克隆,不能提交 | QA / PM |
| Developer | 克隆、开发、提交、push | 普通开发 |
| Maintainer | 建项目、打 tag、保护分支、管成员 | 核心开发 |
| Owner | 改可见性、删项目、管组成员 | 组长 |

4.3 把本地代码推上去
在 IDEA 里 Add → Commit Directory 提交到本地仓库,再 Push 到 http://192.168.100.153:82/chen_group/web_demo.git,输入 GitLab 账号密码。

小技巧:IDEA 第一次 push 时如果弹窗要求输入账号,用「用户名 + 密码」,不要用 token;GitLab 12.x 对 http 账号直接支持。
五、第三步:Jenkins 部署与初始化
5.1 用 Docker 起 Jenkins
bash
# 安装 docker(华为云源)
sudo yum install -y yum-utils device-mapper-persistent-data lvm2
wget -O /etc/yum.repos.d/docker-ce.repo https://mirrors.huaweicloud.com/docker-ce/linux/centos/docker-ce.repo
sudo sed -i 's+download.docker.com+mirrors.huaweicloud.com/docker-ce+' /etc/yum.repos.d/docker-ce.repo
sudo yum makecache fast && sudo yum install docker-ce -y
systemctl enable docker --now
docker-compose.yml(/usr/local/docker/jenkins_docker/):
yaml
version: "3.1"
services:
jenkins:
image: jenkins/jenkins:2.346.3-lts-jdk8
container_name: jenkins
ports:
- 8080:8080
- 50000:50000
volumes:
- ./data/:/var/jenkins_home/
bash
docker-compose up -d
docker logs -f jenkins
第一次启动大概率报:
touch: cannot touch '/var/jenkins_home/copy_reference_file.log': Permission denied
原因是宿主机 data 目录权限和容器内 jenkins 用户(uid 1000)不匹配,最简单的做法:
bash
chmod 777 data/
docker-compose restart
日志里会出现初始管理员密码,也可以直接读文件:
bash
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
5.2 插件与全局工具
实验环境内网受限,采用离线插件 :把 plugins 目录用 Xftp 覆盖到 data/ 下,重启后 Jenkins 自动识别;再手动上传 Deploy、git-parameter 等插件。

然后是 系统管理 → Global Tool Configuration,把 JDK 与 Maven 指向容器内路径(这两个目录是从宿主机拷进 data 的):
JDK : /var/jenkins_home/jdk
Maven: /var/jenkins_home/maven

六、第四步:Publish over SSH 打通推送通道
「构建产物要送到另一台机器」靠 Publish over SSH 插件。配置入口:系统管理 → 系统配置 → Publish over SSH。

| 字段 | 值 | 说明 |
|---|---|---|
| Name | web |
构建步骤里引用的名字 |
| Hostname | 192.168.100.155 |
目标机 IP,不要写主机名 |
| Username | root |
登录用户 |
| Remote Directory | /usr/local/test |
远程根目录,必须存在 |
| 高级 → 密码 | 123 |
目标机 root 密码 |
| 高级 → 跳板机(Jump host) | 留空 | 填任何值都会先连跳板机,极易踩坑 |
配完点 Test Configuration ,成功会提示 Success。如果报 No such directory,去 web 上 mkdir -p /usr/local/test 再测。
⚠️ 高频坑:跳板机字段被误填(比如手滑填了密码
123),Java 会把它当成 IPv4 数字地址0.0.0.123去连接,报
java.net.SocketException: Invalid argument or cannot assign requested address。看到这个错先检查跳板机字段。
七、第五步:第一次构建------把代码拉下来
新建自由风格 任务 mytest,源码管理选 Git:
- Repository URL:
http://192.168.100.153:82/chen_group/web_demo.git - Credentials:添加
chen的用户名密码凭据 - 分支:
*/master

点击「立即构建」,控制台出现 Checking out Revision ... 即成功。产物会落在:
bash
/usr/local/docker/jenkins_docker/data/workspace/mytest/
八、第六步:第二次构建------Maven 打成 jar
在「构建」里增加 Invoke top-level Maven targets:
Maven Version : maven
Goals : clean package -DskipTests
三个参数的含义:clean 清掉旧 target、package 编译打包、-DskipTests 跳过单测(但仍会编译测试代码)。

构建完成后在宿主机可直接看到产物:
bash
[root@jenkins target]# ls
classes demo-0.0.1-SNAPSHOT.jar demo-0.0.1-SNAPSHOT.jar.original ...
九、第七步:第三次构建------用 SSH 把 jar 送到 web
「构建后操作 → Send build artifacts over SSH」:

| 配置项 | 值 |
|---|---|
| SSH Server | web |
| Source files | target/*.jar |
| Remove prefix | target/(可选,去掉目录层级) |
| Remote directory | .(相对 SSH Server 的 /usr/local/test) |
| Exec command | 见下 |
bash
cd /usr/local/test
ls -l
构建成功后 web 上就有了 jar:
bash
[root@web test]# ls
target
[root@web test]# ls target/
demo-0.0.1-SNAPSHOT.jar
十、第八步:Docker 化并真正跑起来
10.1 把 Docker 文件也纳入版本库
在项目里新建 docker/Dockerfile 与 docker/docker-compose.yml,提交到 GitLab,第 4 次构建就能把它们拉下来:
dockerfile
FROM openjdk:8
COPY demo-0.0.1-SNAPSHOT.jar /usr/local/
WORKDIR /usr/local
CMD java -jar demo-0.0.1-SNAPSHOT.jar
yaml
version: '3.1'
services:
mytest:
build:
context: ./
dockerfile: Dockerfile
image: mytest:v1.0
container_name: mytest
ports:
- 8080:8080
10.2 web 机器准备 Docker
bash
yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
systemctl disable firewalld.service --now
setenforce 0
yum install docker-ce -y
systemctl enable docker --now
配好镜像加速(/etc/docker/daemon.json)后 systemctl daemon-reload && systemctl restart docker。
10.3 第 5 次构建:让目标机自己部署
传统的做法是 Jenkins 把文件推过去再手工执行;更工程化的做法是推送完成后远程执行一条命令。在「构建后操作」的 Exec command 中写:
bash
cd /usr/local/test/docker
mv ../target/*.jar ./
docker-compose down
docker-compose up -d --build
docker image prune -f

构建完成,web 上:
bash
[root@web docker]# docker ps -a
CONTAINER ID IMAGE COMMAND PORTS NAMES
525506d7cc30 mytest:v1.0 "/bin/sh -c 'java -j..." 0.0.0.0:8080->8080/tcp mytest
访问 http://192.168.100.155:8080/test,页面返回接口内容,第一条流水线闭环完成。
十一、第九步:版本迭代 v1.0 → v2.0 → v3.0
真正的价值在于改一次代码、点一次构建就能上线新版本:
- 修改
TestController返回文案,pom.xml版本号改为2.0,提交并 push; - Jenkins 立即构建(第 6~8 次构建);
- web 上
docker-compose down后重建,页面变成 v2.0 的内容; - 再迭代一版 v3.0,验证「代码 → 构建 → 部署」的重复性。

十二、踩坑清单
| 现象 | 原因 | 处理 |
|---|---|---|
Permission denied: copy_reference_file.log |
data 目录权限与容器内 uid 不匹配 | chmod 777 data/ 后重启 |
| 浏览器打不开 Jenkins,本机正常 | 未开启 IP 转发 | 宿主机 net.ipv4.ip_forward=1 并 sysctl -p |
No such directory 测试失败 |
远程目录不存在 | 目标机 mkdir -p /usr/local/test |
java.net.SocketException: Invalid argument or cannot assign requested address |
跳板机字段被填了数字(如 123 → 0.0.0.123) |
跳板机留空 |
| 推送成功但 web 上看不到 jar | Source files 与实际路径不一致 | 用 target/*.jar,必要时配 Remove prefix |
docker-compose up 报端口占用 |
8080 被 Jenkins 或旧容器占用 | 换 8081:8080 |
mvn 报 无效的源发行版 |
IDEA/Maven 的 JDK 版本不一致 | File → Project Structure 统一 1.8 |
十三、总结
这一篇做完,你已经具备一条可重复执行的 CI/CD 最小闭环:
- 代码托管在私有 GitLab;
- Jenkins 负责拉取、构建、产物投递;
- 目标机 Docker 化运行,一条命令完成发布。
但它有明显短板:所有步骤都写在 Jenkins 页面的「构建后操作」里,配置不可版本化、不可评审、也无法并行。下一篇把这条链路升级为 Pipeline(Jenkinsfile 即代码),并接入 SonarQube 代码审计与 Harbor 私有镜像仓库。
本系列共三篇:
- DevOps 入门实战:从 CI/CD 概念到 Jenkins 自动构建部署(本篇)
- DevOps 进阶实战:SonarQube 代码审计 + Harbor 私有镜像仓库
- Jenkins Pipeline 实战:把流水线写成代码