Akkoma 的 S3 分级存储/写入分析

我直接克隆了 Akkoma 源码(develop 分支)逐行核对了实现。先说结论:Akkoma/Pleroma 原生并没有"应用层主动把缓存对象在本地和 S3 之间搬运"的分级存储。它实现"单节点写+读缓存、远端 S3 做主存储"靠的是两套独立但互补的机制叠加:

  1. 上传(写入)→ 直接走 S3 uploader,Akkoma 节点本身不持久化文件本体,只存对象的 key/URL。
  2. 读取 → 走 /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))])}}
end

base_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 ...
end

lib/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 前缀。

参考来源

需要的话,我可以进一步给出:完整的 S3 配置示例、nginx 缓存块 + purge 脚本的具体配置,或 CRR/生命周期策略的编排方案。