docker应用原理

使用docker部署一个python应用

使用到的代码如下所示,是一个典型的flask项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)

同时将依赖放在同目录下的requirement.txt的目录中

1
2
$ cat requirements.txt
Flask

接下来,制作Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用官方提供的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"]

在Dockerfile中,使用一些原语来处理逻辑流程,且执行顺序是由上往下执行

值得一提的是,通常还会看到一个叫ENTRYPOINT的原语,实际上,它和CMD都是docker进程启动时所必需的的参数,完整的格式是ENTRYPOINT CMD,但是,在默认情况下,docker会提供一个默认的ENTRYPOINT参数,即/bin/bash -c,所以在这个例子中,实际运行的是/bin/bash -c python app.py

加下来,就可以制作镜像了

1
$ docker build -t helloworld .

-t是指加一个tag,即取一个名字,docker build会依次执行dockerfile文件,并且每一句原语会生成一层镜像。即使原语本身没有明显的修改文件操作,它回应的层也是会存在的,只不过在外界看来,这个层是空的。

exec命令进入容器内部

通过docker exec命令可以进入一个正在运行的docker容器中。

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

通过如下指令,你可以看到当前正在运行的 Docker 容器的进程号(PID)是 25686:

1
2
$ docker inspect --format '{{ .State.Pid }}' 
4ddf4638572d25686

这时,你可以通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:

1
2
3
4
5
6
7
8
9
10
$ 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 文件上。这就相当于’HODL’住了所有的Namespace,那么就可以实现进入一个已经存在的Namespace中了。

这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。

简单说下所依赖的原理,其实是依赖于setns()的Linux系统调用,具体用法可以百度或Google。它的作用就是加入一个进程的Namespace中。

docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

由于使用了联合文件系统,容器对镜像的任何操作,都会被操作系统先复制到可读写层,然后再修改,这就是所谓的Copy On Write。当然,docker commit不会提交Init层的内容。

Volume机制

volume的存在在于解决容器中文件与数据在宿主机上的备份。而volume的存在,允许将宿主机上指定的文件或目录挂载到容器中进行读取和修改。

在docker中,有两种申明方式,如下所示:

1
2
$ docker run -v /test ...
$ docker run -v /home:/test ...

这两种命令的本质是相同的,都是把一个宿主机的目录挂载到容器的/test目录中。只是在第一种情况下,由于没有申明宿主机目录,docker会默认在宿主机创建一个临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data,然后将它挂载到容器的/test目录下。而第二种情况,是直接将宿主机的/home目录挂载到容器的/test目录下。

由于当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff目录下,在容器进程启动后,它们会被联合挂载在/var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

注意:这里提到的 “ 容器进程 “,是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

同时,/test目录中的内容,也不会被docker commit所提交。原因在于,容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

以上,就是docker volume的核心原理了。

总结一下,将rootfs分层所示如下图

docker分层

参考

  1. https://time.geekbang.org/column/article/18119

  2. https://docs.docker.com/engine/reference/builder/

-------------本文结束感谢您的阅读-------------