深夜排障:内存没满、Swap 没动,但 Gitea 快撑不住了
起因
晚上值班时有反馈说"服务器内存占满了"。第一反应是去查 OOM,结果一看数据,跟预想完全不是一回事。
这篇记录把整个排查过程写下来,包括几个容易被误判的地方。
第一步:先看真实的内存状态
任何排查的第一步都应该是拿到原始数据,而不是相信某个面板上的百分比。
free -h
total used free shared buff/cache available
Mem: 3.6Gi 1.3Gi 1.4Gi 33Mi 1.1Gi 2.2Gi
Swap: 2.8Gi 25Mi 2.8Gi
三个关键数字:
- used = 1.3G,占总量的 36%
- available = 2.2G,占 63%
- buff/cache = 1.1G
buff/cache 这一项经常被误读。它是内核用来做文件缓存的内存,在应用真正需要时会立即被回收,所以它不属于"被吃掉"的内存。判断内存是否真的紧张,要看的永远是 available,而不是 total - free。
按面板常见的算法 (total - free) / total 来算,这台机器会显示 61% 已用,看着挺吓人,实际上还剩 2.2G 可用。
第二步:Swap 没被用上,是坏事吗
Swap: 2.8Gi 已用 25Mi。
很多人看到 swap 几乎为 0,会怀疑是不是 swap 没配好。实际上恰恰相反——swap 没被使用,说明内存充足。
Linux 只在有实际内存压力时才把冷页面换出到 swap。vm.swappiness 只是一个倾向性参数:
vm.swappiness = 30
这个值表示内核会在多大程度上优先回收匿名页。它不等于"必须使用 30% 的 swap"。系统当前负载只有 0.29,内核完全没有理由去做换出动作。
判断是否真有内存压力,看这两处比看 swap 用量可靠得多:
# OOM 历史,为空说明从未触发过
journalctl -k | grep -iE "out of memory|oom-kill"
# PSI 内存压力(需内核支持 PSI)
cat /proc/pressure/memory
两个查下来都是空的,可以确认:系统层面没有内存问题。
第三步:问题在容器,不在系统
既然系统没事,那"占满"这个感受是从哪来的?答案是容器列表:
NAME MEM USAGE / LIMIT MEM %
gitea 410.6MiB / 512MiB 80.19%
docker-gitea-db-1 81.25MiB / 3.57GiB 2.22%
vaultwarden 74.1MiB / 3.57GiB 2.03%
Gitea 用掉了自己限额的 80%。这个 80% 跟整机内存没有关系,是它自己的 cgroup 配额。
再往下看它自己的 cgroup 计数器,才看到真正的信号:
CID=$(docker inspect -f '{{.Id}}' gitea)
cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/memory.events
low 0
high 0
max 4129 ← 撞到限额 4129 次
oom 0
oom_kill 0
峰值 513M,撞限 4129 次,但没有一次被 OOM 杀掉。
这说明 Gitea 长期顶着自己的天花板在跑。每次试图超出限额,内核就去回收它的页面缓存来腾空间。进程不会崩,但文件读取会不断退化成读磁盘,性能受损。
第四步:确认这是增长还是泄漏
重启一次,看冷启动占用:
gitea: 277.9MiB / 512MiB (54%) cgroup 事件全 0
运行 21 小时后:410.6MiB / 512MiB (80%)。
冷启动 280M,一天之内爬到 410M。这是 Go 程序的典型行为——Gitea 是 Go 写的,运行时申请的堆内存倾向于持有而不归还操作系统,RSS 只增不减。这不等于内存泄漏,但意味着限额必须按峰值来定,不能按冷启动来定。
它曾经冲到过 513M,也就是说它真实的需求本来就超过 512M,是限额压着它。
处理:把限额抬到 1G
机器上可用内存 2.5G,swap 一点没动,给 Gitea 1G 完全撑得住。跑满也只占全机的 28%。
改动落在两个地方,保持永久生效:
1. 修改 compose 文件,避免下次重建容器又缩回去:
services:
server:
mem_limit: 1g
2. 热更新正在运行的容器,不重启、不断服务:
docker update --memory 1g --memory-swap 1536m gitea
--memory-swap 是内存加 swap 的总上限。设成 1.5G 意味着在 1G 内存之外还留了 512M 的 swap 缓冲,万一真冲高不至于被直接杀掉。
这两步配合的意义:docker update 立即生效但只改运行时;compose 文件保证持久化。只做前者,下次容器重建就失效;只做后者,得等下次重建才生效。
验证结果:
gitea: 278.3MiB / 1GiB (27.18%)
HTTP 200
status: pass
database:ping → pass
cache:ping → pass
StartedAt 没变,RestartCount = 0,说明容器全程没有重建,服务没有中断。
小结
几个值得记住的点:
- 看内存要看
available,不要看total - free。buff/cache是可回收的,面板把它算进已用会误导人。 - swap 用量低不是问题,是好事。真要看内存压力,查 OOM 历史和 PSI。
- "某个东西占满了"要先分清是系统层面还是容器层面。容器的
MEM %是相对它自己的 limit 算的。 - cgroup 的
memory.events里max计数是关键指标。它告诉你容器多久撞一次限额,比docker stats的瞬时百分比更能反映真实状况。 - Go 服务的内存限额要按峰值定。冷启动占用参考价值有限。
docker update和 compose 文件要同时改,一个负责立即生效,一个负责持久化。
整个排查从"内存满了"到定位到"某个容器的配额太小",花了不到十分钟——前提是第一步就拿到了原始数据,而不是相信任何一个仪表盘上的百分比。