# Docker data-root 安全迁移到 `/data` 指南

> 生成日期：2026-04-29
> 审计方：OpenCode（GPT-5.5 + Gemini 3.1 Pro）+ deepseek-v4-pro 三重审计
> 目标：将 Docker 数据目录从系统盘 `/var/lib/docker` 迁移到数据盘 `/data/docker`
> 结论：**通过** ✅ — 方案技术路径正确，风险规避措施完备
> 审计摘要：0 ❌ 否决项、8 ⚠️ 关注项（已全部纳入文档）、5 💡 优化建议（已纳入）

---

## 〇、前置依赖

迁移依赖以下工具，如果缺失请先安装：

```bash
sudo apt update && sudo apt install -y rsync bc jq
```

- `rsync` — 数据同步核心工具
- `bc` — 空间计算（源目空间强制检查）
- `jq` — JSON 处理（daemon.json 合并）

---

## 一、前置检查

### 1. 确认 Docker 当前 data-root

```bash
docker info --format '{{.DockerRootDir}}'
```

```bash
docker info --format '{{.Driver}}'
```

> **注意**：Debian 系统上 `docker info` 的 Driver 字段可能显示 `overlayfs` 而非 `overlay2`，两者功能完全一致，均为 overlay2 实现。两种情况均表示兼容。

```bash
sudo du -sh "$(docker info --format '{{.DockerRootDir}}')"
```

### 2. 检查现有 `daemon.json`

```bash
sudo ls -l /etc/docker/daemon.json && sudo jq . /etc/docker/daemon.json || echo "daemon.json 不存在或内容为空"
```

如果提示 `jq: command not found`，先安装：

```bash
sudo apt update && sudo apt install -y jq
```

### 3. 确认 `/data` 已挂载并空间充足

```bash
findmnt /data
df -h /data
sudo touch /data/.docker-migration-write-test && sudo rm /data/.docker-migration-write-test && echo "✅ /data 可写"
```

### 4. ⚠️ 检查 `/data` 文件系统与 Overlay2 兼容性

Docker overlay2 存储驱动在 xfs 上要求 `ftype=1` 特性，否则无法正常工作。

```bash
# 查看 /data 的文件系统类型
df -T /data | awk 'NR==2 {print $2}'

# 如果是 xfs，检查 ftype 是否开启
stat -f /data | grep -i "ftype\|features"
```

- 如果输出 `ext4` → ✅ 完全兼容
- 如果输出 `xfs` 且 `ftype=1` → ✅ 兼容
- 如果输出 `xfs` 且 `ftype=0` → ❌ **不可直接使用**，需重新格式化 `/data`（会清空数据）

### 5. ✅ 源目空间强制检查

```bash
# 源数据大小
SRC_SIZE=$(sudo du -sb /var/lib/docker | cut -f1)
echo "源数据大小: $(numfmt --to=iec $SRC_SIZE)"

# 目标分区可用空间
TARGET_AVAIL=$(stat -f --format="%a*%S" /data | bc)
echo "目标可用空间: $(numfmt --to=iec $TARGET_AVAIL)"

# 校验：目标可用空间需 ≥ 源数据大小 × 1.2（预留 20% 余量）
if [ "$TARGET_AVAIL" -gt "$(echo "$SRC_SIZE * 1.2 / 1" | bc)" ]; then
    echo "✅ 空间充足，可迁移"
else
    echo "❌ 空间不足！目标可用 $(numfmt --to=iec $TARGET_AVAIL)，至少需要 $(numfmt --to=iec $(echo "$SRC_SIZE * 1.2 / 1" | bc))"
    exit 1
fi
```

### 6. ⚠️ Bind Mount 审计——检查容器是否绑定了 `/var/lib/docker` 下路径

> 容器如果有 bind mount 指向 `/var/lib/docker/...`，迁移后旧目录被重命名，这些容器将找不到数据卷。

```bash
docker ps -q | while read cid; do
  docker inspect "$cid" --format '{{.Name}}: {{range .Mounts}}{{.Source}} {{end}}' \
    | grep /var/lib/docker && echo "⚠️ 发现 /var/lib/docker 下的 bind mount"
done || echo "✅ 无容器绑定 /var/lib/docker 路径"
```

---

## 二、迁移步骤

### 步骤 0：迁移前精简（减少 rsync 数据量）

> 迁移数据量越小，停机时间越短。建议在正式迁移前先做清理。

```bash
# 清理未使用的镜像、已停止的容器、构建缓存
docker system prune -a -f

# 清理无容器引用的 dangling volume（安全版，不会误删有引用的数据卷）
docker volume ls -qf "dangling=true" | xargs -r docker volume rm

# ❌ 不要使用 `docker volume prune -f`——它会删除 Compose 已 down 但仍保留数据的 volume
```

### 步骤 1：记录当前状态（用于迁移后对比）

```bash
docker info --format '{{.DockerRootDir}}'
docker ps -a --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}'
docker system df
```

### 步骤 2a：热同步（在线 rsync，减少停机时间）

> 在不停机的情况下，先将大部分数据同步到目标目录。后续停机后只需增量同步差异。

```bash
sudo rsync -aHAXS --numeric-ids --info=progress2 /var/lib/docker/ /data/docker/
```

### 步骤 2b：数据库准备（可选，确保一致性）

> 数据库类容器在停机前做 checkpoint 可加速恢复。

```bash
# 对 PostgreSQL 数据库执行 checkpoint
docker exec lobe-postgres psql -U postgres -c "CHECKPOINT;" 2>/dev/null || echo "PostgreSQL 不可用或未运行，跳过"
```

### 步骤 3：优雅停止关键容器 + 停止 Docker 和 containerd

> 直接 `systemctl stop docker` 会向所有容器发 SIGTERM，PostgreSQL 等写密集型容器可能在刷 WAL 的途中被强杀。先从容优雅停止容器。
>
> ⚠️ 此步骤会中断所有运行中的容器，包括 Hermes Gateway（微信/飞书网关将短暂断连）。
> 预估停机时间：**热同步后约 30 秒 - 1 分钟**（仅同步增量数据）。

```bash
# 先优雅停止有状态容器（给予充足时间 flush 数据）
#   注意：新版 Docker 中 --time 已废弃，如遇警告可改用 --timeout
docker stop --time=30 lobe-postgres 2>/dev/null || true
docker stop --time=15 lobe-redis 2>/dev/null || true
docker stop --time=15 lobe-rustfs 2>/dev/null || true

# 停止其余容器
docker stop --time=10 $(docker ps -q) 2>/dev/null || true

# 确认所有容器已停止
docker ps -q | wc -l

# 停止 Docker 及其 socket 激活守护
sudo systemctl stop docker
sudo systemctl stop docker.socket
sudo systemctl stop containerd
sudo systemctl is-active docker || true
sudo systemctl is-active containerd || true
```

### 步骤 4：增量同步（停机后，仅同步变更数据）

> ⚠️ **低内存注意**：12 个容器 + rsync 大文件 I/O 在 3.8GB RAM 上可能触发 OOM Killer。停机后可考虑先释放 page cache：`echo 1 > /proc/sys/vm/drop_caches`（需 root）。也可给 rsync 加带宽限制：`--bwlimit=100M`。

```bash
# 断言：源目录非空（防止 `--delete` 在源目录异常时为空的灾难）
#   某些 Debian 系统的镜像层存储在 rootfs/ 而非 overlay2/，两者均需检查。
STORE_DIR=""
if [ -d /var/lib/docker/overlay2 ]; then STORE_DIR="overlay2"
elif [ -d /var/lib/docker/rootfs ]; then STORE_DIR="rootfs"
else echo "⚠️ 未找到 overlay2 或 rootfs 目录，继续但请人工确认"; fi

test -d "/var/lib/docker/${STORE_DIR:-containers}" && \
  test "$(sudo find /var/lib/docker -maxdepth 0 -type d -empty 2>/dev/null)" = "" || \
  { echo "❌ 源目录为空或不存在！取消迁移"; exit 1; }
echo "✅ 源目录非空断言通过（存储目录: ${STORE_DIR:-无}）"

# 增量同步（使用 --delete 确保目标与源一致）
sudo rsync -aHAXS --numeric-ids --delete --info=progress2 /var/lib/docker/ /data/docker/
```

### 步骤 5：备份并合并 `daemon.json`

```bash
# 确保目录存在
sudo mkdir -p /etc/docker

# 如果 daemon.json 不存在，创建空配置
sudo test -f /etc/docker/daemon.json || echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null

# 备份当前配置（文件名带时间戳）
sudo cp -a /etc/docker/daemon.json "/etc/docker/daemon.json.bak.$(date +%Y%m%d-%H%M%S)"

# 校验合法 JSON
sudo jq . /etc/docker/daemon.json >/dev/null || echo "JSON 不合法，请检查"

# 合并 data-root 配置（不覆盖其他已有配置项）
sudo jq '. + {"data-root": "/data/docker"}' /etc/docker/daemon.json | sudo tee /etc/docker/daemon.json.tmp >/dev/null

# 再次校验合并后 JSON
sudo jq . /etc/docker/daemon.json.tmp >/dev/null && sudo mv /etc/docker/daemon.json.tmp /etc/docker/daemon.json

# 查看最终结果
sudo jq . /etc/docker/daemon.json

# 校验权限（应为 600 或 644）
stat -c "%a %A" /etc/docker/daemon.json
```

### 步骤 6：启动 Docker 并验证新 data-root

```bash
sudo systemctl daemon-reload
sudo systemctl start containerd
sudo systemctl start docker
sudo systemctl status docker --no-pager
docker info --format '{{.DockerRootDir}}'
```

如果 `DockerRootDir` 显示为 `/data/docker`，说明迁移成功。

### 步骤 7：容器功能验证

```bash
# 查看容器状态
docker ps -a

# 查看镜像是否完整
docker images

# 查看 volume 是否完整
docker volume ls

# 查看网络是否完整
docker network ls
```

**启动容器：**

```bash
# 仅启动迁移前处于 running 状态的容器（不误启已废弃的容器）
# 使用步骤 1 记录的 running 列表，或按依赖顺序逐个启动：
docker start hermes-gateway lobe-postgres lobe-redis lobe-rustfs 2>/dev/null

# 等待关键服务启动
sleep 5
```

**检查关键容器日志：**

```bash
docker logs --tail=30 hermes-gateway    # Hermes 网关
docker logs --tail=30 lobehub           # LobeHub
docker logs --tail=30 vaultwarden       # Vaultwarden
```

**🔍 容器间网络连通性验证（重要）：**

```bash
# 验证容器间 DNS 解析和网络是否正常
docker exec hermes-gateway nc -zv lobe-postgres 5432 2>&1 || echo "⚠️ Hermes → PostgreSQL 不通"
docker exec hermes-gateway nc -zv lobe-redis 6379 2>&1 || echo "⚠️ Hermes → Redis 不通"

# 验证 Web 服务端口可用
curl -sI http://127.0.0.1:8080 2>/dev/null | head -1 || echo "⚠️ 端口 8080 无响应（视服务情况而定）"
```

```bash
# 确认 Hermes Gateway 启动成功
docker logs --tail=20 hermes-gateway

# 检查 gateway 日志中是否有 Error 级别信息
docker logs hermes-gateway 2>&1 | grep -i "error\|panic\|fatal\|exit" || echo "✅ 无错误日志"

# 确认服务进程状态
docker inspect hermes-gateway --format '{{.State.Status}}'
```

> 如果 Hermes Gateway 的 restart policy 是 `always`，Docker 重启后它会自动恢复。建议手动在微信/飞书上发一条消息确认链路正常。

### 步骤 8：旧数据重命名备份（不删除！）

> 重命名前，确保没有残留进程持有旧目录的文件句柄。

```bash
# 确认新 data-root 已生效
docker info --format '{{.DockerRootDir}}'

# 检查是否有进程仍在访问 /var/lib/docker（避免 "text file busy"）
sudo lsof +D /var/lib/docker 2>/dev/null | head -20 || echo "✅ 无进程持有旧目录文件句柄"

# 重命名旧目录
sudo mv /var/lib/docker "/var/lib/docker.old.$(date +%Y%m%d-%H%M%S)"

# 建议：将备份移至数据盘，进一步释放系统盘空间
sudo mkdir -p /data/backups
sudo mv /var/lib/docker.old.* /data/backups/

# 确认已迁移
sudo ls -ld /data/backups/docker.old.*
df -h /
```

**严禁立即执行 `rm -rf /var/lib/docker`！** 先保留至少 7 天观察期。

### 步骤 9：观察期

| 环境类型 | 建议观察期 |
|---------|-----------|
| 普通开发机 | 3 天 |
| 有业务容器的服务器 | 7 天 |
| 含数据库/持久化服务 | 7-14 天 |

观察期间检查：

```bash
sudo systemctl status docker --no-pager
journalctl -u docker --since "1 hour ago" --no-pager
docker ps -a --format 'table {{.Names}}\t{{.Status}}'
docker logs --tail=10 hermes-gateway 2>&1 | grep -i error || echo "✅ Hermes 状态正常"
df -h / && df -h /data
```

观察期结束且确认无问题后，删除旧备份：

```bash
sudo rm -rf /data/backups/docker.old.YYYYMMDD-HHMMSS
```

---

## 三、回滚方案（5 分钟内可恢复）

如果迁移后 Docker 无法启动、容器丢失、volume 异常，按以下步骤回滚：

```bash
# 1. 停止服务
sudo systemctl stop docker containerd

# 2. 恢复 daemon.json（从时间戳最新的备份恢复）
BACKUP=$(ls -t /etc/docker/daemon.json.bak.* | head -1)
sudo cp -a "$BACKUP" /etc/docker/daemon.json
sudo jq . /etc/docker/daemon.json

# 3. 恢复旧数据目录
sudo test ! -e /var/lib/docker || sudo mv /var/lib/docker "/var/lib/docker.failed.$(date +%Y%m%d-%H%M%S)"
sudo mv /data/backups/docker.old.YYYYMMDD-HHMMSS /var/lib/docker

# 4. 重启验证
sudo systemctl daemon-reload
sudo systemctl start containerd
sudo systemctl start docker
docker info --format '{{.DockerRootDir}}'  # 应显示 /var/lib/docker
docker ps -a

# 5. 验证 Hermes Gateway 恢复
docker logs --tail=10 hermes-gateway 2>&1 | head -5
```

---

## 四、验证清单

| 检查项 | 命令 | 预期结果 |
|--------|------|---------|
| data-root 变更 | `docker info --format '{{.DockerRootDir}}'` | `/data/docker` |
| 存储驱动正常 | `docker info --format '{{.Driver}}'` | `overlay2` 或 `overlayfs` |
| Docker 服务 | `systemctl is-active docker` | `active` |
| 容器完整 | `docker ps -a` | 与迁移前一致 |
| 镜像完整 | `docker images` | 与迁移前一致 |
| Volume 可见 | `docker volume ls` | 与迁移前一致 |
| 网络可见 | `docker network ls` | 与迁移前一致 |
| 系统盘释放 | `df -h /` | Used 明显减少 |
| 日志无报错 | `journalctl -u docker --since "30m ago"` | 无 ERROR |
| 磁盘用量一致 | `docker system df` | 与迁移前对比基本一致 |
| **🔍 Hermes 链路** | 发一条微信/飞书消息 | 正常收发 |

---

## 六、实测记录

> 执行日期：2026-04-29 · 执行环境：Debian 12, Docker overlayfs, 10 容器, 6.1GB 数据

### 环境差异

| 项目 | 文档假设 | 实际环境 | 影响 |
|------|---------|---------|------|
| 存储驱动 | `overlay2` | `overlayfs`（Debian 正常） | 功能一致，无需处理 |
| 镜像层目录 | `/var/lib/docker/overlay2` | `/var/lib/docker/rootfs` | 步骤 4 断言需适配 |
| daemon.json | 假定存在 | 不存在（全新创建） | 跳过合并，直接写入 |
| 依赖工具 | 假定已安装 | `rsync` `bc` `jq` 均缺失 | 已新增 §〇 前置依赖 |
| 写入权限 | `/data` 普通用户可写 | 需要 sudo | 已修正 `touch` 为 `sudo touch` |
| hermes-gateway | 文档多处引用 | 环境中不存在 | 不影响迁移 |

### 迁移时间线

```
01:17  - 热同步开始 (rsync, 49MB/s)
01:19  - 热同步完成 (6.3GB, 57021 文件)
02:18  - 停容器 (10→0)
02:18  - 停 Docker / containerd
02:21  - 增量同步 (几乎无变更)
02:21  - 配置 daemon.json
02:21  - 启动 Docker，data-root 验证通过
02:21  - 全部容器恢复运行
02:22  - 旧目录移至 /data/backups/
```

- **Docker 迁移停机**：约 3 分钟（停容器 → 容器恢复）
- **Containerd 迁移停机**：约 4 分钟（停 Docker/containerd → 恢复）
- **系统盘效果**：25G→18G（83%→59%），释放约 7GB
- **数据盘用量**：8.5GB / 59GB (15%)
- **备份位置**：
  - `/data/backups/docker.old.20260429-022130`（1.3GB）
  - `/data/backups/containerd.old.20260429-023358`（5.9GB）

### 关键教训

1. **热同步时 overlayfs 处于挂载状态**，rsync 捕捉到了完整的镜像层数据（rootfs 4.8GB）。停机后源端 rootfs 显示为 0 是正常的——数据已被正确同步到目标。
2. **不要盲目信任 `overlay2` 目录存在**。某些 Docker 安装使用 `rootfs` 作为镜像层存储目录。
3. **系统盘实际释放量有限**（~1GB），因为镜像层数据原本就在 overlayfs 挂载中而非直接占用磁盘。迁移的主要价值是将未来的 Docker 写入导向大容量数据盘。
4. **Docker data-root 迁移 ≠ 完整迁移**。镜像实际由 containerd 的快照器（snapshotter）管理，存储在 `/var/lib/containerd`。仅迁移 Docker data-root 不能释放镜像占用的空间——必须同时迁移 containerd 的 `root` 目录。
5. **containerd 迁移需额外步骤**：containerd 的 `root` 配置在 `/etc/containerd/config.toml`（默认被注释，值为 `/var/lib/containerd`）。迁移后取消注释并改为 `/data/containerd`，然后 `systemctl restart containerd docker`。

---

## 五、不需要做的操作

```bash
# ❌ 不要直接删除旧数据
sudo rm -rf /var/lib/docker

# ❌ 不要用 tee 覆盖 daemon.json（丢失已有配置）
sudo tee /etc/docker/daemon.json << 'EOF'

# ❌ 不要用 -av 进行 rsync（丢失 overlay2 元数据）
sudo rsync -av /var/lib/docker/ /data/docker/

# ❌ 迁移前不要忘记做 docker system prune（增加不必要的迁移数据量）
```
