不要随便把java应用放在容器container内

原创 2018-07-14 22:54 阅读(406)次


     JVM运行在docker下的问题

翻译外国 Jörg Schad 的文章

自从docker出现后,虚拟化容器化已经飞速发展。不过却很少有人知道jvm在docker下可能隐藏着不少问题。

JVM(不包括java 9,指的是jdk 9之前的版本)并没有完全去适应容器内部使用的隔离机制。这可能会导致很多不同环境下出现意外。为了避免这些意外,应该避免使用JVM的默认参数。

容器,container

很多人知道容器,但却不是所有人都对cgroups,或者namespaces很了解。

容器是一个很酷的工具,一定程度上实现了“write once,run anywhere”。这和Java的目标一致。我们看下去。

容器看起来像是轻量级的虚拟机(VM)。

1. 我们可以在容器上使用shell,或者SSH连接访问操作

2. 容器拥有自己的进程空间

3. 容器拥有自己的网络接口

4. 容器可以安装package

5. 可以运行servers

6. 可以打包成image


但从另外一方面看,他又不像VM。本质是他是一个隔离的linux进程组。这意味着在同一主机上运行的containers都是运行在同一个linux内核上的进程组。

使用docker启动两个容器,这是在linux上


$ docker run ubuntu sleep 1000 &
 
[1] 47048
 
$ docker run ubuntu sleep 1000000 &
 
[2] 47051
查看docker
$ 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
注意:如果在MAC上运行docker,此时是无法查看到这2个进程的。因为docker for MAC是运行在虚拟机上的,而不是直接在MAC上运行。

再查看一下进程细节

$ ps faux
 
…

可以看出两个sleep进程作为container进程的子进程运行着,只是linux内容和的进程组。

正是因为这样,容器和虚拟机相比,隔离效果较弱。容器的运行接近主机CPU、IO速度。并且能快速启动,约0.1秒。容器消耗的硬盘和内存都较少。

隔离

一个容器的内部view是什么样子。通过docker exec来查看一下。

$ 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


从容器的内部,只能看到一个sleep 的任务。可见容器之间是互相隔离的。

容器是如何做到这样的独立view呢?

这就是依靠cgroups和namespaces功能。所有的容器(Docker,Mesos Containerizer,rkt,yarn)都使用了这两者。 mesos甚至可以用到docker的image,但不需要依赖docker daemon进程。

namespaces主要用来提供不同的隔离view。因此每个容器都可以拥有自己的进程IDs,network IDs或 user IDs,因此不同的容器中都有进程ID为1的进程。

而实际上用来隔离资源访问的是cgroups。cgroups可以用来限制资源的访问(如最多使用2GB内存)或计算(跟踪某个进程组在最近一分钟消耗了多少个cpu周期)。

namespaces

namespace用于为以下资源提供视图。

pid(进程)

net(network interfaces,routing)

ipc(System V IPC

mnt(mount points,filesystems)

uts(hostname)

user(UIDs)

用之前的例子(运行了两个docker container),从主机的操作系统可以看到13347和13422作为sleep 进程的IDs。但从容器内部看起来则不同

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
如上, 在主机13422进程代表的容器中,sleep 1000000的PID为1。这是13422容器自己的view

Control groups

这里以cgroups v1来讨论主题。它就像树状来表示folder分层。如下:


每个子系统(内存,cpu)就表示成一个层级tree。每个进程也表示成每个层级上的一个node。每个层级开始于root。每个node等于一组进程,这组进程共享相同的资源。

cgroups可以为tree的每个node都设置限制。

例如内存子系统,每个group都有hard和soft limits。

soft limits只是用来监控或者判定一个应用最佳配置,不会被强制执行,往往只是在日志中触发警告记录。

hard limits则是会触发每个group的OOM killer。java程序员要特别注意这点。因为java程序员习惯用OutOfMemoryError的异常捕获后再来做出处理(try...catch),但如果用了hard limits则会触发killer将java程序杀死并不会发出警告。

如果在docker上对容器设置了128MB的hard limits,那这个container会在操作这个限制后被杀死。

限制如下:

docker run -it --rm -m 128m fedora bash

CPU 隔离

cpu隔离跟内存隔离又不一样,有两种选择,cpu shares和cpu sets。两者不同。

cpu shares是默认的cpu隔离方式,基于对每个进程在使用所有cores设置了cpu周期的优先级权重。默认权重是1024,如果启动一个docker容器,如下

docker run -it --rm -c 512 stress
这个容器将会使用cpu周期比默认进程少。但具体使用多少cpu周期,这个取决于在这个node上运行进程集的大小。

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%
如上,假设系统上只有cgroup A和B,则cgroups A使用了75%cpu份额,cgroup B获得剩余的25%。

如果我们删除cgroup A, 则B会获得100%的cpu周期使用。

CPU sets

cpu sets则是将进程限制在特定的cpu上。这主要是用于避免CPU之间processes bouncing。

如下设置docker,将会把容器限制在CPU 0,4,6上。

docker run -it -cpuset=0,4,6 stress

谈论完隔离,我们来谈论JAVA

Java由java语言,java规范,java runtime组成。主要讨论java runtime。

我们常会设置Java的heap space。但其实还有其他部分占用了内存。

如:

native JRE,Perm/metaspace,JIT bytecode,JNI,NIO,线程。

所以如果我们在设置docker容器内存限制的时候只考虑heap,那显然是不够的。

而且JVM很多参数默认是会根据cores数进行初始化的。

如:

JIT编译器的threads数量

GC的threads数量

fork join pool的threads数量

因此JVM在32核心的主机上运行,如果没有设置指定值,JVM会生成32个gc 线程,32个JIT编译器线程。

看起来没有问题,但如果JVM运行在容器中,问题就来了。

举例:当我们开发完成一个Java应用的时候,我们把他打包成docker image并在本地的笔记本上测试通过了。我们把容器运行在了生产服务器上,运行了10个container。而生产服务器往往是拥有很多cpu cores的高性能服务器,即使我们对每个docker 容器都设置了cpu sets。结果会发生什么?JVM看到了服务器上拥有64核的cpu,使用这个数值初始化了上面提到的线程数,那就是64*10的gc进程,64*10的jit编译器进程......由于线程过多,cpu都瞒着切换他们对cpu周期的占用,无法正常完成业务工作。

再看一下容器和虚拟机的不同

从上图表示,Java 7/8 是从sysconf中获取核心core的数量。他意味着每次新运行一个容器,容器看到的核心数量就是主机上所有的核心数。而虚拟机启动的时候则是获取虚拟系统中核心数。

默认的内存限制也是如此。JVM在容器上运行时,会看到的是主机上的内存总量,并用来设置JVM的默认值。

也就是说JVM忽略了容器用cgroup的限制。而namespaces并未包含cpu和内存。所以容器内部看到的就只能是主机上的整体内存。

Java 9 开始支持容器

在Java 9 提供了新的flags来让JVM支持cgroups.

-XX:+UseCGroupMemoryLimitForHeap
-XX:+UnlockExperimentalVMOptions

通过以上的设置, JVM的heap就会自动适配cgroups的limit。但JVM在heap外还有其他使用的内存,所以这个设置依然无法阻止OOM killer在容器内存使用过多的时候杀死进程,但由于heap被设置小了,gc触发更频繁了,也避免了OOM,这是一种进步。

cpu方面,JVM会检查到cpusets,上面提到的可能创建过多的threads也会根据容器被限制的cpu数量来初始化。

不过绝大多数用户还是用cpu shares作为默认的cpu隔离机制。Java 9在cpu shares隔离机制下,依然会识别到主机的cpu cores总数。这个要特别注意。

重点在于java已经意识到了这个问题,也做出了响应,所以建议用户手动覆盖默认参数,至少XMX等设置内存参数, XX:ParallelGCThreads, XX:ConcGCThreads for CPU)等。

据悉Java 10将支持cpu shares等,会有更多的改进。

本文完。

本站作品的版权皆为作品作者所有。

本站文字和内容为本站编辑或翻译,部分内容属本站原创,所以转载前务必通知本站并以超链接形式注明内容来自本站,否则以免带来不必要的麻烦。

本站内容欢迎分享,但拒绝有商业目的的转载!