restic 夜备内存告警:排查 fsGroup 触发的整库重读
目录
监控发来一条 ContainerMemoryNearLimit:homelab 里 Oracle 集群夜备容器的 7 天内存峰值达到 968.7Mi,limit 是 1Gi,约占 94.6%。周报里的序列是 240 → 580 → 623 → 823 → 969Mi,看起来像内存持续增长。预案本来现成——manifest 注释里写着,7 天峰值超过 820Mi 就把 limit 提到 1.5Gi——但我想先确认它为什么一直在涨。
实际情况是它没有持续增长。那组序列只是把高点单独挑出来后形成的结果。书库 pod 重启后(本场景主要由节点重启触发),kubelet 会处理 24.5GiB 书库中的文件权限;当晚 restic 因此重读了整个目录。下面按排查顺序记录,数字都来自这次排查。
背景#
这套是我 homelab 的备份链路。一个单节点 K3s(v1.34.5+k3s1)跑在 Oracle 的免费 ARM 实例上,每晚 03:30 一个 CronJob 起 alpine 容器:先 pg_dump 两个 Postgres 库、按白名单把各服务的 sqlite 拷进 emptyDir,然后 restic backup 把这份 /work 连同一个 ~24.5GiB 的 calibre 书库目录(local-path PVC)推到家里 NAS 上的 restic 仓库(sftp),最后 forget --prune 收快照。
这个容器的 memory limit 有一段修正史,全写在 manifest 注释里:
- 08-10:512Mi→768Mi,当时的归因是「峰值随仓库快照数单调增长」;
- 08-24:768Mi→1Gi,归因修正为「由当晚备份增量决定」——依据是同一周七次运行按日期排的峰值 161/240/580/111/105/623/98Mi,相邻两晚能差 6 倍,确实不像单调增长(当时记的数值和这次 30d 查询重跑有 ±1Mi 的舍入差异)。这一版顺手定了 820Mi 的触发线。
这次告警踩中的正是那条触发线。事后看,两版归因都不对,但 08-24 那版让我开始按每次运行对齐数据,这次排查正是靠这个方法看出了问题。
现象:一条像在爬坡的峰值序列#
告警的原始素材是「近 7 天峰值 968.7Mi,且逐次攀升:240 → 580 → 623 → 823 → 969」。如果这个读法成立,那就是某种积累——索引变大、快照变多、或者干脆是泄漏——抬 limit 只能续命,早晚还会响。
排查#
把峰值对齐到每一次运行#
CronJob 的 pod 名自带运行时间:倒数第二段是调度时刻的「分钟数」(自 epoch 起),乘 60 就是精确的启动时间。配合 Prometheus 一条 instant query,可以把峰值逐 run 拆开:
max by (pod) (
max_over_time(container_memory_working_set_bytes{
cluster="oracle-k3s", namespace="backup", container="backup"
}[30d])
)
按每次运行拆开后,原来的趋势不见了:
| 日期 | 峰值 | 日期 | 峰值 | 日期 | 峰值 |
|---|---|---|---|---|---|
| 08-18 | 161Mi | 08-22 | 105Mi | 08-26 | 121Mi |
| 08-19 | 240Mi | 08-23 | 623Mi | 08-27 | 823Mi |
| 08-20 | 580Mi | 08-24 | 98Mi | 08-28 | 969Mi |
| 08-21 | 111Mi | 08-25 | 160Mi | 08-29 | 144Mi |
数据分成两组:安静的晚上 98–161Mi,重读晚大多在 600–970Mi(更早的 240Mi、580Mi 两晚也是整库重读,但那批是 calibre 内容改写,见下文)。原来的「240→580→623→823→969」只是把每天的最高点按日期连起来,中间夹着的 98–161Mi 被忽略了。告警值是真实的,但把这些高点串成单调趋势是不对的。
两类夜晚:6560 changed 和 26 changed#
CronJob 留着最近 3 个 Completed pod,kubectl logs 直接看 restic 的汇总(节选):
# 08-28 03:30(峰值 969Mi 那晚)
Files: 0 new, 6560 changed, 0 unmodified
Added to the repository: 228.873 MiB (68.571 MiB stored)
processed 6560 files, 24.457 GiB in 4:56
# 08-29 03:30(峰值 144Mi 那晚)
Files: 0 new, 26 changed, 6534 unmodified
Added to the repository: 209.781 MiB (61.992 MiB stored)
processed 6560 files, 24.456 GiB in 0:29
两次运行的差异是:高峰值晚上 restic 认为全部 6560 个文件都变了,24.5GiB 从头读一遍,跑 4 分 56 秒;安静晚上只有 26 个文件变化,29 秒收工。再往前翻 Loki,08-23(623Mi)从日志看到 6516 changed,比整库 6560 少了 44 个文件,和「整卷 ctime 全变」对不上(可能有 44 个 new 没抄全 [need manual confirm])。峰值和「当晚被重读的字节数」大体对应:重读得多、峰值就高,但同是整库重读,三晚峰值是 623/823/969Mi,并不严格线性。内存具体花在哪我没有单独剖析,机理上大概是:restic 全量重读时要逐个文件切块、算哈希、在内存里做查重,同一时刻驻留的处理对象越多、工作集越大。
内容没变,变的是 stat#
日志里还有一个线索:全量重读那晚 Added to the repository 是 228.9MiB,安静晚是 209.8MiB——只差约 19MiB(9%),并没有和「重读了 24.5GiB」成比例。6560 个文件被重读后,去重把它们的内容全部吸收了,真正新增的还是每晚那份 pg_dump 和 sqlite 的翻新。差的那 19MiB 大概来自当日 dump 大小浮动,外加 6560 个文件的新 stat 元数据(ctime 变了,元数据会真实入仓)——同样指向「内容没变,变的是 stat」。
restic 判断「文件没变」要四个属性同时匹配:mtime、ctime、size、inode(见官方文档的 file change detection 一节,文档还特意解释了为什么要看 ctime:mtime 可以被程序随意改写,ctime 才是内核维护的)。内容没动,mtime/size 就不会动;ext4 上文件没被重建,inode 也不会动。剩下的只有 ctime——而会更新 ctime 又不动内容的操作,典型就是 chown 和 chmod。
不是仓库在长大#
再看「积累」这个假设:仓库 28G,索引目录只有 3.9MB、两个文件;每晚的 forget --prune 在安静晚也照跑,而安静晚总峰值不过 98–161Mi。索引和 prune 都不足以解释接近 1GiB 的峰值,快照数也由保留策略限制在固定数量。这套环境里没有观察到持续增长的对象。
时间线合拢:这次记录里每个全量晚前面都有一次节点重启#
那么是谁在 chown/chmod?先看时间规律。节点的 journalctl --list-boots 给出近期四次重启:08-22 两次、08-26 18:24、08-27 04:29(这台免费实例最近重启得有点勤,原因是另一个待办)。对上备份峰值表:
- 08-22 晚两次重启 → 08-23 03:30 全量重读(623Mi;两次都在 08-22 03:30 之后,当天那次备份才是安静的 105Mi)
- 08-26 傍晚重启 → 08-27 03:30 全量重读(823Mi)
- 08-27 凌晨重启 → 08-28 03:30 全量重读(969Mi)
- 之后没有重启 → 08-29 安静(144Mi)
在这几次记录里,重启后的第一个夜备都发生了全量重读,同一个 boot 里的后续夜备没有再出现这个现象。(更早的 08-19/20 两个峰超出了 Loki 保留期,无法直接验证;不过那几天正好在跑一批 calibre 元数据批处理,同期快照体积从 23.5GiB 涨到 24.3GiB,是真实的内容改写,对得上。)样本有限,我不把它写成必然规律。
排除两个带日志的嫌疑人#
书库 pod 里有个 permission-fixer sidecar,每 5 分钟把不符合预期权限的文件 chmod 一遍——听起来嫌疑很大,但它的 find 只匹配「权限不对」的文件,稳态下是 no-op,且重启后它的日志里只有启动 banner。
这里差点被自己的代码骗了:sidecar 用 find ... -exec chmod {} + | wc -l 统计修了多少文件,但 wc -l 数的是 chmod 的 stdout——chmod 什么都不打印,所以这个计数恒为 0,「fixed N files」这行日志从未真实出现过。日志静默不等于没干活,这行代码后来顺手修掉了(-print -exec)。
另一个嫌疑人是书库应用(calibre-web-automated,linuxserver 系镜像)启动时的 chown,init 日志直接排除:
[cwa-init] NETWORK_SHARE_MODE=true detected; skipping chown of /calibre-library
没有日志的那位:kubelet#
排除这些会写日志的进程后,主要候选变成了不写容器日志的 kubelet。两个条件在这里凑齐了。
第一,持有 fsGroup 的是常驻书库 pod(calibre-web 那套):它的 securityContext 里设了 fsGroup: 1000,而没写 fsGroupChangePolicy,默认值是 Always。每晚新建的备份 pod 不设 fsGroup,所以它自己启动时不会触发这轮处理。
第二,这个书库 PVC 的 PV 是 local 型。在我这套 K3s 上,内置 local-path 存储类产出的 PV 全部长这样(spec.local 有值、spec.hostPath 为空):
$ kubectl get pv pvc-aa2d…calibre-books-local -o jsonpath='{.spec.local}'
{"path":"/var/lib/rancher/k3s/storage/pvc-aa2d…_personal-services_calibre-books-local"}
local 型卷在 kubelet 的属主管理范围内。于是每次持有 fsGroup 的书库 pod 重建(本场景里主要由节点重启触发),kubelet 都按 fsGroup 把整卷递归 chown/chmod 一遍——Kubernetes v1.34.5 源码里递归逻辑是给所有文件 chmod(mode|0660)、目录额外加执行位和 setgid,外加逐个 chown。按 restic 的 processed 计数,整卷是 6560 个文件(之前手数过 6563,差 3 个没对上,可能是符号链接或排除项 [need manual confirm]),每个 ctime 都被刷新,没有任何容器日志。当晚 restic 一比对:ctime 全变,全量重读。
根因链#
(fsGroupChangePolicy 默认 Always)"] B --> C["递归 chown/chmod 整个 local PV
6560 文件全部 ctime 更新,无容器日志"] C --> D["当晚 restic:mtime/ctime/size/inode
四元组不匹配 → 视为全部 changed"] D --> E["24.5GiB 全量重切块
跑 4:56,峰值 600–970Mi"] F[同一 boot 内的后续夜晚
ctime 只在重建时刷新一次] --> G["仅 ~26 个文件变化
跑 0:29,峰值 100–160Mi"]
把「节点重启」画成起点,是因为书库 pod 常驻、只在节点重启(或手动部署)时重建;每晚新建的备份 pod 不设 fsGroup,它自己挂载卷时不会触发这轮递归——所以安静晚才是多数。
修复#
limit 照抬,但把归因写对#
1Gi→1.5Gi 按原触发线执行了。全量重读是一个会重复出现的事件类别(三次实测 623/823/969Mi;同样的 24.5GiB 重读,峰值并不稳定,仅看最近两晚 823/969 也差 ±18%),94.6% 已经没有多少余量,继续使用 1Gi 的风险较高;而备份被 OOMKill 后,CronJob 失败未必会被及时发现。
即使修好触发源,limit 仍然有作用:合法的整库改写(比如那批元数据批处理确实重写了几百 MB 的文件)同样会造出高峰值。manifest 注释里这次把归因写成了「峰值 ∝ 当晚被迫重切块的字节数,双峰分布」,触发线也改成了「重读晚峰值超过 80% 再查根因,别机械抬 limit」。
OnRootMismatch,以及它和权限脚本的打架#
核心配置是这一行(加到书库 pod 的 securityContext,也就是设了 fsGroup 的那套);在我这个场景里,还要同步修改 permission-fixer:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: "OnRootMismatch"
OnRootMismatch 让 kubelet 只检查卷根目录,符合预期就跳过整个递归(见 Kubernetes 文档的 volume permission and ownership change policy 一节)。
但只加这一行在我这个场景仍可能触发递归扫描,原因在「符合预期」的定义里。看Kubernetes v1.34.5 源码的 requiresPermissionChange,根目录要同时满足三条:gid 等于 fsGroup、权限含 0770、并且带 setgid 位(fsInfo.Mode()&os.ModeSetgid == 0 即判定不匹配)。kubelet 自己扫完,目录会变成 2777——而我那个 permission-fixer 用的是精确匹配:
find /library -type d ! -perm 777 -exec chmod 777 {} +
POSIX 里 -perm 777 是「权限位精确等于 0777」,2777 不等于 0777,于是 fixer 会把 kubelet 刚设好的 setgid 剥掉;下次 pod 启动,根目录检查又不匹配,又是一次全量扫。两个权限处理机制会互相覆盖设置,导致 OnRootMismatch 仍然触发递归扫描。改成「至少包含这些位」的语义就兼容了:
find /library -type f ! -perm -666 -print -exec chmod 666 {} +
find /library -type d ! -perm -777 -print -exec chmod 777 {} +
-perm -onum 匹配「指定位全部置位」(POSIX find),2777 包含 0777 的全部位,fixer 不再碰它;同时加上 -print 后,wc -l 统计的才是真实路径数,也修正了前面恒为 0 的计数。
验收#
我写这篇时(2026-08-29)修复刚推上去。部署本身会触发最后一次全量扫(根目录此前没有 setgid,kubelet 要补一次),所以下一次备份(08-30 03:30)还会是一个重读晚,落在新 limit 之内;下一次节点重启后,再看 changed 是否保持在两位数、运行时间是否仍在半分钟级,以此判断修复是否生效。
教训#
这次排查里,先把双峰分布的指标对齐到最小执行单元(这里是「每一次运行」),再判断有没有趋势。
这次排查里,一个比较有用的判断是:changed 文件数暴涨 + Added 字节数持平,通常提示 stat 元数据发生了变化,但文件内容没有变化。遇到这种情况,可以优先检查是否有批量更新 ctime 的操作;候选进程里也要包括不写容器日志的 kubelet。在卷类型支持 fsGroup 管理、pod 设置了 fsGroup、且使用默认 Always 策略时,持有 fsGroup 的那个 pod 重建可能触发整卷权限处理(只影响它自己挂载的卷)。备份变慢、夜备 IO 突增、增量备份「莫名全量」,都可以沿着这条线检查。
总结#
- 把指标按每次 CronJob 运行对齐,才能看出这次峰值并非单调增长,而是分成普通夜晚和重读夜晚两组;
- restic 通过 mtime/ctime/size/inode 判断文件内容是否可能未变;
chown/chmod虽不改内容,也可能触发重读。--ignore-ctime可以绕过 ctime,但我更倾向修触发源; - 对支持 fsGroup 管理的 local PV,
fsGroup配合默认Always可能在 pod 启动时递归处理整卷;OnRootMismatch生效前,还要确认其他权限脚本不会移除 setgid; find -perm 777是精确匹配,-perm -777才是至少包含这些权限位;统计-exec命令处理了多少路径,要用-print -exec,不要数命令本身的 stdout。