Flykhan
Flykhan
发布于 2026-10-22 / 9 阅读
0

深夜排障:内存没满、Swap 没动,但 Gitea 快撑不住了

深夜排障:内存没满、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,说明容器全程没有重建,服务没有中断。

小结

几个值得记住的点:

  1. 看内存要看 available,不要看 total - free。buff/cache 是可回收的,面板把它算进已用会误导人。
  2. swap 用量低不是问题,是好事。真要看内存压力,查 OOM 历史和 PSI。
  3. "某个东西占满了"要先分清是系统层面还是容器层面。容器的 MEM % 是相对它自己的 limit 算的。
  4. cgroup 的 memory.events 里 max 计数是关键指标。它告诉你容器多久撞一次限额,比 docker stats 的瞬时百分比更能反映真实状况。
  5. Go 服务的内存限额要按峰值定。冷启动占用参考价值有限。
  6. docker update 和 compose 文件要同时改,一个负责立即生效,一个负责持久化。

整个排查从"内存满了"到定位到"某个容器的配额太小",花了不到十分钟——前提是第一步就拿到了原始数据,而不是相信任何一个仪表盘上的百分比。