首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Docker容器实战(07)-Docker存储隔离原理

Docker容器实战(07)-Docker存储隔离原理

作者头像
JavaEdge
发布2022-11-30 15:20:06
4110
发布2022-11-30 15:20:06
举报
文章被收录于专栏:JavaEdgeJavaEdge

容器为什么需要进行文件系统隔离呢?

  • 被其他容器篡改文件,导致安全问题
  • 文件的并发写入造成的不一致问题

Linux容器通过Namespace、Cgroups,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是PaaS项目赖以生存的应用“沙盒”。墙内的它们是怎样的生活呢?

1 容器里的进程眼中的文件系统

也许你认为这是Mount Namespace问题。容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就能在自己的容器目录(如/tmp)下操作,而完全不会受宿主机及其他容器的影响。

真是这样吗?

在创建子进程时开启指定的Namespace:

#define _GNU_SOURCE
#include <sys/mount.h> 
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
  "/bin/bash",
  NULL
};

int container_main(void* arg)
{  
  printf("Container - inside the container!\n");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

int main()
{
  printf("Parent - start a container!\n");
  int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
  waitpid(container_pid, NULL, 0);
  printf("Parent - container stopped!\n");
  return 0;
}

通过clone()系统调用创建一个新的子进程container_main,且声明要为它启用Mount Namespace(即CLONE_NEWNS)。

而该子进程执行的,是个“/bin/bash”程序,即一个shell。所以该shell就运行在Mount Namespace的隔离环境。

编译该程序:

就进入该“容器”中。可若在“容器”执行ls指令: /tmp目录下的内容跟宿主机的内容一样。

即使开启Mount Namespace,容器进程看到的文件系统也和宿主机完全一样,why?Mount Namespace修改的,是容器进程对文件系统“挂载点”的认知。 但这也就意味着,只有在“挂载”操作发生后,进程的视图才改变。此前,新创建的容器会直接继承宿主机的各挂载点。

这时,你可能想到解决办法:创建新进程时,除了声明要启用Mount Namespace,还可告诉容器进程,有哪些目录需重新挂载,如/tmp目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp目录的操作:

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

修改后的代码,在容器进程启动前,加上一句mount(“none”, “/tmp”, “tmpfs”, 0, “”)。就这样,我告诉了容器以tmpfs(内存盘)格式,重新挂载了/tmp目录。

这段修改后的代码编译:

这次/tmp成空目录,即重新挂载生效。可用mount -l检查:

可见,容器里的/tmp目录以tmpfs方式单独挂载。

因为创建的新进程启用Mount Namespace,所以这次重新挂载的操作,只在容器进程的Mount Namespace中有效。如在宿主机上用mount -l检查该挂载,会发现它不存在:

这就是Mount Namespace跟其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定伴随挂载操作(mount)才能生效。

可作为用户,希望每当创建一个新容器,容器进程看到的文件系统就是一个独立的隔离环境,而非继承自宿主机的文件系统。怎么做到?可在容器进程启动之前重新挂载它的整个根目录“/”。而由于Mount Namespace的存在,该挂载对宿主机不可见,所以容器进程就能在里面随便折腾。

Linux有个命令:

2 chroot(change root file system)

改变进程的根目录到指定的位置。假设有一$HOME/test目录,想要将其作为一个/bin/bash进程的根目录。

创建一个test目录和几个lib文件夹:

$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T

把bash命令拷贝到test目录对应的bin路径:

$ cp -v /bin/{bash,ls} $HOME/test/bin

把bash命令需要的所有so文件,也拷贝到test目录对应的lib路径下。找到so文件可以用ldd 命令:

$ T=$HOME/test
$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
$ for i in $list; do cp -v "$i" "${T}${i}"; done

执行chroot命令,告诉os,我们将使用$HOME/test目录作为/bin/bash进程的根目录:

$ chroot $HOME/test /bin/bash

这时,若执行ls /,就会看到,它返回的都是$HOME/test目录下面的内容,而非宿主机的内容。

被chroot的进程,它不会感受到自己的根目录已被“修改”成$HOME/test。

这种视图被修改的原理类似Linux Namespace,Mount Namespace正是基于对chroot的不断改良才被发明,也是Linux操作系统里的第一个Namespace。

为让容器的这根目录更“真实”,我一般在这个容器的根目录下挂载一个完整os的文件系统, 如Ubuntu16.04的ISO。这样,在容器启动后,在容器里通过执行"ls /"查看根目录下的内容就是Ubuntu 16.04的所有目录和文件。 而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,即“容器镜像”,也叫:rootfs(根文件系统)。

所以,最常见的rootfs或者说容器镜像,会包括如下所示的一些目录和文件,如/bin,/etc,/proc等:

$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

而你进入容器之后执行的/bin/bash,就是/bin目录下的可执行文件,与宿主机的/bin/bash完全不同。 Docker项目最核心原理就是为待创建的用户进程:

  • 启用Linux Namespace配置
  • 设置指定的Cgroups参数
  • 切换进程的根目录(Change Root)

Docker项目在最后一步的切换会优先使用pivot_root系统调用,若系统不支持,才使用chroot

rootfs只是一个os所包含的文件、配置和目录,并不包括os内核。Linux的这两部分是分开存放的,os只有在开机启动时才会加载指定版本的内核镜像。

对容器来说,这

3 操作系统的“灵魂”在哪?

同一台机器的所有容器,都共享宿主机os的内核。 若你的应用程序需配置内核参数、加载额外的内核模块及和内核进行直接交互, 这些操作和依赖的对象,都是宿主机os的内核,它对于该机器上的所有容器是“全局变量”。

这也是容器相比VM主要缺陷之一,VM不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的Guest OS给应用随便折腾。

不过正由于rootfs,容器才有个被反复宣传至今的重要特性:

4 一致性

由于云端与本地服务器环境不同,应用打包过程,一直是使用PaaS时最“痛苦”的步骤。 但有容器镜像(即rootfs),这问题被优雅解决。由于rootfs里打包的不只是应用,而是整个os的文件和目录,即应用及它运行所需所有依赖,都被封装在一起。

大多数开发者对应用依赖的理解,局限在编程语言层面。对一个应用,os本身才是它运行所需要的最完整的“依赖库”。

有容器镜像“打包os”能力,这最基础的依赖环境也终于变成应用沙盒一部分。这就是容器的一致性:无论在本地、云端,还是在一台任何地方的机器,用户只需解压打包好的容器镜像,则该应用运行所需要的完整的执行环境就会被重现。

这深入到os层的运行环境一致性,打通了应用在本地开发和远端执行环境之间的鸿沟。

难道每开发一个应用或升级一下现有应用,都要重复制作一次rootfs? 如现在用Ubuntu ISO做个rootfs,然后又在里面安装了Java环境,用来部署应用。那么,我的另一个同事在发布他的Java应用时,显然希望能够直接使用我安装过Java环境的rootfs,而不是重复这个流程。

一种直观解决办法,制作rootfs时,每做一步“有意义”的操作,就保存一个rootfs,这样其他同事就能按需求去用他需要的rootfs。但这不具备推广性。一旦你的同事修改该rootfs,新旧两个rootfs之间就无任何关系,导致极度碎片化。

既然这些修改都基于一个旧rootfs,能否以增量方式做这些修改?好处是,所有人都只需维护相对base rootfs修改的增量内容,而非每次修改都制造一个“fork”。这也正是为何,Docker公司在实现Docker镜像时并未沿用以前制作rootfs的标准流程,而是做了小创新: Docker在镜像设计中,引入层(layer)。即用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs。 这就得用到

5 联合文件系统(Union File System,UnionFS)

将多个不同位置的目录联合挂载(union mount)到同一目录。

容器有进程隔离(视野隔离),CGroup资源隔离,还缺少隔离的文件系统,Unionfs将多个文件目录挂载给某个容器进程,供其独享。

为解决该问题,Docker在Ubuntu发行版上默认使用AuFS(Advanced Union FS)支持Docker镜像的Layer,也支持其他UnionFS的版本。

如现在有两个目录A、B,分别有俩文件:

$ tree
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x

然后使用联合挂载,将这俩目录挂载到一个公共目录C:

$ mkdir C
$ mount -t aufs -o dirs=./A:./B none ./C

再查看目录C内容,就能看到目录A和B下的文件被合并:

$ tree ./C
./C
├── a
├── b
└── x

合并后的目录C,有a、b、x三个文件,且x文件只有一份,这就是“合并”。若在目录C对a、b、x文件修改,这些修改也会在对应目录A、B生效。

AuFS全称Another UnionFS,后改名Alternative UnionFS,再改名Advance UnionFS:

  • 对Linux原生UnionFS的重写和改进
  • Linus一直不让AuFS进入Linux内核主干,只能在Ubuntu和Debian这些发行版使用

AuFS最关键的目录结构在/var/lib/docker路径下的diff目录:

/var/lib/docker/aufs/diff/<layer_id>

启动一个容器

$ docker run -d ubuntu:latest sleep 3600

Docker就会从Docker Hub上拉取一个Ubuntu镜像到本地。这“镜像”是个Ubuntu的rootfs,内容是Ubuntu的所有文件和目录。 不同在于Docker镜像使用的rootfs,有多“层”:

docker image inspect ubuntu:latest
...
     "RootFS": {
      "Type": "layers",
      "Layers": [
        "sha256:f49017d4d5ce9c0f544c...",
        "sha256:8f2b771487e9d6354080...",
        "sha256:ccd4d61916aaa2159429...",
        "sha256:c01d74f99de40e097c73...",
        "sha256:268a067217b5fe78e000..."
      ]
    }

这个Ubuntu镜像由五层。就是五个增量rootfs,每层都是Ubuntu操作系统文件与目录的一部分;使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点(等价于前面例子里的“/C”目录)。

这挂载点就是/var/lib/docker/aufs/mnt/,如:

/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e

这个目录里正是完整的Ubuntu操作系统:

$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

那五个镜像层是如何被联合挂载成这样一个完整的Ubuntu文件系统的?这个信息记录在AuFS的系统目录/sys/fs/aufs。

先查看AuFS的挂载信息,找到这个目录对应的AuFS的内部ID(也叫:si):

$ cat /proc/mounts| grep aufs
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0

即,si=972c6d361e6b32ba。然后使用该ID,就可在/sys/fs/aufs下查看被联合挂载在一起的各层的信息:

$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
/var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
/var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
/var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
/var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
/var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh

镜像的层都放置在/var/lib/docker/aufs/diff目录下,然后被联合挂载在/var/lib/docker/aufs/mnt。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-10-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 容器里的进程眼中的文件系统
  • 2 chroot(change root file system)
  • 3 操作系统的“灵魂”在哪?
  • 4 一致性
  • 5 联合文件系统(Union File System,UnionFS)
相关产品与服务
容器镜像服务
容器镜像服务(Tencent Container Registry,TCR)为您提供安全独享、高性能的容器镜像托管分发服务。您可同时在全球多个地域创建独享实例,以实现容器镜像的就近拉取,降低拉取时间,节约带宽成本。TCR 提供细颗粒度的权限管理及访问控制,保障您的数据安全。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档