syncbot
定时把一个 git 仓库(src)镜像推送到另一个 git 仓库(dst)的小机器人。 典型用途:把内网 GitLab / Gitea 上的仓库持续同步到 GitHub,dst 侧用一把 deploy key 授权。
- 单文件、低占用 — 静态二进制 6.4 MB,空转常驻内存约 11 MB,同步时的重活交给短命的
git子进程 - 配置热更新 — 改完
config.toml直接保存,几秒内生效;只有被改动的仓库会重启,其它仓库的计时器和进行中的同步不受影响 - 无状态、自愈 — 不存任何同步进度。每轮都直接问 src 和 dst 各自有哪些引用,只做必要的操作。有人手贱 force push 了 dst、上一次推送推到一半失败、容器被重建 —— 下一轮自动收敛
- 省流量 — 每轮先用
ls-remote探一下,src 没动就不 fetch,dst 已经一致就不 push - 不会误删 — src 突然变空(URL 写错、token 过期长这样)时拒绝把 dst 清空
- 凭据不落盘 — 带 token 的 URL 只在命令行里传,不写进
.git/config;日志和报错里的密码一律脱敏
快速开始
Docker Compose(推荐)
mkdir -p syncbot/keys && cd syncbot
curl -O https://raw.githubusercontent.com/OWNER/syncbot/main/docker-compose.yml
curl -o config.toml https://raw.githubusercontent.com/OWNER/syncbot/main/config.example.toml
# 编辑 docker-compose.yml 里的 image 和 config.toml 里的仓库地址
docker compose up -d
docker compose logs -f
直接跑二进制
go build -o syncbot .
./syncbot -config ./config.toml -check # 先校验配置
./syncbot -config ./config.toml
最小配置
[global]
work_dir = "/var/lib/syncbot"
[[repo]]
name = "my-project"
src = "https://gitlab.internal/team/my-project.git"
dst = "git@github.com:me/my-project.git"
interval = "5m"
ssh_key = "/etc/syncbot/keys/my-project"
完整示例见 config.example.toml。
给 bot 配 GitHub deploy key
# 1. 生成一把不带密码的 key(bot 无人值守,不能有 passphrase)
ssh-keygen -t ed25519 -N "" -C "syncbot" -f keys/my-project
# 2. 把公钥加到 GitHub:仓库 → Settings → Deploy keys → Add deploy key
cat keys/my-project.pub
# ⚠️ 必须勾选 "Allow write access",否则只能读不能推
# 3. 配置里指向私钥
# ssh_key = "/etc/syncbot/keys/my-project"
几个坑:
-
一把 deploy key 只能绑一个仓库。 GitHub 不允许同一把公钥重复添加到多个仓库(会报 key is already in use)。要镜像多个仓库就每个仓库生成一把,或者改用 machine user / GitHub App token 走 HTTPS。
-
私钥权限不用操心。 从 Kubernetes Secret 或
:ro挂载进来的 key 常常是 0644,ssh会直接拒绝。syncbot 会自动在私有临时目录做一份 0600 的副本再用,任务结束即删。 -
主机密钥校验。 默认
accept-new(首次连接自动信任并记到work_dir/home/known_hosts)。想更严格就先固定下来:ssh-keyscan github.com > keys/known_hostsknown_hosts = "/etc/syncbot/keys/known_hosts" strict_host_key = "yes"
配置
[global]
| 字段 | 默认值 | 说明 |
|---|---|---|
work_dir |
/var/lib/syncbot |
本地镜像仓库和 ssh 状态的存放目录,需要可写 |
interval |
5m |
默认轮询间隔 |
timeout |
30m |
单次同步的超时 |
concurrency |
min(4, CPU 核数) |
同时运行的 git 进程数上限,内存主要靠它控制 |
max_backoff |
1h |
连续失败后的退避上限 |
reload_interval |
5s |
多久检查一次配置文件有没有变 |
listen |
空(不监听) | HTTP 端点地址,如 :8080 |
log_level |
info |
debug / info / warn / error |
log_format |
text |
text / json |
[global] 里还可以写下面所有 [[repo]] 字段作为默认值。
[[repo]]
| 字段 | 默认值 | 说明 |
|---|---|---|
name |
必填 | 唯一标识,同时用作目录名和监控标签,限 [A-Za-z0-9._-] |
src |
必填 | 源仓库,字符串或表(见下) |
dst |
必填 | 目标仓库 |
enabled |
true |
设为 false 可临时停掉而不用删配置 |
interval / timeout / max_backoff |
继承 global | |
refs |
["refs/heads/*", "refs/tags/*"] |
镜像哪些引用,每条最多一个 * |
prune |
true |
src 上删掉的分支/标签,也在 dst 上删掉 |
force |
true |
允许非快进推送。上游 rebase 过就必须开 |
atomic |
false |
true 表示所有引用要么全成功要么全不动 |
allow_empty |
false |
允许「空的 src 清空 dst」,见下方保护机制 |
ssh_key |
无 | 私钥绝对路径,同时作用于 src 和 dst |
known_hosts |
无 | 固定的 known_hosts 文件 |
strict_host_key |
有 known_hosts 时 yes,否则 accept-new |
yes / no / accept-new |
git_config |
无 | 原样传给 git 的 -c key=value,用于压内存等 |
端点写法
src / dst 可以直接写成字符串:
src = "https://gitlab.internal/team/repo.git"
dst = "git@github.com:me/repo.git"
两边需要各自的凭据时写成表:
[repo.src]
url = "git@gitlab.internal:team/repo.git"
ssh_key = "/etc/syncbot/keys/gitlab"
[repo.dst]
url = "git@github.com:me/repo.git"
ssh_key = "/etc/syncbot/keys/github"
known_hosts = "/etc/syncbot/keys/known_hosts"
strict_host_key = "yes"
环境变量
URL 和路径里的 ${VAR} 会用环境变量展开,适合放 token:
src = "https://x-access-token:${SRC_TOKEN}@github.com/upstream/repo.git"
- 变量没定义会直接启动失败并指出名字,不会静默展开成空串给你一个坏掉的 URL
- 只认
${VAR},裸写的$VAR原样保留(密码里$很常见) - 环境变量在进程启动时读取,改环境变量需要重启
热更新
syncbot 每 reload_interval 比对一次配置文件内容的哈希,变了就重新加载;也可以 kill -HUP 立即触发。
重新加载时会把新配置和正在跑的任务做 diff:
- 新增的仓库 → 启动
- 删掉或
enabled = false的仓库 → 停掉,其正在进行的 git 操作会被取消 - 配置有改动的仓库 → 重启(新任务会等旧任务完全退出,避免两个进程同时动同一个镜像目录)
- 没改动的仓库 → 完全不动,计时器和进行中的同步都不受影响
几点说明:
- 配置写错了不会挂。 解析失败会记一条 ERROR,然后继续用上一份能跑的配置。修好保存即可恢复
log_level改了立即生效listen改了需要重启进程,日志里会 WARN 提示- 累计的
syncs/pushes计数在仓库重启后保留,只有仓库被删掉才清零
运维
命令行
syncbot -config PATH 配置文件路径(默认 /etc/syncbot/config.toml)
-check 校验配置并打印解析结果后退出
-once 所有仓库同步一次就退出(适合 cron / CI)
-version 打印版本
信号
| 信号 | 行为 |
|---|---|
SIGHUP |
立即重新加载配置 |
SIGTERM / SIGINT |
优雅退出,最多等 20 秒让进行中的同步收尾 |
HTTP 端点(需要设置 listen)
| 路径 | 说明 |
|---|---|
/healthz |
进程活着就返回 200,适合容器 healthcheck |
/readyz |
所有仓库都至少成功同步过一次才返回 200,否则 503 |
/status |
JSON,每个仓库的最后运行时间、失败次数、引用数、最后一次错误 |
/metrics |
Prometheus 文本格式 |
指标:syncbot_build_info、syncbot_uptime_seconds、syncbot_sync_total、
syncbot_push_total、syncbot_consecutive_failures、syncbot_refs、
syncbot_last_success_timestamp_seconds、syncbot_last_duration_seconds。
告警建议盯 syncbot_consecutive_failures > 0,或者
time() - syncbot_last_success_timestamp_seconds 超过 interval 的若干倍。
工作原理
每个仓库一个 goroutine、一个独立计时器,所以某个仓库卡住不会拖累别的。
一个全局信号量限制同时运行的 git 进程数(concurrency)。
单轮同步:
git ls-remote问 src 现在有哪些引用- 和本地镜像(
work_dir/mirrors/<name>.git)比对 → 一样就跳过 fetch - 不一样才
git fetch --prune git ls-remote问 dst 现在有哪些引用- 和本地镜像比对 → 一样就跳过 push
- 不一样才
git push --prune --force
因为每轮都重新问过两边,所以不需要任何本地状态文件,删掉 work_dir 也只是让下次重新克隆一遍而已。
失败时按 interval → 2×→ 4× … 退避,上限 max_backoff;成功后立刻恢复正常节奏。
所有 git 调用都在独立进程组里,超时或退出时整组一起收掉,不会漏下 ssh 之类的子进程。
每次调用都用隔离的 HOME 并禁用系统/全局 gitconfig,行为不受宿主机环境影响。
常见问题
推送被拒:refusing to delete the current branch
dst 的默认分支(HEAD 指向的那个)不允许被删除,GitHub 也一样。
上游把 master 改名成 main 之后会撞上这个:先去 GitHub 仓库设置里把默认分支改成新名字,再让 syncbot 同步。
日志出现 refusing to mirror an empty source
src 一个引用都没返回,但 dst 有内容。绝大多数情况是 URL 写错或者 token 过期,
所以默认拒绝推送以免把 dst 清空。确实想清空的话给那个仓库加 allow_empty = true。
Permission denied (publickey)
依次检查:deploy key 是否勾了 Allow write access;配置里 ssh_key 是否指向私钥(不是 .pub);
key 是否没有 passphrase;同一把 key 是否被重复用在了多个 GitHub 仓库上。
log_level = "debug" 可以看到每条 git 命令。
只想同步分支,不要标签 / PR 引用
refs = ["refs/heads/*"]。默认就不会碰 refs/pull/* 这类引用。
大仓库把内存吃满了
吃内存的是 git,不是 syncbot 自己。调小 concurrency,并给该仓库加:
git_config = ["pack.threads=1", "pack.windowMemory=64m", "core.bigFileThreshold=8m"]
想立刻同步一次,不等下个周期
docker compose restart syncbot,或者用 -once 单独跑一次。
开发
go test ./... # 单元测试 + 跑真实 git 的端到端测试
go test -race ./...
go vet ./...
go build .
代码结构和设计约定见 AGENTS.md。
镜像由 GitHub Actions 构建并推送到 ghcr.io/OWNER/syncbot,
main 分支推 latest,打 v* 标签推对应语义化版本,支持 amd64 / arm64。