跳转至

用自己的对象存储做 git-lfs 后端:不起服务,只要一个脚本

git-lfs 的存储后端是可插拔的 —— 不必买 GitHub 的 LFS 配额,也不必自建 LFS server。写一个 standalone custom transfer agent 就能把二进制真身指向自己的对象存储桶:git 在 push/pull 时把它当子进程拉起,stdin/stdout 上聊四种 JSON 事件,用完即退。没有常驻进程,不监听端口,不需要域名和证书。

本站就这么跑着,agent 是 140 行 Python,凭证复用项目 .env,零新依赖。这篇把机制、协议和可迁移的部分提炼出来;本站为什么走到这一步、以及具体的桶划分,在部署实操手册 · 资源备份

为什么二进制不该进 git

git 的存储模型假设内容可 diff。图片、视频、字体、min.js 都不满足 —— 改一个像素就在历史里躺一份全量,而且永远删不掉(除非重写历史)。仓库只会单向变胖。

git-lfs 换掉的正是这一层:git 里只留一百来字节的指针文本,真身放外面。

version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 12345

指针照常参与 commit、diff、merge、checkout —— 版本控制的语义完好,只是内容被挪到了别处。别处在哪,由 transfer agent 说了算。

机制:standalone agent 把 LFS server 一起跳过了

git-lfs 默认的路子是两段:客户端先向 LFS API(https://<host>/<repo>.git/info/lfs)发 batch 请求,服务端回一批预签名 URL,客户端再照着 URL 传。这条路要有服务、有域名、有鉴权。

custom transfer agent 换掉的是第二段"传"。而配上 lfs.standalonetransferagent 之后,第一段 batch 请求也一并跳过 —— git-lfs 不再联系任何服务端,把活儿整个交给本地程序:

sequenceDiagram
    autonumber
    participant G as git push
    participant L as git-lfs 客户端
    participant A as agent 子进程
    participant C as 对象存储桶
    G->>L: 3 个文件命中 .gitattributes
    L->>A: 拉起进程 · init 事件
    A-->>L: 空对象 = 就绪
    loop 每个 blob
        L->>A: upload 事件(oid + 本地 path)
        A->>C: HEAD lfs/ab/cd/oid
        alt 桶里没有
            A->>C: PUT 真身
        end
        A-->>L: complete 事件
    end
    L->>A: terminate 事件
    A-->>L: 进程退出

agent 是子进程不是服务:push 完就死,不占内存、不用守护、不用 systemd。

协议:四种事件,JSON-lines

一行一个 JSON,stdin 进 stdout 出。全部协议就这四种事件(定义见 git-lfs 仓库的 docs/custom-transfers.md):

事件 git-lfs → agent agent → git-lfs
init operation(upload/download)、concurrent 等一次性参数 {} 表示就绪;失败回 {"error":{"code":…,"message":…}}
upload oidsizepath —— 真身已经在本地这个路径上 {"event":"complete","oid":…}
download oidsize —— 没有 path,落盘是 agent 的活 {"event":"complete","oid":…,"path":"<落盘路径>"}
terminate 无字段 不回,直接退出

upload 和 download 的不对称是这套协议最容易读漏的地方:上传时 git-lfs 把真身准备好了,agent 只管读走;下载时 agent 要自己写出文件、把路径报回去,git-lfs 再把这个文件.git/lfs/objects

"移"是 rename 不是 copy —— 直接决定了临时文件该落在哪,见下面的坑。

实现:agent 只是个哑搬运工

难的部分 git-lfs 客户端全包了:并发、本地缓存(.git/lfs/objects)、sha256 完整性校验、增量判断(哪些 blob 需要传)。agent 剩下的活只有"把这个 oid 存进桶 / 从桶里取出来"。

四个设计点值得单独说。

内容寻址:sha256 分两级目录

def key(self, oid):
    # 内容寻址:sha256 前两级做目录,避免单层百万对象
    return f"{self.prefix}/{oid[:2]}/{oid[2:4]}/{oid}"

oid 就是文件内容的 sha256,git-lfs 直接给。用它当 key 白送三个性质:去重(同一张图出现在几篇文章、几个历史版本里,桶里只有一份)、幂等(重复 push 秒过)、全历史可还原(桶只增不减,checkout 任意旧 commit,当时的图原样回来)。

分两级目录是给对象存储的控制台和批量工具留活路 —— 百万对象平铺在单层前缀下,列举会很难受。

内容寻址下 oid 相同则内容必然相同,所以桶里已有就直接回 complete,不重传。git-lfs 自己也会算增量,但它的判断基于本地状态;HEAD 一次是站在桶这边的兜底 —— 换台机器、重置历史、重复 push 都不会白传。

下载的临时文件必须跟仓库同一个文件系统

git-lfs 拿到 agent 报的 path 之后是 rename.git/lfs/objects。临时文件落在 /tmp 而仓库在另一块盘上,rename 直接失败(EXDEV: cross-device link),而且报错信息不会指向真正的原因。

所以 agent 先问 git 要 .git 的绝对路径,把临时文件落在 .git/lfs/tmp/

gitdir = subprocess.check_output(
    ["git", "rev-parse", "--absolute-git-dir"], text=True
).strip()
self.tmpdir = Path(gitdir) / "lfs" / "tmp"

不用管并发

agent 串行读 stdin 就行。git-lfs 的并发是多开几个 agent 进程,不是往一个进程里塞多个请求。所以不需要线程、不需要锁,一个 for line in sys.stdin 到底。

代价是进度条:agent 可以选择不发 progress 事件,于是 push 时显示 0 B/s。传输本身正常,只是没人报数。

scripts/lfs-cos-agent.py —— 完整 agent(~140 行,腾讯云 COS 后端)
#!/usr/bin/env python3
"""git-lfs standalone custom transfer agent,后端为腾讯云 COS 备份桶。

由 git-lfs 在 push/pull 时按需拉起(scripts/setup-lfs.sh 负责注册到 git config),
stdin/stdout 走 JSON-lines 协议;每个进程串行处理,git-lfs 靠多开进程实现并发。
协议:https://github.com/git-lfs/git-lfs/blob/main/docs/custom-transfers.md

用法(由 lfs.customtransfer.cos.args 传入):
  lfs-cos-agent.py [前缀]   # blob 存 cos://$COS_BACKUP_BUCKET/<前缀>/ab/cd/<oid>,默认 lfs

凭证复用项目 .env(COS_SECRET_ID/KEY、COS_REGION、COS_BACKUP_BUCKET)。
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

PROJECT_DIR = Path(__file__).resolve().parent.parent


def load_env():
    env = {}
    for line in (PROJECT_DIR / ".env").read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, _, v = line.partition("=")
        env[k.strip()] = v.strip().strip("'\"")
    return env


def reply(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()


def log(msg):
    print(f"[lfs-cos-agent] {msg}", file=sys.stderr, flush=True)


class Agent:
    def __init__(self, prefix):
        self.prefix = prefix.strip("/")
        self.client = None
        self.bucket = None
        self.tmpdir = None

    def key(self, oid):
        # 内容寻址:sha256 前两级做目录,避免单层百万对象
        return f"{self.prefix}/{oid[:2]}/{oid[2:4]}/{oid}"

    def handle_init(self, msg):
        try:
            from qcloud_cos import CosConfig, CosS3Client

            env = load_env()
            self.bucket = env["COS_BACKUP_BUCKET"]
            self.client = CosS3Client(
                CosConfig(
                    Region=env["COS_REGION"],
                    SecretId=env["COS_SECRET_ID"],
                    SecretKey=env["COS_SECRET_KEY"],
                )
            )
            # 下载先落 .git/lfs/tmp:与仓库同文件系统,git-lfs 移走时 rename 不跨设备
            try:
                gitdir = subprocess.check_output(
                    ["git", "rev-parse", "--absolute-git-dir"], text=True
                ).strip()
                self.tmpdir = Path(gitdir) / "lfs" / "tmp"
                self.tmpdir.mkdir(parents=True, exist_ok=True)
            except Exception:
                self.tmpdir = Path(tempfile.mkdtemp(prefix="lfs-cos-"))
            reply({})
        except Exception as e:
            reply({"error": {"code": 1, "message": f"init 失败: {e}"}})

    def handle_upload(self, msg):
        oid = msg["oid"]
        try:
            key = self.key(oid)
            if self.client.object_exists(Bucket=self.bucket, Key=key):
                log(f"跳过(桶里已有){oid[:12]}")
            else:
                self.client.upload_file(
                    Bucket=self.bucket, Key=key, LocalFilePath=msg["path"]
                )
                log(f"上传 {msg.get('size', '?')}B {oid[:12]}")
            reply({"event": "complete", "oid": oid})
        except Exception as e:
            reply(
                {
                    "event": "complete",
                    "oid": oid,
                    "error": {"code": 2, "message": f"上传 {self.key(oid)} 失败: {e}"},
                }
            )

    def handle_download(self, msg):
        oid = msg["oid"]
        try:
            fd, tmp = tempfile.mkstemp(dir=self.tmpdir, prefix=oid[:12] + "-")
            os.close(fd)
            self.client.download_file(
                Bucket=self.bucket, Key=self.key(oid), DestFilePath=tmp
            )
            log(f"下载 {msg.get('size', '?')}B {oid[:12]}")
            reply({"event": "complete", "oid": oid, "path": tmp})
        except Exception as e:
            reply(
                {
                    "event": "complete",
                    "oid": oid,
                    "error": {"code": 2, "message": f"下载 {self.key(oid)} 失败: {e}"},
                }
            )


def main():
    agent = Agent(sys.argv[1] if len(sys.argv) > 1 else "lfs")
    handlers = {
        "init": agent.handle_init,
        "upload": agent.handle_upload,
        "download": agent.handle_download,
    }
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        msg = json.loads(line)
        event = msg.get("event")
        if event == "terminate":
            break
        if event in handlers:
            handlers[event](msg)
        else:
            log(f"忽略未知事件: {event}")


if __name__ == "__main__":
    main()

接入:三行 git config

git lfs install --local
git config lfs.customtransfer.cos.path "$PROJECT_DIR/.venv/bin/python"
git config lfs.customtransfer.cos.args "$PROJECT_DIR/scripts/lfs-cos-agent.py lfs"
git config lfs.standalonetransferagent cos

cos 只是自取的 agent 名字,三处对上即可。path 指解释器、args 第一项指脚本,是为了不依赖 shebang 和 PATH —— 虚拟环境里的 Python 直接点名。

这几行只能落本地 git config,写不进仓库里的 .lfsconfig git-lfs 出于安全不从仓库读 agent 路径 —— 否则 clone 一个陌生仓库就等于执行它指定的任意程序。代价是每台机器、每个 clone 要跑一次接入脚本;收益是 clone 别人的仓库永远不会被静默拉起程序。这笔账值。

哪些文件走 LFS 由 .gitattributes 划定,这个是随仓库走的:

*.png  filter=lfs diff=lfs merge=lfs -text
*.woff2 filter=lfs diff=lfs merge=lfs -text
docs/vendor/echarts/** filter=lfs diff=lfs merge=lfs -text

分界线不是"大小",是文本性:diff 有意义的进 git(md、svg、代码),diff 无意义的进 LFS(位图、视频、字体、第三方 min.js)。规则写好之后加新文件零操作,git add 自动接管。

scripts/setup-lfs.sh —— 每个 clone 一次性接入
#!/usr/bin/env bash
# 把"当前所在的 git 仓库"接上 COS 后端的 git-lfs(scripts/lfs-cos-agent.py)。
#
# 每台机器 / 每个 clone 跑一次(agent 路径出于安全不能写进仓库内 .lfsconfig,
# 只能落在本地 git config)。在目标仓库目录里执行:
#   bash /path/to/wiki/scripts/setup-lfs.sh          # 前缀默认 lfs
#   bash /path/to/wiki/scripts/setup-lfs.sh lfs-test # 测试仓用独立前缀
#
# 依赖:本机装有 git-lfs;wiki 项目的 .venv(qcloud_cos)与 .env(COS 凭证)。
set -euo pipefail

PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PREFIX="${1:-lfs}"

git rev-parse --is-inside-work-tree >/dev/null || { echo "请在目标 git 仓库里执行" >&2; exit 1; }
[ -x "$PROJECT_DIR/.venv/bin/python" ] || { echo "缺 $PROJECT_DIR/.venv,先 uv sync" >&2; exit 1; }
[ -f "$PROJECT_DIR/.env" ] || { echo "缺 $PROJECT_DIR/.env(COS 凭证)" >&2; exit 1; }

git lfs install --local
git config lfs.customtransfer.cos.path "$PROJECT_DIR/.venv/bin/python"
git config lfs.customtransfer.cos.args "$PROJECT_DIR/scripts/lfs-cos-agent.py $PREFIX"
git config lfs.standalonetransferagent cos

echo "✅ 已接入 COS-LFS:blob → cos://\$COS_BACKUP_BUCKET/$PREFIX/(凭证读 $PROJECT_DIR/.env)"

新机器三步

GIT_LFS_SKIP_SMUDGE=1 git clone <repo> && cd <repo>
bash scripts/setup-lfs.sh     # 把 agent 注册进这个 clone 的 git config
git lfs pull                  # 还原全部真身

第一步的 GIT_LFS_SKIP_SMUDGE=1 不是可选项:clone 的那一刻 agent 还没注册,git-lfs 会去联系默认后端(GitHub 的 LFS 服务)然后扑空 —— 那边从来没收到过这些对象。跳过 smudge 让 clone 先拿到指针,接入之后再 git lfs pull 一次取回。

换成 S3 / MinIO / R2 / OSS

只有三个调用碰 SDK,其余都是协议层,一行不用改:

动作 腾讯云 qcloud_cos boto3(S3 / MinIO / R2)
存在性 client.object_exists(Bucket, Key) head_object,捕 ClientError 的 404
上传 upload_file(Bucket, Key, LocalFilePath) upload_file(Filename, Bucket, Key)
下载 download_file(Bucket, Key, DestFilePath) download_file(Bucket, Key, Filename)

任何提供"存在性 + 上传 + 下载"三件套的存储都能当后端 —— 换成 WebDAV、NAS 挂载点甚至另一台机器的 rsync 目标,协议这边完全无感。

这套方案不适合谁

  • 要给不持钥的人开放协作:agent 直接拿密钥读写桶,凡是碰媒体的机器都得有密钥。真要开放,得把存储层搬进一个签发预签名 URL 的 LFS Batch API 服务 —— 升级路径是平滑的(三个存储调用原样搬过去),但那就重新变成"要起服务"了。
  • 要压存储成本:内容寻址的桶只增不减,历史版本永久保留。这既是"全历史可还原"的来源,也是账单的来源。
  • 历史里已经躺着大文件:换 agent 只管新增。旧的胖历史得 git lfs migrate 或者重置历史才能瘦下来。

多机分发建议开子账号(腾讯云 CAM / AWS IAM),只授权这一个桶的读写,主密钥不离开主机。

纯文字协作者倒是零成本:GIT_LFS_SKIP_SMUDGE=1 clone 之后不需要任何密钥,工作区里图片是指针文本,md 照常编辑、照常 push —— 只是本地看不了图。


活例是本站自己:全部图片、视频、字体和第三方 vendor 真身都走这条链路,GitHub 上只有文本和指针。演进过程(塞 git → 手动镜像 → 现在这套)和它带来的写作链路变化,见部署实操手册 · 资源备份建站手记