什么是 docker

两个基本的事实:java

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

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

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

  • 程序文件自己;
  • 程序的依赖;
  • 操做系统内核。

程序自己就不用提了,你的程序得是可以运行的。固然对于 java、python 这类自己没法编译成二进制的语言而言,你须要保证 java 虚拟机以及 python 解释器的存在。linux

程序依赖

程序自身 OK 后,咱们须要考虑程序的依赖问题。为了程序的开发尽量的简单和快捷,开发者们会将通用且经常使用的功能作成各类程序库,当开发者须要使用这些功能的时候,只须要引用或者调用这些库就好了。c++

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

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

# ./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。固然你颇有可能不是这个库,但错误信息是相似的。你甚至能够在操做系统上对这种状况进行模拟:vim

[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。你就没法经过这种方式来查看你的程序是否会有这种依赖。centos

若是运行你程序的操做系统库文件齐全,你根本不会遇到这样的状况,颇有可能也不会知道你的程序会依赖系统库文件。不过出现这样的问题,只要找到这个库所属的 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 系统调用参数 隔离内容
UTS CLONE_NEWUTS 主机名与域名
IPC CLONE_NEWIPC 信号量、消息队列和共享内存
PID CLONE_NEWPID 进程编号
Network CLONE_NEWNET 网络设备、网络栈、端口等等
Mount CLONE_NEWNS 挂载点(文件系统)
User CLONE_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 来查看它支持哪些选项。它支持的选项很是多,而且不少是和资源隔离以及资源限制有关。