ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Docker重新部署Java服务:换镜像、换容器的正确姿势

Docker重新部署Java服务:换镜像、换容器的正确姿势 刚接手一个已经用Docker跑起来的Java服务时很多人第一反应是“我是不是得进容器里把jar包换一下”或者“我直接改代码然后重启容器行不行”——这些都是新手最容易踩的坑。容器化部署之后发新版本的正确姿势其实是一套“换镜像、换容器”的流程而不是“进容器里折腾”。这篇文章我就从实际工作的角度把重新部署这件事从头到尾拆清楚为什么不能进容器改代码、构建新镜像时怎么利用缓存提速、三种不同场景下的发布方式单机直接跑、docker compose管理、高并发下的滚动发布、发版前必须检查的四个问题最后附上我自己遇到过的那些坑和排查方法。1. 先想明白容器化部署下的“重新部署”到底在做什么1.1 镜像和容器的关系用“程序安装包”来理解就通了回忆一下Windows时代你下载一个安装包exe双击它系统里就多了一个“已安装的程序”。这个安装包是静态的、不可变的而装好之后的程序是动态运行中的实例。Docker里也一样。镜像Image就是那个“安装包”它是只读的、不可变的、可以分发复制的容器Container就是那个“正在运行的程序实例”它由镜像创建运行过程中会产生日志、临时文件、缓存等数据这些数据写在容器自己的可写层里。发新版本的本质是什么就是“换安装包”用新代码构建出一个新镜像新安装包。停掉旧容器卸载旧程序。创建并启动一个新容器用新镜像来跑安装新程序。很多刚入门的人会有一个误区容器不是还在跑吗我进去改一下里面的代码不就行了吗我不建议这么做原因在下面。1.2 为什么不能直接进容器改代码docker commit 是个大坑先说结论想“保留现场”进容器手动改文件再用docker commit生成新镜像这绝对是重建镜像里的下策。原因有三镜像不可复现你在容器里手动改的东西没人知道是怎么改的、改了哪几行。等这个人离职了、或者这台机器挂了整个环境就再也还原不出来了。容器化部署最大的优势之一就是“配置即代码、环境可复现”docker commit直接把这个优势废了。镜像会越滚越大容器运行期间会不断产生日志、临时文件、堆转储文件等这些全都会被docker commit打包进新镜像。几次之后镜像就能膨胀到几个G拉取和启动都变慢。和版本管理脱节项目代码应该有git记录、有tag、有发版记录。docker commit生成的镜像是“没有历史”的你根本说不清这个镜像是哪个代码提交构建的出了问题也没法追溯。所以正确的心智是容器是一个一次性的运行环境不要在里面做任何修改要改就改代码、重新构建镜像。这就像你装了个软件出问题了不能直接去软件安装目录改dll而是应该找官方下新版本安装包重装。1.3 构建新镜像Dockerfile 写得好构建速度快一倍既然要重新部署第一步就是把新代码变成一个可运行的新镜像。Java服务最常见的做法是写一个多阶段构建的Dockerfile。这里有个特别容易让团队效率翻车的细节Docker 构建是有分层缓存的。Dockerfile里每一条指令都会生成一个层如果某层没有变化构建时就直接用缓存。构建一个Spring Boot项目很多初级写法是这样的FROM maven:3.8-jdk-11 AS build WORKDIR /app COPY . . RUN mvn clean package -DskipTests FROM openjdk:11-jre-slim COPY --frombuild /app/target/my-service.jar /app/my-service.jar ENTRYPOINT [java, -jar, /app/my-service.jar]这段写法能跑但效率很低只要源码改了一个字COPY . .这一层就会失效紧接着的RUN mvn clean package也会重新执行意味着每次都要重新下载Maven依赖一次构建可能就得花好几分钟甚至十几分钟。如果你和我一样每天要发好几个版本这个等待时间完全无法接受。改进方法很经典先把 pom.xml 和源码的目录结构拷进去先下载依赖再拷源码。FROM maven:3.8-jdk-11 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:11-jre-slim COPY --frombuild /app/target/my-service.jar /app/my-service.jar ENTRYPOINT [java, -jar, /app/my-service.jar]这样只要你没改pom.xmlMaven依赖下载那一层就会一直命中缓存整个构建只需要重新编译源码快一倍不止。1.4 镜像 Tag 的命名别再用 latest 了构建完了总得给镜像起个名字很多人图省事永远打my-service:latest。当时偷的懒早晚都得还。举个例子新版本发布后出现问题你想回滚到上一版。结果你所有版本的镜像tag全是latest本地Docker已经看不到旧镜像仓库里的latest也被新版本覆盖了。这时候你连“上一版”的镜像都找不回来只能重新构建一次旧代码。想想就知道多被动。我的建议是Tag 里至少带版本号或者构建时间两者结合最好。比如registry.example.com/my-service:1.2.4registry.example.com/my-service:20240315-1023如果能接上CI直接用 Git Commit 哈希最稳妥比如my-service:7f3d2a1这样每次发布都有明确的标识回滚也有据可查。2. 三种实际场景下的重新部署操作2.1 单机直接用 docker run 管理最基础的三步如果你部署的Java服务就一两台机器没有上容器编排平台那最基础的操作就是“停掉旧的、删掉旧的、用新镜像跑新的”。# 1. 停掉旧容器发 SIGTERM 给进程让应用优雅停机 docker stop my-service-container # 2. 删除旧容器删除容器本身及其可写层 docker rm my-service-container # 3. 用新镜像启动一个同名新容器 docker run -d \ --name my-service-container \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVEprod \ -v /data/logs:/app/logs \ registry.example.com/my-service:1.2.4这里每一步都有讲究别光复制粘贴。为什么先 stop 再 rmdocker stop会先发送SIGTERM信号给Java进程一个执行优雅停机比如Spring Boot的shutdown hook、处理完正在处理的请求的机会。如果等几秒还没停Docker才会发SIGKILL强制杀也可以手动docker kill直接强杀但一般不推荐。docker rm则是把容器本身和它落盘的可写层清掉属于“清理现场”。为什么端口映射要反复写docker run创建的是全新容器旧容器的所有参数端口、环境变量、卷挂载都不会自动继承。所以每次启动都要重新把所有参数写清楚。这也是为啥我更推荐用docker compose后面会说。卷挂载千万别漏如果你之前把日志、配置、数据写在容器里那么docker rm之后这些内容就没了。这也是为什么从一开始跑容器就建议把日志目录、需要持久化的数据目录都挂载到宿主机上而不是存在容器可写层。注意docker run -d里的-d是后台运行。如果启动后容器立刻退出可以先用docker logs查看日志看看是端口被占、配置加载失败、还是数据库连不上。2.2 用 docker compose 管理一文件搞定重建如果服务不只是一个容器Java服务通常背后还有MySQL、Redis、Nginx等我强烈建议用docker compose。Compose 最大的价值就是把容器参数变成可维护、可版本化的配置。一个简单的docker-compose.yml长这样version: 3.8 services: my-service: image: registry.example.com/my-service:1.2.4 container_name: my-service-container ports: - 8080:8080 environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql volumes: - ./logs:/app/logs depends_on: - mysql mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 volumes: - mysql-data:/var/lib/mysql volumes: mysql-data:有了这个文件之后重新部署只需要两步# 方式一构建并重建变化的容器 docker compose up -d --build # 方式二从仓库拉取新镜像并重建 docker compose pull docker compose up -d这两个命令的区别你得搞清楚--build是“本地构建镜像然后启动”pull是“从远端镜像仓库拉取然后启动”。如果镜像是在CI上构建好推到仓库的生产环境只需要pull如果你就是想在开发机上本地构建那就用--build。docker compose up -d的另一个好处是它会自动对比当前运行的容器和配置里的差异。如果镜像tag变了、环境变量变了、端口变了它会自动重建出对应的新容器如果没有任何变化它就是单纯“确保容器在运行”啥也不动。这就比手写docker run一堆参数要稳得多。注意改了docker-compose.yml之后docker compose up -d会按新配置重建有变动的服务。但depends_on只是控制启动顺序不保证数据库“就绪”Java服务如果启动太快可能连不上刚重启的MySQL这个下面会提到。2.3 滚动发布并发高的时候别做“先停后启”上了规模之后你会发现“stop then run”这套有个致命问题停机窗口。docker stop到新容器完全接收流量之间服务是断的。对于内部管理后台可能无所谓但如果服务在接入层、用户量又大停几秒就会刷出一片报错。这时候你需要的是滚动发布rolling update。思路一句话旧的不停先把新的拉起来确认新的健康了再把流量切换过去最后杀掉旧的。具体到Docker环境常用方式有这么几种端口切换这是最朴素的方案。假设你服务器上用Nginx做前端入口配置里proxy_pass指向http://127.0.0.1:8080的旧版本。发布流程是这样旧容器还在8080跑着这时候用新镜像把新容器跑在8081端口docker run -d --name my-service-new -p 8081:8080 -e SPRING_PROFILES_ACTIVEprod registry.example.com/my-service:1.2.5把Nginx的上游从8080改成8081执行nginx -s reload。等确认新版本日志一切正常、接口响应没问题再把旧容器停掉删掉docker stop my-service-old docker rm my-service-oldNginx reload是平滑的几乎不会丢请求。这套方案很简单适合单机部署的场景但需要Nginx配合本质还是手动的蓝绿部署。Docker Compose 的滚动更新配置如果你用Compose管理一个服务并且希望真正“滚动”而不是“先停后启”Compose在docker-compose.yml里有deploy配置配合 swarm 模式或兼容 Compose 的运行时services: my-service: image: registry.example.com/my-service:1.2.5 deploy: replicas: 3 update_config: order: start-first parallelism: 1 delay: 10s failure_action: rollbackstart-first的意思就是先把新容器启动并确保健康再停止旧容器正好对应滚动发布的逻辑。parallelism: 1表示一次只更新一个实例delay: 10s是每个实例之间的间隔给JVM留出启动时间failure_action: rollback表示如果新版本启动失败、健康检查不过自动回滚到上一版。不过说实话都是单机Docker Compose了用这套还要看你的运行时是否完全支持如果只是普通docker compose up部分deploy配置比如replicas在非 swarm 模式下会被忽略。要真正一键滚动更新最正统的还是上 Kubernetes那是另一个话题了这里不展开。使用负载均衡器如果你前面已经接了云厂商的负载均衡SLB/ALB滚动发布就更简单把旧容器从负载均衡后端的“启用”状态改成“停用”确认它不再接收新请求后停掉旧容器起新容器等新容器的健康检查通过比如Spring Boot Actuator的/actuator/health返回UP再把它启用并加入负载均衡后端。这套流程我今天用起来不复杂但在没有脚本自动化的前提下手工操作有一定出错概率所以发布前把每一步写成checklist很关键。Java服务滚动发布的特殊注意点Java服务滚动发布有几个坑是语言本身带来的JVM启动慢Spring Boot应用启动可能要几十秒甚至几分钟。所以健康检查超时时间一定要设置得足够长否则新容器还在启动负载均衡就判定它不健康直接把它摘掉甚至重启导致“一直起不来”。旧版本还没彻底关停新版本就起来了如果两个版本的定时任务在同一个数据库表里重复执行比如都在扫同一张订单表就可能导致数据重复处理。建议发布前给任务加分布式锁或者干脆接受窗口期有一小部分重复在任务逻辑里做幂等。会话与缓存如果服务用本地内存做缓存或Session注意是本地内存不是Redis滚动切换之后一部分用户会命中新、旧两个节点本地缓存不一致。最好的办法是把会话和缓存外置到Redis。提示滚动发布期间新旧版本会短暂并存。如果数据库结构有变更一定要保证“新代码能跑在旧库上旧代码也能跑在新库上”否则新旧并存期间必然有人报错。这个细节下面专门展开。3. 发版前必须检查的四个问题3.1 数据在不在容器外面不在就危险了重新部署本质上是一次“重建容器”。如果容器里有数据写在可写层容器一删数据就跟着没。Java服务本身通常没有持久化数据的需求但它依赖的MySQL、Redis如果也是容器化的就非常危险。很多新手把MySQL跑在容器里没挂载数据卷某次docker compose pull docker compose up -d之后MySQL容器重建库里的数据全没了。检查方法很直接# 查看当前容器是否挂载了卷 docker inspect my-service-container | grep -A 10 Mounts看输出里有没有宿主机路径或者命名卷如果Mounts是空的说明这个容器的数据是“易失的”。所以发版前的第一条铁律确认所有有状态的数据都不在容器可写层里。MySQL、Redis、ES这些有状态服务必须挂载数据卷到宿主机或用Docker命名卷管理。Java服务本身一般没有数据但日志目录建议挂出来不然容器一删日志也没了排障都没材料。3.2 数据库迁移与双版本兼容滚动发布的生死线Java后端一发新版本十有八九要改数据库。最典型的场景新增了一张表、加了一个字段、改了个索引。发布的时候最容易出问题的点就在这。先记住一条原则在滚动发布期间新旧版本会同时存在一段时间数据库必须同时兼容新旧两个版本的代码。新版本代码要能读旧库结构比如你加了一个非空字段但旧代码插入数据时没这个字段数据库就会报错。旧版本代码也不能被新库结构搞挂比如你把一个旧字段改名了或删了旧代码还在读写它同样报错。所以标准流程是先做兼容性迁移比如加字段就用nullable或给默认值的方式加上去让新旧代码都能用。发布完新版本确认稳定后再做后续清理比如确认所有流量都走了新逻辑再把那个可空字段改成非空或者删除废弃字段。这一点不管你是单机重启还是滚动发布都适用。区别只在于如果是停机发版stop先启新旧并存窗口很短甚至没有如果是滚动发版新旧并存窗口会长一些要求更严。3.3 端口、环境变量和配置最容易“漏参数”前面说了docker run每次都要把所有参数重新写一遍漏了环境变量是常有的事。尤其是Spring Boot项目的配置外置之后端口、数据库地址、密码、Redis地址全靠环境变量传一个变量抄错了容器起来也连不上依赖。这种问题很难肉眼发现我建议发版前做一次“配置核对清单”端口映射宿主机端口是否被其他进程占用ss -lntp | grep 8080扫一下。环境变量SPRING_PROFILES_ACTIVE是 prod 还是 dev这个错了可能把生产数据写到测试库。配置中心如果用了Nacos、Apollo确认新版本用的配置命名空间是否是目标环境。日志级别发版时如果怀疑有问题最好先打成 debug确认正常后再降回 info。3.4 镜像仓库本地可能没有你想要的旧版本发版前还要确认一件事新镜像真的推上去了吗我踩过一次很蠢的坑本地构建好镜像之后直接在生产机器上写了docker compose pull结果生产机器怎么都拉不到新版本排查半天发现是CI配置里只构建没推送镜像根本没进仓库。在Docker的模型里本地构建的镜像和生产拉取的镜像是两回事。你在开发机上docker build出的镜像只存在开发机本地生产机器想要用必须推送到镜像仓库Docker Hub、阿里云ACR、Harbor等之后在生产docker pull或者docker compose pull。所以发版前的检查顺序# 开发/CI机器上 docker build -t registry.example.com/my-service:1.2.5 . docker push registry.example.com/my-service:1.2.5 # 生产机器上 docker pull registry.example.com/my-service:1.2.5 docker images | grep my-service # 确认本地有这个镜像4. 重新部署时最常踩的坑与排查实录4.1 容器启动秒退日志才是第一现场容器“起来又死掉”是我见过最频繁的问题。Java服务秒退最常见的原因有四个原因一端口被占用启动日志里如果有BindException: Address already in use基本都是这个问题。常见于旧容器没删干净新的又绑同一个端口。# 看看旧容器是不是还在跑 docker ps -a | grep my-service # 哪个进程占用了8080端口 ss -lntp | grep 8080解决办法停掉旧的再删掉然后用新的镜像重新启动让容器接上端口。原因二配置加载失败Spring Boot找不到配置文件、变量解析失败比如数据库地址环境变量没传。日志里通常是APPLICATION FAILED TO START Description: Web server failed to start. Port 8080 was already in use.或者Caused by: java.lang.IllegalArgumentException: Could not resolve placeholder DB_PASSWORD这种就核对环境变量看是不是少传了、传错了。原因三数据库连不上容器起来了但初始化连接池失败。这种情况容器可能不会立即退出连接池会重试但接口一直报错。日志里能看到Communications link failure或者Connection refused。重点排查数据库容器是不是也重建了IP变了没密码对不对这里有个新手特别容易踩的坑两个容器用docker run分别启动Java服务里把数据库地址写成localhost那肯定连不上——因为这个localhost是Java容器自己不是宿主机更不是MySQL容器。解决办法用docker network把两个容器放到同一个网络Java服务里数据库地址写MySQL的容器名或者直接用host.docker.internal桌面版Docker支持代替localhost。原因四JVM内存被容器限制卡死Java容器还有一个特有的坑容器给了512MB内存限制但JVM默认的-Xmx可能是物理机的四分之一如果物理机是16GJVM就可能尝试分配4G堆内存。容器内存一超直接被内核OOM杀掉表现就是容器不断重启日志里看不到Java异常只能看到退出码137。解决办法启动命令里显式设置JVM内存参数比如java -XX:MaxRAMPercentage70 -Xms256m -Xmx512m -jar my-service.jarDocker运行的时候还要加上--memory限制形成“双保险”。4.2 回滚的正确姿势比你想的简单发版出了事故回滚就是“把镜像切回旧版本”这件事本身。只是看你想回滚得多干净。如果你用 tag 管理好了版本回滚就是把docker-compose.yml里image的tag改回上一版比如从1.2.5改成1.2.4然后docker compose up -d。如果就靠latest没救只能重新构建旧代码。这就是我为什么反复强调tag的重要性。回滚时顺序反着来滚动发布时回滚是“反向滚动”也就是把旧镜像一个个排回去检查健康后切换流量别一股脑全停了再全起。另外数据库迁移的回滚比应用回滚麻烦得多。如果新版本改了库结构回滚到旧版本时旧版本可能跑不起来旧代码对着新库结构出问题。所以发布前最好有一个数据变更的回滚计划要么是新结构做了兼容设计旧代码也能跑要么准备好补救的迁移脚本。4.3 日志排查三板斧logs、inspect、exec服务起来了但行为不对怎么排查我的习惯是先跑三个命令# 看应用日志含启动日志 docker logs -f --tail 200 my-service-container # 看容器配置、环境变量、挂载、网络 docker inspect my-service-container # 进容器看看进程和环境 docker exec -it my-service-container bashdocker inspect有一个很实用的场景你以为环境变量传对了但容器里实际看到的不是你以为的那个值。用docker inspect看Config.Env字段或者docker exec进容器里env打印一下一对比问题往往马上暴露。还有一个小技巧docker logs默认只显示stdout和stderr。Spring Boot默认日志打到stdout但如果配置了logback写到文件docker logs可能是空的。这时候要用挂载到宿主机的日志目录去查或者docker exec进容器去读日志文件。所以还是那句话日志目录挂载出来永远是值得的。4.4 常见问题速查表问题表现可能原因排查命令解决建议启动秒退日志报端口占用旧容器未删 / 其他进程占端口docker ps -a、ss -lntp清理旧容器换可用端口启动秒退提示找不到配置环境变量没传或传错docker inspect查看Env核对所有环境变量补齐容器反复重启退出码137JVM内存超容器限制被OOMdocker inspect查看State.OOMKilled显式设JVM-Xmx或-XX:MaxRAMPercentage新版本接口报数据库连接失败数据库容器重建IP变了docker network inspect用容器名连接或放进同一网络日志里有时序数据错乱新旧版本并存检查容器启动时间用滚动发布或加分布式锁docker pull拉不到新版本镜像没push或tag写错docker images本地查看先推送镜像确认tag一致拿到的是旧版本配置配置中心环境指错检查SPRING_PROFILES_ACTIVE定义prod/test/dev命名空间5. 写在最后的一点个人经验这几年来回发布过几百次Java容器最深的体会是重新部署这件事看起来是“敲几个命令”但真正决定发布顺利与否的全是在“敲命令之前”做的准备。tag版本有没有留好数据卷有没有挂载数据库结构是不是兼容旧版本JVM内存有没有和容器限制匹配——这些才是发版事故的真正根源。养成一个习惯每次发布都按同一套checklist检查一遍不要觉得“这次改动小就跳过”事故往往就出在“我以为没问题”的那一次。另外发布窗口尽量选在低峰期给别人也给自己留点缓冲时间。
返回列表