问题介绍
前段时间项目中碰到一个比较棘手的问题,同事在k8s平台上使用GPU做选练,训练数据存放在ceph对象存储中,为了方便我们使用了s3fs把对象存储挂载到容器文件系统中,这样训练代码就可以像访问本地文件系统一样操作了。但是问题是训练有时可以正常进行,重起pod后训练有时会失败,报GPU访问错误。通过这篇文章分享下问题的解决思路,希望对其他朋友有帮助。
原因分析
简单分析发现创建k8s任务时只申请了一个GPU,但从tensorflow错误日志看到应用启动时检测到了4个GPU,进一步分析原因是因为挂载s3fs时设置了privileged=true权限,导致容器中可以访问整个宿主机上的GPU,如果这些GPU都空闲着,训练不会出错,但是如果这中间某个GPU已经被k8s分配给了其他的pod,就会导致一些冲突。
这个问题有一些临时的变通方法:
- 把对象存储里的东西挪到cephfs里,但是数据量太大,而且失去了存储层面进行用户隔离的功能。
- 容器里直接调用S3 api访问对象,但这样对用户不友好,没有直接访问文件方便
有没有更好的办法?
解决方案
fuse简单介绍
fuse通过在user space运行一个daemon代理通过/dev/fuse这个虚拟设备和kernel打交道,从而使多种用户态文件系统在不改变kernel的前提下成为可能,比如sshfs, 或者本文中碰到的s3fs, /dev/fuse是在加载fuse内核模块后自动生成的。
容器中使用fuse的正确方法
如果要在容器中挂在sshfs或者s3fs这样的文件系统,必须能访问主机的/dev/fuse设备,而且要能使用mount所涉及到的syscall。
一个比较简单的做法就是给容器privileged权限,但这样的做法有很多弊端:
- 违背了容器的安全隔离性原则,相当于容器可以访问主机内核的所有功能,容器中运行的恶意代码可能导致整个主机出问题
- 容器可以看到主机上的所有设备,比如本文所涉及的GPU导致tensorflow这样的计算框架出错
docker借助于内核内置的安全方案可以做到更细粒度的控制,比如apparmor, selinux, seccomp, linux capability 具体要看主机系统是哪个linux发行版,以及kernel配置中是否启用了对应的功能(grep -i armor /boot/config-'uname -r'), 具体可以查看相应的功能介绍,这里不详细展开。 通过这些kernel自带的功能可以做到非privileged访问/dev/fuse设备:
- ubuntu运行如下命令
docker run -it --rm --device /dev/fuse --security-opt apparmor:unconfined --cap-add SYS_ADMIN image-registry:5000/ubuntu:16.04-sshfs /bin/bash
通过aa-status
可以看到系统中定义的profile,以及profile是enforce还是complain模式, profile中定义可以使用哪些功能,比如网络访问,capability,mount,对文件的读写访问权限。
docker daemon启动时会创建缺省的docker-default profile,如果不特别指定会使用这个profile,使用apparmor:unconfined
表示禁用该限制功能。
- RHEL, CentOS运行如下命令
docker run -it --rm --device /dev/fuse --security-opt seccomp:unconfined --cap-add SYS_ADMIN image-registry:5000/ubuntu:16.04-sshfs /bin/bash
注意rhel上可能还会用到selinux做一些类似的设置。--device /dev/fuse
就是说把host上的/dev/fuse设备挂载到容器中,--cap-add SYS_ADMIN
允许容器中运行的进程执行系统管理任务,如挂载/卸载文件系统,设置磁盘配额,开/关交换设备和文件等。
在k8s容器中使用fuse设备
通过前面的介绍我们大致已经能想到问题的解决思路:
- 把/dev/fuse设备注入到容器
- 给容器分配尽可能少的权限,比如可以执行mount命令挂载文件系统,避免使用privileged访问到所有的GPU
但是因为k8s做了一层转化,事情没有想象的直接,下面分别作介绍
/dev/fuse注入到容器
docker提供了--device
选项做到这个功能,但是查遍了k8s的文档,在网上也搜了一圈没有发现,这里还要提一下我们之前的一个错误做法:
volumeMounts:
- mountPath: /dev/fuse
name: fuse
...
volumes:
- hostPath:
path: /dev/fuse
type: File | CharDevice
name: fuse
其实这样是不生效的,真正在后台起作用的是设置的privileged.
后来联想到GPU是如何借助device-plugin功能不需要超级权限挂载到容器中的k8s中的,虽然GPU是通过设置环境变量NVIDIA_VISIBLE_DEVICES
,然后nvidia-docker做了些特殊处理,和我们的情况不完全一样,但是从k8s代码看接口中确实提供了另一个选项可以直接设置pluginapi.DeviceRuntimeSpec
结构的Device
字段,抱着试试的想法,大功告成。
具体做法就是开发一个device plugin并以daemonset的形式部署在每个节点上,plugin会向kubelet注册所提供的资源类型,这里的资源定义为github.com/fuse
实现代码已上传到https://github.com/JasonChenY/fuse-device-plugin
在容器中以普通权限作挂载文件系统
ubuntu
从k8s官方文档可以看到通过注解annotations: container.apparmor.security.beta.kubernetes.io
可以控制使用哪个apparmor profile, 但是模仿docker尝试设置unconfined时报invalid,阅读K8s代码发现只支持localhost/xxx
和runtime/default
这两种语法,不支持unconfined
。其实runtime/default在启用apparmor时就对应docker的docker-default, docker-default这个profile的设置中有一条是不允许在容器中进行mount操作, 知道这个后问题就迎刃而解了,在docker-default的基础上创建一个新的profile
cat /etc/apparmor.d/docker | sed 's/deny mount/# deny mount/' | sed 's/profile docker-default/profile docker-fuse-2/' > /etc/apparmor.d/docker-fuse
apparmor_parser -a /etc/apparmor.d/docker-fuse
然后创建k8s对象时就设置
container.apparmor.security.beta.kubernetes.io/: localhost/docker-fuse
结合前面已经挂载的fuse设备就可以挂载文件系统了
mkdir /mnt/test
sshfs -o allow_other [email protected]:/root /mnt/test
RHEL, CentOS
SeLinux基于用户-角色-类型
这个模型来运作的,类型系统是它的核心,可以做到非常细粒度的安全控制,f但是配置偏复杂,弄得不好会把IT自己搞死,所以一般都处于关闭状态,除非核心的关键服务器,这里不展开。
seccomp通过配置文件来限定可以使用哪些系统调用,docker也有个默认的docker/default,禁用了300多个系统调用里的40多个,确保容器里运行的进程不会对宿主系统产生安全威胁。
首先可以确认系统是否支持该功能
$ grep CONFIG_SECCOMP= /boot/config-$(uname -r)
CONFIG_SECCOMP=y
如果不支持,容器就可以随心所欲了?记住文章开始提到的capability,命名空间的隔离, docker缺省限制很多capability, 比如使用raw socket, 加载内核模块,重起机器等等, 处处把关 。
如果支持,那还要看k8s的行为,到目前为止(1.12版本),seccomp还处于alpha状态,缺省是用
unconfined
配置, 也就是说不对k8s容器启用任何seccomp配置,等同于上面不支持的效果。如果想通过seccomp做一些定制的细粒度控制,那要启用PodSecurityPolicy admissionController并设置
seccomp.security.alpha.kubernetes.io/allowedProfileNames
, 官方文档里说支持docker/default, unconfined, localhost/, 或者*这些格式。 其中path是kubelet的启动参数seccomp-profile-root指定的目录下的文件名,在对应的文件里设置想限制容器做哪些系统调用。
最后在yaml文件里设置对应的profile
container.seccomp.security.beta.kubernetes.io/: localhost/
我们的平台属于第二种情况用unconfined
, seccomp没生效,所以直接使用fuse-device-plugin
就成功了,也没有对第三点作实际验证,若有错误勿拍。