Docker 服务器内存占用太高,先别急着加机器

线上 Docker 主机内存打满,很多时候不是业务真的需要那么多内存,而是容器限制没做、日志没管、缓存没看清、JVM/Node.js 这类运行时参数没调,最后表现出来就是 free -h 一看 available 很低,监控一直报警。

实际使用中发现,Docker 内存问题最容易误判。Linux 会把空闲内存拿去做 page cache,看起来 used 很高,但不一定代表内存不够。真正要看的是 available、swap 使用情况、OOM 记录、容器级别的 RSS,以及某个进程是不是持续上涨不回落。

常用排查命令可以先跑一遍:

free -h

docker stats

docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

dmesg -T | grep -i "out of memory\|oom"

journalctl -k | grep -i oom

docker stats 里看到的 MEM USAGE / LIMIT 很关键。如果 LIMIT 是宿主机总内存,说明容器没有限制内存,它想吃多少就吃多少。开发环境问题不大,生产环境这么跑,迟早会被某个服务拖死。

先分清:是 Linux cache 高,还是容器真的吃内存

free -h 里 used 高,不代表马上要扩容。比如一台 16G 机器,used 14G,但 available 还有 6G,这种通常是 page cache 占用多,系统会在需要时释放。

更危险的是这种情况:

Mem: total 16G, used 15G, available 300M

Swap: total 2G, used 1.8G

available 很低,swap 又开始大量使用,业务响应变慢,容器日志里出现连接超时、GC 时间变长,甚至内核 OOM Kill,这才是需要处理的内存压力。

这里补充一点,Docker 容器内看到的内存不一定是真实限制。如果没有配置 cgroup limit,容器里的应用可能认为自己拥有整台宿主机内存。JVM、Node.js、Go 程序都会受这个影响,只是表现不一样。

给容器加 memory limit,不要让单个服务拖垮宿主机

生产环境跑 Docker,建议每个容器都设置内存上限。哪怕只是一个简单的业务服务,也不要裸跑。

直接用 docker run 的场景:

docker run -d --name app --memory=2g --memory-swap=2g nginx:latest

--memory=2g 表示容器最多使用 2G 内存。--memory-swap=2g 表示内存加 swap 总量也是 2G,相当于不让它额外吃 swap。很多线上慢故障就是 swap 被吃满后引起的,CPU 看着不高,但请求延迟飙升。

如果用 docker-compose,可以这样写:

services:

  app:

    image: your-app:latest

    mem_limit: 2g

    memswap_limit: 2g

Swarm 或 Compose v3 的 deploy.resources 在非 Swarm 模式下经常被忽略,这个坑实际遇到过。单机 docker-compose 更稳妥的方式还是确认当前版本是否支持 mem_limit,配置后用 docker inspect 看实际是否生效。

检查方式:

docker inspect app | grep -i Memory

JVM 容器最容易把内存吃爆

Java 服务跑在 Docker 里,如果没有明确设置 -Xmx,很容易出现“容器限制 2G,但 JVM 行为不符合预期”的情况。新版本 JDK 对容器支持好一些,但线上不建议完全依赖自动识别。

比如一个 2G 容器,常见配置可以这样:

JAVA_OPTS="-Xms512m -Xmx1200m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC"

不要把 -Xmx 设置成和容器内存一样大。容器内存不只堆内存,还有 Metaspace、线程栈、Direct Memory、JIT、Native Memory、日志缓冲、系统库占用。2G 容器直接给 -Xmx2g,OOM 是很正常的。

一个常见估算方式是:容器 2G,堆给 1.1G 到 1.4G;容器 4G,堆给 2.5G 到 3G。具体还要看线程数、Netty Direct Memory、业务对象大小。

如果怀疑 Java 进程 Native Memory 偏高,可以打开 NMT:

-XX:NativeMemoryTracking=summary

jcmd VM.native_memory summary

多说一句,Java 容器频繁 OOM,不要只盯着 -Xmx。线程数过多也会吃内存,一个线程 1M 栈空间,1000 个线程就是接近 1G 的潜在开销。

Node.js、Python、Go 服务也要设边界

Node.js 默认内存上限和版本、架构有关,业务对象堆积、队列堆积时,内存会涨得很快。可以用:

node --max-old-space-size=1024 server.js

这表示 old space 控制在大约 1G。容器如果是 2G,不建议直接给到 1800M,还是要给系统和 native 模块留空间。

Python 服务常见问题是 worker 开太多。比如 Gunicorn:

gunicorn app:app -w 8 -k gevent

如果每个 worker 吃 300M,8 个就是 2.4G,还没算 cache 和连接池。实际线上更建议先用 2 到 4 个 worker 压测,看 QPS 和内存曲线,再决定是否增加。

Go 服务一般内存回收还可以,但也不是不会涨。Go 1.19 以后可以用 GOMEMLIMIT 控制软内存限制:

GOMEMLIMIT=1500MiB

这对容器环境比较有用,尤其是日志处理、网关、消费队列这类高吞吐服务。

日志占用不只是磁盘问题,也会影响内存和 IO

Docker 默认 json-file 日志如果不限制,容器长时间跑下来,一个日志文件几十 GB 并不罕见。磁盘满只是表象,日志持续写入还会带来 page cache、IO wait、Docker daemon 管理压力。

建议直接在 Docker daemon 层面限制日志大小:

/etc/docker/daemon.json

{

  "log-driver": "json-file",

  "log-opts": {

    "max-size": "100m",

    "max-file": "3"

  }

}

修改后重启 Docker:

systemctl restart docker

注意,daemon.json 修改后只对新创建的容器生效,老容器通常需要重建。这个细节很容易漏。

查看容器日志大小:

du -sh /var/lib/docker/containers/*/*-json.log

如果已经有超大日志,可以先停止对应容器,再清空日志文件:

truncate -s 0 /var/lib/docker/containers/容器ID/容器ID-json.log

不建议直接 rm 日志文件,Docker daemon 可能还持有文件句柄,空间不一定立刻释放。

镜像和容器太多,会让宿主机越来越沉

很多测试环境内存高,还伴随磁盘高、inode 高,最后一看是没清理过 Docker。停止的容器、悬空镜像、旧 layer、build cache 堆了一堆。

查看占用:

docker system df

清理无用对象:

docker system prune

连没使用的镜像一起清:

docker system prune -a

清理 build cache:

docker builder prune

这里要谨慎,生产机器不要手滑执行 docker system prune -a --volumes。如果 volume 里挂了业务数据,删完就不是内存优化问题了。

数据库不建议和一堆业务容器挤在同一台机器上

MySQL、PostgreSQL、Redis、Elasticsearch 这类服务,本身就是内存敏感型。尤其 Redis,默认能吃多少吃多少,不设置 maxmemory,宿主机很容易被顶满。

Redis 至少配置:

maxmemory 2gb

maxmemory-policy allkeys-lru

MySQL 要看 innodb_buffer_pool_size。一台 8G 机器上,如果 MySQL 给 5G,旁边又跑几个 Java 容器,内存报警很正常。

Elasticsearch 更明显,堆内存建议固定:

-Xms2g

-Xmx2g

并且不要超过物理内存的一半。ES 还依赖 filesystem cache,堆给太大反而不稳。

实际生产里,如果业务已经有稳定流量,数据库、缓存、搜索服务尽量拆出去。Docker 单机混部署可以用于小规模业务和测试环境,但不要把它当成长期承载高并发业务的默认形态。

监控要看容器级别,不要只看宿主机

只看宿主机内存,定位会很慢。宿主机报警后,还要知道是哪一个容器、哪个进程、从什么时候开始涨。

轻量一点可以用 cAdvisor + Prometheus + Grafana。重点看这些指标:

container_memory_usage_bytes

container_memory_working_set_bytes

container_memory_rss

container_memory_cache

container_memory_failcnt

其中 working_set 比 usage 更接近实际压力,usage 里包含 cache,容易看起来偏大。failcnt 如果持续增加,说明容器触碰过内存限制。

如果只是临时排查,可以用:

docker stats --no-stream

再进入容器看进程:

docker exec -it app sh

ps aux --sort=-rss | head

宿主机上也可以直接看容器进程:

ps -eo pid,ppid,cmd,%mem,rss --sort=-rss | head -20

Swap 不是不能开,但别指望它救业务

服务器内存紧张时,很多人会加 swap。swap 可以防止某些场景下直接 OOM,但对在线业务来说,大量 swap 基本等于慢性故障。

建议控制 swappiness:

sysctl vm.swappiness=10

持久化:

echo "vm.swappiness=10" >> /etc/sysctl.conf

如果是数据库服务器,swappiness 可以更低。容器环境里更关键的是给容器设置 --memory--memory-swap,不要让某个容器无限制把 swap 吃满。

容器数量多时,要重新算资源账

一台 8G 机器,如果跑 10 个容器,每个容器看起来只吃 500M,总共就是 5G。但还要算 Docker daemon、系统进程、page cache、日志、监控 Agent、内核开销,以及偶发峰值。

比较稳的预留方式是:不要把容器 limit 总和直接打满宿主机内存。比如 16G 机器,业务容器 limit 总和控制在 11G 到 13G,剩下留给系统、cache 和突发波动。

如果某些容器是批处理、定时任务、报表导出,它们的峰值可能远高于日常值。这个时候只看平均内存没意义,要看 P95、P99,最好把任务错峰。

比如每天凌晨 2 点,三个任务同时跑:一个导出 Excel,一个同步订单,一个压缩图片。平时内存 40%,到点直接 95%,这不是 Docker 本身问题,是调度时间撞车。

内存泄漏要用曲线判断,不要看单点

内存泄漏的典型表现不是“高”,而是“持续上涨不回落”。比如容器启动后 500M,运行 6 小时到 1.5G,12 小时到 2.8G,GC 后也不明显下降,这种就要查应用了。

Java 看 heap dump:

jmap -dump:format=b,file=/tmp/heap.hprof

再用 MAT 或 JProfiler 分析大对象、集合引用、缓存对象。

Node.js 可以用 heap snapshot,Python 可以用 tracemalloc、objgraph。Go 可以开 pprof:

go tool pprof http://127.0.0.1:6060/debug/pprof/heap

这里别把所有问题都推给 Docker。Docker 只是把资源边界显性化了,业务代码里的无上限 Map、本地缓存、消息堆积、连接未释放,都会在容器里表现得更明显。

宿主机规格不合适时,该换就换

优化不是无限压榨机器。业务已经稳定吃内存,比如 Java 服务 6G、Redis 4G、MySQL 8G,还想塞在一台 16G 机器上,调参数只能拖延,不能解决容量问题。

选机器时要看业务类型。如果是跨境电商、TikTok、Amazon 相关业务,出口 IP、线路稳定性和原生属性会影响访问体验;如果是游戏、API 网关、实时业务,延迟和带宽峰值要一起看;如果是面向大陆访问的海外业务,CN2、BGP 线路质量很关键。

如果你也在找这种适合 Docker 部署、业务拆分、海外访问的服务器,可以看看129云。比如美国双ISP-D型是 16C / 16G DDR4 ECC / 150G SSD / 100Mbps 峰值,适合 TikTok、Amazon、电商和部分游戏业务;香港精品CN2-D型是 16C / 16G DDR4 ECC / 220G SSD / 100Mbps 峰值,带 2.5TB 流量,适合对大陆访问质量要求高的业务;轻量业务可以看日本BGP-A型,2C / 2G / 50G SSD,适合小型服务、节点测试、低负载应用。

内存经常打满,但 CPU 长期不到 30%,这种多半是服务堆叠方式有问题,或者机器内存规格偏小。可以先拆服务、限资源、清日志、查泄漏;如果业务本来就需要更大内存,再换更合适的规格。选型不确定时可以直接问129云客服,热线 400-9177118,把容器数量、单容器内存、带宽需求、访问地区说清楚,沟通会快很多。

一个实际处理顺序:先止血,再定位,再改配置

线上内存已经报警时,不建议一上来就重启 Docker。重启 Docker daemon 会影响所有容器,风险比较大。

更稳的处理顺序是先看谁在吃:

docker stats --no-stream

找到异常容器后,看最近日志是否刷屏:

docker logs --tail=200 app

再看宿主机 OOM:

dmesg -T | grep -i oom

如果某个非核心容器异常上涨,可以单独重启它:

docker restart app

如果是日志爆了,先清日志、加 log limit。如果是 Redis、JVM、Node.js 内存没限制,先补运行参数。如果是容器没有 memory limit,安排低峰重建容器。

容器启动参数变更通常需要重建,不要只改 compose 文件就以为生效了:

docker compose up -d --force-recreate app

容易忽略的内存来源

监控 Agent

Prometheus exporter、日志采集 Agent、APM Agent 都会占内存。特别是日志量很大的机器,采集端缓冲区可能越堆越高。Filebeat、Fluent Bit、Vector 都要看队列和缓存配置。

反向代理缓存

Nginx、OpenResty 如果开了 proxy cache、Lua shared dict,内存占用要算进去。Lua 代码里如果全局 table 不受控,也可能泄漏。

临时文件放在 tmpfs

有些容器把 /tmp 或上传目录挂到 tmpfs,文件实际占的是内存。图片处理、视频转码、报表导出场景尤其常见。

连接池开太大

数据库连接池、HTTP 连接池、Redis 连接池都不是越大越好。连接本身占内存,连接背后的 buffer 也占内存。一个服务开 200 个数据库连接,10 个服务就是 2000 个连接,数据库端和应用端都会有压力。

配置层面可以直接落地的调整

Docker daemon 日志限制建议默认加上:

{ "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } }

业务容器加内存限制:

--memory=2g --memory-swap=2g

Java 容器明确堆大小:

-Xms512m -Xmx1200m

Node.js 明确 old space:

--max-old-space-size=1024

Redis 设置最大内存:

maxmemory 2gb

宿主机降低 swap 倾向:

vm.swappiness=10

定期看 Docker 占用:

docker system df

把这些配置补上以后,再观察 24 到 72 小时的内存曲线。重点看容器 RSS 是否稳定、宿主机 available 是否保持在安全范围、swap 是否持续增长、OOM 记录是否还出现。