先讲一个真实的场景你负责维护一个由十几个微服务组成的系统每个服务都有自己的 Dockerfile 和配置发布的时候要先把镜像一个个构建、推送到私有仓库再登录几台服务器去拉镜像、停旧容器、起新容器。听起来不难但只要连续发布过三轮你就会发现这里面全是重复动作还特别容易因为手滑搞出发布事故。我就是从这个状态里把自己逼到了“必须用 Shell 驱动 Docker 自动化”这条路上——容器启停、镜像构建、批量部署这些最高频的操作全部从“人工逐条执行”变成了“一个脚本搞定”。这一篇不是讲什么高深理论而是把我实际在用、随手能改的脚本模板、设计思路和踩过的坑原原本本整理出来。如果你正在用 Docker 做日常开发或部署已经会一点 Shell 基础语法但还没把两者串成一套自动化流程那这篇内容应该可以直接拿来用。1. 为什么用 Shell 写 Docker 自动化先把思路理顺1.1 从“手动敲命令”到“脚本化”的转折点我见过太多团队把 Docker 当成一个简单的启动器docker run -d -p 8080:8080 myapp敲下去能跑就行。等容器数量从两三个变成二三十个问题就全冒出来了——你根本记不住哪个容器挂在哪台机器上、哪个镜像对应哪个版本。手动运维最典型的状态是一条命令从历史记录里复制出来把容器名改一改就执行结果要么版本号打错要么镜像没推送就远程去拉。连续发布几次你就会开始怀疑“刚才到底有没有把端口映射带上”。这个阶段其实已经不用纠结“要不要自动化”了真正的问题是“用什么方式自动化”。Shell 在这里是最不引人注意、但最容易立刻见效的方案。因为它不引入任何新依赖机器上天然有 bash和 Docker CLI 的组合方式也最直观。你可以一条条命令调试调通了再把它们固化成一个脚本这个过程今天就能做不用搭建任何平台。1.2 Shell 的边界什么场景用它最合适我得先泼一点冷水Shell Docker 的自动化方案不是万能的。你不需要用它去替代 Ansible、Kubernetes 这类面向大规模集群的工具也最好不要用它去编排一个有几十个节点、需要复杂滚动策略和审计能力的生产环境。真到了那种规模玩法完全不同。但这个方案的甜区也足够大几台到二十台机器、几十个容器、没有复杂调度需求的环境。在这种规模下引入 Ansible 反而显得重——写 playbook、维护 inventory、准备控制节点学习成本一下子上去。而 Shell 脚本的透明性和轻量是压倒性优势它就是一个文本文件打开就能看到全部逻辑任何人一行行读都能看懂出了问题也能按顺序排查。我的选择思路很简单能用纯 Docker CLI 完成的事就用 Shell 把它串成一个命令跨主机、有状态、需要自愈能力的部分才考虑更重的工具。这样既没有把简单问题复杂化又解决掉了最让人头疼的重复劳动。另外如果你还在管理单个项目的多服务编排docker compose本来就是首选这点下面会提到。2. 容器启停自动化从“一键停”到“优雅撤”2.1 一个可直接抄的启停脚本模板先给一个我自己一直沿用的基础模板它负责单个容器的启动、停止、重启、状态查看。你不需要改动太多把容器名作为参数传进去就能用。#!/bin/bash # ctl.sh - 容器启停管理脚本 set -eu NAME${1:?请指定容器名} ACTION${2:?请指定动作start|stop|restart|status} start_container() { if docker start $NAME /dev/null 21; then echo [$(date %F %T)] 容器 $NAME 启动成功 else echo [$(date %F %T)] 容器 $NAME 启动失败 2 exit 1 fi } stop_container() { echo [$(date %F %T)] 正在停止容器 $NAME docker stop --time 30 $NAME } status_container() { local state state$(docker inspect --format {{.State.Status}} $NAME 2/dev/null || echo missing) echo $NAME 状态: $state if [ $state running ]; then docker inspect --format 启动时间: {{.State.StartedAt}} $NAME fi } case $ACTION in start) start_container ;; stop) stop_container ;; restart) stop_container; docker start $NAME ;; status) status_container ;; *) echo 用法: $0 容器名 {start|stop|restart|status} 2 exit 1 ;; esac调用方式就是./ctl.sh web-app restart这样。几个细节我解释一下。set -eu是必须的不然脚本里最后一条命令的退出码才有效很容易“看起来成功其实失败”。但要注意用了-e之后case分支里如果某个函数返回非零脚本会直接退出——对裸命令来说这反而是好事失败就停下来不要继续往下跑。--time 30是我刻意加的不是随手写的这个值背后的原因在下一小节细说。还有状态查看里用了一行docker inspect --format这比docker ps加 grep 更稳定也方便脚本里做条件判断。2.2 停止容器的正确姿势留意超时与资源清理很多人习惯一条docker rm -f把容器干掉我一开始也这么干后来在线上栽过跟头才改掉这个毛病。docker rm -f的实质是强制停止并删除容器等于对主进程直接下杀手。你的应用如果在退出时需要做清理——保存内存里的状态、关闭数据库连接池、向注册中心反注册、清理临时文件——这一套动作根本没机会执行。我遇到过一个真实的例子Java 服务在停止时要从注册中心摘除节点结果因为用了rm -f服务进程直接没了网关那边旧节点信息还会维持一段时间流量持续打到已经不存在的实例上报错一片。所以现在我的脚本里正常流程一律用docker stop。它先给容器主进程发 SIGTERM等应用自己优雅退出超时之后才发 SIGKILL 强杀。--time 30指的就是等待优雅退出的宽限时间Docker 默认是 10 秒但对很多业务应用来说不够。数据库连接池要关闭几十个连接注册中心反注册要等网络请求超时30 秒更稳妥。遇到特别重的应用我会单独给到 60 秒。那rm -f是不是完全不用也不是。容器已经卡死、无状态任务、或者端口被占急需释放的情况它就很好用。还有一点不要随手在docker rm后面加-v。-v会连容器挂载的匿名卷一起删掉如果数据卷里存了数据库文件一次误操作就是事故。命名数据卷或 bind mount 不受影响但匿名卷说没就没这一点同行里知道的人不少踩坑的也不少。2.3 等待就绪别让脚本被“已启动”骗了容器状态变成running不代表你的服务真的能对外提供服务了。进程起来了、端口监听了和应用完成初始化完全是两码事。我见过有同事启动容器后直接sleep 10再跑用例结果那次应用恰好要 15 秒才能准备好用例全挂在连接超时上。更好的做法是轮询等待并且要等的是一个“服务可用”的信号而不是容器状态。最朴素也最实用的方式就是用nc探测端口或者用curl请求 health 接口。# wait_ready.sh - 等待容器端口就绪 wait_ready() { local host$1 local port$2 local attempts${3:-30} for i in $(seq 1 $attempts); do if nc -z $host $port /dev/null 21; then echo 服务 $host:$port 已就绪等待了 $i 秒 return 0 fi sleep 1 done echo 服务 $host:$port 在 ${attempts} 秒内未就绪 2 return 1 } wait_ready 127.0.0.1 8080如果你的服务有健康检查接口把nc -z换成curl -fsS http://127.0.0.1:8080/healthz更可靠因为端口能连上但接口返回 500 也算不上就绪。用这个等待逻辑替换掉无脑sleep整个自动化脚本才真正变得可以依赖发布时也能根据加载时间自动调整节奏不会出现“服务还在启动就得往下走”的尴尬。3. 镜像构建自动化打好标签、管好缓存3.1 构建脚本的核心框架镜像构建在自动化里其实是最容易做好的一块因为 Docker CLI 本身就是为脚本设计的。我用的构建脚本大概是这个结构#!/bin/bash # build.sh - 镜像构建并推送 set -eu APP_NAME${1:?用法: $0 项目名 [环境名]} ENV${2:-dev} # 自动生成带日期和 commit 的标签 DATE_TAG$(date %Y%m%d%H%M) COMMIT_HASH$(git rev-parse --short HEAD 2/dev/null || echo unknown) TAG${ENV}-${DATE_TAG}-${COMMIT_HASH} IMAGEregistry.example.com/${APP_NAME}:${TAG} if [ ! -f Dockerfile ]; then echo 当前目录下没有 Dockerfile请检查运行目录 2 exit 1 fi echo 开始构建 $IMAGE docker build --pull -t $IMAGE . echo 推送到仓库 docker push $IMAGE echo 构建完成 echo $IMAGE last_build_image.txt这里我用了--pull意思是构建前先拉取最新基础镜像防止基础镜像长期不更新导致安全漏洞。代价是构建时间会变长一点如果内网有镜像缓存其实并没什么影响。把最新的镜像全名写入last_build_image.txt这个动作是后来补的很有用。后续批量部署脚本可以直接从这个文件里读目标镜像名避免在多个终端之间复制粘贴版本号。另外补充一个 Git Bash/Windows 场景里的细节如果你脚本里用了git rev-parse但没有在项目目录里执行脚本commit 号会取不到所以需要cd到项目根目录再跑或者脚本内部做cd $(dirname $0)/..这种定位。这个我踩过后面会再提。3.2 多环境镜像标签与上下文目录的坑镜像标签这事看起来简单用不好会特别乱。我见过有人天天打v1、v2最后根本分不清v2对应的是代码里哪个提交回滚都不知道滚到哪。我的标签规则很简单环境-日期时间-短commit例如prod-202501151430-a3f9c2d。环境是dev、prod这种日期时间精确到分钟保证了唯一性commit 把代码版本钉死。这样任意一个镜像你都能从标签里读出它属于哪个环境、什么时候构建的、对应哪个提交排查问题效率高很多。还有一点不要把latest当成随手上传的垃圾桶标签。我会在正式环境部署完成、并且验证通过之后才单独给镜像补一个latest标签。这样开发机上拉latest永远能得到最新的稳定版本而不是某个构建到一半的残次品。接下来是很多人会忽略的 context构建上下文问题。docker build后面跟的.并不只是“当前目录”的意思而是把当前目录整个打包发给 Docker 守护进程让它在那个上下文里找 Dockerfile 和做 COPY。如果你的项目目录里有node_modules、target、.git又没有.dockerignore每一次构建都要把这几个大目录整个传一遍慢不说还可能触发大文件扫描的奇怪问题。我在每个项目里都会放一个.dockerignore和.gitignore一个套路node_modules target build .git *.log .DS_Store这一行小小的文件往往能让构建速度提升一倍以上。构建上下文变小也意味着即使构建机性能一般脚本的稳定性也会好很多。3.3 构建缓存优化与历史镜像清理镜像构建本质上依赖 Docker 的层缓存。理论上Dockerfile 里每一行指令只要输入不变就可以复用缓存层。但很多人的 Dockerfile 习惯不好导致缓存命中率极低。最常见的反例先COPY . .把全部代码拷进去再执行npm install或mvn package。只要代码里任何文件有变化COPY 层的缓存就失效了后面的依赖安装全部要重来。顺序应该是先只复制依赖清单文件把依赖装好再复制源码。比如 Node 项目FROM node:18 WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . CMD [npm, run, start]这样代码再变只要依赖清单没变npm install那一层的缓存就能命中构建时间从几分钟压缩到几十秒。这是我优化构建脚本后收益最明显的一件事。镜像越攒越多也是个隐患。发布一年的系统本地可能堆着几十个没用的历史镜像每个几百 MB磁盘很快就满了。我一般会在 CI 或定时任务里做清扫用 Docker 自带的过滤条件做半自动清理# 清理悬空镜像无标签且不被任何容器引用 docker image prune -f # 清理超过24小时的临时镜像 docker image prune -a -f --filter until24h注意第二行里的-a会删除所有未被使用且匹配过滤条件的镜像包括一些你可能想保留的旧版本所以这种命令要加双重确认最好只在自动化环境里配合白名单标签策略使用。4. 批量部署自动化一条命令搞定多主机发布4.1 批量部署脚本的整体设计容器启停和镜像构建都解决了剩下最关键的就是批量部署。这部分的痛点和前两个不一样多台服务器、每个环境不同、步骤长、一旦中途出错影响面大。我的做法是分成两个角色本机的“主控脚本”和发送到目标机器上的“远程执行脚本”。主控脚本长这样#!/bin/bash # deploy.sh - 批量部署入口 set -eu IMAGE_NAME${1:?用法: $0 镜像全名 [服务器清单]} SERVERS_FILE${2:-./servers.txt} if [ ! -f $SERVERS_FILE ]; then echo 服务器清单文件不存在: $SERVERS_FILE 2 exit 1 fi while IFS read -r HOST || [ -n $HOST ]; do # 跳过注释行和空行 case $HOST in \#*|) continue ;; esac echo 部署到 $HOST scp ./remote_update.sh deploy${HOST}:/tmp/remote_update.sh /dev/null ssh deploy${HOST} bash /tmp/remote_update.sh $IMAGE_NAME if [ $? -eq 0 ]; then echo $HOST 部署成功 else echo $HOST 部署失败请立即检查 2 exit 1 fi done $SERVERS_FILE远程脚本remote_update.sh是真正干活的#!/bin/bash # remote_update.sh - 在目标服务器上执行 set -eu IMAGE_NAME$1 CONTAINER_NAME${APP_NAME:-app} docker pull $IMAGE_NAME # 停旧容器等优雅退出 docker stop --time 30 $CONTAINER_NAME 2/dev/null || true docker rm $CONTAINER_NAME 2/dev/null || true # 启动新容器 docker run -d \ --name $CONTAINER_NAME \ --restartunless-stopped \ -p 8080:8080 \ $IMAGE_NAME这里有几个刻意做的设计。第一服务器清单放在独立文件里用servers.txt管理脚本本身不带任何服务器地址这样换环境不用改脚本。第二读取文件用了while IFS read -r HOST并且支持#注释行跳过比直接for HOST in $(cat ...)好用得多——后者遇到空行会报错遇到空格会切断而且读大文件时的行为也有差异。第三远程脚本里的|| true不能少第一次部署时容器根本不存在docker stop和docker rm都会返回非零如果没有|| trueset -e会让脚本直接中断新容器永远起不来。还有 SSH 前置条件脚本里用的免密登录必须在部署机上先配好 SSH 公钥放到目标服务器的deploy用户下。不要在自动部署脚本里出现密码既没法审计又不安全。4.2 滚动更新与快速回滚的实现思路上面那句“先停旧再启新”会让服务有几秒的中断很多业务场景能接受但也有一些不能接受。真想做到平滑切换最简单可行的方案是双端口 反向代理。思路是把新容器先用新端口跑起来比如旧容器用 8080新容器用 8081等新容器的健康检查通过后再切换 Nginx 上游端口最后停掉旧容器。流程串一下就是拉新镜像docker run -d -p 8081:8080启动新容器等待 8081 端口健康检查通过修改 Nginx 配置上游地址从 8080 切到 8081nginx -s reload停掉并删除旧容器这套逻辑用 Shell 写也不复杂核心是加了一个健康检查步骤。不要省这一步直接切流量过去然后发现新容器端口没起来那比慢几秒更糟。回滚同样重要。我在部署脚本里会保留上一版镜像的标签并且不会把旧容器立刻删掉而是重命名保留一段时间# 停旧容器前先备份名字 docker rename $CONTAINER_NAME ${CONTAINER_NAME}_old_$(date %Y%m%d%H%M%S) 2/dev/null || true # 如果新容器启动后健康检查失败可以快速切回 docker start $OLD_CONTAINER_NAME这个备份容器不需要留太久确认稳定后手动清理即可。但至少出事的时候你不用再重新构建旧代码、重新拉旧镜像只需一条重命名命令把旧容器翻出来就能恢复到几分钟前的状态。我在一次发布事故里就是靠这个把恢复时间从半小时压到了两分钟。4.3 用简单配置表管理多个服务与主机单个脚本部署单个服务没什么但你要是维护十几个服务每个服务都要部署到不同机器就会需要一点工程化手段。我的做法是维护一套极简的配置文件目录deploy/ ├── deploy.sh ├── remote_update.sh ├── servers.txt └── services.confservers.txt就是目标主机清单每行一个 IP 或域名#开头表示注释10.0.0.8 10.0.0.9 10.0.0.10services.conf用来描述服务与端口映射#!/bin/bash # services.conf APP_NAMEweb-api HOST_PORT8080 CONTAINER_PORT8080然后我可以在主部署脚本里用source ./services.conf把配置引进来就有了一个针对所有服务的通用部署流程。批量构建时也可以用一个简单的循环把十几个服务的镜像一次打全#!/bin/bash # build_all.sh set -eu TAG$(date %Y%m%d%H%M)-$(git rev-parse --short HEAD) for dir in ./services/*/; do svc$(basename $dir) echo 构建服务 $svc (cd $dir docker build -t registry.example.com/$svc:$TAG .) done这不复杂但它把“逐个进入目录、手动打标签、手动推送”的整套流程压缩成了一行。配合上一节的部署脚本整个发布会变成两个命令先./build_all.sh再./deploy.sh 镜像名。自动化一旦跑通你会有一种很强烈的爽感因为再也不用在终端里来回切换、复制粘贴了。5. 常见问题与排障手记5.1 高频报错与解决速查表脚本写得多了各类报错基本都见过。我整理了一张速查表遇到类似问题可以直接对号入座。错误现象常见原因解决办法Cannot connect to the Docker daemon at unix:///var/run/docker.sockDocker 服务没启动Linux 上执行systemctl start dockerWindows/Mac 上启动 Docker Desktopdocker: permission denied当前用户不在 docker 用户组sudo usermod -aG docker $USER后重新登录或临时加 sudo/bin/sh: 1: source: not found脚本被sh执行source是 bash 内建改bash script.sh或在脚本开头加#!/bin/bash并用./script.sh执行The input device is not a TTY在脚本或管道里使用docker exec -it去掉-t只保留-i需要交互终端时才用-itbash: syntax error: unexpected end of file脚本里if/fi、case/esac、for/done未配对或 Windows CRLF 换行符问题检查结构匹配在 VSCode 右下角把换行符改成 LF或用dos2unix转换构建时TLS handshake timeout网络到镜像仓库不稳定配置镜像加速源或检查本机网络docker pull一直卡住不动镜像仓库连接异常或仓库地址写错先docker pull 镜像名在命令行单独试确认仓库地址可访问这些报错看着零碎但每一条都可能卡住自动化脚本一整天。我自己的原则是脚本里任何一条命令都要问一句“如果它会失败失败信息能不能直接告诉我原因”。所以能加2报错信息就加能写成函数就写成函数宁愿脚本长一点也不要排查时全靠猜。5.2 Windows 下跑 Shell Docker 的差异如果你的开发机是 Windows又不想把全部自动化搬到 Linux 虚拟机里这里有几点实际经验。最大的坑是换行符。Windows 记事本甚至 VSCode 默认可能保存为 CRLF 换行bash 脚本执行时就会出现各种诡异报错比如命令带了个看不见的\r在末尾导致command not found。解决办法是统一设置 VSCode 右下角“选择换行符号”为 LF写脚本时大家都按这个约定来。第二点Windows 下的 Docker 自动化建议配合 WSL2 使用。在 WSL2 的 Ubuntu 里跑 Shell 脚本然后让脚本调用 Windows 侧 Docker Desktop 的 Docker daemon这套组合是真实可用的而且兼容性比在 Git Bash 里跑脚本好很多。Git Bash 里我遇到过路径转换问题传/c/Users/xxx/project给 Docker 挂载时Docker CLI 在某些环境并不认这种路径会给你搞出各种挂载失败。第三Docker Desktop 一定要先启动否则所有脚本都会卡在Cannot connect to the Docker daemon上。这看起来是废话但定时任务、开机自动部署这类场景里真的会反复踩中。写脚本的人顺手执行没事换一个机器或换一个计划任务执行环境没起来整个自动化就哑了。5.3 几个让我后悔没早知道的脚本习惯最后分享几个写脚本时才体会到的维护技巧纯个人经验但都很实用。第一个是日志格式统一。一开始我写的脚本到处是echo有带时间的、有不带时间的、有写进2的也有全混在标准输出里的。出问题的时候翻日志非常痛苦。后来我抽了一个小函数log_info() { echo [$(date %F %T)] [INFO] $*; } log_error() { echo [$(date %F %T)] [ERROR] $* 2; }所有脚本统一用这两个函数输出日志一干净排障速度立刻上来。第二个是养成用trap清理临时文件的习惯。有的脚本执行到一半失败退出/tmp里留着上次的临时文件下回再跑就可能覆盖或冲突。我在脚本开头统一做TMP_DIR$(mktemp -d) trap rm -rf $TMP_DIR EXIT这样无论脚本正常结束还是中途崩溃临时目录都会被清掉。第三个是别怕让脚本变长但要怕逻辑藏着掖着。脚本不是越短越好重点是可读、可改、可排障。我见过有人把所有逻辑压成一行管道看起来很酷出问题完全没法定位。与其那样不如拆成清晰的函数和步骤每个关键阶段都输出一行日志。第四个在需要解析复杂命令行参数时别再一个一个$1 $2硬数了用shift或者getopts都好。比如部署脚本如果要支持--env、--skip-build这种参数getopts是 Shell 内置的标准做法排错和扩展都比位置参数清晰得多。热词里也看到有人在找shift命令的用法它其实最适合的场景就是“逐项吞掉参数”的动态解析。这套 Shell Docker 的自动化方案我现在维护起来一点也不费劲。回到开头那句话最明显的感受就是上线从一个小时的紧张操作变成了一条命令加一段进度日志的例行公事。如果你也是被重复发布折磨的人我建议你从最小的点开始——先把启停脚本写出来再把构建脚本接上去最后把批量部署串起来。三个环节打通之后你大概也会和我一样觉得以前手动敲命令的日子真是太亏了。