使用docker部署一个python应用
使用到的代码如下所示,是一个典型的flask项目
1 | from flask import Flask |
同时将依赖放在同目录下的requirement.txt
的目录中
1 | $ cat requirements.txt |
接下来,制作Dockerfile
1 | # 使用官方提供的Python开发镜像作为基础镜像 |
在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 | docker inspect --format '{{ .State.Pid }}' |
这时,你可以通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:
1 | ls -l /proc/25686/ns |
可以看到,一个进程的每种 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 | docker run -v /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分层所示如下图