容器技术提供了不同于传统虚拟机技术的资源隔离方式,容器的打包和启动速度得到提高,但是却降低了容器的隔离强度,这里就有一个资源视图的隔离问题,容器可以通过cgroup的方式对资源的使用情况进行限制,包括内存、cpu等,但是如果一些进程使用一些常用的监控命令,例如free top等命令,其实看到的还是物理机的数据,而非容器的数据,这是由于容器并没有做到对/proc,/sys等文件的资源视图隔离,我们知道容器中看到的/proc伪文件系统的信息是宿主的/proc,没有隔离/proc 意味着获取不到容器中进程相关的proc信息。另外,一些需要读取proc信息的应用,会获取到错误的数据。 常用的proc伪文件系统包括:/proc/meminfo,/proc/cpuinfo, /proc/stat, /proc/uptime, /proc/loadavg等,举一个Java开发者常遇到的问题,当我们在启动JVM时,通常需要给容器设置JVM的内存参数,才能正确使用容器内存。
解决资源视图隔离问题的必要性
- 从容器的视角来看,通常一些业务开发者已经习惯了传统的物理机、虚拟机,在这些强隔离的宿主机上可以使用top、free等命令查看系统的资源使用情况,但是在容器内无法做到这一点,“越界”查看到了宿主机的信息
- 从应用程序的角度,在容器内运行进程和在物理机运行进程,其实运行环境是不同的,并且可能存在一些安全隐患
2.1 例如java的JVM会使用free 查看内存,并尝试使用这个值用于设置JVM内存大小,这里很容易出现OOM
2.2 例如golang程序中,使用runtime.NumCPU获得CPU数量,并用这个值启动相应数量的进程
FUSE文件系统
FUSE(用户态文件系统)是一个实现在用户空间的文件系统框架,通过FUSE内核模块的支持,使用者只需要根据fuse提供的接口实现具体的文件操作就可以实现一个文件系统。在fuse出现以前,Linux中的文件系统都是完全实现在内核态,编写一个特定功能的文件系统,不管是代码编写还是调试都不太方便,就算是仅仅在现有传统文件系统上添加一个小小的功能,因为是在内核中实现仍需要做很大的工作量。在用户态文件系统FUSE出现后(2.6内核以后都支持fuse),就会大大的减少工作量,也会很方便的进行调试。编写FUSE文件系统时,只需要内核加载了fuse内核模块即可,不需要重新编译内核。
完整的fuse功能包括a)用户态customize文件系统b)用户态fuse库(libfuse)c)内核支持(fs/fuse/*),共3层结构协作完成。
以用户态内核态看FUSE
FUSE的工作原理如上图所示。假设基于FUSE的用户态文件系统hello挂载在/tmp/fuse目录下。当应用层程序要访问/tmp/fuse下的文件时,通过glibc中的函数进行系统调用,处理这些系统调用的VFS中的函数会调用FUSE在内核中的文件系统;内核中的FUSE文件系统将用户的请求,发送给用户态文件系统hello;用户态文件系统收到请求后,进行处理,将结果返回给内核中的FUSE文件系统;最后,内核中的FUSE文件系统将数据返回给用户态程序。
fuse举例
这里引用知乎上的一个文章,如果已经使用fuse开发了一套文件系统,例如叫zfuse,这里首先将zfuse挂载到了/mnt/fuse 目录,如果我们要创建/mnt/fuse/my.log文件,会发生什么呢?
- 此时 open系统调用进入kernel space,vfs层根据挂载点文件的操作函数对应到fuse_create_open,此时已经工作在内核态,但是在fuse_create_open内会创建一个包含FUSE_CREATE操作数的消息
- 将FUSE_CREATE的消息通过管道文件发送给管道另一端的用户态接收进程,即我们自定义的zfuse文件系统进行处理,处理这个消息的进程在ZFUSE挂载时由libfuse库代码中创建,作用是读取管道文件消息并根据消息的操作数来执行对应操作,在这里解析到的是FUSE_CREATE操作数,对应libfuse库中的fuse_lib_create函数。
- zfuse的作用,libfuse只是将操作接口与内核VFS做到一一对接,而真正完成操作的还是ZFUSE,在fuse_lib_create中会调用ZFUSE内定义的create函数
- libfuse接口的作用,解耦合,简化ZFUSE开发。假设ZFUSE不需要lseek操作,那么就不需要实现lseek操作,而我们无法控制用户的行为,这里用户是指ZFUSE文件系统的使用者。假设用户在使用ZFUSE时进行了lseek操作,那么会先由libfuse提供的fuse_lib_lseek接口处理,libfuse发现ZFUSE没有实现lseek,就直接返回不支持此操作。
具体的实现流程
- ZFUSE挂载到/mnt/fuse,libfuse会fork出后台进程,用于读取管道文件消息。
- 用户使用ZFUSE文件系统,创建文件my.log
- 调用系统调用
- 经VFS交由fuse处理
- fuse下的create处理,向管道发送带创建操作(FUSE_CREATE)的消息,当前进程A加入等待队列
- libfuse下创建的后台进程读取到消息,解析操作数为FUSE_CREATE,对应到fuse_lib_create,即low level层接口。
- fuse_lib_create中调用ZFUSE的上层接口zfuse.create,由ZFUSE来实现创建操作
- 完成创建后,通过libfuse中的fuse_reply_create向管道发送完成消息从而唤醒之前加入等待队列的进程A
- 进程A得到创建成功的消息,系统调用结束,/mnt/fuse/my.log文件创建成功
用libfuse创建FUSE文件系统
LXCFS
LXCFS是一个简单的用户文件系统,用于解决当前linux kernel的一些局限性,让容器更能感觉为是一个独立的系统,使用libfuse库基于C开发完成,主要提供两个方面
- 提供一系列可以绑定的文件,从而使cgroup能够感知
- 一个容器可以感知的cgroupfs-like tree
lxcfs 是一个开源的fuse文件系统,项目地址,用于让linux 容器更像虚拟机,让容器内的应用在读取内存和 CPU 信息的时候通过 lxcfs 的映射,转到自己的通过对 cgroup 中容器相关定义信息读取的虚拟数据上。将lxcfs的文件挂载到容器内后,容器内的进程在基于/proc 获取容器的CPU和内存时,即读取这些文件时,lxcfs文件系统会获取该容器的1号进程的宿主机ID,将这个读取操作转换为读取宿主机对应容器cgrroup配置文件的信息,从而实现获取正确的容器CPU和内存。
原来lxcfs是LXC容器的一个辅助项目,但是现在可以被任意的runtim使用,lxcfs通过用户态文件系统,在容器内提供以下文件
1 | /proc/cpuinfo |
如下图所示,把宿主机的 /var/lib/lxcfs/proc/memoinfo 文件挂载到Docker容器的/proc/meminfo位置后。容器中进程读取相应文件内容时,LXCFS的FUSE实现会从容器对应的Cgroup文件中读取正确的内存限制。从而使得应用获得正确的资源约束设定。
详细讲解lxcfs,源码分析
LXCFS for Kubernetes
1 | yum install fuse fuse-lib fuse-devel |
测试效果如下所示
Pod的limit参数配置了8个CPU,24G 内存
在容器内只能查看到25G内存
在容器内只能查看到6个CPU
这种交由k8s进行管理,估计会有些风险,将这个服务交由systemd 进行管理,运行在宿主机上,可能更安全一些。
lxcfs服务重启
lxcfs服务重启,会导致原正常使用的容器无法查看cpu、内存,这是因为 lxcfs重启后,/var/lib/lxcfs会删除再重建,inode变了,会出现如下错误
当lxcfs服务重启后,需要对容器挂载的响应目录进行重新挂载,lxcfs issue 列表中对这个问题进行了讨论,issue 193github上有人整理的remount的脚本代码
1 | ! /bin/bash |
但是仍然存在一个问题,如何感知到lxcfs什么时候重启,因为只有知道了lxcfs进行了重启,我们才能对容器进行remount操作,这里有提供一个(方案)[https://github.com/alibaba/pouch/issues/140]
- 将以下卷挂载到容器,除了挂载proc相关的卷,这里增加了/var/lib/lxc/:/var/lib/lxc/:shared
1 | -v /var/lib/lxc/:/var/lib/lxc/:shared \ |
如果是k8s pod 时,建议以以下的方式进行挂载
- 使用systemd 管理lxcfs服务,在service 文件中增加重启或者服务恢复时的重新remount操作
1 | 首先下载lxcfs的源码,编译安装 |
- 当lxcfs服务重启时,会调用/usr/local/bin/container_remount_lxcfs.sh 脚本对全部容器进行重新remount