原理

主要由Namespace,CGroups, UnionFS三大技术支撑docker的实现

Namspace

目的是实现资源的隔离,类似于创建了一个沙箱,不同namespace下的资源是相互独立不可见的。 对于每个单独的namespace来说,会提供一个完整的系统资源抽象。 举个例子 - PID的namespace linux会维护一张进程树,可以通过命令pstree查看,在这张进程树上每个进程会有一个唯一的pid

namespace的作用就是单独开辟一个系统级的抽象空间,让这个空间下的进程就像是另外一个单独系统一样, 如图,子进程下的namespace会在该空间里重新抽象一张pstree出来,在这个pstree里,某个进程可能会与外部的pid是重复的,但它们无法感知该namespace之外其他进程的存在,对于父进程它们是完整可见的,同时这个namespace下的进程会对外暴露一个系统级的pid。

启动一个redis的镜像

可以看到奋斗奋斗redis-server这个容器启用了多种namespace(ipc, mnt, net,pid,uts), 并且子进程都有系统级的唯一PID

进入redis容器内部


可以看到容器内部的pstree也是从1开始的,同一个完整的系统没有区别

namespace的种类

namespace作用
UTS主机名与域名
IPC信号、消息队列和共享内存
PID进程编号
Network网络设备、网络栈、端口等
Mount挂载
User用户和用户组

CGroups

namespace提供的是一种环境隔离,CGroups这种技术则用来做资源上的隔离,它用来限制进程对系统资源的利用(cpu,内存等)。
它提供了以下功能:

  • Resource limitation: 限制资源使用,比如内存使用上限以及文件系统的缓存限制。
  • Prioritization: 优先级控制,比如:CPU利用和磁盘IO吞吐
  • Accounting: 一些审计或一些统计,主要目的是为了计费
  • Control: 挂起进程,恢复执行进程。

UnionFS

联合文件系统,用来将不同位置的目录或者文件挂载到同一个虚拟的目录下,
它包含多种实现:

  • aufs
  • overlayfs
  • mhddfs
  • mergerfs 一个docker镜像包含着这个镜像运行能正常运行的所有文件,这正是UnionFS的用武之地,将不同的镜像所需的不同文件,挂载到了同一个目录下,然后打包成一个镜像

image的结构

docker内部是以层的形式储存数据 使用dockerfile创建一个名为dockertest的镜像

FROM centos
LABEL maintainer="hang"
COPY ./generateImg.js /app
ENTRYPOINT  tail -f /dev/null

上面的FROM,COPY命令会各自创建一个层出来,每一层只会包含与上一层不同的数据,层与层之间互不可见,创建一个container的时候,docker会在镜像数据的最顶层再创建一个读写层,这个层称为container layer,发生在运行中的容器中的所有操作将会在这个读写层上运行。

这张图上可以看到镜像和容器是两个相互依存的东西
镜像和容器的区别就在于顶上的读写层

用dive工具看一下一个镜像的结构,这是第一层FROM命令创建的层,它来自于我本机上的centos镜像

再分析下centos镜像

注意两者的hash值是一样的,这两个镜像复用了同一个centos的镜像层数据,这是UnionFS的神奇之处

再看下dockertest镜像中由COPY命令创建的另外一层

可以看到这一层相比之前的层只多了复制进来的文件

分析下docker的空间占用docker system df

基于这种特性,多个container可以使用复用同一个image层,然后各自管理自身的container层

分析下docker的空间占用docker system df

可以看到我的机器上docker中image container的存储空间占用差别很大,因为有多个container使用了同一个centos的底层镜像 最后一列的RECLAIMABLE 代表可回收的空间,可以使用docker system prune命令来清理

镜像数据层的复用
当pull一个镜像的时候,并不是所有的层都会重新拉取一遍,docker会将所有的层存储在本地,linux上是/var/lib/docker/<storage-driver>/,当重新拉取或者构建一个镜像的时候,docker会复用相同的层
因为镜像层的数据是只读的,所以各个镜像之间可以共享这些数据而不会发生冲突

对比一下两个镜像

第二个是centos基础镜像,第一个镜像基于它创建。
可以看到第一个镜像中最下面的三个层和基础镜像是一样的

#####读写策略 启动一个容器的时候,将会在镜像层的上方加上一个可读写的容器层,镜像层的数据是只读的, docker使用了copy on write的策略来提高性能。

  • 读,不做任何操作,直接从只读层读取数据
  • 修改,从只读层拷贝数据到容器层,然后在容器层修改数据
  • 增加,直接在容器层操作

实际运行过程中,这些操作都是自顶向下的,docker会从顶层一直向下搜索对应的文件。如果是需要更改的文件,会将其拷贝到镜像层。 这种策略能够保证容器层尽可能的小,而且拷贝操作只会发生一次,但是拷贝操作也会带来一些性能开销,因此对于那些有大量写操作的应用,应该单独将数据存储到volumes中,以提高IO性能。

数据管理

如上文所说,docker内部使用专门的文件系统来管理数据,如果将应用的数据直接存储在容器中,会存在性能损耗,同时容器内部的数据对外是不可见的,在实际应用中,会大大降低拓展性和稳定性。 为此容器提供了另外的三种管理数据的方式volumes, bind mounts, tmpfs mount。其中volumes是最好也是最常用的方式。

volumes

数据存储在宿主机上一个专门的目录中,linux上是/var/lib/docker/volumes/,有权限控制,非docker用户无法读写 可用通过docker volume create创建,然后docker volume inspect volumeId查看
volumes可以被多个容器共享,一旦创建,只能用docker volume rm命令删除,即使与其关联的所有容器都已删除。 类似于linux上的远程挂载,docker也可以创建远程的volumes,通过专门的插件,比如sshfs之类,在远程空间上创建volumes。

Bind mounts

存储在任意的地方,无权限控制。 这种方式可以灵活的指定挂载目录,通常用于宿主机与容器的文件同步,比如宿主机上npm build,同时挂载dist目录到容器中,就能及时拿到最新的打包文件。

tmpfs

只存储在内存中,非持久化。在某些处于安全考虑的场景下可以使用这种方式。

网络

docker提供了多种驱动来创建管理网络服务,包括网桥,overlay,Macvlan。 当启动docker的时候,docker会自动创建一个默认网桥,来提供网络服务,用户也可自定义网桥,自定义的网桥的优先级要高于默认的。

网桥

docker默认的网络服务方式。 如果用户未指定,docker会创建一个默认的网桥,默认网桥上的容器需要对外暴露端口,并且通过ip才能访问。因此使用自定义的网桥是更好的方案, 连接到同一个自定义网桥上的容器,会自动相互暴露所有的端口,但对外不可见,可随时断开或者重连,即使容器正在运行中,而默认网桥如果重连就需要重新创建容器。 大多数场景都适用,比如创建一个lnmp环境。

overlay

overlay是一种高层次的抽象的虚拟网络,能够在多个docker进程上创建一个分布式的网络,一般使用在swarm集群上,不同主机上的docker服务,或者不同的swarm服务都可以使用overlay网络来通信。 当创建一个swarm或者加入到一个swarm的时候,docker会创建两个网络,一个是overlay网络,用来处理swarm服务之间的通信,另一个是bridge网络,用来处理该swarm上的docker进程之间通信

macvlan

macvlan可以使主机的一个网络接口上虚拟多个包含不同mac地址的网络接口,它提供同一主机层次上网络虚拟化。使用macvlan可以直接将某个容器的网络接口直接挂在到宿主机的虚拟接口上,不需要再使用端口映射。

创建镜像

Dockerfile

Dockerfile指定了创建一个镜像的环境,端口,命令等,由一条条指令组成,ADD, COPY, RUN等指令会生成一个只读的镜像层。 镜像应该保证精简性,在保证功能完整可用的情况下,尽量减小镜像的体积,以提高性能。 在需要构建某些一次性的镜像并且不需要拷贝或者增加文件的情况下,可以通过管道将指令直接传给docker来构建镜像,而不必编写Dockerfile文件。

echo -e 'FROM centos\nRUN echo "hello world"' | docker build -t myimage:latest - 在这种情况下,将不会有build context传入,能大大提高构建速度,但是不能使用COPY,ADD指令,因为这种方式没有build context,如果需要使用,就增加-f-参数

echo -e 'FROM centos\nRUN echo "hello world"' | docker build -t myimage:latest -f- .,最后的.是指定工作目录。甚至可以指定一个远程的git目录。

dockerignore 类似于.gitignore,.dockerignore用来排除将不必要的文件或者目录发送给build context,以提高build速度

指令的顺序 build过程中docker会使用缓存,从FROM指令开始,每一条指令在执行之前,docker会在使用了相同的指令的镜像中去寻找能否使用的缓存。 特别对于ADDCOPY指令,docker会进行文件和校验,但是最后修改时间和最后读取时间并不会纳入文件和,最终用计算出来的值和缓存的进行对比。 由于指令是按顺序执行的,每一条都会产生缓存,因此,指令的顺序会对缓存的利用率产生重要的影响。 在实际的使用场景中,构建镜像往往按照从底层再到应用层的顺序,比如一个nginx镜像,首先要选择底层系统,然后安装软件,最后进行一系列的环境设置,这是一个普遍适用的顺序,从保证缓存利用率的角度上来看,它遵循着最小改动频率的原则,越少改动的层,越先构建,这样尽管不同的镜像上安装了不同的服务,但只要使用了相同的底层环境,缓存也能最大化利用。
另外,不安装非必须的包,比如nginx镜像使用了alpinelinux做为底层环境,而不是centos,ubuntu之类,整个镜像只有16M。

减小镜像层数 只有RUNCOPYADD等指令会创建单独的层,其他的指令只会创建一个临时镜像,并不会增加镜像体积, 另外多条命令尽量用&&连接起来,不要使用多个RUN命令。

COPY还是ADD 从最终实现的功能的角度来说,两者一样,都是将文件添加到镜像中,只是在方式上有区别。 COPY只能将本地文件复制到镜像中,COPY可以将网络上的资源添加到镜像中,此外还能将本地压缩包解压到镜像中,这一点是ADD最常用的场景。一般COPY是最常用的命令,因为它更加可控。 为了保证缓存的利用率,如果有多个文件需要复制到镜像中,尽量分开COPY,这样某个文件发生了变化,只会导致其对应缓存失效,如果一次性复制,那么只要其中一个文件改变了,整个缓存就失效了。

ENTRYPOINT和CMD 两者都用来指定容器启动后的执行命令,另外还有命令行参数--entrypoint但在优先级上有差别

  • –entrypoint命令行参数,优先级最高,它会覆盖Dockerfile中的entrypoint和cmd
  • ENTRYPOINT指令,专门用来设置启动命令,CMD或者命令行中传入的命令将会作为它的参数
  • CMD用来设置容器的一些默认值,可以是命令也可以是参数,如果是参数将会传给entrypoint,它的优先级最低