本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Docker概述
Docker的实质是隔离了外界,限制了资源的特殊的进程
进程的静态表现是程序,存储在磁盘中。进程的动态表现是程序执行起来后,计算机内存中的数据、寄存器中的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合
容器技术的核心功能就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”
容器就是一个单进程模型
docker run -it busybox /bin/sh
-it参数高数了Docker在启动容器后需要分配一个文本输入输出环境TTY,与容器的标准输入输出相关联
/bin/sh 是我吗要在Docker容器里运行的程序
容器中执行ps指令
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps
容器中只有两个进程在运行,而新执行的/bin/sh的pid=1,说明与宿主机之间产生隔离
Cgroups
- 用于制造约束(限制资源)的主要手段
- 限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等
- 能够执行对进程优先级设置、审计、将进程挂起和恢复等操作
实际上就是一个子系统目录加上一组资源限制文件的组合
$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...
$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
在Linux中Cgroups暴露出来的操作接口是文件系统,以文件和目录的方式组织在/sys/fs/cgroup路径下,可以通过mount -t cgroup将他们展示出来。输出结果是一系列文件系统目录(CPU、块设备I/O限制blkio、独立CPU核和对应的内存结点cpuset、内存使用的限制memory),这些都叫子系统,是本机可以被Cgroups进行限制的资源种类。
可以在对应的子系统中创建一个目录(container),称为控制组。操作系统会自动生成该子系统对应的资源限制文件,修改资源文件,将被限制的进程的PID写入container组里的tasks文件,设置就会自动生效了
Cgroups也存在对资源限制能力不完善的问题
-
在容器内执行top指令显示的信息是宿主机的CPU和内存数据
- /proc文件系统记录了当前内核运行状态的一系列特殊文件,可以通过访问这些文件来查看系统以及当前运行进程的信息,如CPU使用率、内存占用率等。
- /proc文件系统不了解Cgroups限制的存在
- 解决:lxcfs ,容器不挂载宿主机的/prof/stats目录
Namespace
- 用来修改进程视图(隔离资源)的主要方法
- 对隔离应用的进程空间做了隔离限制(比如说pid=1),每个Namespace中的应用进程都看不到宿主机里真正的进程空间,也看不到其他Namespace的具体情况
- 是Linux创建新进程的一个可选参数(clone中的CLONE_NEWPID)
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
Docker在启动的时候会默认启动6个Namespace
docker 深入理解之namespace - 烟雨楼台,行云流水 - 博客园 (cnblogs.com)
基于Linux Namespace的隔离机制相比于虚拟化技术而言,隔离得不彻底
-
容器只是运行在宿主机上的一个特殊的进程,多个容器共享宿主机的内核
-
在Linux内核中,很多资源和对象是不能被Namespace化的,比如时间
- 一旦一个应用程序使用系统调用修改了时间,那么整个宿主机的时间都会被修改
基于虚拟化或独立内核技术的容器实现可以比较好地在隔离与性能之间做出平衡
Uts:主机名和域名隔离
Ipc:信号量,消息队列和共享内存隔离
Mnt:文件系统挂载点隔离
Net:网络设备、网络栈、端口等隔离,每个namespace都有自己独立的ip,路由和端口
Pid:进程编号
User:用户与组
每个进程的每种Namespace都以文件的形式存储在宿主机中,每个Namespace都在对应的/proc/{pid}/ns下有一个对应的虚拟文件,并且链接到一个真实的Namespace文件。
- 通过这种方式,可以选择加入某个进程已有的Namespace中,达到进入这个进程所在容器的目的,即docker exec的实现原理
- 该操作依赖的是一个叫setns()的Linux系统调用
当一个进程加入到另一个Namespace中,宿主机上的Namespace会有所体现(比如创建的一个bash进程,加入了一个容器进程的net Namespace,该bash进程看到的网络设备会和在容器中看到的网络设备一样,即网络设备视图被修改了)
可以说,docker exec指令每次执行都会新创建一个和容器共享Namespace的进程
Docker还专门提供了一个参数,可以启动一个容器并将其“加入”到另一个容器的Network Namespace中,即-net
$ docker run -it --net container:4ddf4638572d busybox ifconfig
如果指定-net=host,意味着这个容器不会为进程启用Network Namespace,
这个容器进程会和宿主机上其余的普通进程一样直接共享宿主机的网络栈。为容器直接操作和
使用宿主机网络提供了一个渠道
容器镜像-基于rootfs的文件系统
即使开启了Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样
Mount Namespace修改的是容器进程对文件系统挂载点的认知,只有在挂载操作发生之后,进程的视图才会被改变,此前,新创建的容器直接继承宿主机的各个挂载点。只有伴随挂载操作才能生效
- 可以通过通知容器进程需要挂载的目录解决。在容器进程启动之前,执行一句挂载语句
$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T
$ cp -v /bin/{bash,ls} $HOME/test/bin
$ T=$HOME/test
$ list="$(ldd /bin/ls | egrep -o '/lib.*.[0-9]')"
$ for i in $list; do cp -v "$i" "${T}${i}"; done
$ chroot $HOME/test /bin/bash
Linux的chroot命令也可以解决这个问题
- 通过改变进程的根目录到指定位置
Namespace就是基础chroot改良发明的
一般会在容器根目录下挂载一个完整os的文件系统,容器启动后就能查看宿主机的所有目录和文件
这个挂载在容器根目录上用于为进程提供隔离后执行环境的文件系统就是所谓的容器镜像。专业名字叫rootfs(根文件系统)
- 常用的rootfs会包括/bin、/etc、/proc等目录和文件。不包括操作系统内核
核心原理
- 启用Linux Namespace配置
- 设置指定Cgroups参数
- 切换进程的根目录(优先使用pivot_root系统调用,不行再执行chroot)TODO与chroot区别
尽管容器共享一个内核是个缺陷,但是正是由于rootfs的存在,容器才有了一致性
- rootfs里打包的不仅是应用,而是整个操作系统的文件和目录,意味着应用以及它运行所需要的所有依赖都被封装在了一起。
- 深入到操作系统级别的运行环境一致性打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟
Docker在镜像设计中,引入了层的概念:用户制作镜像的每一步操作都会生成一个层,也就是增量的rootfs。其中用到了一种叫联合文件系统(Union File System)的能力:主要功能是将多个不同位置的目录联合挂载到同一个目录下,即多个目录下的文件合并到一个目录中
ubuntu默认使用的联合文件系统的实现是AuFS(Advance UnionFS),关键目录结构在/var/lib/docker路径下的diff目录
$ docker image inspect ubuntu:latest
...
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:f49017d4d5ce9c0f544c...",
"sha256:8f2b771487e9d6354080...",
"sha256:ccd4d61916aaa2159429...",
"sha256:c01d74f99de40e097c73...",
"sha256:268a067217b5fe78e000..."
]
}
挂载点如下
/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
包含了一个完整的ubuntu os
$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
存在五个层,使用镜像的时候会将他们联合挂载到一个统一的挂载点上(/var/lib/docker/aufs/mnt/)
目录下是一个完整的ubuntu os
- 关于镜像层被联合挂载到文件系统的信息记录在AuFS的系统目录/sys/fs/aufs下
可以通过查看AuFS挂载信息找到目录对应的AuFS的内部ID(si),然后使用这个ID,可以在/sys/fs/aufs下查看各个层的信息
从信息中能够看到镜像层都放在/var/lib/docker/aufs/diff目录下,然后被联合挂载到/var/lib/docker/aufs/mnt
获取si
$ cat /proc/mounts| grep aufs
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0
查看各层信息
$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
/var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
/var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
/var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
/var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
/var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh
rootfs由以下三部分构成
-
可读写层(容器层)
- 容器rootfs最上层的部分,挂载方式为rw。没写入文件前为空,进行写操作后,修改的内容会以增量的方式出现在该层中
- 如果要实现删除操作,AuFS会在这层创建一个whiteout文件,将只读层的文件遮挡起来
- 比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。
-
Init层
- 以-init结尾,加载只读层和读写层之间。是Docker项目单独生成的一个内部层,用于存放/etc/hosts/etc/resolv.conf等信息
- 使得用户在对当前容器的修改(hostname等)以一个单独的层挂载出来,只对当前容器有效,用户执行docker commit的时候只会提交可读写层,不包含这些层的内容
-
只读层(镜像层)
- 是rootfs最下层的x层,对应的是镜像的x层,挂载方式都是只读的(ro+wh,readonly+writeout)
- 这些层以增量的方式分别包含了ubuntu os的一部分
由于Docker的镜像只是一个os的所有文件和目录,不包含内核,最多几百兆。与传统vm的快照镜像不同
如何在容器里修改Ubuntu镜像的内容
容器中所有的增删改查都只会作用在容器层,相同的文件上层会覆盖掉下层。因此在修改一个文件的时候,会从上到下层查找是否存在这个文件,如果找到了就复制到容器层,修改,修改的结果就会作用到下层的文件,即copy-on-write(写拷贝)
Docker commit
在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成一个新的镜像。只读层在宿主机上是共享的,不会占用额外的空间
也是写拷贝机制
但是由于Init层的存在,避免了docker commit将堆当前容器的一些修改(/etc/hosts等)一起提交掉
Volume机制
容器里进程新建的文件,怎么才能让宿主机获取到?
宿主机上的文件和目录怎么才能让容器里的进程访问到?
Volume机制允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作
有两种声明方式
都是将宿主机目录挂载到容器内/test目录
$ docker run -v /test ...
$ docker run -v /home:/test ...
在不指定主机目录下的时候,会自动生成一个临时目录/var/lib/docker/volumes/{volume_id}/_data
}然后直接将该临时目录挂载到/test上
原理:
在chroot执行之前容器进程一直可以看到宿主机上的整个文件系统
只需要在rootfs准备好之后,执行chroot之前,将Volume指定的宿主机目录(/home)挂载到指定的容器目录(/test)在宿主机上对应的目录(/var/lib/docker/aufs/mnt/{可读写层id/test)
在执行挂载操作的时候容器进程已经创建了,Mount Namespace已经开启了,这个挂载事件只在容器内可见,宿主机上无法看到容器内部的这个挂载点。保证了容器的隔离性不会被Volume打破
注意:这里提到的"容器进程",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。
使用的挂载技术是Linux的绑定挂载(bind mount)机制
- 允许将一个目录或者文件挂载到一个指定目录上,并且这是在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,原挂载点的内容会被隐藏起来不受影响
- 挂载就是一个inode替换的过程,只是改变了dentry指针的指向
由于docker commit是发生在宿主空间中的,因此对于容器内绑定挂载的存在并不知情
不过由于新建目录的操作不是挂载操作,所以还是会创建一个空的文件夹
参考