这篇记录一个很实用的小脚本:在业务服务器上把 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=yes 和 ConnectTimeout。这样定时任务不会因为密码提示、网络抖动或远端不可达而一直挂住。
完整脚本
下面这份是“业务服务器 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
'看到 ok 和 permission-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 源目录在同一台机器上,就不需要 ssh 和 scp。仍然建议先打包到本地临时目录,打包完成后再移动到 /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。