酒店类网站开发策略,陕西渭南富平建设局网站,开个网址多少钱,游戏推广网站制作文章目录Docker部署应用准备制作容器镜像启动容器上传镜像docker exec数据卷#xff08;Volume#xff09;声明原理实践Docker部署
应用准备
这一次#xff0c;我们来用 Docker 部署一个用 Python 编写的 Web 应用。这个应用的代码部分#xff08;app.py#xff09;非常…
文章目录Docker部署应用准备制作容器镜像启动容器上传镜像docker exec数据卷Volume声明原理实践Docker部署
应用准备
这一次我们来用 Docker 部署一个用 Python 编写的 Web 应用。这个应用的代码部分app.py非常简单
from flask import Flask
import socket
import osapp Flask(__name__)app.route(/)
def hello():html h3Hello {name}!/h3 \bHostname:/b {hostname}br/ return html.format(nameos.getenv(NAME, world), hostnamesocket.gethostname())if __name__ __main__:app.run(host0.0.0.0, port80)在这段代码中使用 Flask 框架启动了一个 Web 服务器而它唯一的功能是如果当前环境中有“NAME”这个环境变量就把它打印在“Hello”后否则就打印“Hello world”最后再打印出当前环境的 hostname。 这个应用的依赖则被定义在了同目录下的 requirements.txt 文件里内容如下所示
$ cat requirements.txt
Flask制作容器镜像
而将这样一个应用容器化的第一步是制作容器镜像。
不过相较于之前介绍的制作 rootfs 的过程Docker 为你提供了一种更便捷的方式叫作 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]通过这个文件的内容你可以看到 Dockerfile 的设计思想是使用一些标准的原语即大写高亮的词语描述我们所要构建的 Docker 镜像。并且这些原语都是按顺序处理的。
比如 FROM 原语指定了“python:2.7-slim”这个官方维护的基础镜像从而免去了安装 Python 等语言环境的操作。否则这一段我们就得这么写了
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”。
另外在使用 Dockerfile 时你可能还会看到一个叫作 ENTRYPOINT 的原语。实际上它和 CMD 都是 Docker 容器进程启动所必需的参数完整执行格式是“ENTRYPOINT CMD”。
但是默认情况下Docker 会为你提供一个隐含的 ENTRYPOINT即/bin/sh -c。所以在不指定 ENTRYPOINT 时比如在我们这个例子里实际上运行在容器里的完整进程是/bin/sh -c “python app.py”即 CMD 的内容就是 ENTRYPOINT 的参数。 备注基于以上原因我们后面会统一称 Docker 容器的启动进程为 ENTRYPOINT而不是 CMD。 需要注意的是Dockerfile 里的原语并不都是指对容器内部的操作。就比如 ADD它指的是把当前目录即 Dockerfile 所在的目录里的文件复制到指定容器内的目录当中。
读懂这个 Dockerfile 之后再把上述内容保存到当前目录里一个名叫“Dockerfile”的文件中
$ 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 lsREPOSITORY 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
h3Hello World!/h3bHostname:/b 4ddf4638572dbr/否则我就得先用 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
# 在容器内部新建了一个文件
root4ddf4638572d:/app# touch test.txt
root4ddf4638572d:/app# exit# 将这个新建的文件提交到镜像中保存
$ docker commit 4ddf4638572d geektime/helloworld:v2这里使用了 docker exec 命令进入到了容器当中。在了解了 Linux Namespace 的隔离机制后你应该会很自然地想到一个问题docker exec 是怎么做到进入容器里的呢
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);
}这段代码功能非常简单它一共接收两个参数第一个参数是 argv[1]即当前进程要加入的 Namespace 文件的路径比如 /proc/25686/ns/net而第二个参数则是你要在这个 Namespace 里运行的进程比如 /bin/bash。
这段代码的的核心操作则是通过 open() 系统调用打开了指定的 Namespace 文件并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后当前进程就加入了这个文件对应的 Linux Namespace 当中了。
现在你可以编译执行一下这个程序加入到容器进程PID25686的 Network Namespace 中
$ gcc -o set_ns set_ns.c
$ ./set_ns /proc/25686/ns/net /bin/bash
$ ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:ac:11:00:02 inet addr:172.17.0.2 Bcast:0.0.0.0 Mask:255.255.0.0inet6 addr: fe80::42:acff:fe11:2/64 Scope:LinkUP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1RX packets:12 errors:0 dropped:0 overruns:0 frame:0TX packets:10 errors:0 dropped:0 overruns:0 carrier:0collisions:0 txqueuelen:0 RX bytes:976 (976.0 B) TX bytes:796 (796.0 B)lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0inet6 addr: ::1/128 Scope:HostUP LOOPBACK RUNNING MTU:65536 Metric:1RX packets:0 errors:0 dropped:0 overruns:0 frame:0TX packets:0 errors:0 dropped:0 overruns:0 carrier:0collisions:0 txqueuelen:1000 RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)如上所示当我们执行 ifconfig 命令查看网络设备时我会发现能看到的网卡“变少”了只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢
实际上在 setns() 之后我看到的这两个网卡正是我在前面启动的 Docker 容器里的网卡。也就是说我新创建的这个 /bin/bash 进程由于加入了该容器进程PID25686的 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这时如果按照前面介绍过的方法查看一下这个 PID28499 的进程的 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 目录下这个 PID28499 进程与我们前面的 Docker 容器进程PID25686指向的 Network Namespace 文件完全一样。这说明这两个进程共享了这个名叫 net:[4026532281] 的 Network Namespace。
此外Docker 还专门提供了一个参数可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里这个参数就是 -net比如:
$ docker run -it --net container:4ddf4638572d busybox ifconfig这样我们新启动的这个容器就会直接加入到 ID4ddf4638572d 的容器也就是我们前面的创建的 Python 应用容器PID25686的 Network Namespace 中。所以这里 ifconfig 返回的网卡信息跟我前面那个小程序返回的结果一模一样你也可以尝试一下。
而如果我指定 -nethost就意味着这个容器不会为进程启用 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数据卷Volume
前面我已经介绍过容器技术使用了 rootfs 机制和 Mount Namespace构建出了一个同宿主机完全隔离开的文件系统环境。这时候我们就需要考虑这样两个问题
容器里进程新建的文件怎么才能让宿主机获取到宿主机上的文件和目录怎么才能让容器里的进程访问到
这正是 Docker Volume 要解决的问题Volume 机制允许你将宿主机上指定的目录或者文件挂载到容器里面进行读取和修改操作。
声明
在 Docker 项目里它支持两种 Volume 声明方式可以把宿主机目录挂载进容器的 /test 目录当中
$ docker run -v /test ...
$ docker run -v /home:/test ...而这两种声明方式的本质实际上是相同的都是把一个宿主机的目录挂载进了容器的 /test 目录。
只不过在第一种情况下由于你并没有显示声明宿主机目录那么 Docker 就会默认在宿主机上创建一个临时目录 /var/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/[可读写层 ID]/test上这个 Volume 的挂载工作就完成了。
更重要的是由于执行这个挂载操作时“容器进程”已经创建了也就意味着此时 Mount Namespace 已经开启了。所以这个挂载事件只在这个容器里可见。你在宿主机上是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。 注意这里提到的 容器进程 是 Docker 创建的一个容器初始化进程dockerinit而不是应用进程ENTRYPOINT CMD。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后它通过 execv() 系统调用让应用进程取代自己成为容器里的 PID1 的进程。 而这里要使用到的挂载技术就是 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/[可读写层 ID]/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 的核心原理了。 笔记来源于极客时间《深入剖析Kubernetes》