主题
自托管 AFFiNE:镜像源、digest、和那个"迁移任务已退出"
AFFiNE 是个开源知识库(文档 + 白板一体)。自托管它比装一个单容器应用麻烦一点:它是多容器的(应用 + Postgres + Redis + 一个一次性的迁移任务)。
这篇记录四件事:镜像怎么拉到、为什么要把镜像 pin 到 digest、externalUrl 不配对会怎样、以及那个"迁移任务 Exited"为什么是正常的。
一、镜像:ghcr 的两个坑
AFFiNE 只发布在 GitHub Container Registry(ghcr.io),不在 Docker Hub。而 ghcr.io 在国内直连实测掉到 ~5KB/s,基本等于拉不下来。
两个代理源,实测结果差别很大:
| 源 | 结果 |
|---|---|
ghcr.m.daocloud.io | ❌ 有白名单,affine 不在名单里,直接拒绝 |
ghcr.nju.edu.cn | ✅ 快,无白名单限制 |
bash
docker pull ghcr.nju.edu.cn/toeverything/affine:stable
docker tag ghcr.nju.edu.cn/toeverything/affine:stable ghcr.io/toeverything/affine:stable教训:镜像加速源不是"随便哪个都能用"。有的只代理白名单里的镜像(给自家产品用),有的全量代理。报错信息通常很含蓄——不是 403,而是 "not in the allowlist" 或者直接 404。
二、pin 到 digest(这条官方专门提醒过)
AFFiNE 的 stable tag 会滚动更新,而新版本可能包含数据库迁移。自动拉新版 = 某天重启容器,数据库被单向迁移了,回退不了。
所以把镜像钉死在 digest:
yaml
services:
affine:
image: ghcr.nju.edu.cn/toeverything/affine@sha256:b649f5ce2384ffdf13c23bccf81d759e15973d59d0b0058af65d895a84373099⚠️ 注意 digest 前面要带 repo 前缀,而且必须和你本地 RepoDigests 里的那个前缀一致:
bash
docker inspect ghcr.io/toeverything/affine:stable --format '{{json .RepoDigests}}'如果本地记录的是 ghcr.nju.edu.cn/...@sha256:...,compose 里就写 nju 前缀。写错了会变成"引用不存在",docker 会去拉,然后失败。
好处:compose 直接用本地镜像、不触发任何拉取;日后真要重拉也还走快源。
三、externalUrl 必须是最终对外地址
AFFiNE 用 config.json 配置:
json
{
"server": {
"name": "AFFiNE",
"externalUrl": "https://notes.example.com"
},
"auth": {
"allowSignup": false
}
}externalUrl写错域名 → 登录回调、邮件里的链接、分享链接全会指到错的地方。它必须是浏览器最终访问的那个https://...,不能写http://localhost:3010allowSignup默认是开的 → 意味着任何知道网址的人都能注册,而第一个注册的人就是管理员。部署完先注册自己,然后立刻关掉
bash
# 改完重启生效
docker compose up -d affine⚠️ 这个文件里可能有 SMTP 密码之类的凭据,权限设 600:
bash
chmod 600 config.json四、Postgres 数据目录的 uid
pgvector/pgvector:pg16 这个镜像里,Postgres 以 uid 999 运行。如果你用 bind mount 挂宿主机目录:
bash
mkdir -p data/postgres
chown -R 999:999 data/postgres # 必须做不做的话 Postgres 启动失败,日志报 could not open directory ... Permission denied 或者 initdb: could not change permissions of directory。
这是所有"官方 postgres 镜像 + bind mount"的通用坑,跟 AFFiNE 无关。
五、"迁移任务已退出" 是正常的
docker compose ps 会看到一个容器死了:
affine_migration_job Exited (0)Exited (0) = 成功退出。 它是个 one-shot 任务:跑数据库迁移,跑完就该退出。不是崩溃。
判断标准永远是退出码,不是"容器还在不在跑":
bash
docker inspect affine_migration_job --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
# 期望:0 false顺带一句:真正需要担心的是 OOMKilled=true(内存不够被杀),这个在 NAS 上跑 Postgres + Node 应用时并不罕见。
六、验证:从内网到公网两段走
bash
# 内网:应该 302 到 /admin/setup(说明应用起来了,还没初始化)
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:3010/
# 公网:应该 200
curl -s -o /dev/null -w '%{http_code}\n' https://notes.example.com/两段分开测的价值:内网不通 = 应用/容器问题;内网通但公网不通 = 隧道/域名问题。 一眼分开,不用猜。
七、备份
三个东西,缺一不可:
- Postgres 数据 —— 用
pg_dump,别直接拷数据目录(可能不一致) config.json—— 里面有配置和凭据data/storage—— 用户上传的附件/图片
bash
docker exec affine_postgres pg_dump -U <user> <db> | gzip > /tmp/affine-$(date +%F).sql.gz异地备份要加密(里面有用户内容和凭据)。云挂载点不能直写的坑见这篇。
检查清单
- 镜像走
ghcr.nju.edu.cn(别用有白名单的代理) - pin 到 digest,并且前缀和本地
RepoDigests一致 externalUrl= 最终对外 HTTPS 地址- 部署完立刻注册管理员 +
allowSignup: false - postgres 数据目录
chown 999:999 - 迁移任务
Exited(0)属正常,看退出码 - 验证分内网/公网两段
- 备份三件:
pg_dump+ config + storage
一句话版本:AFFiNE 的坑集中在"版本"和"地址"两件事上——镜像要 pin digest 防自动迁移,
externalUrl要写最终域名否则链接全错。