ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker自动部署脚本实战:健康检查、回滚与CI/CD集成

Docker自动部署脚本实战:健康检查、回滚与CI/CD集成

我这两年经手过的服务部署,几乎都围绕 Docker 转。最开始图省事,直接在一台机器上手动敲 docker run;等维护到第三四个服务的时候,发现每次上线都要打开终端拼命回想“上次那条命令是什么来着”,于是就开始把整套部署流程写成了 docker 自动部署脚本。这个脚本做的事情并不复杂:给定一个版本号,它自己去拉代码、构建镜像、停掉旧容器、拉起新容器、确认服务健康,必要时还能回滚。适合的对象很广——自己做小项目的个人开发者、公司里负责运维或后端的人,甚至刚接触 Docker 的初学者,都可以通过这套思路把部署动作标准化,从“凭记忆操作”变成“跑一条命令”。

很多人提到自动化部署,第一反应是上 CI/CD 平台,但并不是所有场景都需要那么重的体系。比如公司内部的管理后台、个人服务器上的开源项目、临时搭建的演示环境,用一个结构清晰的部署脚本反而比接一套流水线更直接。这篇文章我会从为什么需要这个脚本讲起,然后拆解脚本设计思路,再给出一份可以直接落地的实现,最后把我踩过的坑和排查方法整理成速查表。整个思路不依赖某个特定平台,你完全可以拿去改造成自己的版本。

1. 自动部署脚本到底解决什么问题

1.1 手动部署的痛点与自动化的机会

手动部署 Docker 服务看起来很简单:build、run、完事。但真实环境里根本不是这样。一个正常在跑的服务,至少要处理镜像构建、容器停止、容器删除、新容器启动、端口映射、环境变量注入这些环节。手敲命令时容易出问题的点非常多,我列举几个常见场景。

比如更新版本时,你通常会执行docker stop old_container,但容器可能因为业务还在处理请求而迟迟不退;或者你执行docker rm时提示容器还在运行,又要加-f参数。又比如新版镜像构建完成后,你忘记清理旧镜像,跑上几个月,磁盘直接被一堆无名镜像塞满。更麻烦的是换一台机器部署,别人问你“这台机器怎么部署”,你只能说“你按照我上次的命令来”,结果命令少了个参数,服务起不来。

自动部署脚本的价值,不在于它用了多高深的技术,而在于它把整个部署流程变成一个可重复、可验证、可记录的动作。同一套脚本在开发环境跑一遍,在生产环境再跑一遍,结果应该一致。这样“部署”这件事就不再依赖某个人的记忆,而是变成项目的一部分,可以随代码一起做版本管理。某个同事接手后,不用反复问“当时怎么部署的”,看脚本就知道完整流程。

1.2 脚本部署与完整 CI/CD 平台的边界划分

我见过不少团队,一提到自动化就先搭一套平台,Jenkins、GitLab CI、Drone 一套下来,配置阶段就花了大量时间。但我个人的做法是:能用脚本解决的,先用脚本解决;脚本解决不了的,再考虑平台。

这里的边界其实很清晰。CI/CD 平台的核心能力是“编排”和“触发”,比如代码提交后自动拉取、自动跑测试、自动构建镜像,然后把部署任务分配到指定服务器执行。但这些平台的执行动作,最终还是落在一条条命令上。如果连具体的部署逻辑都没有,平台也只是空壳。

所以我的建议是:先写一个能独立运行的 docker 自动部署脚本,然后再决定要不要把它接进流水线。脚本负责“怎么做”,平台负责“什么时候做”。这样哪怕暂时没有平台,你也能手动跑脚本完成上线;以后接平台时,只需要在流水线里加一步“调用脚本并传入版本号”即可,迁移成本很低。

反过来,如果一个部署场景已经复杂到需要多台机器、不同环境变量矩阵、灰度发布等,那脚本再怎么写都不如平台清晰。但即便如此,脚本仍然可以作为平台的“执行器”存在。我后来接触过的几个项目,最终都是流水线负责触发,平台负责审批,真正跑到服务器上执行的依然是部署脚本。所以两者不是替代关系,而是配合关系。

2. 系统设计与脚本核心逻辑

2.1 部署流程的整体拆解

写自动部署脚本之前,先把流程画清楚。虽然我不建议在文章里堆流程图,但心里必须有一份步骤清单。我常用的部署流程是下面这一段。

  1. 准备部署目录,进入项目源码目录。
  2. 构建新镜像,同时打上版本标签和 latest 标签。
  3. 检查当前是否存在同名容器。
  4. 如果存在,则记录旧镜像标签,为回滚做准备。
  5. 停止并删除旧容器。
  6. 使用新镜像启动容器,注入端口和环境变量。
  7. 等待一段时间,调用健康检查接口确认服务正常。
  8. 如果健康检查失败,回滚到旧镜像并启动容器。
  9. 清理构建过程中产生的中间镜像和旧版本镜像。

很多人写脚本时会把第 3 步和第 4 步省略,直接docker rm -f container了事,这样确实简单,但也就失去了回滚能力。回滚的关键是保留“旧镜像标签”和“当前容器使用的环境变量”。如果把容器直接删了,旧镜像虽然还在,但你可能记不清上次用的环境变量是什么,最终回滚变成重新配置,很痛苦。

另外,这个流程必须保证幂等性。什么叫幂等?就是同一版本重复跑两次,第二次也不会报错。常见做法是在启动容器前先检查同名容器是否存在,存在就停掉删掉。如果不做这一步,第二次运行docker run --name app时,会因为容器名冲突直接失败。

2.2 项目目录结构与部署环境准备

部署脚本不是孤立的一个文件,它通常跟着一个固定的目录结构走。我习惯把不同环境的内容分开,避免生产变量被带到测试环境。

/opt/deploy/my-app/ ├── src/ │ ├── Dockerfile │ └── ...代码文件 ├── .env.dev ├── .env.prod ├── deploy.sh └── release_history.log

src目录存放项目源码和 Dockerfile,deploy.sh是部署脚本本体,.env.dev和.env.prod分别保存不同环境的配置。release_history.log用来记录每次发布的版本号、时间和结果,方便回溯。

环境变量文件里通常包含数据库地址、缓存地址、业务开关等。这些配置我不建议写死在镜像里。原因很简单:镜像一旦构建好,就不应该再因为环境不同而重新构建。同一个镜像,开发环境用它连接开发库,生产环境用它连接生产库,所以配置必须来自外部。启动容器时用--env-file把对应环境的配置注入进去,这是一个值得养成的好习惯。

部署脚本的运行环境也需要提前确认。机器上必须安装 Docker,而且执行脚本的用户需要具备 Docker 权限。如果用户没有权限,脚本里所有 docker 命令都会收到 permission denied。解决办法是把用户加入 docker 组,或者使用sudo调用。不过我建议优先修改用户权限,因为脚本中如果到处都是 sudo,后续在流水线里调用时权限问题会很难受。

2.3 版本号与镜像标签策略

镜像标签是整个部署脚本里最容易被忽略但最关键的部分。很多新手喜欢用latest,因为构建时直接docker build -t my-app:latest,跑的时候也方便。但等版本多了以后,你根本不知道机器上的 latest 是哪个时间构建的,也说不清生产环境跑的是哪一次代码。

我自己采用的方案是“日期 + 构建序号”,例如202506171034,对应 2025 年 6 月 17 日 10 点 34 分。这个版本号作为脚本第一个参数传入,构建时生成两个标签:一个是具体的版本标签,一个是 latest。这样既保证当前最新版本有统一入口,又保留了历史版本用于回滚。

保留历史版本的另一个原因是回滚。如果每次构建都把旧镜像清掉,那么当健康检查失败时,就没有旧版本可以启动。我通常会在脚本里保留最近 5 个镜像,其余清理掉。镜像数量控制在合理范围,既保证回滚窗口,又不会把磁盘塞满。

这里还有一个细节:镜像仓库地址。如果是团队协作,构建出来的镜像最好推送到私有镜像仓库,而不是只留在本地。脚本里可以加一步docker push操作。不过考虑到自动部署脚本的首要场景是单机或者少量服务器,我倾向于在脚本里只做本地构建和启动,推送动作交给 CI 平台处理。这样脚本保持轻量,也更容易维护。

3. 手把手实现一套可用的 Docker 自动部署脚本

3.1 脚本主体:构建、停旧、起新、清理

下面这个脚本,是我目前一直在用的精简版,去掉了无关的业务逻辑,保留核心结构。你直接复制到自己的项目里,按实际服务名和环境变量调整参数即可。

#!/usr/bin/env bash set -euo pipefail APP_NAME="my-app" IMAGE_NAME="registry.example.com/${APP_NAME}" WORK_DIR="/opt/deploy/${APP_NAME}" VERSION="${1:-$(date +%Y%m%d%H%M%S)}" IMAGE_TAG="${IMAGE_NAME}:${VERSION}" LATEST_TAG="${IMAGE_NAME}:latest" CONTAINER_NAME="${APP_NAME}" PORT_MAP="8080:8080" ENV_FILE="${WORK_DIR}/.env.prod" echo "=== 开始部署 ${APP_NAME} ${VERSION} ===" # 1. 进入源码目录并构建镜像 cd "${WORK_DIR}/src" docker build -t "${IMAGE_TAG}" -t "${LATEST_TAG}" . # 2. 记录旧容器信息 OLD_CONTAINER_ID=$(docker ps --filter "name=${CONTAINER_NAME}" --format '{{.ID}}') OLD_IMAGE_ID="" if [ -n "${OLD_CONTAINER_ID}" ]; then OLD_IMAGE_ID=$(docker inspect --format '{{.Image}}' "${OLD_CONTAINER_ID}") echo "发现旧容器: ${OLD_CONTAINER_ID},停止并删除" docker stop "${OLD_CONTAINER_ID}" docker rm "${OLD_CONTAINER_ID}" fi # 3. 启动新容器 docker run -d --name "${CONTAINER_NAME}" \ --restart unless-stopped \ -p "${PORT_MAP}" \ --env-file "${ENV_FILE}" \ "${IMAGE_TAG}" # 4. 基础睡眠,等待进程启动 sleep 5

这个基础版本没有加入健康检查和回滚,只解决最核心的“构建-替换-启动”问题。set -euo pipefail这一段非常重要,它让脚本在遇到未定义变量、管道错误或任意命令失败时立即退出,避免在错误状态下继续执行,这是自动部署脚本的保命符。

VERSION="${1:-$(date +%Y%m%d%H%M%S)}"表示如果没有传参,默认使用当前时间作为版本号。但在实际部署中,我建议总是显式传版本,这样日志里能对得上。

关于docker stop和docker rm,我先 stop 再 rm,而不是直接docker rm -f,是因为 stop 会给应用几秒钟的优雅退出时间,让正在处理的请求有机会完成。有些业务对清理动作有要求,如果直接强删容器,连接会瞬间断开,可能导致数据不一致。

3.2 健康检查与失败回滚机制

一个只有“构建-启动”的脚本只能算半成品。因为容器启动成功不代表服务可用,有时候环境变量写错、数据库连接不上、启动进程报错,容器照样会退出来,或者一直处于 Restarting 状态。所以必须在脚本里加入健康检查。

我在实际项目里,都会要求应用暴露一个/health接口,返回 HTTP 200 就代表服务内部关键依赖正常。然后在启动容器后,循环请求这个接口:

echo "=== 开始健康检查 ===" HEALTH_CHECK_PASSED=0 for i in $(seq 1 20); do if curl -fsS "http://127.0.0.1:${PORT_MAP%%:*}/health" >/dev/null 2>&1; then echo "健康检查通过" HEALTH_CHECK_PASSED=1 break fi echo "第 ${i} 次健康检查未通过,等待 2 秒" sleep 2 done if [ "${HEALTH_CHECK_PASSED}" -ne 1 ]; then echo "健康检查失败,执行回滚" docker stop "${CONTAINER_NAME}" docker rm "${CONTAINER_NAME}" if [ -n "${OLD_IMAGE_ID}" ]; then docker run -d --name "${CONTAINER_NAME}" \ --restart unless-stopped \ -p "${PORT_MAP}" \ --env-file "${ENV_FILE}" \ "${OLD_IMAGE_ID}" echo "已使用旧镜像回滚" else echo "不存在旧容器,无法回滚" fi exit 1 fi

健康检查这段,有几个关键点。

第一,PORT_MAP%%:*会提取8080:8080中冒号左侧的宿主机端口,用于本地访问,这样不管容器内部端口如何变化,检查的都是宿主机实际暴露的端口。

第二,检查的是业务健康,而不是单纯检查进程存在。如果脚本只检查容器状态,很容易出现“容器运行中但业务请求全部 500”的假象。

第三,回滚恢复的是旧容器,但不是清空状态。我没有把旧容器的环境变量和参数保存成文件,而是直接用OLD_IMAGE_ID和当前ENV_FILE重新创建容器。这里有个小风险:如果两次部署之间环境变量文件发生了变化,回滚时就会用到新配置,而旧配置不可用。所以我在实际部署中会额外把旧环境变量备份一份,按版本号存放在env_backup/目录下,回滚时读取对应版本的文件。更完整的做法是把旧容器启动参数通过docker inspect导出,但那样脚本会复杂很多。你先用上面的思路,逐步完善即可。

3.3 多环境配置与传参设计

如果只是单机部署,脚本里写死.env.prod也够用。但稍微正规一点的项目,都要区分测试环境和生产环境。我在脚本里增加了一个环境参数,使用./deploy.sh prod或./deploy.sh dev来指定。

ENV_NAME="${1:-prod}" VERSION="${2:-$(date +%Y%m%d%H%M%S)}" IMAGE_TAG="${IMAGE_NAME}:${VERSION}" case "${ENV_NAME}" in dev) ENV_FILE="${WORK_DIR}/.env.dev" PORT_MAP="8081:8080" ;; prod) ENV_FILE="${WORK_DIR}/.env.prod" PORT_MAP="8080:8080" ;; *) echo "未知环境: ${ENV_NAME}" exit 1 ;; esac

通过 case 语句根据环境选择不同的文件,端口映射也可以跟着变。测试环境占用的宿主机端口和生产环境错开,避免同一台机器上同时部署多个环境时产生冲突。

多环境传参设计还有一个容易被忽略的问题:敏感配置的安全。.env.prod文件里一般有数据库密码、密钥等信息,这个文件不能提交进代码仓库,也不能让所有同事都能查看。我在团队里要求生产环境配置文件的权限为 600,只有特定主机的部署用户能读。脚本运行时会读取 env-file,所以部署用户对配置文件还要有读取权限。

环境变量文件的内容格式也要注意。--env-file要求每一行是KEY=VALUE,等号前后不要有空格,值不需要加引号。如果值里有特殊字符,比如#或者空格,处理起来会比较折腾。我的建议是尽量避免在 env 文件里写复杂字符串,这也能减少安全隐患。

4. 踩坑实录:常见问题与排查技巧

4.1 常见失败场景对照表

脚本写多了以后,你会发现部署失败的原因绕来绕去就那么几类。我整理了一个对照表,基本覆盖了我遇到的大部分问题。

失败现象常见原因排查方向
构建镜像时卡住或失败基础镜像拉取失败、网络抖动确认网络可用性,检查 Dockerfile 依赖源
docker run报端口绑定失败宿主端口已被其他进程占用ss -lntp或netstat查看端口占用
容器创建后立刻退出应用启动失败、环境变量缺失、依赖服务不可达查看容器日志,docker inspect检查退出码
健康检查始终失败应用启动慢、健康检查路径不对、端口映射写错手动 curl 检查,看日志确认监听地址
旧容器删除失败容器仍处于重启中或权限不足先 stop 再 rm,必要时docker rm -f
脚本执行报 permission denied用户不在 docker 用户组将用户加入 docker 组或启用 sudo

表格里每一行背后都有实际教训。比如端口绑定失败,有一次我把某个服务的宿主机端口写成了 8080,但另一台机器上的监控服务占用了同一端口,docker run 直接失败。后来我养成了一个习惯:部署前先检查端口是否空闲,有问题立刻停止。

健康检查路径错误也很典型。有一次应用的健康检查接口实际是/actuator/health,但脚本里写的是/health,结果健康检查永远失败,导致每次部署都被误判为失败并回滚。后来我要求所有新服务必须统一健康检查路径,如果实在无法统一,就把路径作为参数传进脚本。

4.2 排查思路与实用命令

排查部署问题,我习惯按照“容器状态 → 应用日志 → 手动请求”三步走。第一步看容器处于什么状态,第二步看日志有没有明显报错,第三步在宿主机上直接请求服务接口,确认问题出在应用本身还是部署配置。

下面是几个我实际使用频率最高的命令。

# 查看所有容器,包括已退出和重启中的 docker ps -a --filter "name=my-app" # 查看容器日志,最近 200 行并持续跟踪 docker logs --tail 200 -f my-app # 查看容器启动时间和状态细节 docker inspect --format '{{.State.StartedAt}}' my-app # 查看容器退出码,退出码 0 是正常退出,非 0 需要结合日志判断 docker inspect --format '{{.State.ExitCode}}' my-app # 手工发起健康检查请求 curl -v http://127.0.0.1:8080/health

还有一个容易被忽略的命令:docker system df,用来查看镜像、容器、数据卷占用的磁盘空间。部署脚本跑久之后,磁盘空间会慢慢减少,如果不定期清理,最后可能连镜像都构建不了。我通常在脚本结尾加一条清理命令,保留最近 5 个镜像,其余旧镜像自动清理。

# 清理所有未被容器使用的镜像,保留最近 5 个版本 docker image prune -a --filter "until=48h" -f

这里要留意,--filter "until=48h"只清理 48 小时之前创建的镜像。如果服务更新频繁,旧版本可能还没到 48 小时就已经没有容器引用了,依然堆积在磁盘上。更精确的做法是根据镜像标签过滤,这个可以结合你的发布记录来实现。

4.3 两个容易忽略的细节

第一个细节是容器命名冲突。有时候你执行部署脚本,发现docker run --name my-app直接报错,说容器名已存在,但docker ps并没有看到 running 容器。这种情况通常是同名容器处于 exited 状态。脚本里如果只检查 running 容器,就会漏掉它。所以检查容器时应该使用docker ps -a --filter "name=...",而不是docker ps --filter "name=..."。我在脚本里故意把旧容器查找用docker ps而不是docker ps -a,是因为正常情况下脚本已经维护了容器状态;但如果发生了手动删容器失败或其他操作,第二版脚本最好把-a加上。

第二个细节是时区。容器默认使用 UTC,日志时间会和本地时间差 8 小时。如果你的部署脚本里用了$(date +%Y%m%d%H%M%S)生成版本号,那么凌晨部署时,你看到的版本号可能有误差。这不算致命问题,但如果你要根据日志判断部署时间,容易出现误解。我的做法是在 Dockerfile 里设置ENV TZ=Asia/Shanghai,或者在运行容器时挂载/etc/localtime,让容器内时间和宿主机保持一致。

最后的细节是脚本执行目录。很多人习惯在项目根目录直接跑./deploy.sh,但如果脚本内部用了相对路径,比如cd src,那么工作目录不对就会导致找不到 Dockerfile。我建议脚本开头的WORK_DIR写成绝对路径,并且在构建前判断目录是否存在。绝对路径虽然不灵活,但稳定可靠,适合部署这种需要确定性的场景。

5. 进阶扩展:从单机部署走向持续交付

5.1 把部署脚本挂进流水线

当你的脚本可以在服务器上稳定跑通后,下一步就是把它接进持续交付流程。我在某公司遇到过一个场景:代码提交到主分支后,希望自动构建镜像,然后自动更新测试环境;生产环境则需要人工确认后触发。这个场景完全可以在流水线里配置两个步骤,测试环境直接调用脚本,生产环境加一个确认按钮。

流水线调用部署脚本时,版本号不要再用脚本默认的时间戳,而是从流水线的构建号里取。比如某构建平台每次构建会生成一个唯一编号,把这个编号传给脚本作为 VERSION。这样构建记录和部署记录能够一一对应,回滚时也更容易定位应该回到哪个构建版本。

调用方式大概是这样的:

./deploy.sh prod "202506171034-${BUILD_ID}"

流水线里还应该设置超时时间,避免脚本因健康检查循环等太久而拖垮整个发布流程。我在脚本中健康检查最多等待 40 秒,如果超过这个时间就直接回滚并返回非零退出码。流水线看到非零退出码后,就可以停止后续步骤并把发布状态标记为失败。

还有一点很重要:流水线调用的脚本路径要固定。不要因为服务器上部署目录不同就去手动改脚本里的 WORK_DIR,而应该在流水线里用一条软链接或者统一的环境变量来管理。否则脚本在开发机器上调试正常,到生产服务器上却因为路径不一致跑不起来。

5.2 蓝绿发布与一键回滚的脚本化思路

如果你的服务是面向用户的,停机时间太长会影响体验。这时可以考虑把脚本升级成简单的蓝绿发布。思路是维护两套容器,比如my-app-blue和my-app-green,保持两个版本同时存在;通过入口路由切换到当前活跃版本。

脚本层面要怎么改?我提供一个简化思路。假设当前活跃的是蓝色容器,新版本部署时先启动绿色容器,等绿色健康检查通过后,再把入口切到绿色池;切换完成后,停掉蓝色容器作为旧版本保留。下次发布时反过来操作。

这个方案比单容器替换复杂在两点:一是要有一个路由层,比如 Nginx 或负载均衡器,能够动态切换上游;二是脚本要记录当前活跃池。我用过一个最简单的实现,就是在服务器上写一个current文件,里面保存当前活跃的容器名。脚本读取这个文件来决定新容器该用什么名字,以及健康检查通过后要切换哪个池。

蓝绿发布对资源和脚本逻辑要求更高,但它带来的好处非常明显:部署期间旧版本始终在跑,即使新版本健康检查不过,也只是停掉新容器,不影响线上流量。我建议不要一开始就追求蓝绿,先把单容器替换跑稳,再逐步演进到双容器切换。

5.3 监控、告警与日志收集的联动

自动部署脚本里已经包含健康检查,但这个检查只能确保部署当时服务是正常的,等脚本结束后,服务依然有可能在运行期间崩溃。所以我把部署脚本和监控系统做了联动。

部署脚本每次执行时产生的输出,本身就是结构化信息。我在执行关键步骤时加入一行 JSON 输出:

{"app":"my-app","version":"202506171034","action":"health_check","result":"passed"}

这些输出会被外部日志采集组件读取,汇总到日志中心。以后想查“某个版本上线时是否正常”,直接搜索对应版本号就能看到全部关键步骤。

容器本身的日志也建议做采集。最直接的方式是把容器日志目录挂载到宿主机,或者让应用把日志输出到 stdout,然后由 Docker 的 logging driver 转发到远程日志系统。部署脚本帮不了太多日志采集的活,但它可以保证每次部署都会重新创建容器,避免由于容器重建导致日志文件无限增长。

告警方面,如果健康检查失败,脚本除了回滚,还可以调用外部告警接口,把失败信息推送出去。我曾经写过一个版本:健康检查三次不过就调用企业微信机器人的 webhook,这样还没等用户反馈问题,运维同学已经收到上线失败的通知了。这些联动都能让部署脚本从“本地工具”升级成“交付体系的一环”。


维护这套脚本一段时间后,我最大的体会是:不要一开始就追求完美,先把部署流程沉淀成脚本,再持续加健康检查、加回滚、加监控。脚本不是银弹,但确实能把重复劳动压缩到最小,也能让你把精力花在更值得投入的业务问题上。如果你也是一个人在维护一堆 Docker 服务,建议动手写一版自己的部署脚本,哪怕只有 20 行,也比手敲命令更安全、更可追溯。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进