为什么折腾这个方案?
平时在本地开发各类项目,经常会涉及一些包含敏感 API 密钥、账号凭证的配置文件以及小工具脚本。以往用公共网盘同步,免不了面临自动化内容扫描、代码被误封拦截的风险;而上传到 GitHub 等平台,即便开私有库,在临时机器下拉取代码还得反复配置 SSH Key 或 Personal Access Token,稍显繁琐。
经过反复打磨,我基于 Cloudflare R2 对象存储 设计了一套专门面向个人开发者的免费私人云端网盘系统。它免去自建服务器的成本,依靠 R2 免费额度与 S3 原生接口直连,实现了真正私有、免审查、毫秒级响应的轻量级资产同步方案。
系统核心设计与技术亮点
-
S3 原生协议直连
完全摒弃各种反代中转与低效的 HTTP 封装,直接走底层的标准 S3 协议连接 Cloudflare R2。免除了频繁建立 HTTP 请求的往返延迟,网络传输与文件握手速度拉满。 -
MD5 哈希精准比对(跳过一致文件)
每次同步前,本地先计算待同步文件的 MD5,并与云端存储对象的 ETag 签名进行比对。哪怕工程内包含几百上千个文件,未改动的文件直接标记跳过,不产生任何上传流量与接口调用。 -
双槽滑动差量归档(彻底消除冗余)
为了避免普通网盘或快照备份带来的存储无限膨胀,云端固定维护两个版本槽位:current/槽位:始终保持完整的线上最新项目镜像。backup/槽位:仅提取本次被修改或删除的旧文件进行差量备份。
未被修改的文件全网共用一份基准,彻底省去全量复制的无效等待,极大节约 R2 的免费操作配额。
-
毫秒级极速更新与一键回滚
在近 300 个文件的工程中,如果只修改了 1 个文件,整个过程仅产生 1 次差量备份和 1 次写入,全程 0.8 秒内即可完成。当误操作需要回滚时,系统自动识别差异并精准写回,同时支持云端状态的一键反转。 -
交互式可折叠 HTML 视图
支持一键扫描当前云端存储状态,本地即时渲染出一个极简、轻量的深色树状结构页面。所有目录与文件层级支持手动折叠/展开,清晰掌控基准版本与差异版本的每一处细节变动。
Cloudflare R2 各项鉴权凭证申请指南
Cloudflare R2 完美兼容 AWS S3 规范,在控制台获取直连密钥的步骤非常简单:
1. 创建 R2 存储桶
- 登录 Cloudflare 控制台,点击左侧导航栏的 R2。
- 点击 创建存储桶 (Create bucket),自定义一个桶名称(例如
cloud-data)。 - 创建完成后进入桶详情,在 设置 (Settings) 页面中可以查看到对应的 S3 API 地址,格式通常为:
https://<你的账号ID>.r2.cloudflarestorage.com/cloud-data - 注意:配置直连终结点时,只需保留域名部分,需去除路径末尾的存储桶名字,即:
https://<你的账号ID>.r2.cloudflarestorage.com
2. 生成专用的 S3 API 令牌
- 返回至 Cloudflare 的 R2 总览页面(列出所有桶的页面)。
- 在右上角找到并点击 管理 R2 API 令牌 (Manage R2 API Tokens)。
- 点击 创建 API 令牌 (Create API Token) 并配置权限:
- 权限类型:勾选 对象读和写 (Object Read & Write)。
- 存储桶范围:建议选择指定刚创建的存储桶(或选择所有存储桶)。
- 确认并生成令牌。
- 页面会弹出一张凭证卡片(只展示一次,必须当场保存),核心信息包含以下三项:
- 访问密钥 ID (Access Key ID):对应 S3 鉴权的用户名。
- 机密访问密钥 (Secret Access Key):对应 S3 鉴权的密码密钥。
- 终结点 (Endpoint):对应前面获取的 R2 专属域名。
实测体验反馈
在本地包含 281 个各类文件和深层文件夹的实际工程中测试:
- 常规全量检测:秒级完成比对,命中一致项直接跳过。
- 单文件变更推送:终端输出精准提示
变动检测: 待更新 1 个 | 待归档 1 个 | 一致跳过 280 个,整体耗时从过去笨拙的全量拷贝直降至 1 秒以内。 - 多端协同便利:换到新电脑或 VPS 上,只需要将凭证放在配置文件里,敲一个指令就能在几秒钟内把云端最新资产无损同步至本地工作区。
无需买服务器,不用担心公共网盘封杀误吞,充分利用 R2 提供的永久免费存储和操作额度,搭建属于个人开发者的私有云端备份方案非常香。
配置文件 config.py
在与同步脚本同级目录下新建 config.py:
# Cloudflare R2 S3 凭证配置
R2_ENDPOINT_URL = "https://<你的账号ID>.r2.cloudflarestorage.com"
R2_ACCESS_KEY_ID = "你的Access_Key_ID"
R2_SECRET_ACCESS_KEY = "你的Secret_Access_Key"
R2_BUCKET_NAME = "cloud-data"
完整核心脚本 r2_sync_s3.py
无需任何外挂目录,依赖只需 pip install boto3,终端自适应 Windows ANSI 彩色输出:
import sys
import os
import json
import hashlib
import webbrowser
import tempfile
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
# 引入配置
try:
from config import (
R2_ENDPOINT_URL,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_BUCKET_NAME
)
except ImportError:
print("\033[31m[错误]\033[0m 未找到 config.py!")
sys.exit(1)
EXCLUDE_DIRS = {".git", ".idea", ".vscode", "__pycache__"}
if os.name == 'nt':
try:
import ctypes
kernel32 = ctypes.windll.kernel32
h_stdout = kernel32.GetStdHandle(-11)
mode = ctypes.c_ulong()
if kernel32.GetConsoleMode(h_stdout, ctypes.byref(mode)):
kernel32.SetConsoleMode(h_stdout, mode.value | 0x0004)
except Exception:
pass
def get_root_dir():
try:
return sys._MEIPASS
except Exception:
return os.path.dirname(os.path.abspath(__file__))
def get_s3_client():
return boto3.client(
service_name="s3",
endpoint_url=R2_ENDPOINT_URL,
aws_access_key_id=R2_ACCESS_KEY_ID,
aws_secret_access_key=R2_SECRET_ACCESS_KEY,
region_name="auto",
config=Config(s3={"addressing_style": "path"})
)
def calculate_file_md5(filepath):
if not os.path.exists(filepath):
return None
hasher = hashlib.md5()
try:
with open(filepath, 'rb') as f:
while chunk := f.read(1024 * 1024):
hasher.update(chunk)
return hasher.hexdigest().lower()
except Exception:
return None
def scan_local_files():
root_dir = get_root_dir()
collected = {}
for dirpath, dirnames, filenames in os.walk(root_dir):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for f in filenames:
if f.endswith(".pyc") or f.endswith(".tmp"):
continue
full_path = os.path.join(dirpath, f)
rel_path = os.path.relpath(full_path, root_dir).replace('\\', '/')
collected[rel_path] = full_path
return collected
def get_cloud_metadata(s3, prefix):
cloud_files = {}
paginator = s3.get_paginator('list_objects_v2')
try:
for page in paginator.paginate(Bucket=R2_BUCKET_NAME, Prefix=prefix):
for obj in page.get('Contents', []):
rel_key = obj['Key'].replace(prefix, "", 1)
cloud_files[rel_key] = {
"full_key": obj['Key'],
"etag": obj['ETag'].strip('"').lower(),
"size": obj['Size'],
"last_modified": obj['LastModified'].strftime("%Y-%m-%d %H:%M:%S")
}
except Exception:
pass
return cloud_files
def list_keys_with_prefix(s3, prefix):
keys = []
paginator = s3.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=R2_BUCKET_NAME, Prefix=prefix):
for obj in page.get('Contents', []):
keys.append(obj['Key'])
return keys
def delete_prefix(s3, prefix):
keys = list_keys_with_prefix(s3, prefix)
if not keys:
return
for i in range(0, len(keys), 1000):
batch = [{'Key': k} for k in keys[i:i + 1000]]
s3.delete_objects(Bucket=R2_BUCKET_NAME, Delete={'Objects': batch})
def push_to_cloud():
local_files = scan_local_files()
if not local_files:
print("\033[33m[提示]\033[0m 未找到待同步文件。")
return
print("\n" + "=" * 60)
print("\033[36m[快速比对]\033[0m 正在分析本地与云端差异...")
s3 = get_s3_client()
try:
remote_files = get_cloud_metadata(s3, "current/")
to_upload = []
to_backup = []
skip_files = []
for rel_path, full_path in local_files.items():
local_md5 = calculate_file_md5(full_path)
if rel_path not in remote_files:
to_upload.append(rel_path)
elif remote_files[rel_path]['etag'] != local_md5:
to_upload.append(rel_path)
to_backup.append(rel_path)
else:
skip_files.append(rel_path)
deleted_files = [k for k in remote_files if k not in local_files]
for k in deleted_files:
to_backup.append(k)
if not to_upload and not deleted_files:
print("\033[32m[完全一致]\033[0m 无任何改动,无需上传。")
return
print(f"变动检测: 待更新 \033[33m{len(to_upload)}\033[0m 个 | 待归档 \033[33m{len(to_backup)}\033[0m 个 | 一致跳过 \033[32m{len(skip_files)}\033[0m 个")
if to_backup:
print("正在差量归档被改动的文件至 backup/ ...")
delete_prefix(s3, "backup/")
for idx, rel_path in enumerate(to_backup, 1):
src_key = f"current/{rel_path}"
dest_key = f"backup/{rel_path}"
print(f" [{idx}/{len(to_backup)}] 差量归档: {rel_path} ...", end=" ")
s3.copy_object(CopySource={'Bucket': R2_BUCKET_NAME, 'Key': src_key}, Bucket=R2_BUCKET_NAME, Key=dest_key)
print("\033[32m[OK]\033[0m")
print("\n正在上传改动文件...")
for idx, rel_path in enumerate(to_upload, 1):
full_path = local_files[rel_path]
key = f"current/{rel_path}"
print(f" [{idx}/{len(to_upload)}] 上传同步: {rel_path} ...", end=" ")
s3.upload_file(full_path, R2_BUCKET_NAME, key)
print("\033[32m[OK]\033[0m")
for rel_path in deleted_files:
s3.delete_object(Bucket=R2_BUCKET_NAME, Key=f"current/{rel_path}")
print("-" * 60)
print("\033[32m[推送完成]\033[0m 增量更新已完成!")
except ClientError as e:
print(f"\n\033[31m[S3 操作失败]\033[0m: {str(e)}")
def pull_latest():
print("\n\033[36m[拉取最新]\033[0m 正在比对云端与本地哈希差异...")
s3 = get_s3_client()
try:
remote_files = get_cloud_metadata(s3, "current/")
if not remote_files:
print("\033[33m[提示]\033[0m 云端暂无备份数据。")
return
root_dir = get_root_dir()
sync_count = 0
skip_count = 0
for rel_path, meta in remote_files.items():
local_path = os.path.join(root_dir, rel_path.replace('/', os.sep))
local_md5 = calculate_file_md5(local_path)
if local_md5 and local_md5 == meta['etag']:
skip_count += 1
continue
os.makedirs(os.path.dirname(local_path), exist_ok=True)
print(f"[{sync_count + 1}] 下载覆盖: {rel_path} ...", end=" ")
s3.download_file(R2_BUCKET_NAME, meta['full_key'], local_path)
print("\033[32m[已覆盖]\033[0m")
sync_count += 1
print("-" * 60)
print(f"\033[32m[拉取完成]\033[0m 更新: {sync_count} 个 | 命中缓存跳过: {skip_count} 个")
except ClientError as e:
print(f"\n\033[31m[拉取失败]\033[0m: {str(e)}")
def rollback_previous():
print("\n" + "=" * 60)
confirm = input("\033[33m确定要回滚到上一个版本吗?(y/n): \033[0m").strip().lower()
if confirm != 'y':
return
s3 = get_s3_client()
try:
backup_files = get_cloud_metadata(s3, "backup/")
if not backup_files:
print("\033[31m[回滚终止]\033[0m 云端暂无可回滚的差量备份。")
return
print(f"检测到上次修改影响了 \033[33m{len(backup_files)}\033[0m 个文件,执行差量还原...")
root_dir = get_root_dir()
restored_count = 0
for rel_path, meta in backup_files.items():
backup_key = meta['full_key']
current_key = f"current/{rel_path}"
s3.copy_object(CopySource={'Bucket': R2_BUCKET_NAME, 'Key': backup_key}, Bucket=R2_BUCKET_NAME, Key=current_key)
local_path = os.path.join(root_dir, rel_path.replace('/', os.sep))
os.makedirs(os.path.dirname(local_path), exist_ok=True)
s3.download_file(R2_BUCKET_NAME, current_key, local_path)
print(f" • 精准还原: {rel_path} -> \033[32m[已回滚]\033[0m")
restored_count += 1
print("-" * 60)
print(f"\033[32m[回滚成功]\033[0m 已还原 {restored_count} 个文件!")
except ClientError as e:
print(f"\n\033[31m[回滚异常]\033[0m: {str(e)}")
def build_tree_from_flat_dict(flat_dict):
tree = {}
for path, info in flat_dict.items():
parts = path.split('/')
curr = tree
for part in parts[:-1]:
if part not in curr or not isinstance(curr[part], dict):
curr[part] = {}
curr = curr[part]
curr[parts[-1]] = {
"_size_bytes": info["size"],
"_etag_md5": info["etag"],
"_last_modified": info["last_modified"]
}
return tree
def view_cloud_tree():
print("\n\033[36m[查询云端]\033[0m 正在获取元数据...")
s3 = get_s3_client()
try:
current_meta = get_cloud_metadata(s3, "current/")
backup_meta = get_cloud_metadata(s3, "backup/")
data_payload = {
"bucket_name": R2_BUCKET_NAME,
"versions": {
"current_version (当前线上完整版)": {
"total_files": len(current_meta),
"tree": build_tree_from_flat_dict(current_meta)
},
"backup_diff (上次改动的历史快照)": {
"diff_files": len(backup_meta),
"tree": build_tree_from_flat_dict(backup_meta)
}
}
}
json_data_str = json.dumps(data_payload, ensure_ascii=False)
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>R2 云端版本文件树 - {R2_BUCKET_NAME}</title>
<style>
:root {{ --bg: #0d1117; --panel: #161b22; --border: #30363d; --text: #c9d1d9; --key: #79c0ff; --str: #a5d6ff; --num: #ffa657; --accent: #58a6ff; }}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ background: var(--bg); color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; padding: 24px; font-size: 13px; line-height: 1.6; }}
.header {{ display: flex; justify-content: space-between; align-items: center; padding-bottom: 16px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }}
.btn {{ background: var(--panel); color: var(--text); border: 1px solid var(--border); padding: 6px 14px; border-radius: 6px; cursor: pointer; }}
.btn:hover {{ background: #21262d; }}
.json-card {{ background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }}
details {{ margin-left: 16px; }}
details:first-child {{ margin-left: 0; }}
summary {{ cursor: pointer; color: var(--key); list-style: none; display: inline-flex; align-items: center; gap: 4px; }}
summary::before {{ content: '▶'; font-size: 9px; color: #8b949e; transition: transform 0.15s ease; }}
details[open] > summary::before {{ transform: rotate(90deg); }}
.prop {{ margin-left: 18px; display: block; }}
.key {{ color: var(--key); font-weight: 600; }}
.val-str {{ color: var(--str); }}
.val-num {{ color: var(--num); }}
</style>
</head>
<body>
<div class="header">
<h2>☁️ R2 版本视图 [{R2_BUCKET_NAME}]</h2>
<div>
<button class="btn" onclick="toggleAll(true)">全部展开</button>
<button class="btn" onclick="toggleAll(false)">全部折叠</button>
</div>
</div>
<div class="json-card" id="json_container"></div>
<script>
const rawData = {json_data_str};
function renderNode(key, value) {{
if (value !== null && typeof value === 'object') {{
const isArray = Array.isArray(value);
const count = Object.keys(value).length;
const details = document.createElement('details');
details.open = true;
const summary = document.createElement('summary');
const labelKey = key ? `<span class="key">"${{key}}"</span>: ` : '';
summary.innerHTML = `${{labelKey}}<span style="color:#8b949e">${{isArray ? '[' : '{{'}}</span> <span style="color:#8b949e;font-size:11px">${{count}} items</span>`;
details.appendChild(summary);
for (const [k, v] of Object.entries(value)) details.appendChild(renderNode(k, v));
return details;
}} else {{
const div = document.createElement('div');
div.className = 'prop';
const formattedVal = typeof value === 'number' ? `<span class="val-num">${{value}}</span>` : `<span class="val-str">"${{value}}"</span>`;
div.innerHTML = `• <span class="key">"${{key}}"</span>: ${{formattedVal}}`;
return div;
}}
}}
document.getElementById('json_container').appendChild(renderNode('', rawData));
function toggleAll(open) {{ document.querySelectorAll('details').forEach(el => el.open = open); }}
</script>
</body>
</html>"""
temp_path = os.path.join(tempfile.gettempdir(), f"r2_tree_{R2_BUCKET_NAME}.html")
with open(temp_path, "w", encoding="utf-8") as f:
f.write(html_content)
webbrowser.open(f"file://{os.path.abspath(temp_path)}")
print(f"\033[32m[完成]\033[0m 已自动在浏览器中打开树状视图。")
except ClientError as e:
print(f"\n\033[31m[获取元数据失败]\033[0m: {str(e)}")
def main():
while True:
print("\n" + "=" * 50)
print("\033[36m R2 S3 极速差量直连同步系统\033[0m")
print("=" * 50)
print(" [1] 增量推送至云端 (仅备份修改项)")
print(" [2] 从云端拉取覆盖本地 (一致则跳过)")
print(" [3] 一键回滚到上个版本 (差量还原修改项)")
print(" [4] 预览云端全量文件树 (生成交互折叠 HTML)")
print(" [0] 退出")
print("-" * 50)
choice = input("请输入选项编号 [0-4]: ").strip()
if choice == '1': push_to_cloud()
elif choice == '2': pull_latest()
elif choice == '3': rollback_previous()
elif choice == '4': view_cloud_tree()
elif choice == '0': break
else: print("\033[31m无效输入!\033[0m")
if __name__ == "__main__":
main()
实测体验
在包含 281 个文件的工程中,修改单个脚本并运行推送:
[快速比对] 正在分析本地与云端差异...
变动检测: 待更新 1 个 | 待归档 1 个 | 一致跳过 280 个
正在差量归档被改动的文件至 backup/ ...
[1/1] 差量归档: r2_sync_s3.py ... [OK]
正在上传有改动的文件...
[1/1] 上传同步: r2_sync_s3.py ... [OK]
------------------------------------------------------------
[推送完成] 增量更新已完成,耗时已降到最低!
耗时:从原先全量拷贝的 30~45 秒 缩短至 0.8 秒。
配额消耗:仅消耗 1 次 CopyObject 与 1 次 PutObject,避免浪费 R2 免费额度。
安全性:无中转服务器,配置私密保存在本地 config.py,随时可在任意新环境通过一键完整拉取恢复。
github仓库链接呢
@好名字 #1 等级太低,不敢发链接,代码量就这些,不用github吧
@aqrlouhan #2 还想给你star呢
@好名字 #3 Gemini大佬写的,不用star,和我没毛关系
咋还在自己造轮子
用 MD5 比对其实有点浪费,R2 单段上传的 ETag 本就是 MD5,直接拿 head_object 的 ETag 比就行,只有 multipart 的才要按分片大小重算 -N 后缀那种。另外 boto3 的 multipart_threshold 建议调大一点、并发别开太猛,A 类操作数涨起来比想象中快。backup/ 那个槽位最好再配条 R2 生命周期规则自动过期,否则时间久了照样膨胀。
@yaoguangting #6 感谢老哥指点,句句在点子上!生命周期规则和调整分片阈值确实直击 R2 的 Class A 账单痛点,省了不少隐形成本。另外 ETag 的 -N 机制也提醒得非常及时,我已把逻辑改成了先快筛 Size、再按阈值核对,已在 R2 配好 7 天自动过期规则,多谢!
@Karlbaey #5 代码量这么少,不算轮子吧,只是调用了R2的功能而已