一份 Bash 脚本跑完 Docker CI/CD:build、deploy、健康检查与失败自动回滚
每次帮小团队架 CI/CD 都会卡在同一个地方:到底要不要上 Jenkins?Jenkins 带来的不是部署,而是另一台需要被部署的服务——Master 要管、Agent 要管、Plugin 升级要怕、JVM OOM 要救。GitLab Runner 相对轻量,但要绑 GitLab,要么自己搭 Runner,运维成本一样不低。Drone、Tekton、Argo Workflows 各有强项,但对只有三五个工程师、十来个容器服务的环境来说,整套 CI 系统的运维成本可能比业务本身还高。
很多团队「上 Jenkins」这件事本身就是个假动作。CI server 跑了,但里面只有两三个 job,job 内容大半是 ssh prod-host 'docker pull && docker restart'。这时 Jenkins 其实没有提供 CI 该有的价值,反而多了一个会坏、会被攻击、会占内存的服务。
所以这篇要做的事很单纯:用一份 bash 脚本,把「build → push → deploy → health check → 失败自动 rollback」这条链路串起来,顺便记录每次跑的状态、统计成功率、留 alert log。没有数据库、没有额外 daemon,只有 bash 加 docker,脚本本身就是 state machine、就是调度器、就是 audit trail。整篇逻辑按照「为什么这样设计 → 怎么实现 → 边界在哪」铺开。
一、什么场景适合纯 Shell CI/CD
适合:
- 单机或少几台 VM 部署:服务跑在一两台 host 上,没有大规模 K8s cluster
- 服务数量在 10~30 个容器以内:再多就该上 K8s + GitOps
- release cadence 不高:一天部署 1~5 次,不是 50 次
- 没有专职 SRE:写 Jenkinsfile 跟运维 Master 的是同一个 backend
- 要一条 hotfix 通道:产线出包要快速 rollback,不能等 CI queue
不适合:
- 多人同时推不同分支、要 PR 触发不同 pipeline
- 需要矩阵测试(多版本 Python、多 OS)
- 跨 cluster、多 region 同步部署
- 严格的 audit / approval 流程(要 SOX、ISO)
一句话:规模没到、自动化收益小于运维成本,就是 shell 的主场。
二、整体流程设计
整条 pipeline 拆成五个阶段,每个阶段都会落 log,失败有重试或回滚路径。四个关键设计原则:
- 每阶段都产生一笔结构化记录(管线 ID、阶段、服务、状态、时间戳),事后可以用 awk / grep 查
- 旧版本镜像不立刻删,部署前先记下来,rollback 直接拿来启动
- 健康检查要主动轮询,不要
sleep 30 && echo done - 失败不沉默,至少要写一笔 alert log,最好带 webhook
三、关键设计
3.1 健康检查的判定
陷阱:docker run -d 之后直接 echo "deploy done"。容器启动 = 程序跑起来 = 服务可用,这三件事不能画等号。常见死法:容器 status 是 running,但 app 还在 init、port 还没 listen;App 启动成功但连不到 DB,每隔 5 秒 panic 一次 → restart loop;健康检查端点本身回 200,但下游依赖挂了。
判定分两层:
# 第一层:容器层级
status=$(docker inspect --format '{{.State.Status}}' "$cname")
# 第二层:应用层级(Dockerfile 有 HEALTHCHECK 才有值)
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cname")
判定规则:
running + healthy→ 通过running + none→ 通过(无 HEALTHCHECK 就退而求其次)running + starting→ 继续等running + unhealthy→ 失败,回滚exited→ 失败,回滚dead→ 失败,回滚
timeout 一定要设。默认 120 秒、每 5 秒轮一次,超时就当失败。否则一个卡住的容器会把整条 pipeline 锁死。timeout 应根据服务类型分层——纯 web service 30 秒就够、跑 migration 的后端服务可能要 5 分钟、有大量 cache warm-up 的应用甚至需要 10 分钟。把 HEALTH_TIMEOUT 开成可覆写的环境变量。
3.2 失败自动回滚
回滚的前提是「有东西可以滚回去」。最简单:在新版部署之前,把旧容器目前用的镜像 tag 记下来:
old_image=$(docker inspect --format '{{.Config.Image}}' "$cname" 2>/dev/null || echo "")
部署如果失败(docker run 直接 fail,或健康检查超时),用这个 old_image 重启一份。两个前提:
- 旧镜像不能被 prune 掉:
docker image prune -af是 rollback 杀手,要么改用prune --filter "until=72h",要么用MAX_VERSIONS控制保留数量 - 启动参数要持久化:rollback 时不能只有 image name,还要记得当初是用什么
-p、-v、-e启的。把这些参数存到 sidecar 文件(例如/var/lib/docker-cicd/myapp_prod.run),部署和 rollback 都读同一份
第二点是精髓——把容器启动参数当成配置文件管理,而不是写死在脚本里。换版本、换环境、换主机都不用改 deploy 脚本本身。这份 .run 文件最好也纳入 git 管理,这样「谁、什么时候、改了哪个容器的什么启动参数」能用 git blame 追溯。
3.3 部署状态记录
state 要写到文件系统,不是内存。每笔记录:
pipeline_id | stage | service | tag | env | status | timestamp | extra
pipeline_20260428_120355_8421 | DEPLOY | myapp | v1.3 | prod | SUCCESS | 2026-04-28 12:03:55 | old=myapp:v1.2
用 pipe 分隔、append-only 写入,事后查数据就是 grep + awk + cut。三类记录分文件:pipelines.log(build/push)、deploys.log(deploy,含旧镜像)、rollbacks.log(自动/手动回滚)。优点:没有 schema migration 问题、可以直接喂给 Grafana Loki / Promtail。
两个小技巧:append 写入用 >> 不要用 tee -a;多进程并发写同一份 log 时加 flock:
(
flock -x 9
echo "${id}|DEPLOY|${service}|...|$(ts)" >&9
) 9>> "$DEPLOY_LOG"
3.4 告警通知
最低限度写一份 alerts.log:
write_alert() { echo "[$(date '+%F %T')] [$1] $2" >> "$ALERT_LOG"; }
write_alert "ERROR" "健康检查失败:${cname}"
进阶:write_alert 同时打 webhook:
write_alert() {
local level="$1" msg="$2"
echo "[$(date '+%F %T')] [$level] $msg" >> "$ALERT_LOG"
[ -n "${WEBHOOK_URL:-}" ] && \
curl -fsS -m 5 -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"level\":\"$level\",\"msg\":\"$msg\",\"host\":\"$(hostname)\"}" \
> /dev/null 2>&1 || true
}
-m 5 是 timeout,|| true 确保 webhook 挂掉不会把整条 pipeline 拖死。告警是 best-effort,不能是 critical path。
四、最小可用实现
把核心三个动作抽成最精简版本,每个函数约 10~20 行。
4.1 build 阶段
#!/usr/bin/env bash
set -euo pipefail
DATA_DIR="/var/lib/docker-cicd"
PIPELINE_LOG="${DATA_DIR}/pipelines.log"
mkdir -p "$DATA_DIR"
ts() { date '+%Y-%m-%d %H:%M:%S'; }
pid() { echo "p_$(date +%Y%m%d_%H%M%S)_$$"; }
cmd_build() {
local service="$1" tag="${2:-latest}" dockerfile="${3:-Dockerfile}" ctx="${4:-.}"
local image="${service}:${tag}"
local id; id=$(pid)
local t0; t0=$(date +%s)
echo "${id}|BUILD|${service}|${tag}|START|$(ts)" >> "$PIPELINE_LOG"
if docker build -t "$image" -f "$dockerfile" "$ctx"; then
local dur=$(( $(date +%s) - t0 ))
echo "${id}|BUILD|${service}|${tag}|SUCCESS|$(ts)|${dur}s" >> "$PIPELINE_LOG"
return 0
else
echo "${id}|BUILD|${service}|${tag}|FAILED|$(ts)|-" >> "$PIPELINE_LOG"
return 1
fi
}
刻意设计:set -euo pipefail 是最低标配;pipeline_id 内含 PID,并发跑不会撞 ID;同一 pipeline 写两次(START / SUCCESS or FAILED),事后可算每阶段耗时。
4.2 deploy 阶段
DEPLOY_LOG="${DATA_DIR}/deploys.log"
HEALTH_TIMEOUT=120
HEALTH_INTERVAL=5
wait_healthy() {
local cname="$1" elapsed=0
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
local s h
s=$(docker inspect --format '{{.State.Status}}' "$cname" 2>/dev/null || echo "unknown")
h=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cname" 2>/dev/null || echo "none")
case "$s/$h" in
running/healthy|running/none) return 0 ;;
running/unhealthy|exited/*|dead/*) return 1 ;;
esac
sleep $HEALTH_INTERVAL
elapsed=$(( elapsed + HEALTH_INTERVAL ))
done
return 1
}
cmd_deploy() {
local service="$1" tag="${2:-latest}" env="${3:-prod}"
local image="${service}:${tag}"
local cname="${service}_${env}"
local id; id=$(pid)
local old_image
old_image=$(docker inspect --format '{{.Config.Image}}' "$cname" 2>/dev/null || echo "")
echo "${id}|DEPLOY|${service}|${tag}|${env}|START|$(ts)|${old_image}" >> "$DEPLOY_LOG"
docker stop "$cname" 2>/dev/null || true
docker rm "$cname" 2>/dev/null || true
local run_args=""
[ -f "${DATA_DIR}/${cname}.run" ] && run_args=$(cat "${DATA_DIR}/${cname}.run")
if ! eval "docker run -d --name ${cname} ${run_args} ${image}"; then
echo "${id}|DEPLOY|${service}|${tag}|${env}|FAILED|$(ts)|${old_image}" >> "$DEPLOY_LOG"
[ -n "$old_image" ] && _rollback "$cname" "$old_image" "$id"
return 1
fi
if wait_healthy "$cname"; then
echo "${id}|DEPLOY|${service}|${tag}|${env}|SUCCESS|$(ts)|${old_image}" >> "$DEPLOY_LOG"
return 0
else
echo "${id}|DEPLOY|${service}|${tag}|${env}|UNHEALTHY|$(ts)|${old_image}" >> "$DEPLOY_LOG"
[ -n "$old_image" ] && _rollback "$cname" "$old_image" "$id"
return 1
fi
}
run_args 那段是精华——启动参数从外部文件读,脚本本身对「容器要怎么跑」一无所知,纯粹是 orchestrator。
4.3 rollback 阶段
ROLLBACK_LOG="${DATA_DIR}/rollbacks.log"
_rollback() {
local cname="$1" target_image="$2" id="$3"
docker stop "$cname" 2>/dev/null || true
docker rm "$cname" 2>/dev/null || true
local run_args=""
[ -f "${DATA_DIR}/${cname}.run" ] && run_args=$(cat "${DATA_DIR}/${cname}.run")
local result="FAILED"
if eval "docker run -d --name ${cname} ${run_args} ${target_image}"; then
result="SUCCESS"
fi
echo "${id}|ROLLBACK|${cname}|${target_image}|AUTO|${result}|$(ts)" >> "$ROLLBACK_LOG"
[ "$result" = "SUCCESS" ]
}
cmd_rollback_manual() {
local cname="$1"
local i=0
declare -a versions=()
while IFS='|' read -r _ stage svc tag _ status t _; do
[ "$stage" = "DEPLOY" ] && [ "$status" = "SUCCESS" ] || continue
i=$(( i + 1 ))
versions+=("${svc}:${tag}")
printf " %d. [%s] %s:%s\n" "$i" "$t" "$svc" "$tag"
done `:容器刚启动瞬间问状态会拿到空字符串,要包 `|| echo "unknown"`
- `eval` 是地雷:启动参数从文件读进来再 eval,如果文件被别人塞坏可以拿到 root shell。要么只允许特定用户写该目录,要么改用 xargs / array 展开
- log 文件会无限长:append-only 一年下来几百 MB,要排 logrotate
- 磁盘满了 deploy 不会失败、会挂在 pull image:脚本一开头加 df 检查,剩余空间 < 5GB 直接 abort
- 时区陷阱:log 都用 `date '+%F %T'`,如果跨主机比对就要强制 `TZ=UTC`
- 健康检查不要打到 `/`:很多 framework 默认首页 200,但服务本体挂了。写一个专门的 `/healthz` 端点,顺便检查 DB 连接
- 不要在 deploy 中途清旧镜像:`cleanup_old_images` 必须在「健康检查通过 + 标记 SUCCESS」之后才跑
- 环境变量不要写进启动参数文件:`.run` 文件可能进 git,把密码写进去就完了。改用 `--env-file /etc/secrets/myapp.env`
- `docker stop` 默认 10 秒:服务若 graceful shutdown 需要更久,改用 `docker stop -t 30`
最大限制是没有 UI。要看 pipeline 状态只能 tail log 或 stats 命令。部署频率高、stakeholder 多时这限制会很恼人——通常那也就是该升级到正规 CI 的信号。想看到一点可视化,最低成本:把 log 用 awk 转成 JSON 丢给 Grafana:`deploys.log → Promtail → Loki → Grafana dashboard`,不用动到 deploy 脚本本身。
## 八、适合与不适合
适合:少数机器、少量服务的 staging / 内部工具部署;没有外部 audit 要求的中小型团队;想在现有 CI 之外加一层「机器侧」部署层;学习 CI/CD 概念。
不适合:多人协作频繁 merge 的单体 repo;部署频率每天 30+ 次;严格的权限分离(谁能 deploy prod);要做 canary / blue-green / 流量切分。
## 九、小结
写一份 bash 脚本管 CI/CD 不是回到石器时代,是承认并非每个项目都需要正规 CI。纯 shell 的价值在于把 CI/CD 的本质暴露出来:build 是一串命令、deploy 是一串命令、rollback 也是。把这些命令串起来、加上状态记录、加上健康检查、加上 alert,就是一条完整的 pipeline。等到团队大到需要 Web UI、需要 RBAC、需要矩阵测试那天再升级也不迟,迁移成本反而比一开始就上 Jenkins 还低。
参考资料:Docker HEALTHCHECK 官方文档;Bash strict mode(`set -euo pipefail`)。
标签:#Bash #CI/CD #docker #Health Check #Rollback #shell