线上排坑:日志采集引发的容器 Page Cache “伪泄漏”与 OOM
案发现场
在线上环境跑得好好的业务,突然在 Grafana 上的监控面板里报警了:容器的 memory_working_set_bytes 指标一路狂飙,居高不下,最终触发 OOM 被 K8s 强杀。
第一反应通常是业务代码漏了。但拉了个 pprof 看堆内存(Heap),发现应用本身的内存占用极其健康。那剩下的内存去哪了?
进容器一查,全被 Page Cache 吃干抹净了。
更有意思的是,即使系统内存吃紧,这些 Cache 依然死活不释放。经过排查,罪魁祸首竟然是 “业务侧写日志” 与 “Sidecar / 离线脚本读日志” 这俩操作在底层 cgroup 机制上的致命冲突。
底层到底发生了什么?
这个问题本质上是 Linux 的 Memcg 记账机制(Accounting) 与 Page Cache 全局回收机制(Reclaim) 产生了化学反应。
1. 记账机制:写日志的容器当了“冤大头”
Linux Memory Cgroup 管理 Page Cache 遵循一个死理:Charge-on-write(首次触碰记账) 。
当业务进程往日志文件里 write() 时,数据先落到内核的 Page Cache(脏页),然后异步刷盘。就在写入的那一刻,这页 Cache 的“账”就永久记在了业务容器的 cgroup 头上。
此后,无论负责采集日志的 Sidecar 容器怎么疯狂地去读这些日志,内核都绝对不会重新分配归属。Sidecar 容器读得再多,账单全算在业务容器头上。
2. 回收阻塞:只读不释放,硬生生把冷数据盘成“包浆”
账记在业务容器头上就算了,按理说内存不够了内核会自动回收。但这里有个大坑:页面的活跃状态(Active/Inactive)是全局共享的。
Linux 的内存回收基于 LRU 链表,正常压力下只挑 Inactive List(不活跃链表)里的软柿子捏。
而我们的日志采集容器,通常会定时(比如每小时)去扫一遍日志。每次一读,内核就会给这些日志的 Page Cache 打上“最近访问”的标记,硬生生把它们从 Inactive 链表重新拽回 Active 链表。
只要你的采集脚本还在按周期读,这些本该被淘汰的旧日志就会在 Active 链表里“永生”,永远排不到被回收的队列里。
3. 隐形杀手:一段 find 脚本引发的血案
很多人在做离线日志归档(比如推送到 HDFS)时,喜欢写这种 Bash 脚本:
log_files=($(find "$SEARCH_DIR" -type f -name "*.$TIME_PARAM"))
这行代码在本地跑没问题,在容器里就是灾难。
find 为了匹配文件名,会对目录树下所有文件执行 stat() 系统调用。这不仅仅是拿元数据,还会把对应的 inode 和目录块全加载进内存。如果文件系统没挂载 noatime,这波扫描还会更新访问时间,直接向内核宣告:“这些几天前的老日志我刚访问过,别回收它们!”
这就是为什么虽然你只想采集当前小时的日志,但整个目录的老日志全被按在内存里摩擦的原因。
怎么彻底解决?
要打破这个死结,必须在文件流转完之后,主动切断内核对这些 Cache 的眷恋。
终极方案:写后主动 DONTNEED
在日志归档或采集完成后,对老日志文件调用 POSIX_FADV_DONTNEED,明确告诉内核:“这文件我用完了,它的 Cache 你直接扬了吧。”
我们可以自己写个小工具,在上传完文件后触发。这里给一个极简的 Go 版本实现(因为 Go 编译单文件无依赖,塞进容器执行最方便):
package main
import (
"flag"
"fmt"
"os"
"golang.org/x/sys/unix"
)
func main() {
filePath := flag.String("file", "", "Path to the log file")
flag.Parse()
if *filePath == "" {
fmt.Println("Usage: fadvise-tool -file <path>")
os.Exit(1)
}
// 打开文件,注意这里只需要只读权限
f, err := os.OpenFile(*filePath, os.O_RDONLY, 0)
if err != nil {
fmt.Printf("Failed to open file: %v\n", err)
os.Exit(1)
}
defer f.Close()
// 核心系统调用:通知内核释放 Page Cache
err = unix.Fadvise(int(f.Fd()), 0, 0, unix.FADV_DONTNEED)
if err != nil {
fmt.Printf("fadvise FADV_DONTNEED failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Successfully dropped cache for: %s\n", *filePath)
}
把这个编译成 fadvise-tool,放到你的 Shell 脚本里:
hadoop fs -put "$log_file" "$target_path" && ./fadvise-tool -file="$log_file"
跑完这行,你会看到 Grafana 上的曲线瞬间断崖式下跌,清爽无比。
补充优化:管好你的采集器
- 停止全量
find:如果一定要用 bash 扫文件,用-mmin 或-mtime限定只扫最近产生的文件,别去碰老文件。 - 按时间分目录:日志最好按小时或天建子目录(如
/log/2026-04-15/),采集器直接定位到对应目录,从根源上阻断对历史文件的无效stat()。 - 暴力兜底(慎用) :如果应用方改不了代码,可以在宿主机起个 Cronjob,定时定向走
echo 1 > /proc/sys/vm/drop_caches,但这容易引发全局 IO 抖动,不到万不得已别用。
总结一句话: Memcg 只管“谁拉的屎谁擦”,不管“谁后来又去闻了”。日志读写分离的架构下,必须主动介入 Cache 生命周期,别指望内核能猜透你的业务逻辑。