• Docker镜像


    本文内容:

    • 什么是Docker镜像
    • 什么是rootfs
    • 什么是容器的一致性

    什么是Docker镜像

    Docker镜像是一个只读的Docker容器模板,含有启动Docker容器所需要的文件系统结构及其内容,是启动一个容器的基础。可以这么理解,Docker镜像是Docker容器的静态视角,而Docker容器是Docker镜像的运行状态。

    什么是rootfs?

    在博客里和讲解Docker的书里,一般是这样介绍rootfs的:rootfs是Docker容器在启动时内部进程可见的文件系统,即Docker容器的根目录。rootfs通常包含一个操作系统运行所需要的文件系统,如/proc、/bin、/etc等等,以及运行Docker所需要的配置文件、工具等。

    作为初学者,对于上述描述似懂非懂。下面,通过代码来理解。

    这里借用耗子叔在DOCKER基础技术:LINUX 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!
    ");
      execv(container_args[0], container_args);
      printf("Something's wrong!
    ");
      return 1;
    }
    
    int main()
    {
      printf("Parent - start a container!
    ");
      int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
      waitpid(container_pid, NULL, 0);
      printf("Parent - container stopped!
    ");
      return 0;
    }
    

    这段代码的作用是:在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。而这个子进程执行的,是一个“/bin/bash”程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中。

    我们来编译执行这个程序:

    root@ubuntu:~/work/container# gcc -o ns ns.c
    root@ubuntu:~/work/container# ./ns
    Parent - start a container!
    Container - inside the container! // 注意,你已经进入容器中了哦~
    root@ubuntu:~/work/container#  // 注意,此时你所在的shell已经不是原来的那个shell了,如果要退出当前这个shell,可以执行exit
    

    这里对于初学者要特别注意,此时,虽然我们看不出什么变化,但其实已经进入了一个新的shell里,我们暂且把这个新的shell当作一个“容器”,此时在这个“容器”里执行 ls 命令,会发现看到的内容和在原宿主机(即原来的那个shell)上看到的内容一致。这里以 ls /tmp 为例。

    root@ubuntu:~/work/container# ls /tmp
    hello.txt  hi
    

    (注:为了实验,可以在宿主机的/tmp目录下先造一些临时文件)

    也就是说,即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

    事实上,在创建新进程时,除了声明要启用 Mount Namespace 之外,还需要告诉容器进程,有哪些目录需要重新挂载,就比如这个 /tmp 目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作,于是修改程序如下:

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

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

    重新编译执行代码,如下:(记得先退回到宿主机上,即先执行 exit )

    root@ubuntu:~/work/container# gcc -o ns ns.c // 在宿主机上
    root@ubuntu:~/work/container# ./ns   // 进入新的shell中
    Parent - start a container!
    Container - inside the container!
    root@ubuntu:~/work/container# ls /tmp // 此时执行该命令发现没东西了
    root@ubuntu:~/work/container# 
    

    上述实验说明了,Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效

    有了上面的启示,我们希望,每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?不难想到,只需要挂载整个根目录 "/" 就好了,这样一来,对容器进程而言,从根目录开始(也就是整个文件系统)都是独立的,与宿主机不可见,于是容器进程就可以在里面随便折腾了。

    为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO(注:推荐阅读 What Is an ISO File?)。这样,在容器启动之后,我们在容器里通过执行 "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
    

    对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程完成以下工作:

    1. 启用 Linux Namespace 配置;
    2. 设置指定的 Cgroups 参数;
    3. 切换进程的根目录(Change Root)。

    这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别。(注:在 Linux 操作系统里,有一个名为 chroot 的命令可以改变进程的根目录到你指定的位置

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

    实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。

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

    扩展:

    Q:容器使用的内核是和宿主机内核一致的,但如果容器需要不同的内核怎么办?

    A:没!办!法!可以去了解一下katacontainers,这种基于虚拟化的容器是有独立内核的。

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

    什么是容器的“一致性”?

    以往应用打包的时候,由于云端与本地服务器环境不同,往往会产生很多的问题。但有了容器之后,更准确地说,有了容器镜像(即 rootfs)之后,这个问题被非常优雅地解决了。由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起

    有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。

    另外,Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。这其中用到了一种叫做联合文件系统(Union File System)的能力。Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。这里以AUFS为例,做一个简单的实验,见这里

    极客时间专栏上介绍Docker默认的联合文件系统是aufs,但是目前Docker默认采用的都是overlay2,为了继续实验,可以把Docker的Storage Driver改成aufs,步骤如下:

    (1)首先检查系统是否支持aufs

    // 如果这条命令没有输出,说明不支持
    root@ubuntu:~# grep aufs /proc/filesystems
    nodev	aufs 
    

    (2)停止Docker服务

    sudo service docker stop
    

    (3)修改Storage Driver

    修改前:

    root@ubuntu:~# docker info
    ...
    Storage Driver: overlay2
     Backing Filesystem: extfs
    

    修改,进入/etc/docker/daemon.json(如果不存在该文件,就创建之),添加如下一行:

    {
    	"storage-driver": "aufs"
    }
    

    (4)重启Docker服务

    sudo service docker start
    

    修改后:

    root@ubuntu:~# docker info
    ...
    Storage Driver: aufs
      Root Dir: /var/lib/docker/aufs
      Backing Filesystem: extfs
    ...
    WARNING: the aufs storage-driver is deprecated, and will be removed in a future release.//提示该驱动已经过时
    

    从上面的docker info信息可以看到,采用 aufs 作为 storage driver后,与镜像和容器层相关的信息都存储在 /var/lib/docker/aufs 路径下,在这个目录下,包含如下3个文件:

    root@ubuntu:/var/lib/docker/aufs# tree .
    .
    ├── diff
    ├── layers
    └── mnt
    3 directories, 0 files
    
    • diff/:the contents of each layer, each stored in a separate subdirectory
    • layers/metadata about how image layers are stacked. This directory contains one file for each image or container layer on the Docker host. Each file contains the IDs of all the layers below it in the stack (its parents).
    • mnt/Mount points, one per image or container layer, which are used to assemble and mount the unified filesystem for a container. For images, which are read-only, these directories are always empty.

    接下来开始实验。

    首先,启动一个容器,比如:

    $ docker run -d ubuntu sleep 3600
    

    执行该命令后,Docker 就会从Docker Hub上拉取一个 ubuntu 镜像(默认为latest版本)到本地,并运行 ubuntu 容器,在容器内执行 sleep 3600 命令。

    这个所谓的“镜像”,实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作系统的所有文件和目录。不过,与之前我们讲述的 rootfs 稍微不同的是,Docker 镜像使用的 rootfs,往往由多个“层”组成:

    $ docker image inspect ubuntu
    ...
          "RootFS": {
                "Type": "layers",
                "Layers": [
                    "sha256:7789f1a3d4e9...",
                    "sha256:9e53fd489559...",
                    "sha256:2a19bd70fcd4...",
                    "sha256:8891751e0a17..."
                ]
            }
    

    可以看到,这个 Ubuntu 镜像,实际上由4个layer组成(极客专栏上写的是5层,为什么我这里实验的是4层?)。这4个层就是4个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上,这个挂载点就是 /var/lib/docker/aufs/mnt/

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

    首先,通过查看 AuFS 的挂载信息,我们可以找到这个目录对应的 AuFS 的内部 ID(也叫:si):

    root@ubuntu:~# cat /proc/mounts | grep aufs
    none /var/lib/docker/aufs/mnt/fbf1c400306e261f8ea73101ada2d1cc4323e1c0118d43d7796d63562db9b926 aufs rw,relatime,si=2a580ec2183d577c,dio,dirperm1 0 0
    

    即,si=2a580ec2183d577c

    然后使用这个ID,就可以在 /sys/fs/aufs 下查看被联合挂载在一起的各个层的信息:

    root@ubuntu:~# ls /sys/fs/aufs/
    config  si_2a580ec2183d577c
    root@ubuntu:~# ls /sys/fs/aufs/si_2a580ec2183d577c/
    br0  br1  br2  br3  br4  br5  brid0  brid1  brid2  brid3  brid4  brid5  xi_path
    root@ubuntu:~# cat /sys/fs/aufs/si_2a580ec2183d577c/br[0-9]*
    /var/lib/docker/aufs/diff/fbf1c400306e261f8ea73101ada2d1...=rw
    /var/lib/docker/aufs/diff/fbf1c400306e261f8ea73101ada2d1...-init=ro+wh
    /var/lib/docker/aufs/diff/1f171870738b22b76c6ba7961b3620...=ro+wh
    /var/lib/docker/aufs/diff/ae40d4b90efd2b53f37b62a3a71269...=ro+wh
    /var/lib/docker/aufs/diff/230abda1ab9f98929ae038206ac9c3...=ro+wh
    /var/lib/docker/aufs/diff/2c34cd8fc182a596cce9e806126415...=ro+wh
    

    我们进入到 /var/lib/docker/aufs/diff 查看,可以看到:

    root@ubuntu:/var/lib/docker/aufs/diff# ls
    1f171870738b22b76c6ba7961b362083bbd48425fa2a93df476aff0cb889e2b8 
    230abda1ab9f98929ae038206ac9c3e7872feec4b827bc4ab000cc1e8f1066de 
    2c34cd8fc182a596cce9e806126415ced3f057bba5bc790de4b9f3b298bf9562 
    ae40d4b90efd2b53f37b62a3a7126973d6c13d06ead6af53d8ba85bd1c66575f 
    fbf1c400306e261f8ea73101ada2d1cc4323e1c0118d43d7796d63562db9b926 
    fbf1c400306e261f8ea73101ada2d1cc4323e1c0118d43d7796d63562db9b926-init 
    

    这就是各层的内容,即镜像的层都放置在 /var/lib/docker/aufs/diff 目录下,然后被联合挂载在 /var/lib/docker/aufs/mnt 里面。

    从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:

    img

    第一部分,只读层

    它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是 whiteout,下面马上会讲到)。这时,我们可以分别查看一下这些层的内容:

    root@ubuntu:~# ls /var/lib/docker/aufs/diff/2c34cd8fc182a596cce9e806126415ced3f057bba5bc790de4b9f3b298bf9562/
    bin   dev  home  lib32  libx32  mnt  proc  run   srv  tmp  var
    boot  etc  lib   lib64  media   opt  root  sbin  sys  usr
    root@ubuntu:~# ls /var/lib/docker/aufs/diff/230abda1ab9f98929ae038206ac9c3e7872feec4b827bc4ab000cc1e8f1066de/
    var
    root@ubuntu:~# ls /var/lib/docker/aufs/diff/ae40d4b90efd2b53f37b62a3a7126973d6c13d06ead6af53d8ba85bd1c66575f/
    etc  usr  var
    root@ubuntu:~# ls /var/lib/docker/aufs/diff/1f171870738b22b76c6ba7961b362083bbd48425fa2a93df476aff0cb889e2b8/
    run
    

    可以看到,这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分。

    第二部分,可读写层

    它是这个容器的 rootfs 最上面的一层(fbf1c400306e2...),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,修改产生的内容就会以增量的方式出现在这个层中。

    可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。比如,要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,看上去就像“消失”了一样。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。

    分享我的一点学习经验:

    关于出现的一些技术名词,如果看博客、看文档都觉得似懂非懂时,不妨追本溯源,查查该技术名词的英英释义,通常情况下,结合释义,可以帮助我们理解的更清楚。比如这里的”whiteout“,它的释义为:”an arctic atmospheric condition with clouds over snow produce a uniform whiteness and objects are difficult to see“,其大致的意思就是,云层覆盖住了白雪,导致物体都看不清了。

    回到技术本身,不也就是这么个意思吗!如果要删除只读层里的 foo 文件,Docker的做法并不是真正的去删除它(当然也无法删除,因为文件在只读层里),因此在可读写层里创建一个 .wh.foo 文件,这就是一朵云,覆盖住了原本的物体,导致人们看不见它了,于是就达到了”删除“的效果。

    再重复一遍,很多技术名词看上去各种高大上,不妨我提供的这个角度去理解,这也是程序员为什么要好好学英文的原因。

    所以,最上面这个可读写层的作用,就是专门用来存放修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这就是增量 rootfs 的好处。

    第三部分,Init 层

    它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。

    (全文完)


    参考:

  • 相关阅读:
    Java中String的intern方法
    3.7测试复盘
    3.6测试复盘
    LTMU论文解析
    移动机器人相机模型:从相机移动到二维图像
    一步步分析MIPS数据通路(单周期)
    Slam笔记I
    如何理解SiamRPN++?
    如何使用Pytorch迅速写一个Mnist数据分类器
    PySide2的This application failed to start because no Qt platform plugin could be initialized解决方式
  • 原文地址:https://www.cnblogs.com/kkbill/p/12945178.html
Copyright © 2020-2023  润新知