我们分别从Linux Namespace的隔离,Linux的Cgroups的限制,基于rootfs的文件系统三个角度,剖析了一个Linux容器的核心实现原理
我们拿一个实际的部署Docker的案例,来进行一次深入的总结和扩展,来看透Docker容器的本质,我们拿Docker部署一个python试下
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,
如果没有环境变量 NAME,就输出 Hello world
然后我们针对这个py文件,制作对应的Docker,相对来说,Docker制作现在来说很便捷了,直接使用Dockerfile即可
#使用官方提供Python开发镜像为基础
FROM python:2.7-slim
#切换工作目录
WORKDIR /app
#将目前目录下所有内容复制到 /app下
ADD . /app
#利用pip命令来在容器启动前安装应用依赖
RUN pip install –trusted-host pypi.python.org -r requirements.txt
#端口映射
EXPOSE 80
#设置所需的环境变量
ENV NAME HEAVEN
#设置容器进程
CMD [‘python”,”app.py”]
Dockerfile通过一些标准的原语,构建了需要的Docker环境
比如FROM原语,就是制定了 python这个官方的基础镜像,免去了安装python环境的操作
RUN在补充的安装了一些第三方类库
而且RUN 等于 执行制定shell命令的意思
WORKDIR,等于是制定了Dockerfile的操作目录
最后的CMD,就是Dockerfile指定执行的进程
而且Dockerfile,还有一个隐含的参数
ENTRYPOINT, /bin/sh -c 不指定ENTRYPOINT 时候,会在容器中实际运行 /bin/sh -c “python app.py”
我们统一称Docker的容器的启动进程为ENTRYPOINT,而不是CMD
然后,我们将上面的内容保存在一个Dockerfile的文件中
然后Docker就可以制作这个镜像了,在Dockerfile目录下执行
docker build -t helloword
-t就是给镜像加了一个Tag,就是一个镜像名字
Docker会找到这个目录下的dockerfile,然后进行执行
在执行Dockerfile的时候,每一个原语都会有一个对应的镜像层
build之后,就可以通过docker images来查看结果
docker image ls
然后通过docker run启动容器
docker run -p 4000:80 helloword
在镜像名 helloworld 之后因为已经在file中指定了CMD,不需要加任何启动命令了
容器启动后,可以利用docker ps看到对应的容器
这样,我们利用-p进行了端口映射,只要访问宿主机的4000端口,就可以看到容器返回的结果
这样,我们已经完成了一个应用的部署,如果想要上传,就直接利用DockerHub即可
直接Docekr push,就可以配合自己的docker hub账号
我们还可以利用docker commit的指令,讲一个正在运行的容器进行保存提交
我们可以先进入这个容器进行一些新的操作
docker exec -it container id /bin/sh
然后做些操作
然后进行提交保存
那么一个问题,docker如何进入的容器内部的呢?
首先说,Docker利用Namespace隔离的空间,那么只要我们进入Namespace就可以了
我们可以首先看一个Docker容器的进程号
docker inspect –format ‘{{.State.Pid}}’ 23738ead2182
获得了对应的PID
然后,我们查看宿主机的proc的文件,
每一个Namespace都会在这个对应的ns文件夹下有一个对应的虚拟文件
这样,我们就可以加入到一个已经存在的Namespace当中的了
这就是Docker exec的原理
Docker操作真正依赖的是一个名为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”); } |
我们首先传入一个Namespace的文件路径,然后通过open()函数打开了Namespace的文件,交给了setnus使用,然后进程就相当于加入了指定的Linux Namespace
但当我们加入到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.0 inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:12 errors:0 dropped:0 overruns:0 frame:0 TX packets:10 errors:0 dropped:0 overruns:0 carrier:0 collisions: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.0 inet6 addr: ::1/128 Scope:Host UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:0 errors:0 dropped:0 overruns:0 frame:0 TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:0 (0.0 B) TX bytes:0 (0.0 B) |
但是在这里面看网络设备的时候,可以看到的是,网卡数量变少了,说明我们加入了对应的Network Namespace中
并且,一旦一个新的进程加入了另一个Namespace中,宿主机的Namespace文件也会修改
我们看一下这个set ns 的程序的进程PID
并且看一下这个PID的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] |
两者指向的Network Namespace文件一样,说明两个进程,共享了这个network Namespace
其实docker容器,也提供了类似的参数.可以启动容器并加入另一个容器的Network Namespace中中,使用参数 -net
使用代码如下
$ docker run -it –net container:4ddf4638572d busybox ifconfig
我们新启动的容器,就直接加入到另一个容器的Namesapce了,于是两者的容器信息一致
-net 如果指定为host,就意味着这个容器不会为进程启动Network Namespace,这就意味着,这个容器将会使用宿主机的网络栈
返回docker打包
如果使用了docker commit ,那么就是将容器的可读写层和只读层进行了一个打包,成为了一个新的镜像,只读层只在宿主机上共享,不会占用额外的空间
这样,就可以利用docker push的命令,将镜像打包传到docker hub的镜像上传系统了
如果想要自己的内部镜像上传系统,可以参考Docker Registry和VMware的Harbor项目
最后一个就是Docker的数据共享问题
就是容器内的新建文件,如何让宿主机获取到
宿主机上的文件和目录,如何让容器内的进程访问到
Docker Volume就是解决这个问题的,Volume机制,可以让宿主机上的指定目录,挂载到容器内进行修改和读取
Docker可以在启动的时候
将宿主机的目录挂载到容器内
docker run -v /test
docker run -v /hone:/test
这样,就是讲宿主机的目录挂载到了容器的 /test目录
第一种不指定宿主机目录,会在宿主机上创建一个临时目录 /var/lib/docker/volumnes/[VOLUME_ID]/_data
然后挂载到容器 /test目录下,第二种方式,则是直接的/home 挂载到/test目录下
Docker又是如何把宿主机上的目录挂载到容器内的呢?
我们需要注意,虽然有了Mount Namespace,但是在执行chroot之前,是可以看到整个文件系统的
目录
而宿主机上的文件系统,自然包含了我们的容器镜像,在启动后,会被联合挂载起来
我们只需要在rootfs准备好之前,将Volume制定的宿主机的目录,挂载到指定的容器目录在宿主机上对应的目录上,这个Volume的挂载工作就算完成了
我们这里的挂载,就是Linux的绑定挂载的机制 bind mount
可以将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上,这时候,这个挂载点上的任何操作,都只会发生在被挂载的目录或者文件上,原挂载点的内容会被隐藏起来’
绑定挂载是一个inode替换的流程,在Linux系统中,inode是存放文件内容的对象,目录是这个对象的外部指针
整体来说,就是在一个合适的实际,Docker进行了一次挂载绑定,将宿主机的目录或者文件进行挂载
那么,这个/test目录内的内容,会不会被在docker commit的时候一起提交了呢?
这个问题大可放心,因为在Mount Namespace的作用下,宿主机不知道这个绑定挂载的存在,在宿主机看来,容器中读写层的/test目录
整体的验证流程如下
就是先声明一个Volume,挂载到容器的/test目录上
docker run -d -v /test helloworld
然后查看这个容器的Volume的 ID
$ docker volume ls
DRIVER VOLUME NAME
local cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d
然后根据这个ID,找到对应的Docker目录下的volumns路径
ls /var/lib/docker/volumns/cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d/_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
最后说下整体的Docker容器全景图
整体结构如上,rootfs的最下层,是来自Docker的只读层,只读层之上,是Docker自己添加的init层,存放修改的一些配置文件
而rootfs的最上层是一个可读写层,以Copy-on-Write的方式存放任何只读层的修改
1.现在Docker容器有一个新的cgroup的Namespace,作用是什么呢?
2.执行docker run -v/home :/test的时候,如果容器的/test目录下本来就有内容,宿主机的/home目录下,也会出现这些内容,为啥
dockerfile使用sh启动Java,而不是exec启动的时候,若是通过docker stop,是不能停掉Java进程的,可以选择trap sig term kill