一动就扩容,扩了就不缩第一枪打错了地方三个偏方,全都不对劲真相:GC 眼里,内存管够一行配置,当场痊愈这单交的学费

先说结论:这次折腾了好几天的内存问题,最后发现连 bug 都算不上——是部署文件里少写了一行配置。

事情是这样的。我们有个海外项目,一共 70 个服务,前前后后花了一年多时间才完成云服务迁移和架构调整:容器从 docker swarm 换成 Kubernetes,托管在 AWS 的 EKS 上。单个服务本身没怎么动,但外围架构整个换了血。

新架构上线,内存问题就跟着来了。挑一个最典型的服务说:一个普通的 ASP.NET Core gRPC 工程,平时流量不大,但它的内存曲线,怎么看怎么不对劲。

这个服务在 K8s 里配了自动扩缩容(HPA),内存请求值(request)设的 512Mi,最大副本数 5。结果生产环境一启动,Pod 内存蹭一下顶到 512Mi,直接触发扩容,5 个副本拉满。

打开网易新闻 查看精彩图片

HPA 拉满 5 个副本,每个 Pod 内存都在 500Mi 上下

更邪门的是两点:

  • • 副本内存长时间维持在 500~600Mi 不释放,压测的时候甚至能冲到 800Mi;
  • • 就算完全没有请求,内存也要超过 20 个小时才慢慢降到 350Mi 左右;
  • • 而接口响应时间完全正常,用户毫无感知。

QA 环境压根没这毛病,所以测试阶段一点征兆都没有。老板看曲线血压升高,我们只能自己上手查。

排查的第一反应永远是:是不是代码有内存泄漏?毕竟其他类似服务都好好的。

于是开始翻代码。翻了好几遍,除了几处 ToList 用得太频繁,没发现问题;用 VS 诊断工具跑了一遍,也没有内存泄露。不死心,又在 QA 环境用 dotnet-dump 把内存快照拉下来分析,还真找到一个大对象堆(LOH)驻留的类型——一个从数据库查列表再分组计算的类。

然后开始最原始的二分法+注释大法:把这个查询注释掉,内存果然只在 300Mi 左右,回落也变快了。眼看就要结案,结果继续往下拆——最后只保留数据库查询、连返回值引用都不接,内存照样冲到 500~600Mi。

这就离谱了。列表拢共才 11000 条数据,一个查询而已,怎么就能把内存顶穿扩容线?代码这条路,走到头了。

代码查不出毛病,那就调 GC。先后试了三个方案:

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

定时执行 GC.Collect 的实验:蓝色 Pod 每 5 分钟回收一次,内存确实压下来了

看起来方案一、三都能用,但查了资料心里更虚了:ASP.NET Core 默认就是 Server GC,为高性能设计,本来就不会频繁回收。强行换模式、强行手动回收,都是跟框架拧着干。真正的病根,不在这。

查资料的过程中才反应过来一个被忽略的基本设定:K8s 的容器资源有request(预留)和limit(上限)两套值。我们的部署文件只写了 request,没写 limit。

这意味着什么?得让 GC 自己说话。于是给服务加了个查询接口:

打开网易新闻 查看精彩图片

一查,答案直接糊脸上——

打开网易新闻 查看精彩图片

GC 感知到的可用内存:4,037,939,200 字节,约 4Gi

GC 眼里的可用内存是4Gi——因为没设 limit,容器直接把宿主机的 4GB 内存当成了自己的额度。对 GC 来说,内存管够,凭什么急着回收?所以它慢悠悠地攒着,攒到触发 HPA 阈值,副本一个个拉满,内存曲线自然死活不降。

补上 limit:

打开网易新闻 查看精彩图片

加上 limit 后再查,可用内存变成 512Mi。重新压测:

打开网易新闻 查看精彩图片

修复后:压测结束内存回落,副本缩容正常

内存释放正常,副本扩了能缩、缩了能稳,接口响应时间没受任何影响。折腾了好几天的问题,收工只用了一行配置。

  • • 遇到内存居高不下,先别急着怀疑代码——先确认 .NET 的 GC 模式(Server 还是 Workstation),再确认容器实际分到的可用内存是多少;
  • • K8s 下第一件事就是检查资源 limit 有没有设置,没有 limit,GC 会把宿主机内存当成自己的额度;
  • • limit 设了还有泄漏,再去翻代码也不迟。

说白了,这次不是代码写错了,是「环境」和「预期」根本没对上:你以为容器只有 512Mi,GC 以为它有 4Gi。两边各说各话,内存曲线就成了悬案。

你的项目里,request 和 limit 都配齐了吗?评论区聊聊,我猜不少人只写了 request