随着容器发布越来越流行,持续交付最后一公里的产物,逐渐由之前的代码包变成了容器镜像。然而,容器镜像构建与传统的代码构建有很多不同之处,也增加了很多新鲜的技术领域和内容需要我们去学习。
所以,今天我们就一起来聊聊容器镜像构建的那些事儿,打通容器镜像构建的各个环节。
在虚拟机时代就有镜像的说法,当我们创建一个虚拟机时,通常会去网上下载一个ISO格式的虚拟机镜像,然后经过VirtualBox或者VMware加载,最终形成一个包含完整操作系统的虚拟机实例。
而容器镜像也是类似的意思,只不过它不像虚拟机镜像那么庞大和完整,它是一个只读的模板,一个独立的文件系统,包含了容器运行初始化时所需要的数据和软件,可以重复创建出多个一模一样的容器。
容器镜像可以是一个完整的Ubuntu系统,也可以是一个仅仅能运行一个sleep进程的独立环境,大到几G小到几M。而且。Docker的镜像是分层的,它由一层一层的文件系统组成,这种层级的文件系统被称为UnionFS。下图就是一个Ubuntu15.04的镜像结构。
图中的镜像部分画了一个锁的标记,它表示镜像中的每一层都是只读的,只有创建容器时才会在最上层添加一个叫作Containerlayer的可写层。容器运行后的所有修改都是在这个可写层进行,而不会影响容器镜像本身。
因为这一特性,创建容器非常节省空间,因为一台宿主机上基于同一镜像创建的容器只有这一份镜像文件系统,每次创建多出来的只是每个容器与镜像diff的磁盘空间。而虚拟机每增加一个实例,都会在宿主机上占用一个完整的镜像磁盘空间。
了解了什么是容器的镜像,以及与虚拟机镜像的区别后,可以清楚地看到:容器都是基于镜像产生的,没有镜像就没有容器。那么,我们应该怎么创建一个镜像呢?
Docker Hub上提供了非常多的常用镜像,比如Ubuntu镜像,CentOS镜像,或者仅仅是一个包含Java程序的镜像,你可以通过dockerpull命令把它们下载到本地使用。当然你也可以自己在本地通过docker build制作镜像。
如果你想要修改或者加工这些镜像,可以找到文件系统中对应的laver目录,然后进行修改。按照这种方式操作的话,如果我要添加一个文件还好说,但如果要安装一个软件,那就要拷贝一堆文件到各个目录中,相当麻烦。
如果真要这样操作的话,容器镜像也就不会有今天如此庞大的用户群体了。Docker帮我们解决这个问题的方式,就是提供了Dockerfile。
简单来说,Dockerfile第一个好处就是,可以通过文本格式的配置文件描述镜像,这个配置文件里面可以运行功能丰富的指令,你可以通过运行docker build 将这些指令转化为镜像。
比如,我要更改Ubuntu镜像安装一个Vim编辑器,那么我的Dockerfile可以这样写:
FROM ubuntu
RUN apt-get install vim-y
其中,FROM指令说明我们这个镜像需要继承Ubuntu镜像,RUN指令是需要在镜像内运行的命令。
因为Ubuntu镜像内包含了apt-aet包管理器,所以相当于启动了一个Ubuntu镜像的容器。
然后在这个容器内部安装Vim。这期间会产生一个新的layer,这个新的layer包含安装Vim所需的所有文件。
运行dockerbuild后会产生一个新镜像,我们可以通过dockertag给这个新镜像起一个名字,然后dockerpush到仓库,就可以从仓库下载这个镜像了,后续的其他镜像也可以继承这个镜像进行其他改动。
镜像就是这样通过Dockerfile一层一层的继承,不断增加新的内容,直到变成你想要的样子。
Dockerfile的另外一个好处就是可以描述镜像的变化,通过一行命令就可以直观描述出环境变更的过程,如果再通过git进行版本控制,就可以让环境的管理更加可靠与简单。
了解了Dockerfile之后,你就可以利用它进行代码更新了,最主要的步骤就以下三步:
1.将代码包下载到构建服务器;
2.通过Dockerfile的ADD命令将代码包加载到容器里;
3.Docker build完成新的镜像。
原则上,我们总是希望能够让镜像保持小巧、精致,这样可以让镜像环境更加清晰,不用占用过多空间,下载也会更快。
那么,如何做好镜像的优化呢?你可以从3个方面入手:
1.选择合适的Base镜像;
2.减少不必要的镜像层的产生;
3.充分利用指令的缓存。
为什么第一条说要选择合适的Base镜像呢?因为,这是最直接和有效的方式
举个例子就更好理解了。比如,我只想运行一个Java进程,那么镜像里就只有这个Java进程所需的环境就可以了,而没必要使用一个完整Ubuntu或者CentOS镜像。
关于第二点,减少不必要的镜像层,是因为使用Dockerfile时,每一条指令都会创建一个镜像层,继而会增加整体镜像的大小。
比如,下面这个Dockerfile:
FROM ubuntu
RUN apt-get install vim -y
RUN apt-get remove vim -y
虽然这个操作创建的镜像中没有安装Vim,但是镜像的大小和有Vim是一样的。原因就是,每条指令都会新加一个镜像层,执行installvim后添加了一层,执行removevim后也会添加一层,而这一删除命令并不会减少整个镜像的大小。
因此,当我们编写Dockerfile时,可以合并多个RUN指令,减少不必要的镜像层的产生,并且在之后将多余的命令清理干净,只保留运行时需要的依赖。就好比我买了两斤橘子,只需要把橘子肉保留下来就好,橘子皮可以直接丢掉,不用保留在房间里。
Dockerfile构建的另外一个重要特性是指令可以缓存,可以极大地缩短构建时间。因为之前也说了,每一个RUN都会产生一个镜像,而Docker在默认构建时,会优先选择这些缓存的镜像,而非重新构建一层镜像。比如,一开始我的Dockerfile如下:
FROM ubuntu
RUN aptget install vim-y
使用一段时间之后,我发现需要添加新的特性,Dockerfile变成了如下的样子:
FROM ubuntu
RUN apt-get install vim -y
ADD java/usr/local/java
重新build时,前面安装Vim那步可以使用缓存,而不需要重新运行。当我们需要构建一个新镜像时,这个特性非常有用,可以快速跳过前面构建通过的步骤,而不需要每次都重新构建,尤其适用于在Docker里面编译一些大型软件的情况,可以帮你节省大量时间。
当我们学会了使用Dockerfile构建镜像之后,下一步就是如何搭建构建环境了。搭建构建环境,最简单的方式就是在虚拟机上安装DockerDaemon,然后根据你所使用的语言提供的Docker客户端与DockerDaemon进行交互,完成构建。
但是,我们推崇构建环境容器化,因为我们的构建环境可能除了Docker外,还会有一些其他的依赖,比如编程语言、Git等等。
上面我也分析了Docker镜像的各种好处,那如果环境还没有实现容器化,是不是就有点说不过去了?
接下来,我们就看看构建环境如何实现容器化。一般情况下,用容器来构建容器镜像有两种方式:
1.Docker Out Of Docker(DooD)
2.Docker In Docker(DinD)
这种方式比较简单,首先在虚拟机上安装DockerDaemon,然后将你的构建环境镜像下载下来启动一个容器。
在默认情况下,Docker客户端都是通过/var/run/dockersock与DockerDaemon进行通信。我们在创建Docker实例时,把外部的/var/run/dockersockmount到容器内部,这样容器内的Docker客户端就可以与外部的DockerDaemon进行通信了。
另外,你还需要注意权限问题,容器内部的构建进程必须拥有读取/var/run/dockersock的权限,才可以完成通信过程。
这种方式的好处很明显,我们可以将镜像构建环境打包复用,对宿主机来说,只要安装Docker Daemon就可以了。但是这种方式的缺点是,内部的环境必须要与外部保持一致,不然就会报错,比如缺少库文件。此外,如果构建容器时不小心把DockerDaemon搞挂了,那么就会影响该宿主机上的其他容器。
为了解决这个问题,我们是否可以在容器内部使用DockerDaemon呢?
Docker In Docker,就是在容器内部启动一个完整的DockerDaemon进程,然后构建工具只需要和该进程交互,而不影响外部的Docker进程
默认情况下,容器内部不允许开启DockerDaemon进程,必须在运行容器的时候加上--privileged参数,这个参数的作用是真正取得root的权限。另外,Docker社区官方提供了一个docker:dind镜像可以直接拿来使用。
这样一来,容器内部DockerDaemon就和容器外部的DockerDaemon彻底分开了,容器内部就是一个完整的镜像构建环境,是不是很神奇。
然而DinD也不是百分之百的完美和健壮,它也有一些关于安全和文件系统的问题。此外,因为每个容器都有独立的/var/lib/docker用来保存镜像文件,一旦容器被重启了,这些镜像缓存就消失了,这可能会影响我们构建镜像的性能。
通过以上两个方法,你就可以做到用容器来构建容器镜像了。
今天,我针对容器镜像构建的那些事儿,和你进行了讨论。
首先,容器镜像是一个独立的文件系统,它包含了容器运行初始化时所需要的数据或软件。Docker容器的文件系统是分层的,只读的,每次创建容器时只要在最上层添加一个叫作Containerlayer的可写层就可以了。这种创建方式不同于虚拟机,可以极大的减少对磁盘空间的占用。
其次,Docker提供了Dockerfile这个可以描述镜像的文本格式的配置文件。你可以在Dockerfile中运行功能丰富的指令,并可以通过dockerbuild将这些指令转化为镜像
再次,基干Dockerfile的特性,我分享了Dockerfile镜像构建优化的三个建议包括:选择合适的Base镜像、减少不必要的镜像层产生,以及善用构建缓存。
最后,用容器来构建容器镜像,主要有DooD和DinD两种方案。这两种方案,各有优劣,你可以根据自身情况去选择。