HPC环境下使用Docker

docker
HPC应用总类繁多,各种软件可能运行在不同的系统平台之上,容器技术正是解决这类问题的绝佳手段。我在Singularity——HPC环境的绝佳容器解决方案一文中介绍了Singularity这个HPC下非常适合的容器技术。但是Singularity也有一些不尽人意的地方,例如缺少虚拟化网络、热度不如Docker高等。那如果想在一个高性能集群中使用Docker又会遇到什么问题呢?有没有什么好的解决方案?

需要解决的问题

非root用户运行Docker

在许多系统中你尝试以普通用户身份运行Docker指令,通常会得到如下提示:

1
Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get http://%2Fvar%2Frun%2Fdocker.sock/v1.37/containers/json: dial unix /var/run/docker.sock: connect: permission denied

Docker默认不支持普通用户直接运行Docker命令,这里有一篇不错的文章说明了为什么我们不允许非root用户直接运行Docker命令:为什么我们不允许非root用户在CentOS、Fedora和RHEL上直接运行Docker命令,文章中提到了两个解决方案:

  • 将非root用户加入docker用户组
  • 设置sudo规则

但是这样会给管理员带来一些额外的工作量,不方便与类似Slurm之类的软件配合使用,而且安全性较低。

资源限制

Docker运行指令实际上会由Docker daemon去执行,所以当你将启动容器的指令包装到一个脚本中再通过调度系统去运行时,集群资源限制会失去作用。原因是它们是由完全不相关的进程去负责启动的,cgroup限制自然会失效。这样一来虽然环境是隔离了,但是失去了资源限制,会影响集群资源分配策略。

解决方案

借用root的权限

为了解决上述问题,我查询了很多资料,有一篇IEEE论文中提出了一个思路: Enabling Docker Containers for High-Performance and Many-Task Computing,我用Golang按照作者的思路开发了一个名为go-socker的工具(GitHub仓库:go-socker)。针对上面的两个问题,go-socker给出了解决方案。go-socker本质上就是封装了一些基本的Docker指令,然后我们将编译后的可执行程序owner设置为root,再为其加上特殊的s权限位,这样普通用户执行的命令就会在程序内被提升到root。
关于这个特殊的s权限位介绍: 鸟哥的Linux私房菜——第七章、Linux文件与目录管理

当 s 这个标志出现在文件拥有者的 x 权限上时,例如刚刚提到的 /usr/bin/passwd 这个文件的权限状态:『-rwsr-xr-x』,此时就被称为 Set UID,简称为 SUID 的特殊权限。 那么SUID的权限对於一个文件的特殊功能是什么呢?基本上SUID有这样的限制与功能:

  • SUID 权限仅对二进位程序(binary program)有效;
  • 运行者对於该程序需要具有 x 的可运行权限;
  • 本权限仅在运行该程序的过程中有效 (run-time);
  • 运行者将具有该程序拥有者 (owner) 的权限。

这样就解决了运行权限的问题,但是没有解决安全问题。

通过user namespace来提高安全性

上面的方式只是让普通用户可以运行docker命令了,但是同时也带来一个严重的安全问题,普通用户可能通过这个安全问题实现root提权,具体怎么操作呢?我举个例子:

假设我以admin的身份执行我封装好的程序启动了docker,此时我们将会以root身份运行容器,一旦我们挂载某个外部的目录进去,此时便可以在该目录中编写一个恶意程序,并且在容器内将该程序的权限+xs,再退出这个容器。此时普通用户便可以用root的身份执行该程序,假设这个程序的功能是rm -rf /root,则普通用户可以删除掉本来不属于自己的文件夹。这个程序完全可以为所欲为,普通用户很容易就获取到了root的权限。

你可能会想启动的时候指定–user不就行了吗?但是这样启动的容器,用户在容器内不具备root权限,则无法安装软件,很多操作都无法使用,容器用起来就非常不方便了。

那如何避免安全问题又兼顾容器内的权限呢?最好的办法就是启用docker的userns-remap功能,该功能可以将容器内的root映射到宿主机的其他普通用户。相较于启动时指定–user参数,这样的好处是容器内既可以获得root权限,也不会威胁到宿主机的安全。具体如何配置userns-remap可以查阅Docker文档,这里不作赘述。

通过挂载特定目录实现数据交换

上面解决了安全性的问题,但是也限制了普通用户对外部数据卷的访问能力。因为被映射成一个特殊的用户了,假设此时用户挂载自己的家目录到容器内,会发现竟然没有权限,在实际使用中会产生一些不便。所以我们还要稍加处理,这里我用了一个目录权限切换的技巧实现了特殊数据卷的挂载。具体的方式是在容器启动前将家目录权限修改为0755,同时创建一个0777的container数据交换目录,将这个目录挂载到容器内,然后再将用户家目录修改回0750,这样容器内将获得一个0777的文件夹,并且容器外部其他用户无法访问该文件夹,而启动容器的用户自己却可以在容器内以及宿主机上访问该目录。

最小功能实现

做得越多,越容易出错,安全性也越低。所以我们封装的程序应该只实现Docker的部分功能,例如实现容器启动,但只支持必须支持的选项。每增加一个功能或者选项的支持都要认真分析是否会对系统安全和其他用户的体验造成影响。

通过cgroup重配置实现资源限制

对于资源限制的问题,本质上Slurm的资源限制是通过cgroup实现的,所以我们只需要将容器内运行的进程定位到,再将其放到对应的Slurm作业所在的资源限制组中,就实现了资源限制,关于上述问题的详细实现方式可以直接读go-socker的代码了解,也欢迎提交issue和PR!

参考文档

坚持原创技术分享,您的支持将鼓励我继续创作!