Docker 数据打包上传到 Syncthing 源目录的备份脚本

用 Bash 将 Docker 数据目录打包成 tar.gz,通过 SSH 上传到远端 /srv/backup,使用同文件系统临时目录避免半截文件,并让 Syncthing 继续同步到最终备份端。

这篇记录一个很实用的小脚本:在业务服务器上把 Docker 数据目录打包成 tar.gz,通过 SSH 上传到另一台服务器的 /srv/backup,再由那台服务器上的 Syncthing 继续同步到备份端。

这个方案适合下面这种链路:

业务服务器 A -> SSH 上传 -> Syncthing 源端 B:/srv/backup -> Syncthing 同步 -> 备份端 C:/srv/backup

脚本本身只负责“打包”和“上传”。真正的多端同步、断点传输、设备连接状态和备份端落盘,由 Syncthing 继续处理。

为什么要这样写

直接把备份文件 scp/srv/backup 也能用,但有两个细节容易踩坑。

第一,上传大文件时,Syncthing 可能看到一个还没有传完的半截文件。更稳妥的方式是先上传到远端临时目录,传完后再 mv/srv/backup。同一文件系统内的 mv 基本是原子操作,Syncthing 看到的是最终文件。

第二,Syncthing 现在以普通 syncthing 用户运行。远端文件最好统一成:

syncthing:syncthing
0644

这样 Syncthing 后续读取、索引、同步都更稳。文件如果是 root:root 且权限为 0644,通常也能读;但如果脚本或系统 umask 让文件变成 0600,Syncthing 就读不到了。

前提条件

这篇假设远端服务器已经按下面方式准备好 Syncthing 源目录:

install -d -o syncthing -g syncthing /srv/backup

远端 Syncthing 服务以 syncthing 用户运行,并且文件夹路径配置为:

/srv/backup

业务服务器 A 需要能 SSH 登录远端服务器 B。生产定时任务推荐使用 SSH 私钥,不建议依赖交互式密码。脚本里的变量按实际情况修改:

SERVER_B_IP="192.236.142.146"
SSH_USER="root"
SSH_PORT="22"
SSH_PRIVATE_KEY="/root/.ssh/id_rsa24"

生产环境更建议只用私钥登录,并给 SSH 加上 BatchMode=yesConnectTimeout。这样定时任务不会因为密码提示、网络抖动或远端不可达而一直挂住。

完整脚本

下面这份是“业务服务器 A”和“Syncthing 源端 B”不在同一台机器时使用的远程上传版。如果 Docker 数据和 Syncthing 源目录就在同一台机器上,可以直接看后面的本地版脚本。

这份脚本默认在 A 机 root 下执行:源目录是 /root/data/docker_data,私钥是 /root/.ssh/id_rsa24,本地备份包也暂存在 /root。如果 A 机用普通用户运行,看后面的“普通用户远程上传版”。

可以保存为 /root/backup_docker_data.sh

#!/bin/bash

set -euo pipefail

# 服务器A上的源目录,也就是要打包备份的 Docker 数据目录
SRC_DIR="/root/data/docker_data"

# 服务器B上的最终备份目录
# 这个目录应由 Syncthing 同步,并且建议属主是 syncthing:syncthing
DST_DIR="/srv/backup"

# 服务器B的 IP 地址
SERVER_B_IP="192.236.142.146"

# 服务器B的 SSH 用户名
SSH_USER="root"

# 服务器B的 SSH 端口
SSH_PORT="22"

# SSH 私钥文件路径
SSH_PRIVATE_KEY="/root/.ssh/id_rsa24"

# 备份文件前缀
BACKUP_NAME_PREFIX="docker_data_backup"

# 压缩文件格式
ARCHIVE_FORMAT="tar.gz"

# 服务器B上的临时上传目录。
# 必须和 DST_DIR 在同一个文件系统上,避免 /tmp 太小导致大文件上传失败。
REMOTE_TMP_DIR="${DST_DIR}/.upload"

# 生成备份文件名
current_date=$(date +"%Y%m%d_%H%M%S")
backup_filename="${BACKUP_NAME_PREFIX}_${current_date}.${ARCHIVE_FORMAT}"

# 本地备份文件路径
local_backup_file="/root/${backup_filename}"

# 服务器B上的临时文件路径
remote_tmp_file="${REMOTE_TMP_DIR}/${backup_filename}.upload"

# 服务器B上的最终文件路径
remote_final_file="${DST_DIR}/${backup_filename}"

cleanup_local_backups() {
  find /root -maxdepth 1 -type f -name "${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}" -printf '%T@ %p\n' |
    sort -nr |
    awk 'NR>1 {print substr($0, index($0,$2))}' |
    xargs --no-run-if-empty rm -f
}

cleanup_remote_backups() {
  ssh -p "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
    -o BatchMode=yes -o ConnectTimeout=20 \
    "${SSH_USER}@${SERVER_B_IP}" "
      find '${DST_DIR}' -maxdepth 1 -type f -name '${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}' -printf '%T@ %p\n' 2>/dev/null |
        sort -nr |
        awk 'NR>3 {print substr(\$0, index(\$0,\$2))}' |
        xargs --no-run-if-empty rm -f
      find '${REMOTE_TMP_DIR}' -type f -name '${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}.upload' -mtime +1 -delete 2>/dev/null || true
    "
}

trap cleanup_local_backups EXIT

# 压缩源目录到本地当前目录
tar -czf "${local_backup_file}" -C "${SRC_DIR}" .

# 确保服务器B上的临时上传目录和最终备份目录存在
ssh -p "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
  -o BatchMode=yes -o ConnectTimeout=20 \
  "${SSH_USER}@${SERVER_B_IP}" "
  mkdir -p '${REMOTE_TMP_DIR}' '${DST_DIR}' &&
  chmod 700 '${REMOTE_TMP_DIR}' &&
  chown syncthing:syncthing '${DST_DIR}' &&
  printf '.upload\n' > '${DST_DIR}/.stignore' &&
  chown syncthing:syncthing '${DST_DIR}/.stignore' &&
  chmod 644 '${DST_DIR}/.stignore'
"

# 上传到服务器B的临时目录
scp -P "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
  -o BatchMode=yes -o ConnectTimeout=20 \
  "${local_backup_file}" "${SSH_USER}@${SERVER_B_IP}:${remote_tmp_file}"

# 在服务器B上修正属主和权限,然后原子移动到最终备份目录
ssh -p "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
  -o BatchMode=yes -o ConnectTimeout=20 \
  "${SSH_USER}@${SERVER_B_IP}" "
  chown syncthing:syncthing '${remote_tmp_file}' &&
  chmod 644 '${remote_tmp_file}' &&
  mv '${remote_tmp_file}' '${remote_final_file}'
"

# 远端只保留最新 3 个;本地只保留最新 1 个。
cleanup_remote_backups
cleanup_local_backups

赋权:

chmod +x /root/backup_docker_data.sh

手动执行一次:

/root/backup_docker_data.sh

普通用户远程上传版

如果 A 机不想用 root 跑定时备份,也可以使用普通用户。前提是:

  • A 机普通用户能读取要打包的 Docker 数据目录。
  • A 机普通用户有自己的 SSH 私钥,例如 ~/.ssh/id_ed25519
  • B 机上创建一个普通上传用户,例如 backup
  • B 机提前授权 backup 用户可以写入 /srv/backup/srv/backup/.upload

B 机可以由 root 做一次性准备。下面假设 A 机普通用户已经生成了 SSH 密钥:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -C "docker-data-backup"
cat ~/.ssh/id_ed25519.pub

把输出的公钥复制出来,然后在 B 机 root 下执行:

BACKUP_USER="backup"
BACKUP_SSH_PUB="ssh-ed25519 AAAA... docker-data-backup"

# Debian/Ubuntu 如果没有 setfacl/getfacl,先安装:
# apt update && apt install -y acl
#
# Alpine:
# apk add acl

# 创建只用于备份上传的普通用户。
# 注意:不要用 nologin,否则 scp 和远程 mv/find 命令无法执行。
if ! id "${BACKUP_USER}" >/dev/null 2>&1; then
  if command -v useradd >/dev/null 2>&1; then
    useradd --system --create-home --shell /bin/bash "${BACKUP_USER}"
  else
    adduser -D -h "/home/${BACKUP_USER}" -s /bin/sh "${BACKUP_USER}"
  fi
fi
passwd -l "${BACKUP_USER}" >/dev/null 2>&1 || true

# 写入 A 机普通用户的 SSH 公钥。
install -d -o "${BACKUP_USER}" -g "${BACKUP_USER}" -m 700 "/home/${BACKUP_USER}/.ssh"
touch "/home/${BACKUP_USER}/.ssh/authorized_keys"
grep -qxF "${BACKUP_SSH_PUB}" "/home/${BACKUP_USER}/.ssh/authorized_keys" ||
  printf '%s\n' "${BACKUP_SSH_PUB}" >> "/home/${BACKUP_USER}/.ssh/authorized_keys"
chown "${BACKUP_USER}:${BACKUP_USER}" "/home/${BACKUP_USER}/.ssh/authorized_keys"
chmod 600 "/home/${BACKUP_USER}/.ssh/authorized_keys"

# 准备 Syncthing 源目录和临时上传目录。
install -d -o syncthing -g syncthing /srv/backup
install -d -o "${BACKUP_USER}" -g "${BACKUP_USER}" -m 700 /srv/backup/.upload
printf '.upload\n' > /srv/backup/.stignore
chown syncthing:syncthing /srv/backup/.stignore
chmod 644 /srv/backup/.stignore

# 授权 backup 用户在 /srv/backup 下创建、移动和删除备份包。
setfacl -m "u:${BACKUP_USER}:rwx" /srv/backup

# 检查 ACL 是否生效,应能看到 user:backup:rwx。
getfacl /srv/backup

这样上传用户只获得备份目录的写入权限,不需要远程 root,也不需要在脚本里跑 sudo。密码登录也被锁住了,日常只允许 A 机普通用户用 SSH 私钥登录。

回到 A 机普通用户下,先测试 SSH 和写入权限:

ssh -i ~/.ssh/id_ed25519 backup@192.236.142.146 'echo ok'
ssh -i ~/.ssh/id_ed25519 backup@192.236.142.146 '
  test -w /srv/backup &&
  test -w /srv/backup/.upload &&
  touch /srv/backup/.upload/permission-test &&
  rm -f /srv/backup/.upload/permission-test &&
  echo permission-ok
'

看到 okpermission-ok 后,再使用下面的普通用户版脚本。

普通用户版可以保存为 ~/bin/backup_docker_data.sh

#!/bin/bash

set -euo pipefail

# 普通用户能读取的 Docker 数据目录
SRC_DIR="${HOME}/data/docker_data"

# 普通用户自己的本地工作目录
LOCAL_BACKUP_DIR="${HOME}/backup-work/docker-data"

# 服务器B上的最终备份目录
DST_DIR="/srv/backup"

# 服务器B的 IP 地址
SERVER_B_IP="192.236.142.146"

# 服务器B上的普通上传用户
SSH_USER="backup"

# 服务器B的 SSH 端口
SSH_PORT="22"

# 普通用户自己的 SSH 私钥
SSH_PRIVATE_KEY="${HOME}/.ssh/id_ed25519"

BACKUP_NAME_PREFIX="docker_data_backup"
ARCHIVE_FORMAT="tar.gz"
REMOTE_TMP_DIR="${DST_DIR}/.upload"

current_date=$(date +"%Y%m%d_%H%M%S")
backup_filename="${BACKUP_NAME_PREFIX}_${current_date}.${ARCHIVE_FORMAT}"

local_backup_file="${LOCAL_BACKUP_DIR}/${backup_filename}"
local_tmp_file="${local_backup_file}.upload"
remote_tmp_file="${REMOTE_TMP_DIR}/${backup_filename}.upload"
remote_final_file="${DST_DIR}/${backup_filename}"

cleanup_local_backups() {
  rm -f "${local_tmp_file}"
  find "${LOCAL_BACKUP_DIR}" -maxdepth 1 -type f -name "${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}" -printf '%T@ %p\n' |
    sort -nr |
    awk 'NR>1 {print substr($0, index($0,$2))}' |
    xargs --no-run-if-empty rm -f
}

cleanup_remote_backups() {
  ssh -p "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
    -o BatchMode=yes -o ConnectTimeout=20 \
    "${SSH_USER}@${SERVER_B_IP}" "
      find '${DST_DIR}' -maxdepth 1 -type f -name '${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}' -printf '%T@ %p\n' 2>/dev/null |
        sort -nr |
        awk 'NR>3 {print substr(\$0, index(\$0,\$2))}' |
        xargs --no-run-if-empty rm -f
      find '${REMOTE_TMP_DIR}' -type f -name '${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}.upload' -mtime +1 -delete 2>/dev/null || true
    "
}

trap cleanup_local_backups EXIT

mkdir -p "${LOCAL_BACKUP_DIR}"
chmod 700 "${LOCAL_BACKUP_DIR}"

tar -czf "${local_tmp_file}" -C "${SRC_DIR}" .
mv "${local_tmp_file}" "${local_backup_file}"

ssh -p "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
  -o BatchMode=yes -o ConnectTimeout=20 \
  "${SSH_USER}@${SERVER_B_IP}" "
  mkdir -p '${REMOTE_TMP_DIR}' &&
  chmod 700 '${REMOTE_TMP_DIR}'
"

scp -P "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
  -o BatchMode=yes -o ConnectTimeout=20 \
  "${local_backup_file}" "${SSH_USER}@${SERVER_B_IP}:${remote_tmp_file}"

ssh -p "${SSH_PORT}" -i "${SSH_PRIVATE_KEY}" \
  -o BatchMode=yes -o ConnectTimeout=20 \
  "${SSH_USER}@${SERVER_B_IP}" "
  chmod 644 '${remote_tmp_file}' &&
  mv '${remote_tmp_file}' '${remote_final_file}'
"

cleanup_remote_backups
cleanup_local_backups

赋权并测试:

mkdir -p ~/bin
chmod +x ~/bin/backup_docker_data.sh
~/bin/backup_docker_data.sh

定时任务写到普通用户自己的 crontab:

30 4 * * * /home/backup/bin/backup_docker_data.sh >> /home/backup/backup-work/docker-data.log 2>&1

本地直接打包到 Syncthing 目录

如果 Docker 数据目录和 Syncthing 源目录在同一台机器上,就不需要 sshscp。仍然建议先打包到本地临时目录,打包完成后再移动到 /srv/backup,这样 Syncthing 不会扫描到半截文件。

可以保存为 /root/backup_docker_data_local.sh

#!/bin/bash

set -euo pipefail

# 本机上的 Docker 数据目录
SRC_DIR="/root/data/docker_data"

# 本机上的 Syncthing 源目录
DST_DIR="/srv/backup"

# 本机临时打包目录。
# 必须放在 DST_DIR 下面,保证最后 mv 是同文件系统内的原子移动。
LOCAL_TMP_DIR="${DST_DIR}/.upload"

# 备份文件前缀
BACKUP_NAME_PREFIX="docker_data_backup"

# 压缩文件格式
ARCHIVE_FORMAT="tar.gz"

# 生成备份文件名
current_date=$(date +"%Y%m%d_%H%M%S")
backup_filename="${BACKUP_NAME_PREFIX}_${current_date}.${ARCHIVE_FORMAT}"

# 本地临时文件路径
tmp_backup_file="${LOCAL_TMP_DIR}/${backup_filename}.upload"

# 最终进入 Syncthing 源目录的文件路径
final_backup_file="${DST_DIR}/${backup_filename}"

cleanup_local_tmp() {
  rm -f "${tmp_backup_file}"
  find "${LOCAL_TMP_DIR}" -type f -name "${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}.upload" -mtime +1 -delete 2>/dev/null || true
}

cleanup_old_backups() {
  find "${DST_DIR}" -maxdepth 1 -type f -name "${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}" -printf '%T@ %p\n' |
    sort -nr |
    awk 'NR>3 {print substr($0, index($0,$2))}' |
    xargs --no-run-if-empty rm -f
}

trap cleanup_local_tmp EXIT

# 确保临时目录和 Syncthing 源目录存在
mkdir -p "${LOCAL_TMP_DIR}" "${DST_DIR}"
chmod 700 "${LOCAL_TMP_DIR}"
chown syncthing:syncthing "${DST_DIR}"
printf '.upload\n' > "${DST_DIR}/.stignore"
chown syncthing:syncthing "${DST_DIR}/.stignore"
chmod 644 "${DST_DIR}/.stignore"

# 打包到临时目录
tar -czf "${tmp_backup_file}" -C "${SRC_DIR}" .

# 修正属主和权限
chown syncthing:syncthing "${tmp_backup_file}"
chmod 644 "${tmp_backup_file}"

# 移动到 Syncthing 源目录
mv "${tmp_backup_file}" "${final_backup_file}"

# 只保留最新 3 个备份文件
cleanup_old_backups

赋权:

chmod +x /root/backup_docker_data_local.sh

手动执行一次:

/root/backup_docker_data_local.sh

如果要每天凌晨 4:30 自动执行:

30 4 * * * /root/backup_docker_data_local.sh >> /var/log/docker-data-backup-local.log 2>&1

关键点说明

1. 先上传到临时目录

远程上传版先把文件传到:

/srv/backup/.upload/xxx.tar.gz.upload

本地版则先写到:

/srv/backup/.upload/xxx.tar.gz.upload

完成后再移动到:

/srv/backup/xxx.tar.gz

这样 Syncthing 不会在 /srv/backup 里扫描到一个还在上传或还在压缩的半成品。

远程临时目录不要放在 /tmp。很多 VPS 的 /tmp 是小 tmpfs,上传 1GB 以上备份时很容易先把 /tmp 塞满,导致 scp 失败并留下 .upload 残片。把临时目录放在 ${DST_DIR}/.upload,既能保证和最终目录同文件系统,也能避免 Syncthing 看到半截文件。

本地版也一样不要写到 /tmp。如果 /tmp/srv/backup 是不同文件系统,mv 会退化成复制再删除,目标目录里可能短暂出现一个还没复制完的最终文件。临时目录放在 /srv/backup/.upload 后,最后一步 mv 才是同文件系统内的原子移动。

因为 .upload 是给 root 上传过程使用的临时目录,权限通常是 0700。脚本会在 /srv/backup/.stignore 写入:

.upload

这样 Syncthing 不会扫描 .upload,面板里也不会因为临时目录权限产生 permission denied 错误。

2. SSH 私钥适合定时任务

远程上传版默认推荐 SSH 私钥登录:

SSH_PRIVATE_KEY="/root/.ssh/id_rsa24"

定时备份不建议依赖交互式密码。脚本里的 SSH 和 SCP 都加了:

-o BatchMode=yes -o ConnectTimeout=20

如果密钥失效、远端不可达或网络异常,命令会明确失败,不会卡在密码提示里等到无人处理。

3. 失败时也清理本地旧包

本地清理函数会在脚本退出时执行:

trap cleanup_local_backups EXIT

所以即使上传失败,业务服务器 A 的 /root 下也不会一直堆旧的 docker_data_backup_*.tar.gz。当前脚本策略是本地只保留最新 1 个,远端 Syncthing 源目录只保留最新 3 个。

4. 修正属主和权限

远端执行:

chown syncthing:syncthing "${remote_tmp_file}"
chmod 644 "${remote_tmp_file}"

这一步保证最终进入 /srv/backup 的文件适合 Syncthing 读取和索引。

5. 只保留最新备份

远端清理会按修改时间排序,只保留最新 3 个:

find "${DST_DIR}" -maxdepth 1 -type f -name "${BACKUP_NAME_PREFIX}_*.${ARCHIVE_FORMAT}" -printf '%T@ %p\n' |
sort -nr |
awk 'NR>3 {print substr($0, index($0,$2))}' |
xargs --no-run-if-empty rm -f

本地清理同样按修改时间排序,只保留最新 1 个。旧文件在 Syncthing 源端删除后,备份端也会同步删除。这适合“镜像式保留最近几份备份”的场景。如果需要防误删归档,应在最终备份端另外做快照或版本保留。

加入定时任务

每天凌晨 4:30 执行:

crontab -e

写入:

30 4 * * * /root/backup_docker_data.sh >> /var/log/docker-data-backup.log 2>&1

然后查看日志:

tail -n 100 /var/log/docker-data-backup.log

验证上传和同步

在远端服务器 B 上检查:

ls -lh /srv/backup

正常能看到类似:

-rw-r--r-- 1 syncthing syncthing 1.7G May 24 22:55 docker_data_backup_20260524_224856.tar.gz

再检查 Syncthing 面板,源端文件夹应该逐渐变成 最新。备份端也应收到同名文件,属主通常也是 syncthing:syncthing

如果 Syncthing 长时间不传,可以优先检查:

  • 远端 /srv/backup 是否真的出现了新文件。
  • 文件权限是否至少可被 syncthing 用户读取。
  • Syncthing 源端文件夹路径是否是 /srv/backup
  • 远程设备是否连接,文件夹是否共享给备份端。
  • 备份端是否还有足够磁盘空间。

小结

这类脚本的重点不是复杂,而是把几个边界处理好:备份文件先传临时目录,传完再进入 Syncthing 源目录;进入源目录前修正属主和权限;定期删除旧备份,让 Syncthing 把删除动作同步到备份端。这样一来,业务服务器只需要负责打包和上传,后面的分发就交给 Syncthing。