容器化服务生产问题经验总结

581 阅读8分钟

背景

最近有个服务接入EKS容器化部署,上线时间已有一周。该服务是Java服务,使用Springboot和WebFlux作为开发框架,JDK版本是JDK17,主要是提供账户相关接口给App和Web端。
但因为当时接入容器化比较仓促,没有预估生产流量,资源配置是只有两个副本pod,每个pod 1C5G,Java进程是pod里的唯一进程。

后来有天晚上出现生产告警,该服务的Get /account/status接口突然大量报502,同时运维Ops同事收到资源不足告警,当时Ops通过扩容资源暂时解决了接口502问题

问题排查

在出现生产问题次日,开始着手排查问题。找Ops沟通,得知其在收到资源不足告警后在不断扩容,原本服务有2个pod,每个pod 1C5G,现在扩容到了10个pod,但当时内存占用还在不断增长,奇怪的是CPU占用只有10%。因为该服务上线时没做好相关监控,所以不知道昨晚出现生产问题时的生产QPS是多少,缺乏各种监控去分析昨晚的现象,只能做些事后弥补措施:

  • 联系Ops配置好相关的监控数据源,然后在Grafana上配置相关的服务监控指标
  • 该接口属于基础接口,要求测试加入到日常巡检,以及时暴露问题
  • 准备接口压测,期望能通过压测知道接口瓶颈

尽管当时生产问题现场证据有限,但依然可以做出些大致的问题分析:

  1. 502是服务挂了不响应了; 503一般是主动响应的, 服务还在, 响应这个不可用异常;如果是db超时的话网关应该报504才对
  2. Get /account/status接口是查询用户状态接口,很多交易线都需要知道用户状态,属于基础接口,甚至前端会轮询该接口,但该接口逻辑调了其他二方接口,既有写入数据库,也有读取redis缓存操作。
  3. 出现生产问题之后,发现除了日志,既看不到K8s容器基础监控也看不到服务监控,服务的metrics没有暴露Prometheus采集,导致无法定位排查。这警醒我们一点,新服务上线一定要配置好监控才算发布成功
  4. 从仅有的日志来看,是获取数据库连接失败导致的接口挂了,那么到底是内存资源不足导致的数据库连接创建失败,还是服务请求太多从而数据库连接堆积太多导致内存资源不足呢?这点需要配置数据库连接监控才能看到
  5. 从目前仅有信息来看似乎该服务有两个生产问题:一个是接口在流量大时会大量报502,属于接口性能问题;一个是在流量高峰期间内存占用不断增长容器资源不足,属于资源不足问题。而这两个问题是否有关联属于需要排查的点

资源不足问题主要是在内存占用问题上,这个服务内存占用很夸张,即使扩容后内存占用还在不断增长。

因为我是首次进行服务接入容器化,缺乏经验,不了解Java服务容器化的参数配置。因而考虑到接入容器化的Java服务与虚拟机的Java服务在JVM配置方面可能存在差异导致服务内存资源占用过高。 在学习如何配置容器化的Java服务的JVM之前,先看下这个服务的JVM参数配置一开始是怎样的:

`- name: JAVA_OPTS 
   value: >- 
        -Xms2048m -Xmx4500m 
        -XX:+UseZGC 
        -XX:+HeapDumpOnOutOfMemoryError 
        -XX:HeapDumpPath=/java`

可以看到,-Xmx(最大堆大小)是-Xms(最小堆大小)的两倍,并且使用的垃圾回收器是最新的ZGC

Java服务容器化参数配置实践

K8s 的资源模型有两个定义,资源请求(request)和资源限制(limit),K8s保障容器拥有 request数量的资源,但不允许使用超过limit数量的资源。当进程使用的资源量超过 Cgroup 的限制量,就会被系统 OOM Killer 无情地杀死,这里的OOM是指容器的OOM。

所谓的容器 OOM,实质是运行在Linux系统上的容器进程发生了OOM。容器的OOM跟服务部署在虚拟机的OOM是不一样的,服务容器化环境下资源使用是做了限额的。

  • 对于JVM 来说,默认情况下占用物理机内存的 1/4,那对于容器来说,Docker 中的 JVM 检测到的是宿主机的内存信息,它无法感知容器的资源上限,这样可能会导致意外的情况。比如我们平时在启动容器是设置了容器资源,但是 Java 应用容器在运行中还是会莫名奇妙地被 OOM Killer 干掉。所以,为了解决这个问题,Java 10 引入了 UseContainerSupport 允许 JVM 从主机读取 cgroup 限制。得益于 “+UseContainerSupport” 参数,JVM 能够感知到对容器的内存限制。这个参数在JDK10以上版本是JVM 中的默认参数,因此无需显式配置。
  • 当在物理机或者虚拟机上部署Java服务配置JVM参数时,你可以选择使用-Xmx/-Xms 来指定 Java 堆大小,但这样指定的话,就固定了 JVM 堆占用大小。如果将 Java 应用程序移植到K8s Pod 中,K8S 本身有垂直扩容的能力,如果把内存从 8G 增长到 16G,JVM的堆大小还是固定不变的话就不能做到充分利用内存资源,所以容器化环境下JVM的堆参数要如何配置呢?Java 8 update 191 及更高版本支持“-XX:MaxRAMPercentage”、“-XX:InitialRAMPercentage”、“-XX:MinRAMPercentage” JVM 参数。当 设置“-XX:MaxRAMPercentage=50”时JVM 将最大堆大小分配为容器内存的一半,InitialRAMPercentage和MinRAMPercentage同理。

通过以上的了解,我们可以把JVM参数配置成这样

-XX:+UseZGC 
-XX:+UseContainerSupport 
-XX:InitialRAMPercentage=70 
-XX:MaxRAMPercentage=70.0 
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/java`

‌将JVM的InitialRAMPercentage(初始堆内存大小,等同于Xms)和MaxRAMPercentage(最大堆内存大小,等同于Xmx)设置为相同的值,可以避免JVM在运行时频繁调整堆内存大小,当堆内存大小频繁变化时,JVM需要花费时间重新分配内存,并可能执行额外的垃圾收集操作,这会影响应用程序的响应速度和吞吐量。通过固定堆内存大小,可以减少这种动态调整带来的性能影响。

那除此之外,垃圾回收器的选择是否会影响服务的内存资源呢?

本地压测接口不同GC下的内存占用:

在mac上使用docker容器部署该服务,限制容器资源为1核5G,启动命令的JVM参数与生产保持一致,使用Jmeter作为压测工具,压测使用不同垃圾回收器时的资源占用情况:

  1. CPU=1C,memory=5G, -Xms2048m -Xmx4500m -XX:+UseZGC

image.png 2.更改GC为G1:CPU=1C,memory=5G, -Xms2048m -Xmx4500m -XX:+UseG1GC

image.png

通过对比可以看到结果:内存占用显著降低。 那为什么使用了ZGC会比使用G1占用更多内存呢?这个问题可以留给有兴趣的人探究,提供一些关于ZGC的文章:

所以最终JVM的参数配置是这个样子:

-XX:+UseG1
-XX:+UseContainerSupport 
-XX:InitialRAMPercentage=70 
-XX:MaxRAMPercentage=70.0 
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/java`

经过上面的参数配置优化,服务的内存问题得到解决。

经验总结:

  1. 服务接入容器化,一定要配置好各种监控才算服务发布成功。这些监控包括但不限于:服务JVM监控,服务接口监控,K8s容器pod监控,服务日志,K8s Ingress网关日志
  2. 在服务上线前最好能对服务需要承受的请求量有所预估,比如生产上线后会承接多大QPS,这样Ops才好分配容器资源
  3. 在服务上线前最好能对关键接口进行压测,得到一个基准,比如1C2G的硬件资源下服务能承接多少QPS,然后根据这个基准去纵向或者横向扩容
  4. 一些服务参数不应该写死在服务配置文件里,应该定义在环境变量中。比如JVM参数(最大堆,最小堆,GC),应用参数(配置的请求线程数,线程空闲时间)。
  5. 对于要经常排查生产问题的需要,要多看生产监控,了解grafana监控配置
  6. 容器化环境和虚拟机环境有很大不同,服务感知的机器配置也不同。Java服务容器化需要配置特定参数,与虚拟机不一样。
  7. 在容器化部署Java服务时,指定 -XX:HeapDumpPath 路径需要考虑持久化存储的方式。在虚拟机部署时,HeapDumpPath往往是虚拟机上的某个路径,比如-XX:HeapDumpPath=/home/excore/java-api/config/,但部署到容器化,就得使用持久化卷(PVC)或宿主机路径(hostPath)进行持久化存储。

参考引用