部署数据丢失事故复盘

一次 deploy-update.sh 执行后博客文章"全部消失"的排查与修复记录。

从现象到根因一共四层下钻:确认挂载 → 发现两份 db → 路径不匹配 → DATABASE_URL 格式错误。每一层的排查命令和判断依据都有记录,可作为类似问题的快速定位参考。


一、前置背景

1.1 这个项目是怎么部署的

项目使用 Docker 部署,有两套部署脚本:

deploy-run.sh — 首次部署 + 全量更新
- 本地 deploy-package.sh 打包完整镜像(约 3.5GB,含 PyTorch、Chrome 等)
- 传到服务器 docker load 导入
- 首次部署必须用它

deploy-update.sh — 日常轻量更新(事故中使用的脚本)
- 本地打包 personal-blog-update.tar.gz(约 5MB,仅源代码)
- 服务器上有之前构建的 Docker 缓存(PyTorch、Chrome 等已缓存)
- 只重跑 Dockerfile 的 COPY . . 层,5-10 秒完成构建
- 适合代码更新,不涉及底层依赖变更

事故中的操作流程:

# 本地
./deploy-update.sh                                    # 生成代码包
scp personal-blog-update.tar.gz root@服务器:/tmp/     # 传到服务器

# 服务器
sudo bash /tmp/deploy-update.sh /tmp/personal-blog-update.tar.gz
# → 解包 → docker compose build → docker compose down → docker compose up -d

1.2 博客文件在 Docker 中的存储结构

宿主机 /opt/personal-blog/       容器内 /app/ (WORKDIR)
┌──────────────────────────┐    ┌────────────────────────┐
│  blog.db          ←──────── bind mount ──────  blog.db        │
│  data/             ←──────── bind mount ──────  data/          │
│  .env              ←──── env_file ──────────  (环境变量)       │
│  docker-compose.yml│    │                        │
│  app/ (代码文件)    │    │  app/ (Python package) │
│  ...               │    │  ...                   │
└──────────────────────────┘    └────────────────────────┘
│                              ↑
└─────── COPY . . (构建时) ─────┘

关键概念:bind mount vs overlayfs

  • bind mount(卷挂载):宿主机目录/文件直接映射进容器,容器里对文件的修改会实时反映到宿主机。删除容器不影响。
  • overlayfs(容器可写层):容器的文件系统由镜像层 + 容器层组成。容器内对文件的修改写在这个层。删除容器时这个层也一起删除。

部署脚本做的事:

tar xzf 解包到 /opt/personal-blog
    ↓  覆盖代码文件(blog.db 被 --exclude 排除,不受影响)
docker compose build
    ↓  用当前目录的 Dockerfile 构建镜像,COPY . . 把代码拷进镜像
docker compose down
    ↓  删除容器(overlayfs 层随之消失——数据如果写在这里就没了)
docker compose up -d
    ↓  用新镜像创建新容器,bind mount 重新挂载宿主机文件

1.3 配置差异:为什么本地没发现

本地开发机和服务器用的 docker-compose 文件不同:

文件 用途 包含 DATABASE_URL?
docker-compose.yml 开发环境 ✅ 硬编码了 sqlite:///app/blog.db
docker-compose.prod.yml 生产环境 ❌ 靠 .env 传入

本地开发没问题是因为:
- 本地直接 python app/main.py 运行(不经过 Docker),config.py 的默认值解析正确
- 开发时没有人用 Docker 跑生产配置,所以这个差异一直没有被触发

首次部署也没问题是因为:
- 首次部署用的 deploy-run.sh,当时还没有什么硬编码的 DATABASE_URL

本次事故才触发是因为:
- deploy-update.sh 没有指定 -f docker-compose.prod.yml,默认读了开发版的 docker-compose.yml,里面的 DATABASE_URL 覆盖了正确的生产配置
- 这个 Bug 从一开始就存在,只是一直没人用 deploy-update.sh 在服务器上执行过更新


二、事故触发

2.1 表面现象

  • 浏览器访问博客首页 → 文章列表为空
  • 管理后台 → 文章数为 0
  • ls -la /opt/personal-blog/blog.db → 文件还在

2.2 初步直觉

文件在,但文章没了
  ├── 可能是数据库被清空(但 151KB 不像是空库)
  ├── 可能是挂载出了问题(容器里用的是另一份 db)
  └── 可能是写到了别的地方(overlayfs 层)

三、排查过程(四层下钻)

第一层:确认挂载关系

# 宿主机和容器里的 blog.db 是同一个文件吗?
stat /opt/personal-blog/blog.db | grep Inode
# → Inode: 413624

docker exec personal-blog stat /app/blog.db | grep Inode
# → Inode: 413624

两个 Inode 相同 → 文件没丢,bind mount 工作正常。 问题不在文件是否被删,而在于数据写到了别的地方。

第二层:发现多份 blog.db

# 全盘搜索 blog.db
find / -name "blog.db" -type f 2>/dev/null

结果找到了多个:

路径 大小 说明
/opt/personal-blog/blog.db 151KB 挂载的宿主文件——空的
/var/lib/docker/rootfs/.../app/app/blog.db 155KB 有文章——在 overlayfs 层
/var/lib/docker/rootfs/.../app/blog.db 0KB 镜像层的空文件

确认有数据的文件在 /var/lib/docker/rootfs/.../app/app/blog.db,不在 bind mount 的 /app/blog.db

第三层:定位路径差异

# 容器内看两个文件的差异
docker exec personal-blog ls -la /app/blog.db /app/app/blog.db
路径 大小 来源
/app/blog.db 151KB bind mount 挂进来的,空库
/app/app/blog.db 155KB overlayfs 层,有文章

容器内的目录结构:

/app/              ← WORKDIR(Dockerfile 定义的)
  ├── app/         ← Python 包目录(代码在 app/app/ 下)
  │   ├── config.py
  │   └── ...
  ├── blog.db      ← 挂载进来的(151KB,空)
  └── app/blog.db  ← 实际写入的(155KB,有文章)

第四层:DATABASE_URL 的真相

谁告诉应用把数据库写到 /app/app/blog.db 的?查配置链路:

# 1. 容器内的环境变量
docker exec personal-blog env | grep DATABASE_URL
# → DATABASE_URL=sqlite:///app/blog.db

# 2. 这个值从哪里来的?
grep "DATABASE_URL" /opt/personal-blog/docker-compose.yml
# → - DATABASE_URL=sqlite:///app/blog.db

grep "DATABASE_URL" /opt/personal-blog/docker-compose.prod.yml
# → (没有输出,生产版不硬编码这个值)

grep "DATABASE_URL" /opt/personal-blog/.env
# → # DATABASE_URL=postgresql://...(被注释了)

根因浮出水面:

deploy-update.sh 默认读了开发版的 docker-compose.yml,里面写死了:

environment:
  - DATABASE_URL=sqlite:///app/blog.db

3 个斜杠 /// = 相对路径。 解析规则:

# SQLAlchemy 的 SQLite URI 解析
sqlite:///app/blog.db    # 3 斜杠 → 相对路径 → WORKDIR + /app/blog.db = /app/app/blog.db
sqlite:////app/blog.db   # 4 斜杠 → 绝对路径 → /app/blog.db

在 Docker 中 WORKDIR=/app,所以 app/blog.db 被拼接为 /app/app/blog.db。数据写到了 overlayfs 层,而 bind mount 的 /app/blog.db 从未被更新过。

docker compose down 删除容器 → overlayfs 层消失 → "数据丢失"。


四、排查命令速查手册

以上排查过程使用的命令,按场景分类整理,可直接用于类似问题。

4.1 确认文件状态

# 检查宿主机文件
ls -la /opt/personal-blog/blog.db
stat /opt/personal-blog/blog.db

# 检查容器内的对应文件
docker exec personal-blog ls -la /app/blog.db

# 对比 inode——确认是否为同一个文件
stat /opt/personal-blog/blog.db | grep Inode
docker exec personal-blog stat /app/blog.db | grep Inode

# 全盘搜索——找有没有其他副本
find / -name "blog.db" -type f 2>/dev/null

# 排除 overlayfs 和 containerd 的残留文件,只看实际数据库
find / -name "blog.db" -type f -not -path "*/overlayfs/*" -not -path "*/containerd/*" -not -path "*snapshots*" 2>/dev/null

4.2 查看数据内容

# 列出所有表
sqlite3 /opt/personal-blog/blog.db ".tables"

# 查文章数量
sqlite3 /opt/personal-blog/blog.db "SELECT COUNT(*) FROM articles;"

# 查最近的文章
sqlite3 /opt/personal-blog/blog.db "SELECT id, title, created_at FROM articles ORDER BY id DESC LIMIT 5;"

# 查数据库文件大小和 WAL 状态
ls -la /opt/personal-blog/blog.db*
sqlite3 /opt/personal-blog/blog.db "PRAGMA wal_checkpoint(TRUNCATE);"

4.3 检查 Docker 卷挂载

# 查看容器的所有挂载
docker inspect personal-blog --format='{{json .Mounts}}' | python3 -m json.tool

# 查看环境变量
docker exec personal-blog env | grep DATABASE_URL

# 查看容器内文件结构
docker exec personal-blog ls -la /app/

# 进入容器交互式排查
docker exec -it personal-blog sh

4.4 数据救援

# 从 overlayfs 复制到 bind mount 位置
docker exec personal-blog sh -c "cp /app/app/blog.db /app/blog.db"

# 合并两个数据库
sqlite3 /opt/personal-blog/blog.db <<EOF
ATTACH DATABASE '/path/to/source.db' AS src;
INSERT INTO articles SELECT * FROM src.articles;
INSERT INTO article_tags SELECT * FROM src.article_tags;
INSERT INTO tags SELECT * FROM src.tags WHERE name NOT IN (SELECT name FROM tags);
DETACH src;
EOF

五、修复方案

5.1 紧急救援

# ① 修复 DATABASE_URL:用 4 斜杠绝对路径
echo 'DATABASE_URL=sqlite:////app/blog.db' >> /opt/personal-blog/.env

# ② 从 overlayfs 扒出数据
docker exec personal-blog sh -c "cp /app/app/blog.db /app/blog.db"

# ③ 验证
sqlite3 /opt/personal-blog/blog.db "SELECT id, title, created_at FROM articles;"

为什么这样能修? Docker Compose 的环境变量优先级:

.env 文件 > docker-compose.yml 里的 environment > Dockerfile 里的 ENV

.env 里写入 DATABASE_URL 会覆盖 docker-compose.yml 里的硬编码值。

5.2 脚本修复(本次事故的直接产出)

deploy-update.sh 做了三个改动:

改动 原因
增加 COMPOSE_FLAG:自动检测 docker-compose.prod.yml 避免误用开发版配置
移除 docker system prune -f 避免删构建缓存导致部署从 5 秒变 15 分钟
保留 df -h 只显示磁盘空间,不自动清理

5.3 部署后验证清单

# 每次部署后检查:
# 1. 数据库挂载正常
docker exec personal-blog ls -la /app/blog.db

# 2. 数据能读到
sqlite3 /opt/personal-blog/blog.db "SELECT COUNT(*) FROM articles;"

# 3. 环境变量正确
docker exec personal-blog env | grep DATABASE_URL
# 期望: DATABASE_URL=sqlite:////app/blog.db

六、教训与预防

6.1 相对路径的陷阱

SQLite URI 中斜杠数量的含义:

URI 斜杠数 含义 在 WORKDIR=/app 时的解析结果
sqlite:///app/blog.db 3 相对路径,拼上当前工作目录 /app/app/blog.db
sqlite:////app/blog.db 4 绝对路径 /app/blog.db

记忆方法: 4 个斜杠 = 4 sure(绝对路径)。3 个斜杠 = 拼上 WORKDIR。

6.2 部署脚本的几个原则

  1. 显式指定 compose 文件 — 永远用 -f docker-compose.prod.yml,不要让 docker compose 自动寻找
  2. 不要自动清理docker system prune -f 不应该出现在生产部署脚本中,把清理交给 crontab 手动控制
  3. 部署前自动备份 — 在 docker compose down 之前先备份 blog.db
cp blog.db "blog.db.$(date +%Y%m%d_%H%M%S)"

6.3 备份策略

# crontab:每天凌晨 3 点备份,保留 7 天
0 3 * * * cd /opt/personal-blog && cp blog.db backups/blog.db.$(date +\%Y\%m\%d) && find backups/ -name "blog.db.*" -mtime +7 -delete

# 手动备份
cp /opt/personal-blog/blog.db /opt/personal-blog/blog.db.$(date +%Y%m%d)

# 检查备份
ls -la /opt/personal-blog/backups/

6.4 快速定位 checklist

遇到"页面空白无数据"时,按此顺序排查:

# Step 1:数据库文件存在吗?
ls -la /opt/personal-blog/blog.db

# Step 2:数据库有文章吗?
sqlite3 /opt/personal-blog/blog.db "SELECT COUNT(*) FROM articles;"
# → 如果为 0 但文件在,可能是挂载路径配错了

# Step 3:容器内看到的数据库路径正确吗?
docker exec personal-blog env | grep DATABASE_URL
# → 期望 4 斜杠或明确绝对路径

# Step 4:有没有其他 blog.db?
find / -name "blog.db" -type f 2>/dev/null
# → 如果在 overlayfs 层里有数据,说明 DATABASE_URL 指向了错误路径

七、参考

  • Docker Compose 环境变量优先级Compose specification on environment variables.env、compose file environment、Dockerfile ENV 三者的优先级关系
  • SQLite URI 在 SQLAlchemy 中的解析SQLAlchemy SQLite dialectssqlite:///sqlite://// 的路径解析差异
  • Docker bind mounts vs volumesDocker storage overview。bind mounts 和 overlayfs 层的交互机制
  • Docker system prune参考文档prune -f 默认不删 volumes,但会删 build cache
  • Docker compose down参考文档down 不加 -v 不会删 named volumes 和 bind mounts
  • 本项目相关文档
  • DEPLOY.md — 部署操作步骤
  • deploy-update.sh — 增量更新脚本
  • docker-compose.yml / docker-compose.prod.yml — Docker Compose 配置文件

文档版本: 1.0 | 最后更新: 2026-06-24

本文档是一次真实事故的完整复盘,排查路径、命令和方法论可直接复用于类似问题。