38 加餐8:Java程序从虚拟机迁移到Kubernetes的一些坑 我们又见面了。结课并不意味着结束,我非常高兴能持续把好的内容分享给你,也希望你能继续在留言区与我保持交流,分享你的学习心得和实践经验。

使用 Kubernetes 大规模部署应用程序,可以提升整体资源利用率,提高集群稳定性,还能提供快速的集群扩容能力,甚至还可以实现集群根据压力自动扩容。因此,现在越来越多的公司开始把程序从虚拟机(VM)迁移到 Kubernetes 了。

在大多数的公司中,Kubernetes 集群由运维来搭建,而程序的发布一般也是由 CI/CD 平台完成。从虚拟机到 Kubernetes 的整个迁移过程,基本不需要修改任何代码,可能只是重新发布一次而已。所以,我们 Java 开发人员可能对迁移这个事情本身感知不强烈,认为 Kubernetes 只是运维需要知道的事情。但是程序一旦部署到了 Kubernetes 集群中,在容器环境中运行,总是会出现各种各样之前没有的奇怪的问题。

今天的加餐,就让我们一起看下这其中大概会遇到哪些“坑”,还有相应的“避坑方法”。

Pod IP 不固定带来的坑

Pod 是 Kubernetes 中能够创建和部署应用的最小单元,我们可以通过 Pod IP 来访问到某一个应用实例,但需要注意的是,如果没有经过特殊配置,Pod IP 并不是固定不变的,会在 Pod 重启后会发生变化。

不过好在,通常我们的 Java 微服务都是没有状态的,我们并不需要通过 Pod IP 来访问到某一个特定的 Java 服务实例。通常来说,要访问到部署在 Kubernetes 中的微服务集群,有两种服务发现和访问的方式:

通过 Kubernetes 来实现。也就是通过 Service 进行内部服务的互访,通过 Ingress 从外部访问到服务集群。

通过微服务注册中心(比如 Eureka)来实现。也就是服务之间的互访通过客户端负载均衡后 + 直接访问 Pod IP 进行,外部访问到服务集群通过微服务网关转发请求。

使用这两种方式进行微服务的访问,我们都没有和 Pod IP 直接打交道,也不会把 Pod IP 记录持久化,所以一般不需要太关注 Pod IP 变动的问题。不过,在一些场景下,Pod IP 的变动会造成一些问题。

之前我就遇到过这样的情况:某任务调度中间件会记录被调度节点的 IP 到数据库,随后通过访问节点 IP 查看任务节点执行日志的时候,如果节点部署在 Kubernetes 中,那么节点重启后 Pod IP 就会变动。这样,之前记录在数据库中的老节点的 Pod IP 必然访问不到,那么就会发生无法查看任务日志的情况。

遇到这种情况,我们应该怎么做呢?这时候,可能就需要修改这个中间件,把任务执行日志也进行持久化,从而避免这种访问任务节点来查看日志的行为。

总之,我们需要意识到 Pod IP 不固定的问题,并且进行“避坑操作”:在迁移到 Kubernetes 集群之前,摸排一下是否会存在需要通过 IP 访问到老节点的情况,如果有的话需要进行改造。

程序因为 OOM 被杀进程的坑

在 Kubernetes 集群中部署程序的时候,我们通常会为容器设置一定的内存限制(limit),容器不可以使用超出其资源 limit 属性所设置的资源量。如果容器内的 Java 程序使用了大量内存,可能会出现各种 OOM 的情况。

第一种情况,是 OS OOM Kill 问题。如果过量内存导致操作系统 Kernel 不稳定,操作系统可能就会杀死 Java 进程。这时候,你能在操作系统 /var/log/messages 日志中找到类似 oom_kill_process 的关键字。

第二种情况,是我们最常遇到的 Java 程序的 OOM 问题。程序超出堆内存的限制申请内存,导致 Heap OOM,后续可能会因为健康检测没有通过被 Kubernetes 重启 Pod。

img

在 Kubernetes 中部署 Java 程序时,这两种情况都很常见,表现出的症状也都是 OOM 关键字 + 重启。所以,当运维同学说程序因为 OOM 被杀死或重启的时候,我们一定要和运维同学一起去区分清楚,到底是哪一种情况,然后再对症处理。

对于情况 1,问题的原因往往不是 Java 堆内存不够,更可能是程序使用了太多的堆外内存,超过了内存限制。这个时候,调大 JVM 最大堆内存只会让问题更严重,因为堆内存是可以通过 GC 回收的。我们需要分析 Java 进程哪部分区域内存占用过大,是不是合理,以及是否可能存在内存泄露问题。Java 进程的内存占用除了堆之外,还包括

直接内存

元数据区

线程栈大小 Xss /* 线程数

JIT 代码缓存

GC、编译器使用额外空间

……

我们可以使用 NMT 打印各部分区域大小,从而判断到底是哪部分内存区域占用了过多内存,或是可能有内存泄露问题: java -XX:NativeMemoryTracking=smmary/detail -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics

如果你确认 OOM 是情况 2,那么我同样不建议直接调大堆内存的限制,防止之后再出现情况 1。我会更建议你把堆内存限制为容器内存限制的 50%~70%,预留出足够多的内存给堆外和 OS 核心。如果需要扩容堆内存的话,那么也需要同步扩容容器的内存 limit。此外,也需要通过 Heap Dump(你可以回顾下第 35 讲的相关内容)等手段来排查为什么堆内存占用会这么大,排除潜在的内存泄露的可能性。

内存和 CPU 资源配置不适配容器的坑

刚刚我们提到了,堆内存扩容需要结合容器内存 limit 同步进行。其实,我们更希望的是,Java 程序的堆内存配置能随着容器的资源配置,实现自动扩容或缩容,而不是写死 Xmx 和 Xms。这样一来,运维同学可以更方便地针对整个集群进行扩容或缩容。

对于 JDK>8u191 的版本,我们可以设置下面这些 JVM 参数,来让 JVM 自动根据容器内存限制来设置堆内存用量。比如,下面配置相当于把 Xmx 和 Xms 都设置为了容器内存 limit 的 50%: XX:MaxRAMPercentage=50.0 -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=50.0

接下来,我们看看 CPU 资源配置不适配容器的坑,以及对应的解决方案。

对于 CPU 资源的使用,我们主要需要注意的是,代码中的各种组件甚至是 JVM 本身,会根据 CPU 数来配置并发数等重要参数指标,那么:

如果这个值因为 JVM 对容器的兼容性问题取到了 Kubernetes 工作节点的 CPU 数量,那么这个数量可能就不是 4 或 8,而是 128 以上,进而导致并发数过高。

对于 JDK>8u191 的版本可能会对容器兼容性较好,但是其获取到的 Runtime.getRuntime().availableProcessors() 其实是 request 的值而不是 limit 的值(比如我们设置 request 为 2、limit 为 8、CICompilerCount 和 ParallelGCThreads 可能只是 2),那么可能并发数就会过低,进而影响 JVM 的 GC 或编译性能。

所以,我的建议是:

第一,通过 -XX:+PrintFlagsFinal 开关,来确认 ActiveProcessorCount 是不是符合我们的期望,并且确认 CICompilerCount、ParallelGCThreads 等重要参数配置是否合理。

第二,直接设置 CPU 的 request 和 limit 一致,或是对于 JDK>8u191 的版本可以通过 -XX:ActiveProcessorCount=xxx 直接把 ActiveProcessorCount 设置为容器的 CPU limit。

Pod 重启以及重启后没有现场的坑

除非宿主机有问题,否则虚拟机不太会自己重启或被重启,而 Kubernetes 中 Pod 的重启绝非小概率事件。在存活检测不通过、Pod 重新进行节点调度等情况下,Pod 都会进行重启。对于 Pod 的重启,我们需要关注两个问题。

第一个问题是,分析 Pod 为什么会重启。

其中,除了“程序因为 OOM 被杀进程的坑”这部分提到的 OOM 的问题之外,我们还需要关注存活检查不通过的情况。

Kubernetes 有 readinessProbe 和 livenessProbe 两个探针,前者用于检查应用是否已经启动完成,后者用于持续探活。一般而言,运维同学会配置这 2 个探针为一个健康检测的断点,如果健康检测访问一次需要消耗比较长的时间(比如涉及到存储或外部服务可用性检测),那么很可能可以通过 readinessProbe 的检查但不通过 livenessProbe 检查(毕竟我们通常会为 readinessProbe 设置比较长的超时时间,而对于 livenessProbe 则没有那么宽容)。此外,健康检测也可能会受到 Full GC 的干扰导致超时。所以,我们需要和运维同学一起确认 livenessProbe 的配置地址和超时时间设置是否合理,防止偶发的 livenessProbe 探活失败导致的 Pod 重启。

第二个问题是,要理解 Pod 和虚拟机不同。

虚拟机一般都是有状态的,即便部署在虚拟机内的 Java 程序重启了,我们始终能有现场。而对于 Pod 重启来说,则是新建一个 Pod,这就意味着老的 Pod 无法进入。因此,如果因为堆 OOM 问题导致重启,我们希望事后查看当时 OS 的一些日志或是在现场执行一些命令来分析问题,就不太可能了。

所以,我们需要想办法在 Pod 关闭之前尽可能保留现场,比如:

对于程序的应用日志、标准输出、GC 日志等可以直接挂载到持久卷,不要保存在容器内部。

对于程序的堆栈现场保留,可以配置 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath 在堆 OOM 的时候生成 Dump;还可以让 JVM 调用任一个 shell 脚本,通过脚本来保留线程栈等信息: -XX:OnOutOfMemoryError=saveinfo.sh

对于容器的现场保留,可以让运维配置 preStop 钩子,在 Pod 关闭之前把必要的信息上传到持久卷或云上。

重点回顾

今天,我们探讨了 Java 应用部署到 Kubernetes 集群会遇到的 4 类问题。

第一类问题是,我们需要理解应用的 IP 会动态变化,因此要在设计上解除对 Pod IP 的强依赖,使用依赖服务发现来定位到应用。

第二类问题是,在出现 OOM 问题的时候,首先要区分 OOM 的原因来自 Java 进程层面还是容器层面。如果是容器层面的话,我们还需要进一步分析到底是哪个内存区域占用了过多内存,定位到问题后再根据容器资源设置合理的 JVM 参数或进行资源扩容。

第三类问题是,需要确保程序使用的内存和 CPU 资源匹配容器的资源限制,既要确保程序所“看”到的主机资源信息是容器本身的而不是物理机的,又要确保程序能尽可能随着容器扩容而扩容其资源限制。

第四类问题是,我们需要重点关注程序非发布期重启的问题,并且针对 Pod 的重启问题做好现场保留的准备工作,排除资源配置不合理、存活检查不通过等可能性,以避免因为程序频繁重启导致的偶发性能问题或可用性问题。

只有解决了这些隐患,才能让 Kubernetes 集群更好地发挥作用。

思考与讨论

在你的工作中,还遇到过 Java+Kubernetes 中的其他坑吗?

我是朱晔,欢迎在评论区与我留言分享,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Java%20%e4%b8%9a%e5%8a%a1%e5%bc%80%e5%8f%91%e5%b8%b8%e8%a7%81%e9%94%99%e8%af%af%20100%20%e4%be%8b/38%20%e5%8a%a0%e9%a4%908%ef%bc%9aJava%e7%a8%8b%e5%ba%8f%e4%bb%8e%e8%99%9a%e6%8b%9f%e6%9c%ba%e8%bf%81%e7%a7%bb%e5%88%b0Kubernetes%e7%9a%84%e4%b8%80%e4%ba%9b%e5%9d%91.md