上周四晚上十一点,我还在手动部署积木学堂这套SpringCloud项目。服务不多,六个微服务外加Nacos,但每次发版都要先在本地编译,再通过scp上传服务器,然后按照依赖顺序一个个启动。传到第三个服务的时候我突然反应过来:这个流程里我就是一个“人肉自动化脚本”,还是一个特别容易出错的脚本。标题里的“SpringCloud+Jenkins+Docker持续部署积木学堂[谷粒学院]”就是那次之后我折腾出来的结果。这篇文章不聊虚的,直接讲清楚为什么这套组合能解决微服务发布的问题、我的流水线是怎么写的、以及那些文档里不会告诉你的坑。适合正在维护微服务项目、又被手动发布折磨的开发或者运维朋友参考。
1. 积木学堂为什么要做持续部署:手动发布的三座大山
1.1 积木学堂是什么:一套常见的SpringCloud微服务在线教育项目
积木学堂(内称谷粒学院)是一个典型的在线教育微服务项目,站点有首页门户、课程展示、视频点播、订单支付,后端用SpringCloud做了微服务拆分。整个项目拆完大约是六七个可独立部署的服务:网关、认证、用户、课程、订单、视频服务,中间件用到了Nacos(注册中心+配置中心)、MySQL、Redis。每个服务最终打成一个可执行jar包,单包体积100MB上下。
这种复杂度是你做持续部署的前提。不是说服务少就不能做自动化,而是服务一旦变多,手动维护成本会指数上升。更麻烦的是这些服务之间不是孤立的,启动有先后依赖关系,手动部署时必须把顺序记在脑子里,时间一长,谁都有可能记错。
1.2 痛点一:服务多了,启动顺序全靠脑子记
微服务部署首先要处理启动顺序。比如Nacos如果不先起来,后面的服务启动时注册不上,或者从配置中心拉不到配置,启动直接失败。我之前就吃过一次亏:那天只改了用户服务,想着单独重启用户服务就行,结果用户服务启动时往Nacos注册失败,日志又没看全,过一会儿订单服务调用用户服务一直超时。排查到最后才发现根本不是代码问题,而是Nacos在服务启动期间刚好做了一次重启,注册信息没真正写入。
这种问题在手动部署场景下特别容易发生。因为每个人的部署习惯不一样,有人习惯先启网关,有人习惯先启业务服务,产生的现象五花八门。而当服务多到五个以上时,靠人肉记忆依赖顺序,本质上就是在赌运气。
1.3 痛点二:环境配置漂移,“本地能跑,线上崩”
微服务项目最典型的问题,是环境差异。本地IDE调试的时候,数据库地址、Redis地址、Nacos地址全部是localhost,到了服务器上就变成内网IP或者Docker容器地址,配置文件稍微改错一个地方,整个服务起不来。
手动部署时最常见的循环是这样的:本地打包,传到服务器,启动,报错连不上数据库,回本地改配置,重新打包上传。一次发布折腾一晚上,最后发现只是配置文件里少写了一个端口段。持续部署解决的就是把环境定义和部署流程标准化:一旦流水线正确跑通,环境配置就从“每次发版靠人为记忆”变成“镜像加配置中心的产物”。开发环境、测试环境、生产环境之间的差异,被收敛到了配置中心这一个地方,而不是散落在每台服务器的jar包旁边。
1.4 痛点三:发版记录缺失,回滚靠猜
手动发版还有一个隐藏问题:当前线上跑的到底是哪个版本的包,往往没有记录。很多团队的做法是“反正我本地有这个jar包,传上去跑就行”。一旦出问题要回滚,就回本地找旧包,能不能找到完全靠运气。
Jenkins加Docker这套方案天然带版本概念:每个镜像有唯一tag,流水线记录每次构建对应的代码版本,发布本身变成一次参数化操作。回滚也只是把镜像tag指回旧版本,不需要重新编译,也不怕旧包被覆盖。这件事对长期维护的项目来说特别重要,属于“用一次就回不去”的能力。
手动部署不是不能干,而是当服务数量、发布频率、环境数量都上来之后,人不可能稳定地不出错。持续部署的核心,并不是自动化工具本身,而是把发布过程从“手工步骤”变成“可重复、可追溯的参数化流程”。这就是我做这套东西的出发点。
2. 整体架构与前期准备:先画图纸,再动服务器
2.1 部署链路中每个角色的职责
动手之前,先把整条链路在脑海里过一遍。代码从开发者提交到服务更新,中间要经过四个环节:代码仓库、持续集成服务(Jenkins)、镜像仓库、目标服务器。我用一张表格理清它们的职责,后面写脚本的时候边界就清晰了:
| 角色 | 承担什么 | 我用的方案 |
|---|---|---|
| 代码仓库 | 保存源码、触发构建 | 内部自建的Git服务,配置Webhook |
| Jenkins | 拉代码、编译、构建镜像、部署 | 宿主机安装,配Pipeline插件 |
| 镜像仓库 | 保存不可变版本镜像 | 自建私有镜像仓库,内网拉取快 |
| 目标服务器 | 运行MySQL、Redis、Nacos与微服务容器 | 两台2核4G Linux |
这个架构里有一个关键设计:编译和构建镜像在Jenkins主机上完成,线上服务器只负责拉镜像和跑容器。为什么这样设计?因为如果每台服务器都编译代码,那么环境差异问题就又回来了。而镜像一旦构建出来,里面是什么环境就是什么环境,复制到任何一台装Docker的机器上,行为完全一致。这一条是持续部署能少踩坑的最底层原因。
2.2 Docker与基础环境初始化
新服务器到手后,不要急着跑容器,先把基础环境固定下来。建议安装Docker后立刻做三件事:配置镜像加速、设定时区、开启Docker服务并设为开机自启。比如在Linux上:
# 安装 Docker(不同发行版命令有差异,这里以常见发行版为例) sudo curl -fsSL https://get.docker.com | bash # 设置 registry-mirrors,拉公共镜像会快不少 sudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json > /dev/null <<'EOF' { "registry-mirrors": [""], "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "5" } } EOF sudo systemctl daemon-reload sudo systemctl restart docker sudo systemctl enable docker # 容器里统一使用东八区,避免日志时间少8小时 echo 'TZ="Asia/Shanghai"' | sudo tee -a /etc/profileregistry-mirrors 我故意留空,实际填你那边最快的公共镜像加速地址就行。日志参数也建议提前设置,不然Docker的json-file日志默认无限增长,两三个月就能把磁盘写满。这个问题我后来是在清理磁盘空间时才意识到的,早配置能省很多事情。
2.3 目录与Compose文件规划
整台服务器上,我把所有容器定义和数据放在/opt/edu/compose下面,原因很简单:一个目录搞定所有服务文件,备份、迁移都方便。目录结构大概是:
/opt/edu/compose/ ├── docker-compose.yml ├── .env # IMAGE_TAG 等环境变量 ├── mysql/ │ ├── data/ # MySQL数据目录 │ └── conf/ # my.cnf ├── redis/ │ └── data/ └── logs/ # 各容器日志的宿主机挂载点docker-compose.yml 会把基础组件(MySQL、Redis、Nacos)和微服务都定义在一起。有人会问,为什么不用一堆docker run命令,而是用Compose?我的理由是:Compose方便表达服务之间的依赖关系,一条docker compose up -d能按照定义顺序和依赖关系拉起服务,整体启停也是统一操作。更重要的是,我把镜像tag写进了 .env 文件,每次发布只需要改IMAGE_TAG这一个参数,然后执行up -d,Compose会自己对比镜像并重建需要更新的服务。这正是部署的基本诉求。
2.4 一个容易被忽略的点:镜像tag设计
动手写流水线前,先把镜像tag规则定下来。我的规则是“代码提交哈希加Jenkins构建号”。具体来说,Jenkins取当前构建对应的 GIT_COMMIT 前7位作为默认tag,比如 d3f4a2e,同时支持手动指定。这样每次发布都等于给“某一刻的源码”盖了一个永久戳,回滚时直接找回旧tag就能恢复。
不要用latest这种可变tag。latest 的问题在于,你昨天拉下来的 latest 和今天拉下来的 latest 可能是完全不同的两个镜像,很难确认线上跑的是哪个版本。用不可变tag之后,镜像仓库里的每一个镜像都对应唯一一次代码提交,部署和排查都变得有据可查。
3. Jenkins流水线核心脚本:从源码到镜像再到服务器
3.1 Jenkins侧准备:宿主机版还是容器版
关于Jenkins的安装方式,我自己最终选择了宿主机直接安装,而不是用官方容器镜像跑。原因很现实:流水线里大量shell命令依赖 mvn、docker、git 这些工具,如果Jenkins跑在容器里,要么在容器里额外装一遍工具链,要么把宿主的命令挂载进去,还要处理jenkins用户对docker.sock的权限问题。与其纠结这些,不如直接让Jenkins进程跑在宿主机上,以root身份执行构建任务,简单直接,个人项目和小团队场景完全够用。
如果一定要用容器版Jenkins,至少要做这几件事:挂载/var/jenkins_home做数据持久化,挂载/var/run/docker.sock和宿主docker二进制,同时把/var/lib/jenkins/workspace也映射出来,否则流水线里的sh命令会找不到依赖或权限不足。一个简单的验证方法:在Jenkins的“脚本命令行”里执行def out = new ProcessBuilder('docker','version').execute().text; println out,能输出版本说明权限通了,否则就是挂载或用户组配置有问题。这一步走通,后面就顺了。
创建流水线任务时,选Pipeline类型,然后配置Git仓库地址和访问凭据,在“Pipeline Script from SCM”里指向仓库根目录下的 Jenkinsfile。首次配置时最容易漏掉的是凭据:拉取私有Git仓库需要配一个账号密码或SSH key,构建镜像需要配镜像仓库的账号密码,远程部署需要配目标服务器的SSH key。这三类凭据全部放进Jenkins的凭据管理里,通过插件按需注入。
3.2 一份能直接改改就用的Jenkinsfile
下面这份脚本是我在积木学堂项目里实际使用的精简版本,以“课程服务”为例。它做的事情很直接:拉代码、编译、构建镜像、推送、远程部署。
pipeline { agent any environment { REGISTRY = "registry.internal.example" REPO = "edu-platform" SERVICE = "course" // 切换服务时改这里 COMPOSE_HOST = "deploy@app-server" COMPOSE_DIR = "/opt/edu/compose" IMAGE_NAME = "${REGISTRY}/${REPO}-service-${SERVICE}:${GIT_COMMIT.take(7)}" } stages { stage('拉取代码') { steps { checkout scm // 从Git仓库拉取当前分支 } } stage('Maven编译') { steps { // 只编译指定服务,-am表示同时编译依赖的公共模块 sh 'mvn clean package -DskipTests -pl service-course/service-course-server -am' } } stage('构建镜像') { steps { // Dockerfile 放在服务模块目录里,只COPY打好的jar sh 'docker build -t ${IMAGE_NAME} service-course/service-course-server/' } } stage('推送镜像') { steps { withCredentials([usernamePassword( credentialsId: 'private-registry-cred', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS' )]) { sh ''' docker login ${REGISTRY} -u ${REG_USER} -p ${REG_PASS} docker push ${IMAGE_NAME} ''' } } } stage('远程部署') { steps { sshagent(['deploy-ssh-key']) { sh """ ssh ${COMPOSE_HOST} "cd ${COMPOSE_DIR} && echo IMAGE_TAG=${GIT_COMMIT.take(7)} > .env && docker compose up -d" """ } } } } }这份脚本已经能跑。课程服务的Dockerfile其实特别简单,因为编译已经在Jenkins的Maven阶段做完了,镜像只负责把jar包跑起来:
FROM openjdk:11-jre-slim WORKDIR /app COPY target/edu-service-course-*.jar app.jar EXPOSE 9020 ENTRYPOINT ["java","-jar","/app.jar"]这里刻意用jre而不是jdk,镜像体积能小一半,构建和拉取都快。需要注意的是,我这里只写了课程服务一个服务。要想部署全部服务,可以加一个参数选择服务名,然后循环构建和推送;或者做多分支流水线,不同分支对应不同环境。我的建议是先把单个服务跑通,再推广到全量,一次全量改动出问题的时候不好排查。
3.3 编译阶段为什么用宿主Maven而不是容器Maven
最开始我图省事,直接在流水线里写docker run -v $WORKSPACE:/app maven:3.8-openjdk-11 mvn clean package。第一次构建能过,但第二次、第三次每次都重新下载依赖。整个项目几百MB的依赖仓库,一次编译跑七八分钟,流水线慢得像蜗牛。
后来改成宿主机直接装Maven,复用宿主机的 ~/.m2 本地仓库。第一次全量下载后,后续每次构建只下载增量,编译时间从七八分钟降到几十秒。如果你的构建机是Windows或Mac,也可以把本地仓库目录挂载到容器里,本质是一样的:依赖缓存必须复用,不要让每次构建都从零开始。
还有一个细节:编译前建议用-pl service-course/service-course-server -am,只编译当前服务和它依赖的公共模块,而不是每次全量编译整个项目。积木学堂有十几个Maven模块,全量编译浪费大量时间,而实际上每次发布往往只动一两个服务。
3.4 镜像构建与推送:tag和登录都是重点
镜像是整套持续部署的交付物,这里有几个细节值得展开。第一,tag不能是 latest,这一点前面说过。第二,构建命令的上下文要传对,Dockerfile所在目录就是构建上下文根目录,否则 COPY target/*.jar 会找不到文件。第三,docker login和push的关键在凭据,绝对不要把密码明文写进Jenkinsfile。我用Jenkins的Credentials Binding插件,在withCredentials块里动态注入账号密码,凭据只在运行时以环境变量形式存在,脚本本身提交到Git仓库也不会有泄露风险。
构建命令docker build -t ${IMAGE_NAME} service-course/service-course-server/的最后一个参数是构建上下文,也就是Dockerfile所在目录。这个目录里必须有 target 子目录,里面的jar包是Maven编译阶段刚生成的。如果上下文给错,Docker会报文件找不到,排查起来又得浪费半天。
3.5 远程部署阶段:SSH执行还是开放Docker API
远程部署有两条路线:一条是Jenkins通过SSH登录目标主机执行命令,另一条是给目标主机开放Docker API(比如2375端口),Jenkins直接调用远程Docker接口。我的选择是SSH。
理由很直接:Docker API一旦开放TCP端口,等于把这台机器的一个重要管理接口暴露给网络。如果安全组和防火墙没配好,公网扫描到2375端口,轻则被人拉一堆挖矿容器,重则整个内网被攻破。而SSH方式下,目标主机只接受22端口连接,Jenkins用密钥登录,能执行的命令范围由我在脚本里严格控制。这里也建议你检查一下自己的Docker配置:如果/etc/docker/daemon.json里有"hosts": ["tcp://0.0.0.0:2375"],请立刻移除,改用SSH方式管理远程Docker。
目标主机上执行的命令很简单:先进入compose目录,把新的 IMAGE_TAG 写入 .env,然后执行docker compose up -d。Compose会自动对比运行中的容器和新镜像,需要更新的服务自动重建,没变化的保持不动。
4. 容器化微服务的经典坑:地址、顺序、配置日志
4.1 localhost 是容器自己,不是宿主机
这是很多第一次把微服务放进Docker的人必踩的坑。在容器里,localhost指向容器自身,但你的MySQL、Redis、Nacos往往在其他容器或宿主机上。所以微服务连接数据库不能写localhost:3306,而是要写compose文件里定义的服务名,比如mysql:3306、redis:6379、nacos:8848。
如果使用Nacos做注册中心,服务注册地址也要注意,有时候需要显式指定注册IP。我刚开始没注意这一点,服务一直注册不上Nacos,后来把bootstrap.yml里的地址改成服务名才解决。一个常见做法是在每个微服务的bootstrap.yml里,把注册中心和配置中心地址写成:
spring: cloud: nacos: server-addr: nacos:8848 config: file-extension: yaml启动后服务会优先从Nacos配置中心拉取数据库、Redis等其他配置,环境差异被收敛到配置中心一个地方。这个“配置集中化”的思路,是微服务体系里特别重要的一环,值得花时间做好。
4.2 启动顺序:用 healthcheck 而不是死等几秒
很多人写docker-compose的时候用depends_on控制启动顺序,结果发现顺序是保证了,服务却还是起不来。原因是depends_on默认只保证“容器启动”了,并不保证“容器内服务就绪”。我见过一个项目,Nacos容器明明是running状态,但应用服务往里注册时,Nacos还在初始化,连接失败直接退出。
正确做法是给基础组件加健康检查,然后让应用依赖condition: service_healthy。比如Nacos容器可以这样定义:
nacos: image: nacos/nacos-server:v2.2.0 ports: - "8848:8848" - "9848:9848" environment: MODE: standalone PREFER_HOST_MODE: hostname healthcheck: test: ["CMD-SHELL", "wget -q -O - http://localhost:8848/nacos/v1/console/health/readiness || exit 1"] interval: 15s timeout: 5s retries: 20 start_period: 20s然后微服务这样写:
edu-service-course: image: registry.internal.example/edu-platform-service-course:${IMAGE_TAG} depends_on: mysql: condition: service_healthy nacos: condition: service_healthyCompose会等MySQL和Nacos真正可用了,再启动课程服务。这一套用熟了,“启动顺序靠脑子记”的问题基本不会再出现。
4.3 环境隔离:用Nacos namespace区分开发和测试
项目跑起来后,配置文件会越改越多。今天改了数据库连接串,明天加了Redis密码,如果没有环境隔离,开发环境改坏一个配置,生产环境跟着出问题。Nacos配置中心天然支持namespace,我把开发环境和生产环境放在不同的namespace里,运行参数通过--spring.cloud.nacos.config.namespace区分。
这样做的效果是:同一套镜像,只要在启动命令里指定不同namespace,就能跑出不同环境。真正做到了“一套产物,多环境部署”。这一步最好在流水线跑通之前就做好,否则等系统跑起来后再改配置中心结构,涉及的服务重启排查成本会高很多。
4.4 日志别往容器里写堆文件
SpringBoot默认日志输出到控制台,这个在Docker场景下反而是最合理的:docker logs直接看,日志跟随Docker的json-file驱动写入宿主目录。千万别在应用配置里开启日志文件写入路径,因为容器重建后,容器内部文件系统就没了,日志也跟着没了。遇到过业务方要排查上周的问题,因为日志写在容器内,容器一重建日志全没了,只能干瞪眼。
简单做法是:容器日志由Docker统一管理,配合前面daemon.json里的max-size和max-file限制大小。要更完整的日志归档,后面再接日志采集组件,把容器stdout统一收集起来。这一步等整套流程稳定之后再补也不迟,初期完全够用。
4.5 端口与网络安全:安全组和防火墙最容易被忽略
微服务部署还有一件容易被忽略的事:机器安全组或防火墙放行规则。我第一次把全部服务用Compose跑起来后,在办公室用浏览器访问网关9000端口,一直超时;在服务器上curl却正常。排查一圈才发现,是安全组没放行9000端口。如果你也出现“服务器内部正常,外部死活连不上”这种症状,先去检查安全组和防火墙,不要第一时间怀疑代码。
另外再次强调:不要给Docker daemon开放0.0.0.0:2375。远程管理Docker可以用SSH隧道做端口转发,或者用防火墙限定来源IP。这个底线一定要守住。
5. 一次完整上线的实操记录与回滚演练
5.1 从提交代码到服务更新:全过程时间记录
流程跑通后,一次课程服务的日常发布大概是这样的:改完代码推到Git仓库,Jenkins的Webhook自动触发流水线,然后我在网页上盯着任务跑。整个流水线耗时分布大致如下(2核4G的构建机):
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 拉取代码 | 2s | 仓库不大,秒级 |
| Maven编译 | 40-60s | 有本地依赖缓存,增量编译 |
| Docker构建镜像 | 15-25s | jar包COPY进镜像,很快 |
| 推送镜像 | 10-20s | 内网镜像仓库,速度快 |
| SSH远程部署 | 30-40s | 拉镜像、启动容器、等服务起来 |
| 合计 | 约2分钟 | 全程无手工操作 |
而之前手动发布,单是编译打jar包就超过5分钟,接着scp、登录、docker run、改配置,一轮下来怎么也要15分钟。省下来的时间是一方面,可控性和可重复性才是更值钱的部分。
5.2 回滚:一个tag搞定,不用重新编译
持续部署带来的另一个大红利,是回滚变成一次参数操作。比如当前线上课程服务镜像tag是 d3f4a2e,发布后业务方反馈有问题,想回到上一个版本,旧tag是 8c19a0f。我只需要去目标服务器执行:
cd /opt/edu/compose echo "IMAGE_TAG=8c19a0f" > .env docker compose up -dCompose会把课程服务容器重新创建为旧镜像版本,整个回滚不到30秒。这在手动部署时代是不可想象的:你得找到旧jar包,重新上传,还要祈祷旧包没被人覆盖过。所以每一次发布我都坚持在tag里带GIT_COMMIT前7位,就是为了让回滚永远有据可查。
5.3 小规模微服务,Compose够用,别急着上K8s
每次聊到SpringCloud部署,总会有人问:为什么不上K8s?我的回答是:看规模和团队阶段。积木学堂也就六七个微服务,两台机器就能撑起来,Compose的抽象能力和运维成本完全够用。K8s带来的调度、伸缩、自愈能力,在这个规模下体现不出来,反而倒逼团队学习一整条新的技术栈。
等到服务数量上到二三十个,或者需要弹性伸缩时,再考虑K8s不迟。到那时,这套Jenkins流水线依然能用,只需要把“远程SSH执行compose”这个部署步骤,换成“更新Deployment镜像并apply”即可。其余编译、构建、推送逻辑完全不需要改动。这也是我坚持把构建和部署分离的原因:构建逻辑稳定,部署方式可以随时替换。
5.4 流水线的下一步优化方向
这套链路跑稳之后,我陆续补了几个小功能。第一是Webhook自动触发,推代码进仓库后不需要人工去点“立即构建”。第二是加了一个DEPLOY_ENV参数,同一份Jenkinsfile可以用于开发和生产环境,环境差异收敛到Nacos的namespace。第三是发布完成后给IM机器人发一条消息,带上提交人和构建号,群里的同事都知道这次是谁在什么时候发布的。第四是定期清理旧镜像和构建工作区,防止磁盘被日志和构建产物堆满。这些功能都不复杂,但每一条都能明显减少日常维护摩擦。
回到底层逻辑:持续部署的本质,是把发布过程中的变量变成参数。代码版本是参数,镜像tag是参数,目标服务器是参数,环境是参数。人不再需要亲自执行每一个命令,只负责做决策——比如这次可以发版,有问题就回滚。机器把剩下的活儿全干了,而且干得比人稳定得多。
我自己做这个项目的最大体会是:先别急着上最复杂的技术栈,把Jenkins加Docker这条链路先打通,让每一次发布都能在两分钟之内完成,且随时能回滚,这个收益比什么概念都实在。等这条链路稳定了,再去研究K8s、服务网格这些更上层的东西,你会发现底层思路是完全相通的。最后分享一个小细节:如果你也准备给SpringCloud项目做Docker化,先把每个服务的基础镜像固定下来,不要每次构建时让基础镜像tag浮动不定。基础镜像一变,生产环境跑出来就是另一个环境,这个坑我踩过,希望你们不要再踩。