「层」这个概念在软件领域非常常见。通过「分层」,可以把不同领域的问题隔离开,单独解决。架构上有MVC,即模型、视图、控制器;运行环境上,可以分为CPU/操作系统/运行时;编写代码时,可以分为方法/类/包等等。
容器技术本质上也是创造操作系统之上的「层」,它隔离开其内部的程序和外部的宿主,保证双方最大限度的解耦。
内核空间和用户空间
操作系统中,以Linux为例,宏观上存在两层——内核层和用户层。内核层主要负责一些系统级操作,内存管理、IO操作、进程管理、文件系统等等。用户层区别于内核层,只能通过叫做「系统调用」的方式与内核层交互。
在Linux的无数发行版本中(Ubuntu/CentOS/Fedora/…),内核层都来自于一套内核代码。但是在用户层,每个发行版都打包了各自一套软件库。尽管每个发行版可以自定义,编译出不同的内核,但和内核通讯的「系统调用」在不同发行版中却相差无几。这保证了Linux上的程序可以兼容不同的发行版。(同一容器也因此兼容不同的宿主)。
一个程序运行在一个Linux操作系统中时,一部分会依赖于系统调用,完成一些内核级别的操作;另一部分会依赖用户层的软件库,但这些依赖库,最终又追溯到不同的系统调用。所以在一个程序运行时,「程序/依赖库/内核」三层又可以归为「程序/内核」两层。
从文件系统的维度来看,程序本身就是文件。要么是二进制文件,要么是可解释代码文件。在创建容器时,内核是共享的,所以只需要把程序文件拷贝到容器里,然后在容器里运行程序。
Docker用镜像(image)来管理文件,一个镜像中包含了应用程序和其依赖的程序库。容器启动后,会自动「拷贝」镜像中的文件到容器「本地」,然后运行。
UnionFS和Docker镜像
Linux中的文件系统有些功能设计的很巧妙,比如挂载(mount),允许把一个外部的文件系统(如CD,USB)以本地路径的形式去访问。例如放置一张CD后,我们可以通过访问/mnt/cdrom
来查看CD中的内容。
Union File System(UnionFS)则设计的更加巧妙。在「挂载」功能的基础上,UnionFS允许在本地路径挂载多个目标目录。例如,我有两个文件夹a, b,结构如下:
/tmp
--1.txt
/var
--2.txt
/opt
--2.txt
UnionFS允许把它们一起挂载到同一个本地路径/mnt/unions
,其最终结构变为:
/aufs
--1.txt
--2.txt
--3.txt
在挂载时,我可以选择新文件和新增的修改写入到哪一个目标目录。如果把修改写入不同于源的目标路径,UnionFS支持增量存储,高效利用存储空间。
UnionFS技术在Docker容器技术中的运用,首先体现在「镜像(image)」和「容器(container)」上。每一个Docker镜像都是一个只读的文件夹,当在容器中运行镜像时,Docker会自动挂载镜像中的、只读的文件目录,以及宿主机上一个临时的、可写的文件目录。容器中所有文件修改,都会写入这个临时目录里去。容器终结后,这个临时目录也会被相应删除。
Docker中的「层」
UnionFS在Docker中的另一个应用,还体现在镜像本身上。
容器运行时,在挂载的临时目录中如果写入数据,还可以选择把这部分数据从临时目录中保存下来,这样就生成了一个新的镜像。Docker在保存新镜像时,会把它们两部分——原镜像和增量——都保存在新镜像中。其中新的增量部分,就被称为「层(layer)」。
事实上,我们的原始镜像,也可能是由另一个镜像和另一个「层」组成。如此追溯下去,会发现最开始的镜像,是「空镜像」。所以,Docker镜像的结构,本质上就是一层层数据增量的堆叠。
Dockerfile
同一镜像,有时代码或配置改变,可能需要重新构建。为了提高效率,我们要设法让这些可重复执行的代码尽量自动化。在虚拟机时代,因为依赖较多,我们不得不借助于诸如Ansible、Chef之类的自动化部署工具。这样的方式在容器中虽然也可行,不过Docker提供了一种更加巧妙的解决方案——Dockerfile。
编写Dockerfile时,有一套简单的语法,包括一些基本的命令。例如FROM
选择基础镜像,COPY
拷贝文件,RUN
执行命令等。每一行语句,可以表示镜像中的一层。执行一行语句时,Docker会基于上一行生成的镜像,运行一个容器,然后在容器中执行这行语句代表的命令,随后把执行这行语句所产生的「层」保存下来,生成新的镜像。
这是一个简单Node.Js程序的Dockerfile文件:
FROM alpine
RUN apk update && apk upgrade \
&& apk add --update --no-cache nodejs npm
RUN rm -rf /var/cache/apk/*
WORKDIR /app
COPY src/package.json /app
RUN npm i
COPY src /app
EXPOSE 80
ENTRYPOINT ["/bin/sh", "-c", "node index.js"]
在执行镜像构建时,你会看到类似这样的输出:
...
Step 3/9 : RUN rm -rf /var/cache/apk/*
---> Running in 8b4c21e41f5d
Removing intermediate container 8b4c21e41f5d
---> df5172370dc1
Step 4/9 : WORKDIR /app
---> Running in 064c9126723f
Removing intermediate container 064c9126723f
---> bad0bb1f5137
Step 5/9 : COPY app/package.json /app
...
可以看出,根据文件行数(10),Docker在构建时分为了9步(第一行是base image)。每一步都会运行一个容器、生成一个镜像。
在通过Dockerfile构建镜像的过程中,产生的这些临时镜像并不会被扔掉。Docker会在下次执行构建时,会自动根据一些规则去判断这行语句是否会和上次构建结果不同,如果相同则直接使用上次的缓存,而不是再次构建。基于这项技术,Docker镜像在构建时可以非常高效。
例如,上述同样的构建我重复执行时,输出会变成这样:
...
Step 3/9 : RUN rm -rf /var/cache/apk/*
---> Using cache
---> df5172370dc1
Step 4/9 : WORKDIR /app
---> Using cache
---> bad0bb1f5137
Step 5/9 : COPY app/package.json /app
---> Using cache
---> 46aa3717f438
...
总结
本文通过介绍介绍「内核空间」和「用户空间」,从文件系统的角度再次理解容器本质。接着从UnionFS技术,引出Docker在容器实践中的实现原理。最后,简单介绍了Dockerfile和它的缓存构建机制。