java与容器的小火花

2022-08-28  本文已影响0人  xpbob

这里的容器是一个代称包含cgroup,docker等。

java在容器环境的问题

一个在物理机上跑的很好的java程序,如果用的jdk8u131之前的版本,加上容器限制后,可能会出现奇奇怪怪的问题,有的会启动失败,有的线程数远远超过限制的核数,cpu飙高。究其原因,本质是jvm的默认配置所致。

  1. 与cpu相关的默认配置。如gc线程数,jit线程数,forkjoin并发度等。
  2. 与内存相关的默认配置。如heap,direct memory等。

下面主要围绕cpu,内存展开。

java环境现状

jdk 8已经发布8u333。已经可以做到物理机跑的java程序,加上容器限制后,相当于跑在了对应资源的物理机上的效果。主要起作用的参数是

-XX:+UseContainerSupport

这个参数默认是打开的。这里就不介绍一些过渡的一些容器参数了。下面我们来解析一下这个参数是如何起到作用的。

UseContainerSupport的实现

识别容器限制

 optResult = determineType("/proc/self/mountinfo", "/proc/cgroups", "/proc/self/cgroup");

代码主要围绕着3个文件来进行读取。就是上面方法中的3个参数。我们后面根据代码来看看这3个文件的作用。
了解cgroup的同学,了解资源的限制是写在了文件路径下的。所以代码的核心的就是去找到文件路径。填充如下的class数据数据.

public class CgroupInfo {

    private final String name;
    private final int hierarchyId;
    private final boolean enabled;
    private String mountPoint;
    private String mountRoot;
    private String cgroupPath;
    
 }

第一步从 /proc/cgroups从获取子系统的信息。
jdk默认识别的子系统有6种,他们是作为常量写在java文件中的。

    private static final String CPU_CTRL = "cpu";
    private static final String CPUACCT_CTRL = "cpuacct";
    private static final String CPUSET_CTRL = "cpuset";
    private static final String BLKIO_CTRL = "blkio";
    private static final String MEMORY_CTRL = "memory";
    private static final String PIDS_CTRL = "pids";

第二步是从/proc/self/mountinfo获取mountpoint,mountroot。
这里jdk直接匹配了正则。

    private static final Pattern MOUNTINFO_PATTERN = Pattern.compile(
        "^[^\\s]+\\s+[^\\s]+\\s+[^\\s]+\\s+" + // (1), (2), (3)
        "([^\\s]+)\\s+([^\\s]+)\\s+" +         // (4), (5)     - group 1, 2: root, mount point
        "[^-]+-\\s+" +                         // (6), (7), (8)
        "([^\\s]+)\\s+" +                      // (9)          - group 3: filesystem type
        ".*$");                                // (10), (11)

这里的信息,我们可以通过自己cat或者注释来看。

    /*
     * From https://www.kernel.org/doc/Documentation/filesystems/proc.txt
     *
     *  36 35 98:0 /mnt1 /mnt2 rw,noatime master:1 - ext3 /dev/root rw,errors=continue
     *  (1)(2)(3)   (4)   (5)      (6)      (7)   (8) (9)   (10)         (11)
     *
     *  (1) mount ID:  unique identifier of the mount (may be reused after umount)
     *  (2) parent ID:  ID of parent (or of self for the top of the mount tree)
     *  (3) major:minor:  value of st_dev for files on filesystem
     *  (4) root:  root of the mount within the filesystem
     *  (5) mount point:  mount point relative to the process's root
     *  (6) mount options:  per mount options
     *  (7) optional fields:  zero or more fields of the form "tag[:value]"
     *  (8) separator:  marks the end of the optional fields
     *  (9) filesystem type:  name of filesystem of the form "type[.subtype]"
     *  (10) mount source:  filesystem specific information or "none"
     *  (11) super options:  per super block options
     */

在获取到mountpoint,mountroot之后,我们还差最后一个cgroupPath。
这个数据就是从/proc/self/cgroup中获取。
/proc/self/cgroup的数据如下


  /*
     * Sets the path to the cgroup controller for cgroups v1 based on a line
     * in /proc/self/cgroup file (represented as the 'tokens' array).
     *
     * Note that multiple controllers might be joined at a single path.
     *
     * Example:
     *
     * 7:cpu,cpuacct:/system.slice/docker-74ad896fb40bbefe0f181069e4417505fffa19052098f27edf7133f31423bc0b.scope
     *
     * => tokens = [ "7", "cpu,cpuacct", "/system.slice/docker-74ad896fb40bbefe0f181069e4417505fffa19052098f27edf7133f31423bc0b.scope" ]
     */

通过切割字符串,我们获取到了cgroupPath。
通过以上的3个操作,我们最终构造出了完整的cgroup路径,后面的部分就是根据路径读取配置的文件名即可。

cpu配置相关影响

目前cpu的设置,主要是3种使用方式,第一种是设置quota,第二种是设置share比率,第三种是设置cpu具体的核。以下就围绕着三种情况进行展开。

quota模式

  if (quota > -1 && period > 0) {
    quota_count = ceilf((float)quota / (float)period);
    log_trace(os, container)("CPU Quota count based on quota/period: %d", quota_count);
  }

在设置了quota和period。jdk就用quota/period作为cpu的核数。这里的处理是虚拟的核数。因为period的周期是可以调整的,是否真的用到了对应倍数的物理机核,这里其实是不确定的。

share模型

在share模式下,只是设置了竞争cpu的比率。竞争强跑满了,share才有限制,如果都跑不满,限制是不存在的。在这种情况下,就需要用户做一些自己系统的预判。第一个设置是UseContainerCpuShares,开启这个参数后,就会读取cpu.shares的配置值,然后和1024(默认值做倍数关系)

  if (share > -1) {
    share_count = ceilf((float)share / (float)PER_CPU_SHARES);
    log_trace(os, container)("CPU Share count based on shares: %d", share_count);
  }

这种就是模拟大家都跑满的场景。并且希望设置核数比率的时候,是按照默认值的倍数来进行设置。否者jdk拿到的核数是不对的。这里只是jdk自己的限制,cgroup并没有这么要求。
如果不开启UseContainerCpuShares,share就会用系统的核数。这里模拟的就是程序竞争不激烈的情况。

 cpu_count = limit_count = os::Linux::active_processor_count();

直接限制物理核

通过cgroup设置cpuset.cpus,可以固定使用具体某几个核。这种设置在系统函数中就会返回被限制后的。

 cpu_count = limit_count = os::Linux::active_processor_count();

混合模式

以上三种模式,是可以混合在一起设置的。我们来继续看看,当出现混合设置的时候,如何去做cpu个数的计算。
先处理share和quota
这里的share是开启了UseContainerCpuShares的情况,没开启的时候拿到的核数和设置了cpuset.cpus是同一个逻辑。同时引入了一个新的参数PreferContainerQuotaForCPUCount,这个参数开启了就会直接使用quota值。否则会根据share和quota谁的比较小来做判断。

   if (PreferContainerQuotaForCPUCount) {
      limit_count = quota_count;
    } else {
      limit_count = MIN2(quota_count, share_count);
    }

最后需要和cpuset.cpus的结果在做对比。这里注意的一点是,share不开启UseContainerCpuShares之后获取物理核的方法和cpuset.cpus是同一个,所以接下来的对比是包含了2中情况的,但是代码是同一处。
第一种情况是没有cpuset.cpus,那么对比的就是和物理机的核数。
第二种情况是有cpuset.cpus,对比是就是cpuset.cpus的值,并且cpuset.cpus的值一定比物理机核数小,同时也就保证了怎么都不会超过物理真实核数。

result = MIN2(cpu_count, limit_count);

依旧最终选择最小。这里也就发现了一个点,最大值不能超过物理机核数,现在看第一个quota模式,如果period只有正常cpu的一半,然后想用光所有cpu,就必须把quota扩大一倍。其实这样的比率设置和跑满没有区别。这里建议peroid的设置还是和cpu的时钟保值一致最好。这样的比率就接近于物理机的真是核数了。

内存配置相关影响

设置了cgroup主要影响heap的默认值。direct memory默认是xmx-s0的大小,他是被间接影响的。

void Arguments::set_heap_size() {
  julong phys_mem;
…………

  if (override_coop_limit) {
    if (FLAG_IS_DEFAULT(MaxRAM)) {
      phys_mem = os::physical_memory();
      FLAG_SET_ERGO(MaxRAM, (uint64_t)phys_mem);
    } else {
      phys_mem = (julong)MaxRAM;
    }
  } else {
    phys_mem = FLAG_IS_DEFAULT(MaxRAM) ? MIN2(os::physical_memory(), (julong)MaxRAM)
                                       : (julong)MaxRAM;
  }

…………
    julong reasonable_max = (julong)((phys_mem * MaxRAMPercentage) / 100);
    const julong reasonable_min = (julong)((phys_mem * MinRAMPercentage) / 100);
    if (reasonable_min < MaxHeapSize) {
      // Small physical memory, so use a minimum fraction of it for the heap
      reasonable_max = reasonable_min;
    } else {
      // Not-small physical memory, so require a heap at least
      // as large as MaxHeapSize
      reasonable_max = MAX2(reasonable_max, (julong)MaxHeapSize);
    }
…………

从上面的代码,我们可以看出xmx默认是按照phys_mem的比率来的,因为MaxHeapSize默认64m,所以我们接触到的场景的批示else分支的reasonable_max,MaxRAMPercentage根据其他默认值计算是25.最终是phys_mem的四分之一。

phys_mem的获取从os::physical_memory()来

julong os::physical_memory() {
  jlong phys_mem = 0;
  if (OSContainer::is_containerized()) {
    jlong mem_limit;
    if ((mem_limit = OSContainer::memory_limit_in_bytes()) > 0) {
      log_trace(os)("total container memory: " JLONG_FORMAT, mem_limit);
      return mem_limit;
    }
    log_debug(os, container)("container memory limit %s: " JLONG_FORMAT ", using host value",
                            mem_limit == OSCONTAINER_ERROR ? "failed" : "unlimited", mem_limit);
  }

  phys_mem = Linux::physical_memory();
  log_trace(os)("total system memory: " JLONG_FORMAT, phys_mem);
  return phys_mem;
}

如果是容器的环境下,返回的是memory_limit_in_bytes的设置。

现有的不足

容器问题并没有完全解决,目前还有如下已知问题。

  1. jmx getProcessCpuTime指标在使用share mode的时候,运行一段时间会返回-1。
    目前这个bug已经在19,17,11上修复。但是jdk8的还没修复。pr还没有被review
    https://github.com/openjdk/jdk8u-dev/pull/105
  2. 当在容器环境下,jvm启动不检测xmx是否有效。jvm进程设置了xmx,但是内存默认是懒加载,所以不会里面占用那么多,但容器给不了那么多的资源,最后会在运行时被kill掉。如果改成提前加载的模式 -XX:+AlwaysPreTouch,在启动时就知道heap的内存是否足够。相关讨论在https://github.com/openjdk/jdk/pull/8256
  3. 目前没有容器配置的jmx指标暴露。目前还在社区讨论https://github.com/openjdk/jdk/pull/9372。不过能合并进去的概率不大,建议使用第三方的https://github.com/xpbob/containerJmx。支持jdk11+.
上一篇下一篇

猜你喜欢

热点阅读