监控发来一条 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 又不动内容的操作,典型就是 chownchmod

不是仓库在长大#

再看「积累」这个假设:仓库 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 全变,全量重读。

根因链#

flowchart TD A[节点重启 → 书库 pod 重建] --> B["kubelet:fsGroup 属主管理
(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。

参考资料#