从零搭建 CI/CD 流水线
从零搭建 CI/CD 全流程实战:4 台虚拟机 + Jenkins + Gitea + Registry + Trivy + Ansible
本文记录使用 4 台 Ubuntu 24.04 虚拟机搭建一套完整 CI/CD 流水线的全过程。涵盖架构设计、私有镜像仓库、代码仓库、Jenkins 主从节点、Trivy 镜像扫描、Ansible 自动化部署,以及 Webhook 自动触发。部署过程中所有能自动化的步骤均通过 Ansible Playbook 完成,减少手工操作。
一、背景与目标
1.1 硬件配置
| 节点 | 配置 | 磁盘 | 角色 |
|---|---|---|---|
| Server1 | 4C / 4G + 2G Swap | 60G | Jenkins 主 + Gitea + Registry + Ansible 控制节点 |
| Server2 | 2C / 4G + 2G Swap | 40G | Jenkins Agent + 部署目标 |
| Server3 | 2C / 4G + 2G Swap | 40G | Jenkins Agent + 部署目标 |
| Server4 | 2C / 4G + 2G Swap | 40G | Jenkins Agent + 部署目标 |
1.2 网络环境
- 虚拟机网络:NAT 模式
- 四台机器均可访问外网
- 内网 IP 段:
192.168.136.0/24
| 节点 | IP |
|---|---|
| Server1 | 192.168.136.155 |
| Server2 | 192.168.136.156 |
| Server3 | 192.168.136.157 |
| Server4 | 192.168.136.158 |
1.3 项目目标
搭建一套完整的 CI/CD 流水线:
代码提交 → 自动拉取 → 构建镜像 → 安全扫描 → 推送私有仓库 → Ansible 自动部署设计原则:除 Server1 的基础 Docker 环境外,Server2-4 的所有依赖(Java、Docker 配置、DNS、时钟同步、Trivy、Python Docker SDK)均由 Ansible Playbook 统一推送,不手工登录每台机器操作。
二、最终架构
┌──────────────────────────────────────────────────────────────┐
│ Server1 (4C/4G) │
│ ┌────────┐ ┌─────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Gitea │ │ Jenkins │ │Docker Registry│ │ Ansible │ │
│ │ :3000 │ │ :8080 │ │ :5000 │ │ (控制节点) | │
│ └────────┘ └─────────┘ └───────────────┘ └───────────────┘ │
└──────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Server2 │ │ Server3 │ │ Server4 │
│ Jenkins │ │ Jenkins │ │ Jenkins │
│ Agent │ │ Agent │ │ Agent │
│ + 部署 │ │ + 部署 │ │ + 部署 │
└──────────┘ └──────────┘ └──────────┘流水线流程:
- 开发者推送代码到 Server1 的 Gitea
- Gitea 通过 Webhook 通知 Jenkins
- Jenkins 将任务分发到 Server2-4 中的某一台
- 构建节点拉取代码 → 构建 Docker 镜像 → Trivy 安全扫描 → 推送到私有 Registry
- Jenkins 调用 Ansible Playbook,在目标节点上拉取镜像并启动容器,完成部署
三、部署过程
3.1 基础环境准备(Server1)
1. 安装 Docker(Server1 手工执行一次)
Server1 作为控制节点,需要先有 Docker 环境才能跑 Registry、Gitea、Jenkins:
# 安装 Docker(使用国内加速脚本)
bash <(wget -qO- https://get.xuanyuan.cloud/docker.sh)2. 安装 Ansible(Server1)
sudo apt update
sudo apt install -y ansible
ansible --version3. 配置 SSH 免密
Ansible 通过 SSH 管理 Server2-4,需要先打通免密:
# 在 Server1 上生成密钥(如果已有可跳过)
ssh-keygen -t rsa -b 4096 -N "" -f ~/.ssh/id_rsa
# 分发公钥到 Server2-4
for ip in 192.168.136.156 192.168.136.157 192.168.136.158; do
ssh-copy-id admin_123@$ip
done4. 创建 Ansible 工作目录与 inventory
sudo mkdir -p /opt/ansible
cd /opt/ansible创建 inventory.ini:
[servers]
server2 ansible_host=192.168.136.156 ansible_user=admin_123
server3 ansible_host=192.168.136.157 ansible_user=admin_123
server4 ansible_host=192.168.136.158 ansible_user=admin_123
[servers:vars]
ansible_python_interpreter=/usr/bin/python3
ansible_ssh_common_args='-o StrictHostKeyChecking=no'验证连通性:
ansible -i inventory.ini servers -m ping3.2 用 Ansible 初始化 Server2-4 基础环境
编写 /opt/ansible/init-nodes.yml,一次性完成以下所有基础配置:
- 配置国内 DNS
- 安装并启用时钟同步
- 配置 Docker 信任私有仓库
- 安装 Java(Jenkins Agent 依赖)
- 安装 Python Docker SDK(Ansible docker 模块依赖)
- 创建 Jenkins Agent 工作目录
---
- name: 初始化 Jenkins 构建节点
hosts: servers
become: yes
vars:
registry_ip: "192.168.136.155:5000"
agent_dir: "/opt/jenkins-agent"
agent_user: "admin_123"
tasks:
# ========== 1. 配置 DNS ==========
- name: 创建 systemd-resolved 配置目录
file:
path: /etc/systemd/resolved.conf.d
state: directory
mode: '0755'
- name: 配置国内 DNS
copy:
dest: /etc/systemd/resolved.conf.d/dns_servers.conf
content: |
[Resolve]
DNS=223.5.5.5 223.6.6.6
FallbackDNS=8.8.8.8
mode: '0644'
notify: restart systemd-resolved
# ========== 2. 安装并启用时钟同步 ==========
- name: 安装 systemd-timesyncd
apt:
name: systemd-timesyncd
state: present
update_cache: yes
- name: 启用并启动 systemd-timesyncd
systemd:
name: systemd-timesyncd
enabled: yes
state: started
# ========== 3. 安装 Java(Jenkins Agent 依赖)==========
- name: 安装 OpenJDK 21 JRE
apt:
name: openjdk-21-jre-headless
state: present
# ========== 4. 安装 Python Docker SDK ==========
- name: 安装 python3-pip
apt:
name: python3-pip
state: present
- name: 安装 Docker Python SDK
pip:
name: docker
state: present
executable: pip3
# ========== 5. 配置 Docker 信任私有仓库 ==========
- name: 配置 daemon.json(合并已有配置)
copy:
dest: /etc/docker/daemon.json
content: |
{
"registry-mirrors": ["https://docker.xuanyuan.me"],
"insecure-registries": ["{{ registry_ip }}"]
}
mode: '0644'
notify: restart docker
# ========== 6. 创建 Jenkins Agent 工作目录 ==========
- name: 创建 Agent 工作目录
file:
path: "{{ agent_dir }}"
state: directory
owner: "{{ agent_user }}"
group: "{{ agent_user }}"
mode: '0755'
# ========== 7. 将用户加入 docker 组 ==========
- name: 将 admin_123 加入 docker 组
user:
name: "{{ agent_user }}"
groups: docker
append: yes
handlers:
- name: restart systemd-resolved
systemd:
name: systemd-resolved
state: restarted
- name: restart docker
systemd:
name: docker
state: restarted执行:
cd /opt/ansible
ansible-playbook -i inventory.ini init-nodes.yml注意:daemon.json的合并逻辑在 Playbook 里是直接覆盖。如果目标节点已有其他 Docker 配置(如data-root),需要改成用blockinfile或template做 JSON 合并,避免丢失原有配置。
3.3 部署 Docker Registry(Server1)
1. 创建数据目录
sudo mkdir -p /opt/docker-data/registry2. 启动 Registry 容器
docker run -d \
--name registry \
--restart=always \
-p 5000:5000 \
-v /opt/docker-data/registry:/var/lib/registry \
registry:23. 验证
curl http://localhost:5000/v2/ # 期望返回 {}
docker info | grep -A 2 "Insecure Registries" # 确认配置生效3.4 部署 Gitea(Server1)
1. 创建数据目录
sudo mkdir -p /opt/docker-data/gitea2. 启动 Gitea 容器
docker run -d \
--name gitea \
--restart=always \
-p 3000:3000 \
-p 222:22 \
-v /opt/docker-data/gitea:/data \
-v /etc/timezone:/etc/timezone:ro \
-v /etc/localtime:/etc/localtime:ro \
gitea/gitea:latest3. 初始化
浏览器打开 http://192.168.136.155:3000,按向导完成:
- 数据库:SQLite3
- SSH 服务端口:222(注意不是 22)
- 创建管理员账号
3.5 部署 Jenkins 主节点(Server1)
1. 创建数据目录
sudo mkdir -p /opt/docker-data/jenkins
sudo chown -R 1000:1000 /opt/docker-data/jenkins2. 启动 Jenkins 容器
docker run -d \
--name jenkins \
--restart=always \
-p 8080:8080 \
-p 50000:50000 \
-v /opt/docker-data/jenkins:/var/jenkins_home \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /usr/bin/docker:/usr/bin/docker \
-e JAVA_OPTS="-Xms1024m -Xmx2048m -XX:+UseG1GC" \
jenkins/jenkins:lts-jdk173. 获取初始密码
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword浏览器访问 http://192.168.136.155:8080,输入密码,安装推荐插件,创建管理员账号。
注意:安装插件时如遇java.net.SocketTimeoutException,通常是国内访问 Jenkins 官方源不稳定。可换清华镜像源,或手动下载.hpi文件通过 插件管理 → Advanced settings → Deploy Plugin 上传。
3.6 配置 Jenkins 构建节点(Server2-4)
1. 添加 SSH 凭据
- 获取 Server1 的私钥:
cat ~/.ssh/id_rsa - Jenkins → 系统管理 → 凭据 → 全局凭据 → Add Credentials
- 类型:
SSH Username with private key - ID:
server1-ssh-key - Username:
admin_123 - Private Key:粘贴 Server1 的私钥
2. 添加节点
| 配置项 | Server2 | Server3 | Server4 |
|---|---|---|---|
| 节点名称 | server2 | server3 | server4 |
| Remote root directory | /opt/jenkins-agent | 同左 | 同左 |
| Labels | build-node | build-node | build-node |
| Launch method | Launch agents via SSH | 同左 | 同左 |
| Host | 192.168.136.156 | 192.168.136.157 | 192.168.136.158 |
| Credentials | server1-ssh-key | 同左 | 同左 |
| Host Key Verification | Non verifying | 同左 | 同左 |
3. 验证
节点状态应为绿色 ✅。如果显示 ❌ 且日志提示 java: command not found,说明 3.2 节 Playbook 没跑完,重新执行 ansible-playbook -i inventory.ini init-nodes.yml 即可。
3.7 用 Ansible 部署 Trivy
1. 在 Server1 上准备 Trivy 二进制
# 下载(选择适合架构的版本)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | \
sudo sh -s -- -b /tmp/trivy-bin
ls /tmp/trivy-bin/trivy2. 编写 Playbook /opt/ansible/install-trivy.yml
---
- name: 在构建节点上安装 Trivy
hosts: servers
become: yes
tasks:
- name: 复制 Trivy 二进制
copy:
src: /tmp/trivy-bin/trivy
dest: /usr/local/bin/trivy
mode: '0755'
owner: root
group: root
- name: 验证 Trivy 版本
command: trivy --version
register: trivy_version
changed_when: false
- name: 输出版本
debug:
msg: "{{ trivy_version.stdout }}"执行:
cd /opt/ansible
ansible-playbook -i inventory.ini install-trivy.yml3. 推送 Trivy 镜像与漏洞库到私有仓库
由于国内访问 Google 源受限,Trivy 运行时从私有仓库拉取漏洞库更稳定。在 Server1 上执行:
# 推送 Trivy 镜像
docker pull aquasec/trivy:latest
docker tag aquasec/trivy:latest 192.168.136.155:5000/aquasec/trivy:latest
docker push 192.168.136.155:5000/aquasec/trivy:latest
# 推送漏洞库
docker pull aquasec/trivy-db:2
docker tag aquasec/trivy-db:2 192.168.136.155:5000/aquasec/trivy-db:2
docker push 192.168.136.155:5000/aquasec/trivy-db:2注意:Trivy 第一次运行时需要下载漏洞库。如果直接从公网拉,国内大概率失败;改为从私有仓库拉取即可绕开。Jenkinsfile 中的 TRIVY_DB_REPOSITORY 参数就是做这件事。3.8 创建示例项目
1. 在 Gitea 上创建仓库 taobao-page
2. 添加三个文件
index.html(简化版淘宝页面,略,可参照前文)。
Dockerfile:
FROM nginx:alpine
RUN apk upgrade --no-cache && rm -rf /usr/share/nginx/html/*
COPY . /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]说明:apk upgrade --no-cache 会在构建时升级系统包,减少基础镜像中已知漏洞的数量。Jenkinsfile:
pipeline {
agent { label 'build-node' }
environment {
REGISTRY = "192.168.136.155:5000"
IMAGE_NAME = "taobao-page"
IMAGE_TAG = "v${BUILD_NUMBER}"
}
stages {
stage('拉取代码') {
steps { sh 'ls -la' }
}
stage('构建 Docker 镜像') {
steps {
sh '''
docker build -t ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} .
docker tag ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} ${REGISTRY}/${IMAGE_NAME}:latest
'''
}
}
stage('镜像安全扫描') {
steps {
sh '''
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e TRIVY_DB_REPOSITORY="192.168.136.155:5000/aquasec/trivy-db:2" \
-e TRIVY_INSECURE="true" \
192.168.136.155:5000/aquasec/trivy:latest image \
--severity HIGH,CRITICAL \
--exit-code 0 \
--no-progress \
${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
'''
}
}
stage('推送镜像到私有仓库') {
steps {
sh '''
docker push ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
docker push ${REGISTRY}/${IMAGE_NAME}:latest
'''
}
}
stage('通过 Ansible 部署') {
steps {
sh '''
cd /opt/ansible
ansible-playbook -i inventory.ini deploy-taobao.yml \
-e "image_tag=${IMAGE_TAG}" \
-e "registry=${REGISTRY}" \
-e "image_name=${IMAGE_NAME}"
'''
}
}
}
}3. 推送代码到 Gitea
git init
git config --global user.email "admin@example.com"
git config --global user.name "admin"
git add .
git commit -m "初始化淘宝页面项目"
git remote add origin http://192.168.136.155:3000/admin/taobao-page.git
git push -u origin main3.9 创建 Jenkins Pipeline 任务
- Jenkins → 新建 Item → 名称
taobao-page-build→ 选择 Pipeline 配置:
- Definition:Pipeline script from SCM
- SCM:Git
- Repository URL:
http://192.168.136.155:3000/admin/taobao-page.git - Credentials:添加 Gitea 用户名密码凭据(ID:
gitea-credential) - Branch Specifier:
*/main - Script Path:
Jenkinsfile
- 保存并测试构建
3.10 配置 Webhook 自动触发
1. 安装 Generic Webhook Trigger 插件
如插件源下载超时,手动下载 .hpi 上传:
- 下载:
https://mirrors.jenkins.io/plugins/generic-webhook-trigger/latest/ - Jenkins → 系统管理 → 插件管理 → Advanced settings → Deploy Plugin
- 重启 Jenkins
2. 在 Jenkins 任务中启用
- 任务配置 → 构建触发器 → 勾选 Generic Webhook Trigger
- Token 填:
taobao-trigger-2026 - 保存
3. 在 Gitea 中添加 Webhook
- Gitea → 仓库 → 设置 → Web 钩子 → 添加 Web 钩子
- 目标 URL:
http://172.17.0.1:8080/generic-webhook-trigger/invoke?token=taobao-trigger-2026 - HTTP 方法:POST
- POST Content Type:
application/json - 触发条件:勾选"推送事件"
注意:Gitea 运行在 Docker 容器中,无法通过宿主机内网 IP
192.168.136.155:8080访问 Jenkins。Webhook URL 需使用 Docker 网关地址172.17.0.1。验证:进入 Gitea 容器测试:
docker exec -it gitea sh wget -q -O - http://172.17.0.1:8080/generic-webhook-trigger/invoke?token=taobao-trigger-2026 # 返回 {"triggered":true} 即成功 exit
3.11 用 Ansible 完成部署
1. 编写 Playbook /opt/ansible/deploy-taobao.yml
---
- name: 部署淘宝页面到目标节点
hosts: servers
become: yes
vars:
registry: "192.168.136.155:5000"
image_name: "taobao-page"
image_tag: "latest"
container_name: "taobao-page"
host_port: 8081
container_port: 80
tasks:
- name: 拉取最新镜像
docker_image:
name: "{{ registry }}/{{ image_name }}:{{ image_tag }}"
source: pull
force_source: yes
- name: 停止旧容器(如果存在)
docker_container:
name: "{{ container_name }}"
state: stopped
ignore_errors: yes
- name: 删除旧容器(如果存在)
docker_container:
name: "{{ container_name }}"
state: absent
ignore_errors: yes
- name: 启动新容器
docker_container:
name: "{{ container_name }}"
image: "{{ registry }}/{{ image_name }}:{{ image_tag }}"
state: started
restart_policy: always
ports:
- "{{ host_port }}:{{ container_port }}"
- name: 验证容器运行状态
command: docker ps --filter "name={{ container_name }}" --format "{{ '{{' }}.Status{{ '}}' }}"
register: container_status
changed_when: false
- name: 输出容器状态
debug:
msg: "容器 {{ container_name }} 状态:{{ container_status.stdout }}"2. Jenkins 调用 Ansible
Jenkinsfile 的"通过 Ansible 部署"阶段已定义,构建时自动执行。
3. 验证
# 手动执行一次验证
cd /opt/ansible
ansible-playbook -i inventory.ini deploy-taobao.yml输出应类似:
TASK [启动新容器] ***
changed: [server2]
changed: [server3]
changed: [server4]
TASK [输出容器状态] ***
ok: [server2] => { "msg": "容器 taobao-page 状态:Up 3 seconds" }浏览器访问 http://192.168.136.157:8081 即可看到部署的页面。
四、成果验证
4.1 构建记录
| 构建号 | 触发方式 | 结果 |
|---|---|---|
| #1 | 手动 | ✅ |
| #2 | 手动 Webhook 测试 | ✅ |
| #3 / #4 | Gitea 自动触发 | ✅ |
| #5 ~ #11 | Trivy 调试 | ❌ / ✅ |
| #12 | Gitea 自动触发 + Trivy 扫描 | ✅ |
4.2 Ansible 部署日志片段
PLAY [部署淘宝页面到目标节点] ****************************************
TASK [Gathering Facts] *********************************************
ok: [server2]
ok: [server3]
ok: [server4]
TASK [拉取最新镜像] *************************************************
changed: [server2]
changed: [server3]
changed: [server4]
TASK [启动新容器] ***************************************************
changed: [server2]
changed: [server3]
changed: [server4]
TASK [输出容器状态] *************************************************
ok: [server2] => { "msg": "容器 taobao-page 状态:Up 3 seconds" }4.3 Trivy 扫描结果
192.168.136.155:5000/taobao-page:v11 (alpine 3.24.2)
Total: 1 (HIGH: 1, CRITICAL: 0)
├─ libexpat │ CVE-2026-93990 │ HIGH │ 2.8.4-r0 → 2.8.5-r0说明:扫描发现有 1 个 HIGH 漏洞,--exit-code 0让流水线只报告不阻断。生产环境可以改为--exit-code 1强制阻断,或在 Dockerfile 里升级相关包。
五、Ansible 在架构中的价值
| 环节 | 手工方式 | Ansible 方式 |
|---|---|---|
| 部署执行者 | 登录每台机器 | Server1 统一调度 |
| 幂等性 | 手动 stop/rm | docker_container 模块自带幂等 |
| 多目标支持 | 一台一台操作 | hosts: servers 一次部署全部 |
| 配置集中管理 | 分散在 Jenkinsfile 的 shell 里 | 集中在 Playbook |
| 可审计 | 脚本难追踪 | Playbook 可纳入 Git 版本控制 |
| 参数传递 | 硬编码 | -e 动态传入 |
| 扩展性 | 加机器要改脚本 | 只需改 inventory.ini |
基础环境初始化也走 Ansible 的意义:Java、DNS、时钟同步、Docker 配置这些"每台都要做一遍"的事,一次 Playbook 全部搞定,避免手工操作遗漏。
六、配置要点速查
| # | 事项 | 说明 | 处理 |
|---|---|---|---|
| 1 | Docker 信任私有仓库 | 已有 registry-mirrors 时 | 在原有 JSON 上追加,勿覆盖 |
| 2 | Jenkins 插件源 | 国内访问官方源不稳定 | 换清华源或手动上传 .hpi |
| 3 | Jenkins Agent 依赖 | 需要 Java | 由 init-nodes.yml 自动安装 |
| 4 | Server 节点 DNS | 127.0.0.53 可能无法解析 | 由 init-nodes.yml 统一切换 |
| 5 | 时钟同步 | Jenkins 要求节点时间一致 | 由 init-nodes.yml 启用 systemd-timesyncd |
| 6 | Gitea Webhook 地址 | 容器无法通过宿主机 IP 访问 | 改用 Docker 网关 172.17.0.1 |
| 7 | Trivy 漏洞库 | 国内无法直连 Google 源 | 推送到私有仓库,用 TRIVY_DB_REPOSITORY 指向 |
| 8 | Ansible docker 模块 | 依赖 Python Docker SDK | 由 init-nodes.yml 自动安装 |
| 9 | Ansible 权限 | 用户需在 docker 组 | 由 init-nodes.yml 自动加入 |
七、后续优化方向
- Harbor 替代基础 Registry:带 UI、用户权限、镜像扫描、垃圾回收,生产级。
- SonarQube 代码质量扫描:在"构建"之前加一道质量门禁,不达标阻断流水线。
- 通知集成:在 Jenkinsfile 的
post块里加钉钉 / 飞书 / 邮件通知。 - Prometheus + Grafana 监控:采集 Jenkins 指标,看构建成功率、时长、队列。
清理策略:
- Registry:定期
registry garbage-collect - 构建节点:定期
docker system prune -f - Jenkins:配置"丢弃旧的构建"保留最近 10 次
- Registry:定期
- Kubernetes 化:把 Ansible 部署换成 k8s 的 Deployment,Agent 用动态 Pod。
八、总结
8.1 最终流水线
开发者修改代码 → git push 到 Gitea
→ Gitea 通过 Webhook 通知 Jenkins
→ Jenkins 在 Server2/3/4 中分配构建节点
→ 拉取代码 → 构建 Docker 镜像
→ Trivy 安全扫描(报告漏洞)
→ 推送到私有 Registry
→ Jenkins 调用 Ansible Playbook
→ Ansible 在目标节点拉取镜像并启动容器
→ 对外提供服务8.2 覆盖的技术点
| 技术 | 用途 |
|---|---|
| Docker | 容器运行时 |
| Docker Registry | 私有镜像仓库 |
| Gitea | 代码仓库 |
| Jenkins | CI/CD 调度引擎 |
| Jenkins Agent | 分布式构建节点 |
| Trivy | 镜像安全扫描 |
| Ansible | 批量运维与部署 |
| Webhook | 自动化触发 |
| Nginx | Web 服务 |
8.3 这套架构的关键设计
- 基础环境自动化:Java、DNS、时钟、Docker 配置、Python SDK 全部由 Ansible 统一推送,避免逐台登录
- 镜像安全前置:Trivy 在推送前扫描,早发现早处理
- 部署配置集中:Ansible Playbook 集中在 Server1 的
/opt/ansible,可纳入版本控制 - 参数外置:Jenkinsfile 通过
-e传参给 Playbook,配置与代码分离 - 容器网络打通:Gitea Webhook 用 Docker 网关地址,绕开容器网络隔离
整套系统规模不大,但已经具备生产环境 CI/CD 的核心要素。后续可在此基础上继续优化,逐步完善为一个更健壮、易维护的自动化交付平台。
评论已关闭