深入理解Docker容器镜像
1 容器是什么:特殊的进程
容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用“装”起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰;而被装进集装箱的应用,也可以被方便地搬来搬去,这也是 PaaS 最理想的状态。
容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。
Docker 容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数
。这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。
障眼法(PID Namespace)解释:
- 本来,每当我们在宿主机上运行了一个 /bin/sh 程序,操作系统都会给它分配一个进程编号,比如 PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。所以 PID=100,可以粗略地理解为这个 /bin/sh 是我们公司里的第 100 号员工,而第 1 号员工就自然是比尔 · 盖茨这样统领全局的人物。
- 而现在,我们要通过 Docker 把这个 /bin/sh 程序运行在一个容器当中。这时候,Docker 就会在这个第 100 号员工入职时给他施一个“障眼法”,让他永远看不到前面的其他 99 个员工,更看不到比尔 · 盖茨。这样,他就会错误地以为自己就是公司里的第 1 号员工。
- 这种机制(Namespace),其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第 100 号进程。
除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼法”操作。
2 容器的隔离与限制:Namespace&Cgroups(障眼法)
2.1 Namespace:隔离
Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。但对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。
2.2 Cgroups:限制进程资源
Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能
- Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
案例:限制某个进程的CPU使用
# 限制cpu使用率最多为20%
# 每 100 ms 的时间里,被该控制组限制的进程只能使用 20 ms 的 CPU 时间
$ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
# 配置限制的进程ID
$ echo 226 > /sys/fs/cgroup/cpu/container/tasks
3 容器镜像是如何组成的
3.1 创建一个容器所需步骤
Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:
- 启用 Linux Namespace 配置;
- 设置指定的 Cgroups 参数;
- 切换进程的根目录(Change Root)。
这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别。
- chroot是只改变即将运行的 某进程的根目录。
- pviot_root主要是把整个系统切换到一个新的root目录,然后去掉对之前rootfs的依赖,以便于可以umount 之前的文件系统(pivot_root需要root权限)
3.2 容器中的文件系统rootfs:只包含文件及目录
①概念
在第二部分我们已经知道了,可以通过Namespace来隔离容器与宿主机。但是Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。
- 这就导致,我们挂载之后看到的文件依然是宿主机的文件(继承宿主机文件)。我们希望每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。
- 解决办法:通过chroot 或者pivot_root命令。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。
# 告诉操作系统,我们将使用$HOME/test目录作为/bin/bash进程的根目录
chroot $HOME/test /bin/bash
- chroot是只改变即将运行的 某进程的根目录。
- pviot_root主要是把整个系统切换到一个新的root目录,然后去掉对之前rootfs的依赖,以便于可以umount 之前的文件系统(pivot_root需要root权限)
这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等:
需要明确的是, rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核
。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
- rootfs 只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”
- 实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核
这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
②组成
最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。
1. 只读层(read only+whiteout)
它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout白障)。
2. 可读写层:容器中写操作会以增量方式写入该层
它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。
- 写操作:而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。
- 容器的rootfs,比如ubuntu镜像都是以只读方式挂载,那么如何实现修改?
- Copy on write:拷贝只读层的文件到可读写层,然后修改。后续可以docker commit提交,新增一层layer。相同文件名,上层会覆盖下层。
- 删除操作:AuFS(用于联合挂载,比如:我把A、B目录合并起来挂载为一个C目录) 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。
所以,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量 rootfs 的好处。
3. Init 层:/etc/hosts文件等,只对当前容器有效
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。
- 可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。
- 所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。
4 体验整个容器过程
4.1 Dockerfile构建并启动容器
①编写Dockerfile
- app.py
from flask import Flask
import socket
import os
app = Flask(__name__)
@app.route('/')
def hello():
html = "<h3>Hello {name}!</h3>" \
"<b>Hostname:</b> {hostname}<br/>"
return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())
if __name__ == "__main__":
app.run(host='0.0.0.0', port=80)
- requirements.txt
Flask
- Dockerfile
# 使用官方提供的Python开发镜像作为基础镜像
FROM python:2.7-slim
# 将工作目录切换为/app
WORKDIR /app
# 将当前目录下的所有内容复制到/app下
ADD . /app
# 使用pip命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt
# 允许外界访问容器的80端口
EXPOSE 80
# 设置环境变量
ENV NAME World
# 设置容器进程为:python app.py,即:这个Python应用的启动命令
CMD ["python", "app.py"]
该容器启动之后(run),会直接执行python app.py命令
②docker build 构建镜像
所需文件结构:
# -t 的作用是给这个镜像加一个 Tag,即:起一个好听的名字
docker build -t helloworld .
# 查看build出来的镜像
docker images | grep helloworld
③docker run运行容器
# 运行容器
docker run -p 4000:80 helloworld
浏览器访问容器:
④ docker commit & docker push
有时,我们想将在原来镜像的基础上增添加自己的修改,那么则需要使用docker commit命令
$ docker exec -it 26df5f908a6f /bin/sh
# 在容器内部新建了一个文件
root@26df5f908a6f:/app# touch test.txt
root@26df5f908a6f:/app# exit
#将这个新建的文件提交到镜像中保存
$ docker commit 26df5f908a6f ziyi/helloworld:v2
下一次使用,只需要拉取v2版本的镜像即可。
问题解析:
1. docker exec 是怎么做到进入容器里的呢(启进程共享namespace)
Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。
# containerID
$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d
25686
# 查看该进程对应NameSpace的文件
ls -l /proc/25686/ns
# 命令结果:
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]
一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。
这也就意味着一个进程,可以选择加入到某个进程已有的 Namespace 当中(可以看到该容器下的所有文件),从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。
- setns() 的 Linux 系统调用,实现进入namespace
2. 容器与宿主机的文件如何相互访问(Volume)
Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。
# 运行时,通过-v参数指定挂载目录
docker run -v /home:/test ...
我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。
更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。
- 注意:这里提到的"容器进程",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。
绑定挂载原理:
如果大家有了解Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。
正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。
3. rootfs是以只读方式挂载,那么我们如何修改镜像
rootfs:
- 读写层:运行容器后进行的增删改,都会记录到该层
- init层:存放/etc/hosts这种只对当前容器有效的文件
- 只读层:镜像原本的文件
上面的读写层通常也称为容器层,下面的只读层称为镜像层,所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。知道这一点,就不难理解镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。
任何镜像里的内容,都属于只读层。commit之后的东西当然也属于只读层。