Akkoma 的 S3 分级存储/写入分析
我直接克隆了 Akkoma 源码(develop 分支)逐行核对了实现。先说结论:Akkoma/Pleroma 原生并没有"应用层主动把缓存对象在本地和 S3 之间搬运"的分级存储。它实现"单节点写+读缓存、远端 S3 做主存储"靠的是两套独立但互补的机制叠加:
- 上传(写入)→ 直接走 S3 uploader,Akkoma 节点本身不持久化文件本体,只存对象的 key/URL。
- 读取 → 走
/media或/proxy路由,由 nginx 反向代理层做磁盘 Cache-aside(长期缓存),命中即不碰 S3。
真正"读缓存"发生在 nginx 层,不在 BEAM 里。下面按代码证据拆解。
1. 写入路径:节点只负责"写",不落本地
配置 config/config.exs(默认是 Local,S3 需手动切):
config :pleroma, Pleroma.Upload,
uploader: Pleroma.Uploaders.Local, # ← 切到 S3 的关键开关
base_url: nil
config :pleroma, Pleroma.Uploaders.S3,
bucket: nil,
bucket_namespace: nil,
truncated_namespace: nil,
streaming_enabled: true
config :ex_aws, :s3,
access_key_id: nil,
secret_access_key: nil,
scheme: "https://"写入核心在 lib/pleroma/upload.ex 的 store/2 → 最后调用 Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload)。
lib/pleroma/uploaders/s3.ex 的 put_file/1 真正执行上传:
def put_file(%Pleroma.Upload{} = upload) do
config = Config.get([__MODULE__])
bucket = Keyword.get(config, :bucket)
streaming = Keyword.get(config, :streaming_enabled)
s3_name = strict_encode(upload.path)
op =
if streaming do
upload.tempfile
|> ExAws.S3.Upload.stream_file() # 流式多段上传
|> ExAws.S3.upload(bucket, s3_name, [{:acl, :public_read}, {:content_type, ...}])
else
{:ok, file_data} = File.read(upload.tempfile)
ExAws.S3.put_object(bucket, s3_name, file_data, [{:acl, :public_read}, {:content_type, ...}])
end
case ExAws.request(op) do
{:ok, _} -> {:ok, {:file, s3_name}} # 返回 key,不落本地
error -> {:error, "S3 Upload failed"}
end
end关键点: 节点把文件直接推到远端 S3,返回 {:file, s3_name}(即对象 key),节点本地磁盘不保存副本。get_file/1 只是拼 URL:
def get_file(file) do
{:ok, {:url, Path.join([Pleroma.Upload.base_url(), strict_encode(URI.decode(file))])}}
endbase_url/0(lib/pleroma/upload.ex)决定对外暴露的 S3 访问地址,支持把 public_endpoint 指向另一套 CDN/S3 端点(可与上传的 S3 集群不同):
def base_url do
uploader = Config.get([Pleroma.Upload, :uploader])
upload_base_url = Config.get([Pleroma.Upload, :base_url])
public_endpoint = Config.get([uploader, :public_endpoint])
case uploader do
Pleroma.Uploaders.Local -> upload_base_url
Pleroma.Uploaders.S3 ->
bucket = Config.get([...S3, :bucket])
# bucket_namespace / truncated_namespace 支持把前缀独立成单独的桶/命名空间
...
if public_endpoint, do: Path.join([public_endpoint, bucket_with_namespace]),
else: Path.join([upload_base_url, bucket_with_namespace])
_ -> public_endpoint || upload_base_url
end
end这解释了"写一个 S3、对外读用另一个 S3/CDN 集群"的最小形态:上传 S3 与 public_endpoint 可以拆开。但注意 —— Akkoma 不会主动把对象从 S3 A 同步到 S3 B;那通常是 S3 的跨区域复制(CRR)/生命周期策略做的事,Akkoma 不负责。
2. 读取路径:nginx 磁盘缓存 = 真正意义的"读缓存"
Akkoma 的"读缓存"设计在其官方文档 Storing Remote Media 里有明确描述:远程联邦媒体默认不落地 Akkoma 节点,靠 nginx 的 proxy_cache 做一年期磁盘缓存。
lib/pleroma/web/media_proxy/media_proxy_controller.ex 的 remote/2 把请求交给 Pleroma.ReverseProxy.call/3,由它回源拉取:
def remote(conn, %{"sig" => sig64, "url" => url64}) do
with {_, true} <- {:enabled, MediaProxy.enabled?()},
{:ok, url} <- MediaProxy.decode_url(sig64, url64),
...
:ok <- MediaProxy.verify_request_path_and_url(conn, url) do
ReverseProxy.call(conn, url, media_proxy_opts())
else ...
endlib/pleroma/reverse_proxy.ex 是回源代理,但它不做本地对象缓存 —— 它只是把源站响应透传给客户端,并加上缓存头 public, max-age=1209600, immutable(14 天,配合 nginx 1 年 inactive):
@default_cache_control_header "public, max-age=1209600, immutable"
...
defp build_resp_cache_headers(headers, _opts) do
# 强制写 cache-control
List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
end磁盘缓存由 nginx 承担(官方建议配置):
proxy_cache_path /long/term/storage/path/akkoma-media-cache
levels=1:2 keys_zone=akkoma_media_cache:10m inactive=1y use_temp_path=off;
location ~ ^/(media|proxy) {
proxy_cache akkoma_media_cache;
slice 1m;
proxy_cache_key $host$uri$is_args$args$slice_range;
proxy_set_header Range $slice_range;
proxy_cache_valid 200 206 301 304 1h;
proxy_cache_lock on;
proxy_buffering on;
proxy_pass http://phoenix;
}所以访问链是:
客户端 → nginx(磁盘缓存) → 命中→直接返回
↓ 未命中
Akkoma ReverseProxy → 回源(S3/远端实例)命中时节点根本不处理、不访问 S3,这就是"单节点只读缓存"的落地方式。
3. 预热缓存:MediaProxyWarmingPolicy
为了让联邦进来的媒体主动进缓存而不是等用户首次点击才回源,Akkoma 提供 MRF 策略 MediaProxyWarmingPolicy(lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex):
defp prefetch(url) do
if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
prefetch_url = MediaProxy.preview_url(url)
ConcurrentLimiter.limit(__MODULE__, fn ->
Task.start(fn -> fetch(prefetch_url) end) # HTTP.get 走 /proxy/preview
end)
end
end在 filter/1 里对 Create/Update 的每个 attachment URL 预取。开启后每收到一条联邦帖子,附件就被提前拉进 nginx 缓存。
4. 本地上传的读取:/media 路由(UploadedMedia Plug)
本地用户上传(最终也在 S3)的读取走 lib/pleroma/web/plugs/uploaded_media.ex,它按 uploader 类型分派:
defp get_media(conn, {:static_dir, directory}, opts) do
# Local uploader → 从本地磁盘目录静态服务
Plug.Static.call(conn, static_opts)
end
defp get_media(conn, {:url, url}, _) do
# S3 uploader → 直接 302 重定向到 S3 public URL
Phoenix.Controller.redirect(conn, external: url)
end用 S3 uploader 时,get_file 返回 {:url, ...},于是 /media/xxx 直接被 302 到 S3。要让"读缓存"命中,同样靠前面的 nginx location /media 缓存块:第一个请求 302→nginx 回源缓存,之后直接在 nginx 磁盘缓存命中。
5. 缓存失效/清理
- 失效(invalidation):
config :pleroma, :media_proxy, invalidation: [...],支持两种 provider:Pleroma.Web.MediaProxy.Invalidation.Script(跑外部 purge 脚本,如调用 nginx 的proxy_cache_purge)和...Invalidation.Http(发 purge HTTP 请求)。这是主动踢掉 nginx 磁盘缓存条目的机制。 - 后台任务:
Pleroma.Web.MediaProxy.MediaProxyCacheController(/api/pleroma/admin/media_proxy_caches/purge)提供管理接口。
6. 针对你需求(单节点写+读缓存、另一 S3 做主存储)的落地结论
Akkoma 本身不做跨 S3 的对象迁移/分层。要凑成你要的架构,需要"配置组合 + S3 自身能力":
| 你的目标 | Akkoma 里的做法 |
|---|---|
| 单节点只负责写入 | uploader: Pleroma.Uploaders.S3,写 S3,节点不落盘 |
| 另一 S3 集群做对外主存储 | S3 之间用跨区复制(CRR) 或生命周期 Tier 把对象从"写集群"同步到"读集群";Pleroma.Uploaders.S3 的 public_endpoint 指向读集群端点,使 /media 302 到读集群 |
| 读缓存 | nginx proxy_cache 在 /media、/proxy 上做一年期磁盘 Cache-aside,命中即不碰后端 S3 |
| 缓存预热 | 开 MediaProxyWarmingPolicy MRF,联邦媒体主动进缓存 |
| 缓存失效 | media_proxy.invalidation Script/Http provider |
注意两个坑:
- 若把
public_endpoint指到"读 S3",要保证读 S3 已通过 CRR 收到对象,否则会 404(Akkoma 不会帮你兜底回源写集群)。 upload与public_endpoint分离时,bucket_namespace/truncated_namespace可用于在两端保持一致的 key 前缀。
参考来源
- 官方配置:Configuration Cheat Sheet — Uploads/S3
- 官方文档:Storing Remote Media(nginx 缓存 + 预热)
- 源码(
develop分支,本地克隆核对):lib/pleroma/uploaders/s3.ex、lib/pleroma/upload.ex、lib/pleroma/web/plugs/uploaded_media.ex、lib/pleroma/reverse_proxy.ex、lib/pleroma/web/media_proxy/media_proxy_controller.ex、lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex、config/config.exs
需要的话,我可以进一步给出:完整的 S3 配置示例、nginx 缓存块 + purge 脚本的具体配置,或 CRR/生命周期策略的编排方案。