事故摘要
目标原本很简单:清理一台 Docker 主机,只保留 Jenkins 与 Portainer,其余容器停止并删除,再回收不用的镜像和构建缓存;明确禁止删除 volume。
执行过程中,远端 shell 的引号没有按预期保留,保留名单过滤失效。原本应该被排除的 Jenkins 和 Portainer 也进入了停止与清理集合,最终容器列表为空。
幸运的是,命名卷和绑定目录仍在,没有执行 docker volume prune。两个服务随后通过原 Compose 配置重建,并重新挂载到原数据位置。
影响可以概括为:
1 | 容器对象:被误删,需要重建 |
第一处错误:把计划输出当成安全保证
执行前已经说清楚“保留 Jenkins 和 Portainer”,但自然语言中的保留名单并不会自动约束 shell。真正危险的步骤是把容器名经过多层本地 PowerShell、SSH 和远端 shell 引号后再交给过滤表达式。
在跨 shell 环境中,下面这些内容都可能改变语义:
1 | 引号由本地还是远端解释 |
事故发生前,命令输出已经把应保留的容器列进待停清单。这本应成为硬停止条件,而不是继续执行的警告。
第二处风险:清理动作太靠近筛选动作
把“发现目标、停止目标、删除目标、清理镜像”放进一个批处理,节省了几行命令,却压缩了最后的人工复核窗口。安全的流程应拆成不可越过的阶段:
1 | 快照 → 生成候选 → 人工核对 → 停止 → 健康检查 → 删除 → 再次检查 → 清理镜像 |
一个更容易审查的远端候选生成方式,是避免复杂正则,逐项比较容器名:
1 | docker ps -a --format '{{.Names}}' | while IFS= read -r name; do |
第一遍只打印,不接 docker stop 或 docker rm。只有候选集合与预期完全一致,才进入下一阶段。
为什么数据还能恢复
Docker 官方将 volume 定义为独立于容器生命周期的持久化存储。删除容器并不会自动删除命名卷;volume 删除是单独动作。详见 Docker Volumes。
真正需要警惕的是:
1 | docker compose down -v |
docker compose down 默认不删除命名卷,但 -v 会删除 Compose 声明的卷,官方文档对此有明确说明:docker compose down。
本次恢复流程先做只读核对:
1 | docker volume ls |
随后从 Compose 文件和旧容器标签确认原项目名、服务名与挂载关系,再重建服务。
恢复过程中又踩到 Compose 项目名
第一次重建 Jenkins 时,Compose 根据当前目录生成了新的项目名,于是创建了一个新的空卷。服务看起来启动了,实际却没有挂载原 Jenkins 数据。
这个问题通过 docker inspect 的 Mounts 字段发现。修正 Compose 项目名后,Jenkins 重新挂回原命名卷,历史数据恢复。临时误建的空卷只有在确认未被任何容器使用后才删除。
1 | docker inspect jenkins --format '{{json .Mounts}}' |
因此,“容器 Up”只能证明进程启动,不能证明数据路径正确。
验收不是只看 HTTP 200
恢复后分别检查:
- 容器状态与重启次数。
- 实际挂载的 volume/bind source。
- Jenkins 日志是否完成初始化。
- 未认证访问返回 403 是否符合权限预期。
- Portainer 页面是否可达。
- 原任务、用户和配置是否存在。
- 是否残留临时空卷和错误 Compose 项目。
Jenkins 的 403 在这个场景中不是失败,而是未认证访问被拒绝;必须结合日志和登录后数据验证一起判断。
以后如何执行类似清理
1 | 1. 导出 docker ps -a、docker image ls、docker volume ls |
结语
这次事故没有演变成数据丢失,靠的不是清理命令足够安全,而是持久化数据与容器对象分离,并且 volume 没有被继续清理。真正应该记住的是:自动化执行得越快,越需要在破坏性边界之前设置无法自动跨越的核对点。