我最初是怎么接触到 Docker 的已经无法追溯,可能是大学期间闲来无事,不知道哪里看到了 Docker 的介绍,便在自己电脑上装了一个玩玩,在当时也没有意识到这项技术的应用性,感觉自己不怎么用得到,便再也没有进行更深入的理解。大三在腾讯实习时,导师是个容器大佬,做的也是容器相关的业务,而我早就把 Docker 的知识抛在脑后了。在这边我认识到容器是如何被用在生产环境中,通过 Kubernetes 来管理容器是多么的方便,自此对容器方面兴趣颇大。忙完秋招后,趁着这段比较闲的时间,我重新审视和学习容器这项技术,希望自己未来能在这方面有所建树。
Docker 存储结构
了解过 Docker 的人应该多多少少知道 Docker 是用一种分层的方式来存储镜像和容器,就像我在简历上不知廉耻地写上“了解 Docker”,被问到时只能支支吾吾回答一个“分层”。
程序猿一到工位,所有的人便都看着他笑,有的叫道,“程序猿,你头顶又变秃了!”他不回答,对黑框框敲出,“ docker run -itd *** /bin/bash ”,便拍出一击 Enter。他们又故意的高声嚷道,“你一定不知道 Docker 的底层原理。”程序猿睁大眼睛说,“你怎么这样凭空污人清白……”“什么清白?我前天亲眼见你论坛上跟人争辩,还百度查。”程序猿便涨红了脸,额上的青筋条条绽出,争辩道,“百度的不能算不知道……借鉴!……敲代码的事,能不借鉴么?”接连便是难懂的话,什么“分层存储”,什么“只读可写”之类,引得众人都哄笑起来:公司内外充满了快活的空气。
联合文件系统(UnionFS)
联合文件系统(UnionFS,Union File System)是一种分层、轻量级且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。虚拟文件系统( VFS , Virtual File System )可以简单理解为:包含所有层次中的所有文件,并展现给用户的一个文件系统。
如下图:
在这张图中,用户的增删改查操作只能在 VFS 上进行。实际上,左边的三层都可以进行权限定义,定义是否只读或可写,如果设为“只读”,那么在 VFS 上的操作不会影响到左边的文件,如果设置为“读写”,在操作 VFS 的同时,也会影响到原文件。默认情况下,最上层的是“读写”,其余均为“只读”。
UnionFS 是 Docker 镜像的基础,镜像可以通过分层来继承,基于基础镜像制作各种具体的应用镜像,不同的镜像可以共享一些基础的文件系统层,同时添加上自己独有的改动层,大大提高了存储效率。
Docker 18.03 中支持的联合文件系统可以在源代码中查到:
暂不做这部分的详细讲解,之后我会对常用的存储驱动做一个详细的学习。
使用 docker info 命令可以看到当前 docker 使用的是什么存储驱动,我这个使用的是 overlay2
分层存储
镜像是一系列只读层的统一视角,即通过统一文件系统将不同的层整合到一个虚拟文件系统中,在用户的角度,看到的是这个虚拟文件系统,从而隐藏了多层的存在。顾名思义,只读层不可以被修改。
暂时不要关心层(Layer)中的内容,只把一个层当作一个单位就好。
构建镜像时,前一层是后一层的基础,每一层构建完成后就不会再发生改变,后一层上的任何改变,都只发生在当前层。例如我删除某个文件,其实并不是真的删除了,只是在这一层标记该文件删除。运行容器时,虽然容器中看不到该文件,但该文件依然存在。所以在构建镜像时,应额外小心,每一层尽可能只包含该层需要的东西,任何额外的东西在该层构建结束前要清理掉。
容器是在镜像上添加一层读写层,这样我们可以对这个虚拟文件系统进行读写操作。但要注意的是,读写层的生命周期与容器的生命周期一致,容器被删除后,读写层也随之消亡,任何保存在读写层的数据都将丢失。因此,容器不应该向读写层写入任何数据,所有文件写入操作,都应该使用数据卷(volume)或绑定宿主机目录。在启动容器时可以通过 –mount 或 -v 来进行操作。
镜像和容器的关系,用面向对象的思想来类比,可以看作类与对象的关系。不算静态成员的情况下,在对象中做出的修改(读写层修改)不能被反映到类中(镜像),但 Docker 中有方法可以将读写层的修改保存,即 docker commit 命令。
commit 后的镜像继承了原镜像的层级,把容器的读写层转化为只读层,比运行容器的基础镜像多了一层,用面向对象的话来说,commit 后产生的镜像,是原镜像的“子类”,继承了原镜像的特性并添加了自己独有的其他特性。
但要尽量避免使用 commit ,之前提过,后一层上的任何改变,都只发生在当前层。如果长期使用 commit 制作镜像及后期增删改查,在虚拟文件系统上看起来整洁许多,但实际上之前的文件都存在,会使得镜像越来越臃肿。除此之外,使用了 commit 制作镜像,除了当事人以外,别人无从得知执行过什么命令,即使是当事人也难免会遗忘一些操作。所以制作镜像推荐使用 Dockerfile。
注意:根据 VFS 那张图,读写层实际上不是用户所面对的文件系统,在读写层进行的操作,其实是在虚拟文件系统进行的操作,但对虚拟文件系统的操作会被映射到读写层上。所以不抬杠地讲,我们操作了读写层。
Dockerfile 部分也暂不详细说明,但要强调一些与我们讨论的存储结构有关的东西。Dockerfile 中每一个 RUN 命令都是一次 commit ,一个 RUN 命令执行时,首先先以当前镜像状态启动一个容器,在容器上进行读写操作,然后 commit ,退出并删除容器,再进入下一步构建操作。即一个 Dockerfile 文件中有多个 RUN 操作时,Docker 在不停地执行”运行容器”- >”执行命令”- >”commit”- >”退出并删除容器”这样的操作。而上面我们提到,过多的层次会导致镜像的臃肿,所以要尽可能地控制 RUN 的次数,在一次 RUN 操作中,完成所有需要完成的工作,而且在最后要删除一些无关依赖,否则这些依赖将永远地留在这一层,使镜像变地更加臃肿。
总结
- 后一层上的任何改变,都只发生在当前层;
- 读写层的生命周期与容器的生命周期一致;
- docker commit 命令可以将容器读写层转化为只读层添加在基础镜像上,成为一个新的镜像;
- 基于第 1 条,在读写层进行删除操作,不会使镜像或容器体积减少;
- 基于第 2 条,进行数据存储需使用 volume 或挂载宿主机;
- 基于第 1、3、4 条,commit 命令有导致镜像臃肿的风险;
- RUN 命令是一次 commit 操作
- 基于第 1、6、7 条,应尽量在一次 RUN 中完成当前层次的全部操作,最后要记得删除无关依赖;
- 补充:读写层实际上不是用户所面对的文件系统,在读写层进行的操作,其实是在虚拟文件系统进行的操作,但对虚拟文件系统的操作会被映射到读写层上。