事故摘要

目标原本很简单:清理一台 Docker 主机,只保留 Jenkins 与 Portainer,其余容器停止并删除,再回收不用的镜像和构建缓存;明确禁止删除 volume。

执行过程中,远端 shell 的引号没有按预期保留,保留名单过滤失效。原本应该被排除的 Jenkins 和 Portainer 也进入了停止与清理集合,最终容器列表为空。

幸运的是,命名卷和绑定目录仍在,没有执行 docker volume prune。两个服务随后通过原 Compose 配置重建,并重新挂载到原数据位置。

影响可以概括为:

1
2
3
4
5
6
容器对象:被误删,需要重建
镜像:仍可重新拉取或复用
命名卷:保留,是恢复 Jenkins 数据的关键
绑定目录:保留,是恢复 Portainer 配置的关键
服务可用性:短时中断
持久化数据:最终验证未丢失

第一处错误:把计划输出当成安全保证

执行前已经说清楚“保留 Jenkins 和 Portainer”,但自然语言中的保留名单并不会自动约束 shell。真正危险的步骤是把容器名经过多层本地 PowerShell、SSH 和远端 shell 引号后再交给过滤表达式。

在跨 shell 环境中,下面这些内容都可能改变语义:

1
2
3
4
引号由本地还是远端解释
$variable 在哪一层展开
正则中的括号和竖线是否保留
空输出到底表示“没有目标”还是“过滤失败”

事故发生前,命令输出已经把应保留的容器列进待停清单。这本应成为硬停止条件,而不是继续执行的警告。

第二处风险:清理动作太靠近筛选动作

把“发现目标、停止目标、删除目标、清理镜像”放进一个批处理,节省了几行命令,却压缩了最后的人工复核窗口。安全的流程应拆成不可越过的阶段:

1
快照 → 生成候选 → 人工核对 → 停止 → 健康检查 → 删除 → 再次检查 → 清理镜像

一个更容易审查的远端候选生成方式,是避免复杂正则,逐项比较容器名:

1
2
3
4
5
6
docker ps -a --format '{{.Names}}' | while IFS= read -r name; do
case "$name" in
jenkins|portainer) ;;
*) printf '%s\n' "$name" ;;
esac
done

第一遍只打印,不接 docker stopdocker rm。只有候选集合与预期完全一致,才进入下一阶段。

为什么数据还能恢复

Docker 官方将 volume 定义为独立于容器生命周期的持久化存储。删除容器并不会自动删除命名卷;volume 删除是单独动作。详见 Docker Volumes

真正需要警惕的是:

1
2
3
docker compose down -v
docker volume prune
docker volume rm <name>

docker compose down 默认不删除命名卷,但 -v 会删除 Compose 声明的卷,官方文档对此有明确说明:docker compose down

本次恢复流程先做只读核对:

1
2
3
docker volume ls
docker volume inspect <expected-volume>
docker image ls

随后从 Compose 文件和旧容器标签确认原项目名、服务名与挂载关系,再重建服务。

恢复过程中又踩到 Compose 项目名

第一次重建 Jenkins 时,Compose 根据当前目录生成了新的项目名,于是创建了一个新的空卷。服务看起来启动了,实际却没有挂载原 Jenkins 数据。

这个问题通过 docker inspectMounts 字段发现。修正 Compose 项目名后,Jenkins 重新挂回原命名卷,历史数据恢复。临时误建的空卷只有在确认未被任何容器使用后才删除。

1
2
docker inspect jenkins --format '{{json .Mounts}}'
docker inspect portainer --format '{{json .Mounts}}'

因此,“容器 Up”只能证明进程启动,不能证明数据路径正确。

验收不是只看 HTTP 200

恢复后分别检查:

  • 容器状态与重启次数。
  • 实际挂载的 volume/bind source。
  • Jenkins 日志是否完成初始化。
  • 未认证访问返回 403 是否符合权限预期。
  • Portainer 页面是否可达。
  • 原任务、用户和配置是否存在。
  • 是否残留临时空卷和错误 Compose 项目。

Jenkins 的 403 在这个场景中不是失败,而是未认证访问被拒绝;必须结合日志和登录后数据验证一起判断。

以后如何执行类似清理

1
2
3
4
5
6
7
8
1. 导出 docker ps -a、docker image ls、docker volume ls
2. 保存每个关键容器的 inspect 与 Compose 配置
3. 对重要卷做可恢复备份,并实际测试恢复
4. 生成待处理清单,只打印
5. 保留名单出现在候选中时立即停止
6. 先 stop,观察关键服务,再 rm
7. 不把 volume prune 混入普通空间清理
8. 重建后验证 Mounts、日志、权限和业务数据

结语

这次事故没有演变成数据丢失,靠的不是清理命令足够安全,而是持久化数据与容器对象分离,并且 volume 没有被继续清理。真正应该记住的是:自动化执行得越快,越需要在破坏性边界之前设置无法自动跨越的核对点。