容器是一种沙盒技术,将应用像集装箱一样封装起来,应用之间就像有了边界而互不干扰。并且应用也可以被随意迁移。 接下来就要介绍容器是如何实现这个边界的。
边界
容器的核心技术就是,通过约束和修改进程的动态表现,从而为其创建出一个边界。对于Docker这样的Linux容器,使用Linux的Cgroups技术进行约束,Namespace技术进行修改。
因此,最核心的技术原理就是Cgroups与Namespace技术。动手实践来理解这两个技术。
隔离
首先,创建一个容器
$ docker run -it busybox /bin/sh启动一个容器运行/bin/sh命令,并提供一个可以与容器交互的命令行终端。
然后,在容器内部执行ps查看进程,可以发现整个容器内部只有最开始执行/bin/sh和刚刚执行的ps。
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps这就说明,容器已经与宿主机完全隔离开了。
但其实,只是容器中的进程感知不到宿主机上的进程了,像是容器被施加了障眼法,对于宿主机来说,容器执行的/bin/sh并不是1号进程。
这其中使用的技术就是Namespace技术了。而Namespace的使用方式其实就是Linux创建新进程的一个可选参数。
在Linux中,创建进程的系统调用为clone:
int pid = clone(main_function, stack_size, SIGCHLD, NULL); 在创建进程的时候指定参数CLONE_NEWPID,那么这个进程就会在一个新的PID Namespace中,每个Namespace中的进程既看不到宿主机里真正的进程空间,也看不到其它Namespace中的具体情况。
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL); 除了PID Namespace,Linux还有Mount,UTS,IPC,Network和User这些Namespace,用来对不同的进程上下文进行“障眼法”操作。
所以可以这样说,容器只是一种特殊的进程,在创建进程的时候设置了一些特殊的参数,实现了资源的隔离。
在使用 Docker 的时候,并没有一个真正的“Docker 容器”运行在宿主机里面。Docker 项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。
基于Namespace的容器化技术,可以使得无需使用Hypervisor来虚拟硬件运行GuestOS,执行系统调用也不需要经过虚拟化软件的拦截处理,使得资源的损耗和性能的损耗都大大降低。敏捷和高性能是其较虚拟机最大的优势。
但是也有弊端,那就是Namespace隔离的不彻底。因为容器只是运行在宿主机上的一种特殊的进程,所有容器使用的还是同一个宿主机的内核。而且,在Linux中还有很多资源是无法被Namespace化的,比如时间。
限制
Namespace已经实现了容器,为什么还需要进行“限制”
容器虽然表面上被隔离开了,但是它们使用的资源却还是被宿主或者其它容器所占用的,或者这个容器本身全部占用。这不是容器作为一个“沙盒”应该表现的行为。因此需要进行限制。
Linux的Cgroups就是Linux内核为进程限制资源的一个重要功能。
Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
Linux中,Cgroups为用户提供的操作接口时文件系统,也就是说通过操作文件和目录来对资源进行控制。
在/sys/fs/cgroup下可以看到很多子目录,也叫做子系统,每个子目录都对应一种资源。在子目录下存在配置文件,就是通过对配置文件进行修改来设置资源限制。在这些子目录下创建目录被称为“控制组”,并且会自动在控制组下生成该子系统对应的资源限制文件,并且是可以嵌套创建的。
例如,/sys/fs/cgroup/cpu是对CPU资源进行限制的子系统,目录下存在很多资源限制文件:
cgroup.clone_children cpuacct.usage_percpu cpu.cfs_quota_us init.scope user.slice
cgroup.procs cpuacct.usage_percpu_sys cpu.rt_period_us notify_on_release YunJing
cgroup.sane_behavior cpuacct.usage_percpu_user cpu.rt_runtime_us onion
cpuacct.stat cpuacct.usage_sys cpu.shares release_agent
cpuacct.usage cpuacct.usage_user cpu.stat system.slice
cpuacct.usage_all cpu.cfs_period_us docker tasks在这个目录下可以创建一个container目录,操作系统会自动在这个目录下生成与上面一致的资源限制文件,这个container目录也是一个控制组。
修改这个控制组下的资源限制文件,再将需要进行限制的进程pid写入tasks文件中,控制组中的限制就会对加入的进程生效。
对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。
Note
容器本身是一个启用了多个Namespace的应用进程,并使用Cgroups对这个进程能够使用的资源进行限制。容器是一个“单进程”模型。
但是容器是存在一个问题的,那就是/proc文件系统并不知道Cgroups限制的存在,也就是说,再容器内部使用top命令,看到的也是宿主机内的CPU和内存数据,而不是当前容器的数据。
生产环境中使用lxcfs解决这个问题。把宿主机的 /var/lib/lxcfs/proc/xxx 文件挂载到Docker容器的/proc/xxxx位置,而/var/lib/lxcfs会从容器对应的Cgroup中读取对应的限制资源。容器中进程读取相应文件内容时,LXCFS的FUSE实现会从容器对应的Cgroup中读取正确的内存限制。从而使得应用获得正确的资源约束设定。
镜像
对于docker容器来说,在容器内部,我们希望看到的是与宿主机独立的文件系统。理所当然使用了Mount Namespace,但是==Mount Namespace与其它的Namespace不同:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效==。也就是说,没有修改挂载点的话,会与宿主机的文件系统保持一致。
因为Mount Namespace挂载操作只在容器内部生效,并且我们期望一个独立的文件系统,所以需要在容器启动之前重新挂载它的整个根目录。Linux提供的有一个简易的命令,`chroot 可以用来修改进程的根目录。
这个挂载到容器根目录上,用来为容器提供隔离后执行环境的文件系统,就是所谓的容器镜像。它还有一个更加专业的名字,叫做:rootfs(根文件系统)。
前面已经了解到了容器是一个特殊的进程,对于Docker来说,核心原理就是在待创建的用户进程上,进行了如下处理:
- 启用Linux Namespace配置
- 设置指定的Cgroups参数
- 切换进程的根目录(Docker优先使用pivot_root,系统不支持再使用chroot)
为了让rootfs具有通用性,用户对rootfs的修改都是增量操作,所有人都维护相对于base rootfs修改的增量内容,那么这个base rootfs对所有人来说就是通用的了。
Docker引用了层layer的概念,用户制作镜像的每一步操作都会生成一个层,也就是一个增量rootfs。 Docker使用了联合文件系统的能力,也就是将多个不同位置的目录联合挂载到同一目录下,docker就利用这一点,将多个层联合挂载到同一个挂载点,形成一个最终的文件系统。
一个容器的rootfs可以由三部分组成:只读层、init层和可读写层。
1. 只读层
挂载方式是只读的(ro+wh,即readonly+whiteout)。
2. 可读写层
这是rootfs最上面的一层,挂载方式为rw。一旦在容器中做了写操作,修改的内容就会以增量的方式出现在这个层中。
为了实现删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。例如删除只读层的foo文件,会在可读写层生成一个.wh.foo文件,在两层被联合挂载后,foo文件就会消失了。
可读写层就是专门用来存放修改rootfs后产生的增量,无论增删改都可以保存,还可以使用docker commit和push保存被修改过的可读写层,并上传到Docker Hub,供其他人使用;并且原来只读层的内容不会发生改变,这就是增量rootfs的好处。
3. init层
只读层与可读写层中间的一层,以"-init"结尾。Init层是Docker单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息,也就是一些环境配置。
这些文件应该属于只读层的,但是在我们使用容器的时候,常常会对容器添加一些启动参数来满足特定需求,这就需要在可读写层进行修改,但是这些修改是只对当前容器有效的,并不希望进行docker commit的时候将这些修改与可读写层一起提交,于是便有了init层。
所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。
容器
通过使用Docker部署一个应用来了解容器。
部署应用
项目的目录结构如下:
$ ls
Dockerfile app.py requirements.txt应用的代码app.py非常简单
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)其依赖文件requirements.txt如下:
FlaskDocker使用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"]dockers启动进程的原语是ENTRYPOINT CMD,默认情况下,Docker会提供一个隐式的ENTRYPOINT,即/bin/bash -C,而CMD就是ENTRYPOITN的参数。
应用代码写好了,用于构建镜像的Dockerfile准备好了,接下来就可以制作镜像了。执行如下命令:
docker build -t helloworld .-t相当于给镜像起个名字。docker build会自动加载当前目录下的Dockerfile文件,按照顺序执行文件中的原语。整个过程实际等同于,Docker使用基础镜像启动了一个容器,在容器中执行Dockerfile中的原语。
需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。
镜像构建好了之后,就可以启动容器了
docker run -p 4000:80 helloworld将容器的80端口映射到宿主机的4000端口上,这样访问宿主机的4000端口,就可以看到容器里应用返回的结果。否则需要访问http://<容器ip>:80才能够看到结果。
curl http://localhost:4000上传镜像
制作好的镜像可以上传到Docker Hub上,分享给更多的人使用。 首先需要注册一个Docker Hub账号,然后使用docker login命令登录。
然后给需要发布的镜像起一个完整的名字。
docker tag helloworld geektime/helloworld:v1(账户名称/镜像名称:版本号)然后使用push命令上传镜像
docker push geektime/helloworld:v1很多时候,我们拉取别人的镜像,然后运行容器,进行修改之后,使用commit将修改后的容器保存为一个新的镜像,然后发布。
进入容器
使用docker exec命令可以进入 容器,那么这是如何实现的呢?
一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。
查看容器的进程号
$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d
25686然后就能够在/proc/25686/ns下看到这个进程的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]这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。
Linux提供了一个setns系统调用,可以将当前进程加入到指定的Namespace文件中。然后再通过exec函数簇来执行/bin/bash命令行交互程序。通过这种方式就能够进入到容器当中。
Docker还提供了一个参数--net可以在启动容器的时候加入到另一个容器的Network Namespace当中。
commit镜像
docker容器中对rootfs做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改,这就是所谓的:Copy-on-Write。
docker commit是将可读写层和原来的只读层打包成一个新的镜像。只读层再宿主机上共享,不会占用额外的空间,只是可读写层产生了一个新的镜像。
Volume数据卷
通过Linux Namespace机制,让容器构建了一个与宿主机隔离的文件系统环境。
但是宿主机与容器的文件有时是需要互相访问的。而Volume机制,可以将宿主机上的文件或者目录挂载到容器当中进行读取和修改操作。
这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,rootfs就是联合挂载后的目录,然后使用-v将宿主机的目录或文件挂载到对应rootfs下的目录,挂载工作就完成了。
并且由于mount namespace,这个挂载事件只在容器内部可见。
可以确认,容器 Volume 里的信息,并不会被 docker commit 提交掉(因为对于宿主机来说,是看不到容器内的挂载点的,所以一直认为容器内的挂载点的目录是空的);但这个挂载点目录本身,则会出现在新的镜像当中。
kubernetes
截止到目前,我们已经能够明白了容器就是由Linux Namespace,Linux Cgroups和rootfs三种技术构建出来的进程的隔离环境。 一个运行的Linux容器,可以分为两部分:
- 静态视图,即容器镜像。
- 动态视图,即Namespace和Cgroups构成的隔离环境,也称作容器运行时。。
作为开发者,并不关心容器运行时的差异,这是运维所关心的,在开发流程中,真正承载着容器信息进行传递的是容器镜像,而不是运行时。
向 Docker 镜像制作者和使用者方向回溯,整条路径上的各个服务节点,比如 CI/CD、监控、安全、网络、存储等等,都有我可以发挥和盈利的余地。这个逻辑,正是所有云计算提供商如此热衷于容器技术的重要原因:通过容器镜像,它们可以和潜在用户(即,开发者)直接关联起来。
脱胎于Google的Borg的kubernetes成为了容器编排技术的标准。
k8s的使用目的是确定的:现有了容器镜像,在一个集群上把这个应用运行起来。并提供路由网关、水平扩展、备份、容灾恢复等一系列运维能力。
k8s架构
由两种节点组成,Master节点和Node节点。
- Master节点是控制节点,由三个紧密协作的独立组件完成
- kube-apiserver:负责API服务,整个集群的持久化数据,由kube-apiserver处理后保存在etcd。
- kube-scheduler:负责调度。
- kube-controller-manager:负责容器编排。
- Node节点是计算节点,由以下部分组成。
- kubelet:这是计算节点上最核心的部分。负责同容器运行时交互,依赖CRI(Container Runtime Interface)的远程调用,只要你的这个容器运行时能够运行标准的容器镜像,它就可以通过实现 CRI 接入到 Kubernetes 项目当中。这也是为何,Kubernetes 项目并不关心你部署的是什么容器运行时、使用的什么技术实现。具体的容器运行时通过OCI规范与操作系统交互,即把CRI翻译成对Linux系统的调用。
- kubelet通过grpc与Device Plugin的插件进行交互,这个插件是kubernetes用来管理GPU等宿主机物理设备的主要组件,也是基于k8s进行机器学习、高性能作业支持等工作必须关注的功能。
- kubelet通过调用CNI(Container Networking Interface)接口来调用网络插件为容器配置网络。
- kubelet通过调用CSI(Container Storage Interface)接口来调用存储插件进行持久化存储。
容器可以将各个组件、应用做成镜像,独立运行,互不干扰,但是容器之间是需要协同工作的,是需要通信的。
Kubernetes 项目对容器间的“访问”进行了分类,首先总结出了一类非常常见的“紧密交互”的关系,即:这些应用之间需要非常频繁的交互和访问;又或者,它们会直接通过本地文件进行信息交换。这些容器则会被划分为一个“Pod”,Pod 里的容器共享同一个 Network Namespace、同一组数据卷,从而达到高效率交换信息的目的。
容器通常是不会部署在一台机器上的,并且对于一个容器来说,它的IP地址不固定的,所以如何进行容器间的通信呢?K8s是给Pod绑定一个Service,Service服务的IP地址等信息是不变的,Service就是作为Pod的代理入口(Portal),从而为Pod提供一个对外暴露的固定的网络地址。与代理网关的作用类似。
一个Pod通过service访问另一个Pod,而serivce所代理的Pod的IP地址,端口等信息是由k8s负责维护的。
如下是k8s核心功能的全景图
从容器出发,首先有了容器间“紧密协作”关系的难题,于是扩展到了Pod;有了Pod之后,希望能够一次启动多个实例,于是有了Development这个Pod的多实例管理器;为了能够这个Pod能够被访问,于是有了Serivce提供一个固定的IP和和端口以负载均衡的方式来帮助访问Pod。
两个Pod之间访问需要授权,例如Credential(用户名和密码)信息,k8s提供了secret对象,这是一个存储在etcd中的键值对数据库。把Credential以Secret的方式存放在etcd中,k8s就会在指定的Pod启动的时候,自动将Secret中的数据以Volume的方式挂载到容器里,这样一个Pod就可以访问另一个Pod了。
基于Pod,k8s定义了新的改进对象。例如描述一次性运行的Pod,即Job;描述每个宿主机上必须且只能运行一个副本的守护进程服务DaemonSet;描述定时任务的CronJob。
k8s的核心设计理念:
- 首先声明编排对象,比如pod\job\cronjob\daemonset等,来描述应用
- 然后为应用声明一些服务,用来提供平台级别的能力
Kubernetes 项目的本质,是为用户提供一个具有普遍意义的容器编排工具。