这一次,我要用+Docker+部署一个用+Python+编写的+Web+应用。这个应用的代码部分(app.py)很是简单:html
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)
在这段代码中,我使用+Flask+框架启动了一个 Web 服务器,而它惟一的功能是:若是当前环境中有“NAME”这个环境变量,就把它打印在“Hello”后,不然就打印“Hello world”,最后再打印出当前环境的 hostname。 这个应用的依赖,则被定义在了同目录下的 requirements.txt 文件里,内容以下所示:node
$ cat requirements.txt Flask
而将这样一个应用容器化的第一步,是制做容器镜像。 不过,相较于我以前介绍的制做 rootfs 的过程,Docker 为你提供了一种更便捷的方式,叫做 Dockerfile,以下所示。python
# 使用官方提供的 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 的设计思想,是使用一些标准的原语(即大写高亮的词语),描述咱们所要构建的 Docker 镜像。而且这些原语,都是按顺序处理的。 好比 FROM 原语,指定了“python:2.7-slim”这个官方维护的基础镜像,从而免去了安装 Python 等语言环境的操做。不然,这一段咱们就得这么写了:docker
FROM ubuntu:latest RUN apt-get update -yRUN apt-get install -y python-pip python-dev build-essential ...
其中,RUN 原语就是在容器里执行 shell 命令的意思。 而 WORKDIR,意思是在这一句以后,Dockerfile 后面的操做都以这一句指定的 /app 目录做为当前目录。 因此,到了最后的 CMD,意思是 Dockerfile 指定 python app.py 为这个容器的进程。这里,app.py 的实际路径是 /app/app.py。因此,CMD[“python”,“app.py”]等价于 "docker run python app.py"。shell
另外,在使用 Dockerfile 时,你可能还会看到一个叫做 NTRYPOINT 的原语。实际上,它和 CMD 都是 Docker 容器进程启动所必需的参数,完整执行格式是:“ENTRYPOINT CMD”。flask
可是,默认状况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c。因此,在不指定 ENTRYPOINT 时,好比在咱们这个例子里,实际上运行在容器里的完整进程是:/bin/sh+-c “python app.py”,即 CMD 的内容就是 ENTRYPOINT 的参数。 备注:基于以上缘由,咱们后面会统一称 Docker+容器的启动进程为 ENTRYPOINT,而不是 CMD。ubuntu
须要注意的是,Dockerfile 里的原语并不都是指对容器内部的操做。就好比 ADD,它指的是把当前目录(即 Dockerfile 所在的目录)里的文件,复制到指定容器内的目录当中。小程序
读懂这个 Dockerfile 以后,我再把上述内容,保存到当前目录里一个名叫“Dockerfile”的文件中:bash
$ ls Dockerfile app.py requirements.txt
接下来,我就可让+Docker+制做这个镜像了,在当前目录执行:服务器
$ docker build -t helloworld .
其中,-t 的做用是给这个镜像加一个 Tag,即:起一个好听的名字。docker build 会自动加载当前目录下的 Dockerfile 文件,而后按照顺序,执行文件中的原语。而这个过程,实际上能够等同于 Docker 使用基础镜像启动了一个容器,而后在容器中依次执行 Dockerfile 中的原语。
须要注意的是,Dockerfile 中的每一个原语执行后,都会生成一个对应的镜像层。即便原语自己并无明显地修改文件的操做(好比,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。 docker build 操做完成后,我能够经过 docker images 命令查看结果:
$ docker image ls REPOSITORY TAG IMAGE ID helloworld latest 653287cdf998
接下来,我使用这个镜像,经过+docker+run+命令启动容器:
$ docker run -p 4000:80 helloworld
在这一句命令中,镜像名+helloworld+后面,我什么都不用写,由于在+Dockerfile+中已经指定了+CMD。不然,我就得把进程的启动命令加在后面:
$ docker run -p 4000:80 helloworld python app.py
容器启动以后,我可使用+docker+ps+命令看到:
$ docker ps CONTAINER ID IMAGE COMMAND CREATED 4ddf4638572d helloworld "python app.py" 10 seconds ago
同时,我已经经过 -p 4000:80 告诉了 Docker,请把容器内的 80 端口映射在宿主机的 4000 端口上。 这样作的目的是,只要访问宿主机的 4000 端口,我就能够看到容器里应用返回的结果:
$ curl http://localhost:4000 <h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>
不然,我就得先用 docker inspect 命令查看容器的 IP 地址,而后访问“http://< 容器 IP 地址 >:80”才能够看到容器内应用的返回。
至此,我已经使用容器完成了一个应用的开发与测试,若是如今想要把这个容器的镜像上传到 DockerHub 上分享给更多的人,我要怎么作呢?
为了可以上传镜像,我首先须要注册一个 Docker Hub 帐号,而后使用 docker login 命令登陆。 接下来,我要用 docker tag 命令给容器镜像起一个完整的名字:
$ docker tag helloworld geektime/helloworld:v1
注意:你本身作实验时,请将 "geektime" 替换成你本身的 Docker Hub 帐户名称,好比 zhangsan/helloworld:v1 其中,geektime 是我在 Docker Hub 上的用户名,它的“学名”叫镜像仓库(Repository);“/”后面的 helloworld 是这个镜像的名字,而“v1”则是我给这个镜像分配的版本号。 而后,我执行 docker+push:
$ docker push geektime/helloworld:v1
这样,我就能够把这个镜像上传到 Docker Hub 上了。 此外,我还可使用 docker commit 指令,把一个正在运行的容器,直接提交为一个镜像。通常来讲,须要这么操做缘由是:这个容器运行起来后,我又在里面作了一些操做,而且要把操做结果保存到镜像里,好比:
$ docker exec -it 4ddf4638572d /bin/sh # 在容器内部新建了一个文件 root@4ddf4638572d:/app# touch test.txt root@4ddf4638572d:/app# exit # 将这个新建的文件提交到镜像中保存 $ docker commit 4ddf4638572d geektime/helloworld:v2
这里,我使用了 docker exec 命令进入到了容器当中。在了解了 Linux Namespace 的隔离机制后,你应该会很天然地想到一个问题:docker exec 是怎么作到进入容器里的呢?
实际上,Linux Namespace 建立的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,而且是以一个文件的方式存在。
好比,经过以下指令,你能够看到当前正在运行的+Docker+容器的进程号(PID)是 25686:
$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d 25686
这时,你能够经过查看宿主机的 proc 文件,看到这个 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 文件上。
有了这样一个能够“hold+住”全部 Linux Namespace 的文件,咱们就能够对 Namespace 作一些颇有意义事情了,好比:加入到一个已经存在的 Namespace 当中。
这也就意味着:一个进程,能够选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。 而这个操做所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。它的调用方法,我能够用以下一段小程序为你说明:
#define _GNU_SOURCE #include <fcntl.h> #include <sched.h> #include <unistd.h> #include <stdlib.h> #include <stdio.h> #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0) int main(int argc, char *argv[]) { int fd; fd = open(argv[1], O_RDONLY); if (setns(fd, 0) == -1) { errExit("setns"); } execvp(argv[2], &argv[2]); errExit("execvp"); }
正如上所示,当咱们执行 ifconfig 命令查看网络设备时,我会发现能看到的网卡“变少”了:只有两个。而个人宿主机则至少有四个网卡。这是怎么回事呢?
实际上,在 setns() 以后我看到的这两个网卡,正是我在前面启动的 Docker 容器里的网卡。也就是说,我新建立的这个 /bin/bash 进程,因为加入了该容器进程(PID=25686)的 Network Namepace,它看到的网络设备与这个容器里是同样的,即:/bin/bash 进程的网络设备视图,也被修改了。
而一旦一个进程加入到了另外一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有所体现。
在宿主机上,你能够用 ps 指令找到这个 set_ns 程序执行的/bin/bash 进程,其真实的 PID 是 28499:
# 在宿主机上 ps aux | grep /bin/bash root 28499 0.0 0.0 19944 3612 pts/0 S 14:15 0:00 /bin/bash
这时,若是按照前面介绍过的方法,查看一下这个 PID=28499 的进程的 Namespace,你就会发现这样一个事实:
$ ls -l /proc/28499/ns/net lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281] $ ls -l /proc/25686/ns/net lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]
在 /proc[PID]/ns/net 目录下,这个 PID=28499 进程,与咱们前面的 Docker 容器进程(PID=25686)指向的 Network Namespace 文件彻底同样。这说明这两个进程,共享了这个名叫+net:[4026532281]的 Net: work Namespace。 此外,Docker 还专门提供了一个参数,可让你启动一个容器并“加入”到另外一个容器的 Network Namespace 里,这个参数就是 -net,好比
$ docker run -it --net container:4ddf4638572d busybox ifconfig
这样,咱们新启动的这个容器,就会直接加入到 ID=4ddf4638572d 的容器,也就是咱们前面的建立的 Python 应用容器(PID=25686)的 Network Namespace 中。因此,这里 ifconfig 返回的网卡信息,跟我前面那个小程序返回的结果如出一辙,你也能够尝试一下。
而若是我指定–net=host,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的“隔离墙”,因此,它会和宿主机上的其余普通进程同样,直接共享宿主机的网络栈。这就为容器直接操做和使用宿主机网络提供了一个渠道。
转了一个大圈子,我实际上是为你详细解读了 docker exec 这个操做背后,Linux Namespace 更具体的工做原理。
这种经过操做系统进程相关的知识,逐步剖析 Docker 容器的方法,是理解容器的一个关键思路,但愿你必定要掌握。
如今,咱们再一块儿回到前面提交镜像的操做 docker commit 上来吧。 docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。固然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。
而因为使用了联合文件系统,你在容器里对镜像 rootfs 所作的任何修改,都会被操做系统先复制到这个可读写层,而后再修改。这就是所谓的:Copy-on-Write。
而正如前所说,Init 层的存在,就是为了不你执行 docker commit 时,把 Docker 本身对 /etc/hosts 等文件作的修改,也一块儿提交掉。 有了新的镜像,咱们就能够把它推送到 Docker Hub 上了:
$ docker push geektime/helloworld:v2
你可能还会有这样的问题:我在企业内部,能不能也搭建一个跟 Docker Hub 相似的镜像上传系统呢?
固然能够,这个统一存放镜像的系统,就叫做 Docker Registry。感兴趣的话,你能够查看Docker 的官方文档,以及VMware 的 Harbor 项目。
最后,我再来说解一下 Docker 项目另外一个重要的内容:Volume(数据卷)。
前面我已经介绍过,容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机彻底隔离开的文件系统环境。这时候,咱们就须要考虑这样两个问题:
容器里进程新建的文件,怎么才能让宿主机获取到? 宿主机上的文件和目录,怎么才能让容器里的进程访问到? 这正是 Docker Volume 要解决的问题:Volume 机制,容许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操做。 在 Docker 项目里,它支持两种 Volume 声明方式,能够把宿主机目录挂载进容器的 /Ftest 目录当中:
$ docker run -v /test ... $ docker run -v /home:/test ...
而这两种声明方式的本质,其实是相同的:都是把一个宿主机的目录挂载进了容器的 /test+目录。 只不过,在第一种状况下,因为你并无显示声明宿主机目录,那么 Docker 就会默认在宿主机上建立一个临时目录/Fvar/lib/docker/volumes[VOLUME_ID]/_data,而后把它挂载到容器的 /test 目录上。而在第二种状况下,Docker 就直接把宿主机的/home 目录挂载到容器的 /test 目录上。
那么,Docker 又是如何作到把一个宿主机上的目录或者文件,挂载到容器里面去呢?难道又是 Mount Namespace 的黑科技吗?
实际上,并不须要这么麻烦。当容器进程被建立以后,尽管开启了 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[可读写层+D]/test)上,这个 Volume 的挂载工做就完成了。
更重要的是,因为执行这个挂载操做时,“容器进程”已经建立了,也就意味着此时 Mount Namespace 已经开启了。因此,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。
注意:这里提到的 " 容器进程 ",是 Docker 建立的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列须要在容器内进行的初始化操做。最后,它经过 execv() 系统调用,让应用进程取代本身,成为容器里的 PID=1 的进程。
而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要做用就是,容许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。而且,这时你在该挂载点上进行的任何操做,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。 其实,若是你了解 Linux 内核的话,就会明白,绑定挂载其实是一个 inode 替换的过程。在 Linux 操做系统中,inode 能够理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。
正如上图所示,mount+--bind/home/test,会将/home 挂载到 /test 上。其实至关于将 /test 的 dentry,重定向到了 /home 的 inode。这样当咱们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为什么,一旦执行 umount 命令,/test+目录原先的内容就会恢复:由于修改真正发生在的,是 /home 目录里。
因此,在一个正确的时机,进行一次绑定挂载,Docker 就能够成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。
这样,进程在容器里对这个 /test 目录进行的全部操做,都实际发生在宿主机的对应目录(好比,/home,或者/var/lib/docker/volumes[VOLUME_ID]_data)里,而不会影响容器镜像的内容。
那么,这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?
也不会。 这个缘由其实咱们前面已经提到过。容器的镜像操做,好比 docker commit,都是发生在宿主机空间的。而因为 Mount Namespace 的隔离做用,宿主机并不知道这个绑定挂载的存在。因此,在宿主机看来,容器中可读写层的/test 目录(/var/lib/docker/aufs/mnt%2F%5B[可读写层]/test),始终是空的。
不过,因为 Docker 一开始仍是要建立/test 这个目录做为挂载点,因此执行了 docker commit 以后,你会发现新产生的镜像里,会多出来一个空的/test 目录。毕竟,新建目录操做,又不是挂载操做,Mount Namespace 对它可起不到“障眼法”的做用。
结合以上的讲解,咱们如今来亲自验证一下: 首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上:
$ docker run -d -v /test helloworld cf53b766fa6f
容器启动以后,咱们来查看一下这个+Volume+的+ID:
$ docker volume ls DRIVER VOLUME NAME local cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d
而后,使用这个+ID,能够找到它在 Docker 工做目录下的+volumes+路径:
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。 接下来,咱们在容器的 Volume 里,添加一个文件 text.txt:
$ docker exec -it cf53b766fa6f /bin/sh cd test/ touch text.txt
这时,咱们再回到宿主机,就会发现+text.txt+已经出如今了宿主机上对应的临时目录里:
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/ text.txt
但是,若是你在宿主机上查看该容器的可读写层,虽然能够看到这个/test 目录,但其内容是空的
$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test
能够确认,容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 自己,则会出如今新的镜像当中。 以上内容,就是 Docker Volume 核心原理了。
更重要的是,我着重介绍了如何使用 Linux Namespace、Cgroups,以及 rootfs 的知识,对容器进行了一次庖丁解牛似的解读。 借助这种思考问题的方法,最后的 Docker 容器,咱们实际上就能够用下面这个“全景图”描述出来:
这个容器进程“python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;而它运行所须要的各类文件,好比 python,app.py,以及整个操做系统文件,则由多个联合挂载在一块儿的 rootfs 层提供。 这些 rootfs 层的最下层,是来自 Docker 镜像的只读层。
在只读层之上,是 Docker 本身添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。 而 rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存听任何对只读层的修改,容器声明的 Volume 的挂载点,也出如今这一层。 经过这样的剖析,对于曾经“神秘莫测”的容器技术,你是否是感受清晰了不少呢?