拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Docker+Nginx实现Java服务零停机发布,告别502

Docker+Nginx实现Java服务零停机发布,告别502

先问一句,你有没有经历过这种时刻:代码写完、构建通过、正准备跟团队说“发版了”,结果运维那边直接甩过来一张截图——502 Bad Gateway。紧接着就是用户反馈、领导过问、全员在线排查。这种情况在Java 服务发布时特别常见,而且用了Docker之后反而更容易踩坑:容器重启那一下,Nginx还在傻傻地往旧实例转发流量,连接被拒,网关直接报错。这篇文章我就把Docker + Nginx 实现 Java 服务零停机发布的完整方案拆开讲,从架构设计、配置细节到发布脚本、踩坑实录,一次说透。这套思路不光能解决 502,还能顺带把发布回滚、健康检查、优雅停机这些老大难问题一起理顺,适合正在被发布流程折磨的后端开发、运维和全栈工程师参考。

1. 为什么发布时老出 502:先搞清楚问题根源

1.1 一次常见的发布事故回放

先还原一个我见过很多次的现场。团队用的是 Spring Boot,服务打包成 Docker 镜像,线上架构是 Nginx 反代到一台服务器的 8080 端口。发布的时候,操作步骤通常是这样的:

docker kill myapp-old docker run -d --name myapp-new -p 8080:8080 myapp:1.0.1

看起来没毛病,但就在docker kill执行的那一刻,Nginx 里还缓存着上游服务的连接信息,下一秒钟用户请求进来,Nginx 尝试把请求转发到 8080 端口,结果发现这个端口已经什么都不监听了。于是日志里出现经典的错误:

[error] 12345#0: *678 connect() failed (111: Connection refused) while connecting to upstream

用户在浏览器里看到的就是一个大大的 502。更尴尬的是,Java 服务(尤其是 Spring Boot)启动是真的慢,JVM 起来、Spring 上下文加载、数据库连接池初始化,随随便便三四十秒。也就是说从杀掉旧容器到新容器真正能接流量,中间存在一个巨大的“真空期”。这个真空期里所有请求全部 502,而且用户还会不断重试,把错误日志刷得飞起。

1.2 502 的本质:网关连不上后端

很多人一看到 502 就慌,其实它只是一个结果,不是原因。要解决它,得先理解 Nginx 在中间扮演的角色。Nginx 是一个反向代理网关,用户请求进来后,它按配置把请求转发给后端的 Java 服务,再把后端返回的内容回给用户。这个“转发”动作有两个关键环节:建立 TCP 连接、发送 HTTP 请求并等待响应。

502 Bad Gateway 的核心含义是:网关(Nginx)成功收到了你的请求,但它往后端转发的时候失败了。失败的原因可能是后端进程没起来、端口没监听、TCP 连接被拒绝、后端返回了无法解析的响应等等。区分它和 504 有个简单办法:504 是连接建立了,但后端迟迟不给响应,网关等不下去主动断开;502 是压根连不上,或者连上了拿到一个非法的响应。两者的排查方向完全不同。

再说一个容易混淆的 499 状态码,这是 Nginx 特有的,意思是客户端在网关等后端响应时自己先断了(比如用户刷新了页面)。发布期间 502 伴随 499 大量出现,基本可以断定是后端服务不可用导致的连锁反应。

1.3 传统发布方式的三个死穴

为什么传统的“杀旧起新”方式必然会产生 502?总结下来有三个死穴:

第一,先停后起,必有窗口期。把旧进程杀掉到新进程监听端口,中间任何一秒都是有请求进来但没后端可用的状态。对于 Java 这种启动慢的物种,窗口期以十秒为单位计算,用户体感就是“网站挂了”。

第二,端口冲突导致新起失败。如果你先起新容器再杀旧的,新容器要把 8080 端口占住,但旧容器还占着这个端口,docker run 直接报port is already allocated。这就是很多团队被迫“先杀后起”的原因——不是他们不想先起后杀,是端口就那么一个,没法共存。

第三,JVM 冷启动,端口监听不等于服务可用。就算你用-p 8081:8080先起了新容器,Spring Boot 进程开始监听了,但 Spring 容器还没初始化完,这个端口上虽然有进程,却没有真正处理请求的能力。此时 Nginx 一旦转发,轻则超时,重则直接 502。更隐蔽的情况是 Tomcat 线程池还没准备好,请求进来排队等超时。

理解了这三点,就能明白为什么加个sleep 30再重启是治标不治本——它只是把真空期人为延长,并没有消除“流量切换”和“服务就绪”之间的脱节。

2. Docker + Nginx 零停机架构设计与选型

2.1 为什么选 Docker:环境一致性与快速扩缩

这套方案选 Docker 作为基础设施不是因为它花哨,而是因为三个非常实际的原因。

其一,环境一致性。Java 服务最烦人的就是“在我机器上能跑”。Docker 镜像把 JDK 版本、运行参数、系统依赖、时区配置全部固化在镜像里,测试环境和生产环境跑的是同一个镜像,发布只是换一个镜像版本,从根源上减少了环境差异导致的诡异问题。

其二,快速启动与快速回滚。镜像本身就是个完整的运行环境,启动一个新版本容器只需要秒级操作。而且 Docker 的命名与标签机制让回滚变得极其简单——切回旧镜像重新起容器就行,比在服务器上翻历史构建产物靠谱多了。

其三,端口自由映射。这正好解决了传统发布里“端口冲突”的死穴。同一个宿主机上可以同时运行两个版本的服务容器,一个映射到 8081,一个映射到 8082,互不干扰。这个特性是整个零停机方案的基础,没有它,蓝绿部署根本无从谈起。

但必须说清楚一点:Docker 本身解决不了 502。恰恰相反,如果直接把容器 stop/start 当成发布手段,Docker 默认docker stop的宽限期只有 10 秒,如果进程没在这段时间内退出,Docker 会直接发 SIGKILL 强杀。Java 服务可能连优雅关闭的机会都没有,该断的请求照样断。所以 Docker 只是载体,真正解决 502 的是一套完整的发布编排逻辑。

2.2 为什么需要 Nginx 配合:流量切换的艺术

Nginx 在这套方案里的角色可以类比成一个“流量闸门”。正常运行时它把用户请求负载均衡到后端服务;发布时它需要瞬间把流量从旧实例切到新实例,而且切换的过程不能断流。

Nginx 能承担这个角色,靠的是两个特性。

第一个特性是upstream 配置支持多个后端实例。你可以很自然地在upstream块里列出多个服务地址,Nginx 会按权重或者负载均衡算法把请求分发下去。这意味着后端多实例运行不是架构上的奢望,而是一件顺手就能做的事情。

第二个特性是nginx -s reload是平滑重载。很多新手以为 reload 等于重启,会断连接。实际上 reload 的机制是:master 进程重新读取配置,然后启动一组新的 worker 进程,旧的 worker 进程继续处理未完成的请求,处理完才退出。在这个过程中,已经建立的长连接不会被掐断,新进来的请求由新配置处理。所以 reload 是真正意义上的“热切换”,这也是为什么我们可以在发布过程中放心地修改 Nginx 配置。

2.3 蓝绿方案 vs 滚动方案,怎么选

零停机发布的落地方式主要有两种:蓝绿部署和滚动部署。我先把两者的核心逻辑说清楚,再给选择建议。

蓝绿部署:准备两套完全独立的环境,一套叫“绿”代表当前线上版本,一套叫“蓝”代表要发布的新版本。发布时先在蓝环境把新版本启动起来,做健康检查,确认没问题后,把 Nginx upstream 从绿整体切到蓝,然后再把绿环境停掉。下个版本发布时反过来,蓝绿彼此角色互换。

滚动部署:不止两个实例,而是一组实例(比如 5 个),发布时每次只替换其中 1 个,逐个进行。每个实例只承担 1/5 流量,替换期间大部分流量仍在旧版本上,因此用户几乎无感。整个过程 Nginx upstream 不需要大改,只需保证负载均衡列表里有新有旧。

两个方案对比起来各有优劣:

方案资源占用发布复杂度回滚速度适用场景
蓝绿高(需要双倍资源)低(配置简单直观)快(反向切换即可)中小团队、单机多容器、追求简单可控
滚动低(复用原有资源)中(需要编排与健康状态跟踪)中(回滚到上一批)多节点集群、K8s 环境、大流量系统

我的建议很直接:如果你只有一两台服务器,或者团队刚起步,优先选蓝绿。它的心智负担最小,出问题时的回滚就是改一行 Nginx 配置再加 reload,3 秒搞定。滚动部署虽然节省资源,但真正的价值要在多节点、弹性的环境下才能体现出来,单机场景反而容易搞复杂。

3. 核心配置与实现细节

3.1 第一步:Java 服务的 Dockerfile 写法

要跑零停机发布,Java 服务本身的镜像必须可靠。这里给一个我在生产环境用过的 Dockerfile,基于 Spring Boot + Maven 构建:

# 多阶段构建,第一阶段用 Maven 镜像编译 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 第二阶段,只拷贝 jar 包,用精简 JRE 运行 FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /build/target/app.jar /app/app.jar RUN groupadd -r app && useradd -r -g app -u 1001 app \ && mkdir -p /app/logs && chown -R app:app /app USER app EXPOSE 8080 ENV TZ=Asia/Shanghai ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

有几个细节值得展开:

为什么用多阶段构建?因为最终镜像里只需要 JRE 和 jar 包,不需要 Maven、不需要 JDK、不需要源码。多阶段构建可以把中间产物直接丢弃,最终镜像可能只有 200MB 左右,而如果直接把 JDK + Maven 装进镜像,动辄 700MB 甚至 1GB。镜像越小,拉取越快,发布速度越快,502 窗口也就越小。

为什么建非 root 用户?Java 服务应该以非 root 身份运行,这是安全基线要求。但要注意,如果 jar 包需要用 write 权限写文件(比如日志),就必须把对应目录 chown 给这个用户,否则一启动就报权限错误。

为什么显式设置 TZ=Asia/Shanghai?基础镜像默认是 UTC 时区,如果你不设置,日志时间会比北京时间慢 8 小时。这个坑看起来小,排查问题的时候会让你怀疑人生。

JVM 参数最好通过环境变量注入。镜像里写死-Xmx512m只是给了个默认值,实际部署时通过-e JAVA_OPTS="-Xmx2g"覆盖,这样同一个镜像在不同规格的机器上都能灵活运行。

3.2 第二步:Nginx 双 upstream 配置

蓝绿部署的关键在于 Nginx 配置需要预设两套 upstream,并且能用一次 reload 完成切换。下面是我在用的配置模板:

upstream backend_green { server 127.0.0.1:8081 max_fails=3 fail_timeout=10s; keepalive 32; } upstream backend_blue { server 127.0.0.1:8082 max_fails=3 fail_timeout=10s; keepalive 32; } server { listen 80; server_name api.example.com; location / { proxy_pass http://backend_green; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 60s; } }

这里有几个容易踩坑的点。

为什么用两个 upstream 而不是一个 upstream 里写两个 server?因为发布切换时,我们需要把一个版本的流量整体切走。如果两个 server 写在同一个 upstream 里,Nginx 会负载均衡地同时向新旧两个版本发流量,这会导致“同一个请求可能打到旧版本,也可能打到新版本”。对于无状态接口问题不大,但对于数据库结构变更、依赖协议变化的版本,新旧并存本身就意味着脏数据风险。两个独立的 upstream 块,让切换变成一次原子操作:要么全部流量走蓝,要么全部走绿。

proxy_http_version 1.1和Connection ""是干什么的?默认情况下 Nginx 向后端转发用的是 HTTP/1.0,每发一个请求就新建一个 TCP 连接,后端 Tomcat 的压力会很大。改成 HTTP/1.1 并清掉 Connection 头,配合keepalive 32,Nginx 和后端之间可以复用连接,大幅降低握手开销。发布切换的瞬间,复用连接能减少“新连接建立中”的抖动概率。

max_fails=3 fail_timeout=10s是被动健康检查。它的含义是:如果 10 秒内对这个后端失败 3 次,Nginx 就把这个后端标记为不可用,10 秒内不再向它转发请求。这对发布有重要帮助——如果新版本健康检查没通过,流量切过去后 Nginx 会自动“刹车”,不会持续把请求打到坏节点上。

3.3 第三步:健康检查与优雅停机

零停机发布最核心的两个机制,一个是“新服务怎么算就绪”,另一个是“旧服务怎么算退出”。先说前者。

健康检查:不要只看进程,要看业务就绪状态。

很多人检查服务起没起用curl 127.0.0.1:8080,这在 Spring Boot 里极不靠谱。因为 Spring Boot 的内嵌 Tomcat 在应用上下文加载完成之前就已经开始监听端口了。也就是说,进程在、端口通,但你的 Controller、数据库连接池、Redis 客户端可能还没准备好。这时候放流量进来,轻则 404、重则连接超时,效果跟 502 没什么区别。

正确做法是引入 Spring Boot Actuator 的 health endpoint。在pom.xml里加依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>

然后在application.yml里把 health 端点敞开:

management: endpoints: web: exposure: include: health,info endpoint: health: show-details: always

health endpoint 会聚合数据源、Redis、消息队列等各组件的健康状态,只有所有组件都正常时才返回 HTTP 200 和{"status":"UP"},任何一个依赖挂了就返回 503。发布脚本只要轮询这个接口,就能准确判断新版本是否可以接流量。

优雅停机:让旧服务把手上请求处理完再死。

Spring Boot 2.3 开始支持优雅停机,配置很简单:

server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s

开启之后,应用收到 SIGTERM 信号时不会立刻关闭 Tomcat,而是停止接收新连接,等待已收到的请求处理完,最多等 30 秒。这正好配合nginx -s reload的切换动作:reload 先让新流量走新版本,旧 worker 里的存量请求依然往旧容器转发;旧容器此刻还没退出,能把这批请求处理完;等 Nginx 旧 worker 清空了,再关闭旧容器,自然就没有请求被半路掐断。

注意,优雅停机的宽度要留够。如果你的接口有长任务、文件下载、大报表导出,30 秒可能不够。实战中我一般把timeout-per-shutdown-phase调到 60 秒,同时把docker stop -t 90的宽限期也对应调大,保证两边的机制匹配。

3.4 第四步:零停机发布脚本

前面的配置都是地基,真正把发布跑起来的是一个发布脚本。我用的是最朴素、也最好维护的 Bash 脚本,逻辑清晰,每一行都知道在干什么:

#!/bin/bash set -euo pipefail IMAGE_NAME="registry.example.com/myapp" VERSION="${1:?请提供版本号,例如 ./publish.sh 1.0.1}" CURRENT_UPSTREAM=$(grep -oP 'proxy_pass http://\Kbackend_[a-z]+' /etc/nginx/conf.d/myapp.conf) if [ "$CURRENT_UPSTREAM" == "backend_green" ]; then NEW_UPSTREAM="backend_blue" NEW_PORT=8082 NEW_CONTAINER="myapp-blue" OLD_CONTAINER="myapp-green" else NEW_UPSTREAM="backend_green" NEW_PORT=8081 NEW_CONTAINER="myapp-green" OLD_CONTAINER="myapp-blue" fi echo "当前流量在 $CURRENT_UPSTREAM,新版本将发布到 $NEW_UPSTREAM" # 1. 拉取新镜像 docker pull "${IMAGE_NAME}:${VERSION}" # 2. 启动新版本容器(先起后切,这是零停机的前提) docker run -d --name "$NEW_CONTAINER" \ -p "${NEW_PORT}:8080" \ -e "JAVA_OPTS=-Xms512m -Xmx1g" \ -e "SPRING_PROFILES_ACTIVE=prod" \ --restart unless-stopped \ "${IMAGE_NAME}:${VERSION}" # 3. 健康检查:最多等 120 秒,直到新容器返回 UP health_check_passed=0 for i in $(seq 1 60); do if curl -fsS "http://127.0.0.1:${NEW_PORT}/actuator/health" | grep -q '"UP"'; then echo "健康检查通过,新版本已就绪(等待 ${i}x2 秒)" health_check_passed=1 break fi sleep 2 done if [ "$health_check_passed" -ne 1 ]; then echo "健康检查失败,启动回滚流程" docker stop "$NEW_CONTAINER" docker rm "$NEW_CONTAINER" exit 1 fi # 4. 切换 Nginx 流量 sed -i "s/proxy_pass http:\/\/${CURRENT_UPSTREAM}/proxy_pass http:\/\/${NEW_UPSTREAM}/" /etc/nginx/conf.d/myapp.conf nginx -t && nginx -s reload echo "Nginx 流量已切换到 $NEW_UPSTREAM" # 5. 优雅停止旧容器,留足宽限期 docker stop -t 60 "$OLD_CONTAINER" docker rm "$OLD_CONTAINER" echo "发布完成,旧容器已清理"

脚本的核心逻辑其实就一句话:先起新、验健康、切流量、再停旧。前两步保证任何时候都有一个健康的版本在承接流量,第三步是零停机切换的关键动作,第四步才清退旧版本。

这个脚本还有一个隐藏优点:它天然支持回滚。如果新版本上线后出了问题,只需重新执行脚本,传入旧版本号,它会把“当前流量”切回另一个 upstream。因为蓝绿两套源还在,回滚就是一个同样的发布流程,只是方向相反。这在线上出问题时的价值,用过的都懂。

4. 实操过程与核心环节实现

4.1 环境准备与目录规划

在讲完整的实操演示之前,先把环境清单列出来。这里以一台 Ubuntu 20.04 服务器为例,上面已经装好了 Docker 和 Nginx。用 Docker 启动我们的 Java 服务(Spring Boot 项目,假设构建产物是app.jar),Nginx 作为宿主机上的反向代理(也可以再把 Nginx 容器化,但直接在宿主机装 Nginx 对配置切换更直观,新手也更好理解)。

服务器上的目录规划大概是这样:

/opt/myapp/ ├── docker/ # Dockerfile 和构建脚本 │ ├── Dockerfile │ └── build.sh ├── conf/ # Nginx 配置 │ └── myapp.conf └── scripts/ ├── publish.sh # 零停机发布脚本 └── rollback.sh # 回滚脚本(复用 publish.sh 逻辑)

Nginx 的站点配置放在/etc/nginx/conf.d/myapp.conf,也就是 3.2 节那段双 upstream 配置。这样发布脚本里的sed替换才有明确的目标文件。

4.2 从构建镜像到首次发布全流程演示

第一步,构建镜像。在项目根目录执行:

docker build -t registry.example.com/myapp:1.0.0 . docker push registry.example.com/myapp:1.0.0

首次发布时,还没有“旧版本”,我们需要先把 1.0.0 部署成初始的 green 环境。这一步不涉及零停机,因为线上还没流量。手动执行:

docker run -d --name myapp-green -p 8081:8080 \ -e "SPRING_PROFILES_ACTIVE=prod" \ registry.example.com/myapp:1.0.0

然后验证:

curl http://127.0.0.1:8081/actuator/health # 期望输出 {"status":"UP"}

再确认 Nginx 配置里proxy_pass http://backend_green;,reload 后整个服务就通了。

第二步,模拟一次线上发布。现在我们要发 1.0.1 版本,按 3.4 节的脚本走一遍:

cd /opt/myapp/scripts ./publish.sh 1.0.1

脚本的执行过程大致如下:

当前流量在 backend_green,新版本将发布到 backend_blue 健康检查通过,新版本已就绪(等待 5x2 秒) Nginx 流量已切换到 backend_blue 旧容器已清理

关键观察点在第三步。执行nginx -s reload之后,可以用curl -I连续请求几次,注意响应头里有没有异常状态码。正常情况应该是清一色的 200。

第三步,现场确认流量分布。用docker ps看一下容器状态:

CONTAINER ID IMAGE STATUS a1b2c3d4e5f6 myapp:1.0.1 Up 2 minutes (healthy)

此时myapp-green已经不存在,说明脚本完成了清理动作。如果对切换不放心,还可以临时把 green 容器手动再起起来,验证两个 upstream 都能独立工作:

docker run -d --name myapp-green -p 8081:8080 \ registry.example.com/myapp:1.0.0

但我建议测试完立即删除,避免下个发布周期里容器名冲突。

4.3 关键验证:发布期间请求零失败

前面流程跑通了,但“零停机”不是靠感觉,而是靠实测。我有一个压箱底的验证方法,不需要复杂的压测工具,只需要一个循环脚本持续向服务发请求,同时进行发布,最后统计失败数量。

先命令行起一个持续请求循环:

for i in $(seq 1 500); do curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/api/ping sleep 0.2 done > request.log 2>&1 &

这个循环大约会跑 100 秒,在这 100 秒内我们执行发布脚本:

./publish.sh 1.0.2

发布完成后,等循环结束,统计状态码:

sort request.log | uniq -c

预期输出是:

500 200

500 200意味着 500 个请求全部成功,没有出现一个 502 或 504。如果中间出现任何非 200 状态码,说明发布流程里还有缝隙,需要回头检查健康检查或者优雅停机的配置。

如果追求更接近真实的压力,可以用wrk或者ab做并发请求。比如:

wrk -t4 -c100 -d60s http://127.0.0.1/api/ping

以 100 并发持续压一分钟,同时触发发布,看 wrk 报表里的Socket errors和Non-2xx响应数。我在腾讯云的一台 4C8G 机器上实测过,Spring Boot 服务在每秒 800+ 请求的压力下做切换,零错误率是完全可以做到的,前提是健康检查接口本身不能拖后腿。

4.4 发布中的日志观察技巧

发布过程中,日志是判断问题最直接的依据。我通常开三个终端窗口,分别盯三个日志流:

第一个窗口盯 Nginx 错误日志:

tail -f /var/log/nginx/error.log

第二个窗口盯新版本容器的应用日志:

docker logs -f --tail=100 myapp-blue

第三个窗口盯老版本容器的退出日志:

docker logs -f --tail=20 myapp-green

正常发布时,Nginx 错误日志不应该有任何新增的connect() failed或upstream timed out。新版本容器日志里应该能看到优雅停机前最后一个请求正常完成。如果老版本容器日志里出现 Thread 中断异常或者连接池强制关闭的堆栈,说明优雅停机配置没生效,需要检查server.shutdown: graceful是否真的加载了。

5. 常见问题与排查技巧

5.1 502 问题速查表

发布过程中遇到的 502 五花八门,我把高频场景整理成一张速查表,遇到问题按图索骥:

现象典型原因排查命令解决方式
Nginx 日志报 connect() failed (111)后端端口没有进程监听ss -lntp | grep 8081确认容器已启动,端口映射正确
容器起来了但 curl 不通Docker 端口映射失败docker ps查看 PORTS 列检查-p参数是否重复占用
健康检查返回 DOWN数据库/Redis 连接异常curl /actuator/health看 details检查依赖服务连接串
健康检查返回 200 但接口仍超时JVM 仍在预热,Tomcat 线程池未就绪jstack看线程状态等 readiness 状态,或用 actuator readiness 组
切换流量后间歇性 502新容器健康但被max_fails标记为不可用nginx -T | grep upstream调大 fail_timeout,或确认被动健康检查参数
reload 前后偶发 502keepalive 连接指向已关闭的后端观察 error.log 中 connection reset检查旧容器停止顺序是否在 reload 之后
发布完成后用户仍看到 502浏览器/客户端缓存了旧连接确认 DNS 与 CDN 缓存清理客户端缓存或等 TTL 过期

注意一个很容易被误判的情况:容器启动成功、日志正常、但端口映射没生效。docker ps里如果 PORTS 列为空,说明容器内进程在启动命令里没有以 8080 端口监听,或者 ENTRYPOINT 里的参数被覆盖了。这种问题在加了自定义 JAVA_OPTS 环境变量时特别容易触发,因为$JAVA_OPTS的引号问题可能导致整个启动命令变形。

5.2 Nginx reload 的注意点与常见误区

nginx -s reload确实是平滑的,但它不是万能的,有三个细节容易踩坑。

第一,先nginx -t再 reload。配置写错了直接 reload 会导致整个 Nginx 重载失败,服务还是按旧配置运行。表面上看没出问题,但你的流量切换根本没生效,新旧容器都挂着,用户还是在打旧版本的接口。所以脚本里我固定写死nginx -t && nginx -s reload,语法检查不通过就不执行 reload。

第二,reload 期间 master 和旧 worker 有一个过渡期。reload 后,旧 worker 进程不会立刻消失,它要把处理中的请求完成才会退出。这个过程通常几秒到几十秒不等,取决于请求耗时。在这期间,旧 worker 仍然会向旧的 upstream 转发请求。这就是为什么脚本把“停止旧容器”放在 reload 之后而不是之前——必须给旧 worker 留出清空请求的时间。如果你在 reload 之前就docker stop旧容器,等于把旧 worker 手里的请求掐断,用户照样会看到 502 或连接重置。

第三,reload 不能解决后端端口配置错误。如果新 upstream 指向的端口根本没人监听,reload 之后所有请求照样 502,而且错误日志会刷屏。发布脚本里健康检查那一步就是在提前发现这个问题,宁可发布失败回滚,也别切一个不能用的 upstream 上去。

5.3 优雅停机没生效的排查思路

优雅停机是这套方案里最容易被忽略、也最容易悄悄失效的环节。我遇到过一次很迷惑的情况:配置了server.shutdown: graceful,发布时老容器还是秒退,Nginx 上有几条连接被重置。排查之后发现原因有两点。

第一,Spring Boot 版本太老。优雅停机是 2.3.0 才引入的能力,如果你的项目还在用 2.1.x 或更早,配置写上去根本没有对应实现。查看版本时要注意,Spring Boot 2.2 的server.shutdown枚举里没有 graceful。

第二,Docker 的 SIGTERM 没有传递到 Java 进程。Docker 默认向容器内 PID 1 进程发信号,如果 ENTRYPOINT 是用sh -c "java -jar app.jar"启动的,PID 1 其实是 sh,而 Java 是 sh 的子进程。sh 收到 SIGTERM 后不会转发给 Java,甚至可能直接把 Java 当成孤儿进程处理。这个问题在 3.1 节的 Dockerfile 里就埋了雷:ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]确实是sh -c的形式。

那怎么解决?有两个靠谱路子:

路子一:用 exec 替代 sh。把 ENTRYPOINT 改写成 exec 形式,让 Java 进程直接成为 PID 1:

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

但这样又没法动态设置 JAVA_OPTS 了。折中的办法是写一个 wrapper 脚本:

COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh ENTRYPOINT ["docker-entrypoint.sh"]

脚本里最后一行必须是exec java $JAVA_OPTS -jar /app/app.jar,用exec替换当前 shell 进程,这样 PID 1 就是 Java 进程本身,信号能直接送达。

路子二:用 tini 或 dumb-init 作为 PID 1。这些轻量级 init 系统专门负责信号转发和僵尸进程回收。Debian 系的 Java 镜像可以直接装 tini:

RUN apt-get update && apt-get install -y tini ENTRYPOINT ["tini", "--", "sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

两个方案我都实测过,效果都不错。但要注意,即使信号正确到达了 Java 进程,优雅停机还有一个大前提:Spring 能正常完成 shutdown 钩子。如果应用代码里用了自定义线程池、ScheduledExecutor、或者自己开的 Socket,这些资源不会自动优雅关闭,需要注册 DisposableBean 或者监听 ContextClosedEvent 手动处理。

5.4 我踩过的其他坑(按发生频率排序)

第一个坑:健康检查放行过快导致流量打到未就绪服务。Spring Boot 的/actuator/health默认是 liveness 语义,进程活着就返回 UP。但我们要的是 readiness 语义——服务是否有能力接请求。Spring Boot 2.3 起可以用ReadinessState分组,但配置稍显复杂。更省事的做法是健康检查里带上数据库 ping 或者一个轻量级业务接口,比如写一个/api/pingController 返回"pong",并且里面强制查一次数据库:

@RestController public class PingController { @Autowired private JdbcTemplate jdbcTemplate; @GetMapping("/api/ping") public String ping() { jdbcTemplate.queryForObject("SELECT 1", Integer.class); return "pong"; } }

发布脚本的探测目标从/actuator/health换成/api/ping,能更真实地反映“能不能处理业务请求”。代价是这个接口必须有足够快的响应,否则健康检查轮询本身会成为发布瓶颈。

第二个坑:JVM 堆内存设置过大导致启动巨慢。如果你给容器设置了-Xmx4g而宿主机只有 8G 内存,JVM 启动时预留堆空间的耗时可能飙到 30 秒以上。零停机发布最怕的就是新版本长时间不就绪。解决方案是给 JVM 加上-XX:InitialRAMPercentage或者直接设置初始堆-Xms等于-Xmx,让 JVM 启动时直接分配好内存,不要边跑边扩容。另外,有条件的话上-XX:+UseContainerSupport(JDK 10+ 默认开启),让 JVM 正确识别容器内存限制。

第三个坑:服务间调用没走网关,走的是内网 IP。如果你的微服务架构里,一个服务调用另一个服务用的是内部 IP 加端口,那发布顺序就很重要:必须先发布被调方,再发布调用方。否则调用方切到新版本,被调方还在旧容器上,两个版本之间的协议不兼容,又是一片 502、4xx 报错。这种问题不在 Nginx 层面体现,排查难度大得多。所以上线蓝绿方案之前,先把服务间的调用链路梳理清楚,发布顺序写进发布文档。

第四个坑:数据库迁移和蓝绿发布的顺序。蓝绿两套环境会短暂并存,如果新版本包含数据库表结构变更(比如新增了非空字段),而旧版本还在跑,旧版本的写操作可能会因为新字段缺默认值而直接报错。反过来也一样,新版本如果依赖旧数据格式,可能会读不到数据。稳妥的做法是把数据库迁移拆成“向前兼容”的步骤:先加可空字段、再加默认值、最后才切流量。这个话题展开又是一篇文章,但发布脚本本身解决不了这个问题,必须团队约定好。

写在最后

这套 Docker + Nginx 蓝绿发布方案,我在生产环境跑了快两年,从单机部署到后面加了配置中心和注册中心,核心逻辑一直没变:先起新、验健康、切流量、再停旧。它不依赖任何复杂的编排平台,一台服务器、一个脚本、一段 Nginx 配置,就能把发布 502 从根上解决。

顺手分享一个后续扩展的小技巧:如果你想把这套流程接入 Jenkins 或者 GitLab CI,只需要把 3.4 节的 publish.sh 包一层,让 CI 传入版本号就行。再往后,如果你对自动化要求更高,可以把健康检查、切换、回滚的状态上报到企业微信或者钉钉机器人,发布过程全程留痕。但不管怎么扩展,核心的蓝绿切换逻辑不需要变——它简单、可控、回滚快,这才是发布系统最重要的品质。

返回列表