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

发布时间:2026/10/23 13:20:02
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 部署流程的整体拆解写自动部署脚本之前先把流程画清楚。虽然我不建议在文章里堆流程图但心里必须有一份步骤清单。我常用的部署流程是下面这一段。准备部署目录进入项目源码目录。构建新镜像同时打上版本标签和 latest 标签。检查当前是否存在同名容器。如果存在则记录旧镜像标签为回滚做准备。停止并删除旧容器。使用新镜像启动容器注入端口和环境变量。等待一段时间调用健康检查接口确认服务正常。如果健康检查失败回滚到旧镜像并启动容器。清理构建过程中产生的中间镜像和旧版本镜像。很多人写脚本时会把第 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.logsrc目录存放项目源码和 Dockerfiledeploy.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_NAMEmy-app IMAGE_NAMEregistry.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_MAP8080: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_PASSED0 for i in $(seq 1 20); do if curl -fsS http://127.0.0.1:${PORT_MAP%%:*}/health /dev/null 21; then echo 健康检查通过 HEALTH_CHECK_PASSED1 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_MAP8081:8080 ;; prod) ENV_FILE${WORK_DIR}/.env.prod PORT_MAP8080:8080 ;; *) echo 未知环境: ${ENV_NAME} exit 1 ;; esac通过 case 语句根据环境选择不同的文件端口映射也可以跟着变。测试环境占用的宿主机端口和生产环境错开避免同一台机器上同时部署多个环境时产生冲突。多环境传参设计还有一个容易被忽略的问题敏感配置的安全。.env.prod文件里一般有数据库密码、密钥等信息这个文件不能提交进代码仓库也不能让所有同事都能查看。我在团队里要求生产环境配置文件的权限为 600只有特定主机的部署用户能读。脚本运行时会读取 env-file所以部署用户对配置文件还要有读取权限。环境变量文件的内容格式也要注意。--env-file要求每一行是KEYVALUE等号前后不要有空格值不需要加引号。如果值里有特殊字符比如#或者空格处理起来会比较折腾。我的建议是尽量避免在 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 namemy-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 until48h -f这里要留意--filter until48h只清理 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 TZAsia/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 行也比手敲命令更安全、更可追溯。