包扫描放到子容器中没有事务
这篇文章改编自Codemotion 2017上的一个会议。
Tl; dr : Java虚拟机(甚至在Java 9发行版中也没有)不完全了解容器在内部使用的隔离机制,这可能导致不同环境之间发生意外行为(例如,测试与生产)。 为避免此行为,应考虑覆盖一些默认参数(通常由节点上可用的内存和处理器设置)以匹配容器限制。
首先,我必须承认我主要使用C ++和Golang进行开发。 那么,为什么我要在容器中运行基于JVM的应用程序呢? 原因是我在Apache Mesos和DC / OS上工作 ,这两个平台都使用户能够运行其容器化的应用程序(或大量数据服务,例如Apache Spark,Flink,Kafka等 ,它们也正在利用容器)。
因此,遇到问题时,他们会向像我这样的人寻求帮助。 这就是为什么我关心这个话题。 另外,我有两位出色的同事Ken Sipe和Johannes Unterstein ,他们总是愿意帮助我,帮助他们获得无穷的Java知识。
本文的结构如下:
我们将首先尝试了解什么是容器,以及如何从Linux内核功能构建容器。 然后,我们将回顾有关JVM如何处理内存和CPU的一些细节。 最后,我们将把这两个部分放在一起,看看JVM如何在容器化环境中运行以及出现了哪些挑战。 当然,我们还将讨论如何应对这些挑战。 前两个部分主要是为最后一部分提供先决条件,因此,如果您已经对容器或JVM内部有很深的了解,请随时跳过/略过相应的部分。
货柜
虽然许多人都对容器有所了解,但我们当中有多少人对底层概念(如C组和名称空间)了解得这么多? 我想对这些主题进行一些介绍。 这些是了解在容器中运行Java的挑战的基础。
容器是一种非常酷的工具,可以打包应用程序,并且在一定程度上基本上可以编写一次,可以在任何地方运行 。 无论如何,这就是容器的承诺。 这个诺言在多大程度上成立? 我们许多使用Java的人以前都曾听到过这样的承诺:Java声称您可以编写应用程序并在任何地方运行它。 可以在Docker容器中结合这两个承诺吗? 我们拭目以待。
从高层次看,容器看起来像是轻量级的虚拟机。
- 我可以在上面安装外壳(通过SSH或其他方式)
- 像虚拟机一样“感觉”
- 自己的过程空间
- 自己的网络接口
- 可以安装软件包
- 可以运行服务器
- 可以打包成图片
另一方面,它们根本不像虚拟机。
他们基本上只是孤立的linux进程组,甚至可以说容器只是一个虚构的概念 。 这意味着在同一主机上运行的所有“容器”都是在主机的同一Linux内核上运行的进程组。
让我们更详细地了解这意味着什么,并使用docker启动两个容器:
$ docker run ubuntu sleep 1000 &
[1] 47048
$ docker run ubuntu sleep 1000000 &
[2] 47051
让我们快速检查两者是否都在运行:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f5b9bb7523b1 ubuntu "sleep 1000000" 10 seconds ago Up 8 seconds lucid_heisenberg
b8869675eb5d ubuntu "sleep 1000" 14 seconds ago Up 13 seconds agitated_brattain
太好了,两个容器都在运行。 那么,如果我们查看主机上的流程,会发生什么呢? 只是警告,如果您使用Docker for Mac尝试此操作,您将看不到“ sleep”进程,因为Mac上的Docker会在虚拟机中运行实际的容器 (因此,容器所在的主机不在您的Mac,但虚拟机)。
$ ps faux
…
因此,在这里我们可以看到两个“睡眠”进程都作为cointainerd进程的子进程运行。 由于“只是”成为Linux内核上的进程组:
- 与虚拟机相比,容器的隔离性也较弱
- 容器可以以接近自然的速度运行CPU / IO
- 大约0.1秒内启动容器(libcontainer)
- 容器具有更少的存储和内存开销
隔离
如我们先前所见,核心容器是运行共享内核的标准Linux进程。
但是从那些容器之一的内部看是什么? 让我们通过使用docker exec
在其中一个容器内启动交互式shell进行调查,然后查看可见的进程:
$ docker exec -it f5b9bb7523b1 bin/bash
root@5e1cb2fd8fcb:/# ps faux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 7 0.3 0.0 18212 3288 ? Ss 21:38 0:00 bin/bash
root 17 0.0 0.0 34428 2944 ? R+ 21:38 0:00 \_ ps faux
root 1 0.0 0.0 4384 664 ? Ss 21:23 0:00 sleep 1000000
从容器内部,我们只能看到一个睡眠任务,因为它与其他容器是隔离的。
那么,容器如何设法提供这样的隔离视图?
有两种Linux内核功能可供使用 : cgroups和namespaces 。 所有容器技术(例如Docker,Mesos Containerizer或rkt)都充分利用了这些功能。 实际上,有趣的是,Mesos从早期开始就有自己的容器化器。 它还取决于同一部分,因此我们在内部也使用cgroup和名称空间。 结果,Mesos甚至可以利用docker映像,而不必依赖docker守护程序。
命名空间基本上用于在系统上提供不同的隔离视图。 因此,每个容器可以在不同的名称空间(例如进程ID,网络ID或用户ID)上看到自己的视图。 它对流程的作用相同。 因此,例如,在不同的容器中,进程ID为1。
尽管名称空间提供了隔离的视图,但控制组(简称cgroup)实际上用于隔离对资源的访问。 因此,cgroup可以用于限制访问(例如,一个进程组最多只能使用2GB的内存)或用于计费(例如,跟踪某个进程组在最后一分钟消耗了多少cpu周期)。 稍后我们将更详细地介绍这一点。
命名空间
如前所述,每个容器都有其自己的系统视图,名称空间用于为以下资源(以及其他资源)提供这些视图:
- pid(进程)
- 网络(网络接口,路由…)
- ipc(系统V IPC)
- mnt(挂载点,文件系统)
- uts(主机名)
- 用户(UID)
考虑例如进程ID名称空间和我们之前运行两个docker容器的示例。 从主机操作系统,我们可以将13347和13422视为睡眠进程的进程ID:
但是从容器内部看,它略有不同:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 7 0.3 0.0 18212 3288 ? Ss 21:38 0:00 bin/bash
root 17 0.0 0.0 34428 2944 ? R+ 21:38 0:00 \_ ps faux
root 1 0.0 0.0 4384 664 ? Ss 21:23 0:00 sleep 1000000
因此,在容器内部, sleep 1000000
进程的进程ID为1(与主机上的13422相反)。 这是因为容器在其自己的名称空间中运行,因此对进程ID具有自己的视图。
对照组
我们在这里更深入地进入了对照组。 (确切地说,我们在这里讨论的是cgroup v1,还有v2也存在实质性差异。)如前所述,控制组既可以用于限制访问权限,也可以用于计费目的。 与Linux或Unix中的所有内容一样,它就像一个可以查看树的分层文件夹。 这种结构如下所示:
- 每个子系统(内存,CPU等)都有一个层次结构(树)
- 每个进程在每个层次结构中恰好属于一个节点
- 每个层次结构均以1个节点(根)开头
- 每个节点=进程组(共享相同的资源)
有趣的是,可以为该树中的每个节点设置限制。 例如考虑内存子系统:
- 每个组可以有硬性限制和软性限制
- 不强制执行软限制(即,仅在日志中触发警告),这可能或可能对例如监视或尝试确定某个应用程序的最佳内存限制有用。
- 硬限制将触发每个组OOM杀手。 这通常需要Java开发人员改变思维方式,因为他们习惯了一个OutOfMemoryError,他们可以做出相应的React。 但是,如果容器的存储空间有限,则整个容器将被杀死而不会发出警告。
使用docker时,我们可以为容器设置128MB的硬限制,如下所示:
docker run -it --rm -m 128m fedora bash
CPU隔离
看完内存隔离之后,接下来让我们考虑CPU隔离。 在这里,我们有两个主要选择:CPU份额和CPU集。 它们之间有差异,这很重要。
CPU份额
CPU份额是默认的CPU隔离,基本上在所有内核的所有cpu周期中提供优先级加权。
任何进程的默认权重为1024,因此,如果按照docker run -it --rm -c 512 stress
启动容器,它将比默认进程/容器接收更少的CPU周期。
但是到底有多少个周期? 这取决于在该节点上运行的整个进程集。 让我们考虑两个cgroup A和B。
sudo cgcreate -g cpu:A
sudo cgcreate -g cpu:B
cgroup A: sudo cgset -r cpu.shares=768 A 75%
cgroup B: sudo cgset -r cpu.shares=256 B 25%
Cgroups A的CPU份额为768,其他的256。这意味着CPU份额假定如果系统上没有其他任何运行,则cgroup A将获得75%的CPU份额,而cgroup B将获得剩余的25。 %。
如果删除cgroup a,则cgroup b最终将获得100%的CPU份额。
请注意,您也可以使用CFS隔离来获得更严格,不太乐观的隔离保证,但是有关更多详细信息,请参考此博客文章 。
CPU组
CPU集略有不同。 它们允许将容器限制为特定的CPU。 这主要用于避免进程在CPU之间跳动,但也与NUMA系统有关,在NUMA系统中,不同的CPU可以快速访问不同的内存区域(因此,您希望您的容器仅使用可快速访问同一内存区域的CPU)。
我们可以将cpu集与docker一起使用,如下所示:
docker run -it -cpuset=0,4,6 stress
这意味着,我们会将容器固定到CPU 0、4和6。
让我们谈谈Java
接下来,让我们回顾一下Java的一些细节。
首先,Java由几个部分组成。 它是Java语言,Java规范和Java运行时。 在这里,我们主要讨论Java运行时,因为它实际上运行在我们的容器中。
JVM的内存占用量
那么,是什么导致了JVM内存占用呢? 我们大多数运行Java应用程序的人都知道如何设置最大堆空间。 但是实际上,还有更多的内存占用空间:
- 本机JRE
- 烫发/元空间
- JIT字节码
- 杰尼
- 蔚来
- 线程数
当我们想使用Docker容器设置内存限制时,需要牢记很多。 并将容器内存限制设置为最大堆空间,可能还不够……
JVM和CPU
让我们简单看一下JVM如何根据运行在其上的节点上的可用处理器/核数进行调整。 实际上,实际上有许多参数是根据核心数初始化的。
- JIT编译器线程数
- #垃圾回收线程
- 普通fork-join池中的线程数
- …
因此,如果JVM在32个核心节点上运行(并且没有覆盖默认节点),则JVM将产生32个垃圾收集线程,32个JIT编译器线程……。
接下来,让我们看看它如何与容器一起工作。
JVM遇到容器
最后,我们拥有所有可用的工具,并准备将所有工具整合在一起!
至此,我们已经完成了基于JVM的应用程序的开发,现在将其打包到docker映像中,并在笔记本上进行本地测试。 一切工作正常,因此我们将该容器的10个实例部署到我们的生产集群上。 突然之间,应用程序开始节流并且无法达到我们在测试系统上看到的相同性能。 我们的测试系统甚至是具有64核的高性能系统。
发生了什么事? 为了允许多个容器并行运行,我们已将其限制为一个cpu(或CPU份额的等效比率)。 不幸的是,JVM将看到该节点(64)上的内核总数,并使用该值初始化我们之前看到的默认线程数。 在开始的10个实例中,我们最终得到:
10 * 64个Jit编译器线程
10 * 64个垃圾回收线程
10 * 64…。
而且我们的应用程序可以使用的cpu周期数受到限制,它主要处理不同线程之间的切换,并且无法完成任何实际工作。
突然出现了容器的承诺,“一次打包,可以在任何地方运行”似乎违反了……
只是换个角度来看,让我们再一次将容器与虚拟机进行比较,然后在每种情况下JVM从以下位置收集其信息(即,#个核心,内存等):
在JDK 7/8中,它从sysconf
获取核心计数资源。 这意味着,无论何时在容器中运行它,我都将获得系统上可用的处理器总数,或者对于虚拟机:虚拟系统。
默认内存限制也是如此:JVM将查看主机的整体内存,并将其用于设置其默认值。
因此,我们可以说JVM忽略了cgroups并导致了如上所述的问题。
如果您已经关注了,您可能会奇怪,为什么这里没有使用名称空间。 毕竟我们说过,它们创建资源的容器特定视图。 不幸的是,没有CPU或内存的名称空间(名称空间通常也有稍微不同的目标),因此从容器内部简单地less /proc/meminfo
仍会向您显示主机上的整体内存。
但是,Java 9支持容器!
使用(Open)JDK 9,改变了 。 因此,Java现在支持docker cpu和内存限制。 让我们看看“支持”的实际含义。
记忆
如果指定了以下标志,JVM现在将考虑cgroups内存限制:
- -XX:+ UseCGroupMemoryLimitForHeap
- -XX:+ UnlockExperimentalVMOptions
在这种情况下,最大堆空间将自动(如果未覆盖)设置为cgroup指定的限制。 如前所述,JVM除了使用堆以外还使用内存,因此这不会阻止OOM杀手的用户删除其容器。 但是,尤其是考虑到垃圾收集器将随着堆的填充而变得更具攻击性,这已经是一个很大的改进。
中央处理器
使用OpenJDK 9,JVM将自动检测cpuset,如果设置了cpuset,则使用指定的CPU数量来初始化前面讨论的默认值。
不幸的是,大多数用户(尤其是容器协调器,例如DC / OS )使用CPU共享作为默认的CPU隔离。 使用CPU份额,您仍将获得不正确的默认参数值。
那我该怎么办?
最重要的可能是简单地了解问题,然后确定是否对您的环境有问题。
如果没有,那就太好了。 如果有问题,则应考虑手动覆盖默认参数
(例如,至少XMX用于内存,XX:ParallelGCThreads,XX:ConcGCThreads用于CPU),具体取决于您的cgroup限制。
OpenJDK 10还将大大改善对容器的支持 :例如,它包括对CPU共享的支持。 有关更多详细信息,请查看此增强功能 。
在DC / OS和Mesosphere中找到更多信息。
翻译自: https://jaxenter.com/nobody-puts-java-container-139373.html
包扫描放到子容器中没有事务