运维知识
悠悠
2026年6月23日

从手动部署到一键上线:我用 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 都能干这个事,关键是先跑起来,让团队习惯"代码提交后自动化流程就开始运转"这个节奏。等跑顺了再根据实际痛点去优化,比一开始就追求完美架构要靠谱得多。

觉得有用就转发给还在手动部署的同事吧(

公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码