从「发布即断网」到全程无感

最近有个小系统,因为经常加入一些小需求功能,每次开发完成验证后都需要快速上线,而每次上线服务就需要 先杀旧容器,再起新服务。构建加启动的一两分钟里,系统就是"断网"状态,服务每次都需要宕个四五分钟的时间,这体验感也太差了。

想要的效果很朴素:新版本确认能正常服务了,旧版本才退出。这就是滚动发布的思路。

方案选择

方案
优点
劝退点
手写蓝绿脚本 + Nginx 切换
灵活
脚本自己维护,边界情况多
Kubernetes
能力最全
单服务单机,杀鸡用牛刀
Docker Swarm
Docker 原生,swarm init 即用
功能简单(但够了)

Swarm 有个为这个场景量身定做的参数:--update-order start-first。默认的 stop-first 是先停旧再起新(必有中断),改成 start-first 后:起新容器 → 健康检查通过 → 切流量 → 优雅停旧容器。配合 failure_action: rollback,新版本起不来还会自动回滚。

落地:把 docker run 改为 Swarm 服务

流程概览

先看整体的一个构建发布流程,通过jenkins编译服务包,然后推送到远程服务器进行镜像构建,最后进行服务的滚动更新。

Jenkins 构建
   │  1. 传输 test-service.jar 到服务器
   ▼
服务器 /app/test
   │  2. 生成版本号 V2.0.0.{构建号}_YYYYMMDD
   │  3. docker build 构建新镜像(旧服务照常运行)
   │  4. docker stack deploy 触发滚动更新
   ▼
Docker Swarm(单机模式,stack 名 zhd)
   │  5. 起新容器 → 健康检查通过 → 切流量 → 优雅停旧容器
   │  6. 观察期 60s 内异常 → 自动回滚
   ▼
Jenkins 等待收敛结果:成功=绿 / 回滚或超时=红

实操改造

初始化swarm集群,由于本身就是单机部署,所以直接初始化就行

#初始化 Swarm(单机即可)
docker swarm init --advertise-addr $(hostname -I | awk '{print $1}')

swarm集群创建成功会输出一个唯一的token,如果是多节点的情况可以将其他节点通过这个token加入同一个集群中,这里只是单机,所以初始化完成即可。

Stack 文件(deploy_server.yml)

这个yml文件就是部署策略的核心文件,包含了如何更新,如何健康检查,回滚等等,可以理解成docker compose yaml文件的升级版本,支持更多的特性,示例如下:

services:
  test-service-v2:
    image: test-service:V2.0.0.xxx_YYYYMMDD   # 由脚本注入当次版本号
    cap_add:
      - ALL                    # 替代 docker run 的 --privileged(Swarm 不支持 --privileged)
    ports:
      - "9101:9100"
    volumes:
      - type: bind
        source: /app/logs
        target: /app/logs
    stop_grace_period: 30s     # 收到 SIGTERM 后给 30s 处理完存量请求
    healthcheck:
      # 健康检查在容器内部执行,端口用容器内的 9100(不是对外的 9101)
      test: ["CMD", "curl", "-sf", "-o", "/dev/null", "http://127.0.0.1:9100/"]
      interval: 5s             # 每 5s 检查一次
      timeout: 3s
      retries: 3               # 连续 3 次失败才判 unhealthy
      start_period: 60s        # 启动预热期,期间失败不计数(按应用启动耗时调整)
    deploy:
      replicas: 1
      update_config:
        parallelism: 1         # 一次滚动 1 个
        delay: 10s
        order: start-first     # ★ 无感核心:先起新容器,healthy 后才停旧容器
        failure_action: rollback  # 健康检查不过 → 自动回滚
        monitor: 60s           # 切流量后的观察期,期间异常也算本次更新失败
      rollback_config:
        parallelism: 1
        order: stop-first
      restart_policy:
        condition: on-failure
        max_attempts: 3        # 任务连续失败 3 次后不再重试(需人工 --force)

Jenkins 配置

jenkins的编译过程此处忽略,构建后通过publish 到远程服务实现镜像构建

Publish Over SSH 设置

配置项
值
Transfer Set → Source files
target/test-service.jar
Transfer Set → Remote directory
test-service
Advanced → Exec timeout (ms)900000
(默认 120000 会在构建/滚动期间掐断脚本,导致 UNSTABLE)

Exec command

Jenkins的执行命令配置如下部署流程shell脚本,自动写入yaml,然后构建镜像,滚动更新服务

set -e
cd /app/test-service

# ===== 1. 生成唯一版本号(BUILD_NUMBER 由 Jenkins 注入,每次构建都不同)=====
[[ -n "$BUILD_NUMBER" ]] || { echo "==> 错误:未取到 Jenkins BUILD_NUMBER"; exit 1; }
IMAGE_TAG="V2.0.0.${BUILD_NUMBER}_$(date +%Y%m%d)"
SERVICE="test-service-v2"
echo "==> 本次发布版本: test-service:${IMAGE_TAG}"

# ===== 2. 生成 stack 文件(tag 写文件时注入,YAML 随 Jenkins 任务走,服务器不用手工维护)=====
cat > test-service.yml <<EOF
services:
  test-service-v2:
    image: test-service:${IMAGE_TAG}
    cap_add:
      - ALL
    ports:
      - "9101:9100"
    volumes:
      - type: bind
        source: /app/logs
        target: /app/logs
    stop_grace_period: 30s
    healthcheck:
      test: ["CMD", "curl", "-sf", "-o", "/dev/null", "http://127.0.0.1:9100/"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 60s
    deploy:
      replicas: 1
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
        monitor: 60s
      rollback_config:
        parallelism: 1
        order: stop-first
      restart_policy:
        condition: on-failure
        max_attempts: 3
EOF

# ===== 3. 构建新镜像(构建期间旧服务照常运行,零中断;唯一 tag 不会覆盖旧镜像)=====
docker build --rm -f Dockerfile -t "test-service:${IMAGE_TAG}" .

# ===== 4. 首次迁移:swarm 服务尚不存在时才停旧 docker run 容器 =====
if ! docker service inspect ${SERVICE} >/dev/null 2>&1; then
  docker rm -f test-service 2>/dev/null || true
fi

# ===== 5. 部署/滚动更新(唯一 tag 保证每次都触发滚动:起新容器 → healthy 后切流量 → 优雅停旧容器)=====
docker stack deploy -c test-service.yml example

# ===== 6. 等待滚动结果:成功才退出0;自动回滚/超时让 Jenkins 构建标红 =====
OK=0
for i in $(seq 120); do
  STATE=$(docker service inspect --format '{{.UpdateStatus.State}}' ${SERVICE} 2>/dev/null || echo none)
  case "${STATE}" in
    completed)
      OK=1; echo "==> 滚动更新成功"; break ;;
    rollback_started|rollback_completed)
      echo "==> 新版本健康检查未通过,Swarm 已自动回滚"; exit 1 ;;
    none|"")
      REPLICAS=$(docker service inspect --format '{{.Replicas}}' ${SERVICE} 2>/dev/null || echo 0/0)
      if [[ "${REPLICAS}" == "1/1" ]]; then OK=1; echo "==> 服务就绪"; break; fi
      ;;
  esac
  sleep 5
done
[[ ${OK} -eq 1 ]] || { echo "==> 等待滚动更新超时(10分钟)"; exit 1; }

# ===== 7. 镜像清理:保留最近 3 个版本(供手动回滚),删除更早的 =====
KEEP=3
docker images test-service --format '{{.Tag}}' | tail -n +$((KEEP+1)) | while read -r t; do
  docker rmi "test-service:${t}" 2>/dev/null || true
done
docker image prune -f >/dev/null

echo "==> 部署完成: test-service:${IMAGE_TAG}"
echo "==> 手动回滚命令(如需): docker service rollback ${SERVICE}"

常用运维命令

回滚操作

# 方式 1:一键回滚到上一个版本(镜像本地保留了最近 3 版,秒回)
docker service rollback example_test-service

# 方式 2:回滚到指定历史版本
docker images test-service                       # 查看保留的版本
docker service update --image test-service:V2.0.0.126_20260915 example_test-service

# 方式 3:Jenkins 重新构建旧代码分支(会生成新版本号,走正常滚动流程)

服务的停止 / 启动 / 删除

# 临时停止(保留服务定义,可随时恢复)
docker service scale example_test-service=0
docker service scale example_test-service=1     # 恢复

# 删除单个服务(容器停止 + 服务定义删除)
docker service rm example_test-service

# 删除整个 stack
docker stack rm exampe

总结

服务的稳定性和可用性在生产中都是重要的指标,会直接影响用户的使用体验,通过以上的方式可以实现最低成本的服务不停服更新,全面保证系统的可用性,不会再出现每次部署都需要等待杀死,然后重新启动完成的这个间隙不可用的问题。

版权声明

本文仅代表作者观点,不代表本站立场。
本文系作者授权发表,未经许可,不得转载。

评论