部署数据丢失事故复盘
一次
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 部署脚本的几个原则
- 显式指定 compose 文件 — 永远用
-f docker-compose.prod.yml,不要让docker compose自动寻找 - 不要自动清理 —
docker system prune -f不应该出现在生产部署脚本中,把清理交给 crontab 手动控制 - 部署前自动备份 — 在
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 fileenvironment、DockerfileENV三者的优先级关系 - SQLite URI 在 SQLAlchemy 中的解析 — SQLAlchemy SQLite dialects。
sqlite:///与sqlite:////的路径解析差异 - Docker bind mounts vs volumes — Docker 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
本文档是一次真实事故的完整复盘,排查路径、命令和方法论可直接复用于类似问题。
No comments yet. Be the first!