Docker容器跑多个微服务内存分配不合理会不会拖垮整台宿主机
Docker容器跑多个微服务,内存分配不合理会不会拖垮整台宿主机
会,而且现场里见过不止一次。Docker容器不是虚拟机,容器里的进程本质上还是宿主机上的进程,只是被namespace隔离、被cgroup限制资源。如果没有给容器设置memory limit,或者设置得很随意,多个微服务一起跑的时候,宿主机内存、page cache、swap、OOM Killer都会被牵扯进去。
比较典型的情况是:单个服务看起来没问题,压测也能跑;一到线上,网关、业务API、任务队列、日志采集、监控Agent、Redis、MySQL sidecar之类全堆在一台机器上,内存一点点被吃满。最后不是某个容器单独挂掉,而是宿主机load飙高、SSH卡死、Docker daemon响应慢,甚至整台机器被OOM打穿。
容器没限制内存时,吃的是宿主机总内存
Docker默认情况下不会自动给容器分配固定内存。也就是说,一个容器启动时如果没有加--memory参数,它能申请的内存上限基本就是宿主机可用内存。多个容器一起跑,每个都觉得自己还能申请,最后争的是同一个物理内存池。
这里容易误解的是,容器里看到的free -m不一定等于它真正能用的上限。老版本环境或者没做限制时,容器里看到的可能就是宿主机总内存。应用如果根据这个数自动调整堆大小,比如JVM、Node.js、Go程序的缓存池,就可能把自己放得太开。
实际使用中发现,很多微服务不是被业务请求直接打爆,而是被“默认值”拖死的。JVM默认堆、连接池默认值、日志缓冲、ORM缓存、本地队列、图片处理临时对象,这些东西单独看都不大,叠在一起就很难看。
一台8G宿主机上跑多个服务,大概会怎么炸
假设一台8G内存的云服务器,上面跑这些容器:
gateway:预期300MB,峰值700MB;user-api:预期600MB,峰值1.5GB;order-api:预期800MB,峰值2GB;worker:预期500MB,峰值2GB;Redis:预期1GB,峰值2GB;日志采集和监控:预期300MB,峰值800MB。
按“平时看起来”的占用算,大约3.5GB上下,似乎很安全。问题在于线上不会永远停在平稳状态。只要遇到批量任务、慢SQL堆积、接口重试、消息队列积压、日志暴增,几个服务同时进入峰值,内存需求就可能冲到9GB以上。
8G机器本身还要留给Linux kernel、Docker daemon、文件系统page cache、网络缓冲、系统服务。真正能稳定给业务长期吃满的内存通常不是8G,而是要打折。很多环境里,业务容器长期占到物理内存的75%以后,就已经开始变得敏感了。
内存打满以后,不一定马上报错,先表现为系统变慢
内存压力上来时,最先看到的往往不是容器退出,而是延迟抖动。接口P99突然上升,数据库连接变慢,日志写入卡顿,容器exec进去很慢,docker ps半天不返回。
原因很直接:内存不够后,系统会更频繁地回收page cache,可能触发swap;如果swap开着,进程还能苟一会儿,但性能会非常差。磁盘I/O被swap拖住以后,整个宿主机都会变钝。尤其是云服务器上,如果系统盘IOPS一般,swap一跑起来,应用层看到的就是随机卡顿。
多说一句,swap不是不能开,而是不能拿它当内存扩容。线上服务如果依赖swap维持运行,基本说明容量模型已经不对了。可以保留少量swap防止瞬时抖动,但不能让业务长期在swap里跑。
OOM Killer会杀谁,不一定按人的预期来
当宿主机内存真的撑不住,Linux OOM Killer会介入。没有给容器设置memory limit时,OOM Killer是在宿主机维度挑进程杀。它看的是进程内存占用、oom_score、oom_score_adj等因素,不会理解“这个服务更重要”。
现场里比较糟糕的情况是:本来只是worker内存泄漏,结果宿主机OOM时杀掉了MySQL、Redis,或者把Docker daemon相关进程搞得异常。业务影响从单个任务容器扩大成整机故障。
如果给容器设置了--memory,当容器内部超过限制,通常会触发容器级OOM。这样影响范围更可控:该容器被杀、重启,其他容器还在。虽然服务也会抖,但比整台宿主机被拖垮要好处理得多。
Docker内存限制不是写个数就完事
常见参数有--memory、--memory-swap、--oom-kill-disable这些。生产环境里最常用的是明确设置--memory,不建议随便关闭OOM Kill。关闭之后进程申请不到内存可能卡住,宿主机压力反而更难判断。
比如一个Java服务:
docker run -d --name order-api --memory=1g --memory-swap=1g your-image
这里--memory=1g表示容器最多使用1GB内存,--memory-swap=1g表示不额外使用swap。这个配置比较硬,超过就容易容器OOM。适合希望故障边界清晰的服务。
如果希望允许少量swap缓冲,可以设成:
docker run -d --name order-api --memory=1g --memory-swap=1536m your-image
这表示内存1GB,总内存加swap最多1.5GB。这里补充一点,具体行为和Docker版本、cgroup v1/v2有关系,线上最好在同版本系统上验证,不要只看文档想象。
JVM服务尤其要注意容器感知
Java微服务是内存分配事故高发区。以前不少JDK版本对容器cgroup感知不完整,JVM可能按宿主机内存计算默认堆大小。比如宿主机16G,容器限制1G,但JVM以为自己能用16G的一部分,最后堆、直接内存、线程栈、Metaspace加起来超过容器限制。
现在使用较新的JDK版本,容器感知已经好很多,但仍然建议显式设置:
-Xms512m -Xmx512m -XX:MaxMetaspaceSize=128m -XX:MaxDirectMemorySize=128m
不要只设置-Xmx。很多线上OOM不是堆满,而是direct memory、线程栈、本地内存、压缩解压临时buffer把容器顶穿。Netty、gRPC、Kafka client这类组件用得多时,direct memory要单独估。
Go和Node.js也不是省心到不用管
Go服务平时看着内存占用不高,但高并发下goroutine、对象分配、GC目标、连接缓冲都会涨。Go的runtime会根据内存增长调整GC节奏,新版本可以配GOMEMLIMIT,这个在容器里很有用。
例如容器限制512MB,Go服务可以设置:
GOMEMLIMIT=400MiB
这样给runtime一个更明确的软上限,留出一部分空间给栈、mmap、C库、系统开销。
Node.js默认老生代内存也要看版本和环境,不要以为容器限制了512MB,Node就一定乖乖在512MB内工作。高流量JSON处理、图片处理、Excel导出、日志堆积,都可能把内存拉上去。需要时用--max-old-space-size控制。
多个微服务混跑时,预留比平均值更重要
只按平均内存做规划,基本会踩坑。容量规划要看峰值、启动瞬间、发布滚动期间、异常重试期间。
举个更贴近现场的数:一台16G宿主机,系统和基础组件预留2G,page cache和突发预留2G,剩下12G给业务容器。不是说可以把12个1G容器塞满就完事,因为服务发布时可能新旧容器同时存在,健康检查还没通过,旧容器不能立刻下线。滚动发布阶段内存可能短时间翻倍一部分。
如果单个服务平时500MB,峰值1GB,发布时新旧实例并存,那这个服务在节点上的瞬时预算可能要按1.5GB甚至2GB看。这个数字看起来保守,但比半夜被OOM叫醒便宜。
别把数据库和一堆微服务挤在小内存机器上
很多小团队为了省机器,会把MySQL、Redis、Nginx、后端API、任务服务、监控全放一台2G或4G机器。低流量阶段没问题,一旦业务增长或者遭遇爬虫,问题会集中爆发。
Redis尤其明显。Redis本身是内存数据库,如果maxmemory没设,或者淘汰策略不对,它会和业务容器抢宿主机内存。MySQL也一样,buffer pool、连接数、临时表都会吃内存。数据库被OOM杀掉,恢复成本通常比无状态API重启高很多。
如果确实预算有限,至少把无状态微服务和有状态存储分开。API容器可以重启,Redis和MySQL不要随便跟着一起冒险。
云服务器规格选择时,别只盯CPU核数
微服务跑在Docker里,CPU不够会慢,内存不够会死得更难看。选云服务器时,1C1G、2C2G这类规格适合轻量站点、测试环境、小型API,不适合把一堆Java微服务、Redis、监控全塞进去。
如果业务是海外访问、游戏分发、接口服务、带宽型应用,选机器时还要把网络线路、带宽峰值、DDoS防护一起看。比如需要海外大带宽测试、下载分发,可以看意大利大宽带这类2Gbps峰值机器;如果是面向大陆用户的香港小型业务,CN2直连线路会比普通线路稳定不少;如果业务在中东区域,迪拜节点更适合本地访问延迟。
如果你也在找这种云服务器、G口大带宽服务器、高防服务器或者海外云计算资源,可以看看129云。它家覆盖游戏、企业、高防和海外访问场景,选型时可以直接问清楚内存、带宽、线路和防护规格,客服热线400-9177118。实际采购时建议把容器部署规模、单服务内存峰值、是否跑数据库这些信息一起报过去,不要只说“要一台能跑Docker的机器”。
线上判断是不是内存分配问题,看这些现象
docker stats里某些容器MEM USAGE长期贴近LIMIT,或者没有LIMIT但宿主机available越来越低,这就是危险信号。
dmesg里出现Out of memory、Killed process、oom_reaper,基本可以确认发生过OOM。不要只看应用日志,进程被内核杀掉时,应用自己可能来不及写任何日志。
如果接口延迟抖动同时伴随si、so不为0,说明swap在参与。vmstat 1能很快看到。只要swap in/out持续出现,业务性能就不可能稳定。
还有一个容易忽略的指标是容器重启次数。Kubernetes里看RESTARTS,Docker单机可以看docker inspect或docker ps状态。很多人只看服务当前是running,就以为没问题,实际上容器可能已经因为OOM重启过好几次。
单机Docker部署时,可以这样分内存
拿8G机器举例,如果只是跑几个轻量服务,可以按这个思路估算:系统和Docker预留1G到1.5G,监控日志预留500MB到1G,page cache和突发预留1G,业务容器总limit控制在4.5G到5.5G之间。
如果是16G机器,系统和基础组件预留2G,缓存和突发预留2G到3G,业务容器总limit控制在10G到12G之间会比较稳。服务类型越重,预留越要多。Java服务、图片处理、报表导出、视频截图这类,不要按静态待机内存算。
一个常见配置方式是:网关512MB,普通Go API 512MB到1GB,普通Java API 1GB到2GB,worker按任务类型给1GB到4GB,Redis明确设置maxmemory并小于容器limit,日志采集控制在256MB到512MB。
Redis示例:容器limit给2GB,Redis maxmemory设1.5GB左右,留出fork、AOF rewrite、连接和系统开销。不要容器给2GB,Redis也设2GB,这样后台重写或内存碎片一上来就容易炸。
Kubernetes里同样会拖垮节点
换成Kubernetes,不代表问题消失,只是表现形式变了。Pod如果只写requests不写limits,或者requests写得很低,调度器会把很多Pod塞进同一个Node。平时利用率好看,突发时节点MemoryPressure,Pod被驱逐,服务开始抖。
requests决定调度时“占位”,limits决定运行时“上限”。只写requests不写limits,Pod可能超过预期吃内存;requests写太低,节点会被过度承诺。线上经常看到某个Node上Pod都没超过自己的想象值,但加起来把Node压满。
对于关键服务,requests不要写成拍脑袋的128Mi、256Mi。至少根据压测和线上监控的P95、P99内存来定。limits也别贴着平均值写,贴太紧会频繁OOMKilled。
内存泄漏和内存分配不合理要分开看
内存泄漏是程序问题,内存分配不合理是资源治理问题。两者会叠加,但处理方式不同。
如果某个服务内存曲线只涨不降,重启后恢复,跑一段时间又涨,大概率要查泄漏。Java看heap dump、GC日志、native memory;Go看pprof;Node看heap snapshot。
如果多个服务在业务高峰一起涨,峰值后能回落,但宿主机期间被压得很危险,那就是分配和容量问题。需要调整limit、拆节点、扩内存、削峰、限制并发,而不是只让开发去查泄漏。
不要让日志把内存和磁盘一起拖住
容器日志也会参与事故。高并发异常时,服务疯狂打印错误日志,日志驱动、采集Agent、磁盘I/O都会被打满。内存紧张时,日志缓冲和采集队列还会继续占资源。
Docker默认json-file日志如果不限制大小,磁盘被写满后也会引发连锁问题。建议配置max-size和max-file,例如单容器日志100MB、保留3到5个文件。日志采集Agent也要有限流和缓冲上限,不能让它为了“保证采集”把宿主机拖死。
真正危险的是没有边界
容器化不是自动隔离故障。没设memory limit、没设应用内部上限、数据库和业务混跑、swap乱用、日志无限写,这些叠在一起,Docker只是把多个进程包装得更好看,并不会让宿主机更安全。
比较稳的做法是:每个容器有明确memory limit;应用内部知道自己的可用内存;有状态服务单独规划;宿主机保留足够余量;监控能看到容器和宿主机两层指标;发生OOM时能从dmesg、容器状态、监控曲线追到具体进程。
一台宿主机上跑多个微服务没问题,问题在于每个服务都不能假装自己独占整台机器。内存边界一旦模糊,最先出事的可能不是业务代码,而是整台宿主机的稳定性。