ARTICLE DETAIL

资讯详情

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

Docker与Nginx配合Java后端实现零停机发布:告别502实战指南

Docker与Nginx配合Java后端实现零停机发布:告别502实战指南 做了几年 Java 后端我最大的体会是线上发布最让人紧张的往往不是代码写不写得完而是发布窗口那几秒会不会冒出刺眼的 502。用 Docker、Nginx 给 Java 服务做零停机发布是我把发布流程从“每次上线都心惊胆战”变成“日常操作”的关键一步。这篇我会把完整方案写下来502 是怎么产生的、双容器组加 Nginx 切换为什么能规避它、Docker Compose 和 Nginx 具体怎么配、以及我踩过的几个还算隐蔽的坑。适合正在用传统方式部署 Spring Boot 服务、想用尽量轻的代价把发布做得平滑的团队参考。1. 从一次线上发布说起1.1 502 是怎么出现的很多团队发布 Java 服务的流程还是这样的先构建新镜像然后 stop 旧容器再 run 新容器。这个流程里存在一个显著的空窗期旧容器已经停止新容器还没来得及监听端口而这期间 Nginx 的 upstream 依然指向旧容器。Nginx 把用户请求转发过去发现连接被拒绝于是返回 502 Bad Gateway。空窗期长短取决于两个因素一是容器启动速度二是 Java 应用初始化耗时。Spring Boot 应用从进程启动到 Tomcat 真正可以接收请求少则三五秒多则几十秒如果中间还要连数据库、连 Redis、加载配置中心时间会更长。有些同学在容器里跑启动脚本先做一堆检查再启动 Java 进程窗口期会被进一步拉长。更糟糕的是如果发布脚本里带有 docker build 这类耗时操作比如把构建也放在发布命令里镜像构建可能要花几分钟这时候服务处于完全不可用状态。用户看到的界面大概就是“获取首页数据失败伺服器错误 502”虽然不影响 JVM 进程本身但对外表现就是服务挂了。要想彻底解决就不能再沿着“先停旧、再启新”的思路走而是要让新实例先就绪再把流量切过去。1.2 零停机发布的几个常用思路常见的零停机方案大致有滚动发布、蓝绿发布和金丝雀发布三种。滚动发布适合多节点集群比如你有三台机器一台一台替换每替换一台就等待健康检查通过全部替换完成即发布结束。这种方式在只有一个节点或者服务器资源有限时做不了同一时间你很难同时存在三份应用实例。蓝绿发布准备两套环境一套蓝色、一套绿色任意时刻只有一组对外提供服务。发布新版本时先启动另一组等它完全就绪通过 Nginx 切换流量再停掉旧组。这套方案很契合 Docker因为容器天生适合同时运行多份实例而且回滚特别直接——切回旧组就行。金丝雀发布本质上是在蓝绿基础上引入灰度比例让一小部分流量先打到新版本观察无异常后再逐步放量。它需要更完整的监控和日志能力否则很难判断新版本是否真的健康。对于一个团队规模不大的项目来说蓝绿发布是性价比最高的选择单机可用、配置简单、回滚快不需要额外引入编排平台。我最终采用的就是 Docker Compose 管理双容器组Nginx 做流量切换。先用这套方案把 502 问题解决掉后面如果想做灰度也可以在同一条技术路径上延伸。2. 方案设计与环境准备2.1 总体架构双容器组加 Nginx 切换整个部署环境跑在一台 Linux 服务器上通过 Docker Compose 管理三个容器app-blue、app-green 和 nginx。app-blue 和 app-green 是同一套 Java 应用的两个独立容器实例分别使用不同版本的镜像。任意时刻只有一组被 Nginx 指向另一组处于“热备”状态容器在跑但不接收流量。nginx 容器作为统一入口监听 80 端口通过 upstream 把请求转发给当前激活的那一组。三个容器放在同一个 Docker 自定义网络里Nginx 直接用服务名 app-blue:8080 或 app-green:8080 访问后端不依赖宿主机 IP也不关心容器重启后 IP 是否变化。两个应用容器不需要把 8080 端口暴露到宿主机只在需要调试时临时映射即可这样还能避免端口冲突。为什么这套架构能解决 502核心在于切换顺序。旧容器继续运行到最后一刻新容器提前启动完成Nginx 的 reload 又是平滑操作连接不中断。用户在整个发布过程中始终能访问到某个健康的 Java 实例自然就不会看到 502 了。2.2 基础环境与版本选型我的环境是 Ubuntu 22.04Docker 24 以上Docker Compose 使用 v2 插件。如果你还在用 docker-compose 这个独立命令注意把命令替换成 docker compose功能大同小异但 v2 语法支持更好。Java 应用是 Spring Boot 2.7 版本基础镜像用的 eclipse-temurin:17-jre。选择 JRE 镜像而不是 JDK 镜像主要是因为构建产物是可执行 Jar运行时用不到编译器镜像能小不少。这里有一个容易踩的坑健康检查要在容器内执行 curl而很多 JRE 基础镜像里根本没有 curl。我的 Dockerfile 会比较直接地安装 curl顺便清掉 apt 索引缓存控制镜像体积FROM eclipse-temurin:17-jre RUN apt-get update \ apt-get install -y curl \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY target/myapp.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar]生产环境我还会用非 root 用户启动应用避免容器以 root 权限运行这里为了演示精简没写进来但建议你不要省。Nginx 直接用 nginx:1.27-alpine 镜像通过容器方式跑省去宿主机编译安装的麻烦。Nginx 配置通过挂载卷传入容器后续切换 upstream 时只需修改宿主机配置文件再让容器内 Nginx reload 即可。3. 关键设计健康检查、优雅停机与切换脚本3.1 Docker 健康检查让 Nginx 知道后端真的活着零停机发布一个很容易被忽略的细节是容器起来了不代表应用已经可以对外提供可靠服务。Spring Boot 应用启动过程中端口可能已经监听但 Spring 容器还没完全初始化这时候切流量进来请求大概率会失败。Docker 自带的 healthcheck 机制能解决这个问题。它会在容器内部周期性执行指定命令根据返回值判断容器状态最终标记为 starting、healthy 或 unhealthy。发布脚本只有读到 healthy 才会切换流量从而避免“容器起来了但应用还没好”的情况。我在 Compose 里加的是这样的健康检查healthcheck: test: [CMD, curl, -fsS, http://127.0.0.1:8080/actuator/health] interval: 10s timeout: 3s retries: 3 start_period: 40s几个参数分别说明一下interval 是两次探测的间隔10 秒比较合适太频繁会增加无谓负载timeout 是单次探测超时3 秒足够retries 表示连续失败几次判定为 unhealthy3 次能容忍瞬时抖动start_period 是启动宽限期40 秒内失败不计入重试次数避免 Spring Boot 初始化慢导致误判。为什么要探测 /actuator/health 而不是根路径根路径通常返回一个欢迎页或者空响应只能证明 HTTP 端口通了无法证明 Spring 容器初始化完成。actuator 的健康检查是 Spring 应用自己生成的能反映内部状态完善程度。默认的 /actuator/health 可能会包含数据库、Redis 等组件的健康状态这对发布来说有时候过于敏感。我后来在项目里单独做了一个轻量的 HealthIndicator只返回应用存活状态不检查外部依赖专门给 Docker healthcheck 用。发布探测的是“应用起来了”而不是“数据库这把状态好不好”避免下游组件抖动把发布脚本卡死。3.2 Java 优雅停机不是 Nginx 不切而是旧容器要体面退场很多人以为发布流程做到 Nginx 切换就结束了其实还有一个关键问题切换流量时旧容器里可能还有正在处理的请求。如果脚本在 Nginx reload 后立刻 stop 旧容器Docker 会向 Java 进程发送 SIGTERM而 Spring Boot 默认行为是立刻退出处理到一半的请求直接被中断用户端依然可能看到报错。Spring Boot 2.3 之后支持优雅停机需要显式开启。我的配置是server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s开启 graceful 之后JVM 收到 SIGTERM 信号会先停止接收新请求再等待已经受理的请求执行完超过 timeout-per-shutdown-phase 时间才强制退出。这样配合 stop_grace_period 才能完整实现零停机。Docker Compose 里每个容器默认的停止宽限期是 10 秒。如果应用的某些请求耗时超过 10 秒优雅停机还没跑完Docker 就会发送 SIGKILL 强制结束进程。所以我会在服务定义里加上stop_grace_period: 35s这个时间比 Spring 的 timeout-per-shutdown-phase 稍微大一点保证 Spring 优雅停机流程能完整走完。需要注意的是stop_grace_period 生效的前提是应用正确响应了 SIGTERM。如果你在 Dockerfile 里用 ENTRYPOINT [java, -jar, ...]Java 进程就是 1 号进程信号能直接送达这是最理想的情况。如果入口是 shell 脚本包了一层就得确认信号能被转发给 Java 进程否则整个优雅停机配置都可能白做。3.3 切换脚本发布流程怎么编排有了健康检查和优雅停机剩下就是发布脚本。发布脚本承担四个职责启动待发布容器组、等待健康、修改 Nginx upstream、reload Nginx。停旧容器放在最后且不强制立即执行。我常用的发布脚本骨架大致如下#!/usr/bin/env bash set -euo pipefail APP_IMAGEmyapp:2.0.0 STANDBYapp-green ACTIVEapp-blue # 1. 用新镜像拉起待发布组 IMAGE_GREEN$APP_IMAGE docker compose up -d $STANDBY # 2. 等待健康检查通过最多等 150 秒 status for i in $(seq 1 30); do status$(docker inspect --format {{.State.Health.Status}} $STANDBY 2/dev/null || echo empty) if [ $status healthy ]; then break fi sleep 5 done if [ $status ! healthy ]; then echo ERROR: $STANDBY is not healthy, stop deploy exit 1 fi # 3. 切换 Nginx upstream 指向新组 sed -i s|server app-blue:8080|# server app-blue:8080| nginx/upstream.conf sed -i s|# server app-green:8080|server app-green:8080| nginx/upstream.conf # 4. 校验配置并 reload nginx -t docker compose exec nginx nginx -s reload # 5. 停止旧容器保留为新版本回滚备用 docker compose stop $ACTIVE这里每一步顺序都不能乱。set -euo pipefail 保证脚本中途出错会退出而不是带着错误继续往下跑。第 2 步的健康检查等待是核心如果新实例一直 unhealthy脚本会在切换前停下来线上还由旧版继续提供服务相当于发布失败但不影响用户。sed 切换注释的方式简单直观缺点是如果配置格式变化sed 匹配也要跟着改。更稳妥的做法是维护两个独立的 upstream 文件切换时用软链接指向当前生效文件再 reload Nginx。两种方式我都在用小项目用 sed 相对直接后续要接自动化平台时建议改成链接文件方案。4. 配置与实操docker-compose 加 Nginx 完整落地4.1 docker-compose 编排部署目录结构我建议这样组织~/deploy/ ├── docker-compose.yml ├── .env ├── nginx/ │ ├── nginx.conf │ └── upstream.conf └── scripts/ ├── deploy.sh └── rollback.sh.env 文件用来声明两个容器组各自用的镜像版本IMAGE_BLUEmyapp:1.0.0 IMAGE_GREENmyapp:2.0.0docker-compose.yml 的核心内容如下services: app-blue: image: ${IMAGE_BLUE} container_name: app-blue networks: [deploy-net] restart: unless-stopped stop_grace_period: 35s healthcheck: test: [CMD, curl, -fsS, http://127.0.0.1:8080/actuator/health] interval: 10s timeout: 3s retries: 3 start_period: 40s app-green: image: ${IMAGE_GREEN} container_name: app-green networks: [deploy-net] restart: unless-stopped stop_grace_period: 35s healthcheck: test: [CMD, curl, -fsS, http://127.0.0.1:8080/actuator/health] interval: 10s timeout: 3s retries: 3 start_period: 40s nginx: image: nginx:1.27-alpine container_name: nginx-gw ports: - 80:80 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/upstream.conf:/etc/nginx/conf.d/upstream.conf:ro networks: [deploy-net] restart: unless-stopped networks: deploy-net: driver: bridge为什么这里用两个固定服务名而不是一个服务加 --scale因为我们要维护两组彼此独立且镜像版本可能不同的容器用两个 service 定义更直白。发布新版本时只需要在 .env 里改待发布组的镜像标签然后 docker compose up -d 指定服务名就能只重建那一组另一组完全不受影响。有一点要特别注意不要在发布流程里用 docker compose down。这个命令会把整个 Compose 项目里的所有服务全部停掉包括 nginx。很多事故就是这么来的——发布脚本写得不谨慎直接 down结果 Nginx 先没了后端再健康也救不回来。正确的做法是只针对单个服务操作比如 docker compose up -d app-green、docker compose stop app-blue。4.2 Nginx 配置和 reload 的意义Nginx 的 upstream.conf 是发布切换的关键文件upstream java_backend { # active: app-blue server app-blue:8080 max_fails1 fail_timeout10s; # standby: app-green # server app-green:8080 max_fails1 fail_timeout10s; } server { listen 80; server_name _; location / { proxy_pass http://java_backend; 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_connect_timeout 5s; proxy_read_timeout 60s; } }max_fails 和 fail_timeout 是 Nginx 对后端节点的被动健康检测连续 1 次失败且在 10 秒内会将该节点标记为不可用。但它仰赖真实业务请求触发不能完全替代 Docker healthcheck。真正控制“何时切流量”的是发布脚本里的 healthcheck 等待逻辑Nginx 这里的 max_fails 只是额外保险。很多教程会让你给 upstream 配置 keepalive 连接池比如 keepalive 32。这对 Java 后端不是必需的而且蓝绿切换时连接池会让行为更复杂。Java 服务通常使用短连接每次请求创建新 TCP 连接不会拖泥带水所以我没有在 upstream 里配置 keepalive。如果你的服务请求模型确实需要长连接另当别论但常规场景下不配反而更干净。关于 reload要明白为什么它安全nginx -s reload 会让 master 进程重新读取配置文件启动一批新 worker 进程旧 worker 进程在处理完手上现有请求后自然退出。监听端口一直由 master 进程持有新连接会交给新 worker整个过程不关闭监听 socket也就不存在连接中断窗口。相比之下如果执行 stop 再启动监听 socket 会被关闭那才是真正的中断。所以我强调一个原则发布切换时永远用 nginx -s reload不要用 nginx restart。reload 是平滑的restart 会断开所有现有连接等于自己把简单问题复杂化了。4.3 发布与回滚操作手册完整发布流程我一共分六步本地构建新版本镜像并推到镜像仓库。在部署机上更新 .env 里待发布组的镜像版本。执行 docker compose up -d 待发布组让其后台启动。用 docker inspect 轮询健康状态直到 healthy。修改 upstream.conf 切换 active 组为新版本。执行 nginx -t 校验配置再 nginx -s reload最后停掉旧容器组。回滚流程和发布完全对称只是把切换方向反过来确保旧版本容器还停着如果已经被删了就用旧镜像标签重新拉起。等待旧组健康检查通过。修改 upstream.conf 指回旧组。nginx -t nginx -s reload。停止新版本容器组。回滚速度取决于旧容器是否还保留。所以我在发布脚本最后一步用的是 docker compose stop 而不是 rm旧容器虽然停了但镜像和容器配置都还在回滚时一条 docker compose start 就能快速拉起很省事。如果服务器磁盘紧张最短也要在确认新版本稳定运行一个周期后再清理旧容器。5. 踩坑实录502 排查与常见问题5.1 502 常见场景排查表发布过程中遇到的 502原因各不相同我整理了一张速查表方便对号入座现象常见原因快速排查方法解决方向发布后持续 502Nginx 日志 connection refused后端容器没起来或没监听端口docker ps 看容器状态docker logs 看启动日志调整启动参数等待健康检查通过再切流量502 只出现在发布开始后前几秒旧容器已停新容器还没就绪查看发布脚本执行顺序必须先启新容器等 healthy再切流量502 偶发Nginx 日志 connection reset by peer后端处理超时或主动断开连接看 Java 日志是否有慢请求或线程池满调大 proxy_read_timeout排查慢查询502 持续Nginx 日志 no resolver defined配置里写了域名但 Nginx 没配置解析器nginx -T 查看配置upstream 直接使用服务名避免写外部域名502 持续Nginx 日志 host not found in upstream容器网络没连通服务名解析不了docker compose ps 看容器是否在同一个网络确保所有容器在同一个自定义网络发布后健康检查一直 starting镜像里没有 curl 或 healthcheck 路径不对docker inspect 查看健康状态安装 curl或改成镜像能执行的探测命令这张表我是在实际踩坑过程中逐渐补全的最有价值的其实是第一行很多 502 的根因不是 Nginx 配置而是容器和应用的启动链路出问题。只要保证发布脚本在切换前严格等待健康检查绝大多数发布型 502 都能被挡在门外。5.2 亲历的三个隐蔽问题第一个问题是镜像里没有 curl导致 healthcheck 永远处于 starting 状态。我第一次用 eclipse-temurin JRE 镜像时自信地配好 healthcheck结果发布脚本在等待循环里卡了整整一个超时周期。排查时发现容器一直在 running但健康状态始终是 starting。docker exec 进去执行 curl 才知道命令不存在。后来在每个基础镜像里统一安装 curl这个问题再也没有出现过。第二个问题是 actuator 健康检查太敏感把外部依赖的小抖动放大成了发布失败。项目里数据库做了主从架构某个时间段主从切换健康检查里的 db 状态短暂变为 DOWNhealthcheck 返回 503发布脚本判断新实例不健康就中止了。这其实是一种误杀。后来我给线上应用单独加了一个 HealthIndicator只检查应用内部的必要状态不查外部依赖。对外你可以把完整 actuator 信息通过别的路径暴露但发布探活只用轻量接口。第三个问题隐藏在公司同事误用 docker compose down。有次同事图省事用这个命令想清理环境结果 nginx 也被一起停了。发布本来已经切到一半用户直接失去入口造成一次不小的事故。从那以后我在部署文档里明确写清楚不要在发布流程中执行 docker compose down只操作具体服务。同时我给 nginx 容器加了 restart: unless-stopped即便意外停止Docker 也会尽量拉它起来。还有一个小坑和优雅停机有关。默认 stop_grace_period 只有 10 秒而 Spring Boot 的 graceful shutdown 默认最多等 30 秒。如果你只开了 server.shutdowngraceful忘了调 stop_grace_period那么 10 秒一到 Docker 就会把 Java 进程强制杀掉优雅停机形同虚设。我一并配上 35 秒后才真正看到日志里出现“Commencing graceful shutdown”并完整执行完。5.3 排查工具与习惯建议发布窗口期内我习惯开三个终端一个看 Nginx 访问日志一个看 Java 应用日志一个留着执行命令。一旦哪里不对三屏对照能快速定位是流量没切过去还是后端本身有问题。几个高频命令值得养成肌肉记忆# 查看容器健康状态 docker inspect --format {{.State.Health.Status}} app-green # 查看容器启动日志 docker logs -f app-green --tail 200 # 查看 Nginx 是否已加载新 upstream 配置 docker compose exec nginx cat /etc/nginx/conf.d/upstream.conf # 直接验证后端端口 curl -I http://127.0.0.1:8080/actuator/health每次改完 Nginx 配置reload 之前必须执行 nginx -t。这一步能拦住绝大多数语法错误和路径错误。哪怕只是改一个注释行我也坚持先 nginx -t 再 reload习惯比记性可靠。6. 从零停机到灰度发布的一点扩展6.1 用 Nginx weight 做金丝雀发布蓝绿发布解决的是“发布有没有窗口”的问题灰度解决的是“新版本敢不敢全量上”的问题。有了双容器组之后灰度其实也很好做。Nginx upstream 支持按 weight 权重分发流量。我把两组容器同时保留在 upstream 里刚开始把新版本 weight 设为 1旧版本 weight 设为 99让大约 1% 的请求打到新版本。观察一段时间后修改 weight 为 5、10、30、50逐步放大直到全量切换upstream java_backend { server app-blue:8080 weight99; server app-green:8080 weight1; }每次修改 weight 后同样执行 nginx -t nginx -s reload不需要重启 Nginx。这里有一个前提条件就是新老版本必须兼容同一个数据库结构否则灰度期间新老版本同时操作数据很容易出现脏数据。建议在灰度前做一次表结构兼容性检查再加好接口幂等设计再开始放流量。6.2 什么时候需要升级到容器平台这套 Docker 加 Nginx 的手工蓝绿发布完全足够支撑中小团队的单机或少量机器场景。我曾经在只有一台 4 核 8G 的服务器上用它承载过一个日活几万的小系统发布五十多次真正出现 502 的次数是零。但如果你开始面对这些情况服务数量超过十个、需要根据负载自动扩缩容、需要跨多台机器调度、需要更细粒度的权限和审计那手工脚本就会开始吃力。这个时候不是零停机方案本身不对而是编排层面需要升级到 K8s 那类平台。K8s 的滚动发布本质上也是“先启动新 Pod等就绪探针通过再摘掉旧 Pod”核心逻辑和我们这套 Nginx 切换方案一脉相承。所以我通常不建议小项目一上来就搭 K8s。先把架构做简单用 Docker 加 Nginx 跑通发布流程收益是最快的。等需求真的增长到那个量级再带着对发布原理的理解去上平台思路会清晰很多。我现在的个人习惯是不管以后用不用 K8s健康检查、优雅停机、平滑 reload 这三个基本功都要吃得足够透。它们才是零停机发布的地基。这套方案运行一年多发布脚本几乎没有大改过新同事接手时也能看懂这就是我眼里“足够好”的工程方案。
返回列表