阅读 877

什么是 docker

两个基本的事实:

  • 无论是虚拟机还是容器,都是提供程序的运行时环境;
  • 开发者只关心程序运行的结果,不关心程序的运行过程。

对于运维来讲,可能关注虚拟机和容器之间差异会更多些,因为涉及到日常维护、排障、监控、日志等操作,但这不是重点。至少不是本篇文章的重点。

那么一个程序的运行需要哪些条件呢?总结下来有这么几个:

  • 程序文件本身;
  • 程序的依赖;
  • 操作系统内核。

程序本身就不用提了,你的程序得是能够运行的。当然对于 java、python 这类本身无法编译成二进制的语言而言,你需要保证 java 虚拟机以及 python 解释器的存在。

程序依赖

程序自身 OK 后,我们需要考虑程序的依赖问题。为了程序的开发尽可能的简单和快捷,开发者们会将通用且常用的功能做成各种程序库,当开发者需要使用这些功能的时候,只需要引用或者调用这些库就行了。

通过这样的方式,确实极大的提升了开发的效率,同时也极大的降低了开发的难度,但是它同时也会为程序产生依赖的问题。当然,每种语言自身会提供依赖的解决方案,比如 java 的 maven、python 的 pip 等。通过这些工具,你可以保证你的程序依赖的库文件你的程序都可以加载到。但是一旦你依赖的库文件依赖于系统层面的库(c 标准库)时,maven、pip 这类的工具是无法知晓这样的问题的。而当操作系统缺少这些的库时,程序就会运行失败。

你也许会遇到这样的报错:

# ./clocktest
./clocktest: error while loading shared libraries: libpthread_rt.so.1: cannot open shared object file: No such file or directory
复制代码

这是典型的在操作系统上找不到依赖的共享 C 库文件,这里缺少的是 libpthread_rt.so.1。当然你很有可能不是这个库,但错误信息是类似的。你甚至可以在操作系统上对这种情况进行模拟:

[root@localhost ~]# ldd /bin/man # 查看 man 命令依赖哪些系统库
        linux-vdso.so.1 =>  (0x00007ffd121f8000)
        libmandb-2.6.3.so => /usr/lib64/man-db/libmandb-2.6.3.so (0x00007f077f4a8000)
        libman-2.6.3.so => /usr/lib64/man-db/libman-2.6.3.so (0x00007f077f288000)
        libgdbm.so.4 => /lib64/libgdbm.so.4 (0x00007f077f07f000)
        libpipeline.so.1 => /lib64/libpipeline.so.1 (0x00007f077ee72000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f077eaa5000)
        libz.so.1 => /lib64/libz.so.1 (0x00007f077e88f000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f077f6ae000)
[root@localhost ~]# mv /lib64/libpipeline.so.1 /tmp/ # 将库文件移走
[root@localhost ~]# man ls # 命令就运行失败了
man: error while loading shared libraries: libpipeline.so.1: cannot open shared object file: No such file or directory
复制代码

上面的示例中,我们可以通过 ldd 这个命令来查看一个二进制程序依赖哪些操作系统的库文件。一旦它依赖的库文件缺失,它就无法运行。但是对于 java、python 这样的语言来说,它的程序文件是无法编译成二进制的,它们的二进制程序只是 java 和 python。你就无法通过这种方式来查看你的程序是否会有这种依赖。

如果运行你程序的操作系统库文件齐全,你根本不会遇到这样的情况,很有可能也不会知道你的程序会依赖系统库文件。不过出现这样的问题,只要找到这个库所属的 rpm 包,yum 安装就行。当然,如果你的程序是 C 写的,可能还会有头文件的依赖。

第三方库为什么会依赖系统库?因为系统库提供了很多相对于比较底层的功能,比如图形接口等。你想造轮子都没法造(非 C 语言),因为内核都是 C 写的。

库文件也有版本,也会存在版本冲突的情况。当你的操作系统两个程序使用同一个库的不同版本时,会在 yum 安装时提示冲突。其实不通过 yum 安装,运行时也会存在问题。

对于可以将程序源代码编译成二进制的语言,比如 c、c++、go、rust 等,你可以在编译的时候将它依赖的动态库文件编译到程序二进制文件中,这样就不会存在运行时库文件找不到的问题。不过即使无法将动态库编译到程序文件中,你也可以通过 ldd 来查看它的依赖,然后提供就好。

内核

程序的依赖解决以后,就剩下内核了,程序的运行为什么依赖于内核呢?首先你得明白,内核是一个操作系统的绝对核心,它提供如下功能:

  • 进程管理;
  • 文件系统;
  • 驱动程序;
  • 网络子系统;
  • 安全功能;
  • 内存管理。

直观的讲,用户空间的进程,也就是你写的程序,是无法直接和硬件打交道的。这里的硬件包括 cpu、内存、磁盘、网卡等,你的程序是无法直接使用它们。不过你会发现你的程序使用内存、磁盘和网络时不存在任何问题,这是因为你的程序在使用这些资源的时候会发起系统调用,由内核帮你完成。

内核是核心,但是光有内核还不行,你还需要和内核进行交互。而众多的 Linux 发行版,包括 CentOS、Ubuntu、Debian 等就是提供和内核交互功能的。

程序本身、程序的依赖和内核共同构成了程序运行的最基本的因素。当我们需要在操作系统上运行一个程序时,内核一定存在,因此我们只需要提供程序文件和它的依赖就可以让它运行起来。docker 使用的就是这种思想,它通过镜像来提供程序以及它的依赖。

相比于 Linux 发行版,docker 镜像由于只需要为一个特定进程提供运行所需环境,因此它可以做的非常小。镜像越小下载越快,运行就越快,因此镜像越小越好。而如果你能将镜像做的越小,你对操作系统的理解就越深。

docker 和虚拟机

docker 和虚拟机之间差距巨大,两者之间的技术难度也不是同一个层次。虚拟机和 docker 其实都是宿主机上的一个进程,为何差距这么大?又或者说 docker 轻量,它轻量在哪呢?它们最大的区别在于虚拟机使用了自己的内核,这会造成一系列非常复杂的问题。

我们先说说虚拟机重在哪,先看一个虚拟机(kvm)进程:

/usr/libexec/qemu-kvm -name k8s.master.01 -S -machine pc-i440fx-rhel7.0.0,accel=kvm,usb=off,dump-guest-core=off -cpu Broadwell-IBRS,+vme,+ds,+acpi,+ss,+ht,+tm,+pbe,+dtes64,+monitor,+ds_cpl,+vmx,+smx,+est,+tm2,+xtpr,+pdcm,+dca,+osxsave,+f16c,+rdrand,+arat,+tsc_adjust,+intel-pt,+stibp,+ssbd,+xsaveopt,+pdpe1gb,+abm -m 8096 -realtime mlock=off -smp 4,sockets=4,cores=1,threads=1 -uuid e43f9c9d-d295-4b95-9616-9e1dc5cd854d -no-user-config -nodefaults -chardev socket,id=charmonitor,path=/var/lib/libvirt/qemu/domain-1-k8s.master.01/monitor.sock,server,nowait -mon chardev=charmonitor,id=monitor,mode=control -rtc base=utc,driftfix=slew -global kvm-pit.lost_tick_policy=delay -no-hpet -no-shutdown -global PIIX4_PM.disable_s3=1 -global PIIX4_PM.disable_s4=1 -boot strict=on -device ich9-usb-ehci1,id=usb,bus=pci.0,addr=0x5.0x7 -device ich9-usb-uhci1,masterbus=usb.0,firstport=0,bus=pci.0,multifunction=on,addr=0x5 -device ich9-usb-uhci2,masterbus=usb.0,firstport=2,bus=pci.0,addr=0x5.0x1 -device ich9-usb-uhci3,masterbus=usb.0,firstport=4,bus=pci.0,addr=0x5.0x2 -device virtio-serial-pci,id=virtio-serial0,bus=pci.0,addr=0x6 -drive file=/home/k8s.master.01,format=qcow2,if=none,id=drive-virtio-disk0 -device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x7,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1 -drive file=/opt/CentOS-7-x86_64-Minimal-1804.iso,format=raw,if=none,id=drive-ide0-0-1,readonly=on -device ide-cd,bus=ide.0,unit=1,drive=drive-ide0-0-1,id=ide0-0-1,bootindex=2 -netdev tap,fd=25,id=hostnet0,vhost=on,vhostfd=27 -device virtio-net-pci,netdev=hostnet0,id=net0,mac=52:54:00:1a:4c:0a,bus=pci.0,addr=0x3 -chardev pty,id=charserial0 -device isa-serial,chardev=charserial0,id=serial0 -chardev spicevmc,id=charchannel0,name=vdagent -device virtserialport,bus=virtio-serial0.0,nr=1,chardev=charchannel0,id=channel0,name=com.redhat.spice.0 -spice port=5900,addr=127.0.0.1,disable-ticketing,image-compression=off,seamless-migration=on -vga qxl -global qxl-vga.ram_size=67108864 -global qxl-vga.vram_size=67108864 -global qxl-vga.vgamem_mb=16 -global qxl-vga.max_outputs=1 -device intel-hda,id=sound0,bus=pci.0,addr=0x4 -device hda-duplex,id=sound0-codec0,bus=sound0.0,cad=0 -chardev spicevmc,id=charredir0,name=usbredir -device usb-redir,chardev=charredir0,id=redir0,bus=usb.0,port=1 -chardev spicevmc,id=charredir1,name=usbredir -device usb-redir,chardev=charredir1,id=redir1,bus=usb.0,port=2 -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x8 -msg timestamp=on
复制代码

这就启动了一台虚拟机了,这些参数看起来非常底层,可读性很差。至于这个进程里面是怎么实现内核以及各种用户空间进程的,它们怎么交互的,我们根本看不到,都由虚拟机自行维护。整个过程非常抽象,难以理解。

同时,虚拟机使用的硬件是模拟的,内核也是自己的,因此虚拟机的任何操作都要比 docker 都多做一次。

我们首先拿虚拟机内存来举例。

虚拟机内存管理

有没有想过一个问题,假如你的操作系统有 4G 内存,在不知道你的操作系统会运行哪些程序的情况下,内核怎么确定哪些内存空间是给 A 进程,哪些内存空间是给 B 进程的呢?而一旦将某一段内存区域划分给某个进程之后,它是不是就只能使用这么多内存呢?所有进程都直接使用物理内存,随着内存不断的申请和释放,势必会产生非常多内存碎片,可用的内存只会越来越小。

为了解决这样的问题,进程不会直接使用物理内存,而是使用虚拟的线性内存,就是在进程和物理内存之间加一个中间层。我们以 32 位系统为例,每个进程都认为自己有 4G 内存可用,其中的 1G 为内核所使用。因此,在每个进程看来,当前系统上只有自己和内核这两个进程。

为了实现这种机制,cpu 必须要将除了内核之外的内存划分成一个又一个的页面(页框),每一个页框都是一个固定大小的存储单元,每一个都是 4k。当任何一个进程启动之后,假如它需要 10k 的空间,内核会在内存中找 3 个 4k 的页面。而这三个页面在内存中很有可能是不相邻的,也就是说它们很有可能是不连续的,但是在每一个进程看来,它是连续的。进程为什么会认为是连续的呢?因为它看到的是内核为它维持的内核数据结构,我们在数据结构中规定了进程能够使用的空间是 3G,并且是连续的。

线性地址只是一个中间层,数据最终还是要存放到物理地址,CPU 中的有一个专门的设备 MMU 专门负责这种线性地址和物理内存之间的映射关系。

这样会造成一个问题:虚拟机作为操作系统中的一个进程,它使用的内存是线性内存。本身划分给虚拟机的内存就是虚拟的,结果虚拟机中的进程使用线性内存会通过 MMU 映射到虚拟机的线性内存。最终在宿主机层面,线性内存才会真正映射到物理内存。也就说虚拟机中的进程使用的内存会被映射两次,性能可想而知。

早期会使用半虚拟化来解决这样的问题,当然现在 CPU 已经支持将虚拟机中的线性内存直接映射到宿主机的物理内存上,一步到位。从这点可以看出虚拟机的复杂性,而 docker 直接使用宿主机的内存,完全没有这样的问题。

下面在介绍 namespace 时,你会对 docker 的这个过程更加理解。

虚拟机系统调用

虚拟机提供了包括内核在内的完整操作系统,但它本身只是宿主机的一个进程。当虚拟机中的进程想要和硬件打交道时,会发起系统调用,由内核帮它完成。比如当虚拟机中某个进程需要申请内存,该进程会发起系统调用,虚拟机的内核登场。但是虚拟机本身就是宿主机上的一个进程,虚拟机的内核根本无法访问内存,于是它只能发起一个系统调用,让宿主机的内核帮忙申请。一个虚拟机内的进程想要申请内存都需要经过两次系统调用,性能同样十分低下。

早期同样会使用半虚拟来解决这样的问题,不过现在的 cpu 同样支持让虚拟机的内核直接和宿主机的硬件打交道。

二者对比

上面只是简单列出了虚拟机重在哪,与之对应的就是 docker 的轻。docker 的轻就轻在它就是操作系统的一个进程,它运行方式和进程一模一样,完全没有非常抽象、难以理解的虚拟化过程。它的资源隔离以及限制都由内核完成(下面会讲到),整个过程非常容易理解。

很多人喜欢将 docker 和虚拟机进行对比,我这里也简单列下:

对比点虚拟机docker
启动速度需要硬件自检、内核引导、用户空间初始化,慢非常快
复杂度虽然是一个进程,但是难以理解,非常复杂就是一个进程
资源占用众多内核进程产生额外消耗无多余消耗
隔离性

对比只是为了看起来更直观,但是它们本质上就不是同一类产品。就说最简单的一点,你只要在本地安装了 docker,想要什么服务 docker run 一下就行,虚拟机不可能做到。docker 真正方便的是开发者,如果开发者不会使用的话,就挺吃亏。

安装 docker

接下来我们就重点讲述 docker 的一些特点以及它的一些实现。首先我们需要安装它(这里基于 centos7 和阿里云 yum 源)。

yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
yum makecache fast
yum -y install docker-ce
复制代码

安装完成后启动它并设置开机自启:

systemctl enable --now docker
复制代码

docker 只支持 Linux 平台,虽然 Mac 和 Windows 都能安装,但都是通过虚拟机来实现,并非原生支持。主要还是三者内核完全不一致。

docker 镜像

前面提到了,docker 本质上就是提供程序本身以及它的依赖,docker 通过镜像将它们组织到一起。镜像是宿主机磁盘上的文件,里面包含了应用的程序文件以及依赖。镜像虽然表现的和一个程序文件一样,但是它不是一个文件,它是一种分层结构。当你制作一个镜像的时候,你一定会基于一个镜像开始制作,而不是完全从零开始。这也是镜像的一个特点。

为了方便镜像的传播,也为了减少镜像占用的空间以及下载的时间,docker 对镜像进行了分层。怎么理解呢?比如我们现在有个 java 程序,想要把它做成镜像。想要将这个程序运行起来,那我们镜像中必须要有 java 环境,如果我们还要提供 java 环境的话,制作镜像就太麻烦了。我们完成可以在已经存在的、别人已经制作好的 jre 镜像上,将我们的程序加上去,这样我们的程序就可以直接运行了。

在这个场景中,jre 镜像是一个镜像层,我们新增的内容会在其上增加镜像层。而我们使用的 jre 镜像,它也不可能只有一层,它在制作的时候可能也是在一个满足了 java 运行环境的镜像上进行的。

我们只需在 dockerhub 上面搜下 java,就可以看到非常多的 java 镜像。我们随便选一个官方的镜像,pull 下来:

# docker pull openjdk:8-alpine 
8-alpine: Pulling from library/openjdk
e7c96db7181b: Extracting [=====================================>             ]  2.064MB/2.757MB
f910a506b6cb: Download complete 
c2274a1a0e27: Downloading [=>                                                 ]  2.669MB/70.73MB
复制代码

pull 命令用来从拉取镜像到本地,镜像名由三部分组成:REGISTRY/IMAGE:TAG

  • REGISTRY:镜像所在的仓库,如果将其省略,那么表示从 docker 官方仓库 pull。我们这里就是从官方拉的;
  • IMAGE:镜像名称。这里的名称是 openjdk;
  • TAG:对镜像的一种补充说明,一般会说明版本以及基于的发行版。我们这里是 8-alpine。

alpine 是一种发行版,它使用的不是标准 C 库 glibc,而是 muslc。特点是非常小,性能会比 glibc 差一些?因为基于 alpine 制作的镜像都很小,且它自带包管理工具,很多流行应用通过它可以直接下载安装,制作镜像十分方便,因此越来越多的镜像基于它来制作。

从 pull 过程中,可以看到这个镜像有三层。下载到本地之后可以通过 docker images 列出当前机器上的所有的镜像。你可以通过下面的命令查看该镜像的层数:

echo -e `docker inspect --format "{{ range .RootFS.Layers }}{{.}}\n{{ end }}" openjdk:8-alpine`
复制代码

通过 docker history 可以看到它的构建过程:

[root@localhost ~]# docker history openjdk:8-alpine
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
a3562aa0b991        15 months ago       /bin/sh -c set -x  && apk add --no-cache   o…   99.3MB              
<missing>           15 months ago       /bin/sh -c #(nop)  ENV JAVA_ALPINE_VERSION=8…   0B                  
<missing>           15 months ago       /bin/sh -c #(nop)  ENV JAVA_VERSION=8u212       0B                  
<missing>           15 months ago       /bin/sh -c #(nop)  ENV PATH=/usr/local/sbin:…   0B                  
<missing>           15 months ago       /bin/sh -c #(nop)  ENV JAVA_HOME=/usr/lib/jv…   0B                  
<missing>           15 months ago       /bin/sh -c {   echo '#!/bin/sh';   echo 'set…   87B                 
<missing>           15 months ago       /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B                  
<missing>           15 months ago       /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B                  
<missing>           15 months ago       /bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6a…   5.53MB   
复制代码

当你 pull 一个镜像时,docker 会对它的镜像层进行校验,如果该层本地已经存在就不再下载,这样可以节约空间和时间。不过任何事情都是有利有弊。docker 的镜像层都是只读的,不管运行起来成容器后怎么写都影响不到镜像,这样可以确保你每一次运行镜像的结果都是相同。

当同一个文件出现在多个层中时,你只能看到最上层的文件,下面所有层中的该文件都不可见。这种结构在共享镜像的时候非常方便,因为你只需要下载本地没有的层。但是它也会带来性能的问题,当你在可写层(镜像运行成容器后会添加一层可写层)修改一个底层的文件时,docker 必须要将文件从底层复制到顶层之后才能写(copy-on-write 写时复制),当镜像层数越多,它找这个文件就会越慢。当这个文件越大,它复制也会越慢。这也就是为什么说镜像越小越好、镜像层越少越好的的原因。

由于镜像层是只读的,容器运行时只会在镜像层的顶层添加一层可写层,因此同一个镜像运行为多个容器时,多个容器共享同样镜像层。当你在可写层删除镜像层的文件时,docker 只是将其屏蔽,让你不可见,而不会真正删除。

关于可写容器层以及写时复制技术的实现,不同的存储驱动的实现是不同的,docker 支持的存储驱动有:

  • AUFS:18.06 版本之前的默认存储驱动;
  • Btrfs:需要文件系统的支持,你宿主机得使用 btrfs 才行;
  • Device mapper:早期 centos/rhel 不支持 overlay2 时的首选;
  • Overlay2:docker 的首选,所有目前主流的发行版都支持;
  • ZFS:同样需要宿主机文件系统支持;
  • VFS:测试用的。

在不同的场景,它们有不同的表现。但是,我们一般不会在容器层写数据,所以知道有这么个东西就行。

通过 docker info 可以看到当前使用的存储驱动,当你的内核支持多个存储驱动时,docker 会有一个优先的列表。

# docker info
...
 Storage Driver: overlay2
  Backing Filesystem: xfs
  Supports d_type: true
  Native Overlay Diff: true
复制代码

centos7 上默认使用 overlay2,所有的镜像层都保存在 /var/lib/docker/overlay2

ls /var/lib/docker/overlay2
复制代码

可写层的文件会直接写入到宿主机的文件系统,因为可写层会保存在 /var/lib/docker/containers

namespace

当你运行一个镜像时,docker 会为这个容器创建 namespace 以及 CGroup。namespace 提供了资源隔离能力,任何运行在容器内的进程看不到宿主机上运行的其他进程,同时对它们影响很小。

namespace 是内核的功能,它用来隔离操作系统的各种资源。相比于虚拟机的资源隔离,namespace 轻量太多。也正是因为它和 CGroup 的存在,容器的使用才成为了一种可能。

namespace 有 6 种:

Namespace系统调用参数隔离内容
UTSCLONE_NEWUTS主机名与域名
IPCCLONE_NEWIPC信号量、消息队列和共享内存
PIDCLONE_NEWPID进程编号
NetworkCLONE_NEWNET网络设备、网络栈、端口等等
MountCLONE_NEWNS挂载点(文件系统)
UserCLONE_NEWUSER用户和用户组

说到安全,namespace 的六项隔离看似全面,实际上依旧没有完全隔离 Linux 的资源,比如 SELinux、 Cgroups 以及 /sys、/proc/sys、/dev/sd* 等目录下的资源。

这个先不谈,我们挑几个 namespace 验证一把。

pid namespace

pid namespace 用来隔离 pid,也就说相同的 pid 可以出现在不同的 namespace 下。pid namespace 是一个树状结构,根 namespace 是所有 pid namespace 的父节点,所有其他 namespace 是它的子节点。从根 namespace 可以看到所有子 namespace 中的进程,反之不可以。

也就是说,宿主机作为 pid namespace 的根,可以看到所有容器中的进程,还可以通过信号的方式对其进行影响。而容器作为一个 namespace,只能看到自己下面的。在容器中 pid 为 1 的进程,在宿主机上只是一个普通的进程。

# docker run -it --name busybox --rm busybox /bin/sh
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    6 root      0:00 ps
复制代码
# docker inspect --format "{{.State.Pid}}" busybox
41881
复制代码

因为 linux 中 pid 为 1 的进程是所有进程的父进程,我们可以在容器中运行一个进程,然后查看这个进程在宿主机上的表现:

/ # cat &
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 sh
   10 root      0:00 cat
   11 root      0:00 ps
[1]+  Stopped (tty input)        cat
复制代码

stop 可以不用理会。我们在容器中运行了一个后台进程,它的 pid 是 10。然后我们回到宿主机上通过 grep 的方式来找这个进程:

# ps -ef|grep cat
root      41929  41881  0 10:20 pts/0    00:00:00 cat
root      41999  41934  0 10:20 pts/1    00:00:00 grep --color=auto cat
复制代码

可以看到它的 pid 是 41929,父进程是 41881,也就是容器的进程。通过这个你可能不一定确认它就是我们要的 cat 进程,你可以直接 kill 它,然后回到容器中查看。

user namespace

它可以实现普通用户的进程,在其他 namespace 中运行的用户是 root。

我们已经知道,容器是一个进程,既然是进程,那就一定有运行它的用户。默认情况下,运行容器的用户为 root(和 docker 配置有关)。虽然容器提供了资源隔离性,但是使用 root 运行总归存在安全隐患。我们就可以通过 user namespace 的方式让其以非 root 用户运行。

# docker run --rm -it --name busybox --user=99:99 busybox /bin/sh
复制代码

将宿主机的 uid 和 gid 为 99 的用户(nobody)映射成了容器中 root 用户,容器中的所有进程都只拥有 99 用户的权限。

你可以在宿主机上看到该容器的所有 namespace:

ll /proc/42534/ns/
total 0
lrwxrwxrwx 1 nobody nobody 0 Aug  7 10:55 ipc -> ipc:[4026532585]
lrwxrwxrwx 1 nobody nobody 0 Aug  7 10:55 mnt -> mnt:[4026532583]
lrwxrwxrwx 1 nobody nobody 0 Aug  7 10:46 net -> net:[4026532588]
lrwxrwxrwx 1 nobody nobody 0 Aug  7 10:55 pid -> pid:[4026532586]
lrwxrwxrwx 1 nobody nobody 0 Aug  7 10:55 user -> user:[4026531837]
lrwxrwxrwx 1 nobody nobody 0 Aug  7 10:55 uts -> uts:[4026532584]
复制代码

中括号中的数字表示的是这个 namespace 的编号,如果两个进程的编号相同,证明这两个进程处于同一个 namespace 之下。

你可以在当前容器中运行一个能够运行一段时间的命令(就像之前 cat 命令一样),然后在宿主机上查看这个进程的用户。

mnt namespace

在我们运行一个容器之前,我们先将当前宿主机上的挂载内容输出到一个文件中:

mount > /tmp/run_before
复制代码

运行容器,然后在另一个会话中将挂载的内容输出到另一个文件中:

docker run --rm -it busybox
mount > /tmp/running
复制代码

通过使用 vimdiff 进行比较,你会发现运行一个容器后,会多出两行挂载:

overlay on /var/lib/docker/overlay2/1da1bb59b8cca7c2c2b681039fe2bd1fd39ad94badad81ee2da93e2aae80c34c/merged type overlay (rw,relatime,seclabel,lowerdir=/var/lib/docker/overlay2/l/3BX7LKVS3ZFSG43S3OZH4ZUJBR:/var/lib/docker/overlay2/l/XPK3YPEURTZO2ZNVMY6WUTGUYO,upperdir=/var/lib/docker/overlay2/1da1bb59b8cca7c2c2b681039fe2bd1fd39ad94badad81ee2da93e2aae80c34c/diff,workdir=/var/lib/docker/overlay2/1da1bb59b8cca7c2c2b681039fe2bd1fd39ad94badad81ee2da93e2aae80c34c/work)
proc on /run/docker/netns/8d6016c8a392 type proc (rw,nosuid,nodev,noexec,relatime)
复制代码

一个是将宿主机的 /var/lib/docker/overlay2/1da1bb59b8cca7c2c2b681039fe2bd1fd39ad94badad81ee2da93e2aae80c34c/merged 挂载到了容器的根目录,也就是说这个目录就是可写层。

另一个是 /run/docker/netns/8d6016c8a392 作为容器的 /proc,看起来和 network namespace 有关。

我们可以将当前容器退出后,让它挂载一个 nfs 后重新运行:

docker run --rm -it --mount 'type=volume,source=nfsvolume,target=/app,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/opt/test,volume-opt=o=addr=10.0.0.13' busybox
复制代码

这会将 10.0.0.13 nfs server 上的 /opt/test 目录挂载到容器的 /app 目录。

再次在宿主机上执行 mount 命令,你会发现除了容器的两个挂载之外,还多了一个 nfs 挂载:

:/opt/test on /var/lib/docker/volumes/nfsvolume/_data type nfs (rw,relatime,vers=3,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=10.0.0.13,mountvers=3,mountproto=tcp,local_lock=none,addr=10.0.0.13)
复制代码

从这点可以看出,容器的挂载都是挂载到宿主机上,然后映射到容器中,只不过其他进程不可见。这会给人一种是容器直接挂载的假象。因此你不管是在容器的 /app 目录,还是在宿主机的 /var/lib/docker/volumes/nfsvolume/_data 目录,看到的内容都一样。

net namespace

对于使用者来讲,这个 namespace 是最直观的。net namespace 提供了网络层面的隔离,任何容器会有自己的网络栈。从这一点上看,同一宿主机上容器之间的访问就像物理机之间的访问一样。

容器启动之后,docker 会为该容器分配一个 ip 地址,这个地址你在宿主机上无法看到,只能看到多出了一个网卡。类似这样的:

16460: vethd9ea29c@if16459: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default 
    link/ether e2:43:d2:a1:3b:3a brd ff:ff:ff:ff:ff:ff link-netnsid 1
    inet6 fe80::e043:d2ff:fea1:3b3a/64 scope link 
       valid_lft forever preferred_lft forever
复制代码

这个网卡是成对出现的,一个在宿主机上的 namespace,一个在容器的 namespace。通过这种方式,容器的 namespace 的流量就可以通过宿主机出去了。想要验证这个很简单,只要对着这个网卡抓包就行。

从上面的输出信息可以看到,它的网卡序号是 16460,它的另一对是 16459。

我们可以直接进入容器的 net namespace。先获得其 pid:

pid=`docker inspect --format "{{.State.Pid}}" busybox`
复制代码

通过 nsenter 切换:

nsenter -n -t $pid
复制代码

你现在可以使用一切宿主机的网络相关的命令,只不过它显示的都是该容器中信息。包括 ip、netstat、tcpdump、iptables 等:

# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
16459: eth0@if16460: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

# ip r
default via 172.17.0.1 dev eth0 # 默认网关是 docker0
172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.2 
复制代码

通过 exit 命令退出当前的网络名称空间。

默认情况下,docker 本地的网络是 bridge 模式。docker0 会作为桥设备,所有为容器在宿主机上创建的网卡都会连接到 docker0 这个桥设备上。docker0 其实就相当于一个交换机,同时也是所有容器的网关。

你可以通过 brctl 命令看到这一点;

[root@localhost ~]# brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.02423551e99b       no              veth3ebfdf3
                                                        vethba044e7
复制代码

当我启动两个容器时,这两个容器的对端网卡都被桥接到了 docker0 上。

cgroup

cgroup 也是内核的功能,通过它来限制进程资源使用。它可以限制以下资源:

# ll /sys/fs/cgroup/
total 0
drwxr-xr-x 4 root root  0 Jun 23 10:38 blkio
lrwxrwxrwx 1 root root 11 Jun 23 10:38 cpu -> cpu,cpuacct
lrwxrwxrwx 1 root root 11 Jun 23 10:38 cpuacct -> cpu,cpuacct
drwxr-xr-x 4 root root  0 Jun 23 10:38 cpu,cpuacct
drwxr-xr-x 3 root root  0 Jun 23 10:38 cpuset
drwxr-xr-x 4 root root  0 Jun 23 10:38 devices
drwxr-xr-x 3 root root  0 Jun 23 10:38 freezer
drwxr-xr-x 3 root root  0 Jun 23 10:38 hugetlb
drwxr-xr-x 4 root root  0 Jun 23 10:38 memory
lrwxrwxrwx 1 root root 16 Jun 23 10:38 net_cls -> net_cls,net_prio
drwxr-xr-x 3 root root  0 Jun 23 10:38 net_cls,net_prio
lrwxrwxrwx 1 root root 16 Jun 23 10:38 net_prio -> net_cls,net_prio
drwxr-xr-x 3 root root  0 Jun 23 10:38 perf_event
drwxr-xr-x 4 root root  0 Jun 23 10:38 pids
drwxr-xr-x 4 root root  0 Jun 23 10:38 systemd
复制代码

我们可以限制一个容器只能使用 10m 内存:

docker run --rm -it --name busybox -m 10m --mount 'type=tmpfs,dst=/tmp' busybox /bin/sh
复制代码

你可以在 CGroup 内存子系统中找到你容器的 pid:

cat /sys/fs/cgroup/memory/system.slice/docker-564f2957666b661bbd4947c945107efa6399e3954af7d4c2c5216521a0d5c5d7.scope/tasks 
43973
复制代码

这里的 564f2957666b661bbd4947c945107efa6399e3954af7d4c2c5216521a0d5c5d7 是容器的 id。接着可以看到它的限制,单位是字节:

cat /sys/fs/cgroup/memory/system.slice/docker-564f2957666b661bbd4947c945107efa6399e3954af7d4c2c5216521a0d5c5d7.scope/memory.limit_in_bytes 
10485760
复制代码

我们可以使用 dd 命令来模拟内存使用过大:

# dd if=/dev/urandom of=/tmp/xxx bs=2M count=10
Killed
复制代码

因为是容器中的 dd 命令使用的内存过多,所以内核只杀掉了 dd 进程。而因为容器中 pid 为 1 的进程(这里是 sh)没有被杀掉,所以容器运行正常。从这一点上可以看出,容器中所有的进程都会受到到资源的限制,你可以通过查看 CGroup 子系统来验证这一说法。

这会造成一个问题:如果你容器中运行了 5 个进程,且限制容器只能使用 4G 内存。那么只要这 5 个进程使用内存在 3.9G,那么容器以及其中的进程都不会被干掉,但是容器其实使用的内存已经远远超过了 4G,这也是为什么不建议一个容器中跑多个进程的原因之一。

关于其他的资源限制这里就不演示了,我们只要有这么回事就行。

运行容器

docker 包装了程序的本身以及它的依赖,但是它的运行依赖于 Linux 内核,因为它的所有镜像都是基于 Linux 环境。虽然 Windows 和 Mac 上都提供了 docker 的安装包,但是都是通过虚拟机的方式完成的,这一点需要注意。

运行容器通过 docker run 来完成,我们可以使用 docker run --help 来查看它支持哪些选项。它支持的选项非常多,并且很多是和资源隔离以及资源限制有关。