使用 rclone 将本地文件通过 OpenList 的 WebDAV 接口上传到夸克网盘时,OpenList 的内存占用可能明显增长,甚至触发容器 OOM。排查后发现,关键在于夸克驱动上传前的整文件缓存,以及容器统计中包含的文件页缓存。

本文分析针对 OpenList v4.2.7、普通夸克驱动、Docker 和 cgroup v2。其他驱动、其他版本应重新核对实现。

夸克上传为什么需要缓存

OpenList 的 WebDAV 入口把 HTTP 请求体包装成文件流,再交给目标存储驱动。这一步本身不统一将整个文件读入内存。

但普通夸克驱动会先取得文件的 MD5 和 SHA1。WebDAV 上传路径没有向文件对象提供这两个哈希,因此驱动调用:

stream.CacheFullAndWriter(&up, io.MultiWriter(writers...))

这个调用在计算哈希的同时,缓存完整文件。随后,驱动向夸克提交预上传和哈希请求;如果不能秒传,再从缓存中读取数据、分片上传。

哈希计算可以使用很小的内存,真正占空间的是为后续回读而保留的文件内容。 即使夸克支持分片上传,也不代表前面的整文件缓存不存在。

v4.2.7 的 HybridCache 有内存和文件两类后备方式。允许使用内存时,会尝试扩充缓存,分配受限后再切换至文件;其中部分内存通过匿名 mmap 管理。因此不能仅靠调整 Go GC 参数约束所有缓存占用。

rclone 的 --transfers 控制同时传输的文件数。两个文件并发上传,意味着两份整文件缓存可能同时存在。降低 rclone 自身的 --buffer-size 并不能直接限制 OpenList 进程中的这部分缓存。

为什么有临时目录仍占用内存

例如:

"temp_dir": "data/temp"

这只指定临时文件保存位置,不强制每次缓存都写入磁盘。该相对路径还要结合 OpenList 的工作目录、Docker 挂载判断实际位置。

此外,v4.2.7 的缓存扩容检查使用系统可用内存统计,没有在该检查中直接读取容器的 memory.max 和 memory.current。这使得宿主机尚有空闲内存时,容器仍可能先撞上自己的内存限额。

改用磁盘临时文件

先暂停上传并备份配置,在 config.json 顶层设置:

"min_free_memory": -1

这是字段片段,不是完整配置文件,修改时应保留其他内容和合法的 JSON 结构。

v4.2.7 的初始化代码将负值解释为关闭 HybridCache 内存缓存。0 会自动计算阈值,并不表示禁用。这个调整只改变对应缓存机制,不是整个进程的内存硬上限。

确保 temp_dir 落在真实磁盘上,并有足够空间。若临时目录位于 tmpfs,所谓文件缓存仍会消耗内存,达不到预期效果。缓存完整文件还意味着需要为并发文件预留足够的磁盘空间。

确认 Docker 工作目录和挂载:

container='openlist'
docker inspect -f '工作目录={{.Config.WorkingDir}}
{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}' "$container"

在已确认工作目录为 /opt/openlist、临时目录为 data/temp 的部署中,可检查:

docker exec openlist df -h /opt/openlist/data/temp

其他部署应替换为实际路径。完成配置后重启:

docker restart openlist
docker logs --tail 100 openlist 2>&1 |
    grep -E 'disable memory cache|min free memory|auto memory limit'

看到以下日志表示配置已生效:

disable memory cache

此时即使仍打印 auto memory limit: 4MB,也不代表重新启用了该缓存。若没有生效,应检查是否编辑了实际使用的文件,以及是否有环境变量覆盖;变量前缀应以实际启动配置为准。

如何判断内存是否真的改善

不能只看 memory.current。它包含匿名内存、文件页缓存以及其他计费内存;改用磁盘文件后,Linux 仍会利用 RAM 缓存文件数据。

以下命令在 Docker 宿主机执行,适用于 cgroup v2。容器名按实际修改:

(
    container='openlist'
    pid=$(docker inspect -f '{{.State.Pid}}' "$container") || exit 1
    [ "${pid:-0}" -gt 0 ] || { echo '容器未运行'; exit 1; }
    cg=$(awk -F: '$1 == "0" {print $3}' "/proc/$pid/cgroup")
    [ -n "$cg" ] || { echo '未找到 cgroup v2 路径'; exit 1; }
    dir="/sys/fs/cgroup$cg"

    awk '
        $1 ~ /^(anon|file|shmem|file_dirty|file_writeback|kernel|sock)$/ {
            printf "%-16s %.1f MiB\n", $1, $2/1048576
        }
    ' "$dir/memory.stat"
    cat "$dir/memory.events"
    docker stats --no-stream "$container"
)

一次优化后的实测结果为:

指标用量
anon26.8 MiB
file131.4 MiB
sock5.5 MiB
shmem0.0 MiB
file_dirty131.3 MiB
file_writeback0.0 MiB
Docker 显示内存36.2 MiB

这说明该时刻匿名内存较低,主要占用来自文件页缓存,其中大部分尚未写回磁盘。file_dirty 和 shmem 等是细分项,不能再次加到 file 上。

file_writeback=0 仅代表采样瞬间正在写回的数据为零,不代表文件未使用磁盘。脏页需要先写回,才能被回收,不能将所有文件缓存视为可立即释放。

Docker CLI 在 Linux 上会扣除部分文件缓存,因此它显示的内存可能明显低于 memory.current。两者用途不同,应结合 memory.stat 判断。

这组结果支持缓存策略调整有效,但单次样本不能证明整个运行周期都没有泄漏。如果怀疑异常,应比较相同任务多次运行时的 anon、总用量和 OOM 事件,而不是仅凭上传结束后内存未立即下降下结论。

根据负载设置容器限额

先完成有代表性的高负载同步,记录总内存峰值。如果内核提供 memory.peak,可读取它;否则每秒采样 memory.current 只能得到观察期间的近似峰值,可能漏过短暂尖峰。

本次调整后曾观测到约 478 MiB 的采样峰值,因此可以将 768 MiB 作为试运行起点:

docker update --memory 768m openlist

该限额尚需完整负载验证,不是所有环境的推荐固定值。若 Docker 提示现有交换空间配置冲突,应先检查 memory-swap 设置并决定策略。

使用 Compose 时,将限额合并到原有服务:

services:
  openlist:
    # 保留原有镜像、挂载和网络设置
    mem_limit: 768m

不要按 Docker 显示的几十 MiB 将限额压得过低,文件缓存同样计入 cgroup 内存限额。设置后应观察 memory.events 中的 oom、oom_kill 是否增加,以及上传是否失败或明显变慢。若负载较重,可先将 rclone Transfers 降为 1 做对比。

参考资料