About resouer

张磊 浙江大学

《Docker容器与容器云》推荐

《Docker容器与容器云》是Kubernetes社区核心开发者张磊博士及其技术团队近年来PaaS方面积累,全书不仅描述了容器与容器云技术,还融入了实验室四年来对云计算各个层面的理解。该书是国内第一本对Docker原理进行深度解析的书籍,也是第一本结合PaaS对各类容器云进行深度剖析,并着重深入分析Kubernetes原理的书籍。

该书从2014年12月开始写作到2015年9月正式出版发行,期间数易其稿,从最初的对源码进行逐字逐句的分析,转变为带着问题去思考,最后再回到源码去理解问题背后的本质,书的每一章每一节都是实验室智慧与汗水的结晶。

出版发行以来,我们几乎没有对本书进行特别的宣传,凭着少数几次活动以及朋友圈里朋友们的口口相传,逐渐被广大云计算爱好者们发现,并受到了业内外读者的一致好评。从九月初至今,不到两个月的时间,第一次印刷的三千五百册就已接近全部售罄,准备进行第二次印刷。我们也会尽可能多的根据读者的反馈,对第一次印刷中存在的问题进行勘误。

我们自己没有太多权利评判书本本身的好坏,但是我们看到了业内外读者的评价,让我们感受到了深深的肯定,非常感谢大家的支持!同时也向更多朋友推荐我们这本《Docker容器与容器云》,这绝对是您在容器技术方面最值得一读的书!

Docker 容器与容器云-

最后,摘录一些读者的推荐如下:

  • “虽然在此之前已经有了由Docker团队出的第一本Docker书,但是这是国内第一本深入解读Docker与Kubernetes原理的原创图书,这一点意义重大。本书比较完整地介绍了Docker与Kubernetes的工作原理和生态,非常有借鉴意义。” ——许式伟,七牛云存储CEO

  • “Docker容器技术已经在国内如火如荼地流行起来,浙江大学SEL实验室目前是国内掌握Docker技术最熟练的技术团队之一,他们在国内Docker技术界一直产生着重要影响。这次他们把Docker的实战经验汇编成书,可以帮助更多的Docker爱好者学习到一手的实战经验。”——肖德时,数人科技CTO

  • “本书非常细致地讲解了Docker技术的来龙去脉和技术细节,更为难得是还加入了Docker生态当中的其他技术。Docker这项技术本身就是将多种思想和技术融合的产物,从生态的视角去解读技术的来龙去脉将极大地促进读者对云计算和容器技术的重新思考。”——程显峰,OneAPM首席运营官

  • “本书宏观上描绘了容器和容器云技术发展的浪潮和生态系统,微观上以Docker和Kubernetes为典型进行了深度分析。无论是Docker技术爱好者,还是系统架构师、云端开发者、系统管理和运维人员,都能在本书中找到适合自己阅读的要点。浙江大学SEL实验室云计算团队是一支非常优秀的云计算研究团队,很多85后、90后人才活跃在顶级社区前沿,感谢他们能将多年的知识和智慧积累分享出来!”——刘俊,百度运维部高级架构师,百度最高奖获得者

  • “本书是浙江大学SEL实验室云计算团队多年深耕Docker及背后的容器技术的结晶。最大的特点就是深入,并且有各种实用案例和细致讲解。另外,这本书在怎样真正地把Docker及周边产品落地以构建灵活多变的云平台方面也进行了生动的阐释。”——郝林,微影时代架构师,《Go并发编程实战》作者

  • “Docker颠覆了容器技术,也将容器技术带到了新的高度。InfoQ从2014年初就开始密切关注容器技术,见证并切身参与了容器技术的发展。作为我们的优秀作者,浙江大学SEL实验室在InfoQ撰写了很多与Docker、Kubernetes相关的技术文章,得到了广大读者的肯定。希望这本书能推动容器技术在中国的落地。”——郭蕾,InfoQ主编

  • “浙江大学SEL实验室属于国内较早接触并研究开源PaaS技术的团队之一,从传统PaaS的开源代表CloudFoundry、OpenShift,到新一代基于Docker的PaaS平台如DEIS、Flynn等,他们均有深入的研究和实践经验。更为难得的是,他们不仅参与开源贡献,而且笔耕不辍,通过博客、论坛等方式积极分享有深度、有内涵的技术文章,并广泛参与国内PaaS届各种技术交流会议。华为PaaS团队也在与之交流中汲取了不少营养。此次,他们将近年来对Docker容器和Kubernetes、DEIS、Flynn等PaaS开源平台的研究成果结集成册,内容详尽且深入浅出。我相信,无论是入门者还是老手,都能够从中获益。”——刘赫伟,华为中央软件院

  • “容器技术在大型互联网企业中已广泛应用,而Docker是容器技术中的杰出代表。本书不仅介绍了Docker基础知识,而且进行了代码级的深入分析,并通过对Kubernetes等技术的讲解延伸至集群操作系统以及对Docker生态领域的思考,同时结合了大量实践,内容丰富,值得拥有。”——王炜煜,百度运维部高级架构师,JPaaS项目负责人

  • “Docker作为操作系统层面轻量级的虚拟化技术,凭借简易的使用、快速的部署以及灵活敏捷的集成支持等优势,奠定了Docker如今在PaaS领域的江湖地位。 浙江大学SEL实验室在云计算和 PaaS领域耕耘多年, 积累了丰富的经验。本书既有对Docker源代码层面的深度解读,也有实战经验的分享,希望这本书能够帮助Docker开发者在技术上更上一层楼。”——李三红,蚂蚁金服基础技术部JVM Architect

  • 本书覆盖面非常广,从docker的使用、核心原理、高级实践再到编排工具以及著名的集群调度工具kubernetes均有涉及,很好的把握了技术人员的痛点。而且架构原理方面均是用docker1.7和部分1.6作为依据,非常有时效性。《Docker源码分析》的作者也是出于该团队,容器云方面功底很深厚啊。 ——网友Crazykev

  • 比较专业的书,书中的内容远远高于这本书的物理容量,希望好好研究Docker和Linux某些虚拟化机制的同学,可以以这本书入门去不断扩充自己的知识。不过这边毕竟是一Docker为主线的书,主要还是为了深入帮助大家理解Docker,以及Docker生态圈内的应用和服务。一句话:国内不可多得的一本Docker相关知识的书,此书讲解深入,排版合理,内容紧凑,值得好好看。 ——网友RHMAN

  • 其实之前不太了解SEL实验室的背景是什么,不过从此书的内容来看,团队的研究能力和文字能力都相当不错。好久没有读过让人“爽快”的技术书籍了。在Docker应用中各领域的知识要点都能提纲挈领的进行讲解,而且对于生产环境的挑战也有深刻的理解,算得上是既有广度又有深度的佳作。 ——网友jerryshang

Docker背后的标准化容器执行引擎——runC

文 孙健波

随着容器技术发展的愈发火热,Linux基金会于2015年6月成立OCI(Open Container Initiative)组织,旨在围绕容器格式和运行时制定一个开放的工业化标准。该组织一成立便得到了包括谷歌、微软、亚马逊、华为等一系列云计算厂商的支持。而runC就是Docker贡献出来的,按照该开放容器格式标准(OCF, Open Container Format)制定的一种具体实现。

1. 容器格式标准是什么?

制定容器格式标准的宗旨概括来说就是不受上层结构的绑定,如特定的客户端、编排栈等,同时也不受特定的供应商或项目的绑定,即不限于某种特定操作系统、硬件、CPU架构、公有云等。

该标准目前由libcontainerappc的项目负责人(maintainer)进行维护和制定,其规范文档就作为一个项目在Github上维护,地址为https://github.com/opencontainers/specs

1.1 容器标准化宗旨

标准化容器的宗旨具体分为如下五条。

  • 操作标准化:容器的标准化操作包括使用标准容器感觉创建、启动、停止容器,使用标准文件系统工具复制和创建容器快照,使用标准化网络工具进行下载和上传。

  • 内容无关:内容无关指不管针对的具体容器内容是什么,容器标准操作执行后都能产生同样的效果。如容器可以用同样的方式上传、启动,不管是php应用还是mysql数据库服务。

  • 基础设施无关:无论是个人的笔记本电脑还是AWS S3,亦或是Openstack,或者其他基础设施,都应该对支持容器的各项操作。

  • 为自动化量身定制:制定容器统一标准,是的操作内容无关化、平台无关化的根本目的之一,就是为了可以使容器操作全平台自动化。

  • 工业级交付:制定容器标准一大目标,就是使软件分发可以达到工业级交付成为现实。

1.2 容器标准包(bundle)和配置

一个标准的容器包具体应该至少包含三块部分:

  • config.json: 基本配置文件,包括与宿主机独立的和应用相关的特定信息,如安全权限、环境变量和参数等。具体如下:
    • 容器格式版本
    • rootfs路径及是否只读
    • 各类文件挂载点及相应容器内挂载目录(此配置信息必须与runtime.json配置中保持一致)
    • 初始进程配置信息,包括是否绑定终端、运行可执行文件的工作目录、环境变量配置、可执行文件及执行参数、uid、gid以及额外需要加入的gid、hostname、低层操作系统及cpu架构信息。
  • runtime.json: 运行时配置文件,包含运行时与主机相关的信息,如内存限制、本地设备访问权限、挂载点等。除了上述配置信息以外,运行时配置文件还提供了“钩子(hooks)”的特性,这样可以在容器运行前和停止后各执行一些自定义脚本。hooks的配置包含执行脚本路径、参数、环境变量等。
  • rootfs/:根文件系统目录,包含了容器执行所需的必要环境依赖,如/bin/var/lib/dev/usr等目录及相应文件。rootfs目录必须与包含配置信息的config.json文件同时存在容器目录最顶层。

1.3 容器运行时和生命周期

容器标准格式也要求容器把自身运行时的状态持久化到磁盘中,这样便于外部的其他工具对此信息使用和演绎。该运行时状态以JSON格式编码存储。推荐把运行时状态的json文件存储在临时文件系统中以便系统重启后会自动移除。

基于Linux内核的操作系统,该信息应该统一地存储在/run/opencontainer/containers目录,该目录结构下以容器ID命名的文件夹(/run/opencontainer/containers/<containerID>/state.json)中存放容器的状态信息并实时更新。有了这样默认的容器状态信息存储位置以后,外部的应用程序就可以在系统上简便地找到所有运行着的容器了。

state.json文件中包含的具体信息需要有:

  • 版本信息:存放OCI标准的具体版本号。
  • 容器ID:通常是一个哈希值,也可以是一个易读的字符串。在state.json文件中加入容器ID是为了便于之前提到的运行时hooks只需载入state.json就可以定位到容器,然后检测state.json,发现文件不见了就认为容器关停,再执行相应预定义的脚本操作。
  • PID:容器中运行的首个进程在宿主机上的进程号。
  • 容器文件目录:存放容器rootfs及相应配置的目录。外部程序只需读取state.json就可以定位到宿主机上的容器文件目录。

标准的容器生命周期应该包含三个基本过程。

  • 容器创建:创建包括文件系统、namespaces、cgroups、用户权限在内的各项内容。
  • 容器进程的启动:运行容器进程,进程的可执行文件定义在的config.json中,args项。
  • 容器暂停:容器实际上作为进程可以被外部程序关停(kill),然后容器标准规范应该包含对容器暂停信号的捕获,并做相应资源回收的处理,避免孤儿进程的出现。

1.4 基于开放容器格式(OCF)标准的具体实现

从上述几点中总结来看,开放容器规范的格式要求非常宽松,它并不限定具体的实现技术也不限定相应框架,目前已经有基于OCF的具体实现,相信不久后会有越来越多的项目出现。

  • 容器运行时opencontainers/runc,即本文所讲的runc项目,是后来者的参照标准。
  • 虚拟机运行时hyperhq/runv,基于Hypervisor技术的开放容器规范实现。
  • 测试huawei-openlab/oct基于开放容器规范的测试框架。

2. runC工作原理与实现方式

runC的前身实际上是Docker的libcontainer项目,笔者曾经写过一篇文章《Docker背后的容器管理——Libcontainer深度解析》专门对libcontainer进行源码分析和解读,感兴趣的读者可以先阅读一下,目前runC也是对libcontainer包的调用,libcontainer包变化并不大。所以此文将不再花费太多笔墨分析其源码,我们将着重讲解其中的变化。

2.1 runC从libcontainer的变迁

从本质上来说,容器是提供一个与宿主机系统共享内核但与系统中的其他进程资源相隔离的执行环境。Docker通过调用libcontainer包对namespaces、cgroups、capabilities以及文件系统的管理和分配来“隔离”出一个上述执行环境。同样的,runC也是对libcontainer包进行调用,去除了Docker包含的诸如镜像、Volume等高级特性,以最朴素简洁的方式达到符合OCF标准的容器管理实现。

总体而言,从libcontainer项目转变为runC项目至今,其功能和特性并没有太多变化,具体有如下几点。

  1. 把原先的nsinit移除,放到外面,命令名称改为runc,同样使用cli.go实现,一目了然。
  2. 按照开放容器标准把原先所有信息混在一起的一个配置文件拆分成config.jsonruntime.json两个。
  3. 增加了按照开放容器标准设定的容器运行前和停止后执行的hook脚本功能。
  4. 相比原先的nsinit时期的指令,增加了runc kill命令,用于发送一个SIG_KILL信号给指定容器ID的init进程。

总体而言,runC希望包含的特征有:

  • 支持所有的Linux namespaces,包括user namespaces。目前user namespaces尚未包含。
  • 支持Linux系统上原有的所有安全相关的功能,包括Selinux、 Apparmor、seccomp、cgroups、capability drop、pivot_root、 uid/gid dropping等等。目前已完成上述功能的支持。
  • 支持容器热迁移,通过CRIU技术实现。目前功能已经实现,但是使用起来还会产生问题。
  • 支持Windows 10 平台上的容器运行,由微软的工程师开发中。目前只支持Linux平台。
  • 支持Arm、Power、Sparc硬件架构,将由Arm、Intel、Qualcomm、IBM及整个硬件制造商生态圈提供支持。
  • 计划支持尖端的硬件功能,如DPDK、sr-iov、tpm、secure enclave等等。
  • 生产环境下的高性能适配优化,由Google工程师基于他们在生产环境下的容器部署经验而贡献。
  • 作为一个正式真实而全面具体的标准存在!

2.2 runC是如何启动容器的?

从开放容器标准中我们已经定义了关于容器的两份配置文件和一个依赖包,runc就是通过这些来启动一个容器的。首先我们按照官方的步骤来操作一下。

runc运行时需要有rootfs,最简单的就是你本地已经安装好了Docker,通过

docker pull busybox

下载一个基本的镜像,然后通过

docker export $(docker create busybox) > busybox.tar

导出容器镜像的rootfs文件压缩包,命名为busybox.tar。然后解压缩为rootfs目录。

mkdir rootfs
tar -C rootfs -xf busybox.tar

这时我们就有了OCF标准的rootfs目录,需要说明的是,我们使用Docker只是为了获取rootfs目录的方便,runc的运行本身不依赖Docker。

接下来你还需要config.jsonruntime.json,使用

runc spec 可以生成一份标准的config.jsonruntime.json配置文件,当然你也可以按照格式自己编写。

如果你还没有安装runc,那就需要按照如下步骤安装一下,目前runc暂时只支持Linux平台。

`\

create a ‘github.com/opencontainers’ in your GOPATH/src

cd github.com/opencontainers git clone https://github.com/opencontainers/runc cd runc make sudo make install `\

最后执行

runc start

你就启动了一个容器了。

可以看到,我们对容器的所有定义,均包含在两份配置文件中,一份简略的config.json配置文件类似如下,已用省略号省去部分信息,完整的可以查看官方github

{
"version": "0.1.0",
"platform": {
"os": "linux",
"arch": "amd64"
},
"process": {
"terminal": true,
"user": {
"uid": 0,
"gid": 0,
"additionalGids": null
},
"args": [
"sh"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
],
"cwd": ""
},
"root": {
"path": "rootfs",
"readonly": true
},
"hostname": "shell",
"mounts": [
{
"name": "proc",
"path": "/proc"
},
……
{
"name": "cgroup",
"path": "/sys/fs/cgroup"
}
],
"linux": {
"capabilities": [
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE"
]
}
}
各部分表示的意思在1.2节中已经讲解,针对具体的内容我们可以看到,版本是0.10,该配置文件针对的是AMD64架构下的Linux系统,启动容器后执行的命令就是sh,配置的环境变量有两个,是PATHTERM,启动后user的uid和gid都为0,表示进入后是root用户。cwd项为空表示工作目录为当前目录。capabilities能力方面则使用白名单的形式,从配置上可以看到只允许三个能力,功能分别为允许写入审计日志、允许发送信号、允许绑定socket到网络端口。

一份简略的runtime.json配置则如下,同样用省略号省去了部分内容:

{
"mounts": {
"proc": {
"type": "proc",
"source": "proc",
"options": null
},
……
"cgroup": {
"type": "cgroup",
"source": "cgroup",
"options": [
"nosuid",
"noexec",
"nodev",
"relatime",
"ro"
]
}
},
"hooks": {
"prestart": null,
"poststop": null
},
"linux": {
"uidMappings": null,
"gidMappings": null,
"rlimits": [
{
"type": "RLIMIT_NOFILE",
"hard": 1024,
"soft": 1024
}
],
"sysctl": null,
"resources": {
"disableOOMKiller": false,
"memory": {
"limit": 0,
"reservation": 0,
"swap": 0,
"kernel": 0,
"swappiness": -1
},
"cpu": {
"shares": 0,
"quota": 0,
"period": 0,
"realtimeRuntime": 0,
"realtimePeriod": 0,
"cpus": "",
"mems": ""
},
"pids": {
"limit": 0
},
"blockIO": {
"blkioWeight": 0,
"blkioWeightDevice": "",
"blkioThrottleReadBpsDevice": "",
"blkioThrottleWriteBpsDevice": "",
"blkioThrottleReadIopsDevice": "",
"blkioThrottleWriteIopsDevice": ""
},
"hugepageLimits": null,
"network": {
"classId": "",
"priorities": null
}
},
"cgroupsPath": "",
"namespaces": [
{
"type": "pid",
"path": ""
},
{
"type": "network",
"path": ""
},
{
"type": "ipc",
"path": ""
},
{
"type": "uts",
"path": ""
},
{
"type": "mount",
"path": ""
}
],
"devices": [
{
"path": "/dev/null",
"type": 99,
"major": 1,
"minor": 3,
"permissions": "rwm",
"fileMode": 438,
"uid": 0,
"gid": 0
},
……
{
"path": "/dev/urandom",
"type": 99,
"major": 1,
"minor": 9,
"permissions": "rwm",
"fileMode": 438,
"uid": 0,
"gid": 0
}
],
"apparmorProfile": "",
"selinuxProcessLabel": "",
"seccomp": {
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": []
},
"rootfsPropagation": ""
}
}

可以看到基本的几项配置分别为挂载点信息、启动前与停止后hooks脚本、然后就是针对Linux的特性支持的诸如用户uid/gid绑定,rlimit配置、namespace设置、cgroups资源限额、设备权限配置、apparmor配置项目录、selinux标记以及seccomp配置。其中,namespacescgroups笔者均有写文章详细介绍过。

再下面的工作便都由libcontainer完成了,大家可以阅读这个系列前一篇文章《Docker背后的容器管理——Libcontainer深度解析》或者购买书籍《Docker容器与容器云》,里面均有详细介绍。

简单来讲,有了配置文件以后,runC就开始借助libcontainer处理以下事情:

  • 创建libcontainer构建容器需要使用的进程,称为Process;
  • 设置容器的输出管道,这里使用的就是daemon提供的pipes;
  • 使用名为Factory的工厂类,通过factory.Create(<容器ID>, <填充好的容器模板container>)创建一个逻辑上的容器,称为Container;
  • 执行Container.Start(Process)启动物理的容器;
  • runC等待Process的所有工作都完成。

可以看到,具体的执行者是libcontainer,它是对容器的一层抽象,它定义了Process和Container来对应Linux中“进程”与“容器”的关系。一旦上述物理的容器创建成功,其他调用者就可以通过ID获取这个容器,接着使用Container.Stats得到容器的资源使用信息,或者执行Container.Destory来销毁这个容器。

综上,runC实际上是把配置交给libcontainer,然后由libcontainer完成容器的启动,而libcontainer中最主要的内容是Process、Container以及Factory这3个逻辑实体的实现原理。runC或者其他调用者只要依次执行“使用Factory创建逻辑容器Container”、“用Process启动逻辑容器Container”即可。

3. 总结

本文从OCI组织的成立开始讲起,描述了开放容器格式的标准及其宗旨,这其实就是runC的由来。继而针对具体的runC特性及其启动进行了详细介绍。笔者在后续的文章中还将针对runC中诸如CRIU热迁移、selinux、apparmor及seccomp配置等特性进行具体的介绍。可以看到OCI的成立表明了社区及各大厂商对容器技术的肯定以及加快容器技术发展进步的强烈决心,相信在不久的将来,符合OCI标准的开放容器项目会越来越多,容器技术将更加欣欣向荣地不断前进。

4. 作者简介

孙健波,浙江大学SEL实验室硕士研究生,《Docker容器与容器云》主要作者之一,目前在云平台团队从事科研和开发工作。浙大团队对PaaS、Docker、大数据和主流开源云计算技术有深入的研究和二次开发经验,团队现将部分技术文章贡献出来,希望能对读者有所帮助。

kubernetes apiserver源码分析——api请求的认证过程

文 王哲

解决什么问题

笔者之前希望全面分析一下k8apiserver的源码,后来发现这样并不十分有效,其一没有针对性,其二由于代码本身比较复杂,涉及到的功能较多,面面俱到也不太现实。

于是我们就回到最初的需求,到底需要解决什么问题,第一个问题就是,apiserver启动的时候,使用secure模式,参数应该如何设置,相关的机制又是怎样?

这一部分的issue很多,如果不从源码来分析的话,就只能黑盒化的去尝试各种参数搭配,费时费力,也不确定是否正确,之前就是这样,这几个issue可以供参考:

https://github.com/GoogleCloudPlatform/kubernetes/issues/10159#issuecomment-113955582

https://github.com/GoogleCloudPlatform/kubernetes/issues/11000

Continue reading

tutum-agent原理浅析

孙健波

tutum-aget是tutum提供的一个开源代理引擎。当你把tutum-agent安装在你本地机器上以后,你就可以把本地的机器节点添加到云端,让tutum来帮你统一进行管理。你随时随地都可以登陆tutum网站获得如下功能。

  1. 启停Docker容器。
  2. 动态伸缩容器实例数量。
  3. 监控容器状态、容器CPU\Memory\Disk\Bandwidth使用量
  4. 查看容器日志、操作记录
  5. 与tutum其他服务结合的衍生功能(部署应用、绑定服务等)

可见,安装了tutum-agent以后相当于把本地的机器加入到了tutum的数据中心进行统一管理。本文将分析tutum-agent的工作原理。

安装

tutum-agent通过shell进行安装,安装脚本可以在执行官方的安装指令时下载curl -Ls https://get.tutum.co/,也可以在github源码中找到。

这个shell脚本很简单,根据不同linux发行版安装tutum-agent,如果是ubuntu则是执行apt-get install -yq tutum-agent,然后生成配置文件/etc/tutum/agent/tutum-agent.conf,内容如下。

这些配置文件的作用在随后的代码解析中讲解。生成完配置文件后启动该服务。

service tutum-agent restart

至此shell脚本执行完了,但是安装过程并没有结束,在安装tutum-agent时,会因为冲突卸载原先已经安装好的Docker。

启动后的第一件事就是在TutumHost上注册并验证用户的TutumToken和TutumUUID,获得相应的证书文件,然后从TutumHost上下载Docker的二进制文件,目前Tutum官方提供的是1.5版本的Docker。随后启动Docker daemon,之前下载的证书也主要用在这里。

启动完Docker以后,就开始下载Ngrok客户端。Ngrok是一个网络代理,它可以让你在防火墙或者NAT映射的私有网络内的服务,让公网可以直接访问。Ngrok的实现方式是,在公网架设一个Ngrok 服务器,私有网络的Ngrok客户端与公网的Ngrok Server保持tcp长连接,用户通过访问Ngrok Server进行代理,从而访问到Ngrok客户端所在机器的服务。使用Ngrok客户端,只需要配置你想要提供服务的应用程序对外服务的端口即可。把这个端neng l口告诉Ngrok客户端,就相当于把本地私有网络下的服务提供给外部。

至此,所有的服务也就安装完毕了。总结一下,我们实际上安装了tutum-agent、Docker、Ngrok三个应用程序。通过Ngrok把本地的Docker作为服务让公网的其他用户可以使用,tutum-agent在随后会处理包括日志、监控、Docker指令处理等相关内容。

连接

安装完Ngrok以后,实际上就相当于建立了穿越NAT网络的隧道,使得公网所在的程序可以直接控制私有网络的Docker服务。Tutum-agent在启动Docker时使用了用户相关的证书,保证了Docker只接受有证书认证的请求,一定程度上保证了本地服务的安全。

获得了本地Docker服务的控制能力,容器相关的功能的实现自然迎刃而解。Ngrok还会为用户提供命令操作日志以便于审计,所以你可以方便的查看容器的操作记录。

建立了连接后我们还需要做什么?

通过Ngrok,这一切都变得很简单,但是别忘了,Docker daemon的运行状态需要时刻监控。tutum-agent会对Docker daemon的运行状态周期性的检查,一旦daemon意外推出,可以保证快速检测到并重启。

性能

ngrok是一个非常强大的tunnel工具,使用它以后本身带来的代理转发性能损失非常低。

但是实际上通过ngrok搭建起来的服务,都是应用用户先去访问ngrok服务端,然后再有服务端转发给ngrok客户端,最后处理用户请求并返回。所以,真正让人感知到性能损失的可能是你的ngrok服务端搭建的位置较远,而ngrok客户端与应用用户本身网络较近,这样就容易导致较高的应用访问延迟。

延伸

看完了上述内容,可能你会想到,如何构建一个类似Tutum的“Bring your own node”服务,并把它应用到Docker以为的其他项目上。

第一步,搭建自己的ngrok服务,使得用户可以通过ngrok客户端连接并在任意网络访问本地的服务,可参考博客“搭建自己的ngrok服务”

第二步,区分用户。当你的ngrok对外提供服务的时候,会有许多客户端来连接,不同的客户端可能是不同的用户连接的,也有可能是同一个用户的不同应用或不同主机节点。所以你需要在服务端编写自己的判断逻辑,方法很简单。ngrok客户端与服务端建立连接时会生成子域名(或者自定义域名),这个域名一旦建立了连接,就是唯一的,别的用户无法占用,通过这个方法就可以进行最简单的区分。当然,根据你对外提供的服务,你可能还需要通过生成证书来保证服务的安全性。

第三步,通过API进行操控。当ngrok客户端连接成功后,实际上服务端已经可以连接用户私有网络下服务的端口了,通过端口自然可以访问到服务的API,就基本上可以让用户全局操控自己的服务了。

第四步,日志、监控、报警与可视化。简单的日志收集分为两部分,一部分自然是ngrok的操作日志,另一部分则是应用相关的日志,需要通过应用API收取。简单来说,监控也分为两部分,一部分是用户主机资源、服务可用性相关的数据监控,另一部分是业务相关的监控。

基本上,有了这些,一个最基本的服务就完成了。

Docker背后的容器管理——libcontainer深度解析

孙健波

libcontainer 是Docker中用于容器管理的包,它基于Go语言实现,通过管理namespacescgroupscapabilities以及文件系统来进行容器控制。你可以使用libcontainer创建容器,并对容器进行生命周期管理。

容器是一个可管理的执行环境,与主机系统共享内核,可与系统中的其他容器进行隔离。

在2013年Docker刚发布的时候,它是一款基于LXC的开源容器管理引擎。把LXC复杂的容器创建与使用方式简化为Docker自己的一套命令体系。随着Docker的不断发展,它开始有了更为远大的目标,那就是反向定义容器的实现标准,将底层实现都抽象化到libcontainer的接口。这就意味着,底层容器的实现方式变成了一种可变的方案,无论是使用namespace、cgroups技术抑或是使用systemd等其他方案,只要实现了libcontainer定义的一组接口,Docker都可以运行。这也为Docker实现全面的跨平台带来了可能。

1. libcontainer 特性

目前版本的libcontainer,功能实现上涵盖了包括namespaces使用、cgroups管理、Rootfs的配置启动、默认的Linux capability权限集、以及进程运行的环境变量配置。内核版本最低要求为2.6,最好是3.8,这与内核对namespace的支持有关。

目前除user namespace不完全支持以外,其他五个namespace都是默认开启的,通过clone系统调用进行创建。

1.1 建立文件系统

文件系统方面,容器运行需要rootfs。所有容器中要执行的指令,都需要包含在rootfs(在Docker中指令包含在其上叠加的镜像层也可以执行)所有挂载在容器销毁时都会被卸载,因为mount namespace会在容器销毁时一同消失。为了容器可以正常执行命令,以下文件系统必须在容器运行时挂载到rootfs中。

路径 类型 参数 权限及数据
/proc proc MS_NOEXEC,MS_NOSUID,MS_NODEV
/dev tmpfs MS_NOEXEC,MS_STRICTATIME mode=755
/dev/shm shm MS_NOEXEC,MS_NOSUID,MS_NODEV mode=1777,size=65536k
/dev/mqueue mqueue MS_NOEXEC,MS_NOSUID,MS_NODEV
/dev/pts devpts MS_NOEXEC,MS_NOSUID newinstance,ptmxmode=0666,mode=620,gid5
/sys sysfs MS_NOEXEC,MS_NOSUID,MS_NODEV,MS_RDONLY

当容器的文件系统刚挂载完毕时,/dev文件系统会被一系列设备节点所填充,所以rootfs不应该管理/dev文件系统下的设备节点,libcontainer会负责处理并正确启动这些设备。设备及其权限模式如下。

路径 模式 权限
/dev/null 0666 rwm
/dev/zero 0666 rwm
/dev/full 0666 rwm
/dev/tty 0666 rwm
/dev/random 0666 rwm
/dev/urandom 0666 rwm
/dev/fuse 0666 rwm

容器支持伪终端TTY,当用户使用时,就会建立/dev/console设备。其他终端支持设备,如/dev/ptmx则是宿主机的/dev/ptmx 链接。容器中指向宿主机 /dev/null的IO也会被重定向到容器内的 /dev/null设备。当/proc挂载完成后,/dev/中与IO相关的链接也会建立,如下表。

源地址 目的地址
/proc/1/fd /dev/fd
/proc/1/fd/0 /dev/stdin
/proc/1/fd/1 /dev/stdout
/proc/1/fd/2 /dev/stderr

pivot_root 则用于改变进程的根目录,这样可以有效的将进程控制在我们建立的rootfs中。如果rootfs是基于ramfs的(不支持pivot_root),那么会在mount时使用MS_MOVE标志位加上chroot来顶替。

当文件系统创建完毕后,umask权限被重新设置回0022

1.2 资源管理

《Docker背后的内核知识:cgroups资源隔离》一文中已经提到,Docker使用cgroups进行资源管理与限制,包括设备、内存、CPU、输入输出等。

目前除网络外所有内核支持的子系统都被加入到libcontainer的管理中,所以libcontainer使用cgroups原生支持的统计信息作为资源管理的监控展示。

容器中运行的第一个进程init,必须在初始化开始前放置到指定的cgroup目录中,这样就能防止初始化完成后运行的其他用户指令逃逸出cgroups的控制。父子进程的同步则通过管道来完成,在随后的运行时初始化中会进行展开描述。

1.3 可配置的容器安全

容器安全一直是被广泛探讨的话题,使用namespace对进程进行隔离是容器安全的基础,遗憾的是,usernamespace由于设计上的复杂性,还没有被libcontainer完全支持。

libcontainer目前可通过配置capabilitiesSELinuxapparmor 以及seccomp进行一定的安全防范,目前除seccomp以外都有一份默认的配置项提供给用户作为参考。

在本系列的后续文章中,我们将对容器安全进行更深入的探讨,敬请期待。

1.4 运行时与初始化进程

在容器创建过程中,父进程需要与容器的init进程进行同步通信,通信的方式则通过向容器中传入管道来实现。当init启动时,他会等待管道内传入EOF信息,这就给父进程完成初始化,建立uid/gid映射,并把新进程放进新建的cgroup一定的时间。

在libcontainer中运行的应用(进程),应该是事先静态编译完成的。libcontainer在容器中并不提供任何类似Unix init这样的守护进程,用户提供的参数也是通过exec系统调用提供给用户进程。通常情况下容器中也没有长进程存在。

如果容器打开了伪终端,就会通过dup2把console作为容器的输入输出(STDIN, STDOUT, STDERR)对象。

除此之外,以下4个文件也会在容器运行时自动生成。
* /etc/hosts
* /etc/resolv.conf
* /etc/hostname
* /etc/localtime

1.5 在运行着的容器中执行新进程

用户也可以在运行着的容器中执行一条新的指令,就是我们熟悉的docker exec功能。同样,执行指令的二进制文件需要包含在容器的rootfs之内。

通过这种方式运行起来的进程会随容器的状态变化,如容器被暂停,进程也随之暂停,恢复也随之恢复。当容器进程不存在时,进程就会被销毁,重启也不会恢复。

1.6 容器热迁移(Checkpoint & Restore)

目前libcontainer已经集成了CRIU作为容器检查点保存与恢复(通常也称为热迁移)的解决方案,应该在不久之后就会被Docker使用。也就是说,通过libcontainer你已经可以把一个正在运行的进程状态保存到磁盘上,然后在本地或其他机器中重新恢复当前的运行状态。这个功能主要带来如下几个好处。

  • 服务器需要维护(如系统升级、重启等)时,通过热迁移技术把容器转移到别的服务器继续运行,应用服务信息不会丢失。
  • 对于初始化时间极长的应用程序来说,容器热迁移可以加快启动时间,当应用启动完成后就保存它的检查点状态,下次要重启时直接通过检查点启动即可。
  • 在高性能计算的场景中,容器热迁移可以保证运行了许多天的计算结果不会丢失,只要周期性的进行检查点快照保存就可以了。

要使用这个功能,需要保证机器上已经安装了1.5.2或更高版本的criu工具。不同Linux发行版都有criu的安装包,你也可以在CRIU官网上找到从源码安装的方法。我们将会在nsinit的使用中介绍容器热迁移的使用方法。

CRIU(Checkpoint/Restore In Userspace)由OpenVZ项目于2005年发起,因为其涉及的内核系统繁多、代码多达数万行,其复杂性与向后兼容性都阻碍着它进入内核主线,几经周折之后决定在用户空间实现,并在2012年被Linus加并入内核主线,其后得以快速发展。

你可以在CRIU官网查看其原理,简单描述起来可以分为两部分,一是检查点的保存,其中分为3步。

  1. 收集进程与其子进程构成的树,并冻结所有进程。
  2. 收集任务(包括进程和线程)使用的所有资源,并保存。
  3. 清理我们收集资源的相关寄生代码,并与进程分离。

第二部分自然是恢复,分为4步。

  1. 读取快照文件并解析出共享的资源,对多个进程共享的资源优先恢复,其他资源则随后需要时恢复。
  2. 使用fork恢复整个进程树,注意此时并不恢复线程,在第4步恢复。
  3. 恢复所有基础任务(包括进程和线程)资源,除了内存映射、计时器、证书和线程。这一步主要打开文件、准备namespace、创建socket连接等。
  4. 恢复进程运行的上下文环境,恢复剩下的其他资源,继续运行进程。

至此,libcontainer的基本特性已经预览完毕,下面我们将从使用开始,一步步深入libcontainer的原理。

2. nsinit与libcontainer的使用

俗话说,了解一个工具最好的入门方式就是去使用它,nsinit就是一个为了方便不通过Docker就可以直接使用libcontainer而开发的命令行工具。它可以用于启动一个容器或者在已有的容器中执行命令。使用nsinit需要有 rootfs 以及相应的配置文件。

2.1 nsinit的构建

使用nsinit需要rootfs,最简单最常用的是使用Docker busybox,相关配置文件则可以参考sample_configs目录,主要配置的参数及其作用将在配置参数一节中介绍。拷贝一份命名为container.json文件到你rootfs所在目录中,这份文件就包含了你对容器做的特定配置,包括运行环境、网络以及不同的权限。这份配置对容器中的所有进程都会产生效果。

具体的构建步骤在官方的README文档中已经给出,在此为了节省篇幅不再赘述。

最终编译完成后生成nsinit二进制文件,将这个指令加入到系统的环境变量,在busybox目录下执行如下命令,即可使用,需要root权限。

nsinit exec --tty --config container.json /bin/bash

执行完成后会生成一个以容器ID命名的文件夹,上述命令没有指定容器ID,默认名为”nsinit”,在“nsinit”文件夹下会生成一个state.json文件,表示容器的状态,其中的内容与配置参数中的内容类似,展示容器的状态。

2.2 nsinit的使用

目前nsinit定义了9个指令,使用nsinit -h就可以看到,对于每个单独的指令使用--help就能获得更详细的使用参数,如nsinit config --help

nsinit这个命令行工具是通过cli.go实现的,cli.go封装了命令行工具需要做的一些细节,包括参数解析、命令执行函数构建等等,这就使得nsinit本身的代码非常简洁明了。具体的命令功能如下。

  • config:使用内置的默认参数加上执行命令时用户添加的部分参数,生成一份容器可用的标准配置文件。
  • exec:启动容器并执行命令。除了一些共有的参数外,还有如下一些独有的参数。
    • –tty,-t:为容器分配一个终端显示输出内容。
    • –config:使用配置文件,后跟文件路径。
    • –id:指定容器ID,默认为nsinit
    • –user,-u:指定用户,默认为“root”.
    • –cwd:指定当前工作目录。
    • –env:为进程设置环境变量。
  • init:这是一个内置的参数,用户并不能直接使用。这个命令是在容器内部执行,为容器进行namespace初始化,并在完成初始化后执行用户指令。所以在代码中,运行nsinit exec后,传入到容器中运行的实际上是nsinit init,把用户指令作为配置项传入。
  • oom:展示容器的内存超限通知。
  • pause/unpause:暂停/恢复容器中的进程。
  • stats:显示容器中的统计信息,主要包括cgroup和网络。
  • state:展示容器状态,就是读取state.json文件。
  • checkpoint:保存容器的检查点快照并结束容器进程。需要填--image-path参数,后面是检查点保存的快照文件路径。完整的命令示例如下。
    nsinit checkpoint --image-path=/tmp/criu

  • restore:从容器检查点快照恢复容器进程的运行。参数同上。

总结起来,nsinit与Docker execdriver进行的工作基本相同,所以在Docker的源码中并不会涉及到nsinit包的调用,但是nsinit为libcontainer自身的调试和使用带来了极大的便利。

3. 配置参数解析

  • no_pivot_root :这个参数表示用rootfs作为文件系统挂载点,不单独设置pivot_root
  • parent_death_signal: 这个参数表示当容器父进程销毁时发送给容器进程的信号。
  • pivot_dir:在容器root目录中指定一个目录作为容器文件系统挂载点目录。
  • rootfs:容器根目录位置。
  • readonlyfs:设定容器根目录为只读。
  • mounts:设定额外的挂载,填充的信息包括原路径,容器内目的路径,文件系统类型,挂载标识位,挂载的数据大小和权限,最后设定共享挂载还是非共享挂载(独立于mount_label的设定起作用)。
  • devices:设定在容器启动时要创建的设备,填充的信息包括设备类型、容器内设备路径、设备块号(major,minor)、cgroup文件权限、用户编号、用户组编号。
  • mount_label:设定共享挂载还是非共享挂载。
  • hostname:设定主机名。
  • namespaces:设定要加入的namespace,每个不同种类的namespace都可以指定,默认与父进程在同一个namespace中。
  • capabilities:设定在容器内的进程拥有的capabilities权限,所有没加入此配置项的capabilities会被移除,即容器内进程失去该权限。
  • networks:初始化容器的网络配置,包括类型(loopback、veth)、名称、网桥、物理地址、IPV4地址及网关、IPV6地址及网关、Mtu大小、传输缓冲长度txqueuelen、Hairpin Mode设置以及宿主机设备名称。
  • routes:配置路由表。
  • cgroups:配置cgroups资源限制参数,使用的参数不多,主要包括允许的设备列表、内存、交换区用量、CPU用量、块设备访问优先级、应用启停等。
  • apparmor_profile:配置用于SELinux的apparmor文件。
  • process_label:同样用于selinux的配置。
  • rlimits:最大文件打开数量,默认与父进程相同。
  • additional_groups:设定gid,添加同一用户下的其他组。
  • uid_mappings:用于User namespace的uid映射。
  • gid_mappings:用户User namespace的gid映射。
  • readonly_paths:在容器内设定只读部分的文件路径。
  • MaskPaths:配置不使用的设备,通过绑定/dev/null进行路径掩盖。

4. libcontainer实现原理

在Docker中,对容器管理的模块为execdriver,目前Docker支持的容器管理方式有两种,一种就是最初支持的LXC方式,另一种称为native,即使用libcontainer进行容器管理。在孙宏亮的《Docker源码分析系列》中,Docker Deamon启动过程中就会对execdriver进行初始化,会根据驱动的名称选择使用的容器管理方式。

虽然在execdriver中只有LXC和native两种选择,但是native(即libcontainer)通过接口的方式定义了一系列容器管理的操作,包括处理容器的创建(Factory)、容器生命周期管理(Container)、进程生命周期管理(Process)等一系列接口,相信如果Docker的热潮一直像如今这般汹涌,那么不久的将来,Docker必将实现其全平台通用的宏伟蓝图。本节也将从libcontainer的这些抽象对象开始讲解,与你一同解开Docker容器管理之谜。在介绍抽象对象的具体实现过程中会与Docker execdriver联系起来,让你充分了解整个过程。

4.1 Factory 对象

Factory对象为容器创建和初始化工作提供了一组抽象接口,目前已经具体实现的是Linux系统上的Factory对象。Factory抽象对象包含如下四个方法,我们将主要描述这四个方法的工作过程,涉及到具体实现方法则以LinuxFactory为例进行讲解。

  1. Create():通过一个id和一份配置参数创建容器,返回一个运行的进程。容器的id由字母、数字和下划线构成,长度范围为1~1024。容器ID为每个容器独有,不能冲突。创建的最终返回一个Container类,包含这个id、状态目录(在root目录下创建的以id命名的文件夹,存state.json容器状态文件)、容器配置参数、初始化路径和参数,以及管理cgroup的方式(包含直接通过文件操作管理和systemd管理两个选择,默认选cgroup文件系统管理)。
  2. Load():当创建的id已经存在时,即已经Create过,存在id文件目录,就会从id目录下直接读取state.json来载入容器。其中的参数在配置参数部分有详细解释。
  3. Type():返回容器管理的类型,目前可能返回的有libcontainer和lxc,为未来支持更多容器接口做准备。
  4. StartInitialization():容器内初始化函数。
    • 这部分代码是在容器内部执行的,当容器创建时,如果New不加任何参数,默认在容器进程中运行的第一条命令就是nsinit init。在execdriver的初始化中,会向reexec注册初始化器,命名为native,然后在创建libcontainer以后把native作为执行参数传递到容器中执行,这个初始化器创建的libcontainer就是没有参数的。
    • 传入的参数是一个管道文件描述符,为了保证在初始化过程中,父子进程间状态同步和配置信息传递而建立。
    • 不管是纯粹新建的容器还是已经创建的容器执行新的命令,都是从这个入口做初始化。
    • 第一步,通过管道获取配置信息。
    • 第二步,从配置信息中获取环境变量并设置为容器内环境变量。
    • 若是已经存在的容器执行新命令,则只需要配置cgroup、namespace的Capabilities以及AppArmor等信息,最后执行命令。
    • 若是纯粹新建的容器,则还需要初始化网络、路由、namespace、主机名、配置只读路径等等,最后执行命令。

至此,容器就已经创建和初始化完毕了。

4.2 Container 对象

Container对象主要包含了容器配置、控制、状态显示等功能,是对不同平台容器功能的抽象。目前已经具体实现的是Linux平台下的Container对象。每一个Container进程内部都是线程安全的。因为Container有可能被外部的进程销毁,所以每个方法都会对容器是否存在进行检测。

  1. ID():显示Container的ID,在Factor对象中已经说过,ID很重要,具有唯一性。
  2. Status():返回容器内进程是运行状态还是停止状态。通过执行“SIG=0”的KILL命令对进程是否存在进行检测。
  3. State():返回容器的状态,包括容器ID、配置信息、初始进程ID、进程启动时间、cgroup文件路径、namespace路径。通过调用Status()判断进程是否存在。
  4. Config():返回容器的配置信息,可在“配置参数解析”部分查看有哪些方面的配置信息。
  5. Processes():返回cgroup文件cgroup.procs中的值,在Docker背后的内核知识:cgroups资源限制部分的讲解中我们已经提过,cgroup.procs文件会罗列所有在该cgroup中的线程组ID(即若有线程创建了子线程,则子线程的PID不包含在内)。由于容器不断在运行,所以返回的结果并不能保证完全存活,除非容器处于“PAUSED”状态。
  6. Stats():返回容器的统计信息,包括容器的cgroups中的统计以及网卡设备的统计信息。Cgroups中主要统计了cpu、memory和blkio这三个子系统的统计内容,具体了解可以通过阅读“cgroups资源限制”部分对于这三个子系统统计内容的介绍来了解。网卡设备的统计则通过读取系统中,网络网卡文件的统计信息文件/sys/class/net/<EthInterface>/statistics来实现。
  7. Set():设置容器cgroup各子系统的文件路径。因为cgroups的配置是进程运行时也会生效的,所以我们可以通过这个方法在容器运行时改变cgroups文件从而改变资源分配。
  8. Start():构建ParentProcess对象,用于处理启动容器进程的所有初始化工作,并作为父进程与新创建的子进程(容器)进行初始化通信。传入的Process对象可以帮助我们追踪进程的生命周期,Process对象将在后文详细介绍。
    • 启动的过程首先会调用Status()方法的具体实现得知进程是否存活。
    • 创建一个管道(详见Docker初始化通信——管道)为后期父子进程通信做准备。
    • 配置子进程cmd命令模板,配置参数的值就是从factory.Create()传入进来的,包括命令执行的工作目录、命令参数、输入输出、根目录、子进程管道以及KILL信号的值。
    • 根据容器进程是否存在确定是在已有容器中执行命令还是创建新的容器执行命令。若存在,则把配置的命令构建成一个exec.Cmd对象、cgroup路径、父子进程管道及配置保留到ParentProcess对象中;若不存在,则创建容器进程及相应namespace,目前对user namespace有了一定的支持,若配置时加入user namespace,会针对配置项进行映射,默认映射到宿主机的root用户,最后同样构建出相应的配置内容保留到ParentProcess对象中。通过在cmd.Env写入环境变量_libcontainer_INITTYPE来告诉容器进程采用的哪种方式启动。
    • 执行ParentProcess中构建的exec.Cmd内容,即执行ParentProcess.start(),具体的执行过程在Process部分介绍。
    • 最后如果是新建的容器进程,还会执行状态更新函数,把state.json的内容刷新。
  9. Destroy():首先使用cgroup的freezer子系统暂停所有运行的进程,然后给所有进程发送SIGKIL信号(如果没有使用pid namespace就不对进程处理)。最后把cgroup及其子系统卸载,删除cgroup文件夹。
  10. Pause():使用cgroup的freezer子系统暂停所有运行的进程。
  11. Resume():使用cgroup的freezer子系统恢复所有运行的进程。
  12. NotifyOOM():为容器内存使用超界提供只读的通道,通过向cgroup.event_control写入eventfd(用作线程间通信的消息队列)和cgroup.oom_control(用于决定内存使用超限后的处理方式)来实现。
  13. Checkpoint():保存容器进程检查点快照,为容器热迁移做准备。通过使用CRIU的SWRK模式来实现,这种模式是CRIU另外两种模式CLI和RPC的结合体,允许用户需要的时候像使用命令行工具一样运行CRIU,并接受用户远程调用的请求,即传入的热迁移检查点保存请求,传入文件形式以Google的protobuf协议保存。
  14. Restore():恢复检查点快照并运行,完成容器热迁移。同样通过CRIU的SWRK模式实现,恢复的时候可以传入配置文件设置恢复挂载点、网络等配置信息。

至此,Container对象中的所有函数及相关功能都已经介绍完毕,包含了容器生命周期的全部过程。

TIPs: Docker初始化通信——管道

libcontainer创建容器进程时需要做初始化工作,此时就涉及到使用了namespace隔离后的两个进程间的通信。我们把负责创建容器的进程称为父进程,容器进程称为子进程。父进程clone出子进程以后,依旧是共享内存的。但是如何让子进程知道内存中写入了新数据依旧是一个问题,一般有四种方法。

  • 发送信号通知(signal)
  • 对内存轮询访问(poll memory)
  • sockets通信(sockets)
  • 文件和文件描述符(files and file-descriptors)

对于Signal而言,本身包含的信息有限,需要额外记录,namespace带来的上下文变化使其不易理解,并不是最佳选择。显然通过轮询内存的方式来沟通是一个非常低效的做法。另外,因为Docker会加入network namespace,实际上初始时网络栈也是完全隔离的,所以socket方式并不可行。

Docker最终选择的方式就是打开的可读可写文件描述符——管道。

Linux中,通过pipe(int fd[2])系统调用就可以创建管道,参数是一个包含两个整型的数组。调用完成后,在fd[1]端写入的数据,就可以从fd[0]端读取。

调用pipe函数后,创建的子进程会内嵌这个打开的文件描述符,对fd[1]写入数据后可以在fd[0]端读取。通过管道,父子进程之间就可以通信。通信完毕的奥秘就在于EOF信号的传递。大家都知道,当打开的文件描述符都关闭时,才能读到EOF信号,所以libcontainer中父进程先关闭自己这一端的管道,然后等待子进程关闭另一端的管道文件描述符,传来EOF表示子进程已经完成了初始化的过程。

4.3 Process 对象

Process 主要分为两类,一类在源码中就叫Process,用于容器内进程的配置和IO的管理;另一类在源码中叫ParentProcess,负责处理容器启动工作,与Container对象直接进行接触,启动完成后作为Process的一部分,执行等待、发信号、获得pid等管理工作。

ParentProcess对象,主要包含以下六个函数,而根据”需要新建容器”和“在已经存在的容器中执行”的不同方式,具体的实现也有所不同。

  • 已有容器中执行命令

    1. pid(): 启动容器进程后通过管道从容器进程中获得,因为容器已经存在,与Docker Deamon在不同的pid namespace中,从进程所在的namespace获得的进程号才有意义。
    2. start(): 初始化容器中的执行进程。在已有容器中执行命令一般由docker exec调用,在execdriver包中,执行exec时会引入nsenter包,从而调用其中的C语言代码,执行nsexec()函数,该函数会读取配置文件,使用setns()加入到相应的namespace,然后通过clone()在该namespace中生成一个子进程,并把子进程通过管道传递出去,使用setns()以后并没有进入pid namespace,所以还需要通过加上clone()系统调用。
    • 开始执行进程,首先会运行C代码,通过管道获得进程pid,最后等待C代码执行完毕。
    • 通过获得的pid把cmd中的Process替换成新生成的子进程。
    • 把子进程加入cgroup中。
    • 通过管道传配置文件给子进程。
    • 等待初始化完成或出错返回,结束。
  • 新建容器执行命令

    1. pid():启动容器进程后通过exec.Cmd自带的pid()函数即可获得。
    2. start():初始化及执行容器命令。
    • 开始运行进程。
    • 把进程pid加入到cgroup中管理。
    • 初始化容器网络。(本部分内容丰富,将从本系列的后续文章中深入讲解)
    • 通过管道发送配置文件给子进程。
    • 等待初始化完成或出错返回,结束。
  • 实现方式类似的一些函数

    • **terminate() **:发送SIGKILL信号结束进程。
    • **startTime() **:获取进程的启动时间。
    • signal():发送信号给进程。
    • wait():等待程序执行结束,返回结束的程序状态。

Process对象,主要描述了容器内进程的配置以及IO。包括参数Args,环境变量Env,用户User(由于uid、gid映射),工作目录Cwd,标准输入输出及错误输入,控制终端路径consolePath,容器权限Capabilities以及上述提到的ParentProcess对象ops(拥有上面的一些操作函数,可以直接管理进程)。

5. 总结

本文主要介绍了Docker容器管理的方式libcontainer,从libcontainer的使用到源码实现方式。我们深入到容器进程内部,感受到了libcontainer较为全面的设计。总体而言,libcontainer本身主要分为三大块工作内容,一是容器的创建及初始化,二是容器生命周期管理,三则是进程管理,调用方为Docker的execdriver。容器的监控主要通过cgroups的状态统计信息,未来会加入进程追踪等更丰富的功能。另一方面,libcontainer在安全支持方面也为用户尽可能多的提供了支持和选择。遗憾的是,容器安全的配置需要用户对系统安全本身有足够高的理解,user namespace也尚未支持,可见libcontainer依旧有很多工作要完善。但是Docker社区的火热也自然带动了大家对libcontainer的关注,相信在不久的将来,libcontainer就会变得更安全、更易用。

Docker背后的内核知识——cgroups资源限制

摘要

当我们谈论Docker时,我们常常会聊到Docker的实现方式。很多开发者都会知道,Docker的本质实际上是宿主机上的一个进程,通过namespace实现了资源隔离,通过cgroup实现了资源限制,通过UnionFS实现了Copy on Write的文件操作。但是当我们再深入一步的提出,namespace和cgroup实现细节时,知道的人可能就所剩无几了。浙江大学SEL/VLIS实验室孙健波同学在docker基础研究工作中着重对内核的cgroup技术做了细致的分析和梳理,希望能对读者深入理解Docker有所帮助

正文

上一篇中,我们了解了Docker背后使用的资源隔离技术namespace,通过系统调用构建一个相对隔离的shell环境,也可以称之为一个简单的“容器”。本文我们则要开始讲解另一个强大的内核工具——cgroups。他不仅可以限制被namespace隔离起来的资源,还可以为资源设置权重、计算使用量、操控进程启停等等。在介绍完基本概念后,我们将详细讲解Docker中使用到的cgroups内容。希望通过本文,让读者对Docker有更深入的了解。

1. cgroups是什么

cgroups(Control Groups)最初叫Process Container,由Google工程师(Paul Menage和Rohit Seth)于2006年提出,后来因为Container有多重含义容易引起误解,就在2007年更名为Control Groups,并被整合进Linux内核。顾名思义就是把进程放到一个组里面统一加以控制。官方的定义如下{![引自:https://www.kernel.org/doc/Documentation/cgroups/cgroups.txt]}。

cgroups是Linux内核提供的一种机制,这种机制可以根据特定的行为,把一系列系统任务及其子任务整合(或分隔)到按资源划分等级的不同组内,从而为系统资源管理提供一个统一的框架。

通俗的来说,cgroups可以限制、记录、隔离进程组所使用的物理资源(包括:CPU、memory、IO等),为容器实现虚拟化提供了基本保证,是构建Docker等一系列虚拟化管理工具的基石。

对开发者来说,cgroups有如下四个有趣的特点:
* cgroups的API以一个伪文件系统的方式实现,即用户可以通过文件操作实现cgroups的组织管理。
* cgroups的组织管理操作单元可以细粒度到线程级别,用户态代码也可以针对系统分配的资源创建和销毁cgroups,从而实现资源再分配和管理。
* 所有资源管理的功能都以“subsystem(子系统)”的方式实现,接口统一。
* 子进程创建之初与其父进程处于同一个cgroups的控制组。

本质上来说,cgroups是内核附加在程序上的一系列钩子(hooks),通过程序运行时对资源的调度触发相应的钩子以达到资源追踪和限制的目的。

2. cgroups的作用

实现cgroups的主要目的是为不同用户层面的资源管理,提供一个统一化的接口。从单个进程的资源控制到操作系统层面的虚拟化。Cgroups提供了以下四大功能{![参照自:http://en.wikipedia.org/wiki/Cgroups]}。

  • 资源限制(Resource Limitation):cgroups可以对进程组使用的资源总额进行限制。如设定应用运行时使用内存的上限,一旦超过这个配额就发出OOM(Out of Memory)。
  • 优先级分配(Prioritization):通过分配的CPU时间片数量及硬盘IO带宽大小,实际上就相当于控制了进程运行的优先级。
  • 资源统计(Accounting): cgroups可以统计系统的资源使用量,如CPU使用时长、内存用量等等,这个功能非常适用于计费。
  • 进程控制(Control):cgroups可以对进程组执行挂起、恢复等操作。

过去有一段时间,内核开发者甚至把namespace也作为一个cgroups的subsystem加入进来,也就是说cgroups曾经甚至还包含了资源隔离的能力。但是资源隔离会给cgroups带来许多问题,如PID在循环出现的时候cgroup却出现了命名冲突、cgroup创建后进入新的namespace导致脱离了控制等等{![详见:https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=a77aea92010acf54ad785047234418d5d68772e2]},所以在2011年就被移除了。

3. 术语表

  • task(任务):cgroups的术语中,task就表示系统的一个进程。
  • cgroup(控制组):cgroups 中的资源控制都以cgroup为单位实现。cgroup表示按某种资源控制标准划分而成的任务组,包含一个或多个子系统。一个任务可以加入某个cgroup,也可以从某个cgroup迁移到另外一个cgroup。
  • subsystem(子系统):cgroups中的subsystem就是一个资源调度控制器(Resource Controller)。比如CPU子系统可以控制CPU时间分配,内存子系统可以限制cgroup内存使用量。
  • hierarchy(层级树):hierarchy由一系列cgroup以一个树状结构排列而成,每个hierarchy通过绑定对应的subsystem进行资源调度。hierarchy中的cgroup节点可以包含零或多个子节点,子节点继承父节点的属性。整个系统可以有多个hierarchy。

4. 组织结构与基本规则

大家在namespace技术的讲解中已经了解到,传统的Unix进程管理,实际上是先启动init进程作为根节点,再由init节点创建子进程作为子节点,而每个子节点由可以创建新的子节点,如此往复,形成一个树状结构。而cgroups也是类似的树状结构,子节点都从父节点继承属性。

它们最大的不同在于,系统中cgroup构成的hierarchy可以允许存在多个。如果进程模型是由init作为根节点构成的一棵树的话,那么cgroups的模型则是由多个hierarchy构成的森林。这样做的目的也很好理解,如果只有一个hierarchy,那么所有的task都要受到绑定其上的subsystem的限制,会给那些不需要这些限制的task造成麻烦。

了解了cgroups的组织结构,我们再来了解cgroup、task、subsystem以及hierarchy四者间的相互关系及其基本规则{![参照自:https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/6/html/Resource_Management_Guide/sec-Relationships_Between_Subsystems_Hierarchies_Control_Groups_and_Tasks.html]}。

  • 规则1: 同一个hierarchy可以附加一个或多个subsystem。如下图1,cpu和memory的subsystem附加到了一个hierarchy。
    pic1
    图1 同一个hierarchy可以附加一个或多个subsystem

  • 规则2: 一个subsystem可以附加到多个hierarchy,当且仅当这些hierarchy只有这唯一一个subsystem。如下图2,小圈中的数字表示subsystem附加的时间顺序,CPU subsystem附加到hierarchy A的同时不能再附加到hierarchy B,因为hierarchy B已经附加了memory subsystem。如果hierarchy B与hierarchy A状态相同,没有附加过memory subsystem,那么CPU subsystem同时附加到两个hierarchy是可以的。
    pic2
    图2 一个已经附加在某个hierarchy上的subsystem不能附加到其他含有别的subsystem的hierarchy上

  • 规则3: 系统每次新建一个hierarchy时,该系统上的所有task默认构成了这个新建的hierarchy的初始化cgroup,这个cgroup也称为root cgroup。对于你创建的每个hierarchy,task只能存在于其中一个cgroup中,即一个task不能存在于同一个hierarchy的不同cgroup中,但是一个task可以存在在不同hierarchy中的多个cgroup中。如果操作时把一个task添加到同一个hierarchy中的另一个cgroup中,则会从第一个cgroup中移除。在下图3中可以看到,httpd进程已经加入到hierarchy A中的/cg1而不能加入同一个hierarchy中的/cg2,但是可以加入hierarchy B中的/cg3。实际上不允许加入同一个hierarchy中的其他cgroup野生为了防止出现矛盾,如CPU subsystem为/cg1分配了30%,而为/cg2分配了50%,此时如果httpd在这两个cgroup中,就会出现矛盾。
    pic3
    图3 一个task不能属于同一个hierarchy的不同cgroup

  • 规则4: 进程(task)在fork自身时创建的子任务(child task)默认与原task在同一个cgroup中,但是child task允许被移动到不同的cgroup中。即fork完成后,父子进程间是完全独立的。如下图4中,小圈中的数字表示task 出现的时间顺序,当httpd刚fork出另一个httpd时,在同一个hierarchy中的同一个cgroup中。但是随后如果PID为4840的httpd需要移动到其他cgroup也是可以的,因为父子任务间已经独立。总结起来就是:初始化时子任务与父任务在同一个cgroup,但是这种关系随后可以改变。
    pic4
    图4 刚fork出的子进程在初始状态与其父进程处于同一个cgroup

5. subsystem简介

subsystem实际上就是cgroups的资源控制系统,每种subsystem独立地控制一种资源,目前Docker使用如下八种subsystem,还有一种net_cls subsystem在内核中已经广泛实现,但是Docker尚未使用。他们的用途分别如下。

  • blkio: 这个subsystem可以为块设备设定输入/输出限制,比如物理驱动设备(包括磁盘、固态硬盘、USB等)。
  • cpu: 这个subsystem使用调度程序控制task对CPU的使用。
  • cpuacct: 这个subsystem自动生成cgroup中task对CPU资源使用情况的报告。
  • cpuset: 这个subsystem可以为cgroup中的task分配独立的CPU(此处针对多处理器系统)和内存。
  • devices 这个subsystem可以开启或关闭cgroup中task对设备的访问。
  • freezer 这个subsystem可以挂起或恢复cgroup中的task。
  • memory 这个subsystem可以设定cgroup中task对内存使用量的限定,并且自动生成这些task对内存资源使用情况的报告。
  • perf_event 这个subsystem使用后使得cgroup中的task可以进行统一的性能测试。{![perf: Linux CPU性能探测器,详见https://perf.wiki.kernel.org/index.php/Main_Page]}
  • *net_cls 这个subsystem Docker没有直接使用,它通过使用等级识别符(classid)标记网络数据包,从而允许 Linux 流量控制程序(TC:Traffic Controller)识别从具体cgroup中生成的数据包。

6. cgroups实现方式及工作原理简介

(1)cgroups实现结构讲解

cgroups的实现本质上是给系统进程挂上钩子(hooks),当task运行的过程中涉及到某个资源时就会触发钩子上所附带的subsystem进行检测,最终根据资源类别的不同使用对应的技术进行资源限制和优先级分配。那么这些钩子又是怎样附加到进程上的呢?下面我们将对照结构体的图表一步步分析,请放心,描述代码的内容并不多。

cgroup_struct
图5 cgroups相关结构体一览

Linux中管理task进程的数据结构为task_struct(包含所有进程管理的信息),其中与cgroup相关的字段主要有两个,一个是css_set *cgroups,表示指向css_set(包含进程相关的cgroups信息)的指针,一个task只对应一个css_set结构,但是一个css_set可以被多个task使用。另一个字段是list_head cg_list,是一个链表的头指针,这个链表包含了所有的链到同一个css_set的task进程(在图中使用的回环箭头,均表示可以通过该字段找到所有同类结构,获得信息)。

每个css_set结构中都包含了一个指向cgroup_subsys_state(包含进程与一个特定子系统相关的信息)的指针数组。cgroup_subsys_state则指向了cgroup结构(包含一个cgroup的所有信息),通过这种方式间接的把一个进程和cgroup联系了起来,如下图6。

cgroup_task
图6 从task结构开始找到cgroup结构

另一方面,cgroup结构体中有一个list_head css_sets字段,它是一个头指针,指向由cg_cgroup_link(包含cgroup与task之间多对多关系的信息,后文还会再解释)形成的链表。由此获得的每一个cg_cgroup_link都包含了一个指向css_set *cg字段,指向了每一个task的css_setcss_set结构中则包含tasks头指针,指向所有链到此css_set的task进程构成的链表。至此,我们就明白如何查看在同一个cgroup中的task有哪些了,如下图7。

cgroup_cglink
图7 cglink多对多双向查询

细心的读者可能已经发现,css_set中也有指向所有cg_cgroup_link构成链表的头指针,通过这种方式也能定位到所有的cgroup,这种方式与图1中所示的方式得到的结果是相同的。

那么为什么要使用cg_cgroup_link结构体呢?因为task与cgroup之间是多对多的关系。熟悉数据库的读者很容易理解,在数据库中,如果两张表是多对多的关系,那么如果不加入第三张关系表,就必须为一个字段的不同添加许多行记录,导致大量冗余。通过从主表和副表各拿一个主键新建一张关系表,可以提高数据查询的灵活性和效率。

而一个task可能处于不同的cgroup,只要这些cgroup在不同的hierarchy中,并且每个hierarchy挂载的子系统不同;另一方面,一个cgroup中可以有多个task,这是显而易见的,但是这些task因为可能还存在在别的cgroup中,所以它们对应的css_set也不尽相同,所以一个cgroup也可以对应多个·css_set

在系统运行之初,内核的主函数就会对root cgroupscss_set进行初始化,每次task进行fork/exit时,都会附加(attach)/分离(detach)对应的css_set

综上所述,添加cg_cgroup_link主要是出于性能方面的考虑,一是节省了task_struct结构体占用的内存,二是提升了进程fork()/exit()的速度。

cgroup_hashtable
图8 css_set与hashtable关系

当task从一个cgroup中移动到另一个时,它会得到一个新的css_set指针。如果所要加入的cgroup与现有的cgroup子系统相同,那么就重复使用现有的css_set,否则就分配一个新css_set。所有的css_set通过一个哈希表进行存放和查询,如上图8中所示,hlist_node hlist就指向了css_set_table这个hash表。

同时,为了让cgroups便于用户理解和使用,也为了用精简的内核代码为cgroup提供熟悉的权限和命名空间管理,内核开发者们按照Linux 虚拟文件系统转换器(VFS:Virtual Filesystem Switch)的接口实现了一套名为cgroup的文件系统,非常巧妙地用来表示cgroups的hierarchy概念,把各个subsystem的实现都封装到文件系统的各项操作中。有兴趣的读者可以在网上搜索并阅读VFS的相关内容,在此就不赘述了。

定义子系统的结构体是cgroup_subsys,在图9中可以看到,cgroup_subsys中定义了一组函数的接口,让各个子系统自己去实现,类似的思想还被用在了cgroup_subsys_state中,cgroup_subsys_state并没有定义控制信息,只是定义了各个子系统都需要用到的公共信息,由各个子系统各自按需去定义自己的控制信息结构体,最终在自定义的结构体中把cgroup_subsys_state包含进去,然后内核通过container_of(这个宏可以通过一个结构体的成员找到结构体自身)等宏定义来获取对应的结构体。

cgroup_subsys
图9 cgroup子系统结构体

(2)基于cgroups实现结构的用户层体现

了解了cgroups实现的代码结构以后,再来看用户层在使用cgroups时的限制,会更加清晰。

在实际的使用过程中,你需要通过挂载(mount)cgroup文件系统新建一个层级结构,挂载时指定要绑定的子系统,缺省情况下默认绑定系统所有子系统。把cgroup文件系统挂载(mount)上以后,你就可以像操作文件一样对cgroups的hierarchy层级进行浏览和操作管理(包括权限管理、子文件管理等等)。除了cgroup文件系统以外,内核没有为cgroups的访问和操作添加任何系统调用。

如果新建的层级结构要绑定的子系统与目前已经存在的层级结构完全相同,那么新的挂载会重用原来已经存在的那一套(指向相同的css_set)。否则如果要绑定的子系统已经被别的层级绑定,就会返回挂载失败的错误。如果一切顺利,挂载完成后层级就被激活并与相应子系统关联起来,可以开始使用了。

目前无法将一个新的子系统绑定到激活的层级上,或者从一个激活的层级中解除某个子系统的绑定。

当一个顶层的cgroup文件系统被卸载(umount)时,如果其中创建后代cgroup目录,那么就算上层的cgroup被卸载了,层级也是激活状态,其后代cgoup中的配置依旧有效。只有递归式的卸载层级中的所有cgoup,那个层级才会被真正删除。

层级激活后,/proc目录下的每个task PID文件夹下都会新添加一个名为cgroup的文件,列出task所在的层级,对其进行控制的子系统及对应cgroup文件系统的路径。

一个cgroup创建完成,不管绑定了何种子系统,其目录下都会生成以下几个文件,用来描述cgroup的相应信息。同样,把相应信息写入这些配置文件就可以生效,内容如下。

  • tasks:这个文件中罗列了所有在该cgroup中task的PID。该文件并不保证task的PID有序,把一个task的PID写到这个文件中就意味着把这个task加入这个cgroup中。
  • cgroup.procs:这个文件罗列所有在该cgroup中的线程组ID。该文件并不保证线程组ID有序和无重复。写一个线程组ID到这个文件就意味着把这个组中所有的线程加到这个cgroup中。
  • notify_on_release:填0或1,表示是否在cgroup中最后一个task退出时通知运行release agent,默认情况下是0,表示不运行。
  • release_agent:指定release agent执行脚本的文件路径(该文件在最顶层cgroup目录中存在),在这个脚本通常用于自动化umount无用的cgroup。

除了上述几个通用的文件以外,绑定特定子系统的目录下也会有其他的文件进行子系统的参数配置。

在创建的hierarchy中创建文件夹,就类似于fork中一个后代cgroup,后代cgroup中默认继承原有cgroup中的配置属性,但是你可以根据需求对配置参数进行调整。这样就把一个大的cgroup系统分割成一个个嵌套的、可动态变化的“软分区”。

7. cgroups的使用方法简介

(1)安装cgroups工具库

本节主要针对Ubuntu14.04版本系统进行介绍,其他Linux发行版命令略有不同,原理是一样的。不安装cgroups工具库也可以使用cgroups,安装它只是为了更方便的在用户态对cgroups进行管理,同时也方便初学者理解和使用,本节对cgroups的操作和使用都基于这个工具库。

apt-get install cgroup-bin

安装的过程会自动创建/cgroup目录,如果没有自动创建也不用担心,使用 mkdir /cgroup 手动创建即可。在这个目录下你就可以挂载各类子系统。安装完成后,你就可以使用lssubsys(罗列所有的subsystem挂载情况)等命令。

说明:也许你在其他文章中看到的cgroups工具库教程,会在/etc目录下生成一些初始化脚本和配置文件,默认的cgroup配置文件为/etc/cgconfig.conf,但是因为存在使LXC无法运行的bug,所以在新版本中把这个配置移除了,详见:https://bugs.launchpad.net/ubuntu/+source/libcgroup/+bug/1096771。

(2)查询cgroup及子系统挂载状态

在挂载子系统之前,可能你要先检查下目前子系统的挂载状态,如果子系统已经挂载,根据第4节中讲的规则2,你就无法把子系统挂载到新的hierarchy,此时就需要先删除相应hierarchy或卸载对应子系统后再挂载。

  • 查看所有的cgroup:lscgroup
  • 查看所有支持的子系统:lssubsys -a
  • 查看所有子系统挂载的位置: lssubsys –m
  • 查看单个子系统(如memory)挂载位置:lssubsys –m memory

(3)创建hierarchy层级并挂载子系统

在组织结构与规则一节中我们提到了hierarchy层级和subsystem子系统的关系,我们知道使用cgroup的最佳方式是:为想要管理的每个或每组资源创建单独的cgroup层级结构。而创建hierarchy并不神秘,实际上就是做一个标记,通过挂载一个tmpfs{![基于内存的临时文件系统,详见:http://en.wikipedia.org/wiki/Tmpfs]}文件系统,并给一个好的名字就可以了,系统默认挂载的cgroup就会进行如下操作。

mount -t tmpfs cgroups /sys/fs/cgroup

其中-t即指定挂载的文件系统类型,其后的cgroups是会出现在mount展示的结果中用于标识,可以选择一个有用的名字命名,最后的目录则表示文件的挂载点位置。

挂载完成tmpfs后就可以通过mkdir命令创建相应的文件夹。

mkdir /sys/fs/cgroup/cg1

再把子系统挂载到相应层级上,挂载子系统也使用mount命令,语法如下。

mount -t cgroup -o subsystems name /cgroup/name
其​​​中​​​ subsystems 是​​​使​​​用​​​,(逗号)​​​分​​​开​​​的​​​子​​​系​​​统​​​列​​​表,name 是​​​层​​​级​​​名​​​称​​​。具体我们以挂载cpu和memory的子系统为例,命令如下。

mount –t cgroup –o cpu,memory cpu_and_mem /sys/fs/cgroup/cg1

mount命令开始,-t后面跟的是挂载的文件系统类型,即cgroup文件系统。-o后面跟要挂载的子系统种类如cpumemory,用逗号隔开,其后的cpu_and_mem不被cgroup代码的解释,但会出现在/proc/mounts里,可以使用任何有用的标识字符串。最后的参数则表示挂载点的目录位置。

说明:如果挂载时提示mount: agent already mounted or /cgroup busy,则表示子系统已经挂载,需要先卸载原先的挂载点,通过第二条中描述的命令可以定位挂载点。

(4)卸载cgroup

目前cgroup文件系统虽然支持重新挂载,但是官方不建议使用,重新挂载虽然可以改变绑定的子系统和release agent,但是它要求对应的hierarchy是空的并且release_agent会被传统的fsnotify(内核默认的文件系统通知)代替,这就导致重新挂载很难生效,未来重新挂载的功能可能会移除。你可以通过卸载,再挂载的方式处理这样的需求。

卸载cgroup非常简单,你可以通过cgdelete命令,也可以通过rmdir,以刚挂载的cg1为例,命令如下。

rmdir /sys/fs/cgroup/cg1

rmdir执行成功的必要条件是cg1下层没有创建其它cgroup,cg1中没有添加任何task,并且它也没有被别的cgroup所引用。

cgdelete cpu,memory:/
使用cgdelete命令可以递归的删除cgroup及其命令下的后代cgroup,并且如果cgroup中有task,那么task会自动移到上一层没有被删除的cgroup中,如果所有的cgroup都被删除了,那task就不被cgroups控制。但是一旦再次创建一个新的cgroup,所有进程都会被放进新的cgroup中。

(5)设置cgroups参数

设置cgroups参数非常简单,直接对之前创建的cgroup对应文件夹下的文件写入即可,举例如下。

  • 设置task允许使用的cpu为0和1.
    echo 0-1 > /sys/fs/cgroup/cg1/cpuset.cpus

使用cgset命令也可以进行参数设置,对应上述允许使用0和1cpu的命令为:

cgset -r cpuset.cpus=0-1 cpu,memory:/

(6)添加task到cgroup

  • 通过文件操作进行添加
    echo [PID] > /path/to/cgroup/tasks
    上述命令就是把进程ID打印到tasks中,如果tasks文件中已经有进程,需要使用">>"向后添加。

  • 通过cgclassify将进程添加到cgroup
    cgclassify -g subsystems:path_to_cgroup pidlist
    这个命令中,subsystems指的就是子系统(如果使用man命令查看,可能也会使用controllers表示)​​​,如果mount了多个,就是用","隔开的子系统名字作为名称,类似cgset命令。

  • 通过cgexec直接在cgroup中启动并执行进程
    cgexec -g subsystems:path_to_cgroup command arguments
    commandarguments就表示要在cgroup中执行的命令和参数。cgexec常用于执行临时的任务。

(7)权限管理

与文件的权限管理类似,通过chown就可以对cgroup文件系统进行权限管理。

chown uid:gid /path/to/cgroup

uidgid分别表示所属的用户和用户组。

8. subsystem配置参数用法

(1)blkio – BLOCK IO资源控制

  • 限额类
    限额类是主要有两种策略,一种是基于完全公平队列调度(CFQ:Completely Fair Queuing )的按权重分配各个cgroup所能占用总体资源的百分比,好处是当资源空闲时可以充分利用,但只能用于最底层节点cgroup的配置;另一种则是设定资源使用上限,这种限额在各个层次的cgroup都可以配置,但这种限制较为生硬,并且容器之间依然会出现资源的竞争。

    • 按比例分配块设备IO资源
    1. blkio.weight:填写100-1000的一个整数值,作为相对权重比率,作为通用的设备分配比。
    2. blkio.weight_device: 针对特定设备的权重比,写入格式为device_types:node_numbers weight,空格前的参数段指定设备,weight参数与blkio.weight相同并覆盖原有的通用分配比。{![查看一个设备的device_types:node_numbers可以使用:ls -l /dev/DEV,看到的用逗号分隔的两个数字就是。有的文章也称之为major_number:minor_number。]}
    • 控制IO读写速度上限
      1. blkio.throttle.read_bps_device:按每秒读取块设备的数据量设定上限,格式device_types:node_numbers bytes_per_second
      2. blkio.throttle.write_bps_device:按每秒写入块设备的数据量设定上限,格式device_types:node_numbers bytes_per_second
      3. blkio.throttle.read_iops_device:按每秒读操作次数设定上限,格式device_types:node_numbers operations_per_second
      4. blkio.throttle.write_iops_device:按每秒写操作次数设定上限,格式device_types:node_numbers operations_per_second
    • 针对特定操作(read, write, sync, 或async)设定读写速度上限
      1. blkio.throttle.io_serviced:针对特定操作按每秒操作次数设定上限,格式device_types:node_numbers operation operations_per_second
      2. blkio.throttle.io_service_bytes:针对特定操作按每秒数据量设定上限,格式device_types:node_numbers operation bytes_per_second
  • 统计与监控
    以下内容都是只读的状态报告,通过这些统计项更好地统计、监控进程的 io 情况。

    1. blkio.reset_stats:重置统计信息,写入一个int值即可。
    2. blkio.time:统计cgroup对设备的访问时间,按格式device_types:node_numbers milliseconds读取信息即可,以下类似。
    3. blkio.io_serviced:统计cgroup对特定设备的IO操作(包括read、write、sync及async)次数,格式device_types:node_numbers operation number
    4. blkio.sectors:统计cgroup对设备扇区访问次数,格式 device_types:node_numbers sector_count
    5. blkio.io_service_bytes:统计cgroup对特定设备IO操作(包括read、write、sync及async)的数据量,格式device_types:node_numbers operation bytes
    6. blkio.io_queued:统计cgroup的队列中对IO操作(包括read、write、sync及async)的请求次数,格式number operation
    7. blkio.io_service_time:统计cgroup对特定设备的IO操作(包括read、write、sync及async)时间(单位为ns),格式device_types:node_numbers operation time
    8. blkio.io_merged:统计cgroup 将 BIOS 请求合并到IO操作(包括read、write、sync及async)请求的次数,格式number operation
    9. blkio.io_wait_time:统计cgroup在各设​​​备​​​中各类型​​​IO操作(包括read、write、sync及async)在队列中的等待时间​(单位ns),格式device_types:node_numbers operation time
    10. blkio.*_recursive:各类型的统计都有一个递归版本,Docker中使用的都是这个版本。获取的数据与非递归版本是一样的,但是包括cgroup所有层级的监控数据。

(2) cpu – CPU资源控制

CPU资源的控制也有两种策略,一种是完全公平调度 (CFS:Completely Fair Scheduler)策略,提供了限额和按比例分配两种方式进行资源控制;另一种是实时调度(Real-Time Scheduler)策略,针对实时进程按周期分配固定的运行时间。配置时间都以微秒(µs)为单位,文件名中用us表示。

  • CFS调度策略下的配置

    • 设定CPU使用周期使用时间上限
    1. cpu.cfs_period_us:设定周期时间,必须与cfs_quota_us配合使用。
    2. cpu.cfs_quota_us :设定周期内最多可使用的时间。这里的配置指task对单个cpu的使用上限,若cfs_quota_uscfs_period_us的两倍,就表示在两个核上完全使用。数值范围为1000 – 1000,000(微秒)。
    3. cpu.stat:统计信息,包含nr_periods(表示经历了几个cfs_period_us周期)、nr_throttled(表示task被限制的次数)及throttled_time(表示task被限制的总时长)。
    • 按权重比例设定CPU的分配
    1. cpu.shares:设定一个整数(必须大于等于2)表示相对权重,最后除以权重总和算出相对比例,按比例分配CPU时间。(如cgroup A设置100,cgroup B设置300,那么cgroup A中的task运行25%的CPU时间。对于一个4核CPU的系统来说,cgroup A 中的task可以100%占有某一个CPU,这个比例是相对整体的一个值。)
  • RT调度策略下的配置
    实时调度策略与公平调度策略中的按周期分配时间的方法类似,也是在周期内分配一个固定的运行时间。

    1. cpu.rt_period_us :设定周期时间。
    2. cpu.rt_runtime_us:设定周期中的运行时间。

(3) cpuacct – CPU资源报告

这个子系统的配置是cpu子系统的补充,提供CPU资源用量的统计,时间单位都是纳秒。
1. cpuacct.usage:统计cgroup中所有task的cpu使用时长
2. cpuacct.stat:统计cgroup中所有task的用户态和内核态分别使用cpu的时长
3. cpuacct.usage_percpu:统计cgroup中所有task使用每个cpu的时长

(4)cpuset – CPU绑定

为task分配独立CPU资源的子系统,参数较多,这里只选讲两个必须配置的参数,同时Docker中目前也只用到这两个。
1. cpuset.cpus:在这个文件中填写cgroup可使用的CPU编号,如0-2,16代表 0、1、2和16这4个CPU。
2. cpuset.mems:与CPU类似,表示cgroup可使用的memory node,格式同上

(5) device – 限制task对device的使用

  • **设备黑/白名单过滤 **
    1. devices.allow:允许名单,语法type device_types:node_numbers access typetype有三种类型:b(块设备)、c(字符设备)、a(全部设备);access也有三种方式:r(读)、w(写)、m(创建)。
    2. devices.deny:禁止名单,语法格式同上。
  • 统计报告
    1. devices.list:报​​​告​​​为​​​这​​​个​​​ cgroup 中​​​的​task设​​​定​​​访​​​问​​​控​​​制​​​的​​​设​​​备

(6) freezer – 暂停/恢复cgroup中的task

只有一个属性,表示进程的状态,把task放到freezer所在的cgroup,再把state改为FROZEN,就可以暂停进程。不允许在cgroup处于FROZEN状态时加入进程。
* **freezer.state **,包括如下三种状态:
– FROZEN 停止
– FREEZING 正在停止,这个是只读状态,不能写入这个值。
– THAWED 恢复

(7) memory – 内存资源管理

  • 限额类

    1. memory.limit_in_bytes:强制限制最大内存使用量,单位有kmg三种,填-1则代表无限制。
    2. memory.soft_limit_in_bytes:软限制,只有比强制限制设置的值小时才有意义。填写格式同上。当整体内存紧张的情况下,task获取的内存就被限制在软限制额度之内,以保证不会有太多进程因内存挨饿。可以看到,加入了内存的资源限制并不代表没有资源竞争。
    3. memory.memsw.limit_in_bytes:设定最大内存与swap区内存之和的用量限制。填写格式同上。
  • 报警与自动控制

    1. memory.oom_control:改参数填0或1, 0表示开启,当cgroup中的进程使用资源超过界限时立即杀死进程,1表示不启用。默认情况下,包含memory子系统的cgroup都启用。当oom_control不启用时,实际使用内存超过界限时进程会被暂停直到有空闲的内存资源。
  • 统计与监控类

    1. memory.usage_in_bytes:报​​​告​​​该​​​ cgroup中​​​进​​​程​​​使​​​用​​​的​​​当​​​前​​​总​​​内​​​存​​​用​​​量(以字节为单位)
    2. memory.max_usage_in_bytes:报​​​告​​​该​​​ cgroup 中​​​进​​​程​​​使​​​用​​​的​​​最​​​大​​​内​​​存​​​用​​​量
    3. memory.failcnt:报​​​告​​​内​​​存​​​达​​​到​​​在​​​ memory.limit_in_bytes设​​​定​​​的​​​限​​​制​​​值​​​的​​​次​​​数​​​
    4. memory.stat:包含大量的内存统计数据。
    • cache:页​​​缓​​​存​​​,包​​​括​​​ tmpfs(shmem),单位为字节。
    • rss:匿​​​名​​​和​​​ swap 缓​​​存​​​,不​​​包​​​括​​​ tmpfs(shmem),单位为字节。
    • mapped_file:memory-mapped 映​​​射​​​的​​​文​​​件​​​大​​​小​​​,包​​​括​​​ tmpfs(shmem),单​​​位​​​为​​​字​​​节​​​
    • pgpgin:存​​​入​​​内​​​存​​​中​​​的​​​页​​​数​​​
    • pgpgout:从​​​内​​​存​​​中​​​读​​​出​​​的​​​页​​​数
    • swap:swap 用​​​量​​​,单​​​位​​​为​​​字​​​节​​​
    • active_anon:在​​​活​​​跃​​​的​​​最​​​近​​​最​​​少​​​使​​​用​​​(least-recently-used,LRU)列​​​表​​​中​​​的​​​匿​​​名​​​和​​​ swap 缓​​​存​​​,包​​​括​​​ tmpfs(shmem),单​​​位​​​为​​​字​​​节​​​
    • inactive_anon:不​​​活​​​跃​​​的​​​ LRU 列​​​表​​​中​​​的​​​匿​​​名​​​和​​​ swap 缓​​​存​​​,包​​​括​​​ tmpfs(shmem),单​​​位​​​为​​​字​​​节
    • active_file:活​​​跃​​​ LRU 列​​​表​​​中​​​的​​​ file-backed 内​​​存​​​,以​​​字​​​节​​​为​​​单​​​位
    • inactive_file:不​​​活​​​跃​​​ LRU 列​​​表​​​中​​​的​​​ file-backed 内​​​存​​​,以​​​字​​​节​​​为​​​单​​​位
    • unevictable:无​​​法​​​再​​​生​​​的​​​内​​​存​​​,以​​​字​​​节​​​为​​​单​​​位​​​
    • hierarchical_memory_limit:包​​​含​​​ memory cgroup 的​​​层​​​级​​​的​​​内​​​存​​​限​​​制​​​,单​​​位​​​为​​​字​​​节​​​
    • hierarchical_memsw_limit:包​​​含​​​ memory cgroup 的​​​层​​​级​​​的​​​内​​​存​​​加​​​ swap 限​​​制​​​,单​​​位​​​为​​​字​​​节​​​

8. 总结

本文由浅入深的讲解了cgroups的方方面面,从cgroups是什么,到cgroups该怎么用,最后对大量的cgroup子系统配置参数进行了梳理。可以看到,内核对cgroups的支持已经较为完善,但是依旧有许多工作需要完善。如网络方面目前是通过TC(Traffic Controller)来控制,未来需要统一整合;资源限制并没有解决资源竞争,在各自限制之内的进程依旧存在资源竞争,优先级调度方面依旧有很大的改进空间。希望通过本文帮助大家了解cgroups,让更多人参与到社区的贡献中。

9. 作者简介

孙健波,浙江大学SEL实验室硕士研究生,目前在云平台团队从事科研和开发工作。浙大团队对PaaS、Docker、大数据和主流开源云计算技术有深入的研究和二次开发经验,团队现将部分技术文章贡献出来,希望能对读者有所帮助。

参考资料

https://sysadmincasts.com/episodes/14-introduction-to-linux-control-groups-cgroups

https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/6/html/Resource_Management_Guide/index.html

http://www.cnblogs.com/lisperl/archive/2013/01/14/2860353.html

https://www.kernel.org/doc/Documentation/cgroups

Docker背后的内核知识——Namespace资源隔离

Docker这么火,喜欢技术的朋友可能也会想,如果要自己实现一个资源隔离的容器,应该从哪些方面下手呢?也许你第一反应可能就是chroot命令,这条命令给用户最直观的感觉就是使用后根目录/的挂载点切换了,即文件系统被隔离了。然后,为了在分布式的环境下进行通信和定位,容器必然需要一个独立的IP、端口、路由等等,自然就想到了网络的隔离。同时,你的容器还需要一个独立的主机名以便在网络中标识自己。想到网络,顺其自然就想到通信,也就想到了进程间通信的隔离。可能你也想到了权限的问题,对用户和用户组的隔离就实现了用户权限的隔离。最后,运行在容器中的应用需要有自己的PID,自然也需要与宿主机中的PID进行隔离。

由此,我们基本上完成了一个容器所需要做的六项隔离,Linux内核中就提供了这六种namespace隔离的系统调用,如下表所示。

34


表1 namespace六项隔离

实际上,Linux内核实现namespace的主要目的就是为了实现轻量级虚拟化(容器)服务。在同一个namespace下的进程可以感知彼此的变化,而对外界的进程一无所知。这样就可以让容器中的进程产生错觉,仿佛自己置身于一个独立的系统环境中,以此达到独立和隔离的目的。

需要说明的是,本文所讨论的namespace实现针对的均是Linux内核3.8及其以后的版本。接下来,我们将首先介绍使用namespace的API,然后针对这六种namespace进行逐一讲解,并通过程序让你亲身感受一下这些隔离效果参考自http://lwn.net/Articles/531114/

Continue reading

Docker源码分析(九):Docker镜像

1.前言

回首过去的2014年,大家可以看到Docker在全球刮起了一阵又一阵的“容器风”,工业界对Docker的探索与实践更是一波高过一波。在如今的2015年以及未来,Docker似乎并不会像其他昙花一现的技术一样,在历史的舞台上热潮褪去,反而在工业界实践与评估之后,显现了前所未有的发展潜力。

究其本质,“Docker提供容器服务”这句话,相信很少有人会有异议。那么,既然Docker提供的服务属于“容器”技术,那么反观“容器”技术的本质与历史,我们又可以发现什么呢?正如前文所提到的,Docker使用的“容器”技术,主要是以Linux的cgroup、namespace等内核特性为基础,保障进程或者进程组处于一个隔离、安全的环境。Docker发行第一个版本是在2013年的3月,而cgroup的正式亮相可以追溯到2007年下半年,当时cgroup被合并至Linux内核2.6.24版本。期间6年时间,并不是“容器”技术发展的真空期,2008年LXC(Linux Container)诞生,其简化了容器的创建与管理;之后业界一些PaaS平台也初步尝试采用容器技术作为其云应用的运行环境;而与Docker发布同年,Google也发布了开源容器管理工具lmctfy。除此之外,若抛开Linux操作系统,其他操作系统如FreeBSD、Solaris等,同样诞生了作用相类似的“容器”技术,其发展历史更是需要追溯至千禧年初期。

可见,“容器”技术的发展不可谓短暂,然而论同时代的影响力,却鲜有Docker的媲美者。不论是云计算大潮催生了Docker技术,抑或是Docker技术赶上了云计算的大时代,毋庸置疑的是,Docker作为领域内的新宠儿,必然会继续受到业界的广泛青睐。云计算时代,分布式应用逐渐流行,并对其自身的构建、交付与运行有着与传统不一样的要求。借助Linux内核的cgroup与namespace特性,自然可以做到应用运行环境的资源隔离与应用部署的快速等;然而,cgroup和namespace等内核特性却无法为容器的运行环境做全盘打包。而Docker的设计则很好得考虑到了这一点,除cgroup和namespace之外,另外采用了神奇的“镜像”技术作为Docker管理文件系统以及运行环境的强有力补充。Docker灵活的“镜像”技术,在笔者看来,也是其大红大紫最重要的因素之一。

Continue reading

Docker源码分析(八):Docker Container网络(下)

1.Docker Client配置容器网络模式

Docker目前支持4种网络模式,分别是bridge、host、container、none,Docker开发者可以根据自己的需求来确定最适合自己应用场景的网络模式。

从Docker Container网络创建流程图中可以看到,创建流程第一个涉及的Docker模块即为Docker Client。当然,这也十分好理解,毕竟Docker Container网络环境的创建需要由用户发起,用户根据自身对容器的需求,选择网络模式,并将其通过Docker Client传递给Docker Daemon。本节,即从Docker Client源码的角度,分析如何配置Docker Container的网络模式,以及Docker Client内部如何处理这些网络模式参数。

需要注意的是:配置Docker Container网络环境与创建Docker Container网络环境有一些区别。区别是:配置网络环境指用户通过向Docker Client传递网络参数,实现Docker Container网络环境参数的配置,这部分配置由Docker Client传递至Docker Daemon,并由Docker Daemon保存;创建网络环境指,用户通过Docker Client向Docker Daemon发送容器启动命令之后,Docker Daemon根据之前保存的网络参数,实现Docker Container的启动,并在启动过程中完成Docker Container网络环境的创建。

以上的基本知识,理解下文的Docker Container网络环境创建流程。

Continue reading

etcd:从应用场景到实现原理的全方位解读

随着CoreOS和Kubernetes等项目在开源社区日益火热,它们项目中都用到的etcd组件作为一个高可用、强一致性的服务发现存储仓库,渐渐为开发人员所关注。在云计算时代,如何让服务快速透明地接入到计算集群中,如何让共享配置信息快速被集群中的所有机器发现,更为重要的是,如何构建这样一套高可用、安全、易于部署以及响应快速的服务集群,已经成为了迫切需要解决的问题。etcd为解决这类问题带来了福音,本章将从etcd的应用场景开始,深入解读etcd的实现方式,以供开发者们更为充分地享用etcd所带来的便利。

1 etcd经典应用场景

etcd是什么?很多人对这个问题的第一反应可能是,它是一个键值存储仓库,却没有重视官方定义的后半句,用于配置共享和服务发现。

A highly-available key value store for shared configuration and service discovery.

实际上,etcd作为一个受到Zookeeper与doozer启发而催生的项目,除了拥有与之类似的功能外,更具有以下4个特点{![引自Docker官方文档]}。

  • 简单:基于HTTP+JSON的API让你用curl命令就可以轻松使用。
  • 安全:可选SSL客户认证机制。
  • 快速:每个实例每秒支持一千次写操作。
  • 可信:使用Raft算法充分实现了分布式。

随着云计算的不断发展,分布式系统中涉及的问题越来越受到人们重视。受阿里中间件团队对ZooKeeper典型应用场景一览一文的启发{![部分案例引自此文。]},我根据自己的理解也总结了一些etcd的经典使用场景。值得注意的是,分布式系统中的数据分为控制数据和应用数据。使用etcd的场景处理的数据默认为控制数据,对于应用数据,只推荐处理数据量很小,但是更新访问频繁的情况。

Continue reading