从手动部署到一键上线:我用 Jenkins + Docker 搭了一套自动化流水线
遇到一个项目,代码仓库是 GitLab 托管的,部署方式嘛……让我说得好听点叫"半自动化"——开发在本地跑完测试,打个 tar 包,scp 传到服务器,然后手动重启服务。出过几次事故之后,组里终于下定决心:搞一套像样的 CI/CD。
我选了 Jenkins + Docker 的方案。不是因为它最新潮(GitHub Actions、GitLab CI 都更"现代"),而是因为团队里有人用过 Jenkins,而且我们的构建环境比较特殊——有几个依赖只能在特定 OS 版本上编译。Docker 的好处就是可以把这些乱七八糟的环境依赖全打包进镜像,构建环境一劳永逸。
环境准备:别小看权限问题
Jenkins 装起来很快,官方提供 WAR 文件和 Docker 镜像两种方式。我选的是 Docker 方式跑 Jenkins 本身——是的,用 Docker 跑 Jenkins,然后让 Jenkins 再去操作 Docker 构建应用镜像。这就是所谓的 "Docker in Docker"(DinD),听起来套娃,但实际操作中很常见。
不过这里有个坑我必须提一下。Jenkins 容器里的用户默认是 jenkins,它没有权限访问宿主机的 Docker daemon。你会看到类似这样的报错:
Got permission denied while trying to connect to the Docker daemon socket解决办法是把 jenkins 用户加入 docker 组:
sudo usermod -aG docker jenkins然后重启 Jenkins 和 Docker 服务。如果你是用容器跑的 Jenkins,还需要把宿主机的 /var/run/docker.sock 挂载进去:
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-v jenkins_home:/var/jenkins_home \
-p 8080:8080 \
jenkins/jenkins:lts这样 Jenkins 容器就能和宿主机的 Docker 通信了。说实话,第一次搞这个的时候我折腾了半天,因为忘了重启服务,一直以为是配置写错了……
插件:装对了事半功倍
Jenkins 的插件生态是它的核心竞争力,也是它被人吐槽"太重"的原因。对于 Docker 集成,有两个必装的:
- Docker Pipeline Plugin:让你在 Jenkinsfile 里直接用
docker.image()这种 DSL 来操作容器 - Pipeline Plugin:这个是基础,没它就没法写声明式 Pipeline
装插件的路径是 Manage Jenkins → Manage Plugins → Available,搜索 "Docker Pipeline" 就行。
我还额外装了 Blue Ocean(让 Pipeline 可视化变好看)和 Git plugin(连接 GitLab 仓库)。插件不要贪多,装太多会拖慢 Jenkins 启动速度,而且插件之间偶尔还会冲突。
Jenkinsfile:Pipeline as Code
这是整个方案的核心。我们把 CI/CD 的流程定义写成一个 Jenkinsfile 放在项目根目录,和业务代码一起版本管理。这意味着任何人改了构建流程,都有 Git 记录可以追溯——谁改的、什么时候改的、为什么改的。
我们的 Jenkinsfile 长这样(做了脱敏处理):
pipeline {
agent { docker { image 'maven:3-alpine' } }
environment {
DOCKER_REGISTRY = 'registry.company.com'
IMAGE_NAME = 'myapp'
IMAGE_TAG = "${env.BUILD_NUMBER}"
}
stages {
stage('Build') {
steps {
sh 'mvn clean install -DskipTests'
}
}
stage('Test') {
steps {
sh 'mvn test'
}
post {
always {
junit '**/target/surefire-reports/*.xml'
}
}
}
stage('Build Image') {
steps {
sh "docker build -t ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} ."
sh "docker push ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
}
}
stage('Deploy') {
steps {
sh """
docker pull ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
docker stop ${IMAGE_NAME} || true
docker rm ${IMAGE_NAME} || true
docker run -d --name ${IMAGE_NAME} \
-p 8080:8080 \
--restart unless-stopped \
${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
"""
}
}
}
post {
failure {
echo '构建失败,发送告警...'
// 这里可以对接钉钉/企业微信通知
}
}
}注意看 agent { docker { image 'maven:3-alpine' } } 这行——它告诉 Jenkins:这个 Pipeline 的构建环境是一个 Maven 容器。Jenkins 会自动拉取这个镜像,启动一个临时容器来执行构建步骤,构建完了自动销毁。这就是为什么 Docker 和 Jenkins 配合这么好:每次构建都是干净的环境,不会有依赖残留污染。
之前我们有过一个诡异的 bug:某次构建明明代码没改,但产出的 jar 包行为不对。排查了很久发现是上一次构建残留的依赖缓存导致的。换成容器化构建后这个问题彻底消失了。
Docker Compose:当你的应用不止一个容器
真实项目往往不是一个孤立的应用。我们的服务依赖 MySQL 和 Redis,集成测试需要这些组件同时跑起来。这时候 Docker Compose 就派上用场了。
我在项目里维护了一个 docker-compose.yml:
version: '3'
services:
web:
image: 'registry.company.com/myapp:latest'
ports:
- '8080:8080'
depends_on:
- db
- cache
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_HOST=db
- REDIS_HOST=cache
db:
image: 'mysql:5.7'
environment:
MYSQL_ROOT_PASSWORD: 'changeme'
MYSQL_DATABASE: 'myapp'
volumes:
- mysql_data:/var/lib/mysql
cache:
image: 'redis:7-alpine'
volumes:
mysql_data:在 Jenkinsfile 的测试阶段,我们用 docker-compose up -d 启动整套环境,跑完集成测试再 docker-compose down 清理。这样每次测试都是在一个完整的、隔离的环境里进行的,不会因为别人在同一台机器上跑了什么东西而相互影响。
有个小技巧:集成测试阶段可以给 docker-compose 文件加 --project-name 参数,用 Jenkins 的 BUILD_NUMBER 作为前缀。这样即使多个构建并行跑,容器名也不会冲突。
Shared Libraries:当多个项目共用流水线
项目多了之后你会发现,很多 Jenkinsfile 长得差不多——都是"拉代码 → 构建 → 测试 → 打镜像 → 部署"。每个项目都复制一份这样的文件很蠢,改起来也痛苦。
Jenkins 提供了 Shared Libraries 机制,你可以把通用的 Pipeline 逻辑抽成一个 Git 仓库里的 Groovy 脚本,然后各项目的 Jenkinsfile 只需要一行:
@Library('my-shared-lib') _
standardPipeline(appName: 'myapp', port: 8080)底下具体怎么 build、怎么 push 镜像、怎么部署,全在 Shared Library 里定义。项目只需要告诉它"我叫什么名字、暴露什么端口"就行了。
这个模式在团队规模大了之后非常有价值。我们现在有十几个微服务,如果构建流程需要调整(比如加一个安全扫描步骤),只需要改 Shared Library,所有项目下次构建就自动生效。
几个我踩过的坑
聊聊实际操作中碰到的问题吧。
磁盘空间。Docker 镜像和构建缓存会默认占用 Jenkins 服务器的磁盘,如果不定期清理,几个月下来磁盘就满了。我的做法是写了个定时任务,每周清理超过 7 天的无用镜像和停止的容器:
docker system prune -af --filter "until=168h"构建慢。如果每次构建都要重新下载 Maven 依赖,那真的很慢。解决方法是在 Jenkins 里配置一个持久化的 Maven 本地仓库目录,或者用 Docker volume 把 .m2 目录挂载出来。
网络问题。Docker 容器里如果需要访问内网的私有镜像仓库或 Maven 私服,需要配置 Docker 的 daemon.json 添加 insecure-registries,或者把证书挂载进容器。
并发构建冲突。如果两个分支同时触发构建,它们可能会抢占同一个端口或容器名。用 BUILD_NUMBER 或分支名作为容器名后缀可以避免这个问题。
现在的状态
搭完这套之后,开发提交代码到 GitLab,Jenkins 自动触发(通过 Webhook),5 分钟之内就能知道构建是否成功、测试是否通过。如果一切正常,镜像自动推送到私有仓库,然后部署到测试环境。生产环境的发布我们还是保留了手动确认的步骤——点一下按钮就行,但至少不用再 SSH 上去手动操作了。
整个流程从之前的"半小时手工操作 + 提心吊胆"变成了"推代码 → 喝杯咖啡 → 看结果"。偶尔有构建失败也不怕,钉钉群里会自动收到通知,点进去就能看到哪个阶段挂了、日志是什么。
这套方案不完美——Jenkins 确实比较"重",配置有时候有点繁琐,UI 也不如新一代工具好看。但它稳定、生态丰富、团队熟悉,对于我们这种不追求最新潮但要求可靠的团队来说,是个务实的选择。
如果你也在考虑搞 CI/CD,我的建议是别纠结于工具选型太久。Jenkins、GitLab CI、GitHub Actions 都能干这个事,关键是先跑起来,让团队习惯"代码提交后自动化流程就开始运转"这个节奏。等跑顺了再根据实际痛点去优化,比一开始就追求完美架构要靠谱得多。
觉得有用就转发给还在手动部署的同事吧(
公众号:耕云躬行录
个人博客:躬行笔记