first commit
This commit is contained in:
135
专栏/容器实战高手课/00开篇词一个态度两个步骤,成为容器实战高手.md
Normal file
135
专栏/容器实战高手课/00开篇词一个态度两个步骤,成为容器实战高手.md
Normal file
@ -0,0 +1,135 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 一个态度两个步骤,成为容器实战高手
|
||||
你好,我是李程远,欢迎你加入我的极客时间专栏。从今天开始,我想和你聊一聊,怎么解决容器里的各种问题。
|
||||
|
||||
先来介绍一下我自己吧。我毕业于浙江大学计算机系,第一份工作是开发基于Xen的Linux虚拟机,接下来的十几年,我的工作始终围绕着Linux系统。
|
||||
|
||||
在2013年,我加入eBay,从事云平台方面的工作,最先接触的是OpenStack云平台。
|
||||
|
||||
一直到了2015年的时候,我们的团队开始做Kubernetes,要用Kubernetes来管理eBay整个云平台。我们需要迁移所有eBay的应用程序,把它们从原来的物理机或者虚拟机迁移到容器的环境里。
|
||||
|
||||
在Kubernetes具体落地的过程中,我们碰到了形形色色的容器问题。
|
||||
|
||||
首先,我们都知道,容器是一种轻量级的隔离技术。而轻量级隔离造成了一些行为模式的不同,比如原来运行在虚拟机里的CPU监控程序,移到容器之后,再用原来的算法计算容器CPU使用率就不适用了。
|
||||
|
||||
然后呢,从隔离程度这个方面考虑,CPU、memory、IO (disk and network)真的能做到精确隔离吗?
|
||||
|
||||
其实还是有问题的,比如想让多个用户容器运行在一个节点上,我们就需要保证,每个容器的磁盘容量在一定的限额范围内,还需要合理分配磁盘读写性能。
|
||||
|
||||
第三个方面,就是处理性能敏感的应用。容器技术的引入,会带来新的开销,那么肯定会影响性能。
|
||||
|
||||
比如说,原来运行在物理机上、有极高性能要求的程序,在迁移到容器后,我们还需要对容器网络做优化,对Cgroup做优化。只有做了这样的优化,我们才能保证迁移过来的程序,当它们运行在容器里的时候,性能差异控制在2%以内(当时做迁移的标准)。
|
||||
|
||||
另外,如果涉及高内存使用的应用,我们做迁移的时候,还要考虑PageCache、Swap,还有HugePage等等问题,在叠加了Cgroup之后,会带来新的变化。
|
||||
|
||||
综合来看,我们遇到的问题有的很简单,看一下源代码,写个测试代码验证一下,一两个小时就可以搞定。但有的问题却很复杂,我们需要尝试不同的测试,反复查看各种源代码,陆陆续续花费一两个月的时间解决。
|
||||
|
||||
通过5年的不断努力,我和我的团队逐渐把eBay所有的业务都迁移到了容器中。现在,我们的云平台上运行着百万个容器。
|
||||
|
||||
怎么理解容器的知识体系?
|
||||
|
||||
可以说,从我接触容器知识到能够得心应手地解决各种容器问题,这个过程还真是有点磕磕绊绊。
|
||||
|
||||
一开始,我被各种各样的问题所淹没,觉得容器的内容太复杂了,没有一个系统性的解决方法。我只能是见招拆招,一个个解决,就这样,随着我解决的问题越来越多,我也开始思考,是不是有一些规律性的东西。
|
||||
|
||||
容器问题虽然有很多类型,既有基本功能问题,也有性能问题,还有不少稳定性问题。但大部分问题,最终都会归结到Linux操作系统上。
|
||||
|
||||
比如,容器里进程被OOM Kill了,这个OOM Killer就是Linux里常见的内存保护机制;容器的进程引起平均负载增高,而平均负载也是在Linux里被反复讨论的概念;还有,容器使用的OverlayFS系统,看上去和Linux常用的XFS、Ext4系统不同,但是它也是Linux内核维护的一种文件系统。
|
||||
|
||||
我们都知道,Linux操作系统不外乎是进程管理、内存管理、文件系统、网络协议栈,再加上一些安全管理。这样一梳理,容器的问题就都可以投射到Linux操作系统这些模块上了,是不是一下子感觉清晰了很多?
|
||||
|
||||
当然了,容器还有自己的特殊性,Linux内核原来的特性加上Namespace和Cgroups会带来的变化。
|
||||
|
||||
所以,我们在对应到每个模块上分析问题的时候,还需要考虑到Namespace和Cgroups。这两个概念是容器技术的基石,我们课程中要讨论的容器相关问题,多少都会和Namespace或者Cgroups相关。
|
||||
|
||||
总之就是一句话,我们可以结合Linux操作系统的主要模块,把容器的知识结构系统地串联起来,同时看到Namespace和Cgroups带来的特殊性。
|
||||
|
||||
|
||||
|
||||
怎么解决容器问题?
|
||||
|
||||
心中有了容器的知识体系,我们也就能在容器实践中解决具体的问题了。结合我自己这么多年的经历,我总结了一条经验,解决容器问题需要一个态度+两个步骤。
|
||||
|
||||
在解决容器问题的过程中,我们常见的误区就是浅尝辄止,不去挖掘问题的根本原因。我之前也碰到过这种情况,接下来我就拿一个具体的例子来说明。
|
||||
|
||||
有一次团队一位同学问我,怎么让Kubernetes节点上的容器,从内部触发自己的容器重启啊?
|
||||
|
||||
我试了一下,在容器中把第1号进程杀了,然后容器退出,Kubernetes自动地把容器带回来,就能实现类似的自动重启功能了,同事试了也可以,认为问题解决了,也挺开心的。我也没有多想,以为自己找到方法了。
|
||||
|
||||
后来又有一个同事和我说,这样做没有效果啊。我这才发现问题没那么简单,是我想当然了。
|
||||
|
||||
所以,我又花时间理了理Linux信号的基本知识,trace了一下内核代码,终于让我找到了真正的原因,那就是对于发送给1号进程的信号,内核会根据不同的类型、不同的注册状态,采取不同的处理方式。
|
||||
|
||||
你看,这是一个挺简单的问题,就是kill一下容器里的1号进程。你或许也遇到过,如果你也和我开始时的态度一样,就很可能会错过找到真正答案的机会。这就是我说的,解决容器问题时我们需要的一个态度:不要浅尝辄止,要刨根问底。
|
||||
|
||||
态度有了,那如果我们在线上碰到了更加复杂的问题,又该怎么解决呢?这就需要两个步骤了。
|
||||
|
||||
我们的第一步,就是化繁为简,重现问题。
|
||||
|
||||
想要做到这一点,倒推回去,还是需要我们对基本的概念足够了解。只有对每个模块的概念都很清晰,我们才能对复杂问题做拆分。
|
||||
|
||||
能够对问题做拆分是不是就够了呢?其实还不够,我自己有一个判断标准,就是还要能够写模拟程序,看是否可以用最简单的程序来重现问题。如果我们能用简单的代码程序重现问题,那么问题也就解决了一半。
|
||||
|
||||
接下来我们还需要进行第二步,就是想办法把黑盒系统变成白盒系统。
|
||||
|
||||
我在前面提到过,容器的问题大多都会归结到Linux系统上。Linux系统从内核、库函数以及服务程序上看,虽然都是开源的,但是它运行在生产环境的时候,几乎就是一个黑盒。
|
||||
|
||||
之所以说系统是黑盒,一方面是因为这个系统太庞大,太复杂了;另一方面,在实际运行的时候,只有很少的log会记录运行的过程和参数。所以,在出问题的时候,我们无法知道问题对应的代码,我们也不可能在生产环境中随心所欲地加debug log。
|
||||
|
||||
因此,我们就需要想点办法把它变成白盒,才能去排查和解决问题。具体怎么做呢?这里需要我们熟练地掌握调试工具,这样才能把某些函数变成“白盒”,从而找到复杂问题的根本原因,再对症下药。
|
||||
|
||||
这里我想提醒你的是,我们熟练掌握工具有个重要前提,就是从全局上去掌握Linux系统以及容器,回归到底层原理去看问题。可以说,你把基础概念吃透了,练好了“内功心法”,有了这个底子,工具运用是水到渠成的事儿。
|
||||
|
||||
我是怎么设计这门课的?
|
||||
|
||||
讲到这里,估计你会有个问题,这“一个态度两个步骤”很好理解啊,我也了解到了,但是怎么才能真正地掌握这些知识、拥有解决问题的思路呢?
|
||||
|
||||
这就是我们这门课想要实现的目标了,那就是带你走进一个个具体的案例中,体验解决问题的全过程,在实战中习得知识和技能。
|
||||
|
||||
所以,在这门课程里,我会把零散的知识点体系化,按照类似操作系统的模块划分,为你讲述我所理解的容器。
|
||||
|
||||
我们将一起学习容器进程、容器内存、容器存储、 容器网络、容器安全这几部分内容。在每一节课中,我们都会解决一个实际问题或者研究一个现象。围绕这个问题,我会为你讲解相关的知识点,并带着你结合实际的操作做理解,最终解决问题或者解释现象。
|
||||
|
||||
我们要实现两个学习目标。
|
||||
|
||||
第一,系统掌握容器核心点:Namespace和Cgroups。
|
||||
|
||||
第二,理解Namespace和Cgroups对Linux原来模块的影响,看看它们是如何影响一些传统操作系统的行为。
|
||||
|
||||
比如Memory Cgroup,对Pagecache和Swap空间有怎样的影响;再比如在proc文件系统下,我们的网络参数应用了Network Namespace之后,需要如何重新设置等等。
|
||||
|
||||
当我们一起把容器知识的框架搭建起来,把里面的核心概念、底层逻辑掌握之后,你其实就可以解决容器的大部分问题了。但是,我知道,你一定还有个问题,那就是工具呢?不讲了吗?我真的可以水到渠成吗?
|
||||
|
||||
不要着急,这里我要做个特别说明,课程结束后,我会给你做一个专题加餐。目前,我是这么设计的,我选择了一个真实案例,就是在生产环境中容器网络延时不稳定的问题。
|
||||
|
||||
在这个案例中,我们会用到perf,ftrace,bcc/ebpf这几个Linux调试工具,了解它们的原理,熟悉它们在调试问题的不同阶段所发挥的作用,然后用这些工具一起来解决现实场景中复杂的容器问题。
|
||||
|
||||
为什么一定要把这个专题放到课程结束后呢?因为我需要给你留一段消化吸收的时间,这里我安排了一个月时间。
|
||||
|
||||
希望你能利用这一个月,把整个课程的内容复习一遍,把基本功打扎实,你才能在专题学习里彻底掌握这几个工具,遇到类似问题时也能有清晰的解决思路,这样这个专题的学习效率也才能更高。
|
||||
|
||||
之所以一定要这么安排,也是想跟你表达我的一个观点,就是工具很重要,但是工具不是最重要的。
|
||||
|
||||
所有学习,我们一定是先掌握知识体系,一定不能陷入唯工具论的思维框架里。我知道,这样的安排似乎只是我的一家之言,但这恰恰就是我想通过这门课交付给你的,因为这些真的是我自己的经验之谈,是我的受益点。这么学看似慢了,但其实只有这样,我们走的才是捷径。
|
||||
|
||||
|
||||
|
||||
好了,介绍完了课程设计和学习目标,还有一件事特别重要,我要特别提醒下。
|
||||
|
||||
在这个容器课程中,每一讲里都会有一些小例子,所以需要你有一台安装有Linux的机器,或者用VirtualBox安装一个虚拟机来跑Linux。Linux的版本建议是CentOS 8 或者是Ubuntu 20.04。
|
||||
|
||||
希望你提前做好准备,这样在学习的过程中,你就能跟着我的讲解进行一些实际的操作,对容器知识也会有更加深刻的印象。
|
||||
|
||||
你还可以拉上身边的小伙伴,组团来学习这门课程,共同学习、互相鼓励的氛围会让你的学习体验更好。另外,有什么想法或者疑问,你都可以通过留言区和我交流、互动。
|
||||
|
||||
最后,我想和你说,容器是一个很好的技术窗口,它可以帮助你在这个瞬息万变的计算机世界里看到后面那些“不变”的技术,只有掌握好那些“不变”的技术,你才可以更加从容地去接受技术的瞬息万变。
|
||||
|
||||
我希望,这个专栏能帮你打开容器这扇窗,让你看到更精彩的风景,建立起你自己的容器知识体系。从今天开始,跟着我一起搞懂容器,提升实力,吃透原理,在技术之路上一起前进吧!
|
||||
|
||||
|
||||
|
||||
|
370
专栏/容器实战高手课/01认识容器:容器的基本操作和实现原理.md
Normal file
370
专栏/容器实战高手课/01认识容器:容器的基本操作和实现原理.md
Normal file
@ -0,0 +1,370 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 认识容器:容器的基本操作和实现原理
|
||||
你好,我是程远。作为一名工程师,我猜在过去的几年时间里,你肯定用过或者听人提起过容器(Container)。
|
||||
|
||||
说实话,容器这东西一点都不复杂,如果你只是想用的话,那跟着Docker官网的说明,应该十来分钟就能搞定。
|
||||
|
||||
简单来说,它就是个小工具,可以把你想跑的程序,库文件啊,配置文件都一起“打包”。
|
||||
|
||||
然后,我们在任何一个计算机的节点上,都可以使用这个打好的包。有了容器,一个命令就能把你想跑的程序跑起来,做到了一次打包,就可以到处使用。
|
||||
|
||||
今天是咱们整个课程的第一讲,我想和你来聊聊容器背后的实现机制。
|
||||
|
||||
当然,空讲原理也没什么感觉,所以我还是会先带着你启动一个容器玩玩,然后咱们再一起来探讨容器里面的两大关键技术—— Namespace和Cgroups。基本上理解了这两个概念,你就能彻底搞懂容器的核心原理了。
|
||||
|
||||
做个镜像
|
||||
|
||||
话不多说,咱们就先动手玩一玩。启动容器的工具有很多,在这里我们还是使用Docker这个最常用的容器管理工具。
|
||||
|
||||
如果你之前根本没用过Docker的话,那我建议你先去官网看看文档,一些基础的介绍我就不讲了,那些内容你随便在网上一搜就能找到。
|
||||
|
||||
安装完Docker之后,咱们先来用下面的命令运行一个httpd服务。
|
||||
|
||||
# docker run -d centos/httpd:latest
|
||||
|
||||
|
||||
这命令也很简单,run的意思就是要启动一个容器, -d 参数里d是Daemon的首字母,也就是让容器在后台运行。
|
||||
|
||||
最后一个参数 centos/httpd:latest 指定了具体要启动哪一个镜像,比如这里咱们启动的是centos/httpd这个镜像的latest版本。
|
||||
|
||||
镜像是Docker公司的创举,也是一个伟大的发明。你想想,在没有容器之前,你想安装httpd的话,会怎么做?是不是得运行一连串的命令?甚至不同的系统上操作方法也不一样?
|
||||
|
||||
但你看,有了容器之后,你只要运行一条命令就搞定了。其实所有的玄机都在这个镜像里面。
|
||||
|
||||
镜像这么神奇,那它到底是怎么一回事呢?其实,镜像就是一个特殊的文件系统,
|
||||
|
||||
它提供了容器中程序执行需要的所有文件。具体来说,就是应用程序想启动,需要三类文件:相关的程序可执行文件、库文件和配置文件,这三类文件都被容器打包做好了。
|
||||
|
||||
这样,在容器运行的时候就不再依赖宿主机上的文件操作系统类型和配置了,做到了想在哪个节点上运行,就可以在哪个节点上立刻运行。
|
||||
|
||||
那么我们怎么来做一个容器镜像呢?
|
||||
|
||||
刚才的例子里,我们用的 centos/httpd:latest 这个镜像是 Docker镜像库里直接提供的。当然,我们也可以自己做一个提供httpd服务的容器镜像,这里仍然可以用Docker这个工具来自定义镜像。
|
||||
|
||||
Docker为用户自己定义镜像提供了一个叫做Dockerfile的文件,在这个Dockerfile文件里,你可以设定自己镜像的创建步骤。
|
||||
|
||||
如果我们自己来做一个httpd的镜像也不难,举个例子,我们可以一起来写一个Dockerfile,体会一下整个过程。用Dockerfile build image的 Dockerfile 和对应的目录我放在这里了。
|
||||
|
||||
操作之前,我们首先要理解这个Dockerfile做了什么,其实它很简单,只有下面这5行:
|
||||
|
||||
# cat Dockerfile
|
||||
FROM centos:8.1.1911
|
||||
RUN yum install -y httpd
|
||||
COPY file1 /var/www/html/
|
||||
ADD file2.tar.gz /var/www/html/
|
||||
CMD ["/sbin/httpd", "-D", "FOREGROUND"]
|
||||
|
||||
|
||||
我们看下它做了哪几件事:在一个centos的基准镜像上安装好httpd的包,然后在httpd提供文件服务的配置目录下,把需要对外提供的文件file1和file2拷贝过去,最后指定容器启动以后,需要自动启动的httpd服务。
|
||||
|
||||
有了这个镜像,我们希望容器启动后,就运行这个httpd服务,让用户可以下载file1还有file2这两个文件。
|
||||
|
||||
我们具体来看这个Dockerfile的每一行,第一个大写的词都是Dockerfile专门定义的指令,也就是 FROM、RUN、COPY、ADD、CMD,这些指令都很基础,所以我们不做详细解释了,你可以参考Dockerfile的官方文档。
|
||||
|
||||
我们写完这个Dockerfile之后,想要让它变成一个镜像,还需要执行一下 docker build 命令。
|
||||
|
||||
下面这个命令中 -f ./Dockerfile 指定Dockerfile文件,-t registry/httpd:v1 指定了生成出来的镜像名,它的格式是”name:tag”,这个镜像名也是后面启动容器需要用到的。
|
||||
|
||||
# docker build -t registry/httpd:v1 -f ./Dockerfile .
|
||||
|
||||
|
||||
docker build 执行成功之后,我们再运行 docker images 这个命令,就可以看到生成的镜像了。
|
||||
|
||||
# docker images
|
||||
REPOSITORY TAG IMAGEID CREATED SIZE
|
||||
registry/httpd v1 c682fc3d4b9a 4 seconds ago 277MB
|
||||
|
||||
|
||||
启动一个容器 (Container)
|
||||
|
||||
做完一个镜像之后,你就可以用这个镜像来启动一个容器了,我们刚才做的镜像名字是 registry/httpd:v1,那么还是用 docker run 这个命令来启动容器。
|
||||
|
||||
# docker run -d registry/httpd:v1
|
||||
|
||||
|
||||
容器启动完成后,我们可以用 docker ps 命令来查看这个已经启动的容器:
|
||||
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
c5a9ff78d9c1 registry/httpd:v1 "/sbin/httpd -D FORE…" 2 seconds ago Up 2 seconds loving_jackson
|
||||
|
||||
|
||||
在前面介绍Dockerfile的时候,我们说过做这个镜像是用来提供HTTP服务的,也就是让用户可以下载file1、file2这两个文件。
|
||||
|
||||
那怎样来验证我们建起来的容器是不是正常工作的呢?可以通过这两步来验证:
|
||||
|
||||
|
||||
第一步,我们可以进入容器的运行空间,查看httpd服务是不是启动了,配置文件是不是正确的。
|
||||
第二步,对于HTTP文件服务,如果我们能用 curl 命令下载文件,就可以证明这个容器提供了我们预期的httpd服务。
|
||||
|
||||
|
||||
我们先来做第一步验证,我们可以运行 docker exec 这个命令进入容器的运行空间,至于什么是容器的运行空间,它的标准说法是容器的命名空间(Namespace),这个概念我们等会儿再做介绍。
|
||||
|
||||
进入容器运行空间之后,我们怎么确认httpd的服务进程已经在容器里启动了呢?
|
||||
|
||||
我们运行下面这个 docker exec 命令,也就是执行 docker exec c5a9ff78d9c1 ps -ef ,可以看到httpd的服务进程正在容器的空间中运行。
|
||||
|
||||
# docker exec c5a9ff78d9c1 ps -ef
|
||||
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 6 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 7 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 8 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 9 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
|
||||
|
||||
这里我解释一下,在这个 docker exec 后面紧跟着的ID表示容器的ID,这个ID就是我们之前运行 docker ps 查看过那个容器,容器的ID值是 c5a9ff78d9c1 。在这个ID值的后面,就是我们要在容器空间里运行的 ps -ef 命令。
|
||||
|
||||
接下来我们再来确认一下,httpd提供文件服务的目录中file1和file2文件是否存在。
|
||||
|
||||
我们同样可以用 docker exec 来查看一下容器的文件系统中,httpd提供文件服务的目录 /var/www/html 是否有这两个文件。
|
||||
|
||||
很好,我们可以看到file1、file2这两个文件也都放在指定目录中了。
|
||||
|
||||
# docker exec c5a9ff78d9c1 ls /var/www/html
|
||||
file1
|
||||
file2
|
||||
|
||||
|
||||
到这里我们完成了第一步的验证,进入到容器的运行空间里,验证了httpd服务已经启动,配置文件也是正确的。
|
||||
|
||||
那下面我们要做第二步的验证,用 curl 命令来验证是否可以从容器的httpd服务里下载到文件。
|
||||
|
||||
如果要访问httpd服务,我们就需要知道这个容器的IP地址。容器的网络空间也是独立的,有一个它自己的IP。我们还是可以用 docker exec 进入到容器的网络空间,查看一下这个容器的IP。
|
||||
|
||||
运行下面的这条 docker exec c5a9ff78d9c1 ip addr 命令,我们可以看到容器里网络接口eth0上配置的IP是 172.17.0.2 。
|
||||
|
||||
这个IP目前只能在容器的宿主机上访问,在别的机器上目前是不能访问的。关于容器网络的知识,我们会在后面的课程里介绍。
|
||||
|
||||
# docker exec c5a9ff78d9c1 ip addr
|
||||
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
||||
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
|
||||
inet 127.0.0.1/8 scope host lo
|
||||
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
168: eth0@if169: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
|
||||
|
||||
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
|
||||
|
||||
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
|
||||
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
|
||||
好了,获取了httpd服务的IP地址之后,我们随便下载一个文件试试,比如选file2。
|
||||
|
||||
我们在宿主机上运行 curl ,就可以下载这个文件了,操作如下。很好,文件下载成功了,这证明了我们这个提供httpd服务的容器正常运行了。
|
||||
|
||||
# curl -L -O http://172.17.0.2/file2
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
|
||||
Dload Upload Total Spent Left Speed
|
||||
|
||||
100 6 100 6 0 0 1500 0 --:--:-- --:--:-- --:--:-- 1500
|
||||
|
||||
# ls
|
||||
file2
|
||||
|
||||
|
||||
上面的步骤完成之后,我们的第二步验证,用 curl 下载httpd服务提供的文件也成功了。
|
||||
|
||||
好了,我们刚才自己做了容器镜像,用这个镜像启动了容器,并且用 docker exec 命令检查了容器运行空间里的进程、文件和网络设置。
|
||||
|
||||
通过这上面的这些操作练习,估计你已经初步感知到,容器的文件系统是独立的,运行的进程环境是独立的,网络的设置也是独立的。但是它们和宿主机上的文件系统,进程环境以及网络感觉都已经分开了。
|
||||
|
||||
我想和你说,这个感觉没错,的确是这样。我们刚才启动的容器,已经从宿主机环境里被分隔出来了,就像下面这张图里的描述一样。
|
||||
|
||||
|
||||
|
||||
从用户使用的角度来看,容器和一台独立的机器或者虚拟机没有什么太大的区别,但是它和虚拟机相比,却没有各种复杂的硬件虚拟层,没有独立的Linux内核。
|
||||
|
||||
容器所有的进程调度,内存访问,文件的读写都直接跑在宿主机的内核之上,这是怎么做到的呢?
|
||||
|
||||
容器是什么
|
||||
|
||||
要回答这个问题,你可以先记住这两个术语 Namespace和Cgroups。如果有人问你Linux上的容器是什么,最简单直接的回答就是Namesapce和Cgroups。Namespace和Cgroups可以让程序在一个资源可控的独立(隔离)环境中运行,这个就是容器了。
|
||||
|
||||
我们现在已经发现:容器的进程、网络还有文件系统都是独立的。那问题来了,容器的独立运行环境到底是怎么创造的呢?这就要提到Namespace这个概念了。所以接下来,就先从我们已经有点感觉的Namespace开始分析。
|
||||
|
||||
Namespace
|
||||
|
||||
接着前面的例子,我们正好有了一个正在运行的容器,那我们就拿这个运行的容器来看看Namespace到底是什么?
|
||||
|
||||
在前面我们运行 docker exec c5a9ff78d9c1 ps -ef,看到了5个httpd进程,而且也只有这5个进程。
|
||||
|
||||
# docker exec c5a9ff78d9c1 ps -ef
|
||||
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 6 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 7 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 8 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
apache 9 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
|
||||
|
||||
如果我们不用 docker exec,直接在宿主机上运行 ps -ef,就会看到很多进程。如果我们运行一下 grep httpd ,同样可以看到这5个httpd的进程:
|
||||
|
||||
# ps -ef | grep httpd
|
||||
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 20731 20684 0 18:59 ? 00:00:01 /sbin/httpd -D FOREGROUND
|
||||
48 20787 20731 0 18:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
|
||||
48 20788 20731 0 18:59 ? 00:00:06 /sbin/httpd -D FOREGROUND
|
||||
48 20789 20731 0 18:59 ? 00:00:05 /sbin/httpd -D FOREGROUND
|
||||
48 20791 20731 0 18:59 ? 00:00:05 /sbin/httpd -D FOREGROUN
|
||||
|
||||
|
||||
这两组输出结果到底有什么差别呢,你可以仔细做个对比,最大的不同就是进程的PID不一样。那为什么PID会不同呢?或者说,运行 docker exec c5a9ff78d9c1 ps -ef 和 ps -ef 实质的区别在哪里呢?
|
||||
|
||||
如果理解了PID为何不同,我们就能搞清楚Linux Namespace的概念了,为了方便后文的讲解,我们先用下面这张图来梳理一下我们看到的PID。
|
||||
|
||||
|
||||
|
||||
Linux在创建容器的时候,就会建出一个PID Namespace,PID其实就是进程的编号。这个PID Namespace,就是指每建立出一个Namespace,就会单独对进程进行PID编号,每个Namespace的PID编号都从1开始。
|
||||
|
||||
同时在这个PID Namespace中也只能看到Namespace中的进程,而且看不到其他Namespace里的进程。
|
||||
|
||||
这也就是说,如果有另外一个容器,那么它也有自己的一个PID Namespace,而这两个PID Namespace之间是不能看到对方的进程的,这里就体现出了Namespace的作用:相互隔离。
|
||||
|
||||
而在宿主机上的Host PID Namespace,它是其他Namespace的父亲Namespace,可以看到在这台机器上的所有进程,不过进程PID编号不是Container PID Namespace里的编号了,而是把所有在宿主机运行的进程放在一起,再进行编号。
|
||||
|
||||
讲了PID Namespace之后,我们了解到 Namespace其实就是一种隔离机制,主要目的是隔离运行在同一个宿主机上的容器,让这些容器之间不能访问彼此的资源。
|
||||
|
||||
这种隔离有两个作用:第一是可以充分地利用系统的资源,也就是说在同一台宿主机上可以运行多个用户的容器;第二是保证了安全性,因为不同用户之间不能访问对方的资源。
|
||||
|
||||
除了PID Namespace,还有其他常见的Namespace类型,比如我们之前运行了 docker exec c5a9ff78d9c1 ip addr 这个命令去查看容器内部的IP地址,这里其实就是在查看Network Namespace。
|
||||
|
||||
在Network Namespace中都有一套独立的网络接口比如这里的lo,eth0,还有独立的TCP/IP的协议栈配置。
|
||||
|
||||
# docker exec c5a9ff78d9c1 ip addr
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
inet 127.0.0.1/8 scope host lo
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
168: eth0@if169: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
|
||||
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
|
||||
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
|
||||
我们还可以运行 docker exec c5a9ff78d9c1 ls/ 查看容器中的根文件系统(rootfs)。然后,你会发现,它和宿主机上的根文件系统也是不一样的。容器中的根文件系统,其实就是我们做的镜像。
|
||||
|
||||
那容器自己的根文件系统完全独立于宿主机上的根文件系统,这一点是怎么做到的呢?其实这里依靠的是Mount Namespace,Mount Namespace保证了每个容器都有自己独立的文件目录结构。
|
||||
|
||||
Namespace的类型还有很多,我们查看”Linux Programmer’s Manual”,可以看到Linux中所有的Namespace:cgroup/ipc/network/mount/pid/time/user/uts。
|
||||
|
||||
在这里呢,你需要记住的是 Namespace 是Linux中实现容器的两大技术之一,它最重要的作用是保证资源的隔离。在后面的课程,讲解到具体问题时,我会不断地提到Namespace这个概念。
|
||||
|
||||
|
||||
|
||||
好了,我们刚才说了Namespace,这些Namespace尽管类型不同,其实都是为了隔离容器资源:PID Namespace负责隔离不同容器的进程,Network Namespace又负责管理网络环境的隔离,Mount Namespace管理文件系统的隔离。
|
||||
|
||||
正是通过这些Namespace,我们才隔离出一个容器,这里你也可以把它看作是一台“计算机”。
|
||||
|
||||
既然是一台“计算机”,你肯定会问这个“计算机”有多少CPU,有多少Memory啊?那么Linux如何为这些“计算机”来定义CPU,定义Memory的容量呢?
|
||||
|
||||
Cgroups
|
||||
|
||||
想要定义“计算机”各种容量大小,就涉及到支撑容器的第二个技术Cgroups (Control Groups)了。Cgroups可以对指定的进程做各种计算机资源的限制,比如限制CPU的使用率,内存使用量,IO设备的流量等等。
|
||||
|
||||
Cgroups究竟有什么好处呢?要知道,在Cgroups出现之前,任意一个进程都可以创建出成百上千个线程,可以轻易地消耗完一台计算机的所有CPU资源和内存资源。
|
||||
|
||||
但是有了Cgroups这个技术以后,我们就可以对一个进程或者一组进程的计算机资源的消耗进行限制了。
|
||||
|
||||
Cgroups通过不同的子系统限制了不同的资源,每个子系统限制一种资源。每个子系统限制资源的方式都是类似的,就是把相关的一组进程分配到一个控制组里,然后通过树结构进行管理,每个控制组都设有自己的资源控制参数。
|
||||
|
||||
完整的Cgroups子系统的介绍,你可以查看Linux Programmer’s Manual 中Cgroups的定义。
|
||||
|
||||
这里呢,我们只需要了解几种比较常用的Cgroups子系统:
|
||||
|
||||
|
||||
CPU子系统,用来限制一个控制组(一组进程,你可以理解为一个容器里所有的进程)可使用的最大CPU。
|
||||
memory子系统,用来限制一个控制组最大的内存使用量。
|
||||
pids子系统,用来限制一个控制组里最多可以运行多少个进程。
|
||||
cpuset子系统, 这个子系统来限制一个控制组里的进程可以在哪几个物理CPU上运行。
|
||||
|
||||
|
||||
因为memory子系统的限制参数最简单,所以下面我们就用memory子系统为例,一起看看Cgroups是怎么对一个容器做资源限制的。
|
||||
|
||||
对于启动的每个容器,都会在Cgroups子系统下建立一个目录,在Cgroups中这个目录也被称作控制组,比如下图里的”docker-”“docker-“等。然后我们设置这个控制组的参数,通过这个方式,来限制这个容器的内存资源。
|
||||
|
||||
|
||||
|
||||
还记得,我们之前用Docker创建的那个容器吗?在每个Cgroups子系统下,对应这个容器就会有一个目录docker-c5a9ff78d9c1……这个容器的ID号,容器中所有的进程都会储存在这个控制组中 cgroup.procs 这个参数里。
|
||||
|
||||
你看下面的这些进程号是不是很熟悉呢?没错,它们就是前面我们用ps看到的进程号。
|
||||
|
||||
我们实际看一下这个例子里的memory Cgroups,它可以控制Memory的使用量。比如说,我们将这个控制组Memory的最大用量设置为2GB。
|
||||
|
||||
具体操作是这样的,我们把(2* 1024 * 1024 * 1024 = 2147483648)这个值,写入memory Cgroup控制组中的memory.limit_in_bytes里,这样设置后,cgroup.procs里面所有进程Memory使用量之和,最大也不会超过2GB。
|
||||
|
||||
# cd /sys/fs/cgroup/memory/system.slice/docker-c5a9ff78d9c1fedd52511e18fdbd26357250719fa0d128349547a50fad7c5de9.scope
|
||||
|
||||
|
||||
# cat cgroup.procs
|
||||
20731
|
||||
20787
|
||||
20788
|
||||
20789
|
||||
20791
|
||||
|
||||
# echo 2147483648 > memory.limit_in_bytes
|
||||
# cat memory.limit_in_bytes
|
||||
2147483648
|
||||
|
||||
|
||||
刚刚我们通过memory Cgroups定义了容器的memory可以使用的最大值。其他的子系统稍微复杂一些,但用法也和memory类似,我们在后面的课程中会结合具体的实例来详细解释其他的Cgroups。
|
||||
|
||||
这里我们还要提一下 Cgroups有v1和v2两个版本:
|
||||
|
||||
Cgroups v1在Linux中很早就实现了,各种子系统比较独立,每个进程在各个Cgroups子系统中独立配置,可以属于不同的group。
|
||||
|
||||
虽然这样比较灵活,但是也存在问题,会导致对同一进程的资源协调比较困难(比如memory Cgroup与blkio Cgroup之间就不能协作)。虽然v1有缺陷,但是在主流的生产环境中,大部分使用的还是v1。
|
||||
|
||||
Cgroups v2 做了设计改进,解决了v1的问题,使各个子系统可以协调统一地管理资源。
|
||||
|
||||
不过Cgroups v2在生产环境的应用还很少,因为该版本很多子系统的实现需要较新版本的Linux内核,还有无论是主流的Linux发行版本还是容器云平台,比如Kubernetes,对v2的支持也刚刚起步。
|
||||
|
||||
所以啊,我们在后面Cgroups的讲解里呢,主要还是用 Cgroups v1这个版本,在磁盘I/O的这一章中,我们也会介绍一下Cgroups v2。
|
||||
|
||||
好了,上面我们解读了Namespace和Cgroups两大技术,它们是Linux下实现容器的两个基石,后面课程中要讨论的容器相关问题,或多或少都和Namespace或者Cgroups相关,我们会结合具体问题做深入的分析。
|
||||
|
||||
目前呢,你只需要先记住这两个技术的作用,Namespace帮助容器来实现各种计算资源的隔离,Cgroups主要限制的是容器能够使用的某种资源量。
|
||||
|
||||
重点总结
|
||||
|
||||
这一讲,我们对容器有了一个大致的认识,包括它的“形”,一些基本的容器操作;还有它的“神”,也就是容器实现的原理。
|
||||
|
||||
启动容器的基本操作是这样的,首先用Dockerfile来建立一个容器的镜像,然后再用这个镜像来启动一个容器。
|
||||
|
||||
那启动了容器之后,怎么检验它是不是正常工作了呢?
|
||||
|
||||
我们可以运行 docker exec 这个命令进入容器的运行空间,查看进程是否启动,检查配置文件是否正确,检验我们设置的服务是否能够正常提供。
|
||||
|
||||
我们用 docker exec 命令查看了容器的进程,网络和文件系统,就能体会到容器的文件系统、运行的进程环境和网络的设置都是独立的,所以从用户使用的角度看,容器和一台独立的机器或者虚拟机没有什么太大的区别。
|
||||
|
||||
最后,我们一起学习了Namespace和Cgroups,它们是Linux的两大技术,用于实现容器的特性。
|
||||
|
||||
具体来说,Namespace帮助容器实现各种计算资源的隔离,Cgroups主要对容器使用某种资源量的多少做一个限制。
|
||||
|
||||
所以我们在这里可以直接记住:容器其实就是Namespace+Cgroups。
|
||||
|
||||
思考题
|
||||
|
||||
用Dockerfile为你最熟悉的应用程序做个镜像,然后用Docker命令启动这个容器。
|
||||
|
||||
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
|
||||
|
449
专栏/容器实战高手课/02理解进程(1):为什么我在容器中不能kill1号进程?.md
Normal file
449
专栏/容器实战高手课/02理解进程(1):为什么我在容器中不能kill1号进程?.md
Normal file
@ -0,0 +1,449 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 理解进程(1):为什么我在容器中不能kill 1号进程?
|
||||
你好,我是程远。
|
||||
|
||||
今天,我们正式进入理解进程的模块。我会通过3讲内容,带你了解容器init进程的特殊之处,还有它需要具备哪些功能,才能保证容器在运行过程中不会出现类似僵尸进程,或者应用程序无法graceful shutdown的问题。
|
||||
|
||||
那么通过这一讲,我会带你掌握init进程和Linux信号的核心概念。
|
||||
|
||||
问题再现
|
||||
|
||||
接下来,我们一起再现用 kill 1 命令重启容器的问题。
|
||||
|
||||
我猜你肯定想问,为什么要在容器中执行 kill 1 或者 kill -9 1 的命令呢?其实这是我们团队里的一位同学提出的问题。
|
||||
|
||||
这位同学当时遇到的情况是这样的,他想修改容器镜像里的一个bug,但因为网路配置的问题,这个同学又不想为了重建pod去改变pod IP。
|
||||
|
||||
如果你用过Kubernetes的话,你也肯定知道,Kubernetes上是没有 restart pod 这个命令的。这样看来,他似乎只能让pod做个原地重启了。当时我首先想到的,就是在容器中使用kill pid 1的方式重启容器。
|
||||
|
||||
为了模拟这个过程,我们可以进行下面的这段操作。
|
||||
|
||||
如果你没有在容器中做过 kill 1 ,你可以下载我在GitHub上的这个例子,运行 make image 来做一个容器镜像。
|
||||
|
||||
然后,我们用Docker构建一个容器,用例子中的 init.sh脚本作为这个容器的init进程。
|
||||
|
||||
最后,我们在容器中运行 kill 1 和 kill -9 1 ,看看会发生什么。
|
||||
|
||||
# docker stop sig-proc;docker rm sig-proc
|
||||
# docker run --name sig-proc -d registry/sig-proc:v1 /init.sh
|
||||
# docker exec -it sig-proc bash
|
||||
[root@5cc69036b7b2 /]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 07:23 ? 00:00:00 /bin/bash /init.sh
|
||||
root 8 1 0 07:25 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100
|
||||
root 9 0 6 07:27 pts/0 00:00:00 bash
|
||||
root 22 9 0 07:27 pts/0 00:00:00 ps -ef
|
||||
|
||||
[root@5cc69036b7b2 /]# kill 1
|
||||
[root@5cc69036b7b2 /]# kill -9 1
|
||||
[root@5cc69036b7b2 /]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 07:23 ? 00:00:00 /bin/bash /init.sh
|
||||
root 9 0 0 07:27 pts/0 00:00:00 bash
|
||||
root 23 1 0 07:27 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100
|
||||
root 24 9 0 07:27 pts/0 00:00:00 ps -ef
|
||||
|
||||
|
||||
当我们完成前面的操作,就会发现无论运行 kill 1 (对应Linux中的SIGTERM信号)还是 kill -9 1(对应Linux中的SIGKILL信号),都无法让进程终止。
|
||||
|
||||
那么问题来了,这两个常常用来终止进程的信号,都对容器中的init进程不起作用,这是怎么回事呢?
|
||||
|
||||
要解释这个问题,我们就要回到容器的两个最基本概念——init进程和Linux信号中寻找答案。
|
||||
|
||||
知识详解
|
||||
|
||||
如何理解init进程?
|
||||
|
||||
init进程的意思并不难理解,你只要认真听我讲完,这块内容基本就不会有问题了。我们下面来看一看。
|
||||
|
||||
使用容器的理想境界是一个容器只启动一个进程,但这在现实应用中有时是做不到的。
|
||||
|
||||
比如说,在一个容器中除了主进程之外,我们可能还会启动辅助进程,做监控或者rotate logs;再比如说,我们需要把原来运行在虚拟机(VM)的程序移到容器里,这些原来跑在虚拟机上的程序本身就是多进程的。
|
||||
|
||||
一旦我们启动了多个进程,那么容器里就会出现一个pid 1,也就是我们常说的1号进程或者init进程,然后由这个进程创建出其他的子进程。
|
||||
|
||||
接下来,我带你梳理一下init进程是怎么来的。
|
||||
|
||||
一个Linux操作系统,在系统打开电源,执行BIOS/boot-loader之后,就会由boot-loader负责加载Linux内核。
|
||||
|
||||
Linux内核执行文件一般会放在/boot目录下,文件名类似vmlinuz*。在内核完成了操作系统的各种初始化之后,这个程序需要执行的第一个用户态程就是init进程。
|
||||
|
||||
内核代码启动1号进程的时候,在没有外面参数指定程序路径的情况下,一般会从几个缺省路径尝试执行1号进程的代码。这几个路径都是Unix常用的可执行代码路径。
|
||||
|
||||
系统启动的时候先是执行内核态的代码,然后在内核中调用1号进程的代码,从内核态切换到用户态。
|
||||
|
||||
目前主流的Linux发行版,无论是RedHat系的还是Debian系的,都会把/sbin/init作为符号链接指向Systemd。Systemd是目前最流行的Linux init进程,在它之前还有SysVinit、UpStart等Linux init进程。
|
||||
|
||||
但无论是哪种Linux init进程,它最基本的功能都是创建出Linux系统中其他所有的进程,并且管理这些进程。具体在kernel里的代码实现如下:
|
||||
|
||||
init/main.c
|
||||
|
||||
/*
|
||||
* We try each of these until one succeeds.
|
||||
*
|
||||
* The Bourne shell can be used instead of init if we are
|
||||
* trying to recover a really broken machine.
|
||||
*/
|
||||
|
||||
if (execute_command) {
|
||||
ret = run_init_process(execute_command);
|
||||
if (!ret)
|
||||
return 0;
|
||||
panic("Requested init %s failed (error %d).",
|
||||
execute_command, ret);
|
||||
}
|
||||
|
||||
if (!try_to_run_init_process("/sbin/init") ||
|
||||
!try_to_run_init_process("/etc/init") ||
|
||||
!try_to_run_init_process("/bin/init") ||
|
||||
!try_to_run_init_process("/bin/sh"))
|
||||
return 0;
|
||||
|
||||
|
||||
panic("No working init found. Try passing init= option to kernel. "
|
||||
"See Linux Documentation/admin-guide/init.rst for guidance.");
|
||||
$ ls -l /sbin/init
|
||||
lrwxrwxrwx 1 root root 20 Feb 5 01:07 /sbin/init -> /lib/systemd/systemd
|
||||
|
||||
|
||||
在Linux上有了容器的概念之后,一旦容器建立了自己的Pid Namespace(进程命名空间),这个Namespace里的进程号也是从1开始标记的。所以,容器的init进程也被称为1号进程。
|
||||
|
||||
怎么样,1号进程是不是不难理解?关于这个知识点,你只需要记住: 1号进程是第一个用户态的进程,由它直接或者间接创建了Namespace中的其他进程。
|
||||
|
||||
如何理解Linux信号?
|
||||
|
||||
刚才我给你讲了什么是1号进程,要想解决“为什么我在容器中不能kill 1号进程”这个问题,我们还得看看kill命令起到的作用。
|
||||
|
||||
我们运行kill命令,其实在Linux里就是发送一个信号,那么信号到底是什么呢?这就涉及到Linux信号的概念了。
|
||||
|
||||
其实信号这个概念在很早期的Unix系统上就有了。它一般会从1开始编号,通常来说,信号编号是1到31,这个编号在所有的Unix系统上都是一样的。
|
||||
|
||||
在Linux上我们可以用 kill -l 来看这些信号的编号和名字,具体的编号和名字我给你列在了下面,你可以看一看。
|
||||
|
||||
$ kill -l
|
||||
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
|
||||
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
|
||||
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
|
||||
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
|
||||
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
|
||||
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
|
||||
31) SIGSYS
|
||||
|
||||
|
||||
用一句话来概括,信号(Signal)其实就是Linux进程收到的一个通知。这些通知产生的源头有很多种,通知的类型也有很多种。
|
||||
|
||||
比如下面这几个典型的场景,你可以看一下:
|
||||
|
||||
|
||||
如果我们按下键盘“Ctrl+C”,当前运行的进程就会收到一个信号SIGINT而退出;
|
||||
如果我们的代码写得有问题,导致内存访问出错了,当前的进程就会收到另一个信号SIGSEGV;
|
||||
我们也可以通过命令kill ,直接向一个进程发送一个信号,缺省情况下不指定信号的类型,那么这个信号就是SIGTERM。也可以指定信号类型,比如命令 “kill -9 ”, 这里的9,就是编号为9的信号,SIGKILL信号。
|
||||
|
||||
|
||||
在这一讲中,我们主要用到 SIGTERM(15)和SIGKILL(9)这两个信号,所以这里你主要了解这两个信号就可以了,其他信号以后用到时再做介绍。
|
||||
|
||||
进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。
|
||||
|
||||
第一个选择是忽略(Ignore),就是对这个信号不做任何处理,但是有两个信号例外,对于SIGKILL和SIGSTOP这个两个信号,进程是不能忽略的。这是因为它们的主要作用是为Linux kernel和超级用户提供删除任意进程的特权。
|
||||
|
||||
第二个选择,就是捕获(Catch),这个是指让用户进程可以注册自己针对这个信号的handler。具体怎么做我们目前暂时涉及不到,你先知道就行,我们在后面课程会进行详细介绍。
|
||||
|
||||
对于捕获,SIGKILL和SIGSTOP这两个信号也同样例外,这两个信号不能有用户自己的处理代码,只能执行系统的缺省行为。
|
||||
|
||||
还有一个选择是缺省行为(Default),Linux为每个信号都定义了一个缺省的行为,你可以在Linux系统中运行 man 7 signal来查看每个信号的缺省行为。
|
||||
|
||||
对于大部分的信号而言,应用程序不需要注册自己的handler,使用系统缺省定义行为就可以了。
|
||||
|
||||
|
||||
|
||||
我刚才说了,SIGTERM(15)和SIGKILL(9)这两个信号是我们重点掌握的。现在我们已经讲解了信号的概念和处理方式,我就拿这两个信号为例,再带你具体分析一下。
|
||||
|
||||
首先我们来看SIGTERM(15),这个信号是Linux命令kill缺省发出的。前面例子里的命令 kill 1 ,就是通过kill向1号进程发送一个信号,在没有别的参数时,这个信号类型就默认为SIGTERM。
|
||||
|
||||
SIGTERM这个信号是可以被捕获的,这里的“捕获”指的就是用户进程可以为这个信号注册自己的handler,而这个handler,我们后面会看到,它可以处理进程的graceful-shutdown问题。
|
||||
|
||||
我们再来了解一下SIGKILL (9),这个信号是Linux里两个特权信号之一。什么是特权信号呢?
|
||||
|
||||
前面我们已经提到过了,特权信号就是Linux为kernel和超级用户去删除任意进程所保留的,不能被忽略也不能被捕获。那么进程一旦收到SIGKILL,就要退出。
|
||||
|
||||
在前面的例子里,我们运行的命令 kill -9 1 里的参数“-9”,其实就是指发送编号为9的这个SIGKILL信号给1号进程。
|
||||
|
||||
现象解释
|
||||
|
||||
现在,你应该理解init进程和Linux信号这两个概念了,让我们回到开头的问题上来:“为什么我在容器中不能kill 1号进程,甚至SIGKILL信号也不行?”
|
||||
|
||||
你还记得么,在课程的最开始,我们已经尝试过用bash作为容器1号进程,这样是无法把1号进程杀掉的。那么我们再一起来看一看,用别的编程语言写的1号进程是否也杀不掉。
|
||||
|
||||
我们现在用C程序作为init进程,尝试一下杀掉1号进程。和bash init进程一样,无论SIGTERM信号还是SIGKILL信号,在容器里都不能杀死这个1号进程。
|
||||
|
||||
# cat c-init-nosig.c
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
printf("Process is sleeping\n");
|
||||
while (1) {
|
||||
sleep(100);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
# docker stop sig-proc;docker rm sig-proc
|
||||
# docker run --name sig-proc -d registry/sig-proc:v1 /c-init-nosig
|
||||
# docker exec -it sig-proc bash
|
||||
[root@5d3d42a031b1 /]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 07:48 ? 00:00:00 /c-init-nosig
|
||||
root 6 0 5 07:48 pts/0 00:00:00 bash
|
||||
root 19 6 0 07:48 pts/0 00:00:00 ps -ef
|
||||
[root@5d3d42a031b1 /]# kill 1
|
||||
[root@5d3d42a031b1 /]# kill -9 1
|
||||
[root@5d3d42a031b1 /]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 07:48 ? 00:00:00 /c-init-nosig
|
||||
root 6 0 0 07:48 pts/0 00:00:00 bash
|
||||
root 20 6 0 07:49 pts/0 00:00:00 ps -ef
|
||||
|
||||
|
||||
我们是不是这样就可以得出结论——“容器里的1号进程,完全忽略了SIGTERM和SIGKILL信号了”呢?你先别着急,我们再拿其他语言试试。
|
||||
|
||||
接下来,我们用 Golang程序作为1号进程,我们再在容器中执行 kill -9 1 和 kill 1 。
|
||||
|
||||
这次,我们发现 kill -9 1 这个命令仍然不能杀死1号进程,也就是说,SIGKILL信号和之前的两个测试一样不起作用。
|
||||
|
||||
但是,我们执行 kill 1 以后,SIGTERM这个信号把init进程给杀了,容器退出了。
|
||||
|
||||
# cat go-init.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Start app\n")
|
||||
time.Sleep(time.Duration(100000) * time.Millisecond)
|
||||
}
|
||||
# docker stop sig-proc;docker rm sig-proc
|
||||
# docker run --name sig-proc -d registry/sig-proc:v1 /go-init
|
||||
# docker exec -it sig-proc bash
|
||||
|
||||
|
||||
[root@234a23aa597b /]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 1 08:04 ? 00:00:00 /go-init
|
||||
root 10 0 9 08:04 pts/0 00:00:00 bash
|
||||
root 23 10 0 08:04 pts/0 00:00:00 ps -ef
|
||||
[root@234a23aa597b /]# kill -9 1
|
||||
[root@234a23aa597b /]# kill 1
|
||||
[root@234a23aa597b /]# [~]# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
|
||||
|
||||
对于这个测试结果,你是不是反而觉得更加困惑了?
|
||||
|
||||
为什么使用不同程序,结果就不一样呢?接下来我们就看看kill命令下达之后,Linux里究竟发生了什么事,我给你系统地梳理一下整个过程。
|
||||
|
||||
在我们运行 kill 1 这个命令的时候,希望把SIGTERM这个信号发送给1号进程,就像下面图里的带箭头虚线。
|
||||
|
||||
在Linux实现里,kill命令调用了 kill()的这个系统调用(所谓系统调用就是内核的调用接口)而进入到了内核函数sys_kill(), 也就是下图里的实线箭头。
|
||||
|
||||
而内核在决定把信号发送给1号进程的时候,会调用sig_task_ignored()这个函数来做个判断,这个判断有什么用呢?
|
||||
|
||||
它会决定内核在哪些情况下会把发送的这个信号给忽略掉。如果信号被忽略了,那么init进程就不能收到指令了。
|
||||
|
||||
所以,我们想要知道init进程为什么收到或者收不到信号,都要去看看 sig_task_ignored()的这个内核函数的实现。
|
||||
|
||||
|
||||
|
||||
在sig_task_ignored()这个函数中有三个if{}判断,第一个和第三个if{}判断和我们的问题没有关系,并且代码有注释,我们就不讨论了。
|
||||
|
||||
我们重点来看第二个if{}。我来给你分析一下,在容器中执行 kill 1 或者 kill -9 1 的时候,这第二个if{}里的三个子条件是否可以被满足呢?
|
||||
|
||||
我们来看下面这串代码,这里表示一旦这三个子条件都被满足,那么这个信号就不会发送给进程。
|
||||
|
||||
kernel/signal.c
|
||||
static bool sig_task_ignored(struct task_struct *t, int sig, bool force)
|
||||
{
|
||||
void __user *handler;
|
||||
handler = sig_handler(t, sig);
|
||||
|
||||
/* SIGKILL and SIGSTOP may not be sent to the global init */
|
||||
if (unlikely(is_global_init(t) && sig_kernel_only(sig)))
|
||||
|
||||
return true;
|
||||
|
||||
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
|
||||
handler == SIG_DFL && !(force && sig_kernel_only(sig)))
|
||||
return true;
|
||||
|
||||
/* Only allow kernel generated signals to this kthread */
|
||||
if (unlikely((t->flags & PF_KTHREAD) &&
|
||||
(handler == SIG_KTHREAD_KERNEL) && !force))
|
||||
return true;
|
||||
|
||||
return sig_handler_ignored(handler, sig);
|
||||
}
|
||||
|
||||
|
||||
接下来,我们就逐一分析一下这三个子条件,我们来说说这个”!(force && sig_kernel_only(sig))” 。
|
||||
|
||||
第一个条件里force的值,对于同一个Namespace里发出的信号来说,调用值是0,所以这个条件总是满足的。
|
||||
|
||||
我们再来看一下第二个条件 “handler == SIG_DFL”,第二个条件判断信号的handler是否是SIG_DFL。
|
||||
|
||||
那么什么是SIG_DFL呢?对于每个信号,用户进程如果不注册一个自己的handler,就会有一个系统缺省的handler,这个缺省的handler就叫作SIG_DFL。
|
||||
|
||||
对于SIGKILL,我们前面介绍过它是特权信号,是不允许被捕获的,所以它的handler就一直是SIG_DFL。这第二个条件对SIGKILL来说总是满足的。
|
||||
|
||||
对于SIGTERM,它是可以被捕获的。也就是说如果用户不注册handler,那么这个条件对SIGTERM也是满足的。
|
||||
|
||||
最后再来看一下第三个条件,”t->signal->flags & SIGNAL_UNKILLABLE”,这里的条件判断是这样的,进程必须是SIGNAL_UNKILLABLE的。
|
||||
|
||||
这个SIGNAL_UNKILLABLE flag是在哪里置位的呢?
|
||||
|
||||
可以参考我们下面的这段代码,在每个Namespace的init进程建立的时候,就会打上 SIGNAL_UNKILLABLE 这个标签,也就是说只要是1号进程,就会有这个flag,这个条件也是满足的。
|
||||
|
||||
kernel/fork.c
|
||||
if (is_child_reaper(pid)) {
|
||||
ns_of_pid(pid)->child_reaper = p;
|
||||
p->signal->flags |= SIGNAL_UNKILLABLE;
|
||||
}
|
||||
|
||||
/*
|
||||
* is_child_reaper returns true if the pid is the init process
|
||||
* of the current namespace. As this one could be checked before
|
||||
* pid_ns->child_reaper is assigned in copy_process, we check
|
||||
* with the pid number.
|
||||
*/
|
||||
|
||||
static inline bool is_child_reaper(struct pid *pid)
|
||||
{
|
||||
return pid->numbers[pid->level].nr == 1;
|
||||
}
|
||||
|
||||
|
||||
我们可以看出来,其实最关键的一点就是 handler == SIG_DFL 。Linux内核针对每个Nnamespace里的init进程,把只有default handler的信号都给忽略了。
|
||||
|
||||
如果我们自己注册了信号的handler(应用程序注册信号handler被称作”Catch the Signal”),那么这个信号handler就不再是SIG_DFL 。即使是init进程在接收到SIGTERM之后也是可以退出的。
|
||||
|
||||
不过,由于SIGKILL是一个特例,因为SIGKILL是不允许被注册用户handler的(还有一个不允许注册用户handler的信号是SIGSTOP),那么它只有SIG_DFL handler。
|
||||
|
||||
所以init进程是永远不能被SIGKILL所杀,但是可以被SIGTERM杀死。
|
||||
|
||||
说到这里,我们该怎么证实这一点呢?我们可以做下面两件事来验证。
|
||||
|
||||
第一件事,你可以查看1号进程状态中SigCgt Bitmap。
|
||||
|
||||
我们可以看到,在Golang程序里,很多信号都注册了自己的handler,当然也包括了SIGTERM(15),也就是bit 15。
|
||||
|
||||
而C程序里,缺省状态下,一个信号handler都没有注册;bash程序里注册了两个handler,bit 2和bit 17,也就是SIGINT和SIGCHLD,但是没有注册SIGTERM。
|
||||
|
||||
所以,C程序和bash程序里SIGTERM的handler是SIG_DFL(系统缺省行为),那么它们就不能被SIGTERM所杀。
|
||||
|
||||
具体我们可以看一下这段/proc系统的进程状态:
|
||||
|
||||
### golang init
|
||||
# cat /proc/1/status | grep -i SigCgt
|
||||
SigCgt: fffffffe7fc1feff
|
||||
|
||||
### C init
|
||||
# cat /proc/1/status | grep -i SigCgt
|
||||
SigCgt: 0000000000000000
|
||||
|
||||
### bash init
|
||||
# cat /proc/1/status | grep -i SigCgt
|
||||
SigCgt: 0000000000010002
|
||||
|
||||
|
||||
第二件事,给C程序注册一下SIGTERM handler,捕获SIGTERM。
|
||||
|
||||
我们调用signal()系统调用注册SIGTERM的handler,在handler里主动退出,再看看容器中 kill 1 的结果。
|
||||
|
||||
这次我们就可以看到,在进程状态的SigCgt bitmap里,bit 15 (SIGTERM)已经置位了。同时,运行 kill 1 也可以把这个C程序的init进程给杀死了。
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGTERM) {
|
||||
printf("received SIGTERM\n");
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
signal(SIGTERM, sig_handler);
|
||||
|
||||
printf("Process is sleeping\n");
|
||||
while (1) {
|
||||
sleep(100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
# docker stop sig-proc;docker rm sig-proc
|
||||
# docker run --name sig-proc -d registry/sig-proc:v1 /c-init-sig
|
||||
# docker exec -it sig-proc bash
|
||||
[root@043f4f717cb5 /]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 09:05 ? 00:00:00 /c-init-sig
|
||||
root 6 0 18 09:06 pts/0 00:00:00 bash
|
||||
root 19 6 0 09:06 pts/0 00:00:00 ps -ef
|
||||
|
||||
[root@043f4f717cb5 /]# cat /proc/1/status | grep SigCgt
|
||||
SigCgt: 0000000000004000
|
||||
[root@043f4f717cb5 /]# kill 1
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
|
||||
|
||||
好了,到这里我们可以确定这两点:
|
||||
|
||||
|
||||
kill -9 1 在容器中是不工作的,内核阻止了1号进程对SIGKILL特权信号的响应。
|
||||
kill 1 分两种情况,如果1号进程没有注册SIGTERM的handler,那么对SIGTERM信号也不响应,如果注册了handler,那么就可以响应SIGTERM信号。
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
好了,今天的内容讲完了。我们来总结一下。
|
||||
|
||||
这一讲我们主要讲了init进程。围绕这个知识点,我提出了一个真实发生的问题:“为什么我在容器中不能kill 1号进程?”。
|
||||
|
||||
想要解决这个问题,我们需要掌握两个基本概念。
|
||||
|
||||
第一个概念是Linux 1号进程。它是第一个用户态的进程。它直接或者间接创建了Namespace中的其他进程。
|
||||
|
||||
第二个概念是Linux信号。Linux有31个基本信号,进程在处理大部分信号时有三个选择:忽略、捕获和缺省行为。其中两个特权信号SIGKILL和SIGSTOP不能被忽略或者捕获。
|
||||
|
||||
只知道基本概念还不行,我们还要去解决问题。我带你尝试了用bash, C语言还有Golang程序作为容器init进程,发现它们对 kill 1的反应是不同的。
|
||||
|
||||
因为信号的最终处理都是在Linux内核中进行的,因此,我们需要对Linux内核代码进行分析。
|
||||
|
||||
容器里1号进程对信号处理的两个要点,这也是这一讲里我想让你记住的两句话:
|
||||
|
||||
|
||||
在容器中,1号进程永远不会响应SIGKILL和SIGSTOP这两个特权信号;
|
||||
对于其他的信号,如果用户自己注册了handler,1号进程可以响应。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
这一讲的最开始,有这样一个C语言的init进程,它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM,会发生什么情况呢?
|
||||
|
||||
欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑,欢迎你把这篇文章分享给他,说不定就帮他解决了一个难题。
|
||||
|
||||
|
||||
|
||||
|
311
专栏/容器实战高手课/03理解进程(2):为什么我的容器里有这么多僵尸进程?.md
Normal file
311
专栏/容器实战高手课/03理解进程(2):为什么我的容器里有这么多僵尸进程?.md
Normal file
@ -0,0 +1,311 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 理解进程(2):为什么我的容器里有这么多僵尸进程?
|
||||
你好,我是程远。今天我们来聊一聊容器里僵尸进程这个问题。
|
||||
|
||||
说起僵尸进程,相信你并不陌生。很多面试官经常会问到这个知识点,用来考察候选人的操作系统背景。通过这个问题,可以了解候选人对Linux进程管理和信号处理这些基础知识的理解程度,他的基本功扎不扎实。
|
||||
|
||||
所以,今天我们就一起来看看容器里为什么会产生僵尸进程,然后去分析如何怎么解决。
|
||||
|
||||
通过这一讲,你就会对僵尸进程的产生原理有一个清晰的认识,也会更深入地理解容器init进程的特性。
|
||||
|
||||
问题再现
|
||||
|
||||
我们平时用容器的时候,有的同学会发现,自己的容器运行久了之后,运行ps命令会看到一些进程,进程名后面加了标识。那么你自然会有这样的疑问,这些是什么进程呢?
|
||||
|
||||
你可以自己做个容器镜像来模拟一下,我们先下载这个例子,运行 make image 之后,再启动容器。
|
||||
|
||||
在容器里我们可以看到,1号进程fork出1000个子进程。当这些子进程运行结束后,它们的进程名字后面都加了标识。
|
||||
|
||||
从它们的Z stat(进程状态)中我们可以知道,这些都是僵尸进程(Zombie Process)。运行top命令,我们也可以看到输出的内容显示有 1000 zombie 进程。
|
||||
|
||||
# docker run --name zombie-proc -d registry/zombie-proc:v1
|
||||
02dec161a9e8b18922bd3599b922dbd087a2ad60c9b34afccde7c91a463bde8a
|
||||
# docker exec -it zombie-proc bash
|
||||
# ps aux
|
||||
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
|
||||
root 1 0.0 0.0 4324 1436 ? Ss 01:23 0:00 /app-test 1000
|
||||
root 6 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 7 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 8 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 9 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 10 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
|
||||
…
|
||||
|
||||
root 999 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1000 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1001 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1002 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1003 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1004 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1005 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1023 0.0 0.0 12020 3392 pts/0 Ss 01:39 0:00 bash
|
||||
|
||||
# top
|
||||
top - 02:18:57 up 31 days, 15:17, 0 users, load average: 0.00, 0.01, 0.00
|
||||
Tasks: 1003 total, 1 running, 2 sleeping, 0 stopped, 1000 zombie
|
||||
…
|
||||
|
||||
|
||||
那么问题来了,什么是僵尸进程?它们是怎么产生的?僵尸进程太多会导致什么问题?想要回答这些问题,我们就要从进程状态的源头学习,看看僵尸进程到底处于进程整个生命周期里的哪一环。
|
||||
|
||||
知识详解
|
||||
|
||||
Linux的进程状态
|
||||
|
||||
无论进程还是线程,在Linux内核里其实都是用 task_struct{}这个结构来表示的。它其实就是任务(task),也就是Linux里基本的调度单位。为了方便讲解,我们在这里暂且称它为进程。
|
||||
|
||||
那一个进程从创建(fork)到退出(exit),这个过程中的状态转化还是很简单的。
|
||||
|
||||
下面这个图是 《Linux Kernel Development》这本书里的Linux进程状态转化图。
|
||||
|
||||
我们从这张图中可以看出来,在进程“活着”的时候就只有两个状态:运行态(TASK_RUNNING)和睡眠态(TASK_INTERRUPTIBLE,TASK_UNINTERRUPTIBLE)。
|
||||
|
||||
|
||||
|
||||
那运行态和睡眠态这两种状态分别是什么意思呢?
|
||||
|
||||
运行态的意思是,无论进程是正在运行中(也就是获得了CPU资源),还是进程在run queue队列里随时可以运行,都处于这个状态。
|
||||
|
||||
我们想要查看进程是不是处于运行态,其实也很简单,比如使用ps命令,可以看到处于这个状态的进程显示的是R stat。
|
||||
|
||||
睡眠态是指,进程需要等待某个资源而进入的状态,要等待的资源可以是一个信号量(Semaphore), 或者是磁盘I/O,这个状态的进程会被放入到wait queue队列里。
|
||||
|
||||
这个睡眠态具体还包括两个子状态:一个是可以被打断的(TASK_INTERRUPTIBLE),我们用ps查看到的进程,显示为S stat。还有一个是不可被打断的(TASK_UNINTERRUPTIBLE),用ps查看进程,就显示为D stat。
|
||||
|
||||
这两个子状态,我们在后面的课程里碰到新的问题时,会再做详细介绍,这里你只要知道这些就行了。
|
||||
|
||||
除了上面进程在活的时候的两个状态,进程在调用do_exit()退出的时候,还有两个状态。
|
||||
|
||||
一个是 EXIT_DEAD,也就是进程在真正结束退出的那一瞬间的状态;第二个是 EXIT_ZOMBIE状态,这是进程在EXIT_DEAD前的一个状态,而我们今天讨论的僵尸进程,也就是处于这个状态中。
|
||||
|
||||
限制容器中进程数目
|
||||
|
||||
理解了Linux进程状态之后,我们还需要知道,在Linux系统中怎么限制进程数目。因为弄清楚这个问题,我们才能更深入地去理解僵尸进程的危害。
|
||||
|
||||
一台Linux机器上的进程总数目是有限制的。如果超过这个最大值,那么系统就无法创建出新的进程了,比如你想SSH登录到这台机器上就不行了。
|
||||
|
||||
这个最大值可以我们在 /proc/sys/kernel/pid_max这个参数中看到。
|
||||
|
||||
Linux内核在初始化系统的时候,会根据机器CPU的数目来设置pid_max的值。
|
||||
|
||||
比如说,如果机器中CPU数目小于等于32,那么pid_max就会被设置为32768(32K);如果机器中的CPU数目大于32,那么pid_max就被设置为 N*1024 (N就是CPU数目)。
|
||||
|
||||
对于Linux系统而言,容器就是一组进程的集合。如果容器中的应用创建过多的进程或者出现bug,就会产生类似fork bomb的行为。
|
||||
|
||||
这个fork bomb就是指在计算机中,通过不断建立新进程来消耗系统中的进程资源,它是一种黑客攻击方式。这样,容器中的进程数就会把整个节点的可用进程总数给消耗完。
|
||||
|
||||
这样,不但会使同一个节点上的其他容器无法工作,还会让宿主机本身也无法工作。所以对于每个容器来说,我们都需要限制它的最大进程数目,而这个功能由pids Cgroup这个子系统来完成。
|
||||
|
||||
而这个功能的实现方法是这样的:pids Cgroup通过Cgroup文件系统的方式向用户提供操作接口,一般它的Cgroup文件系统挂载点在 /sys/fs/cgroup/pids。
|
||||
|
||||
在一个容器建立之后,创建容器的服务会在/sys/fs/cgroup/pids下建立一个子目录,就是一个控制组,控制组里最关键的一个文件就是pids.max。我们可以向这个文件写入数值,而这个值就是这个容器中允许的最大进程数目。
|
||||
|
||||
我们对这个值做好限制,容器就不会因为创建出过多进程而影响到其他容器和宿主机了。思路讲完了,接下来我们就实际上手试一试。
|
||||
|
||||
下面是对一个Docker容器的pids Cgroup的操作,你可以跟着操作一下。
|
||||
|
||||
# pwd
|
||||
/sys/fs/cgroup/pids
|
||||
# df ./
|
||||
Filesystem 1K-blocks Used Available Use% Mounted on
|
||||
cgroup 0 0 0 - /sys/fs/cgroup/pids
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
7ecd3aa7fdc1 registry/zombie-proc:v1 "/app-test 1000" 37 hours ago Up 37 hours frosty_yalow
|
||||
|
||||
# pwd
|
||||
/sys/fs/cgroup/pids/system.slice/docker-7ecd3aa7fdc15a1e183813b1899d5d939beafb11833ad6c8b0432536e5b9871c.scope
|
||||
|
||||
# ls
|
||||
cgroup.clone_children cgroup.procs notify_on_release pids.current pids.events pids.max tasks
|
||||
# echo 1002 > pids.max
|
||||
# cat pids.max
|
||||
1002
|
||||
|
||||
|
||||
解决问题
|
||||
|
||||
刚才我给你解释了两个基本概念,进程状态和进程数目限制,那我们现在就可以解决容器中的僵尸进程问题了。
|
||||
|
||||
在前面Linux进程状态的介绍里,我们知道了,僵尸进程是Linux进程退出状态的一种。
|
||||
|
||||
从内核进程的do_exit()函数我们也可以看到,这时候进程task_struct里的mm/shm/sem/files等文件资源都已经释放了,只留下了一个stask_struct instance空壳。
|
||||
|
||||
就像下面这段代码显示的一样,从进程对应的/proc/ 文件目录下,我们也可以看出来,对应的资源都已经没有了。
|
||||
|
||||
# cat /proc/6/cmdline
|
||||
# cat /proc/6/smaps
|
||||
# cat /proc/6/maps
|
||||
# ls /proc/6/fd
|
||||
|
||||
|
||||
并且,这个进程也已经不响应任何的信号了,无论SIGTERM(15)还是SIGKILL(9)。例如上面pid 6的僵尸进程,这两个信号都已经被响应了。
|
||||
|
||||
# kill -15 6
|
||||
# kill -9 6
|
||||
# ps -ef | grep 6
|
||||
root 6 1 0 13:59 ? 00:00:00 [app-test] <defunct>
|
||||
|
||||
|
||||
当多个容器运行在同一个宿主机上的时候,为了避免一个容器消耗完我们整个宿主机进程号资源,我们会配置pids Cgroup来限制每个容器的最大进程数目。也就是说,进程数目在每个容器中也是有限的,是一种很宝贵的资源。
|
||||
|
||||
既然进程号资源在宿主机上是有限的,显然残留的僵尸进程多了以后,给系统带来最大问题就是它占用了进程号。这就意味着,残留的僵尸进程,在容器里仍然占据着进程号资源,很有可能会导致新的进程不能运转。
|
||||
|
||||
这里我再次借用开头的那个例子,也就是一个产生了1000个僵尸进程的容器,带你理解一下这个例子中进程数的上限。我们可以看一下,1个init进程+1000个僵尸进程+1个bash进程 ,总共就是1002个进程。
|
||||
|
||||
如果pids Cgroup也限制了这个容器的最大进程号的数量,限制为1002的话,我们在pids Cgroup里可以看到,pids.current == pids.max,也就是已经达到了容器进程号数的上限。
|
||||
|
||||
这时候,如果我们在容器里想再启动一个进程,例如运行一下ls命令,就会看到 Resource temporarily unavailable 的错误消息。已经退出的无用进程,却阻碍了有用进程的启动,显然这样是不合理的。
|
||||
|
||||
具体代码如下:
|
||||
|
||||
### On host
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
09e6e8e16346 registry/zombie-proc:v1 "/app-test 1000" 29 minutes ago Up 29 minutes peaceful_ritchie
|
||||
|
||||
# pwd
|
||||
/sys/fs/cgroup/pids/system.slice/docker-09e6e8e1634612580a03dd3496d2efed2cf2a510b9688160b414ce1d1ea3e4ae.scope
|
||||
|
||||
# cat pids.max
|
||||
1002
|
||||
# cat pids.current
|
||||
1002
|
||||
|
||||
### On Container
|
||||
[root@09e6e8e16346 /]# ls
|
||||
bash: fork: retry: Resource temporarily unavailable
|
||||
bash: fork: retry: Resource temporarily unavailable
|
||||
|
||||
|
||||
所以,接下来我们还要看看这些僵尸进程到底是怎么产生的。因为只有理解它的产生机制,我们才能想明白怎么避免僵尸进程的出现。
|
||||
|
||||
我们先看一下刚才模拟僵尸进程的那段小程序。这段程序里,父进程在创建完子进程之后就不管了,这就是造成子进程变成僵尸进程的原因。
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int i;
|
||||
int total;
|
||||
|
||||
if (argc < 2) {
|
||||
total = 1;
|
||||
} else {
|
||||
total = atoi(argv[1]);
|
||||
}
|
||||
|
||||
printf("To create %d processes\n", total);
|
||||
|
||||
for (i = 0; i < total; i++) {
|
||||
pid_t pid = fork();
|
||||
|
||||
if (pid == 0) {
|
||||
printf("Child => PPID: %d PID: %d\n", getppid(),
|
||||
getpid());
|
||||
sleep(60);
|
||||
printf("Child process exits\n");
|
||||
exit(EXIT_SUCCESS);
|
||||
} else if (pid > 0) {
|
||||
printf("Parent created child %d\n", i);
|
||||
} else {
|
||||
printf("Unable to create child process. %d\n", i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
printf("Paraent is sleeping\n");
|
||||
while (1) {
|
||||
sleep(100);
|
||||
}
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
前面我们通过分析,发现子进程变成僵尸进程的原因在于父进程“不负责”,那找到原因后,我们再想想,如何来解决。
|
||||
|
||||
其实解决思路很好理解,就好像熊孩子犯了事儿,你要去找他家长来管教,那子进程在容器里“赖着不走”,我们就需要让父进程出面处理了。
|
||||
|
||||
所以,在Linux中的进程退出之后,如果进入僵尸状态,我们就需要父进程调用wait()这个系统调用,去回收僵尸进程的最后的那些系统资源,比如进程号资源。
|
||||
|
||||
那么,我们在刚才那段代码里,主进程进入sleep(100)之前,加上一段wait()函数调用,就不会出现僵尸进程的残留了。
|
||||
|
||||
for (i = 0; i < total; i++) {
|
||||
int status;
|
||||
wait(&status);
|
||||
}
|
||||
|
||||
|
||||
而容器中所有进程的最终父进程,就是我们所说的init进程,由它负责生成容器中的所有其他进程。因此,容器的init进程有责任回收容器中的所有僵尸进程。
|
||||
|
||||
前面我们知道了wait()系统调用可以回收僵尸进程,但是wait()系统调用有一个问题,需要你注意。
|
||||
|
||||
wait()系统调用是一个阻塞的调用,也就是说,如果没有子进程是僵尸进程的话,这个调用就一直不会返回,那么整个进程就会被阻塞住,而不能去做别的事了。
|
||||
|
||||
不过这也没有关系,我们还有另一个方法处理。Linux还提供了一个类似的系统调用waitpid(),这个调用的参数更多。
|
||||
|
||||
其中就有一个参数WNOHANG,它的含义就是,如果在调用的时候没有僵尸进程,那么函数就马上返回了,而不会像wait()调用那样一直等待在那里。
|
||||
|
||||
比如社区的一个容器init项目tini。在这个例子中,它的主进程里,就是不断在调用带WNOHANG参数的waitpid(),通过这个方式清理容器中所有的僵尸进程。
|
||||
|
||||
int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) {
|
||||
pid_t current_pid;
|
||||
int current_status;
|
||||
|
||||
while (1) {
|
||||
current_pid = waitpid(-1, ¤t_status, WNOHANG);
|
||||
|
||||
switch (current_pid) {
|
||||
case -1:
|
||||
if (errno == ECHILD) {
|
||||
PRINT_TRACE("No child to wait");
|
||||
break;
|
||||
}
|
||||
|
||||
…
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
今天我们讨论的问题是容器中的僵尸进程。
|
||||
|
||||
首先,我们先用代码来模拟了这个情况,还原了在一个容器中大量的僵尸进程是如何产生的。为了理解它的产生原理和危害,我们先要掌握两个知识点:
|
||||
|
||||
|
||||
Linux进程状态中,僵尸进程处于EXIT_ZOMBIE这个状态;
|
||||
容器需要对最大进程数做限制。具体方法是这样的,我们可以向Cgroup中 pids.max这个文件写入数值(这个值就是这个容器中允许的最大进程数目)。
|
||||
|
||||
|
||||
掌握了基本概念之后,我们找到了僵尸进程的产生原因。父进程在创建完子进程之后就不管了。
|
||||
|
||||
所以,我们需要父进程调用wait()或者waitpid()系统调用来避免僵尸进程产生。
|
||||
|
||||
关于本节内容,你只要记住下面三个主要的知识点就可以了:
|
||||
|
||||
|
||||
每一个Linux进程在退出的时候都会进入一个僵尸状态(EXIT_ZOMBIE);
|
||||
僵尸进程如果不清理,就会消耗系统中的进程数资源,最坏的情况是导致新的进程无法启动;
|
||||
僵尸进程一定需要父进程调用wait()或者waitpid()系统调用来清理,这也是容器中init进程必须具备的一个功能。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
如果容器的init进程创建了子进程B,B又创建了自己的子进程C。如果C运行完之后,退出成了僵尸进程,B进程还在运行,而容器的init进程还在不断地调用waitpid(),那C这个僵尸进程可以被回收吗?
|
||||
|
||||
欢迎留言和我分享你的想法。如果你的朋友也被僵尸进程占用资源而困扰,欢迎你把这篇文章分享给他,也许就能帮他解决一个问题。
|
||||
|
||||
|
||||
|
||||
|
397
专栏/容器实战高手课/04理解进程(3):为什么我在容器中的进程被强制杀死了?.md
Normal file
397
专栏/容器实战高手课/04理解进程(3):为什么我在容器中的进程被强制杀死了?.md
Normal file
@ -0,0 +1,397 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 理解进程(3):为什么我在容器中的进程被强制杀死了?
|
||||
你好,我是程远。
|
||||
|
||||
今天我们来讲容器中init进程的最后一讲,为什么容器中的进程被强制杀死了。理解了这个问题,能够帮助你更好地管理进程,让容器中的进程可以graceful shutdown。
|
||||
|
||||
我先给你说说,为什么进程管理中做到这点很重要。在实际生产环境中,我们有不少应用在退出的时候需要做一些清理工作,比如清理一些远端的链接,或者是清除一些本地的临时数据。
|
||||
|
||||
这样的清理工作,可以尽可能避免远端或者本地的错误发生,比如减少丢包等问题的出现。而这些退出清理的工作,通常是在SIGTERM这个信号用户注册的handler里进行的。
|
||||
|
||||
但是,如果我们的进程收到了SIGKILL,那应用程序就没机会执行这些清理工作了。这就意味着,一旦进程不能graceful shutdown,就会增加应用的出错率。
|
||||
|
||||
所以接下来,我们来重现一下,进程在容器退出时都发生了什么。
|
||||
|
||||
场景再现
|
||||
|
||||
在容器平台上,你想要停止一个容器,无论是在Kubernetes中去删除一个pod,或者用Docker停止一个容器,最后都会用到Containerd这个服务。
|
||||
|
||||
而Containerd在停止容器的时候,就会向容器的init进程发送一个SIGTERM信号。
|
||||
|
||||
我们会发现,在init进程退出之后,容器内的其他进程也都立刻退出了。不过不同的是,init进程收到的是SIGTERM信号,而其他进程收到的是SIGKILL信号。
|
||||
|
||||
在理解进程的[第一讲]中,我们提到过SIGKILL信号是不能被捕获的(catch)的,也就是用户不能注册自己的handler,而SIGTERM信号却允许用户注册自己的handler,这样的话差别就很大了。
|
||||
|
||||
那么,我们就一起来看看当容器退出的时候,如何才能让容器中的进程都收到SIGTERM信号,而不是SIGKILL信号。
|
||||
|
||||
延续前面课程中处理问题的思路,我们同样可以运行一个简单的容器,来重现这个问题,用这里的代码执行一下 make image ,然后用Docker启动这个容器镜像。
|
||||
|
||||
docker run -d --name fwd_sig registry/fwd_sig:v1 /c-init-sig
|
||||
|
||||
|
||||
你会发现,在我们用 docker stop 停止这个容器的时候,如果用strace工具来监控,就能看到容器里的init进程和另外一个进程收到的信号情况。
|
||||
|
||||
在下面的例子里,进程号为15909的就是容器里的init进程,而进程号为15959的是容器里另外一个进程。
|
||||
|
||||
在命令输出中我们可以看到,init进程(15909)收到的是SIGTERM信号,而另外一个进程(15959)收到的果然是SIGKILL信号。
|
||||
|
||||
# ps -ef | grep c-init-sig
|
||||
root 15857 14391 0 06:23 pts/0 00:00:00 docker run -it registry/fwd_sig:v1 /c-init-sig
|
||||
root 15909 15879 0 06:23 pts/0 00:00:00 /c-init-sig
|
||||
root 15959 15909 0 06:23 pts/0 00:00:00 /c-init-sig
|
||||
root 16046 14607 0 06:23 pts/3 00:00:00 grep --color=auto c-init-sig
|
||||
|
||||
# strace -p 15909
|
||||
strace: Process 15909 attached
|
||||
restart_syscall(<... resuming interrupted read ...>) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
|
||||
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---
|
||||
write(1, "received SIGTERM\n", 17) = 17
|
||||
exit_group(0) = ?
|
||||
+++ exited with 0 +++
|
||||
|
||||
# strace -p 15959
|
||||
strace: Process 15959 attached
|
||||
restart_syscall(<... resuming interrupted read ...>) = ?
|
||||
+++ killed by SIGKILL +++
|
||||
|
||||
|
||||
知识详解:信号的两个系统调用
|
||||
|
||||
我们想要理解刚才的例子,就需要搞懂信号背后的两个系统调用,它们分别是kill()系统调用和signal()系统调用。
|
||||
|
||||
这里呢,我们可以结合前面讲过的信号来理解这两个系统调用。在容器init进程的第一讲里,我们介绍过信号的基本概念了,信号就是Linux进程收到的一个通知。
|
||||
|
||||
等你学完如何使用这两个系统调用之后,就会更清楚Linux信号是怎么一回事,遇到容器里信号相关的问题,你就能更好地理清思路了。
|
||||
|
||||
我还会再给你举个使用函数的例子,帮助你进一步理解进程是如何实现graceful shutdown的。
|
||||
|
||||
进程对信号的处理其实就包括两个问题,一个是进程如何发送信号,另一个是进程收到信号后如何处理。
|
||||
|
||||
我们在Linux中发送信号的系统调用是kill(),之前很多例子里面我们用的命令 kill ,它内部的实现就是调用了kill()这个函数。
|
||||
|
||||
下面是Linux Programmer’s Manual里对kill()函数的定义。
|
||||
|
||||
这个函数有两个参数,一个是 sig,代表需要发送哪个信号,比如sig的值是15的话,就是指发送SIGTERM;另一个参数是 pid,也就是指信号需要发送给哪个进程,比如值是1的话,就是指发送给进程号是1的进程。
|
||||
|
||||
NAME
|
||||
kill - send signal to a process
|
||||
|
||||
SYNOPSIS
|
||||
#include <sys/types.h>
|
||||
#include <signal.h>
|
||||
|
||||
int kill(pid_t pid, int sig);
|
||||
|
||||
|
||||
我们知道了发送信号的系统调用之后,再来看另一个系统调用,也就是signal()系统调用这个函数,它可以给信号注册handler。
|
||||
|
||||
下面是signal()在Linux Programmer’s Manual里的定义,参数 signum 也就是信号的编号,例如数值15,就是信号SIGTERM;参数 handler 是一个函数指针参数,用来注册用户的信号handler。
|
||||
|
||||
NAME
|
||||
signal - ANSI C signal handling
|
||||
|
||||
SYNOPSIS
|
||||
#include <signal.h>
|
||||
typedef void (*sighandler_t)(int);
|
||||
sighandler_t signal(int signum, sighandler_t handler);
|
||||
|
||||
|
||||
在容器init进程的第一讲里,我们学过进程对每种信号的处理,包括三个选择:调用系统缺省行为、捕获、忽略。而这里的选择,其实就是程序中如何去调用signal()这个系统调用。
|
||||
|
||||
第一个选择就是缺省,如果我们在代码中对某个信号,比如SIGTERM信号,不做任何signal()相关的系统调用,那么在进程运行的时候,如果接收到信号SIGTERM,进程就会执行内核中SIGTERM信号的缺省代码。
|
||||
|
||||
对于SIGTERM这个信号来说,它的缺省行为就是进程退出(terminate)。
|
||||
|
||||
内核中对不同的信号有不同的缺省行为,一般会采用退出(terminate),暂停(stop),忽略(ignore)这三种行为中的一种。
|
||||
|
||||
那第二个选择捕获又是什么意思呢?
|
||||
|
||||
捕获指的就是我们在代码中为某个信号,调用signal()注册自己的handler。这样进程在运行的时候,一旦接收到信号,就不会再去执行内核中的缺省代码,而是会执行通过signal()注册的handler。
|
||||
|
||||
比如下面这段代码,我们为SIGTERM这个信号注册了一个handler,在handler里只是做了一个打印操作。
|
||||
|
||||
那么这个程序在运行的时候,如果收到SIGTERM信号,它就不会退出了,而是只在屏幕上显示出”received SIGTERM”。
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGTERM) {
|
||||
printf("received SIGTERM\n");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
|
||||
{
|
||||
...
|
||||
signal(SIGTERM, sig_handler);
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
我们再来看看第三个选择,如果要让进程“忽略”一个信号,我们就要通过signal()这个系统调用,为这个信号注册一个特殊的handler,也就是 SIG_IGN 。
|
||||
|
||||
比如下面的这段代码,就是为SIGTERM这个信号注册SIG_IGN。
|
||||
|
||||
这样操作的效果,就是在程序运行的时候,如果收到SIGTERM信号,程序既不会退出,也不会在屏幕上输出log,而是什么反应也没有,就像完全没有收到这个信号一样。
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
...
|
||||
signal(SIGTERM, SIG_IGN);
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
好了,我们通过讲解signal()这个系统调用,帮助你回顾了信号处理的三个选择:缺省行为、捕获和忽略。
|
||||
|
||||
这里我还想要提醒你一点, SIGKILL和SIGSTOP信号是两个特权信号,它们不可以被捕获和忽略,这个特点也反映在signal()调用上。
|
||||
|
||||
我们可以运行下面的这段代码,如果我们用signal()为SIGKILL注册handler,那么它就会返回SIG_ERR,不允许我们做捕获操作。
|
||||
|
||||
# cat reg_sigkill.c
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
typedef void (*sighandler_t)(int);
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGKILL) {
|
||||
printf("received SIGKILL\n");
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
sighandler_t h_ret;
|
||||
|
||||
h_ret = signal(SIGKILL, sig_handler);
|
||||
if (h_ret == SIG_ERR) {
|
||||
perror("SIG_ERR");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
# ./reg_sigkill
|
||||
SIG_ERR: Invalid argument
|
||||
|
||||
|
||||
最后,我用下面这段代码来做个小结。
|
||||
|
||||
这段代码里,我们用signal()对SIGTERM这个信号做了忽略,捕获以及恢复它的缺省行为,并且每一次都用kill()系统调用向进程自己发送SIGTERM信号,这样做可以确认进程对SIGTERM信号的选择。
|
||||
|
||||
#include <stdio.h>
|
||||
#include <signal.h>
|
||||
|
||||
typedef void (*sighandler_t)(int);
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGTERM) {
|
||||
printf("received SIGTERM\n\n");
|
||||
// Set SIGTERM handler to default
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
//Ignore SIGTERM, and send SIGTERM
|
||||
// to process itself.
|
||||
|
||||
signal(SIGTERM, SIG_IGN);
|
||||
printf("Ignore SIGTERM\n\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
//Catch SIGERM, and send SIGTERM
|
||||
// to process itself.
|
||||
signal(SIGTERM, sig_handler);
|
||||
printf("Catch SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
|
||||
//Default SIGTERM. In sig_handler, it sets
|
||||
//SIGTERM handler back to default one.
|
||||
printf("Default SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
我们一起来总结一下刚才讲的两个系统调用:
|
||||
|
||||
先说说kill()这个系统调用,它其实很简单,输入两个参数:进程号和信号,就把特定的信号发送给指定的进程了。
|
||||
|
||||
再说说signal()这个调用,它决定了进程收到特定的信号如何来处理,SIG_DFL参数把对应信号恢复为缺省handler,也可以用自定义的函数作为handler,或者用SIG_IGN参数让进程忽略信号。
|
||||
|
||||
对于SIGKILL信号,如果调用signal()函数,为它注册自定义的handler,系统就会拒绝。
|
||||
|
||||
解决问题
|
||||
|
||||
我们在学习了kill()和signal()这个两个信号相关的系统调用之后,再回到这一讲最初的问题上,为什么在停止一个容器的时候,容器init进程收到的SIGTERM信号,而容器中其他进程却会收到SIGKILL信号呢?
|
||||
|
||||
当Linux进程收到SIGTERM信号并且使进程退出,这时Linux内核对处理进程退出的入口点就是do_exit()函数,do_exit()函数中会释放进程的相关资源,比如内存,文件句柄,信号量等等。
|
||||
|
||||
Linux内核对处理进程退出的入口点就是do_exit()函数,do_exit()函数中会释放进程的相关资源,比如内存,文件句柄,信号量等等。
|
||||
|
||||
在做完这些工作之后,它会调用一个exit_notify()函数,用来通知和这个进程相关的父子进程等。
|
||||
|
||||
对于容器来说,还要考虑Pid Namespace里的其他进程。这里调用的就是 zap_pid_ns_processes()这个函数,而在这个函数中,如果是处于退出状态的init进程,它会向Namespace中的其他进程都发送一个SIGKILL信号。
|
||||
|
||||
整个流程如下图所示。
|
||||
|
||||
|
||||
|
||||
你还可以看一下,内核代码是这样的。
|
||||
|
||||
/*
|
||||
* The last thread in the cgroup-init thread group is terminating.
|
||||
* Find remaining pid_ts in the namespace, signal and wait for them
|
||||
* to exit.
|
||||
*
|
||||
* Note: This signals each threads in the namespace - even those that
|
||||
* belong to the same thread group, To avoid this, we would have
|
||||
* to walk the entire tasklist looking a processes in this
|
||||
* namespace, but that could be unnecessarily expensive if the
|
||||
* pid namespace has just a few processes. Or we need to
|
||||
* maintain a tasklist for each pid namespace.
|
||||
*
|
||||
*/
|
||||
|
||||
rcu_read_lock();
|
||||
read_lock(&tasklist_lock);
|
||||
nr = 2;
|
||||
idr_for_each_entry_continue(&pid_ns->idr, pid, nr) {
|
||||
task = pid_task(pid, PIDTYPE_PID);
|
||||
if (task && !__fatal_signal_pending(task))
|
||||
group_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_MAX);
|
||||
}
|
||||
|
||||
|
||||
说到这里,我们也就明白为什么容器init进程收到的SIGTERM信号,而容器中其他进程却会收到SIGKILL信号了。
|
||||
|
||||
前面我讲过,SIGKILL是个特权信号(特权信号是Linux为kernel和超级用户去删除任意进程所保留的,不能被忽略也不能被捕获)。
|
||||
|
||||
所以进程收到这个信号后,就立刻退出了,没有机会调用一些释放资源的handler之后,再做退出动作。
|
||||
|
||||
而SIGTERM是可以被捕获的,用户是可以注册自己的handler的。因此,容器中的程序在stop container的时候,我们更希望进程收到SIGTERM信号而不是SIGKILL信号。
|
||||
|
||||
那在容器被停止的时候,我们该怎么做,才能让容器中的进程收到SIGTERM信号呢?
|
||||
|
||||
你可能已经想到了,就是让容器init进程来转发SIGTERM信号。的确是这样,比如Docker Container里使用的tini作为init进程,tini的代码中就会调用sigtimedwait()这个函数来查看自己收到的信号,然后调用kill() 把信号发给子进程。
|
||||
|
||||
我给你举个具体的例子说明,从下面的这段代码中,我们可以看到除了SIGCHLD这个信号外,tini会把其他所有的信号都转发给它的子进程。
|
||||
|
||||
int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const child_pid) {
|
||||
|
||||
siginfo_t sig;
|
||||
|
||||
if (sigtimedwait(parent_sigset_ptr, &sig, &ts) == -1) {
|
||||
switch (errno) {
|
||||
…
|
||||
}
|
||||
} else {
|
||||
/* There is a signal to handle here */
|
||||
switch (sig.si_signo) {
|
||||
case SIGCHLD:
|
||||
/* Special-cased, as we don't forward SIGCHLD. Instead, we'll
|
||||
* fallthrough to reaping processes.
|
||||
*/
|
||||
PRINT_DEBUG("Received SIGCHLD");
|
||||
break;
|
||||
default:
|
||||
PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo));
|
||||
/* Forward anything else */
|
||||
if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) {
|
||||
if (errno == ESRCH) {
|
||||
PRINT_WARNING("Child was dead when forwarding signal");
|
||||
} else {
|
||||
PRINT_FATAL("Unexpected error when forwarding signal: '%s'", strerror(errno));
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
那么我们在这里明确一下,怎么解决停止容器的时候,容器内应用程序被强制杀死的问题呢?
|
||||
|
||||
解决的方法就是在容器的init进程中对收到的信号做个转发,发送到容器中的其他子进程,这样容器中的所有进程在停止时,都会收到SIGTERM,而不是SIGKILL信号了。
|
||||
|
||||
重点小结
|
||||
|
||||
这一讲我们要解决的问题是让容器中的进程,在容器停止的时候,有机会graceful shutdown,而不是收到SIGKILL信号而被强制杀死。
|
||||
|
||||
首先我们通过对kill()和signal()这个两个系统调用的学习,进一步理解了进程是怎样处理Linux信号的,重点是信号在接收处理的三个选择:忽略,捕获和缺省行为。
|
||||
|
||||
通过代码例子,我们知道SIGTERM是可以被忽略和捕获的,但是SIGKILL是不可以被忽略和捕获的。
|
||||
|
||||
了解这一点以后,我们就找到了问题的解决方向,也就是我们需要在停止容器时,让容器中的应用收到SIGTERM,而不是SIGKILL。
|
||||
|
||||
具体怎么操作呢?我们可以在容器的init进程中对收到的信号做个转发,发送到容器中的其他子进程。这样一来,容器中的所有进程在停止容器时,都会收到SIGTERM,而不是SIGKILL信号了。
|
||||
|
||||
我认为,解决init进程信号的这类问题其实并不难。
|
||||
|
||||
我们只需要先梳理一下和这个问题相关的几个知识点,再写个小程序,让它跑在容器里,稍微做几个试验。然后,我们再看一下内核和Docker的源代码,就可以很快得出结论了。
|
||||
|
||||
思考题
|
||||
|
||||
请你回顾一下基本概念中最后的这段代码,你可以想一想,在不做编译运行的情况下,它的输出是什么?
|
||||
|
||||
#include <stdio.h>
|
||||
#include <signal.h>
|
||||
|
||||
typedef void (*sighandler_t)(int);
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGTERM) {
|
||||
printf("received SIGTERM\n\n");
|
||||
// Set SIGTERM handler to default
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
//Ignore SIGTERM, and send SIGTERM
|
||||
// to process itself.
|
||||
|
||||
signal(SIGTERM, SIG_IGN);
|
||||
printf("Ignore SIGTERM\n\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
//Catch SIGERM, and send SIGTERM
|
||||
// to process itself.
|
||||
signal(SIGTERM, sig_handler);
|
||||
printf("Catch SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
|
||||
//Default SIGTERM. In sig_handler, it sets
|
||||
//SIGTERM handler back to default one.
|
||||
printf("Default SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎你分享给自己的朋友,共同学习和进步。
|
||||
|
||||
|
||||
|
||||
|
311
专栏/容器实战高手课/05容器CPU(1):怎么限制容器的CPU使用?.md
Normal file
311
专栏/容器实战高手课/05容器CPU(1):怎么限制容器的CPU使用?.md
Normal file
@ -0,0 +1,311 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 容器CPU(1):怎么限制容器的CPU使用?
|
||||
你好,我是程远。今天我们来聊一聊容器里僵尸进程这个问题。
|
||||
|
||||
说起僵尸进程,相信你并不陌生。很多面试官经常会问到这个知识点,用来考察候选人的操作系统背景。通过这个问题,可以了解候选人对Linux进程管理和信号处理这些基础知识的理解程度,他的基本功扎不扎实。
|
||||
|
||||
所以,今天我们就一起来看看容器里为什么会产生僵尸进程,然后去分析如何怎么解决。
|
||||
|
||||
通过这一讲,你就会对僵尸进程的产生原理有一个清晰的认识,也会更深入地理解容器init进程的特性。
|
||||
|
||||
问题再现
|
||||
|
||||
我们平时用容器的时候,有的同学会发现,自己的容器运行久了之后,运行ps命令会看到一些进程,进程名后面加了标识。那么你自然会有这样的疑问,这些是什么进程呢?
|
||||
|
||||
你可以自己做个容器镜像来模拟一下,我们先下载这个例子,运行 make image 之后,再启动容器。
|
||||
|
||||
在容器里我们可以看到,1号进程fork出1000个子进程。当这些子进程运行结束后,它们的进程名字后面都加了标识。
|
||||
|
||||
从它们的Z stat(进程状态)中我们可以知道,这些都是僵尸进程(Zombie Process)。运行top命令,我们也可以看到输出的内容显示有 1000 zombie 进程。
|
||||
|
||||
# docker run --name zombie-proc -d registry/zombie-proc:v1
|
||||
02dec161a9e8b18922bd3599b922dbd087a2ad60c9b34afccde7c91a463bde8a
|
||||
# docker exec -it zombie-proc bash
|
||||
# ps aux
|
||||
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
|
||||
root 1 0.0 0.0 4324 1436 ? Ss 01:23 0:00 /app-test 1000
|
||||
root 6 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 7 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 8 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 9 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 10 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
|
||||
…
|
||||
|
||||
root 999 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1000 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1001 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1002 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1003 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1004 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1005 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1023 0.0 0.0 12020 3392 pts/0 Ss 01:39 0:00 bash
|
||||
|
||||
# top
|
||||
top - 02:18:57 up 31 days, 15:17, 0 users, load average: 0.00, 0.01, 0.00
|
||||
Tasks: 1003 total, 1 running, 2 sleeping, 0 stopped, 1000 zombie
|
||||
…
|
||||
|
||||
|
||||
那么问题来了,什么是僵尸进程?它们是怎么产生的?僵尸进程太多会导致什么问题?想要回答这些问题,我们就要从进程状态的源头学习,看看僵尸进程到底处于进程整个生命周期里的哪一环。
|
||||
|
||||
知识详解
|
||||
|
||||
Linux的进程状态
|
||||
|
||||
无论进程还是线程,在Linux内核里其实都是用 task_struct{}这个结构来表示的。它其实就是任务(task),也就是Linux里基本的调度单位。为了方便讲解,我们在这里暂且称它为进程。
|
||||
|
||||
那一个进程从创建(fork)到退出(exit),这个过程中的状态转化还是很简单的。
|
||||
|
||||
下面这个图是 《Linux Kernel Development》这本书里的Linux进程状态转化图。
|
||||
|
||||
我们从这张图中可以看出来,在进程“活着”的时候就只有两个状态:运行态(TASK_RUNNING)和睡眠态(TASK_INTERRUPTIBLE,TASK_UNINTERRUPTIBLE)。
|
||||
|
||||
|
||||
|
||||
那运行态和睡眠态这两种状态分别是什么意思呢?
|
||||
|
||||
运行态的意思是,无论进程是正在运行中(也就是获得了CPU资源),还是进程在run queue队列里随时可以运行,都处于这个状态。
|
||||
|
||||
我们想要查看进程是不是处于运行态,其实也很简单,比如使用ps命令,可以看到处于这个状态的进程显示的是R stat。
|
||||
|
||||
睡眠态是指,进程需要等待某个资源而进入的状态,要等待的资源可以是一个信号量(Semaphore), 或者是磁盘I/O,这个状态的进程会被放入到wait queue队列里。
|
||||
|
||||
这个睡眠态具体还包括两个子状态:一个是可以被打断的(TASK_INTERRUPTIBLE),我们用ps查看到的进程,显示为S stat。还有一个是不可被打断的(TASK_UNINTERRUPTIBLE),用ps查看进程,就显示为D stat。
|
||||
|
||||
这两个子状态,我们在后面的课程里碰到新的问题时,会再做详细介绍,这里你只要知道这些就行了。
|
||||
|
||||
除了上面进程在活的时候的两个状态,进程在调用do_exit()退出的时候,还有两个状态。
|
||||
|
||||
一个是 EXIT_DEAD,也就是进程在真正结束退出的那一瞬间的状态;第二个是 EXIT_ZOMBIE状态,这是进程在EXIT_DEAD前的一个状态,而我们今天讨论的僵尸进程,也就是处于这个状态中。
|
||||
|
||||
限制容器中进程数目
|
||||
|
||||
理解了Linux进程状态之后,我们还需要知道,在Linux系统中怎么限制进程数目。因为弄清楚这个问题,我们才能更深入地去理解僵尸进程的危害。
|
||||
|
||||
一台Linux机器上的进程总数目是有限制的。如果超过这个最大值,那么系统就无法创建出新的进程了,比如你想SSH登录到这台机器上就不行了。
|
||||
|
||||
这个最大值可以我们在 /proc/sys/kernel/pid_max这个参数中看到。
|
||||
|
||||
Linux内核在初始化系统的时候,会根据机器CPU的数目来设置pid_max的值。
|
||||
|
||||
比如说,如果机器中CPU数目小于等于32,那么pid_max就会被设置为32768(32K);如果机器中的CPU数目大于32,那么pid_max就被设置为 N*1024 (N就是CPU数目)。
|
||||
|
||||
对于Linux系统而言,容器就是一组进程的集合。如果容器中的应用创建过多的进程或者出现bug,就会产生类似fork bomb的行为。
|
||||
|
||||
这个fork bomb就是指在计算机中,通过不断建立新进程来消耗系统中的进程资源,它是一种黑客攻击方式。这样,容器中的进程数就会把整个节点的可用进程总数给消耗完。
|
||||
|
||||
这样,不但会使同一个节点上的其他容器无法工作,还会让宿主机本身也无法工作。所以对于每个容器来说,我们都需要限制它的最大进程数目,而这个功能由pids Cgroup这个子系统来完成。
|
||||
|
||||
而这个功能的实现方法是这样的:pids Cgroup通过Cgroup文件系统的方式向用户提供操作接口,一般它的Cgroup文件系统挂载点在 /sys/fs/cgroup/pids。
|
||||
|
||||
在一个容器建立之后,创建容器的服务会在/sys/fs/cgroup/pids下建立一个子目录,就是一个控制组,控制组里最关键的一个文件就是pids.max。我们可以向这个文件写入数值,而这个值就是这个容器中允许的最大进程数目。
|
||||
|
||||
我们对这个值做好限制,容器就不会因为创建出过多进程而影响到其他容器和宿主机了。思路讲完了,接下来我们就实际上手试一试。
|
||||
|
||||
下面是对一个Docker容器的pids Cgroup的操作,你可以跟着操作一下。
|
||||
|
||||
# pwd
|
||||
/sys/fs/cgroup/pids
|
||||
# df ./
|
||||
Filesystem 1K-blocks Used Available Use% Mounted on
|
||||
cgroup 0 0 0 - /sys/fs/cgroup/pids
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
7ecd3aa7fdc1 registry/zombie-proc:v1 "/app-test 1000" 37 hours ago Up 37 hours frosty_yalow
|
||||
|
||||
# pwd
|
||||
/sys/fs/cgroup/pids/system.slice/docker-7ecd3aa7fdc15a1e183813b1899d5d939beafb11833ad6c8b0432536e5b9871c.scope
|
||||
|
||||
# ls
|
||||
cgroup.clone_children cgroup.procs notify_on_release pids.current pids.events pids.max tasks
|
||||
# echo 1002 > pids.max
|
||||
# cat pids.max
|
||||
1002
|
||||
|
||||
|
||||
解决问题
|
||||
|
||||
刚才我给你解释了两个基本概念,进程状态和进程数目限制,那我们现在就可以解决容器中的僵尸进程问题了。
|
||||
|
||||
在前面Linux进程状态的介绍里,我们知道了,僵尸进程是Linux进程退出状态的一种。
|
||||
|
||||
从内核进程的do_exit()函数我们也可以看到,这时候进程task_struct里的mm/shm/sem/files等文件资源都已经释放了,只留下了一个stask_struct instance空壳。
|
||||
|
||||
就像下面这段代码显示的一样,从进程对应的/proc/ 文件目录下,我们也可以看出来,对应的资源都已经没有了。
|
||||
|
||||
# cat /proc/6/cmdline
|
||||
# cat /proc/6/smaps
|
||||
# cat /proc/6/maps
|
||||
# ls /proc/6/fd
|
||||
|
||||
|
||||
并且,这个进程也已经不响应任何的信号了,无论SIGTERM(15)还是SIGKILL(9)。例如上面pid 6的僵尸进程,这两个信号都已经被响应了。
|
||||
|
||||
# kill -15 6
|
||||
# kill -9 6
|
||||
# ps -ef | grep 6
|
||||
root 6 1 0 13:59 ? 00:00:00 [app-test] <defunct>
|
||||
|
||||
|
||||
当多个容器运行在同一个宿主机上的时候,为了避免一个容器消耗完我们整个宿主机进程号资源,我们会配置pids Cgroup来限制每个容器的最大进程数目。也就是说,进程数目在每个容器中也是有限的,是一种很宝贵的资源。
|
||||
|
||||
既然进程号资源在宿主机上是有限的,显然残留的僵尸进程多了以后,给系统带来最大问题就是它占用了进程号。这就意味着,残留的僵尸进程,在容器里仍然占据着进程号资源,很有可能会导致新的进程不能运转。
|
||||
|
||||
这里我再次借用开头的那个例子,也就是一个产生了1000个僵尸进程的容器,带你理解一下这个例子中进程数的上限。我们可以看一下,1个init进程+1000个僵尸进程+1个bash进程 ,总共就是1002个进程。
|
||||
|
||||
如果pids Cgroup也限制了这个容器的最大进程号的数量,限制为1002的话,我们在pids Cgroup里可以看到,pids.current == pids.max,也就是已经达到了容器进程号数的上限。
|
||||
|
||||
这时候,如果我们在容器里想再启动一个进程,例如运行一下ls命令,就会看到 Resource temporarily unavailable 的错误消息。已经退出的无用进程,却阻碍了有用进程的启动,显然这样是不合理的。
|
||||
|
||||
具体代码如下:
|
||||
|
||||
### On host
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
09e6e8e16346 registry/zombie-proc:v1 "/app-test 1000" 29 minutes ago Up 29 minutes peaceful_ritchie
|
||||
|
||||
# pwd
|
||||
/sys/fs/cgroup/pids/system.slice/docker-09e6e8e1634612580a03dd3496d2efed2cf2a510b9688160b414ce1d1ea3e4ae.scope
|
||||
|
||||
# cat pids.max
|
||||
1002
|
||||
# cat pids.current
|
||||
1002
|
||||
|
||||
### On Container
|
||||
[root@09e6e8e16346 /]# ls
|
||||
bash: fork: retry: Resource temporarily unavailable
|
||||
bash: fork: retry: Resource temporarily unavailable
|
||||
|
||||
|
||||
所以,接下来我们还要看看这些僵尸进程到底是怎么产生的。因为只有理解它的产生机制,我们才能想明白怎么避免僵尸进程的出现。
|
||||
|
||||
我们先看一下刚才模拟僵尸进程的那段小程序。这段程序里,父进程在创建完子进程之后就不管了,这就是造成子进程变成僵尸进程的原因。
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int i;
|
||||
int total;
|
||||
|
||||
if (argc < 2) {
|
||||
total = 1;
|
||||
} else {
|
||||
total = atoi(argv[1]);
|
||||
}
|
||||
|
||||
printf("To create %d processes\n", total);
|
||||
|
||||
for (i = 0; i < total; i++) {
|
||||
pid_t pid = fork();
|
||||
|
||||
if (pid == 0) {
|
||||
printf("Child => PPID: %d PID: %d\n", getppid(),
|
||||
getpid());
|
||||
sleep(60);
|
||||
printf("Child process exits\n");
|
||||
exit(EXIT_SUCCESS);
|
||||
} else if (pid > 0) {
|
||||
printf("Parent created child %d\n", i);
|
||||
} else {
|
||||
printf("Unable to create child process. %d\n", i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
printf("Paraent is sleeping\n");
|
||||
while (1) {
|
||||
sleep(100);
|
||||
}
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
前面我们通过分析,发现子进程变成僵尸进程的原因在于父进程“不负责”,那找到原因后,我们再想想,如何来解决。
|
||||
|
||||
其实解决思路很好理解,就好像熊孩子犯了事儿,你要去找他家长来管教,那子进程在容器里“赖着不走”,我们就需要让父进程出面处理了。
|
||||
|
||||
所以,在Linux中的进程退出之后,如果进入僵尸状态,我们就需要父进程调用wait()这个系统调用,去回收僵尸进程的最后的那些系统资源,比如进程号资源。
|
||||
|
||||
那么,我们在刚才那段代码里,主进程进入sleep(100)之前,加上一段wait()函数调用,就不会出现僵尸进程的残留了。
|
||||
|
||||
for (i = 0; i < total; i++) {
|
||||
int status;
|
||||
wait(&status);
|
||||
}
|
||||
|
||||
|
||||
而容器中所有进程的最终父进程,就是我们所说的init进程,由它负责生成容器中的所有其他进程。因此,容器的init进程有责任回收容器中的所有僵尸进程。
|
||||
|
||||
前面我们知道了wait()系统调用可以回收僵尸进程,但是wait()系统调用有一个问题,需要你注意。
|
||||
|
||||
wait()系统调用是一个阻塞的调用,也就是说,如果没有子进程是僵尸进程的话,这个调用就一直不会返回,那么整个进程就会被阻塞住,而不能去做别的事了。
|
||||
|
||||
不过这也没有关系,我们还有另一个方法处理。Linux还提供了一个类似的系统调用waitpid(),这个调用的参数更多。
|
||||
|
||||
其中就有一个参数WNOHANG,它的含义就是,如果在调用的时候没有僵尸进程,那么函数就马上返回了,而不会像wait()调用那样一直等待在那里。
|
||||
|
||||
比如社区的一个容器init项目tini。在这个例子中,它的主进程里,就是不断在调用带WNOHANG参数的waitpid(),通过这个方式清理容器中所有的僵尸进程。
|
||||
|
||||
int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) {
|
||||
pid_t current_pid;
|
||||
int current_status;
|
||||
|
||||
while (1) {
|
||||
current_pid = waitpid(-1, ¤t_status, WNOHANG);
|
||||
|
||||
switch (current_pid) {
|
||||
case -1:
|
||||
if (errno == ECHILD) {
|
||||
PRINT_TRACE("No child to wait");
|
||||
break;
|
||||
}
|
||||
|
||||
…
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
今天我们讨论的问题是容器中的僵尸进程。
|
||||
|
||||
首先,我们先用代码来模拟了这个情况,还原了在一个容器中大量的僵尸进程是如何产生的。为了理解它的产生原理和危害,我们先要掌握两个知识点:
|
||||
|
||||
|
||||
Linux进程状态中,僵尸进程处于EXIT_ZOMBIE这个状态;
|
||||
容器需要对最大进程数做限制。具体方法是这样的,我们可以向Cgroup中 pids.max这个文件写入数值(这个值就是这个容器中允许的最大进程数目)。
|
||||
|
||||
|
||||
掌握了基本概念之后,我们找到了僵尸进程的产生原因。父进程在创建完子进程之后就不管了。
|
||||
|
||||
所以,我们需要父进程调用wait()或者waitpid()系统调用来避免僵尸进程产生。
|
||||
|
||||
关于本节内容,你只要记住下面三个主要的知识点就可以了:
|
||||
|
||||
|
||||
每一个Linux进程在退出的时候都会进入一个僵尸状态(EXIT_ZOMBIE);
|
||||
僵尸进程如果不清理,就会消耗系统中的进程数资源,最坏的情况是导致新的进程无法启动;
|
||||
僵尸进程一定需要父进程调用wait()或者waitpid()系统调用来清理,这也是容器中init进程必须具备的一个功能。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
如果容器的init进程创建了子进程B,B又创建了自己的子进程C。如果C运行完之后,退出成了僵尸进程,B进程还在运行,而容器的init进程还在不断地调用waitpid(),那C这个僵尸进程可以被回收吗?
|
||||
|
||||
欢迎留言和我分享你的想法。如果你的朋友也被僵尸进程占用资源而困扰,欢迎你把这篇文章分享给他,也许就能帮他解决一个问题。
|
||||
|
||||
|
||||
|
||||
|
184
专栏/容器实战高手课/06容器CPU(2):如何正确地拿到容器CPU的开销?.md
Normal file
184
专栏/容器实战高手课/06容器CPU(2):如何正确地拿到容器CPU的开销?.md
Normal file
@ -0,0 +1,184 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 容器CPU(2):如何正确地拿到容器CPU的开销?
|
||||
你好,我是程远。今天我们聊一聊,如何正确地拿到容器CPU的开销。
|
||||
|
||||
为啥要解决这个问题呢,还是来源于实际工作中的需要。
|
||||
|
||||
无论是容器的所有者还是容器平台的管理者,我们想要精准地对运行着众多容器的云平台做监控,快速排查例如应用的处理能力下降,节点负载过高等问题,就绕不开容器CPU开销。因为CPU开销的异常,往往是程序异常最明显的一个指标。
|
||||
|
||||
在一台物理机器或者虚拟机里,如果你想得到这个节点的CPU使用率,最常用的命令就是top了吧?top一下子就能看到整个节点当前的CPU使用情况。
|
||||
|
||||
那么在容器里,top命令也可以做到这点吗?想要知道答案,我们还是得实际动手试一试。
|
||||
|
||||
问题重现
|
||||
|
||||
实际上,你在使用容器的时候,如果运行top命令来查看当前容器总共使用了多少CPU,你肯定马上就会失望了。
|
||||
|
||||
这是因为我们在容器中运行top命令,虽然可以看到容器中每个进程的CPU使用率,但是top中”%Cpu(s)“那一行中显示的数值,并不是这个容器的CPU整体使用率,而是容器宿主机的CPU使用率。
|
||||
|
||||
就像下面的这个例子,我们在一个12个CPU的宿主机上,启动一个容器,然后在容器里运行top命令。
|
||||
|
||||
这时我们可以看到,容器里有两个进程threads-cpu,总共消耗了200%的CPU(2 CPU Usage),而”%Cpu(s)“那一行的”us cpu”是58.5%。对于12CPU的系统来说,12 * 58.5%=7.02,也就是说这里显示总共消耗了7个CPU,远远大于容器中2个CPU的消耗。
|
||||
|
||||
|
||||
|
||||
这个例子说明,top这个工具虽然在物理机或者虚拟机上看得到系统CPU开销,但是如果是放在容器环境下,运行top就无法得到容器中总的CPU使用率。那么,我们还有什么其他的办法吗?
|
||||
|
||||
进程CPU使用率和系统CPU使用率
|
||||
|
||||
通过问题重现,我们发现top工具主要显示了宿主机系统整体的CPU使用率,以及单个进程的CPU使用率。既然没有现成的工具可以得到容器CPU开销,那我们需要自己开发一个工具来解决问题了。
|
||||
|
||||
其实我们自己推导,也没有那么难。我认为,最有效的思路还是从原理上去理解问题。
|
||||
|
||||
所以,在解决怎样得到单个容器整体的CPU使用率这个问题之前,我们先来学习一下,在Linux中到底是如何计算单个进程的CPU使用率,还有整个系统的CPU使用率的。
|
||||
|
||||
进程CPU使用率
|
||||
|
||||
Linux中每个进程的CPU使用率,我们都可以用top命令查看。
|
||||
|
||||
对照我们前面的那张示意图,我们可以发现,每个进程在top命令输出中都有对应的一行,然后“%CPU”的那一列就是这个进程的实时CPU使用率了。
|
||||
|
||||
比如说,100%就表示这个进程在这个瞬时使用了1个CPU,200%就是使用了2个CPU。那么这个百分比的数值是怎么得到呢?
|
||||
|
||||
最直接的方法,就是从源头开始寻找答案。因为是top命令的输出,我们可以去看一下top命令的源代码。在代码中你会看到对于每个进程,top都会从proc文件系统中每个进程对应的stat文件中读取2个数值。我们先来看这个文件,再来解读文件中具体的两个数值。
|
||||
|
||||
这个stat文件就是 /proc/[pid]/stat , [pid] 就是替换成具体一个进程的PID值。比如PID值为1的进程,这个文件就是 /proc/1/stat ,那么这个 /proc/[pid]/stat 文件里有什么信息呢?
|
||||
|
||||
其实这个stat文件实时输出了进程的状态信息,比如进程的运行态(Running还是 Sleeping)、父进程PID、进程优先级、进程使用的内存等等总共50多项。
|
||||
|
||||
完整的stat文件内容和格式在proc文件系统的 Linux programmer’s manual 里定义了。在这里,我们只需要重点关注这两项数值,stat文件中的第14项utime和第15项stime。
|
||||
|
||||
|
||||
|
||||
那么这两项数值utime和stime是什么含义呢?utime是表示进程的用户态部分在Linux调度中获得CPU的ticks,stime是表示进程的内核态部分在Linux调度中获得CPU的ticks。
|
||||
|
||||
看到这个解释,你可能又冒出一个新问题,疑惑ticks是什么?这个ticks就是Linux操作系统中的一个时间单位,你可以理解成类似秒,毫秒的概念。
|
||||
|
||||
在Linux中有个自己的时钟,它会周期性地产生中断。每次中断都会触发Linux内核去做一次进程调度,而这一次中断就是一个tick。因为是周期性的中断,比如1秒钟100次中断,那么一个tick作为一个时间单位看的话,也就是1/100秒。
|
||||
|
||||
我给你举个例子说明,假如进程的utime是130ticks,就相当于130 * 1⁄100=1.3秒,也就是进程从启动开始在用户态总共运行了1.3秒钟。
|
||||
|
||||
这里需要你注意,utime和stime都是一个累计值,也就是说从进程启动开始,这两个值就是一直在累积增长的。
|
||||
|
||||
那么我们怎么计算,才能知道某一进程在用户态和内核态中,分别获得了多少CPU的ticks呢?
|
||||
|
||||
首先,我们可以假设这个瞬时是1秒钟,这1秒是T1时刻到T2时刻之间的,那么这样我们就能获得 T1 时刻的utime_1 和stime_1,同时获得T2时刻的utime_2 和 stime_2。
|
||||
|
||||
在这1秒的瞬时,进程用户态获得的CPU ticks就是 (utime_2 – utime_1), 进程内核态获得的CPU ticks就是 (stime_2 – stime_1)。
|
||||
|
||||
那么我们可以推导出,进程CPU总的开销就是用户态加上内核态,也就是在1秒瞬时进程总的CPU ticks等于 (utime_2 – utime_1) + (stime_2 – stime_1)。
|
||||
|
||||
好了,现在我们得到了进程以ticks为单位的CPU开销,接下来还要做个转化。我们怎样才能把这个值转化成我们熟悉的百分比值呢?其实也不难,我们还是可以去top的源代码里得到这个百分比的计算公式。
|
||||
|
||||
简单总结一下,这个公式是这样的:
|
||||
|
||||
进程的CPU使用率=((utime_2 – utime_1) + (stime_2 – stime_1)) * 100.0 / (HZ * et * 1 )
|
||||
|
||||
接下来,我再给你讲一下,这个公式里每一个部分的含义。
|
||||
|
||||
首先, ((utime_2 – utime_1) + (stime_2 – stime_1))是瞬时进程总的CPU ticks。这个我们已经在前面解释过了。
|
||||
|
||||
其次,我们来看100.0,这里乘以100.0的目的是产生百分比数值。
|
||||
|
||||
最后,我再讲一下 (HZ * et * 1)。这是被除数这里的三个参数,我给你详细解释一下。
|
||||
|
||||
第一个HZ是什么意思呢?前面我们介绍ticks里说了,ticks是按照固定频率发生的,在我们的Linux系统里1秒钟是100次,那么HZ就是1秒钟里ticks的次数,这里值是100。
|
||||
|
||||
第二个参数et是我们刚才说的那个“瞬时”的时间,也就是得到utime_1和utime_2这两个值的时间间隔。
|
||||
|
||||
第三个“1”, 就更容易理解了,就是1个CPU。那么这三个值相乘,你是不是也知道了它的意思呢?就是在这“瞬时”的时间(et)里,1个CPU所包含的ticks数目。
|
||||
|
||||
解释了这些参数,我们可以把这个公式简化一下,就是下面这样:
|
||||
|
||||
进程的CPU使用率=(进程的ticks/单个CPU总ticks)*100.0
|
||||
|
||||
知道了这个公式,就需要上手来验证一下这个方法对不对,怎么验证呢?我们可以启动一个消耗CPU的小程序,然后读取一下进程对应的/proc/[pid]/stat中的utime和stime,然后用这个方法来计算一下进程使用率这个百分比值,并且和top的输出对比一下,看看是否一致。
|
||||
|
||||
先启动一个消耗200%的小程序,它的PID是10021,CPU使用率是200%。
|
||||
|
||||
|
||||
然后,我们查看这个进程对应的stat文件/proc/10021/stat,间隔1秒钟输出第二次,因为stat文件内容很多,我们知道utime和stime第14和15项,所以我们这里只截取了前15项的输出。这里可以看到,utime_1 = 399,stime_1=0,utime_2=600,stime_2=0。
|
||||
|
||||
|
||||
|
||||
根据前面的公式,我们计算一下进程threads-cpu的CPU使用率。套用前面的公式,计算的过程是:
|
||||
|
||||
((600 – 399) + (0 – 0)) * 100.0 / (100 * 1 * 1) =201,也就是201%。你会发现这个值和我们运行top里的值是一样的。同时,我们也就验证了这个公式是没问题的。
|
||||
|
||||
系统CPU使用率
|
||||
|
||||
前面我们介绍了Linux中如何获取单个进程的CPU使用率,下面我们再来看看Linux里是怎么计算系统的整体CPU使用率的。
|
||||
|
||||
其实知道了如何计算单个进程的CPU使用率之后,要理解系统整体的CPU使用率计算方法就简单多了。
|
||||
|
||||
同样,我们要计算CPU使用率,首先需要拿到数据,数据源也同样可以从proc文件系统里得到,对于整个系统的CPU使用率,这个文件就是/proc/stat。
|
||||
|
||||
在/proc/stat 文件的 cpu 这行有10列数据,同样我们可以在proc文件系统的 Linux programmer’s manual 里,找到每一列数据的定义,而前8列数据正好对应top输出中”%Cpu(s)“那一行里的8项数据,也就是在上一讲中,我们介绍过的user/system/nice/idle/iowait/irq/softirq/steal 这8项。
|
||||
|
||||
|
||||
|
||||
而在/proc/stat里的每一项的数值,就是系统自启动开始的ticks。那么要计算出“瞬时”的CPU使用率,首先就要算出这个“瞬时”的ticks,比如1秒钟的“瞬时”,我们可以记录开始时刻T1的ticks, 然后再记录1秒钟后T2时刻的ticks,再把这两者相减,就可以得到这1秒钟的ticks了。
|
||||
|
||||
|
||||
|
||||
这里我们可以得到,在这1秒钟里每个CPU使用率的ticks:
|
||||
|
||||
|
||||
|
||||
我们想要计算每一种CPU使用率的百分比,其实也很简单。我们只需要把所有在这1秒里的ticks相加得到一个总值,然后拿某一项的ticks值,除以这个总值。比如说计算idle CPU的使用率就是:
|
||||
|
||||
1203 /( 0 + 0 + 0 + 1203 + 0 + 0 + 0 + 0)=100%
|
||||
|
||||
好了,我们现在来整体梳理一下,我们通过Linux里的工具,要怎样计算进程的CPU使用率和系统的CPU使用率。
|
||||
|
||||
对于单个进程的CPU使用率计算,我们需要读取对应进程的/proc/[pid]/stat文件,将进程瞬时用户态和内核态的ticks数相加,就能得到进程的总ticks。
|
||||
|
||||
然后我们运用公式“(进程的ticks / 单个CPU总ticks) * 100.0”计算出进程CPU使用率的百分比值。
|
||||
|
||||
对于系统的CPU使用率,需要读取/proc/stat文件,得到瞬时各项CPU使用率的ticks值,相加得到一个总值,单项值除以总值就是各项CPU的使用率。
|
||||
|
||||
解决问题
|
||||
|
||||
前面我们学习了在Linux中,top工具是怎样计算每个进程的CPU使用率,以及系统总的CPU使用率。现在我们再来看最初的问题:为什么在容器中运行top命令不能得到容器中总的CPU使用率?
|
||||
|
||||
这就比较好解释了,对于系统总的CPU使用率,需要读取/proc/stat文件,但是这个文件中的各项CPU ticks是反映整个节点的,并且这个/proc/stat文件也不包含在任意一个Namespace里。
|
||||
|
||||
那么,对于top命令来说,它只能显示整个节点中各项CPU的使用率,不能显示单个容器的各项CPU的使用率。既然top命令不行,我们还有没有办法得到整个容器的CPU使用率呢?
|
||||
|
||||
我们之前已经学习过了CPU Cgroup,每个容器都会有一个CPU Cgroup的控制组。在这个控制组目录下面有很多参数文件,有的参数可以决定这个控制组里最大的CPU可使用率外,除了它们之外,目录下面还有一个可读项cpuacct.stat。
|
||||
|
||||
这里包含了两个统计值,这两个值分别是这个控制组里所有进程的内核态ticks和用户态的ticks,那么我们就可以用前面讲过的公式,也就是计算进程CPU使用率的公式,去计算整个容器的CPU使用率:
|
||||
|
||||
CPU使用率=((utime_2 – utime_1) + (stime_2 – stime_1)) * 100.0 / (HZ * et * 1 )
|
||||
|
||||
我们还是以问题重现中的例子说明,也就是最开始启动容器里的那两个容器threads-cpu进程。
|
||||
|
||||
就像下图显示的这样,整个容器的CPU使用率的百分比就是 ( (174021 - 173820) + (4 – 4)) * 100.0 / (100 * 1 * 1) = 201, 也就是201%。所以,我们从每个容器的CPU Cgroup控制组里的cpuacct.stat的统计值中,可以比较快地得到整个容器的CPU使用率。
|
||||
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
Linux里获取CPU使用率的工具,比如top,都是通过读取proc文件系统下的stat文件来得到CPU使用了多少ticks。而这里的ticks,是Linux操作系统里的一个时间单位,可以理解成类似秒,毫秒的概念。
|
||||
|
||||
对于每个进程来说,它的stat文件是/proc/[pid]/stat,里面包含了进程用户态和内核态的ticks数目;对于整个节点,它的stat文件是 /proc/stat,里面包含了user/system/nice/idle/iowait等不同CPU开销类型的ticks。
|
||||
|
||||
由于/proc/stat文件是整个节点全局的状态文件,不属于任何一个Namespace,因此在容器中无法通过读取/proc/stat文件来获取单个容器的CPU使用率。
|
||||
|
||||
所以要得到单个容器的CPU使用率,我们可以从CPU Cgroup每个控制组里的统计文件cpuacct.stat中获取。单个容器CPU使用率=((utime_2 – utime_1) + (stime_2 – stime_1)) * 100.0 / (HZ * et * 1 )。
|
||||
|
||||
得到单个容器的CPU的使用率,那么当宿主机上负载变高的时候,就可以很快知道是哪个容器引起的问题。同时,用户在管理自己成百上千的容器的时候,也可以很快发现CPU使用率异常的容器,这样就能及早地介入去解决问题。
|
||||
|
||||
思考题
|
||||
|
||||
写一个小程序,在容器中执行,它可以显示当前容器中所有进程总的CPU使用率。
|
||||
|
||||
欢迎在留言区和我互动,一起探讨容器CPU的相关问题。如果这篇文章让你有所收获,也欢迎你分享给更多的朋友,一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
206
专栏/容器实战高手课/07LoadAverage:加了CPUCgroup限制,为什么我的容器还是很慢?.md
Normal file
206
专栏/容器实战高手课/07LoadAverage:加了CPUCgroup限制,为什么我的容器还是很慢?.md
Normal file
@ -0,0 +1,206 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 Load Average:加了CPU Cgroup限制,为什么我的容器还是很慢?
|
||||
你好,我是程远。今天我想聊一聊平均负载(Load Average)的话题。
|
||||
|
||||
在上一讲中,我们提到过CPU Cgroup可以限制进程的CPU资源使用,但是CPU Cgroup对容器的资源限制是存在盲点的。
|
||||
|
||||
什么盲点呢?就是无法通过CPU Cgroup来控制Load Average的平均负载。而没有这个限制,就会影响我们系统资源的合理调度,很可能导致我们的系统变得很慢。
|
||||
|
||||
那么今天这一讲,我们要来讲一下为什么加了CPU Cgroup的配置后,即使保证了容器的CPU资源,容器中的进程还是会运行得很慢?
|
||||
|
||||
问题再现
|
||||
|
||||
在Linux的系统维护中,我们需要经常查看CPU使用情况,再根据这个情况分析系统整体的运行状态。有时候你可能会发现,明明容器里所有进程的CPU使用率都很低,甚至整个宿主机的CPU使用率都很低,而机器的Load Average里的值却很高,容器里进程运行得也很慢。
|
||||
|
||||
这么说有些抽象,我们一起动手再现一下这个情况,这样你就能更好地理解这个问题了。
|
||||
|
||||
比如说下面的top输出,第三行可以显示当前的CPU使用情况,我们可以看到整个机器的CPU Usage几乎为0,因为”id”显示99.9%,这说明CPU是处于空闲状态的。
|
||||
|
||||
但是请你注意,这里1分钟的”load average”的值却高达9.09,这里的数值9几乎就意味着使用了9个CPU了,这样CPU Usage和Load Average的数值看上去就很矛盾了。
|
||||
|
||||
|
||||
|
||||
那问题来了,我们在看一个系统里CPU使用情况时,到底是看CPU Usage还是Load Average呢?
|
||||
|
||||
这里就涉及到今天要解决的两大问题:
|
||||
|
||||
|
||||
Load Average到底是什么,CPU Usage和Load Average有什么差别?
|
||||
如果Load Average值升高,应用的性能下降了,这背后的原因是什么呢?
|
||||
|
||||
|
||||
好了,这一讲我们就带着这两个问题,一起去揭开谜底。
|
||||
|
||||
什么是Load Average?
|
||||
|
||||
要回答前面的问题,很显然我们要搞明白这个Linux里的”load average”这个值是什么意思,又是怎样计算的。
|
||||
|
||||
Load Average这个概念,你可能在使用Linux的时候就已经注意到了,无论你是运行uptime, 还是top,都可以看到类似这个输出”load average:2.02, 1.83, 1.20”。那么这一串输出到底是什么意思呢?
|
||||
|
||||
最直接的办法当然是看手册了,如果我们用”Linux manual page”搜索uptime或者top,就会看到对这个”load average”和后面三个数字的解释是”the system load averages for the past 1, 5, and 15 minutes”。
|
||||
|
||||
这个解释就是说,后面的三个数值分别代表过去1分钟,5分钟,15分钟在这个节点上的Load Average,但是看了手册上的解释,我们还是不能理解什么是Load Average。
|
||||
|
||||
这个时候,你如果再去网上找资料,就会发现Load Average是一个很古老的概念了。上个世纪70年代,早期的Unix系统上就已经有了这个Load Average,IETF还有一个RFC546定义了Load Average,这里定义的Load Average是一种CPU资源需求的度量。
|
||||
|
||||
举个例子,对于一个单个CPU的系统,如果在1分钟的时间里,处理器上始终有一个进程在运行,同时操作系统的进程可运行队列中始终都有9个进程在等待获取CPU资源。那么对于这1分钟的时间来说,系统的”load average”就是1+9=10,这个定义对绝大部分的Unix系统都适用。
|
||||
|
||||
对于Linux来说,如果只考虑CPU的资源,Load Averag等于单位时间内正在运行的进程加上可运行队列的进程,这个定义也是成立的。通过这个定义和我自己的观察,我给你归纳了下面三点对Load Average的理解。
|
||||
|
||||
第一,不论计算机CPU是空闲还是满负载,Load Average都是Linux进程调度器中可运行队列(Running Queue)里的一段时间的平均进程数目。
|
||||
|
||||
第二,计算机上的CPU还有空闲的情况下,CPU Usage可以直接反映到”load average”上,什么是CPU还有空闲呢?具体来说就是可运行队列中的进程数目小于CPU个数,这种情况下,单位时间进程CPU Usage相加的平均值应该就是”load average”的值。
|
||||
|
||||
第三,计算机上的CPU满负载的情况下,计算机上的CPU已经是满负载了,同时还有更多的进程在排队需要CPU资源。这时”load average”就不能和CPU Usage等同了。
|
||||
|
||||
比如对于单个CPU的系统,CPU Usage最大只是有100%,也就1个CPU;而”load average”的值可以远远大于1,因为”load average”看的是操作系统中可运行队列中进程的个数。
|
||||
|
||||
这样的解释可能太抽象了,为了方便你理解,我们一起动手验证一下。
|
||||
|
||||
怎么验证呢?我们可以执行个程序来模拟一下,先准备好一个可以消耗任意CPU Usage的程序,在执行这个程序的时候,后面加个数字作为参数,
|
||||
|
||||
比如下面的设置,参数是2,就是说这个进程会创建出两个线程,并且每个线程都跑满100%的CPU,2个线程就是2 * 100% = 200%的CPU Usage,也就是消耗了整整两个CPU的资源。
|
||||
|
||||
# ./threads-cpu 2
|
||||
|
||||
|
||||
准备好了这个CPU Usage的模拟程序,我们就可以用它来查看CPU Usage和Load Average之间的关系了。
|
||||
|
||||
接下来我们一起跑两个例子,第一个例子是执行2个满负载的线程,第二个例子执行6个满负载的线程,同样都是在一台4个CPU的节点上。
|
||||
|
||||
先来看第一个例子,我们在一台4个CPU的计算机节点上运行刚才这个模拟程序,还是设置参数为2,也就是使用2个CPU Usage。在这个程序运行了几分钟之后,我们运行top来查看一下CPU Usage和Load Average。
|
||||
|
||||
我们可以看到两个threads-cpu各自都占了将近100%的CPU,两个就是200%,2个CPU,对于4个CPU的计算机来说,CPU Usage占了50%,空闲了一半,这个我们也可以从 idle (id):49.9%得到印证。
|
||||
|
||||
这时候,Load Average里第一项(也就是前1分钟的数值)为1.98,近似于2。这个值和我们一直运行的200%CPU Usage相对应,也验证了我们之前归纳的第二点——CPU Usage可以反映到Load Average上。
|
||||
|
||||
因为运行的时间不够,前5分钟,前15分钟的Load Average还没有到2,而且后面我们的例子程序一般都只会运行几分钟,所以这里我们只看前1分钟的Load Average值就行。
|
||||
|
||||
另外,Linux内核中不使用浮点计算,这导致Load Average里的1分钟,5分钟,15分钟的时间值并不精确,但这不影响我们查看Load Average的数值,所以先不用管这个时间的准确性。
|
||||
|
||||
|
||||
那我们再来跑第二个例子,同样在这个4个CPU的计算机节点上,如果我们执行CPU Usage模拟程序threads-cpu,设置参数为6,让这个进程建出6个线程,这样每个线程都会尽量去抢占CPU,但是计算机总共只有4个CPU,所以这6个线程的CPU Usage加起来只是400%。
|
||||
|
||||
显然这时候4个CPU都被占满了,我们可以看到整个节点的idle(id)也已经是0.0%了。
|
||||
|
||||
但这个时候,我们看看前1分钟的Load Average,数值不是4而是5.93接近6,我们正好模拟了6个高CPU需求的线程。这也告诉我们,Load Average表示的是一段时间里运行队列中需要被调度的进程/线程平均数目。
|
||||
|
||||
|
||||
|
||||
讲到这里,我们是不是就可以认定Load Average就代表一段时间里运行队列中需要被调度的进程或者线程平均数目了呢? 或许对其他的Unix系统来说,这个理解已经够了,但是对于Linux系统还不能这么认定。
|
||||
|
||||
为什么这么说呢?故事还要从Linux早期的历史说起,那时开发者Matthias有这么一个发现,比如把快速的磁盘换成了慢速的磁盘,运行同样的负载,系统的性能是下降的,但是Load Average却没有反映出来。
|
||||
|
||||
他发现这是因为Load Average只考虑运行态的进程数目,而没有考虑等待I/O的进程。所以,他认为Load Average如果只是考虑进程运行队列中需要被调度的进程或线程平均数目是不够的,因为对于处于I/O资源等待的进程都是处于TASK_UNINTERRUPTIBLE状态的。
|
||||
|
||||
那他是怎么处理这件事的呢?估计你也猜到了,他给内核加一个patch(补丁),把处于TASK_UNINTERRUPTIBLE状态的进程数目也计入了Load Average中。
|
||||
|
||||
在这里我们又提到了TASK_UNINTERRUPTIBLE状态的进程,在前面的章节中我们介绍过,我再给你强调一下,TASK_UNINTERRUPTIBLE是Linux进程状态的一种,是进程为等待某个系统资源而进入了睡眠的状态,并且这种睡眠的状态是不能被信号打断的。
|
||||
|
||||
下面就是1993年Matthias的kernel patch,你有兴趣的话,可以读一下。
|
||||
|
||||
From: Matthias Urlichs <[email protected]>
|
||||
Subject: Load average broken ?
|
||||
Date: Fri, 29 Oct 1993 11:37:23 +0200
|
||||
|
||||
The kernel only counts "runnable" processes when computing the load average.
|
||||
I don't like that; the problem is that processes which are swapping or
|
||||
waiting on "fast", i.e. noninterruptible, I/O, also consume resources.
|
||||
|
||||
It seems somewhat nonintuitive that the load average goes down when you
|
||||
replace your fast swap disk with a slow swap disk...
|
||||
|
||||
Anyway, the following patch seems to make the load average much more
|
||||
consistent WRT the subjective speed of the system. And, most important, the
|
||||
load is still zero when nobody is doing anything. ;-)
|
||||
|
||||
--- kernel/sched.c.orig Fri Oct 29 10:31:11 1993
|
||||
+++ kernel/sched.c Fri Oct 29 10:32:51 1993
|
||||
@@ -414,7 +414,9 @@
|
||||
unsigned long nr = 0;
|
||||
|
||||
for(p = &LAST_TASK; p > &FIRST_TASK; --p)
|
||||
- if (*p && (*p)->state == TASK_RUNNING)
|
||||
+ if (*p && ((*p)->state == TASK_RUNNING) ||
|
||||
+ (*p)->state == TASK_UNINTERRUPTIBLE) ||
|
||||
+ (*p)->state == TASK_SWAPPING))
|
||||
nr += FIXED_1;
|
||||
return nr;
|
||||
}
|
||||
|
||||
|
||||
那么对于Linux的Load Average来说,除了可运行队列中的进程数目,等待队列中的UNINTERRUPTIBLE进程数目也会增加Load Average。
|
||||
|
||||
为了验证这一点,我们可以模拟一下UNINTERRUPTIBLE的进程,来看看Load Average的变化。
|
||||
|
||||
这里我们做一个kernel module,通过一个/proc文件系统给用户程序提供一个读取的接口,只要用户进程读取了这个接口就会进入UNINTERRUPTIBLE。这样我们就可以模拟两个处于UNINTERRUPTIBLE状态的进程,然后查看一下Load Average有没有增加。
|
||||
|
||||
我们发现程序跑了几分钟之后,前1分钟的Load Average差不多从0增加到了2.16,节点上CPU Usage几乎为0,idle为99.8%。
|
||||
|
||||
可以看到,可运行队列(Running Queue)中的进程数目是0,只有休眠队列(Sleeping Queue)中有两个进程,并且这两个进程显示为D state进程,这个D state进程也就是我们模拟出来的TASK_UNINTERRUPTIBLE状态的进程。
|
||||
|
||||
这个例子证明了Linux将TASK_UNINTERRUPTIBLE状态的进程数目计入了Load Average中,所以即使CPU上不做任何的计算,Load Average仍然会升高。如果TASK_UNINTERRUPTIBLE状态的进程数目有几百几千个,那么Load Average的数值也可以达到几百几千。
|
||||
|
||||
|
||||
|
||||
好了,到这里我们就可以准确定义Linux系统里的Load Average了,其实也很简单,你只需要记住,平均负载统计了这两种情况的进程:
|
||||
|
||||
第一种是Linux进程调度器中可运行队列(Running Queue)一段时间(1分钟,5分钟,15分钟)的进程平均数。
|
||||
|
||||
第二种是Linux进程调度器中休眠队列(Sleeping Queue)里的一段时间的TASK_UNINTERRUPTIBLE状态下的进程平均数。
|
||||
|
||||
所以,最后的公式就是:Load Average=可运行队列进程平均数+休眠队列中不可打断的进程平均数
|
||||
|
||||
如果打个比方来说明Load Average的统计原理。你可以想象每个CPU就是一条道路,每个进程都是一辆车,怎么科学统计道路的平均负载呢?就是看单位时间通过的车辆,一条道上的车越多,那么这条道路的负载也就越高。
|
||||
|
||||
此外,Linux计算系统负载的时候,还额外做了个补丁把TASK_UNINTERRUPTIBLE状态的进程也考虑了,这个就像道路中要把红绿灯情况也考虑进去。一旦有了红灯,汽车就要停下来排队,那么即使道路很空,但是红灯多了,汽车也要排队等待,也开不快。
|
||||
|
||||
现象解释:为什么Load Average会升高?
|
||||
|
||||
解释了Load Average这个概念,我们再回到这一讲最开始的问题,为什么对容器已经用CPU Cgroup限制了它的CPU Usage,容器里的进程还是可以造成整个系统很高的Load Average。
|
||||
|
||||
我们理解了Load Average这个概念之后,就能区分出Load Averge和CPU使用率的区别了。那么这个看似矛盾的问题也就很好回答了,因为Linux下的Load Averge不仅仅计算了CPU Usage的部分,它还计算了系统中TASK_UNINTERRUPTIBLE状态的进程数目。
|
||||
|
||||
讲到这里为止,我们找到了第一个问题的答案,那么现在我们再看第二个问题:如果Load Average值升高,应用的性能已经下降了,真正的原因是什么?问题就出在TASK_UNINTERRUPTIBLE状态的进程上了。
|
||||
|
||||
怎么验证这个判断呢?这时候我们只要运行 ps aux | grep “ D ” ,就可以看到容器中有多少TASK_UNINTERRUPTIBLE状态(在ps命令中这个状态的进程标示为”D”状态)的进程,为了方便理解,后面我们简称为D状态进程。而正是这些D状态进程引起了Load Average的升高。
|
||||
|
||||
找到了Load Average升高的问题出在D状态进程了,我们想要真正解决问题,还有必要了解D状态进程产生的本质是什么?
|
||||
|
||||
在Linux内核中有数百处调用点,它们会把进程设置为D状态,主要集中在disk I/O 的访问和信号量(Semaphore)锁的访问上,因此D状态的进程在Linux里是很常见的。
|
||||
|
||||
无论是对disk I/O的访问还是对信号量的访问,都是对Linux系统里的资源的一种竞争。当进程处于D状态时,就说明进程还没获得资源,这会在应用程序的最终性能上体现出来,也就是说用户会发觉应用的性能下降了。
|
||||
|
||||
那么D状态进程导致了性能下降,我们肯定是想方设法去做调试的。但目前D状态进程引起的容器中进程性能下降问题,Cgroups还不能解决,这也就是为什么我们用Cgroups做了配置,即使保证了容器的CPU资源, 容器中的进程还是运行很慢的根本原因。
|
||||
|
||||
这里我们进一步做分析,为什么CPU Cgroups不能解决这个问题呢?就是因为Cgroups更多的是以进程为单位进行隔离,而D状态进程是内核中系统全局资源引入的,所以Cgroups影响不了它。
|
||||
|
||||
所以我们可以做的是,在生产环境中监控容器的宿主机节点里D状态的进程数量,然后对D状态进程数目异常的节点进行分析,比如磁盘硬件出现问题引起D状态进程数目增加,这时就需要更换硬盘。
|
||||
|
||||
重点总结
|
||||
|
||||
这一讲我们从CPU Usage和Load Average差异这个现象讲起,最主要的目的是讲清楚Linux下的Load Average这个概念。
|
||||
|
||||
在其他Unix操作系统里Load Average只考虑CPU部分,Load Average计算的是进程调度器中可运行队列(Running Queue)里的一段时间(1分钟,5分钟,15分钟)的平均进程数目,而Linux在这个基础上,又加上了进程调度器中休眠队列(Sleeping Queue)里的一段时间的TASK_UNINTERRUPTIBLE状态的平均进程数目。
|
||||
|
||||
这里你需要重点掌握Load Average的计算公式,如下图。
|
||||
|
||||
|
||||
|
||||
因为TASK_UNINTERRUPTIBLE状态的进程同样也会竞争系统资源,所以它会影响到应用程序的性能。我们可以在容器宿主机的节点对D状态进程做监控,定向分析解决。
|
||||
|
||||
最后,我还想强调一下,这一讲中提到的对D状态进程进行监控也很重要,因为这是通用系统性能的监控方法。
|
||||
|
||||
思考题
|
||||
|
||||
结合今天的学习,你可以自己动手感受一下Load Average是怎么产生的,请你创建一个容器,在容器中运行一个消耗100%CPU的进程,运行10分钟后,然后查看Load Average的值。
|
||||
|
||||
欢迎在留言区晒出你的经历和疑问。如果有收获,也欢迎你把这篇文章分享给你的朋友,一起学习和讨论。
|
||||
|
||||
|
||||
|
||||
|
183
专栏/容器实战高手课/08容器内存:我的容器为什么被杀了?.md
Normal file
183
专栏/容器实战高手课/08容器内存:我的容器为什么被杀了?.md
Normal file
@ -0,0 +1,183 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 容器内存:我的容器为什么被杀了?
|
||||
你好,我是程远。
|
||||
|
||||
从这一讲内容开始,我们进入容器内存这个模块。在使用容器的时候,一定会伴随着Memory Cgroup。而Memory Cgroup给Linux原本就复杂的内存管理带来了新的变化,下面我们就一起来学习这一块内容。
|
||||
|
||||
今天这一讲,我们来解决容器在系统中消失的问题。
|
||||
|
||||
不知道你在使用容器时,有没有过这样的经历?一个容器在系统中运行一段时间后,突然消失了,看看自己程序的log文件,也没发现什么错误,不像是自己程序Crash,但是容器就是消失了。
|
||||
|
||||
那么这是怎么回事呢?接下来我们就一起来“破案”。
|
||||
|
||||
问题再现
|
||||
|
||||
容器在系统中被杀掉,其实只有一种情况,那就是容器中的进程使用了太多的内存。具体来说,就是容器里所有进程使用的内存量,超过了容器所在Memory Cgroup里的内存限制。这时Linux系统就会主动杀死容器中的一个进程,往往这会导致整个容器的退出。
|
||||
|
||||
我们可以做个简单的容器,模拟一下这种容器被杀死的场景。做容器的Dockerfile和代码,你可以从这里获得。
|
||||
|
||||
接下来,我们用下面的这个脚本来启动容器,我们先把这个容器的Cgroup内存上限设置为512MB(536870912 bytes)。
|
||||
|
||||
#!/bin/bash
|
||||
docker stop mem_alloc;docker rm mem_alloc
|
||||
docker run -d --name mem_alloc registry/mem_alloc:v1
|
||||
|
||||
sleep 2
|
||||
CONTAINER_ID=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i mem_alloc | awk '{print $1}')
|
||||
echo $CONTAINER_ID
|
||||
|
||||
CGROUP_CONTAINER_PATH=$(find /sys/fs/cgroup/memory/ -name "*$CONTAINER_ID*")
|
||||
echo $CGROUP_CONTAINER_PATH
|
||||
|
||||
echo 536870912 > $CGROUP_CONTAINER_PATH/memory.limit_in_bytes
|
||||
cat $CGROUP_CONTAINER_PATH/memory.limit_in_bytes
|
||||
|
||||
|
||||
好了,容器启动后,里面有一个小程序mem_alloc会不断地申请内存。当它申请的内存超过512MB的时候,你就会发现,我们启动的这个容器消失了。
|
||||
|
||||
|
||||
|
||||
这时候,如果我们运行docker inspect 命令查看容器退出的原因,就会看到容器处于”exited”状态,并且”OOMKilled”是true。
|
||||
|
||||
|
||||
|
||||
那么问题来了,什么是OOM Killed呢?它和之前我们对容器Memory Cgroup做的设置有什么关系,又是怎么引起容器退出的?想搞清楚这些问题,我们就需要先理清楚基本概念。
|
||||
|
||||
如何理解OOM Killer?
|
||||
|
||||
我们先来看一看OOM Killer是什么意思。
|
||||
|
||||
OOM是Out of Memory的缩写,顾名思义就是内存不足的意思,而Killer在这里指需要杀死某个进程。那么OOM Killer就是在Linux系统里如果内存不足时,就需要杀死一个正在运行的进程来释放一些内存。
|
||||
|
||||
那么讲到这里,你可能会有个问题了,Linux里的程序都是调用malloc()来申请内存,如果内存不足,直接malloc()返回失败就可以,为什么还要去杀死正在运行的进程呢?
|
||||
|
||||
其实,这个和Linux进程的内存申请策略有关,Linux允许进程在申请内存的时候是overcommit的,这是什么意思呢?就是说允许进程申请超过实际物理内存上限的内存。
|
||||
|
||||
为了让你更好地理解,我给你举个例子说明。比如说,节点上的空闲物理内存只有512MB了,但是如果一个进程调用malloc()申请了600MB,那么malloc()的这次申请还是被允许的。
|
||||
|
||||
这是因为malloc()申请的是内存的虚拟地址,系统只是给了程序一个地址范围,由于没有写入数据,所以程序并没有得到真正的物理内存。物理内存只有程序真的往这个地址写入数据的时候,才会分配给程序。
|
||||
|
||||
可以看得出来,这种overcommit的内存申请模式可以带来一个好处,它可以有效提高系统的内存利用率。不过这也带来了一个问题,也许你已经猜到了,就是物理内存真的不够了,又该怎么办呢?
|
||||
|
||||
为了方便你理解,我给你打个比方,这个有点像航空公司在卖飞机票。售卖飞机票的时候往往是超售的。比如说实际上有100个位子,航空公司会卖105张机票,在登机的时候如果实际登机的乘客超过了100个,那么就需要按照一定规则,不允许多出的几位乘客登机了。
|
||||
|
||||
同样的道理,遇到内存不够的这种情况,Linux采取的措施就是杀死某个正在运行的进程。
|
||||
|
||||
那么你一定会问了,在发生OOM的时候,Linux到底是根据什么标准来选择被杀的进程呢?这就要提到一个在Linux内核里有一个 oom_badness()函数,就是它定义了选择进程的标准。其实这里的判断标准也很简单,函数中涉及两个条件:
|
||||
|
||||
第一,进程已经使用的物理内存页面数。
|
||||
|
||||
第二,每个进程的OOM校准值oom_score_adj。在/proc文件系统中,每个进程都有一个 /proc//oom_score_adj的接口文件。我们可以在这个文件中输入-1000 到1000之间的任意一个数值,调整进程被OOM Kill的几率。
|
||||
|
||||
adj = (long)p->signal->oom_score_adj;
|
||||
|
||||
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +mm_pgtables_bytes(p->mm) / PAGE_SIZE;
|
||||
|
||||
adj *= totalpages / 1000;
|
||||
points += adj;
|
||||
|
||||
|
||||
结合前面说的两个条件,函数oom_badness()里的最终计算方法是这样的:
|
||||
|
||||
用系统总的可用页面数,去乘以OOM校准值oom_score_adj,再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被OOM Kill的几率也就越大。
|
||||
|
||||
如何理解Memory Cgroup?
|
||||
|
||||
前面我们介绍了OOM Killer,容器发生OOM Kill大多是因为Memory Cgroup的限制所导致的,所以在我们还需要理解Memory Cgroup的运行机制。
|
||||
|
||||
在这个专栏的[第一讲]中,我们讲过Cgroups是容器的两大支柱技术之一,在CPU的章节中,我们也讲到了CPU Cgroups。那么按照同样的思路,我们想理解容器Memory,自然要讨论一下Memory Cgroup了。
|
||||
|
||||
Memory Cgroup也是Linux Cgroups子系统之一,它的作用是对一组进程的Memory使用做限制。Memory Cgroup的虚拟文件系统的挂载点一般在”/sys/fs/cgroup/memory”这个目录下,这个和CPU Cgroup类似。我们可以在Memory Cgroup的挂载点目录下,创建一个子目录作为控制组。
|
||||
|
||||
每一个控制组下面有不少参数,在这一讲里,这里我们只讲跟OOM最相关的3个参数:memory.limit_in_bytes,memory.oom_control和memory.usage_in_bytes。其他参数如果你有兴趣了解,可以参考内核的文档说明。
|
||||
|
||||
首先我们来看第一个参数,叫作memory.limit_in_bytes。请你注意,这个memory.limit_in_bytes是每个控制组里最重要的一个参数了。这是因为一个控制组里所有进程可使用内存的最大值,就是由这个参数的值来直接限制的。
|
||||
|
||||
那么一旦达到了最大值,在这个控制组里的进程会发生什么呢?
|
||||
|
||||
这就涉及到我要给你讲的第二个参数memory.oom_control了。这个memory.oom_control又是干啥的呢?当控制组中的进程内存使用达到上限值时,这个参数能够决定会不会触发OOM Killer。
|
||||
|
||||
如果没有人为设置的话,memory.oom_control的缺省值就会触发OOM Killer。这是一个控制组内的OOM Killer,和整个系统的OOM Killer的功能差不多,差别只是被杀进程的选择范围:控制组内的OOM Killer当然只能杀死控制组内的进程,而不能选节点上的其他进程。
|
||||
|
||||
如果我们要改变缺省值,也就是不希望触发OOM Killer,只要执行 echo 1 > memory.oom_control 就行了,这时候即使控制组里所有进程使用的内存达到memory.limit_in_bytes设置的上限值,控制组也不会杀掉里面的进程。
|
||||
|
||||
但是,我想提醒你,这样操作以后,就会影响到控制组中正在申请物理内存页面的进程。这些进程会处于一个停止状态,不能往下运行了。
|
||||
|
||||
最后,我们再来学习一下第三个参数,也就是memory.usage_in_bytes。这个参数是只读的,它里面的数值是当前控制组里所有进程实际使用的内存总和。
|
||||
|
||||
我们可以查看这个值,然后把它和memory.limit_in_bytes里的值做比较,根据接近程度来可以做个预判。这两个值越接近,OOM的风险越高。通过这个方法,我们就可以得知,当前控制组内使用总的内存量有没有OOM的风险了。
|
||||
|
||||
控制组之间也同样是树状的层级结构,在这个结构中,父节点的控制组里的memory.limit_in_bytes值,就可以限制它的子节点中所有进程的内存使用。
|
||||
|
||||
我用一个具体例子来说明,比如像下面图里展示的那样,group1里的memory.limit_in_bytes设置的值是200MB,它的子控制组group3里memory.limit_in_bytes值是500MB。那么,我们在group3里所有进程使用的内存总值就不能超过200MB,而不是500MB。
|
||||
|
||||
|
||||
好了,我们这里介绍了Memory Cgroup最基本的概念,简单总结一下:
|
||||
|
||||
第一,Memory Cgroup中每一个控制组可以为一组进程限制内存使用量,一旦所有进程使用内存的总量达到限制值,缺省情况下,就会触发OOM Killer。这样一来,控制组里的“某个进程”就会被杀死。
|
||||
|
||||
第二,这里杀死“某个进程”的选择标准是,控制组中总的可用页面乘以进程的oom_score_adj,加上进程已经使用的物理内存页面,所得值最大的进程,就会被系统选中杀死。
|
||||
|
||||
解决问题
|
||||
|
||||
我们解释了Memory Cgroup和OOM Killer后,你应该明白了为什么容器在运行过程中会突然消失了。
|
||||
|
||||
对于每个容器创建后,系统都会为它建立一个Memory Cgroup的控制组,容器的所有进程都在这个控制组里。
|
||||
|
||||
一般的容器云平台,比如Kubernetes都会为容器设置一个内存使用的上限。这个内存的上限值会被写入Cgroup里,具体来说就是容器对应的Memory Cgroup控制组里memory.limit_in_bytes这个参数中。
|
||||
|
||||
所以,一旦容器中进程使用的内存达到了上限值,OOM Killer会杀死进程使容器退出。
|
||||
|
||||
那么我们怎样才能快速确定容器发生了OOM呢?这个可以通过查看内核日志及时地发现。
|
||||
|
||||
还是拿我们这一讲最开始发生OOM的容器作为例子。我们通过查看内核的日志,使用用 journalctl -k 命令,或者直接查看日志文件/var/log/message,我们会发现当容器发生OOM Kill的时候,内核会输出下面的这段信息,大致包含下面这三部分的信息:
|
||||
|
||||
第一个部分就是容器里每一个进程使用的内存页面数量。在”rss”列里,”rss’是Resident Set Size的缩写,指的就是进程真正在使用的物理内存页面数量。
|
||||
|
||||
比如下面的日志里,我们看到init进程的”rss”是1个页面,mem_alloc进程的”rss”是130801个页面,内存页面的大小一般是4KB,我们可以做个估算,130801 * 4KB大致等于512MB。
|
||||
|
||||
|
||||
|
||||
第二部分我们来看上面图片的 “oom-kill:” 这行,这一行里列出了发生OOM的Memroy Cgroup的控制组,我们可以从控制组的信息中知道OOM是在哪个容器发生的。
|
||||
|
||||
第三部分是图中 “Killed process 7445 (mem_alloc)” 这行,它显示了最终被OOM Killer杀死的进程。
|
||||
|
||||
我们通过了解内核日志里的这些信息,可以很快地判断出容器是因为OOM而退出的,并且还可以知道是哪个进程消耗了最多的Memory。
|
||||
|
||||
那么知道了哪个进程消耗了最大内存之后,我们就可以有针对性地对这个进程进行分析了,一般有这两种情况:
|
||||
|
||||
第一种情况是这个进程本身的确需要很大的内存,这说明我们给memory.limit_in_bytes里的内存上限值设置小了,那么就需要增大内存的上限值。
|
||||
|
||||
第二种情况是进程的代码中有Bug,会导致内存泄漏,进程内存使用到达了Memory Cgroup中的上限。如果是这种情况,就需要我们具体去解决代码里的问题了。
|
||||
|
||||
重点总结
|
||||
|
||||
这一讲我们从容器在系统中被杀的问题,学习了OOM Killer和Memory Cgroup这两个概念。
|
||||
|
||||
OOM Killer这个行为在Linux中很早就存在了,它其实是一种内存过载后的保护机制,通过牺牲个别的进程,来保证整个节点的内存不会被全部消耗掉。
|
||||
|
||||
在Cgroup的概念出现后,Memory Cgroup中每一个控制组可以对一组进程限制内存使用量,一旦所有进程使用内存的总量达到限制值,在缺省情况下,就会触发OOM Killer,控制组里的“某个进程”就会被杀死。
|
||||
|
||||
请注意,这里Linux系统肯定不能随心所欲地杀掉进程,那具体要用什么选择标准呢?
|
||||
|
||||
杀掉“某个进程”的选择标准,涉及到内核函数oom_badness()。具体的计算方法是 :系统总的可用页面数乘以进程的OOM校准值oom_score_adj,再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被OOM Kill的几率也就越大。
|
||||
|
||||
接下来,我给你讲解了Memory Cgroup里最基本的三个参数,分别是memory.limit_in_bytes, memory.oom_control 和 memory.usage_in_bytes。我把这三个参数的作用,给你总结成了一张图。第一个和第三个参数,下一讲中我们还会用到,这里你可以先有个印象。
|
||||
|
||||
|
||||
|
||||
容器因为OOM被杀,要如何处理呢?我们可以通过内核日志做排查,查看容器里内存使用最多的进程,然后对它进行分析。根据我的经验,解决思路要么是提高容器的最大内存限制,要么需要我们具体去解决进程代码的BUG。
|
||||
|
||||
思考题
|
||||
|
||||
在我们的例子脚本基础上,你可以修改一下,在容器刚一启动,就在容器对应的Memory Cgroup中禁止OOM,看看接下来会发生什么?
|
||||
|
||||
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
186
专栏/容器实战高手课/09PageCache:为什么我的容器内存使用量总是在临界点.md
Normal file
186
专栏/容器实战高手课/09PageCache:为什么我的容器内存使用量总是在临界点.md
Normal file
@ -0,0 +1,186 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 Page Cache:为什么我的容器内存使用量总是在临界点
|
||||
你好,我是程远。
|
||||
|
||||
上一讲,我们讲了Memory Cgroup是如何控制一个容器的内存的。我们已经知道了,如果容器使用的物理内存超过了Memory Cgroup里的memory.limit_in_bytes值,那么容器中的进程会被OOM Killer杀死。
|
||||
|
||||
不过在一些容器的使用场景中,比如容器里的应用有很多文件读写,你会发现整个容器的内存使用量已经很接近Memory Cgroup的上限值了,但是在容器中我们接着再申请内存,还是可以申请出来,并且没有发生OOM。
|
||||
|
||||
这是怎么回事呢?今天这一讲我就来聊聊这个问题。
|
||||
|
||||
问题再现
|
||||
|
||||
我们可以用这里的代码做个容器镜像,然后用下面的这个脚本启动容器,并且设置容器Memory Cgroup里的内存上限值是100MB(104857600bytes)。
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
docker stop page_cache;docker rm page_cache
|
||||
|
||||
if [ ! -f ./test.file ]
|
||||
then
|
||||
dd if=/dev/zero of=./test.file bs=4096 count=30000
|
||||
echo "Please run start_container.sh again "
|
||||
exit 0
|
||||
fi
|
||||
echo 3 > /proc/sys/vm/drop_caches
|
||||
sleep 10
|
||||
|
||||
docker run -d --init --name page_cache -v $(pwd):/mnt registry/page_cache_test:v1
|
||||
CONTAINER_ID=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i page_cache | awk '{print $1}')
|
||||
|
||||
echo $CONTAINER_ID
|
||||
CGROUP_CONTAINER_PATH=$(find /sys/fs/cgroup/memory/ -name "*$CONTAINER_ID*")
|
||||
echo 104857600 > $CGROUP_CONTAINER_PATH/memory.limit_in_bytes
|
||||
cat $CGROUP_CONTAINER_PATH/memory.limit_in_bytes
|
||||
|
||||
|
||||
把容器启动起来后,我们查看一下容器的Memory Cgroup下的memory.limit_in_bytes和memory.usage_in_bytes这两个值。
|
||||
|
||||
如下图所示,我们可以看到容器内存的上限值设置为104857600bytes(100MB),而这时整个容器的已使用内存显示为104767488bytes,这个值已经非常接近上限值了。
|
||||
|
||||
我们把容器内存上限值和已使用的内存数值做个减法,104857600–104767488= 90112bytes,只差大概90KB左右的大小。
|
||||
|
||||
|
||||
|
||||
但是,如果这时候我们继续启动一个程序,让这个程序申请并使用50MB的物理内存,就会发现这个程序还是可以运行成功,这时候容器并没有发生OOM的情况。
|
||||
|
||||
这时我们再去查看参数memory.usage_in_bytes,就会发现它的值变成了103186432bytes,比之前还少了一些。那这是怎么回事呢?
|
||||
|
||||
|
||||
|
||||
知识详解:Linux系统有那些内存类型?
|
||||
|
||||
要解释刚才我们看到的容器里内存分配的现象,就需要先理解Linux操作系统里有哪几种内存的类型。
|
||||
|
||||
因为我们只有知道了内存的类型,才能明白每一种类型的内存,容器分别使用了多少。而且,对于不同类型的内存,一旦总内存增高到容器里内存最高限制的数值,相应的处理方式也不同。
|
||||
|
||||
Linux内存类型
|
||||
|
||||
Linux的各个模块都需要内存,比如内核需要分配内存给页表,内核栈,还有slab,也就是内核各种数据结构的Cache Pool;用户态进程里的堆内存和栈的内存,共享库的内存,还有文件读写的Page Cache。
|
||||
|
||||
在这一讲里,我们讨论的Memory Cgroup里都不会对内核的内存做限制(比如页表,slab等)。所以我们今天主要讨论与用户态相关的两个内存类型,RSS和Page Cache。
|
||||
|
||||
RSS
|
||||
|
||||
先看什么是RSS。RSS是Resident Set Size的缩写,简单来说它就是指进程真正申请到物理页面的内存大小。这是什么意思呢?
|
||||
|
||||
应用程序在申请内存的时候,比如说,调用malloc()来申请100MB的内存大小,malloc()返回成功了,这时候系统其实只是把100MB的虚拟地址空间分配给了进程,但是并没有把实际的物理内存页面分配给进程。
|
||||
|
||||
上一讲中,我给你讲过,当进程对这块内存地址开始做真正读写操作的时候,系统才会把实际需要的物理内存分配给进程。而这个过程中,进程真正得到的物理内存,就是这个RSS了。
|
||||
|
||||
比如下面的这段代码,我们先用malloc申请100MB的内存。
|
||||
|
||||
p = malloc(100 * MB);
|
||||
if (p == NULL)
|
||||
return 0;
|
||||
|
||||
|
||||
然后,我们运行top命令查看这个程序在运行了malloc()之后的内存,我们可以看到这个程序的虚拟地址空间(VIRT)已经有了106728KB(~100MB),但是实际的物理内存RSS(top命令里显示的是RES,就是Resident的简写,和RSS是一个意思)在这里只有688KB。
|
||||
|
||||
|
||||
|
||||
接着我们在程序里等待30秒之后,我们再对这块申请的空间里写入20MB的数据。
|
||||
|
||||
sleep(30);
|
||||
memset(p, 0x00, 20 * MB)
|
||||
|
||||
|
||||
当我们用memset()函数对这块地址空间写入20MB的数据之后,我们再用top查看,这时候可以看到虚拟地址空间(VIRT)还是106728,不过物理内存RSS(RES)的值变成了21432(大小约为20MB), 这里的单位都是KB。
|
||||
|
||||
|
||||
|
||||
所以,通过刚才上面的小实验,我们可以验证RSS就是进程里真正获得的物理内存大小。
|
||||
|
||||
对于进程来说,RSS内存包含了进程的代码段内存,栈内存,堆内存,共享库的内存, 这些内存是进程运行所必须的。刚才我们通过malloc/memset得到的内存,就是属于堆内存。
|
||||
|
||||
具体的每一部分的RSS内存的大小,你可以查看/proc/[pid]/smaps文件。
|
||||
|
||||
Page Cache
|
||||
|
||||
每个进程除了各自独立分配到的RSS内存外,如果进程对磁盘上的文件做了读写操作,Linux还会分配内存,把磁盘上读写到的页面存放在内存中,这部分的内存就是Page Cache。
|
||||
|
||||
Page Cache的主要作用是提高磁盘文件的读写性能,因为系统调用read()和write()的缺省行为都会把读过或者写过的页面存放在Page Cache里。
|
||||
|
||||
还是用我们这一讲最开始的的例子:代码程序去读取100MB的文件,在读取文件前,系统中Page Cache的大小是388MB,读取后Page Cache的大小是506MB,增长了大约100MB左右,多出来的这100MB,正是我们读取文件的大小。
|
||||
|
||||
|
||||
|
||||
在Linux系统里只要有空闲的内存,系统就会自动地把读写过的磁盘文件页面放入到Page Cache里。那么这些内存都被Page Cache占用了,一旦进程需要用到更多的物理内存,执行malloc()调用做申请时,就会发现剩余的物理内存不够了,那该怎么办呢?
|
||||
|
||||
这就要提到Linux的内存管理机制了。 Linux的内存管理有一种内存页面回收机制(page frame reclaim),会根据系统里空闲物理内存是否低于某个阈值(wartermark),来决定是否启动内存的回收。
|
||||
|
||||
内存回收的算法会根据不同类型的内存以及内存的最近最少用原则,就是LRU(Least Recently Used)算法决定哪些内存页面先被释放。因为Page Cache的内存页面只是起到Cache作用,自然是会被优先释放的。
|
||||
|
||||
所以,Page Cache是一种为了提高磁盘文件读写性能而利用空闲物理内存的机制。同时,内存管理中的页面回收机制,又能保证Cache所占用的页面可以及时释放,这样一来就不会影响程序对内存的真正需求了。
|
||||
|
||||
RSS & Page Cache in Memory Cgroup
|
||||
|
||||
学习了RSS和Page Cache的基本概念之后,我们下面来看不同类型的内存,特别是RSS和Page Cache是如何影响Memory Cgroup的工作的。
|
||||
|
||||
我们先从Linux的内核代码看一下,从mem_cgroup_charge_statistics()这个函数里,我们可以看到Memory Cgroup也的确只是统计了RSS和Page Cache这两部分的内存。
|
||||
|
||||
RSS的内存,就是在当前Memory Cgroup控制组里所有进程的RSS的总和;而Page Cache这部分内存是控制组里的进程读写磁盘文件后,被放入到Page Cache里的物理内存。
|
||||
|
||||
|
||||
|
||||
Memory Cgroup控制组里RSS内存和Page Cache内存的和,正好是memory.usage_in_bytes的值。
|
||||
|
||||
当控制组里的进程需要申请新的物理内存,而且memory.usage_in_bytes里的值超过控制组里的内存上限值memory.limit_in_bytes,这时我们前面说的Linux的内存回收(page frame reclaim)就会被调用起来。
|
||||
|
||||
那么在这个控制组里的page cache的内存会根据新申请的内存大小释放一部分,这样我们还是能成功申请到新的物理内存,整个控制组里总的物理内存开销memory.usage_in_bytes 还是不会超过上限值memory.limit_in_bytes。
|
||||
|
||||
解决问题
|
||||
|
||||
明白了Memory Cgroup中内存类型的统计方法,我们再回过头看这一讲开头的问题,为什么memory.usage_in_bytes与memory.limit_in_bytes的值只相差了90KB,我们在容器中还是可以申请出50MB的物理内存?
|
||||
|
||||
我想你应该已经知道答案了,容器里肯定有大于50MB的内存是Page Cache,因为作为Page Cache的内存在系统需要新申请物理内存的时候(作为RSS)是可以被释放的。
|
||||
|
||||
知道了这个答案,那么我们怎么来验证呢?验证的方法也挺简单的,在Memory Cgroup中有一个参数memory.stat,可以显示在当前控制组里各种内存类型的实际的开销。
|
||||
|
||||
那我们还是拿这一讲的容器例子,再跑一遍代码,这次要查看一下memory.stat里的数据。
|
||||
|
||||
第一步,我们还是用同样的脚本来启动容器,并且设置好容器的Memory Cgroup里的memory.limit_in_bytes值为100MB。
|
||||
|
||||
启动容器后,这次我们不仅要看memory.usage_in_bytes的值,还要看一下memory.stat。虽然memory.stat里的参数有不少,但我们目前只需要关注”cache”和”rss”这两个值。
|
||||
|
||||
我们可以看到,容器启动后,cache,也就是Page Cache占的内存是99508224bytes,大概是99MB,而RSS占的内存只有1826816bytes,也就是1MB多一点。
|
||||
|
||||
这就意味着,在这个容器的Memory Cgroup里大部分的内存都被用作了Page Cache,而这部分内存是可以被回收的。
|
||||
|
||||
|
||||
|
||||
那么我们再执行一下我们的mem_alloc程序,申请50MB的物理内存。
|
||||
|
||||
我们可以再来查看一下memory.stat,这时候cache的内存值降到了46632960bytes,大概46MB,而rss的内存值到了54759424bytes,54MB左右吧。总的memory.usage_in_bytes值和之前相比,没有太多的变化。
|
||||
|
||||
|
||||
|
||||
从这里我们发现,Page Cache内存对我们判断容器实际内存使用率的影响,目前Page Cache完全就是Linux内核的一个自动的行为,只要读写磁盘文件,只要有空闲的内存,就会被用作Page Cache。
|
||||
|
||||
所以,判断容器真实的内存使用量,我们不能用Memory Cgroup里的memory.usage_in_bytes,而需要用memory.stat里的rss值。这个很像我们用free命令查看节点的可用内存,不能看”free”字段下的值,而要看除去Page Cache之后的”available”字段下的值。
|
||||
|
||||
重点总结
|
||||
|
||||
这一讲我想让你知道,每个容器的Memory Cgroup在统计每个控制组的内存使用时包含了两部分,RSS和Page Cache。
|
||||
|
||||
RSS是每个进程实际占用的物理内存,它包括了进程的代码段内存,进程运行时需要的堆和栈的内存,这部分内存是进程运行所必须的。
|
||||
|
||||
Page Cache是进程在运行中读写磁盘文件后,作为Cache而继续保留在内存中的,它的目的是为了提高磁盘文件的读写性能。
|
||||
|
||||
当节点的内存紧张或者Memory Cgroup控制组的内存达到上限的时候,Linux会对内存做回收操作,这个时候Page Cache的内存页面会被释放,这样空出来的内存就可以分配给新的内存申请。
|
||||
|
||||
正是Page Cache内存的这种Cache的特性,对于那些有频繁磁盘访问容器,我们往往会看到它的内存使用率一直接近容器内存的限制值(memory.limit_in_bytes)。但是这时候,我们并不需要担心它内存的不够, 我们在判断一个容器的内存使用状况的时候,可以把Page Cache这部分内存使用量忽略,而更多的考虑容器中RSS的内存使用量。
|
||||
|
||||
思考题
|
||||
|
||||
在容器里启动一个写磁盘文件的程序,写入100MB的数据,查看写入前和写入后,容器对应的Memory Cgroup里memory.usage_in_bytes的值以及memory.stat里的rss/cache值。
|
||||
|
||||
欢迎在留言区写下你的思考或疑问,我们一起交流探讨。如果这篇文章让你有所收获,也欢迎你分享给更多的朋友,一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
198
专栏/容器实战高手课/10Swap:容器可以使用Swap空间吗?.md
Normal file
198
专栏/容器实战高手课/10Swap:容器可以使用Swap空间吗?.md
Normal file
@ -0,0 +1,198 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 Swap:容器可以使用Swap空间吗?
|
||||
你好,我是程远。这一讲,我们来看看容器中是否可以使用Swap空间。
|
||||
|
||||
用过Linux的同学应该都很熟悉Swap空间了,简单来说它就是就是一块磁盘空间。
|
||||
|
||||
当内存写满的时候,就可以把内存中不常用的数据暂时写到这个Swap空间上。这样一来,内存空间就可以释放出来,用来满足新的内存申请的需求。
|
||||
|
||||
它的好处是可以应对一些瞬时突发的内存增大需求,不至于因为内存一时不够而触发OOM Killer,导致进程被杀死。
|
||||
|
||||
那么对于一个容器,特别是容器被设置了Memory Cgroup之后,它还可以使用Swap空间吗?会不会出现什么问题呢?
|
||||
|
||||
问题再现
|
||||
|
||||
接下来,我们就结合一个小例子,一起来看看吧。
|
||||
|
||||
首先,我们在一个有Swap空间的节点上启动一个容器,设置好它的Memory Cgroup的限制,一起来看看接下来会发生什么。
|
||||
|
||||
如果你的节点上没有Swap分区,也没有关系,你可以用下面的这组命令来新建一个。
|
||||
|
||||
这个例子里,Swap空间的大小是20G,你可以根据自己磁盘空闲空间来决定这个Swap的大小。执行完这组命令之后,我们来运行free命令,就可以看到Swap空间有20G。
|
||||
|
||||
输出的结果你可以参考下面的截图。
|
||||
|
||||
|
||||
|
||||
然后我们再启动一个容器,和OOM那一讲里的例子差不多,容器的Memory Cgroup限制为512MB,容器中的mem_alloc程序去申请2GB内存。
|
||||
|
||||
你会发现,这次和上次OOM那一讲里的情况不一样了,并没有发生OOM导致容器退出的情况,容器运行得好好的。
|
||||
|
||||
从下面的图中,我们可以看到,mem_alloc进程的RSS内存一直在512MB(RES: 515596)左右。
|
||||
|
||||
|
||||
那我们再看一下Swap空间,使用了1.5GB (used 1542144KB)。输出的结果如下图,简单计算一下,1.5GB + 512MB,结果正好是mem_alloc这个程序申请的2GB内存。
|
||||
|
||||
|
||||
|
||||
通过刚刚的例子,你也许会这么想,因为有了Swap空间,本来会被OOM Kill的容器,可以好好地运行了。初看这样似乎也挺好的,不过你仔细想想,这样一来,Memory Cgroup对内存的限制不就失去了作用么?
|
||||
|
||||
我们再进一步分析,如果一个容器中的程序发生了内存泄漏(Memory leak),那么本来Memory Cgroup可以及时杀死这个进程,让它不影响整个节点中的其他应用程序。结果现在这个内存泄漏的进程没被杀死,还会不断地读写Swap磁盘,反而影响了整个节点的性能。
|
||||
|
||||
你看,这样一分析,对于运行容器的节点,你是不是又觉得应该禁止使用Swap了呢?
|
||||
|
||||
我想提醒你,不能一刀切地下结论,我们总是说,具体情况要具体分析,我们落地到具体的场景里,就会发现情况又没有原先我们想得那么简单。
|
||||
|
||||
比如说,某一类程序就是需要Swap空间,才能防止因为偶尔的内存突然增加而被OOM Killer杀死。因为这类程序重新启动的初始化时间会很长,这样程序重启的代价就很大了,也就是说,打开Swap对这类程序是有意义的。
|
||||
|
||||
这一类程序一旦放到容器中运行,就意味着它会和“别的容器”在同一个宿主机上共同运行,那如果这个“别的容器” 如果不需要Swap,而是希望Memory Cgroup的严格内存限制。
|
||||
|
||||
这样一来,在这一个宿主机上的两个容器就会有冲突了,我们应该怎么解决这个问题呢?要解决这个问题,我们先来看看Linux里的Swappiness这个概念,后面它可以帮到我们。
|
||||
|
||||
如何正确理解swappiness参数?
|
||||
|
||||
在普通Linux系统上,如果你使用过Swap空间,那么你可能配置过proc文件系统下的swappiness 这个参数 (/proc/sys/vm/swappiness)。swappiness的定义在Linux 内核文档中可以找到,就是下面这段话。
|
||||
|
||||
|
||||
swappiness
|
||||
|
||||
This control is used to define how aggressive the kernel will swap memory pages. Higher values will increase aggressiveness, lower values decrease the amount of swap. A value of 0 instructs the kernel not to initiate swap until the amount of free and file-backed pages is less than the high water mark in a zone.
|
||||
|
||||
The default value is 60.
|
||||
|
||||
|
||||
前面两句话大致翻译过来,意思就是 swappiness可以决定系统将会有多频繁地使用交换分区。
|
||||
|
||||
一个较高的值会使得内核更频繁地使用交换分区,而一个较低的取值,则代表着内核会尽量避免使用交换分区。swappiness的取值范围是0–100,缺省值60。
|
||||
|
||||
我第一次读到这个定义,再知道了这个取值范围后,我觉得这是一个百分比值,也就是定义了使用Swap空间的频率。
|
||||
|
||||
当这个值是100的时候,哪怕还有空闲内存,也会去做内存交换,尽量把内存数据写入到Swap空间里;值是0的时候,基本上就不做内存交换了,也就不写Swap空间了。
|
||||
|
||||
后来再回顾的时候,我发现这个想法不能说是完全错的,但是想得简单了些。那这段swappiness的定义,应该怎么正确地理解呢?
|
||||
|
||||
你还记得,我们在上一讲里说过的两种内存类型Page Cache 和RSS么?
|
||||
|
||||
在有磁盘文件访问的时候,Linux会尽量把系统的空闲内存用作Page Cache来提高文件的读写性能。在没有打开Swap空间的情况下,一旦内存不够,这种情况下就只能把Page Cache释放了,而RSS内存是不能释放的。
|
||||
|
||||
在RSS里的内存,大部分都是没有对应磁盘文件的内存,比如用malloc()申请得到的内存,这种内存也被称为匿名内存(Anonymous memory)。那么当Swap空间打开后,可以写入Swap空间的,就是这些匿名内存。
|
||||
|
||||
所以在Swap空间打开的时候,问题也就来了,在内存紧张的时候,Linux系统怎么决定是先释放Page Cache,还是先把匿名内存释放并写入到Swap空间里呢?
|
||||
|
||||
我们一起来分析分析,都可能发生怎样的情况。最可能发生的是下面两种情况:
|
||||
|
||||
第一种情况是,如果系统先把Page Cache都释放了,那么一旦节点里有频繁的文件读写操作,系统的性能就会下降。
|
||||
|
||||
还有另一种情况,如果Linux系统先把匿名内存都释放并写入到Swap,那么一旦这些被释放的匿名内存马上需要使用,又需要从Swap空间读回到内存中,这样又会让Swap(其实也是磁盘)的读写频繁,导致系统性能下降。
|
||||
|
||||
显然,我们在释放内存的时候,需要平衡Page Cache的释放和匿名内存的释放,而swappiness,就是用来定义这个平衡的参数。
|
||||
|
||||
那么swappiness具体是怎么来控制这个平衡的?我们看一下在Linux内核代码里是怎么用这个swappiness参数。
|
||||
|
||||
我们前面说了swappiness的这个值的范围是0到100,但是请你一定要注意,它不是一个百分比,更像是一个权重。它是用来定义Page Cache内存和匿名内存的释放的一个比例。
|
||||
|
||||
我结合下面的这段代码具体给你讲一讲。
|
||||
|
||||
我们可以看到,这个比例是anon_prio: file_prio,这里anon_prio的值就等于swappiness。下面我们分三个情况做讨论:
|
||||
|
||||
第一种情况,当swappiness的值是100的时候,匿名内存和Page Cache内存的释放比例就是100: 100,也就是等比例释放了。
|
||||
|
||||
第二种情况,就是swappiness缺省值是60的时候,匿名内存和Page Cache内存的释放比例就是60 : 140,Page Cache内存的释放要优先于匿名内存。
|
||||
|
||||
/*
|
||||
* With swappiness at 100, anonymous and file have the same priority.
|
||||
* This scanning priority is essentially the inverse of IO cost.
|
||||
*/
|
||||
|
||||
anon_prio = swappiness;
|
||||
file_prio = 200 - anon_prio;
|
||||
|
||||
|
||||
还有一种情况, 当swappiness的值是0的时候,会发生什么呢?这种情况下,Linux系统是不允许匿名内存写入Swap空间了吗?
|
||||
|
||||
我们可以回到前面,再看一下那段swappiness的英文定义,里面特别强调了swappiness为0的情况。
|
||||
|
||||
当空闲内存少于内存一个zone的”high water mark”中的值的时候,Linux还是会做内存交换,也就是把匿名内存写入到Swap空间后释放内存。
|
||||
|
||||
在这里zone是Linux划分物理内存的一个区域,里面有3个水位线(water mark),水位线可以用来警示空闲内存的紧张程度。
|
||||
|
||||
这里我们可以再做个试验来验证一下,先运行 echo 0 > /proc/sys/vm/swappiness 命令把swappiness设置为0, 然后用我们之前例子里的mem_alloc程序来申请内存。
|
||||
|
||||
比如我们的这个节点上内存有12GB,同时有2GB的Swap,用mem_alloc申请12GB的内存,我们可以看到Swap空间在mem_alloc调用之前,used=0,输出结果如下图所示。
|
||||
|
||||
|
||||
|
||||
接下来,调用mem_alloc之后,Swap空间就被使用了。
|
||||
|
||||
|
||||
|
||||
因为mem_alloc申请12GB内存已经和节点最大内存差不多了,我们如果查看 cat /proc/zoneinfo ,也可以看到normal zone里high (water mark)的值和free的值差不多,这样在free
|
||||
|
||||
|
||||
好了,在这里我们介绍了Linux系统里swappiness的概念,它是用来决定在内存紧张时候,回收匿名内存和Page Cache内存的比例。
|
||||
|
||||
swappiness的取值范围在0到100,值为100的时候系统平等回收匿名内存和Page Cache内存;一般缺省值为60,就是优先回收Page Cache;即使swappiness为0,也不能完全禁止Swap分区的使用,就是说在内存紧张的时候,也会使用Swap来回收匿名内存。
|
||||
|
||||
解决问题
|
||||
|
||||
那么运行了容器,使用了Memory Cgroup之后,swappiness怎么工作呢?
|
||||
|
||||
如果你查看一下Memory Cgroup控制组下面的参数,你会看到有一个memory.swappiness参数。这个参数是干啥的呢?
|
||||
|
||||
memory.swappiness可以控制这个Memroy Cgroup控制组下面匿名内存和page cache的回收,取值的范围和工作方式和全局的swappiness差不多。这里有一个优先顺序,在Memory Cgorup的控制组里,如果你设置了memory.swappiness参数,它就会覆盖全局的swappiness,让全局的swappiness在这个控制组里不起作用。
|
||||
|
||||
不过,这里有一点不同,需要你留意:当memory.swappiness = 0的时候,对匿名页的回收是始终禁止的,也就是始终都不会使用Swap空间。
|
||||
|
||||
这时Linux系统不会再去比较free内存和zone里的high water mark的值,再决定一个Memory Cgroup中的匿名内存要不要回收了。
|
||||
|
||||
请你注意,当我们设置了”memory.swappiness=0时,在Memory Cgroup中的进程,就不会再使用Swap空间,知道这一点很重要。
|
||||
|
||||
我们可以跑个容器试一试,还是在一个有Swap空间的节点上运行,运行和这一讲开始一样的容器,唯一不同的是把容器对应Memory Cgroup里的memory.swappiness设置为0。
|
||||
|
||||
|
||||
|
||||
这次我们在容器中申请内存之后,Swap空间就没有被使用了,而当容器申请的内存超过memory.limit_in_bytes之后,就发生了OOM Kill。
|
||||
|
||||
好了,有了”memory.swappiness = 0”的配置和功能,就可以解决我们在这一讲里最开始提出的问题了。
|
||||
|
||||
在同一个宿主机上,假设同时存在容器A和其他容器,容器A上运行着需要使用Swap空间的应用,而别的容器不需要使用Swap空间。
|
||||
|
||||
那么,我们还是可以在宿主机节点上打开Swap空间,同时在其他容器对应的Memory Cgroups控制组里,把memory.swappiness这个参数设置为0。这样一来,我们不但满足了容器A的需求,而且别的容器也不会受到影响,仍然可以严格按照Memory Cgroups里的memory.limit_in_bytes来限制内存的使用。
|
||||
|
||||
总之,memory.swappiness这个参数很有用,通过它可以让需要使用Swap空间的容器和不需要Swap的容器,同时运行在同一个宿主机上。
|
||||
|
||||
重点总结
|
||||
|
||||
这一讲,我们主要讨论的问题是在容器中是否可以使用Swap?
|
||||
|
||||
这个问题没有看起来那么简单。当然了,只要在宿主机节点上打开Swap空间,在容器中就是可以用到Swap的。但出现的问题是在同一个宿主机上,对于不需要使用swap的容器, 它的Memory Cgroups的限制也失去了作用。
|
||||
|
||||
针对这个问题,我们学习了Linux中的swappiness这个参数。swappiness参数值的作用是,在系统里有Swap空间之后,当系统需要回收内存的时候,是优先释放Page Cache中的内存,还是优先释放匿名内存(也就是写入Swap)。
|
||||
|
||||
swappiness的取值范围在0到100之间,我们可以记住下面三个值:
|
||||
|
||||
|
||||
值为100的时候, 释放Page Cache和匿名内存是同等优先级的。
|
||||
值为60,这是大多数Linux系统的缺省值,这时候Page Cache的释放优先级高于匿名内存的释放。
|
||||
值为0的时候,当系统中空闲内存低于一个临界值的时候,仍然会释放匿名内存并把页面写入Swap空间。
|
||||
|
||||
|
||||
|
||||
|
||||
swappiness参数除了在proc文件系统下有个全局的值外,在每个Memory Cgroup控制组里也有一个memory.swappiness,那它们有什么不同呢?
|
||||
|
||||
不同就是每个Memory Cgroup控制组里的swappiness参数值为0的时候,就可以让控制组里的内存停止写入Swap。这样一来,有了memory.swappiness这个参数后,需要使用Swap和不需要Swap的容器就可以在同一个宿主机上同时运行了,这样对于硬件资源的利用率也就更高了。
|
||||
|
||||
思考题
|
||||
|
||||
在一个有Swap分区的节点上用Docker启动一个容器,对它的Memory Cgroup控制组设置一个内存上限N,并且将memory.swappiness设置为0。这时,如果在容器中启动一个不断读写文件的程序,同时这个程序再申请1/2N的内存,请你判断一下,Swap分区中会有数据写入吗?
|
||||
|
||||
欢迎在留言区分享你的收获和疑问。如果这篇文章让你有所收获,也欢迎分享给你的朋友,一起交流和学习。
|
||||
|
||||
|
||||
|
||||
|
228
专栏/容器实战高手课/11容器文件系统:我在容器中读写文件怎么变慢了?.md
Normal file
228
专栏/容器实战高手课/11容器文件系统:我在容器中读写文件怎么变慢了?.md
Normal file
@ -0,0 +1,228 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 容器文件系统:我在容器中读写文件怎么变慢了?
|
||||
你好,我是程远。从这一讲开始,我们进入容器存储这个模块。
|
||||
|
||||
这一模块我们所讲的内容,都和容器里的文件读写密切相关。因为所有的容器的运行都需要一个容器文件系统,那么我们就从容器文件系统先开始讲起。
|
||||
|
||||
那我们还是和以前一样,先来看看我之前碰到了什么问题。
|
||||
|
||||
这个问题具体是我们在宿主机上,把Linux从ubuntu18.04升级到ubuntu20.04之后发现的。
|
||||
|
||||
在我们做了宿主机的升级后,启动了一个容器,在容器里用fio这个磁盘性能测试工具,想看一下容器里文件的读写性能。结果我们很惊讶地发现,在ubuntu 20.04宿主机上的容器中文件读写的性能只有ubuntu18.04宿主机上的1/8左右了,那这是怎么回事呢?
|
||||
|
||||
问题再现
|
||||
|
||||
这里我提醒一下你,因为涉及到两个Linux的虚拟机,问题再现这里我为你列出了关键的结果输出截图,不方便操作的同学可以重点看其中的思路。
|
||||
|
||||
我们可以先启动一个ubuntu18.04的虚拟机,它的Linux内核版本是4.15的,然后在虚拟机上用命令 docker run -it ubuntu:18.04 bash 启动一个容器,接着在容器里运行fio这条命令,看一下在容器中读取文件的性能。
|
||||
|
||||
# fio -direct=1 -iodepth=64 -rw=read -ioengine=libaio -bs=4k -size=10G -numjobs=1 -name=./fio.test
|
||||
|
||||
|
||||
这里我给你解释一下fio命令中的几个主要参数:
|
||||
|
||||
第一个参数是”-direct=1”,代表采用非buffered I/O文件读写的方式,避免文件读写过程中内存缓冲对性能的影响。
|
||||
|
||||
接着我们来看这”-iodepth=64”和”-ioengine=libaio”这两个参数,这里指文件读写采用异步I/O(Async I/O)的方式,也就是进程可以发起多个I/O请求,并且不用阻塞地等待I/O的完成。稍后等I/O完成之后,进程会收到通知。
|
||||
|
||||
这种异步I/O很重要,因为它可以极大地提高文件读写的性能。在这里我们设置了同时发出64个I/O请求。
|
||||
|
||||
然后是”-rw=read,-bs=4k,-size=10G”,这几个参数指这个测试是个读文件测试,每次读4KB大小数块,总共读10GB的数据。
|
||||
|
||||
最后一个参数是”-numjobs=1”,指只有一个进程/线程在运行。
|
||||
|
||||
所以,这条fio命令表示我们通过异步方式读取了10GB的磁盘文件,用来计算文件的读取性能。
|
||||
|
||||
那我们看到在ubuntu 18.04,内核4.15上的容器I/O性能是584MB/s的带宽,IOPS(I/O per second)是150K左右。
|
||||
|
||||
|
||||
|
||||
同样我们再启动一个ubuntu 20.04,内核5.4的虚拟机,然后在它的上面也启动一个容器。
|
||||
|
||||
我们运行 docker run -it ubuntu:20.04 bash ,接着在容器中使用同样的fio命令,可以看到它的I/O性能是70MB带宽,IOPS是18K左右。实践证明,这的确比老版本的ubuntu 18.04差了很多。
|
||||
|
||||
|
||||
|
||||
知识详解
|
||||
|
||||
如何理解容器文件系统?
|
||||
|
||||
刚才我们对比了升级前后的容器读写性能差异,那想要分析刚刚说的这个性能的差异,我们需要先理解容器的文件系统。
|
||||
|
||||
我们在容器里,运行 df 命令,你可以看到在容器中根目录(/)的文件系统类型是”overlay”,它不是我们在普通Linux节点上看到的Ext4或者XFS之类常见的文件系统。
|
||||
|
||||
那么看到这里你肯定想问,Overlay是一个什么样的文件系统呢,容器为什么要用这种文件系统?别急,我会一步一步带你分析。
|
||||
|
||||
|
||||
|
||||
在说容器文件系统前,我们先来想象一下如果没有文件系统管理的话会怎样。假设有这么一个场景,在一个宿主机上需要运行100个容器。
|
||||
|
||||
在我们这个课程的[第一讲]里,我们就说过每个容器都需要一个镜像,这个镜像就把容器中程序需要运行的二进制文件,库文件,配置文件,其他的依赖文件等全部都打包成一个镜像文件。
|
||||
|
||||
如果没有特别的容器文件系统,只是普通的Ext4或者XFS文件系统,那么每次启动一个容器,就需要把一个镜像文件下载并且存储在宿主机上。
|
||||
|
||||
我举个例子帮你理解,比如说,假设一个镜像文件的大小是500MB,那么100个容器的话,就需要下载500MB*100= 50GB的文件,并且占用50GB的磁盘空间。
|
||||
|
||||
如果你再分析一下这50GB里的内容,你会发现,在绝大部分的操作系统里,库文件都是差不多的。而且,在容器运行的时候,这类文件也不会被改动,基本上都是只读的。
|
||||
|
||||
特别是这样的情况:假如这100个容器镜像都是基于”ubuntu:18.04”的,每个容器镜像只是额外复制了50MB左右自己的应用程序到”ubuntu: 18.04”里,那么就是说在总共50GB的数据里,有90%的数据是冗余的。
|
||||
|
||||
讲到这里,你不难推测出理想的情况应该是什么样的?
|
||||
|
||||
没错,当然是在一个宿主机上只要下载并且存储存一份”ubuntu:18.04”,所有基于”ubuntu:18.04”镜像的容器都可以共享这一份通用的部分。这样设置的话,不同容器启动的时候,只需要下载自己独特的程序部分就可以。就像下面这张图展示的这样。
|
||||
|
||||
|
||||
|
||||
正是为了有效地减少磁盘上冗余的镜像数据,同时减少冗余的镜像数据在网络上的传输,选择一种针对于容器的文件系统是很有必要的,而这类的文件系统被称为UnionFS。
|
||||
|
||||
UnionFS这类文件系统实现的主要功能是把多个目录(处于不同的分区)一起挂载(mount)在一个目录下。这种多目录挂载的方式,正好可以解决我们刚才说的容器镜像的问题。
|
||||
|
||||
比如,我们可以把ubuntu18.04这个基础镜像的文件放在一个目录ubuntu18.04/下,容器自己额外的程序文件app_1_bin放在app_1/目录下。
|
||||
|
||||
然后,我们把这两个目录挂载到container_1/这个目录下,作为容器1看到的文件系统;对于容器2,就可以把ubuntu18.04/和app_2/两个目录一起挂载到container_2的目录下。
|
||||
|
||||
这样在节点上我们只要保留一份ubuntu18.04的文件就可以了。
|
||||
|
||||
|
||||
|
||||
OverlayFS
|
||||
|
||||
UnionFS类似的有很多种实现,包括在Docker里最早使用的AUFS,还有目前我们使用的OverlayFS。前面我们在运行df的时候,看到的文件系统类型”overlay”指的就是OverlayFS。
|
||||
|
||||
在Linux内核3.18版本中,OverlayFS代码正式合入Linux内核的主分支。在这之后,OverlayFS也就逐渐成为各个主流Linux发行版本里缺省使用的容器文件系统了。
|
||||
|
||||
网上Julia Evans有个blog,里面有个的OverlayFS使用的例子,很简单,我们也拿这个例子来理解一下OverlayFS的一些基本概念。
|
||||
|
||||
你可以先执行一下这一组命令。
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
umount ./merged
|
||||
rm upper lower merged work -r
|
||||
|
||||
mkdir upper lower merged work
|
||||
echo "I'm from lower!" > lower/in_lower.txt
|
||||
echo "I'm from upper!" > upper/in_upper.txt
|
||||
# `in_both` is in both directories
|
||||
echo "I'm from lower!" > lower/in_both.txt
|
||||
echo "I'm from upper!" > upper/in_both.txt
|
||||
|
||||
sudo mount -t overlay overlay \
|
||||
-o lowerdir=./lower,upperdir=./upper,workdir=./work \
|
||||
./merged
|
||||
|
||||
|
||||
我们可以看到,OverlayFS的一个mount命令牵涉到四类目录,分别是lower,upper,merged和work,那它们是什么关系呢?
|
||||
|
||||
我们看下面这张图,这和前面UnionFS的工作示意图很像,也不奇怪,OverlayFS就是UnionFS的一种实现。接下来,我们从下往上依次看看每一层的功能。
|
||||
|
||||
首先,最下面的”lower/“,也就是被mount两层目录中底下的这层(lowerdir)。
|
||||
|
||||
在OverlayFS中,最底下这一层里的文件是不会被修改的,你可以认为它是只读的。我还想提醒你一点,在这个例子里我们只有一个lower/目录,不过OverlayFS是支持多个lowerdir的。
|
||||
|
||||
然后我们看”uppder/“,它是被mount两层目录中上面的这层 (upperdir)。在OverlayFS中,如果有文件的创建,修改,删除操作,那么都会在这一层反映出来,它是可读写的。
|
||||
|
||||
接着是最上面的”merged” ,它是挂载点(mount point)目录,也是用户看到的目录,用户的实际文件操作在这里进行。
|
||||
|
||||
其实还有一个”work/“,这个目录没有在这个图里,它只是一个存放临时文件的目录,OverlayFS中如果有文件修改,就会在中间过程中临时存放文件到这里。
|
||||
|
||||
|
||||
|
||||
从这个例子我们可以看到,OverlayFS会mount两层目录,分别是lower层和upper层,这两层目录中的文件都会映射到挂载点上。
|
||||
|
||||
从挂载点的视角看,upper层的文件会覆盖lower层的文件,比如”in_both.txt”这个文件,在lower层和upper层都有,但是挂载点merged/里看到的只是upper层里的in_both.txt.
|
||||
|
||||
如果我们在merged/目录里做文件操作,具体包括这三种。
|
||||
|
||||
第一种,新建文件,这个文件会出现在upper/ 目录中。
|
||||
|
||||
第二种是删除文件,如果我们删除”in_upper.txt”,那么这个文件会在upper/目录中消失。如果删除”in_lower.txt”, 在 lower/目录里的”in_lower.txt”文件不会有变化,只是在 upper/目录中增加了一个特殊文件来告诉OverlayFS,”in_lower.txt’这个文件不能出现在merged/里了,这就表示它已经被删除了。
|
||||
|
||||
|
||||
|
||||
还有一种操作是修改文件,类似如果修改”in_lower.txt”,那么就会在upper/目录中新建一个”in_lower.txt”文件,包含更新的内容,而在lower/中的原来的实际文件”in_lower.txt”不会改变。
|
||||
|
||||
通过这个例子,我们知道了OverlayFS是怎么工作了。那么我们可以再想一想,怎么把它运用到容器的镜像文件上?
|
||||
|
||||
其实也不难,从系统的mounts信息中,我们可以看到Docker是怎么用OverlayFS来挂载镜像文件的。容器镜像文件可以分成多个层(layer),每层可以对应OverlayFS里lowerdir的一个目录,lowerdir支持多个目录,也就可以支持多层的镜像文件。
|
||||
|
||||
在容器启动后,对镜像文件中修改就会被保存在upperdir里了。
|
||||
|
||||
|
||||
|
||||
解决问题
|
||||
|
||||
在理解了容器使用的OverlayFS文件系统后,我们再回到开始的问题,为什么在宿主机升级之后,在容器里读写文件的性能降低了?现在我们至少应该知道,在容器中读写文件性能降低了,那么应该是OverlayFS的性能在新的ubuntu20.04中降低了。
|
||||
|
||||
要找到问题的根因,我们还需要进一步的debug。对于性能问题,我们需要使用Linux下的perf工具来查看一下,具体怎么使用perf来解决问题,我们会在后面讲解。
|
||||
|
||||
这里你只要看一下结果就可以了,自下而上是函数的一个调用顺序。通过perf工具,我们可以比较在容器中运行fio的时候,ubuntu 18.04和ubuntu 20.04在内核函数调用上的不同。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
我们从系统调用框架之后的函数aio_read()开始比较:Linux内核4.15里aio_read()之后调用的是xfs_file_read_iter(),而在Linux 内核5.4里,aio_read()之后调用的是ovl_read_iter()这个函数,之后再调用xfs_file_read_iter()。
|
||||
|
||||
这样我们就可以去查看一下,在内核4.15之后新加入的这个函数ovl_read_iter()的代码。
|
||||
|
||||
查看代码后我们就能明白,Linux为了完善OverlayFS,增加了OverlayFS自己的read/write函数接口,从而不再直接调用OverlayFS后端文件系统(比如XFS,Ext4)的读写接口。但是它只实现了同步I/O(sync I/O),并没有实现异步I/O。
|
||||
|
||||
而在fio做文件系统性能测试的时候使用的是异步I/O,这样才可以得到文件系统的性能最大值。所以,在内核5.4上就无法对OverlayFS测出最高的性能指标了。
|
||||
|
||||
在Linux内核5.6版本中,这个问题已经通过下面的这个补丁给解决了,有兴趣的同学可以看一下。
|
||||
|
||||
commit 2406a307ac7ddfd7effeeaff6947149ec6a95b4e
|
||||
Author: Jiufei Xue <[email protected]>
|
||||
Date: Wed Nov 20 17:45:26 2019 +0800
|
||||
|
||||
ovl: implement async IO routines
|
||||
|
||||
A performance regression was observed since linux v4.19 with aio test using
|
||||
fio with iodepth 128 on overlayfs. The queue depth of the device was
|
||||
always 1 which is unexpected.
|
||||
|
||||
After investigation, it was found that commit 16914e6fc7e1 ("ovl: add
|
||||
ovl_read_iter()") and commit 2a92e07edc5e ("ovl: add ovl_write_iter()")
|
||||
resulted in vfs_iter_{read,write} being called on underlying filesystem,
|
||||
which always results in syncronous IO.
|
||||
|
||||
Implement async IO for stacked reading and writing. This resolves the
|
||||
performance regresion.
|
||||
|
||||
This is implemented by allocating a new kiocb for submitting the AIO
|
||||
request on the underlying filesystem. When the request is completed, the
|
||||
new kiocb is freed and the completion callback is called on the original
|
||||
iocb.
|
||||
|
||||
Signed-off-by: Jiufei Xue <[email protected]>
|
||||
Signed-off-by: Miklos Szeredi <[email protected]>
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
这一讲,我们最主要的内容是理解容器文件系统。为什么要有容器自己的文件系统?很重要的一点是减少相同镜像文件在同一个节点上的数据冗余,可以节省磁盘空间,也可以减少镜像文件下载占用的网络资源。
|
||||
|
||||
作为容器文件系统,UnionFS通过多个目录挂载的方式工作。OverlayFS就是UnionFS的一种实现,是目前主流Linux发行版本中缺省使用的容器文件系统。
|
||||
|
||||
OverlayFS也是把多个目录合并挂载,被挂载的目录分为两大类:lowerdir和upperdir。
|
||||
|
||||
lowerdir允许有多个目录,在被挂载后,这些目录里的文件都是不会被修改或者删除的,也就是只读的;upperdir只有一个,不过这个目录是可读写的,挂载点目录中的所有文件修改都会在upperdir中反映出来。
|
||||
|
||||
容器的镜像文件中各层正好作为OverlayFS的lowerdir的目录,然后加上一个空的upperdir一起挂载好后,就组成了容器的文件系统。
|
||||
|
||||
OverlayFS在Linux内核中还在不断的完善,比如我们在这一讲看到的在kenel 5.4中对异步I/O操作的缺失,这也是我们在使用容器文件系统的时候需要注意的。
|
||||
|
||||
思考题
|
||||
|
||||
在这一讲OverlayFS的例子的基础上,建立2个lowerdir的目录,并且在目录中建立相同文件名的文件,然后一起做一个overlay mount,看看会发生什么?
|
||||
|
||||
欢迎在留言区和我分享你的思考和疑问。如果这篇文章让你有所收获,也欢迎分享给你的同事、朋友,一起学习探讨。
|
||||
|
||||
|
||||
|
||||
|
234
专栏/容器实战高手课/12容器文件Quota:容器为什么把宿主机的磁盘写满了?.md
Normal file
234
专栏/容器实战高手课/12容器文件Quota:容器为什么把宿主机的磁盘写满了?.md
Normal file
@ -0,0 +1,234 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 容器文件Quota:容器为什么把宿主机的磁盘写满了?
|
||||
你好,我是程远。今天我们聊一聊容器文件Quota。
|
||||
|
||||
上一讲,我们学习了容器文件系统OverlayFS,这个OverlayFS有两层,分别是lowerdir和upperdir。lowerdir里是容器镜像中的文件,对于容器来说是只读的;upperdir存放的是容器对文件系统里的所有改动,它是可读写的。
|
||||
|
||||
从宿主机的角度看,upperdir就是一个目录,如果容器不断往容器文件系统中写入数据,实际上就是往宿主机的磁盘上写数据,这些数据也就存在于宿主机的磁盘目录中。
|
||||
|
||||
当然对于容器来说,如果有大量的写操作是不建议写入容器文件系统的,一般是需要给容器挂载一个volume,用来满足大量的文件读写。
|
||||
|
||||
但是不能避免的是,用户在容器中运行的程序有错误,或者进行了错误的配置。
|
||||
|
||||
比如说,我们把log写在了容器文件系统上,并且没有做log rotation,那么时间一久,就会导致宿主机上的磁盘被写满。这样影响的就不止是容器本身了,而是整个宿主机了。
|
||||
|
||||
那对于这样的问题,我们该怎么解决呢?
|
||||
|
||||
问题再现
|
||||
|
||||
我们可以自己先启动一个容器,一起试试不断地往容器文件系统中写入数据,看看是一个什么样的情况。
|
||||
|
||||
用Docker启动一个容器后,我们看到容器的根目录(/)也就是容器文件系统OverlayFS,它的大小是160G,已经使用了100G。其实这个大小也是宿主机上的磁盘空间和使用情况。
|
||||
|
||||
|
||||
|
||||
这时候,我们可以回到宿主机上验证一下,就会发现宿主机的根目录(/)的大小也是160G,同样是使用了100G。
|
||||
|
||||
|
||||
|
||||
好,那现在我们再往容器的根目录里写入10GB的数据。
|
||||
|
||||
这里我们可以看到容器的根目录使用的大小增加了,从刚才的100G变成现在的110G。而多写入的10G大小的数据,对应的是test.log这个文件。
|
||||
|
||||
|
||||
|
||||
接下来,我们再回到宿主机上,可以看到宿主机上的根目录(/)里使用的大小也是110G了。
|
||||
|
||||
|
||||
|
||||
我们还是继续看宿主机,看看OverlayFS里upperdir目录中有什么文件?
|
||||
|
||||
这里我们仍然可以通过/proc/mounts这个路径,找到容器OverlayFS对应的lowerdir和upperdir。因为写入的数据都在upperdir里,我们就只要看upperdir对应的那个目录就行了。果然,里面存放着容器写入的文件test.log,它的大小是10GB。
|
||||
|
||||
|
||||
|
||||
通过这个例子,我们已经验证了在容器中对于OverlayFS中写入数据,其实就是往宿主机的一个目录(upperdir)里写数据。我们现在已经写了10GB的数据,如果继续在容器中写入数据,结果估计你也知道了,就是会写满宿主机的磁盘。
|
||||
|
||||
那遇到这种情况,我们该怎么办呢?
|
||||
|
||||
知识详解
|
||||
|
||||
容器写自己的OverlayFS根目录,结果把宿主机的磁盘写满了。发生这个问题,我们首先就会想到需要对容器做限制,限制它写入自己OverlayFS的数据量,比如只允许一个容器写100MB的数据。
|
||||
|
||||
不过我们实际查看OverlayFS文件系统的特性,就会发现没有直接限制文件写入量的特性。别担心,在没有现成工具的情况下,我们只要搞懂了原理,就能想出解决办法。
|
||||
|
||||
所以我们再来分析一下OverlayFS,它是通过lowerdir和upperdir两层目录联合挂载来实现的,lowerdir是只读的,数据只会写在upperdir中。
|
||||
|
||||
那我们是不是可以通过限制upperdir目录容量的方式,来限制一个容器OverlayFS根目录的写入数据量呢?
|
||||
|
||||
沿着这个思路继续往下想,因为upperdir在宿主机上也是一个普通的目录,这样就要看宿主机上的文件系统是否可以支持对一个目录限制容量了。
|
||||
|
||||
对于Linux上最常用的两个文件系统XFS和ext4,它们有一个特性Quota,那我们就以XFS文件系统为例,学习一下这个Quota概念,然后看看这个特性能不能限制一个目录的使用量。
|
||||
|
||||
XFS Quota
|
||||
|
||||
在Linux系统里的XFS文件系统缺省都有Quota的特性,这个特性可以为Linux系统里的一个用户(user),一个用户组(group)或者一个项目(project)来限制它们使用文件系统的额度(quota),也就是限制它们可以写入文件系统的文件总量。
|
||||
|
||||
因为我们的目标是要限制一个目录中总体的写入文件数据量,那么显然给用户和用户组限制文件系统的写入数据量的模式,并不适合我们的这个需求。
|
||||
|
||||
因为同一个用户或者用户组可以操作多个目录,多个用户或者用户组也可以操作同一个目录,这样对一个用户或者用户组的限制,就很难用来限制一个目录。
|
||||
|
||||
那排除了限制用户或用户组的模式,我们再来看看Project模式。Project模式是怎么工作的呢?
|
||||
|
||||
我举一个例子你会更好理解,对Linux熟悉的同学可以一边操作,一边体会一下它的工作方式。不熟悉的同学也没关系,可以重点关注我后面的讲解思路。
|
||||
|
||||
首先我们要使用XFS Quota特性,必须在文件系统挂载的时候加上对应的Quota选项,比如我们目前需要配置Project Quota,那么这个挂载参数就是”pquota”。
|
||||
|
||||
对于根目录来说,这个参数必须作为一个内核启动的参数”rootflags=pquota”,这样设置就可以保证根目录在启动挂载的时候,带上XFS Quota的特性并且支持Project模式。
|
||||
|
||||
我们可以从/proc/mounts信息里,看看根目录是不是带”prjquota”字段。如果里面有这个字段,就可以确保文件系统已经带上了支持project模式的XFS quota特性。
|
||||
|
||||
|
||||
|
||||
下一步,我们还需要给一个指定的目录打上一个Project ID。这个步骤我们可以使用XFS文件系统自带的工具 xfs_quota 来完成,然后执行下面的这个命令就可以了。
|
||||
|
||||
执行命令之前,我先对下面的命令和输出做两点解释,让你理解这个命令的含义。
|
||||
|
||||
第一点,新建的目录/tmp/xfs_prjquota,我们想对它做Quota限制。所以在这里要对它打上一个Project ID。
|
||||
|
||||
第二点,通过xfs_quota这条命令,我们给/tmp/xfs_prjquota打上Project ID值101,这个101是我随便选的一个数字,就是个ID标识,你先有个印象。在后面针对Project进行Quota限制的时候,我们还会用到这个ID。
|
||||
|
||||
# mkdir -p /tmp/xfs_prjquota
|
||||
# xfs_quota -x -c 'project -s -p /tmp/xfs_prjquota 101' /
|
||||
Setting up project 101 (path /tmp/xfs_prjquota)...
|
||||
Processed 1 (/etc/projects and cmdline) paths for project 101 with recursion depth infinite (-1).
|
||||
|
||||
|
||||
最后,我们还是使用xfs_quota命令,对101(我们刚才建立的这个Project ID)做Quota限制。
|
||||
|
||||
你可以执行下面这条命令,里面的”-p bhard=10m 101”就代表限制101这个project ID,限制它的数据块写入量不能超过10MB。
|
||||
|
||||
# xfs_quota -x -c 'limit -p bhard=10m 101' /
|
||||
|
||||
|
||||
做好限制之后,我们可以尝试往/tmp/xfs_prjquota写数据,看看是否可以超过10MB。比如说,我们尝试写入20MB的数据到/tmp/xfs_prjquota里。
|
||||
|
||||
我们可以看到,执行dd写入命令,就会有个出错返回信息”No space left on device”。这表示已经不能再往这个目录下写入数据了,而最后写入数据的文件test.file大小也停留在了10MB。
|
||||
|
||||
# dd if=/dev/zero of=/tmp/xfs_prjquota/test.file bs=1024 count=20000
|
||||
dd: error writing '/tmp/xfs_prjquota/test.file': No space left on device
|
||||
10241+0 records in
|
||||
10240+0 records out
|
||||
10485760 bytes (10 MB, 10 MiB) copied, 0.0357122 s, 294 MB/s
|
||||
|
||||
# ls -l /tmp/xfs_prjquota/test.file
|
||||
-rw-r--r-- 1 root root 10485760 Oct 31 10:00 /tmp/xfs_prjquota/test.file
|
||||
|
||||
|
||||
好了,做到这里,我们发现使用XFS Quota的Project模式,确实可以限制一个目录里的写入数据量,它实现的方式其实也不难,就是下面这两步。
|
||||
|
||||
第一步,给目标目录打上一个Project ID,这个ID最终是写到目录对应的inode上。
|
||||
|
||||
这里我解释一下,inode是文件系统中用来描述一个文件或者一个目录的元数据,里面包含文件大小,数据块的位置,文件所属用户/组,文件读写属性以及其他一些属性。
|
||||
|
||||
那么一旦目录打上这个ID之后,在这个目录下的新建的文件和目录也都会继承这个ID。
|
||||
|
||||
第二步,在XFS文件系统中,我们需要给这个project ID设置一个写入数据块的限制。
|
||||
|
||||
有了ID和限制值之后,文件系统就可以统计所有带这个ID文件的数据块大小总和,并且与限制值进行比较。一旦所有文件大小的总和达到限制值,文件系统就不再允许更多的数据写入了。
|
||||
|
||||
用一句话概括,XFS Quota就是通过前面这两步限制了一个目录里写入的数据量。
|
||||
|
||||
解决问题
|
||||
|
||||
我们理解了XFS Quota对目录限流的机制之后,再回到我们最开始的问题,如何确保容器不会写满宿主机上的磁盘。
|
||||
|
||||
你应该已经想到了,方法就是对OverlayFS的upperdir目录做XFS Quota的限流,没错,就是这个解决办法!
|
||||
|
||||
其实Docker也已经实现了限流功能,也就是用XFS Quota来限制容器的OverlayFS大小。
|
||||
|
||||
我们在用 docker run 启动容器的时候,加上一个参数 --storage-opt size= <SIZE> ,就能限制住容器OverlayFS文件系统可写入的最大数据量了。
|
||||
|
||||
我们可以一起试一下,这里我们限制的size是10MB。
|
||||
|
||||
进入容器之后,先运行 df -h 命令,这时候你可以看到根目录(/)overlayfs文件系统的大小就10MB,而不是我们之前看到的160GB的大小了。这样容器在它的根目录下,最多只能写10MB数据,就不会把宿主机的磁盘给写满了。
|
||||
|
||||
|
||||
|
||||
完成了上面这个小试验之后,我们可以再看一下Docker的代码,看看它的实现是不是和我们想的一样。
|
||||
|
||||
Docker里SetQuota()函数就是用来实现XFS Quota 限制的,我们可以看到它里面最重要的两步,分别是 setProjectID 和 setProjectQuota 。
|
||||
|
||||
其实,这两步做的就是我们在基本概念中提到的那两步:
|
||||
|
||||
第一步,给目标目录打上一个Project ID;第二步,为这个Project ID在XFS文件系统中,设置一个写入数据块的限制。
|
||||
|
||||
// SetQuota - assign a unique project id to directory and set the quota limits
|
||||
// for that project id
|
||||
|
||||
func (q *Control) SetQuota(targetPath string, quota Quota) error {
|
||||
q.RLock()
|
||||
projectID, ok := q.quotas[targetPath]
|
||||
q.RUnlock()
|
||||
|
||||
if !ok {
|
||||
q.Lock()
|
||||
projectID = q.nextProjectID
|
||||
|
||||
//
|
||||
// assign project id to new container directory
|
||||
//
|
||||
|
||||
err := setProjectID(targetPath, projectID)
|
||||
if err != nil {
|
||||
q.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
q.quotas[targetPath] = projectID
|
||||
q.nextProjectID++
|
||||
q.Unlock()
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// set the quota limit for the container's project id
|
||||
//
|
||||
|
||||
logrus.Debugf("SetQuota(%s, %d): projectID=%d", targetPath, quota.Size, projectID)
|
||||
return setProjectQuota(q.backingFsBlockDev, projectID, quota)
|
||||
}
|
||||
|
||||
|
||||
那 setProjectID 和 setProjectQuota 是如何实现的呢?
|
||||
|
||||
你可以进入到这两个函数里看一下,它们分别调用了ioctl()和quotactl()这两个系统调用来修改内核中XFS的数据结构,从而完成project ID的设置和Quota值的设置。具体的细节,我不在这里展开了,如果你有兴趣,可以继续去查看内核中对应的代码。
|
||||
|
||||
好了,Docker里XFS Quota操作的步骤完全和我们先前设想的一样,那么还有最后一个问题要解决,XFS Quota限制的目录是哪一个?
|
||||
|
||||
这个我们可以根据/proc/mounts中容器的OverlayFS Mount信息,再结合Docker的代码,就可以知道限制的目录是”/var/lib/docker/overlay2/“。那这个目录下有什么呢?果然upperdir目录中有对应的”diff”目录,就在里面!
|
||||
|
||||
|
||||
|
||||
讲到这里,我想你已经清楚了对于使用OverlayFS的容器,我们应该如何去防止它把宿主机的磁盘给写满了吧?方法就是对OverlayFS的upperdir目录做XFS Quota的限流。
|
||||
|
||||
重点总结
|
||||
|
||||
我们这一讲的问题是,容器写了大量数据到OverlayFS文件系统的根目录,在这个情况下,就会把宿主机的磁盘写满。
|
||||
|
||||
由于OverlayFS自己没有专门的特性,可以限制文件数据写入量。这时我们通过实际试验找到了解决思路:依靠底层文件系统的Quota特性来限制OverlayFS的upperdir目录的大小,这样就能实现限制容器写磁盘的目的。
|
||||
|
||||
底层文件系统XFS Quota的Project模式,能够限制一个目录的文件写入量,这个功能具体是通过这两个步骤实现:
|
||||
|
||||
第一步,给目标目录打上一个Project ID。
|
||||
|
||||
第二步,给这个Project ID在XFS文件系统中设置一个写入数据块的限制。
|
||||
|
||||
Docker正是使用了这个方法,也就是用XFS Quota来限制OverlayFS的upperdir目录,通过这个方式控制容器OverlayFS的根目录大小。
|
||||
|
||||
当我们理解了这个方法后,对于不是用Docker启动的容器,比如直接由containerd启动起来的容器,也可以自己实现XFS Quota限制upperdir目录。这样就能有效控制容器对OverlayFS的写数据操作,避免宿主机的磁盘被写满。
|
||||
|
||||
思考题
|
||||
|
||||
在正文知识详解的部分,我们使用”xfs_quota”给目录打了project ID并且限制了文件写入的数据量。那在做完这样的限制之后,我们是否能用xfs_quota命令,查询到被限制目录的project ID和限制的数据量呢?
|
||||
|
||||
欢迎你在留言区分享你的思考或疑问。如果这篇文章让你有所收获,也欢迎转发给你的同事、朋友,一起交流和学习。
|
||||
|
||||
|
||||
|
||||
|
234
专栏/容器实战高手课/13容器磁盘限速:我的容器里磁盘读写为什么不稳定_.md
Normal file
234
专栏/容器实战高手课/13容器磁盘限速:我的容器里磁盘读写为什么不稳定_.md
Normal file
@ -0,0 +1,234 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 容器磁盘限速:我的容器里磁盘读写为什么不稳定_
|
||||
你好,我是程远。今天我们聊一聊容器文件Quota。
|
||||
|
||||
上一讲,我们学习了容器文件系统OverlayFS,这个OverlayFS有两层,分别是lowerdir和upperdir。lowerdir里是容器镜像中的文件,对于容器来说是只读的;upperdir存放的是容器对文件系统里的所有改动,它是可读写的。
|
||||
|
||||
从宿主机的角度看,upperdir就是一个目录,如果容器不断往容器文件系统中写入数据,实际上就是往宿主机的磁盘上写数据,这些数据也就存在于宿主机的磁盘目录中。
|
||||
|
||||
当然对于容器来说,如果有大量的写操作是不建议写入容器文件系统的,一般是需要给容器挂载一个volume,用来满足大量的文件读写。
|
||||
|
||||
但是不能避免的是,用户在容器中运行的程序有错误,或者进行了错误的配置。
|
||||
|
||||
比如说,我们把log写在了容器文件系统上,并且没有做log rotation,那么时间一久,就会导致宿主机上的磁盘被写满。这样影响的就不止是容器本身了,而是整个宿主机了。
|
||||
|
||||
那对于这样的问题,我们该怎么解决呢?
|
||||
|
||||
问题再现
|
||||
|
||||
我们可以自己先启动一个容器,一起试试不断地往容器文件系统中写入数据,看看是一个什么样的情况。
|
||||
|
||||
用Docker启动一个容器后,我们看到容器的根目录(/)也就是容器文件系统OverlayFS,它的大小是160G,已经使用了100G。其实这个大小也是宿主机上的磁盘空间和使用情况。
|
||||
|
||||
|
||||
|
||||
这时候,我们可以回到宿主机上验证一下,就会发现宿主机的根目录(/)的大小也是160G,同样是使用了100G。
|
||||
|
||||
|
||||
|
||||
好,那现在我们再往容器的根目录里写入10GB的数据。
|
||||
|
||||
这里我们可以看到容器的根目录使用的大小增加了,从刚才的100G变成现在的110G。而多写入的10G大小的数据,对应的是test.log这个文件。
|
||||
|
||||
|
||||
|
||||
接下来,我们再回到宿主机上,可以看到宿主机上的根目录(/)里使用的大小也是110G了。
|
||||
|
||||
|
||||
|
||||
我们还是继续看宿主机,看看OverlayFS里upperdir目录中有什么文件?
|
||||
|
||||
这里我们仍然可以通过/proc/mounts这个路径,找到容器OverlayFS对应的lowerdir和upperdir。因为写入的数据都在upperdir里,我们就只要看upperdir对应的那个目录就行了。果然,里面存放着容器写入的文件test.log,它的大小是10GB。
|
||||
|
||||
|
||||
|
||||
通过这个例子,我们已经验证了在容器中对于OverlayFS中写入数据,其实就是往宿主机的一个目录(upperdir)里写数据。我们现在已经写了10GB的数据,如果继续在容器中写入数据,结果估计你也知道了,就是会写满宿主机的磁盘。
|
||||
|
||||
那遇到这种情况,我们该怎么办呢?
|
||||
|
||||
知识详解
|
||||
|
||||
容器写自己的OverlayFS根目录,结果把宿主机的磁盘写满了。发生这个问题,我们首先就会想到需要对容器做限制,限制它写入自己OverlayFS的数据量,比如只允许一个容器写100MB的数据。
|
||||
|
||||
不过我们实际查看OverlayFS文件系统的特性,就会发现没有直接限制文件写入量的特性。别担心,在没有现成工具的情况下,我们只要搞懂了原理,就能想出解决办法。
|
||||
|
||||
所以我们再来分析一下OverlayFS,它是通过lowerdir和upperdir两层目录联合挂载来实现的,lowerdir是只读的,数据只会写在upperdir中。
|
||||
|
||||
那我们是不是可以通过限制upperdir目录容量的方式,来限制一个容器OverlayFS根目录的写入数据量呢?
|
||||
|
||||
沿着这个思路继续往下想,因为upperdir在宿主机上也是一个普通的目录,这样就要看宿主机上的文件系统是否可以支持对一个目录限制容量了。
|
||||
|
||||
对于Linux上最常用的两个文件系统XFS和ext4,它们有一个特性Quota,那我们就以XFS文件系统为例,学习一下这个Quota概念,然后看看这个特性能不能限制一个目录的使用量。
|
||||
|
||||
XFS Quota
|
||||
|
||||
在Linux系统里的XFS文件系统缺省都有Quota的特性,这个特性可以为Linux系统里的一个用户(user),一个用户组(group)或者一个项目(project)来限制它们使用文件系统的额度(quota),也就是限制它们可以写入文件系统的文件总量。
|
||||
|
||||
因为我们的目标是要限制一个目录中总体的写入文件数据量,那么显然给用户和用户组限制文件系统的写入数据量的模式,并不适合我们的这个需求。
|
||||
|
||||
因为同一个用户或者用户组可以操作多个目录,多个用户或者用户组也可以操作同一个目录,这样对一个用户或者用户组的限制,就很难用来限制一个目录。
|
||||
|
||||
那排除了限制用户或用户组的模式,我们再来看看Project模式。Project模式是怎么工作的呢?
|
||||
|
||||
我举一个例子你会更好理解,对Linux熟悉的同学可以一边操作,一边体会一下它的工作方式。不熟悉的同学也没关系,可以重点关注我后面的讲解思路。
|
||||
|
||||
首先我们要使用XFS Quota特性,必须在文件系统挂载的时候加上对应的Quota选项,比如我们目前需要配置Project Quota,那么这个挂载参数就是”pquota”。
|
||||
|
||||
对于根目录来说,这个参数必须作为一个内核启动的参数”rootflags=pquota”,这样设置就可以保证根目录在启动挂载的时候,带上XFS Quota的特性并且支持Project模式。
|
||||
|
||||
我们可以从/proc/mounts信息里,看看根目录是不是带”prjquota”字段。如果里面有这个字段,就可以确保文件系统已经带上了支持project模式的XFS quota特性。
|
||||
|
||||
|
||||
|
||||
下一步,我们还需要给一个指定的目录打上一个Project ID。这个步骤我们可以使用XFS文件系统自带的工具 xfs_quota 来完成,然后执行下面的这个命令就可以了。
|
||||
|
||||
执行命令之前,我先对下面的命令和输出做两点解释,让你理解这个命令的含义。
|
||||
|
||||
第一点,新建的目录/tmp/xfs_prjquota,我们想对它做Quota限制。所以在这里要对它打上一个Project ID。
|
||||
|
||||
第二点,通过xfs_quota这条命令,我们给/tmp/xfs_prjquota打上Project ID值101,这个101是我随便选的一个数字,就是个ID标识,你先有个印象。在后面针对Project进行Quota限制的时候,我们还会用到这个ID。
|
||||
|
||||
# mkdir -p /tmp/xfs_prjquota
|
||||
# xfs_quota -x -c 'project -s -p /tmp/xfs_prjquota 101' /
|
||||
Setting up project 101 (path /tmp/xfs_prjquota)...
|
||||
Processed 1 (/etc/projects and cmdline) paths for project 101 with recursion depth infinite (-1).
|
||||
|
||||
|
||||
最后,我们还是使用xfs_quota命令,对101(我们刚才建立的这个Project ID)做Quota限制。
|
||||
|
||||
你可以执行下面这条命令,里面的”-p bhard=10m 101”就代表限制101这个project ID,限制它的数据块写入量不能超过10MB。
|
||||
|
||||
# xfs_quota -x -c 'limit -p bhard=10m 101' /
|
||||
|
||||
|
||||
做好限制之后,我们可以尝试往/tmp/xfs_prjquota写数据,看看是否可以超过10MB。比如说,我们尝试写入20MB的数据到/tmp/xfs_prjquota里。
|
||||
|
||||
我们可以看到,执行dd写入命令,就会有个出错返回信息”No space left on device”。这表示已经不能再往这个目录下写入数据了,而最后写入数据的文件test.file大小也停留在了10MB。
|
||||
|
||||
# dd if=/dev/zero of=/tmp/xfs_prjquota/test.file bs=1024 count=20000
|
||||
dd: error writing '/tmp/xfs_prjquota/test.file': No space left on device
|
||||
10241+0 records in
|
||||
10240+0 records out
|
||||
10485760 bytes (10 MB, 10 MiB) copied, 0.0357122 s, 294 MB/s
|
||||
|
||||
# ls -l /tmp/xfs_prjquota/test.file
|
||||
-rw-r--r-- 1 root root 10485760 Oct 31 10:00 /tmp/xfs_prjquota/test.file
|
||||
|
||||
|
||||
好了,做到这里,我们发现使用XFS Quota的Project模式,确实可以限制一个目录里的写入数据量,它实现的方式其实也不难,就是下面这两步。
|
||||
|
||||
第一步,给目标目录打上一个Project ID,这个ID最终是写到目录对应的inode上。
|
||||
|
||||
这里我解释一下,inode是文件系统中用来描述一个文件或者一个目录的元数据,里面包含文件大小,数据块的位置,文件所属用户/组,文件读写属性以及其他一些属性。
|
||||
|
||||
那么一旦目录打上这个ID之后,在这个目录下的新建的文件和目录也都会继承这个ID。
|
||||
|
||||
第二步,在XFS文件系统中,我们需要给这个project ID设置一个写入数据块的限制。
|
||||
|
||||
有了ID和限制值之后,文件系统就可以统计所有带这个ID文件的数据块大小总和,并且与限制值进行比较。一旦所有文件大小的总和达到限制值,文件系统就不再允许更多的数据写入了。
|
||||
|
||||
用一句话概括,XFS Quota就是通过前面这两步限制了一个目录里写入的数据量。
|
||||
|
||||
解决问题
|
||||
|
||||
我们理解了XFS Quota对目录限流的机制之后,再回到我们最开始的问题,如何确保容器不会写满宿主机上的磁盘。
|
||||
|
||||
你应该已经想到了,方法就是对OverlayFS的upperdir目录做XFS Quota的限流,没错,就是这个解决办法!
|
||||
|
||||
其实Docker也已经实现了限流功能,也就是用XFS Quota来限制容器的OverlayFS大小。
|
||||
|
||||
我们在用 docker run 启动容器的时候,加上一个参数 --storage-opt size= <SIZE> ,就能限制住容器OverlayFS文件系统可写入的最大数据量了。
|
||||
|
||||
我们可以一起试一下,这里我们限制的size是10MB。
|
||||
|
||||
进入容器之后,先运行 df -h 命令,这时候你可以看到根目录(/)overlayfs文件系统的大小就10MB,而不是我们之前看到的160GB的大小了。这样容器在它的根目录下,最多只能写10MB数据,就不会把宿主机的磁盘给写满了。
|
||||
|
||||
|
||||
|
||||
完成了上面这个小试验之后,我们可以再看一下Docker的代码,看看它的实现是不是和我们想的一样。
|
||||
|
||||
Docker里SetQuota()函数就是用来实现XFS Quota 限制的,我们可以看到它里面最重要的两步,分别是 setProjectID 和 setProjectQuota 。
|
||||
|
||||
其实,这两步做的就是我们在基本概念中提到的那两步:
|
||||
|
||||
第一步,给目标目录打上一个Project ID;第二步,为这个Project ID在XFS文件系统中,设置一个写入数据块的限制。
|
||||
|
||||
// SetQuota - assign a unique project id to directory and set the quota limits
|
||||
// for that project id
|
||||
|
||||
func (q *Control) SetQuota(targetPath string, quota Quota) error {
|
||||
q.RLock()
|
||||
projectID, ok := q.quotas[targetPath]
|
||||
q.RUnlock()
|
||||
|
||||
if !ok {
|
||||
q.Lock()
|
||||
projectID = q.nextProjectID
|
||||
|
||||
//
|
||||
// assign project id to new container directory
|
||||
//
|
||||
|
||||
err := setProjectID(targetPath, projectID)
|
||||
if err != nil {
|
||||
q.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
q.quotas[targetPath] = projectID
|
||||
q.nextProjectID++
|
||||
q.Unlock()
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// set the quota limit for the container's project id
|
||||
//
|
||||
|
||||
logrus.Debugf("SetQuota(%s, %d): projectID=%d", targetPath, quota.Size, projectID)
|
||||
return setProjectQuota(q.backingFsBlockDev, projectID, quota)
|
||||
}
|
||||
|
||||
|
||||
那 setProjectID 和 setProjectQuota 是如何实现的呢?
|
||||
|
||||
你可以进入到这两个函数里看一下,它们分别调用了ioctl()和quotactl()这两个系统调用来修改内核中XFS的数据结构,从而完成project ID的设置和Quota值的设置。具体的细节,我不在这里展开了,如果你有兴趣,可以继续去查看内核中对应的代码。
|
||||
|
||||
好了,Docker里XFS Quota操作的步骤完全和我们先前设想的一样,那么还有最后一个问题要解决,XFS Quota限制的目录是哪一个?
|
||||
|
||||
这个我们可以根据/proc/mounts中容器的OverlayFS Mount信息,再结合Docker的代码,就可以知道限制的目录是”/var/lib/docker/overlay2/“。那这个目录下有什么呢?果然upperdir目录中有对应的”diff”目录,就在里面!
|
||||
|
||||
|
||||
|
||||
讲到这里,我想你已经清楚了对于使用OverlayFS的容器,我们应该如何去防止它把宿主机的磁盘给写满了吧?方法就是对OverlayFS的upperdir目录做XFS Quota的限流。
|
||||
|
||||
重点总结
|
||||
|
||||
我们这一讲的问题是,容器写了大量数据到OverlayFS文件系统的根目录,在这个情况下,就会把宿主机的磁盘写满。
|
||||
|
||||
由于OverlayFS自己没有专门的特性,可以限制文件数据写入量。这时我们通过实际试验找到了解决思路:依靠底层文件系统的Quota特性来限制OverlayFS的upperdir目录的大小,这样就能实现限制容器写磁盘的目的。
|
||||
|
||||
底层文件系统XFS Quota的Project模式,能够限制一个目录的文件写入量,这个功能具体是通过这两个步骤实现:
|
||||
|
||||
第一步,给目标目录打上一个Project ID。
|
||||
|
||||
第二步,给这个Project ID在XFS文件系统中设置一个写入数据块的限制。
|
||||
|
||||
Docker正是使用了这个方法,也就是用XFS Quota来限制OverlayFS的upperdir目录,通过这个方式控制容器OverlayFS的根目录大小。
|
||||
|
||||
当我们理解了这个方法后,对于不是用Docker启动的容器,比如直接由containerd启动起来的容器,也可以自己实现XFS Quota限制upperdir目录。这样就能有效控制容器对OverlayFS的写数据操作,避免宿主机的磁盘被写满。
|
||||
|
||||
思考题
|
||||
|
||||
在正文知识详解的部分,我们使用”xfs_quota”给目录打了project ID并且限制了文件写入的数据量。那在做完这样的限制之后,我们是否能用xfs_quota命令,查询到被限制目录的project ID和限制的数据量呢?
|
||||
|
||||
欢迎你在留言区分享你的思考或疑问。如果这篇文章让你有所收获,也欢迎转发给你的同事、朋友,一起交流和学习。
|
||||
|
||||
|
||||
|
||||
|
200
专栏/容器实战高手课/14容器中的内存与IO:容器写文件的延时为什么波动很大?.md
Normal file
200
专栏/容器实战高手课/14容器中的内存与IO:容器写文件的延时为什么波动很大?.md
Normal file
@ -0,0 +1,200 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 容器中的内存与IO:容器写文件的延时为什么波动很大?
|
||||
你好,我是程远。这一讲,我们继续聊一聊容器中写文件性能波动的问题。
|
||||
|
||||
你应该还记得,我们[上一讲]中讲过Linux中的两种I/O模式,Direct I/O和Buffered I/O。
|
||||
|
||||
对于Linux的系统调用write()来说,Buffered I/O是缺省模式,使用起来比较方便,而且从用户角度看,在大多数的应用场景下,用Buffered I/O的write()函数调用返回要快一些。所以,Buffered I/O在程序中使用得更普遍一些。
|
||||
|
||||
当使用Buffered I/O的应用程序从虚拟机迁移到容器,这时我们就会发现多了Memory Cgroup的限制之后,write()写相同大小的数据块花费的时间,延时波动会比较大。
|
||||
|
||||
这是怎么回事呢?接下来我们就带着问题开始今天的学习。
|
||||
|
||||
问题再现
|
||||
|
||||
我们可以先动手写一个小程序,用来模拟刚刚说的现象。
|
||||
|
||||
这个小程序我们这样来设计:从一个文件中每次读取一个64KB大小的数据块,然后写到一个新文件中,它可以不断读写10GB大小的数据。同时我们在这个小程序中做个记录,记录写每个64KB的数据块需要花费的时间。
|
||||
|
||||
我们可以先在虚拟机里直接运行,虚拟机里内存大小是大于10GB的。接着,我们把这个程序放到容器中运行,因为这个程序本身并不需要很多的内存,我们给它做了一个Memory Cgroup的内存限制,设置为1GB。
|
||||
|
||||
运行结束后,我们比较一下程序写数据块的时间。我把结果画了一张图,图里的纵轴是时间,单位us;横轴是次数,在这里我们记录了96次。图中橘红色的线是在容器里运行的结果,蓝色的线是在虚拟机上运行的结果。
|
||||
|
||||
结果很明显,在容器中写入数据块的时间会时不时地增高到200us;而在虚拟机里的写入数据块时间就比较平稳,一直在30~50us这个范围内。
|
||||
|
||||
|
||||
|
||||
通过这个小程序,我们再现了问题,那我们就来分析一下,为什么会产生这样的结果。
|
||||
|
||||
时间波动是因为Dirty Pages的影响么?
|
||||
|
||||
我们对文件的写入操作是Buffered I/O。在前一讲中,我们其实已经知道了,对于Buffer I/O,用户的数据是先写入到Page Cache里的。而这些写入了数据的内存页面,在它们没有被写入到磁盘文件之前,就被叫作dirty pages。
|
||||
|
||||
Linux内核会有专门的内核线程(每个磁盘设备对应的kworker/flush 线程)把dirty pages写入到磁盘中。那我们自然会这样猜测,也许是Linux内核对dirty pages的操作影响了Buffered I/O的写操作?
|
||||
|
||||
想要验证这个想法,我们需要先来看看dirty pages是在什么时候被写入到磁盘的。这里就要用到/proc/sys/vm里和dirty page相关的内核参数了,我们需要知道所有相关参数的含义,才能判断出最后真正导致问题发生的原因。
|
||||
|
||||
现在我们挨个来看一下。为了方便后面的讲述,我们可以设定一个比值A,A等于dirty pages的内存/节点可用内存*100%。
|
||||
|
||||
第一个参数,dirty_background_ratio,这个参数里的数值是一个百分比值,缺省是10%。如果比值A大于dirty_background_ratio的话,比如大于默认的10%,内核flush线程就会把dirty pages刷到磁盘里。
|
||||
|
||||
第二个参数,是和dirty_background_ratio相对应一个参数,也就是dirty_background_bytes,它和dirty_background_ratio作用相同。区别只是dirty_background_bytes是具体的字节数,它用来定义的是dirty pages内存的临界值,而不是比例值。
|
||||
|
||||
这里你还要注意,dirty_background_ratio和 dirty_background_bytes只有一个可以起作用,如果你给其中一个赋值之后,另外一个参数就归0了。
|
||||
|
||||
接下来我们看第三个参数,dirty_ratio,这个参数的数值也是一个百分比值,缺省是20%。
|
||||
|
||||
如果比值A,大于参数dirty_ratio的值,比如大于默认设置的20%,这时候正在执行Buffered I/O写文件的进程就会被阻塞住,直到它写的数据页面都写到磁盘为止。
|
||||
|
||||
同样,第四个参数dirty_bytes与dirty_ratio相对应,它们的关系和dirty_background_ratio与dirty_background_bytes一样。我们给其中一个赋值后,另一个就会归零。
|
||||
|
||||
然后我们来看dirty_writeback_centisecs,这个参数的值是个时间值,以百分之一秒为单位,缺省值是500,也就是5秒钟。它表示每5秒钟会唤醒内核的flush线程来处理dirty pages。
|
||||
|
||||
最后还有dirty_expire_centisecs,这个参数的值也是一个时间值,以百分之一秒为单位,缺省值是3000,也就是30秒钟。它定义了dirty page在内存中存放的最长时间,如果一个dirty page超过这里定义的时间,那么内核的flush线程也会把这个页面写入磁盘。
|
||||
|
||||
好了,从这些dirty pages相关的参数定义,你会想到些什么呢?
|
||||
|
||||
进程写操作上的时间波动,只有可能是因为dirty pages的数量很多,已经达到了第三个参数dirty_ratio的值。这时执行写文件功能的进程就会被暂停,直到写文件的操作将数据页面写入磁盘,写文件的进程才能继续运行,所以进程里一次写文件数据块的操作时间会增加。
|
||||
|
||||
刚刚说的是我们的推理,那情况真的会是这样吗?其实我们可以在容器中进程不断写入数据的时候,查看节点上dirty pages的实时数目。具体操作如下:
|
||||
|
||||
watch -n 1 "cat /proc/vmstat | grep dirty"
|
||||
|
||||
|
||||
当我们的节点可用内存是12GB的时候,假设dirty_ratio是20%,dirty_background_ratio是10%,那么我们在1GB memory容器中写10GB的数据,就会看到它实时的dirty pages数目,也就是/ proc/vmstat里的nr_dirty的数值,这个数值对应的内存并不能达到dirty_ratio所占的内存值。
|
||||
|
||||
|
||||
|
||||
其实我们还可以再做个实验,就是在dirty_bytes和dirty_background_bytes里写入一个很小的值。
|
||||
|
||||
echo 8192 > /proc/sys/vm/dirty_bytes
|
||||
echo 4096 > /proc/sys/vm/dirty_background_bytes
|
||||
|
||||
|
||||
然后再记录一下容器程序里每写入64KB数据块的时间,这时候,我们就会看到,时不时一次写入的时间就会达到9ms,这已经远远高于我们之前看到的200us了。
|
||||
|
||||
因此,我们知道了这个时间的波动,并不是强制把dirty page写入到磁盘引起的。
|
||||
|
||||
调试问题
|
||||
|
||||
那接下来,我们还能怎么分析这个问题呢?
|
||||
|
||||
我们可以用perf和ftrace这两个工具,对容器里写数据块的进程做个profile,看看到底是调用哪个函数花费了比较长的时间。顺便说一下,我们在专题加餐里会专门介绍如何使用perf、ftrace等工具以及它们的工作原理,在这里你只要了解我们的调试思路就行。
|
||||
|
||||
怎么使用这两个工具去定位耗时高的函数呢?我大致思路是这样的:我们发现容器中的进程用到了write()这个函数调用,然后写64KB数据块的时间增加了,而write()是一个系统调用,那我们需要进行下面这两步操作。
|
||||
|
||||
第一步,我们要找到内核中write()这个系统调用函数下,又调用了哪些子函数。想找出主要的子函数我们可以查看代码,也可以用perf这个工具来得到。
|
||||
|
||||
然后是第二步,得到了write()的主要子函数之后,我们可以用ftrace这个工具来trace这些函数的执行时间,这样就可以找到花费时间最长的函数了。
|
||||
|
||||
好,下面我们就按照刚才梳理的思路来做一下。首先是第一步,我们在容器启动写磁盘的进程后,在宿主机上得到这个进程的pid,然后运行下面的perf命令。
|
||||
|
||||
perf record -a -g -p <pid>
|
||||
|
||||
|
||||
等写磁盘的进程退出之后,这个perf record也就停止了。
|
||||
|
||||
这时我们再执行 perf report 查看结果。把vfs_write()函数展开之后,我们就可以看到,write()这个系统调用下面的调用到了哪些主要的子函数,到这里第一步就完成了。
|
||||
|
||||
|
||||
|
||||
下面再来做第二步,我们把主要的函数写入到ftrace的set_ftrace_filter里,然后把ftrace的tracer设置为function_graph,并且打开tracing_on开启追踪。
|
||||
|
||||
# cd /sys/kernel/debug/tracing
|
||||
# echo vfs_write >> set_ftrace_filter
|
||||
# echo xfs_file_write_iter >> set_ftrace_filter
|
||||
# echo xfs_file_buffered_aio_write >> set_ftrace_filter
|
||||
# echo iomap_file_buffered_write
|
||||
# echo iomap_file_buffered_write >> set_ftrace_filter
|
||||
# echo pagecache_get_page >> set_ftrace_filter
|
||||
# echo try_to_free_mem_cgroup_pages >> set_ftrace_filter
|
||||
# echo try_charge >> set_ftrace_filter
|
||||
# echo mem_cgroup_try_charge >> set_ftrace_filter
|
||||
|
||||
# echo function_graph > current_tracer
|
||||
# echo 1 > tracing_on
|
||||
|
||||
|
||||
这些设置完成之后,我们再运行一下容器中的写磁盘程序,同时从ftrace的trace_pipe中读取出追踪到的这些函数。
|
||||
|
||||
这时我们可以看到,当需要申请Page Cache页面的时候,write()系统调用会反复地调用mem_cgroup_try_charge(),并且在释放页面的时候,函数do_try_to_free_pages()花费的时间特别长,有50+us(时间单位,micro-seconds)这么多。
|
||||
|
||||
1) | vfs_write() {
|
||||
1) | xfs_file_write_iter [xfs]() {
|
||||
1) | xfs_file_buffered_aio_write [xfs]() {
|
||||
1) | iomap_file_buffered_write() {
|
||||
1) | pagecache_get_page() {
|
||||
1) | mem_cgroup_try_charge() {
|
||||
1) 0.338 us | try_charge();
|
||||
1) 0.791 us | }
|
||||
1) 4.127 us | }
|
||||
…
|
||||
|
||||
1) | pagecache_get_page() {
|
||||
1) | mem_cgroup_try_charge() {
|
||||
1) | try_charge() {
|
||||
1) | try_to_free_mem_cgroup_pages() {
|
||||
1) + 52.798 us | do_try_to_free_pages();
|
||||
1) + 53.958 us | }
|
||||
1) + 54.751 us | }
|
||||
1) + 55.188 us | }
|
||||
1) + 56.742 us | }
|
||||
…
|
||||
1) ! 109.925 us | }
|
||||
1) ! 110.558 us | }
|
||||
1) ! 110.984 us | }
|
||||
1) ! 111.515 us | }
|
||||
|
||||
|
||||
看到这个ftrace的结果,你是不是会想到,我们在容器内存[那一讲]中提到的Page Cahe呢?
|
||||
|
||||
是的,这个问题的确和Page Cache有关,Linux会把所有的空闲内存利用起来,一旦有Buffered I/O,这些内存都会被用作Page Cache。
|
||||
|
||||
当容器加了Memory Cgroup限制了内存之后,对于容器里的Buffered I/O,就只能使用容器中允许使用的最大内存来做Page Cache。
|
||||
|
||||
那么如果容器在做内存限制的时候,Cgroup中memory.limit_in_bytes设置得比较小,而容器中的进程又有很大量的I/O,这样申请新的Page Cache内存的时候,又会不断释放老的内存页面,这些操作就会带来额外的系统开销了。
|
||||
|
||||
重点总结
|
||||
|
||||
我们今天讨论的问题是在容器中用Buffered I/O方式写文件的时候,会出现写入时间波动的问题。
|
||||
|
||||
由于这是Buffered I/O方式,对于写入文件会先写到内存里,这样就产生了dirty pages,所以我们先研究了一下Linux对dirty pages的回收机制是否会影响到容器中写入数据的波动。
|
||||
|
||||
在这里我们最主要的是理解这两个参数,dirty_background_ratio 和 dirty_ratio,这两个值都是相对于节点可用内存的百分比值。
|
||||
|
||||
当dirty pages数量超过dirty_background_ratio对应的内存量的时候,内核flush线程就会开始把dirty pages写入磁盘; 当dirty pages数量超过dirty_ratio对应的内存量,这时候程序写文件的函数调用write()就会被阻塞住,直到这次调用的dirty pages全部写入到磁盘。
|
||||
|
||||
在节点是大内存容量,并且dirty_ratio为系统缺省值20%,dirty_background_ratio是系统缺省值10%的情况下,我们通过观察 /proc/vmstat中的nr_dirty数值可以发现,dirty pages不会阻塞进程的Buffered I/O写文件操作。
|
||||
|
||||
所以我们做了另一种尝试,使用perf和ftrace工具对容器中的写文件进程进行profile。我们用perf得到了系统调用write()在内核中的一系列子函数调用,再用ftrace来查看这些子函数的调用时间。
|
||||
|
||||
根据ftrace的结果,我们发现写数据到Page Cache的时候,需要不断地去释放原有的页面,这个时间开销是最大的。造成容器中Buffered I/O write()不稳定的原因,正是容器在限制内存之后,Page Cache的数量较小并且不断申请释放。
|
||||
|
||||
其实这个问题也提醒了我们:在对容器做Memory Cgroup限制内存大小的时候,不仅要考虑容器中进程实际使用的内存量,还要考虑容器中程序I/O的量,合理预留足够的内存作为Buffered I/O 的Page Cache。
|
||||
|
||||
比如,如果知道需要反复读写文件的大小,并且在内存足够的情况下,那么Memory Cgroup的内存限制可以超过这个文件的大小。
|
||||
|
||||
还有一个解决思路是,我们在程序中自己管理文件的cache并且调用Direct I/O来读写文件,这样才会对应用程序的性能有一个更好的预期。
|
||||
|
||||
思考题
|
||||
|
||||
我们对 dirty_bytes 和 dirty_background_bytes做下面的设置:
|
||||
|
||||
-bash-4.2# echo 8192 > /proc/sys/vm/dirty_bytes
|
||||
-bash-4.2# echo 4096 > /proc/sys/vm/dirty_background_bytes
|
||||
|
||||
|
||||
然后再运行下面的fio测试,得到的结果和缺省dirty_*配置的时候会有差别吗?
|
||||
|
||||
# fio -direct=1 -iodepth=64 -rw=write -ioengine=libaio -bs=4k -size=10G -numjobs=1 -name=./fio.test
|
||||
|
||||
|
||||
欢迎你在留言区提出你的思考或是疑问。如果这篇文章对你有帮助的话,也欢迎你分享给你的朋友、同事,一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
312
专栏/容器实战高手课/15容器网络:我修改了_proc_sys_net下的参数,为什么在容器中不起效?.md
Normal file
312
专栏/容器实战高手课/15容器网络:我修改了_proc_sys_net下的参数,为什么在容器中不起效?.md
Normal file
@ -0,0 +1,312 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 容器网络:我修改了_proc_sys_net下的参数,为什么在容器中不起效?
|
||||
你好,我是程远。
|
||||
|
||||
从这一讲开始,我们进入到了容器网络这个模块。容器网络最明显的一个特征就是它有自己的Network Namespace了。你还记得,在我们这个课程的[第一讲]里,我们就提到过Network Namespace负责管理网络环境的隔离。
|
||||
|
||||
今天呢,我们更深入地讨论一下和Network Namespace相关的一个问题——容器中的网络参数。
|
||||
|
||||
和之前的思路一样,我们先来看一个问题。然后在解决问题的过程中,更深入地理解容器的网络参数配置。
|
||||
|
||||
问题再现
|
||||
|
||||
在容器中运行的应用程序,如果需要用到tcp/ip协议栈的话,常常需要修改一些网络参数(内核中网络协议栈的参数)。
|
||||
|
||||
很大一部分网络参数都在/proc文件系统下的/proc/sys/net/目录里。
|
||||
|
||||
修改这些参数主要有两种方法:一种方法是直接到/proc文件系统下的”/proc/sys/net/“目录里对参数做修改;还有一种方法是使用sysctl这个工具来修改。
|
||||
|
||||
在启动容器之前呢,根据我们的需要我们在宿主机上已经修改过了几个参数,也就是说这些参数的值已经不是内核里原来的缺省值了.
|
||||
|
||||
比如我们改了下面的几个参数:
|
||||
|
||||
# # The default value:
|
||||
# cat /proc/sys/net/ipv4/tcp_congestion_control
|
||||
cubic
|
||||
# cat /proc/sys/net/ipv4/tcp_keepalive_time
|
||||
7200
|
||||
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
|
||||
75
|
||||
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
|
||||
9
|
||||
|
||||
# # To update the value:
|
||||
# echo bbr > /proc/sys/net/ipv4/tcp_congestion_control
|
||||
# echo 600 > /proc/sys/net/ipv4/tcp_keepalive_time
|
||||
# echo 10 > /proc/sys/net/ipv4/tcp_keepalive_intvl
|
||||
# echo 6 > /proc/sys/net/ipv4/tcp_keepalive_probes
|
||||
#
|
||||
|
||||
# # Double check the value after update:
|
||||
# cat /proc/sys/net/ipv4/tcp_congestion_control
|
||||
bbr
|
||||
# cat /proc/sys/net/ipv4/tcp_keepalive_time
|
||||
600
|
||||
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
|
||||
10
|
||||
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
|
||||
6
|
||||
|
||||
|
||||
然后我们启动一个容器, 再来查看一下容器里这些参数的值。
|
||||
|
||||
你可以先想想,容器里这些参数的值会是什么?我最初觉得容器里参数值应该会继承宿主机Network Namesapce里的值,实际上是不是这样呢?
|
||||
|
||||
我们还是先按下面的脚本,启动容器,然后运行 docker exec 命令一起看一下:
|
||||
|
||||
# docker run -d --name net_para centos:8.1.1911 sleep 3600
|
||||
deec6082bac7b336fa28d0f87d20e1af21a784e4ef11addfc2b9146a9fa77e95
|
||||
# docker exec -it net_para bash
|
||||
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_congestion_control
|
||||
bbr
|
||||
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_keepalive_time
|
||||
7200
|
||||
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
|
||||
75
|
||||
[root@deec6082bac7 /]# cat /proc/sys/net/ipv4/tcp_keepalive_probes
|
||||
9
|
||||
|
||||
|
||||
从这个结果我们看到,tcp_congestion_control的值是bbr,和宿主机Network Namespace里的值是一样的,而其他三个tcp keepalive相关的值,都不是宿主机Network Namespace里设置的值,而是原来系统里的缺省值了。
|
||||
|
||||
那为什么会这样呢?在分析这个问题之前,我们需要先来看看Network Namespace这个概念。
|
||||
|
||||
知识详解
|
||||
|
||||
如何理解Network Namespace?
|
||||
|
||||
对于Network Namespace,我们从字面上去理解的话,可以知道它是在一台Linux节点上对网络的隔离,不过它具体到底隔离了哪部分的网络资源呢?
|
||||
|
||||
我们还是先来看看操作手册,在Linux Programmer’s Manual里对Network Namespace有一个段简短的描述,在里面就列出了最主要的几部分资源,它们都是通过Network Namespace隔离的。
|
||||
|
||||
我把这些资源给你做了一个梳理:
|
||||
|
||||
第一种,网络设备,这里指的是lo,eth0等网络设备。你可以通过 ip link命令看到它们。
|
||||
|
||||
第二种是IPv4和IPv6协议栈。从这里我们可以知道,IP层以及上面的TCP和UDP协议栈也是每个Namespace独立工作的。
|
||||
|
||||
所以IP、TCP、UDP的很多协议,它们的相关参数也是每个Namespace独立的,这些参数大多数都在 /proc/sys/net/目录下面,同时也包括了TCP和UDP的port资源。
|
||||
|
||||
第三种,IP路由表,这个资源也是比较好理解的,你可以在不同的Network Namespace运行 ip route 命令,就能看到不同的路由表了。
|
||||
|
||||
第四种是防火墙规则,其实这里说的就是iptables规则了,每个Namespace里都可以独立配置iptables规则。
|
||||
|
||||
最后一种是网络的状态信息,这些信息你可以从/proc/net 和/sys/class/net里得到,这里的状态基本上包括了前面4种资源的的状态信息。
|
||||
|
||||
Namespace的操作
|
||||
|
||||
那我们怎么建立一个新的Network Namespace呢?
|
||||
|
||||
我们可以通过系统调用clone()或者unshare()这两个函数来建立新的Network Namespace。
|
||||
|
||||
下面我们会讲两个例子,带你体会一下这两个方法具体怎么用。
|
||||
|
||||
第一种方法呢,是在新的进程创建的时候,伴随新进程建立,同时也建立出新的Network Namespace。这个方法,其实就是通过clone()系统调用带上CLONE_NEWNET flag来实现的。
|
||||
|
||||
Clone建立出来一个新的进程,这个新的进程所在的Network Namespace也是新的。然后我们执行 ip link 命令查看Namespace里的网络设备,就可以确认一个新的Network Namespace已经建立好了。
|
||||
|
||||
具体操作你可以看一下这段代码。
|
||||
|
||||
int new_netns(void *para)
|
||||
{
|
||||
printf("New Namespace Devices:\n");
|
||||
system("ip link");
|
||||
printf("\n\n");
|
||||
|
||||
sleep(100);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
pid_t pid;
|
||||
|
||||
printf("Host Namespace Devices:\n");
|
||||
system("ip link");
|
||||
printf("\n\n");
|
||||
|
||||
pid =
|
||||
clone(new_netns, stack + STACK_SIZE, CLONE_NEWNET | SIGCHLD, NULL);
|
||||
if (pid == -1)
|
||||
errExit("clone");
|
||||
|
||||
if (waitpid(pid, NULL, 0) == -1)
|
||||
errExit("waitpid");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
第二种方法呢,就是调用unshare()这个系统调用来直接改变当前进程的Network Namespace,你可以看一下这段代码。
|
||||
|
||||
int main(void)
|
||||
{
|
||||
pid_t pid;
|
||||
|
||||
printf("Host Namespace Devices:\n");
|
||||
system("ip link");
|
||||
printf("\n\n");
|
||||
|
||||
if (unshare(CLONE_NEWNET) == -1)
|
||||
errExit("unshare");
|
||||
|
||||
printf("New Namespace Devices:\n");
|
||||
system("ip link");
|
||||
printf("\n\n");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
其实呢,不仅是Network Namespace,其它的Namespace也是通过clone()或者unshare()系统调用来建立的。
|
||||
|
||||
而创建容器的程序,比如runC也是用unshare()给新建的容器建立Namespace的。
|
||||
|
||||
这里我简单地说一下runC是什么,我们用Docker或者containerd去启动容器,最后都会调用runC在Linux中把容器启动起来。
|
||||
|
||||
除了在代码中用系统调用来建立Network Namespace,我们也可以用命令行工具来建立Network Namespace。比如用 ip netns 命令,在下一讲学习容器网络配置的时候呢,我们会用到 ip netns,这里你先有个印象就行。
|
||||
|
||||
在Network Namespace 创建好了之后呢,我们可以在宿主机上运行 lsns -t net 这个命令来查看系统里已有的Network Namespace。当然,lsns也可以用来查看其它Namespace。
|
||||
|
||||
用 lsns 查看已有的Namespace后,我们还可以用 nsenter 这个命令进入到某个Network Namespace里,具体去查看这个Namespace里的网络配置。
|
||||
|
||||
比如下面的这个例子,用我们之前的clone()的例子里的代码,编译出clone-ns这个程序,运行后,再使用 lsns 查看新建的Network Namespace,并且用nsenter进入到这个Namespace,查看里面的lo device。
|
||||
|
||||
具体操作你可以参考下面的代码:
|
||||
|
||||
# ./clone-ns &
|
||||
[1] 7732
|
||||
# Host Namespace Devices:
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
|
||||
link/ether 74:db:d1:80:54:14 brd ff:ff:ff:ff:ff:ff
|
||||
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default
|
||||
link/ether 02:42:0c:ff:2b:77 brd ff:ff:ff:ff:ff:ff
|
||||
|
||||
|
||||
New Namespace Devices:
|
||||
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
|
||||
# lsns -t net
|
||||
NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
|
||||
4026531992 net 283 1 root unassigned /usr/lib/systemd/systemd --switched-root --system --deserialize 16
|
||||
4026532241 net 1 7734 root unassigned ./clone-ns
|
||||
# nsenter -t 7734 -n ip addr
|
||||
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
|
||||
|
||||
解决问题
|
||||
|
||||
那理解了Network Namespace之后,我们再来看看这一讲最开始的问题,我们应该怎么来设置容器里的网络相关参数呢?
|
||||
|
||||
首先你要避免走入误区。从我们一开始的例子里,也可以看到,容器里Network Namespace的网络参数并不是完全从宿主机Host Namespace里继承的,也不是完全在新的Network Namespace建立的时候重新初始化的。
|
||||
|
||||
其实呢,这一点我们只要看一下内核代码中对协议栈的初始化函数,很快就可以知道为什么会有这样的情况。
|
||||
|
||||
在我们的例子里tcp_congestion_control的值是从Host Namespace里继承的,而tcp_keepalive相关的几个值会被重新初始化了。
|
||||
|
||||
在函数tcp_sk_init()里,tcp_keepalive的三个参数都是重新初始化的,而tcp_congestion_control 的值是从Host Namespace里复制过来的。
|
||||
|
||||
static int __net_init tcp_sk_init(struct net *net)
|
||||
{
|
||||
…
|
||||
net->ipv4.sysctl_tcp_keepalive_time = TCP_KEEPALIVE_TIME;
|
||||
net->ipv4.sysctl_tcp_keepalive_probes = TCP_KEEPALIVE_PROBES;
|
||||
net->ipv4.sysctl_tcp_keepalive_intvl = TCP_KEEPALIVE_INTVL;
|
||||
|
||||
…
|
||||
/* Reno is always built in */
|
||||
if (!net_eq(net, &init_net) &&
|
||||
try_module_get(init_net.ipv4.tcp_congestion_control->owner))
|
||||
net->ipv4.tcp_congestion_control = init_net.ipv4.tcp_congestion_control;
|
||||
else
|
||||
net->ipv4.tcp_congestion_control = &tcp_reno;
|
||||
|
||||
…
|
||||
|
||||
}
|
||||
|
||||
|
||||
那么我们现在知道Network Namespace的网络参数是怎么初始化的了,你可能会问了,我在容器里也可以修改这些参数吗?
|
||||
|
||||
我们可以启动一个普通的容器,这里的“普通”呢,我指的不是”privileged”的那种容器,也就是在这个容器中,有很多操作都是不允许做的,比如mount一个文件系统。这个privileged容器概念,我们会在后面容器安全这一讲里详细展开,这里你有个印象。
|
||||
|
||||
那么在启动完一个普通容器后,我们尝试一下在容器里去修改”/proc/sys/net/“下的参数。
|
||||
|
||||
这时候你会看到,容器中”/proc/sys/“是只读mount的,那么在容器里是不能修改”/proc/sys/net/“下面的任何参数了。
|
||||
|
||||
# docker run -d --name net_para centos:8.1.1911 sleep 3600
|
||||
977bf3f07da90422e9c1e89e56edf7a59fab5edff26317eeb253700c2fa657f7
|
||||
# docker exec -it net_para bash
|
||||
[root@977bf3f07da9 /]# echo 600 > /proc/sys/net/ipv4/tcp_keepalive_time
|
||||
bash: /proc/sys/net/ipv4/tcp_keepalive_time: Read-only file system
|
||||
[root@977bf3f07da9 /]# cat /proc/mounts | grep "proc/sys"
|
||||
proc /proc/sys proc ro,relatime 0 0
|
||||
|
||||
|
||||
为什么“/proc/sys/” 在容器里是只读mount呢? 这是因为runC当初出于安全的考虑,把容器中所有/proc和/sys相关的目录缺省都做了read-only mount的处理。详细的说明你可以去看看这两个commits:
|
||||
|
||||
|
||||
Mount /proc and /sys read-only, except in privileged containers
|
||||
Make /proc writable, but not /proc/sys and /proc/sysrq-trigger
|
||||
|
||||
|
||||
那我们应该怎么来修改容器中Network Namespace的网络参数呢?
|
||||
|
||||
当然,如果你有宿主机上的root权限,最简单粗暴的方法就是用我们之前说的”nsenter”工具,用它修改容器里的网络参数的。不过这个方法在生产环境里显然是不会被允许的,因为我们不会允许用户拥有宿主机的登陆权限。
|
||||
|
||||
其次呢,一般来说在容器中的应用已经启动了之后,才会做这样的修改。也就是说,很多tcp链接已经建立好了,那么即使新改了参数,对已经建立好的链接也不会生效了。这就需要重启应用,我们都知道生产环境里通常要避免应用重启,那这样做显然也不合适。
|
||||
|
||||
通过刚刚的排除法,我们推理出了网络参数修改的“正确时机”:想修改Network Namespace里的网络参数,要选择容器刚刚启动,而容器中的应用程序还没启动之前进行。
|
||||
|
||||
其实,runC也在对/proc/sys目录做read-only mount之前,预留出了修改接口,就是用来修改容器里 “/proc/sys”下参数的,同样也是sysctl的参数。
|
||||
|
||||
而Docker的–sysctl或者Kubernetes里的allowed-unsafe-sysctls特性也都利用了runC的sysctl参数修改接口,允许容器在启动时修改容器Namespace里的参数。
|
||||
|
||||
比如,我们可以试一下docker –sysctl,这时候我们会发现,在容器的Network Namespace里,/proc/sys/net/ipv4/tcp_keepalive_time这个网络参数终于被修改了!
|
||||
|
||||
# docker run -d --name net_para --sysctl net.ipv4.tcp_keepalive_time=600 centos:8.1.1911 sleep 3600
|
||||
7efed88a44d64400ff5a6d38fdcc73f2a74a7bdc3dbc7161060f2f7d0be170d1
|
||||
# docker exec net_para cat /proc/sys/net/ipv4/tcp_keepalive_time
|
||||
600
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
好了,今天的课我们讲完了,那么下面我来给你做个总结。
|
||||
|
||||
今天我们讨论问题是容器中网络参数的问题,因为是问题发生在容器里,又是网络的参数,那么自然就和Network Namespace有关,所以我们首先要理解Network Namespace。
|
||||
|
||||
Network Namespace可以隔离网络设备,ip协议栈,ip路由表,防火墙规则,以及可以显示独立的网络状态信息。
|
||||
|
||||
我们可以通过clone()或者unshare()系统调用来建立新的Network Namespace。
|
||||
|
||||
此外,还有一些工具”ip”“netns”“unshare”“lsns”和”nsenter”,也可以用来操作Network Namespace。
|
||||
|
||||
这些工具的适用条件,我用表格的形式整理如下,你可以做个参考。
|
||||
|
||||
|
||||
接着我们分析了如何修改普通容器(非privileged)的网络参数。
|
||||
|
||||
由于安全的原因,普通容器的/proc/sys是read-only mount的,所以在容器启动以后,我们无法在容器内部修改/proc/sys/net下网络相关的参数。
|
||||
|
||||
这时可行的方法是通过runC sysctl相关的接口,在容器启动的时候对容器内的网络参数做配置。
|
||||
|
||||
这样一来,想要修改网络参数就可以这么做:如果是使用Docker,我们可以加上”—sysctl”这个参数;而如果使用Kubernetes的话,就需要用到”allowed unsaft sysctl”这个特性了。
|
||||
|
||||
思考题
|
||||
|
||||
这一讲中,我们提到了可以使用”nsenter”这个工具,从宿主机上修改容器里的/proc/sys/net/下的网络参数,你可以试试看具体怎么修改。
|
||||
|
||||
欢迎你在留言区分享你的收获和疑问。如果这篇文章对你有帮助,也欢迎转发给你的同事和朋友,一起交流探讨。
|
||||
|
||||
|
||||
|
||||
|
301
专栏/容器实战高手课/16容器网络配置(1):容器网络不通了要怎么调试.md
Normal file
301
专栏/容器实战高手课/16容器网络配置(1):容器网络不通了要怎么调试.md
Normal file
@ -0,0 +1,301 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 容器网络配置(1):容器网络不通了要怎么调试
|
||||
你好,我是程远。
|
||||
|
||||
在上一讲,我们讲了Network Namespace隔离了网络设备,IP协议栈和路由表,以及防火墙规则,那容器Network Namespace里的参数怎么去配置,我们现在已经很清楚了。
|
||||
|
||||
其实对于网络配置的问题,我们还有一个最需要关心的内容,那就是容器和外面的容器或者节点是怎么通讯的,这就涉及到了容器网络接口配置的问题了。
|
||||
|
||||
所以这一讲呢,我们就来聊一聊,容器Network Namespace里如何配置网络接口,还有当容器网络不通的时候,我们应该怎么去做一个简单调试。
|
||||
|
||||
问题再现
|
||||
|
||||
在前面的课程里,我们一直是用 docker run 这个命令来启动容器的。容器启动了之后,我们也可以看到,在容器里面有一个”eth0”的网络接口,接口上也配置了一个IP地址。
|
||||
|
||||
不过呢,如果我们想从容器里访问外面的一个IP地址,比如说39.106.233.176(这个是极客时间网址对应的IP),结果就发现是不能ping通的。
|
||||
|
||||
这时我们可能会想到,到底是不是容器内出了问题,在容器里无法访问,会不会宿主机也一样不行呢?
|
||||
|
||||
所以我们需要验证一下,首先我们退出容器,然后在宿主机的Network Namespace下,再运行 ping 39.106.233.176,结果就会发现在宿主机上,却是可以连通这个地址的。
|
||||
|
||||
# docker run -d --name if-test centos:8.1.1911 sleep 36000
|
||||
244d44f94dc2931626194c6fd3f99cec7b7c4bf61aafc6c702551e2c5ca2a371
|
||||
# docker exec -it if-test bash
|
||||
|
||||
[root@244d44f94dc2 /]# ip addr
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
inet 127.0.0.1/8 scope host lo
|
||||
valid_lft forever preferred_lft forever
|
||||
808: eth0@if809: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
|
||||
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
|
||||
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
[root@244d44f94dc2 /]# ping 39.106.233.176 ### 容器中无法ping通
|
||||
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
|
||||
^C
|
||||
--- 39.106.233.176 ping statistics ---
|
||||
9 packets transmitted, 0 received, 100% packet loss, time 185ms
|
||||
|
||||
[root@244d44f94dc2 /]# exit ###退出容器
|
||||
exit
|
||||
|
||||
# ping 39.106.233.176 ### 宿主机上可以ping通
|
||||
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
|
||||
64 bytes from 39.106.233.176: icmp_seq=1 ttl=78 time=296 ms
|
||||
64 bytes from 39.106.233.176: icmp_seq=2 ttl=78 time=306 ms
|
||||
64 bytes from 39.106.233.176: icmp_seq=3 ttl=78 time=303 ms
|
||||
^C
|
||||
--- 39.106.233.176 ping statistics ---
|
||||
4 packets transmitted, 3 received, 25% packet loss, time 7ms
|
||||
rtt min/avg/max/mdev = 296.059/301.449/305.580/4.037 ms
|
||||
|
||||
|
||||
那么碰到这种容器内网络不通的问题,我们应该怎么分析调试呢?我们还是需要先来理解一下,容器Network Namespace里的网络接口是怎么配置的。
|
||||
|
||||
基本概念
|
||||
|
||||
在讲解容器的网络接口配置之前,我们需要先建立一个整体的认识,搞清楚容器网络接口在系统架构中处于哪个位置。
|
||||
|
||||
你可以看一下我给你画的这张图,图里展示的是容器有自己的Network Namespace,eth0 是这个Network Namespace里的网络接口。而宿主机上也有自己的eth0,宿主机上的eth0对应着真正的物理网卡,可以和外面通讯。
|
||||
|
||||
|
||||
|
||||
那你可以先想想,我们要让容器Network Namespace中的数据包最终发送到物理网卡上,需要完成哪些步骤呢?从图上看,我们大致可以知道应该包括这两步。
|
||||
|
||||
第一步,就是要让数据包从容器的Network Namespace发送到Host Network Namespace上。
|
||||
|
||||
第二步,数据包发到了Host Network Namespace之后,还要解决数据包怎么从宿主机上的eth0发送出去的问题。
|
||||
|
||||
好,整体的思路已经理清楚了,接下来我们做具体分析。我们先来看第一步,怎么让数据包从容器的Network Namespace发送到Host Network Namespace上面。
|
||||
|
||||
你可以查看一下Docker 网络的文档或者Kubernetes网络的文档,这些文档里面介绍了很多种容器网络配置的方式。
|
||||
|
||||
不过对于容器从自己的Network Namespace连接到Host Network Namespace的方法,一般来说就只有两类设备接口:一类是veth,另外一类是macvlan/ipvlan。
|
||||
|
||||
在这些方法中,我们使用最多的就是veth的方式,用Docker启动的容器缺省的网络接口用的也是这个veth。既然它这么常见,所以我们就用veth作为例子来详细讲解。至于另外一类macvlan/ipvlan的方式,我们在下一讲里会讲到。
|
||||
|
||||
那什么是veth呢?为了方便你更好地理解,我们先来模拟一下Docker为容器建立eth0网络接口的过程,动手操作一下,这样呢,你就可以很快明白什么是veth了。
|
||||
|
||||
对于这个模拟操作呢,我们主要用到的是ip netns 这个命令,通过它来对Network Namespace做操作。
|
||||
|
||||
首先,我们先启动一个不带网络配置的容器,和我们之前的命令比较,主要是多加上了”–network none”参数。我们可以看到,这样在启动的容器中,Network Namespace里就只有loopback一个网络设备,而没有了eth0网络设备了。
|
||||
|
||||
# docker run -d --name if-test --network none centos:8.1.1911 sleep 36000
|
||||
cf3d3105b11512658a025f5b401a09c888ed3495205f31e0a0d78a2036729472
|
||||
# docker exec -it if-test ip addr
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
inet 127.0.0.1/8 scope host lo
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
|
||||
完成刚才的设置以后,我们就在这个容器的Network Namespace里建立veth,你可以执行一下后面的这个脚本。
|
||||
|
||||
pid=$(ps -ef | grep "sleep 36000" | grep -v grep | awk '{print $2}')
|
||||
echo $pid
|
||||
ln -s /proc/$pid/ns/net /var/run/netns/$pid
|
||||
|
||||
# Create a pair of veth interfaces
|
||||
ip link add name veth_host type veth peer name veth_container
|
||||
# Put one of them in the new net ns
|
||||
ip link set veth_container netns $pid
|
||||
|
||||
# In the container, setup veth_container
|
||||
ip netns exec $pid ip link set veth_container name eth0
|
||||
ip netns exec $pid ip addr add 172.17.1.2/16 dev eth0
|
||||
ip netns exec $pid ip link set eth0 up
|
||||
ip netns exec $pid ip route add default via 172.17.0.1
|
||||
|
||||
# In the host, set veth_host up
|
||||
ip link set veth_host up
|
||||
|
||||
|
||||
我在这里解释一下,这个veth的建立过程是什么样的。
|
||||
|
||||
首先呢,我们先找到这个容器里运行的进程”sleep 36000”的pid,通过 “/proc/$pid/ns/net”这个文件得到Network Namespace的ID,这个Network Namespace ID既是这个进程的,也同时属于这个容器。
|
||||
|
||||
然后我们在”/var/run/netns/“的目录下建立一个符号链接,指向这个容器的Network Namespace。完成这步操作之后,在后面的”ip netns”操作里,就可以用pid的值作为这个容器的Network Namesapce的标识了。
|
||||
|
||||
接下来呢,我们用 ip link 命令来建立一对veth的虚拟设备接口,分别是veth_container和veth_host。从名字就可以看出来,veth_container这个接口会被放在容器Network Namespace里,而veth_host会放在宿主机的Host Network Namespace。
|
||||
|
||||
所以我们后面的命令也很好理解了,就是用 ip link set veth_container netns $pid 把veth_container这个接口放入到容器的Network Namespace中。
|
||||
|
||||
再然后我们要把veth_container重新命名为eth0,因为这时候接口已经在容器的Network Namesapce里了,eth0就不会和宿主机上的eth0冲突了。
|
||||
|
||||
最后对容器内的eht0,我们还要做基本的网络IP和缺省路由配置。因为veth_host已经在宿主机的Host Network Namespace了,就不需要我们做什么了,这时我们只需要up一下这个接口就可以了。
|
||||
|
||||
那刚才这些操作完成以后,我们就建立了一对veth虚拟设备接口。我给你画了一张示意图,图里直观展示了这对接口在容器和宿主机上的位置。
|
||||
|
||||
|
||||
|
||||
现在,我们再来看看veth的定义了,其实它也很简单。veth就是一个虚拟的网络设备,一般都是成对创建,而且这对设备是相互连接的。当每个设备在不同的Network Namespaces的时候,Namespace之间就可以用这对veth设备来进行网络通讯了。
|
||||
|
||||
比如说,你可以执行下面的这段代码,试试在veth_host上加上一个IP,172.17.1.1/16,然后从容器里就可以ping通这个IP了。这也证明了从容器到宿主机可以利用这对veth接口来通讯了。
|
||||
|
||||
# ip addr add 172.17.1.1/16 dev veth_host
|
||||
# docker exec -it if-test ping 172.17.1.1
|
||||
PING 172.17.1.1 (172.17.1.1) 56(84) bytes of data.
|
||||
64 bytes from 172.17.1.1: icmp_seq=1 ttl=64 time=0.073 ms
|
||||
64 bytes from 172.17.1.1: icmp_seq=2 ttl=64 time=0.092 ms
|
||||
^C
|
||||
--- 172.17.1.1 ping statistics ---
|
||||
2 packets transmitted, 2 received, 0% packet loss, time 30ms
|
||||
rtt min/avg/max/mdev = 0.073/0.082/0.092/0.013 ms
|
||||
|
||||
|
||||
好了,这样我们完成了第一步,通过一对veth虚拟设备,可以让数据包从容器的 Network Namespace发送到Host Network Namespace上。
|
||||
|
||||
那下面我们再来看第二步, 数据包到了Host Network Namespace之后呢,怎么把它从宿主机上的eth0发送出去?
|
||||
|
||||
其实这一步呢,就是一个普通Linux节点上数据包转发的问题了。这里我们解决问题的方法有很多种,比如说用nat来做个转发,或者建立Overlay网络发送,也可以通过配置proxy arp加路由的方法来实现。
|
||||
|
||||
因为考虑到网络环境的配置,同时Docker缺省使用的是 bridge + nat的转发方式, 那我们就在刚才讲的第一步基础上,再手动实现一下bridge+nat的转发方式。对于其他的配置方法,你可以看一下Docker或者Kubernetes相关的文档。
|
||||
|
||||
Docker程序在节点上安装完之后,就会自动建立了一个docker0的bridge interface。所以我们只需要把第一步中建立的veth_host这个设备,接入到docker0这个bridge上。
|
||||
|
||||
这里我要提醒你注意一下,如果之前你在veth_host上设置了IP的,就需先运行一下”ip addr delete 172.17.1.1⁄16 dev veth_host”,把IP从veth_host上删除。
|
||||
|
||||
# ip addr delete 172.17.1.1/16 dev veth_host
|
||||
ip link set veth_host master docker0
|
||||
|
||||
|
||||
这个命令执行完之后,容器和宿主机的网络配置就会发生变化,这种配置是什么样呢?你可以参考一下面这张图的描述。
|
||||
|
||||
|
||||
|
||||
从这张示意图中,我们可以看出来,容器和docker0组成了一个子网,docker0上的IP就是这个子网的网关IP。
|
||||
|
||||
如果我们要让子网通过宿主机上eth0去访问外网的话,那么加上iptables的规则就可以了,也就是下面这条规则。
|
||||
|
||||
iptables -P FORWARD ACCEPT
|
||||
|
||||
|
||||
好了,进行到这里,我们通过bridge+nat的配置,似乎已经完成了第二步——让数据从宿主机的eth0发送出去。
|
||||
|
||||
那么我们这样配置,真的可以让容器里发送数据包到外网了吗?这需要我们做个测试,再重新尝试下这一讲开始的操作,从容器里ping外网的IP,这时候,你会发现还是ping不通。
|
||||
|
||||
其实呢,做到这一步,我们通过自己的逐步操作呢,重现了这一讲了最开始的问题。
|
||||
|
||||
解决问题
|
||||
|
||||
既然现在我们清楚了,在这个节点上容器和宿主机上的网络配置是怎么一回事。那么要调试这个问题呢,也有了思路,关键就是找到数据包传到哪个环节时发生了中断。
|
||||
|
||||
那最直接的方法呢,就是在容器中继续ping外网的IP 39.106.233.176,然后在容器的eth0 (veth_container),容器外的veth_host,docker0,宿主机的eth0这一条数据包的路径上运行tcpdump。
|
||||
|
||||
这样就可以查到,到底在哪个设备接口上没有收到ping的icmp包。我把tcpdump运行的结果我列到了下面。
|
||||
|
||||
容器的eth0:
|
||||
|
||||
# ip netns exec $pid tcpdump -i eth0 host 39.106.233.176 -nn
|
||||
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
|
||||
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
|
||||
00:47:29.934294 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 1, length 64
|
||||
00:47:30.934766 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 2, length 64
|
||||
00:47:31.958875 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 3, length 64
|
||||
|
||||
|
||||
veth_host:
|
||||
|
||||
# tcpdump -i veth_host host 39.106.233.176 -nn
|
||||
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
|
||||
listening on veth_host, link-type EN10MB (Ethernet), capture size 262144 bytes
|
||||
00:48:01.654720 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 32, length 64
|
||||
00:48:02.678752 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 33, length 64
|
||||
00:48:03.702827 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 34, length 64
|
||||
|
||||
|
||||
docker0:
|
||||
|
||||
# tcpdump -i docker0 host 39.106.233.176 -nn
|
||||
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
|
||||
listening on docker0, link-type EN10MB (Ethernet), capture size 262144 bytes
|
||||
00:48:20.086841 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 50, length 64
|
||||
00:48:21.110765 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 51, length 64
|
||||
00:48:22.134839 IP 172.17.1.2 > 39.106.233.176: ICMP echo request, id 71, seq 52, length 64
|
||||
|
||||
|
||||
host eth0:
|
||||
|
||||
# tcpdump -i eth0 host 39.106.233.176 -nn
|
||||
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
|
||||
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
|
||||
^C
|
||||
0 packets captured
|
||||
0 packets received by filter
|
||||
0 packets dropped by kernel
|
||||
|
||||
|
||||
通过上面的输出结果,我们发现icmp包到达了docker0,但是没有到达宿主机上的eth0。
|
||||
|
||||
因为我们已经配置了iptables nat的转发,这个也可以通过查看iptables的nat表确认一下,是没有问题的,具体的操作命令如下:
|
||||
|
||||
# iptables -L -t nat
|
||||
Chain PREROUTING (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
|
||||
|
||||
Chain INPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain POSTROUTING (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
MASQUERADE all -- 172.17.0.0/16 anywhere
|
||||
|
||||
Chain OUTPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
DOCKER all -- anywhere !127.0.0.0/8 ADDRTYPE match dst-type LOCAL
|
||||
|
||||
Chain DOCKER (2 references)
|
||||
target prot opt source destination
|
||||
RETURN all -- anywhere anywhere
|
||||
|
||||
|
||||
那么会是什么问题呢?因为这里需要做两个网络设备接口之间的数据包转发,也就是从docker0把数据包转发到eth0上,你可能想到了Linux协议栈里的一个常用参数ip_forward。
|
||||
|
||||
我们可以看一下,它的值是0,当我们把它改成1之后,那么我们就可以从容器中ping通外网39.106.233.176这个IP了!
|
||||
|
||||
# cat /proc/sys/net/ipv4/ip_forward
|
||||
0
|
||||
# echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
|
||||
# docker exec -it if-test ping 39.106.233.176
|
||||
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
|
||||
64 bytes from 39.106.233.176: icmp_seq=1 ttl=77 time=359 ms
|
||||
64 bytes from 39.106.233.176: icmp_seq=2 ttl=77 time=346 ms
|
||||
^C
|
||||
--- 39.106.233.176 ping statistics ---
|
||||
2 packets transmitted, 2 received, 0% packet loss, time 1ms
|
||||
rtt min/avg/max/mdev = 345.889/352.482/359.075/6.593 ms
|
||||
|
||||
|
||||
重点小结
|
||||
|
||||
这一讲,我们主要解决的问题是如何给容器配置网络接口,让容器可以和外面通讯;同时我们还学习了当容器网络不通的时候,我们应该怎么来做一个简单调试。
|
||||
|
||||
解决容器与外界通讯的问题呢,一共需要完成两步。第一步是,怎么让数据包从容器的Network Namespace发送到Host Network Namespace上;第二步,数据包到了Host Network Namespace之后,还需要让它可以从宿主机的eth0发送出去。
|
||||
|
||||
我们想让数据从容器Netowrk Namespace发送到Host Network Namespace,可以用配置一对veth虚拟网络设备的方法实现。而让数据包从宿主机的eth0发送出去,就用可bridge+nat的方式完成。
|
||||
|
||||
这里我讲的是最基本的一种配置,但它也是很常用的一个网络配置。针对其他不同需要,容器网络还有很多种。那你学习完这一讲,了解了基本的概念和操作之后呢,还可以查看更多的网上资料,学习不同的网络配置。
|
||||
|
||||
遇到容器中网络不通的情况,我们先要理解自己的容器以及容器在宿主机上的配置,通过对主要设备上做tcpdump可以找到具体在哪一步数据包停止了转发。
|
||||
|
||||
然后我们结合内核网络配置参数,路由表信息,防火墙规则,一般都可以定位出根本原因,最终解决这种网络完全不通的问题。
|
||||
|
||||
但是如果是网络偶尔丢包的问题,这个就需要用到其他的一些工具来做分析了,这个我们会在之后的章节做讲解。
|
||||
|
||||
思考题
|
||||
|
||||
我们这一讲的例子呢,实现了从容器访问外面的IP。那么如果要实现节点外的程序来访问容器的IP,我们应该怎么配置网络呢?
|
||||
|
||||
欢迎你在留言区分享你的思考和问题。如果这篇文章对你有启发,也欢迎分享给你的朋友,一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
253
专栏/容器实战高手课/17容器网络配置(2):容器网络延时要比宿主机上的高吗.md
Normal file
253
专栏/容器实战高手课/17容器网络配置(2):容器网络延时要比宿主机上的高吗.md
Normal file
@ -0,0 +1,253 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 容器网络配置(2):容器网络延时要比宿主机上的高吗
|
||||
你好,我是程远。
|
||||
|
||||
在上一讲里,我们学习了在容器中的网络接口配置,重点讲解的是veth的接口配置方式,这也是绝大部分容器用的缺省的网络配置方式。
|
||||
|
||||
不过呢,从veth的这种网络接口配置上看,一个数据包要从容器里发送到宿主机外,需要先从容器里的eth0 (veth_container) 把包发送到宿主机上veth_host,然后再在宿主机上通过nat或者路由的方式,经过宿主机上的eth0向外发送。
|
||||
|
||||
|
||||
|
||||
这种容器向外发送数据包的路径,相比宿主机上直接向外发送数据包的路径,很明显要多了一次接口层的发送和接收。尽管veth是虚拟网络接口,在软件上还是会增加一些开销。
|
||||
|
||||
如果我们的应用程序对网络性能有很高的要求,特别是之前运行在物理机器上,现在迁移到容器上的,如果网络配置采用veth方式,就会出现网络延时增加的现象。
|
||||
|
||||
那今天我们就来聊一聊,容器网络接口对于容器中应用程序网络延时有怎样的影响,还有这个问题应该怎么解决。
|
||||
|
||||
问题重现
|
||||
|
||||
对于这种veth接口配置导致网络延时增加的现象,我们可以通过运行netperf(Netperf是一个衡量网络性能的工具,它可以提供单向吞吐量和端到端延迟的测试)来模拟一下。
|
||||
|
||||
这里我们需要两台虚拟机或者物理机,这两台机器需要同处于一个二层的网络中。
|
||||
|
||||
具体的配置示意图如下:
|
||||
|
||||
|
||||
|
||||
首先,我们需要在第一台机器上启动一个veth接口的容器,容器的启动和宿主机上的配置你可以参考一下这里的脚本。在第二台机器上,我们只要启动一个netserver就可以了。
|
||||
|
||||
然后呢,我们分别在容器里和宿主机上运行与netserver交互的netperf,再比较一下它们延时的差异。
|
||||
|
||||
我们可以运行netperf的TCP_RR测试用例,TCP_RR是netperf里专门用来测试网络延时的,缺省每次运行10秒钟。运行以后,我们还要计算平均每秒钟TCP request/response的次数,这个次数越高,就说明延时越小。
|
||||
|
||||
接下来,我们先在第一台机器的宿主机上直接运行netperf的TCP_RR测试用例3轮,得到的值分别是2504.92,2410.14和2422.81,计算一下可以得到三轮Transactions平均值是2446/s。
|
||||
|
||||
# ./netperf -H 192.168.0.194 -t TCP_RR
|
||||
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
|
||||
Local /Remote
|
||||
Socket Size Request Resp. Elapsed Trans.
|
||||
Send Recv Size Size Time Rate
|
||||
bytes Bytes bytes bytes secs. per sec
|
||||
|
||||
16384 131072 1 1 10.00 2504.92
|
||||
16384 131072
|
||||
# ./netperf -H 192.168.0.194 -t TCP_RR
|
||||
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
|
||||
Local /Remote
|
||||
Socket Size Request Resp. Elapsed Trans.
|
||||
Send Recv Size Size Time Rate
|
||||
bytes Bytes bytes bytes secs. per sec
|
||||
|
||||
16384 131072 1 1 10.00 2410.14
|
||||
16384 131072
|
||||
|
||||
# ./netperf -H 192.168.0.194 -t TCP_RR
|
||||
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
|
||||
Local /Remote
|
||||
Socket Size Request Resp. Elapsed Trans.
|
||||
Send Recv Size Size Time Rate
|
||||
bytes Bytes bytes bytes secs. per sec
|
||||
|
||||
16384 131072 1 1 10.00 2422.81
|
||||
16384 131072
|
||||
|
||||
|
||||
同样,我们再在容器中运行一下netperf的TCP_RR,也一样运行三轮,计算一下这三次的平均值,得到的值是2141。
|
||||
|
||||
那么我们拿这次容器环境中的平均值和宿主机上得到的值2446做比较,会发现Transactions下降了大概12.5%,也就是网络的延时超过了10%。
|
||||
|
||||
[root@4150e2a842b5 /]# ./netperf -H 192.168.0.194 -t TCP_RR
|
||||
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
|
||||
Local /Remote
|
||||
Socket Size Request Resp. Elapsed Trans.
|
||||
Send Recv Size Size Time Rate
|
||||
bytes Bytes bytes bytes secs. per sec
|
||||
|
||||
16384 131072 1 1 10.00 2104.68
|
||||
16384 131072
|
||||
|
||||
[root@4150e2a842b5 /]# ./netperf -H 192.168.0.194 -t TCP_RR
|
||||
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
|
||||
Local /Remote
|
||||
Socket Size Request Resp. Elapsed Trans.
|
||||
Send Recv Size Size Time Rate
|
||||
bytes Bytes bytes bytes secs. per sec
|
||||
|
||||
16384 131072 1 1 10.00 2146.34
|
||||
16384 131072
|
||||
|
||||
[root@4150e2a842b5 /]# ./netperf -H 192.168.0.194 -t TCP_RR
|
||||
MIGRATED TCP REQUEST/RESPONSE TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.0.194 () port 0 AF_INET : first burst 0
|
||||
Local /Remote
|
||||
Socket Size Request Resp. Elapsed Trans.
|
||||
Send Recv Size Size Time Rate
|
||||
bytes Bytes bytes bytes secs. per sec
|
||||
|
||||
16384 131072 1 1 10.00 2173.79
|
||||
16384 131072
|
||||
|
||||
|
||||
分析问题
|
||||
|
||||
刚才我们已经得到了测试的数值,我们发现veth方式的确带来了很高的网络延时。那现在我们先来分析一下,为什么veth会带来这么大的网络延时,然后再看看有什么方法可以降低容器里的网络延时。
|
||||
|
||||
我们先回顾一下容器里veth接口的配置,还是拿我们上一讲里容器veth的图作为例子。
|
||||
|
||||
|
||||
|
||||
上一讲中我提到过,veth的虚拟网络接口一般都是成对出现,就像上面图里的veth_container和veth_host一样。
|
||||
|
||||
在每次网络传输的过程中,数据包都需要通过veth_container这个接口向外发送,而且必须保证veth_host先接收到这个数据包。
|
||||
|
||||
虽然veth是一个虚拟的网络接口,但是在接收数据包的操作上,这个虚拟接口和真实的网路接口并没有太大的区别。这里除了没有硬件中断的处理,其他操作都差不多,特别是软中断(softirq)的处理部分其实就和真实的网络接口是一样的。
|
||||
|
||||
我们可以通过阅读Linux内核里的veth的驱动代码(drivers/net/veth.c)确认一下。
|
||||
|
||||
veth发送数据的函数是veth_xmit(),它里面的主要操作就是找到veth peer设备,然后触发peer设备去接收数据包。
|
||||
|
||||
比如veth_container这个接口调用了veth_xmit()来发送数据包,最后就是触发了它的peer设备veth_host去调用netif_rx()来接收数据包。主要的代码我列在下面了:
|
||||
|
||||
static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
|
||||
{
|
||||
…
|
||||
/* 拿到veth peer设备的net_device */
|
||||
rcv = rcu_dereference(priv->peer);
|
||||
…
|
||||
/* 将数据送到veth peer设备 */
|
||||
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp) == NET_RX_SUCCESS)) {
|
||||
|
||||
|
||||
…
|
||||
}
|
||||
|
||||
static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
|
||||
struct veth_rq *rq, bool xdp)
|
||||
{
|
||||
/* 这里最后调用了 netif_rx() */
|
||||
return __dev_forward_skb(dev, skb) ?: xdp ?
|
||||
veth_xdp_rx(rq, skb) :
|
||||
netif_rx(skb);
|
||||
}
|
||||
|
||||
|
||||
而netif_rx()是一个网络设备驱动里面标准的接收数据包的函数,netif_rx()里面会为这个数据包raise一个softirq。
|
||||
|
||||
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
|
||||
|
||||
|
||||
其实softirq这个概念,我们之前在[CPU的模块]中也提到过。在处理网络数据的时候,一些运行时间较长而且不能在硬中断中处理的工作,就会通过softirq来处理。
|
||||
|
||||
一般在硬件中断处理结束之后,网络softirq的函数才会再去执行没有完成的包的处理工作。即使这里softirq的执行速度很快,还是会带来额外的开销。
|
||||
|
||||
所以,根据veth这个虚拟网络设备的实现方式,我们可以看到它必然会带来额外的开销,这样就会增加数据包的网络延时。
|
||||
|
||||
解决问题
|
||||
|
||||
那么我们有什么方法可以减少容器的网络延时呢?你可能会想到,我们可不可以不使用veth这个方式配置网络接口,而是换成别的方式呢?
|
||||
|
||||
的确是这样,其实除了veth之外,容器还可以选择其他的网络配置方式。在Docker的文档中提到了macvlan的配置方式,和macvlan很类似的方式还有ipvlan。
|
||||
|
||||
那我们先来简单看一下macvlan和ipvlan的异同点。
|
||||
|
||||
我们先来看这两个方式的相同之处,无论是macvlan还是ipvlan,它们都是在一个物理的网络接口上再配置几个虚拟的网络接口。在这些虚拟的网络接口上,都可以配置独立的IP,并且这些IP可以属于不同的Namespace。
|
||||
|
||||
然后我再说说它们的不同点。对于macvlan,每个虚拟网络接口都有自己独立的mac地址;而ipvlan的虚拟网络接口是和物理网络接口共享同一个mac地址。而且它们都有自己的L2/L3的配置方式,不过我们主要是拿macvlan/ipvlan来和veth做比较,这里可以先忽略macvlan/ipvlan这些详细的特性。
|
||||
|
||||
我们就以ipvlan为例,运行下面的这个脚本,为容器手动配置上ipvlan的网络接口。
|
||||
|
||||
docker run --init --name lat-test-1 --network none -d registry/latency-test:v1 sleep 36000
|
||||
|
||||
pid1=$(docker inspect lat-test-1 | grep -i Pid | head -n 1 | awk '{print $2}' | awk -F "," '{print $1}')
|
||||
echo $pid1
|
||||
ln -s /proc/$pid1/ns/net /var/run/netns/$pid1
|
||||
|
||||
ip link add link eth0 ipvt1 type ipvlan mode l2
|
||||
ip link set dev ipvt1 netns $pid1
|
||||
|
||||
ip netns exec $pid1 ip link set ipvt1 name eth0
|
||||
ip netns exec $pid1 ip addr add 172.17.3.2/16 dev eth0
|
||||
ip netns exec $pid1 ip link set eth0 up
|
||||
|
||||
|
||||
在这个脚本里,我们先启动一个容器,这里我们用”—network none”的方式来启动,也就是在容器中没有配置任何的网络接口。
|
||||
|
||||
接着我们在宿主机eth0的接口上增加一个ipvlan虚拟网络接口ipvt1,再把它加入到容器的Network Namespace里面,重命名为容器内的eth0,并且配置上IP。这样我们就配置好了第一个用ipvlan网络接口的容器。
|
||||
|
||||
我们可以用同样的方式配置第二个容器,这样两个容器可以相互ping一下IP,看看网络是否配置成功了。脚本你可以在这里得到。
|
||||
|
||||
两个容器配置好之后,就像下面图中描述的一样了。从这张图里,你很容易就能看出macvlan/ipvlan与veth网络配置有什么不一样。容器的虚拟网络接口,直接连接在了宿主机的物理网络接口上了,形成了一个网络二层的连接。
|
||||
|
||||
|
||||
|
||||
如果从容器里向宿主机外发送数据,看上去通过的接口要比veth少了,那么实际情况是不是这样呢?我们先来看一下ipvlan接口发送数据的代码。
|
||||
|
||||
从下面的ipvlan接口的发送代码中,我们可以看到,如果是往宿主机外发送数据,发送函数会直接找到ipvlan虚拟接口对应的物理网络接口。
|
||||
|
||||
比如在我们的例子中,这个物理接口就是宿主机上的eth0,然后直接调用dev_queue_xmit(),通过物理接口把数据直接发送出去。
|
||||
|
||||
static int ipvlan_xmit_mode_l2(struct sk_buff *skb, struct net_device *dev)
|
||||
{
|
||||
…
|
||||
if (!ipvlan_is_vepa(ipvlan->port) &&
|
||||
ether_addr_equal(eth->h_dest, eth->h_source)) {
|
||||
…
|
||||
} else if (is_multicast_ether_addr(eth->h_dest)) {
|
||||
…
|
||||
}
|
||||
/*
|
||||
* 对于普通的对外发送数据,上面的if 和 else if中的条件都不成立,
|
||||
* 所以会执行到这一步,拿到ipvlan对应的物理网路接口设备,
|
||||
* 然后直接从这个设备发送数据。
|
||||
*/
|
||||
skb->dev = ipvlan->phy_dev;
|
||||
return dev_queue_xmit(skb);
|
||||
}
|
||||
|
||||
|
||||
和veth接口相比,我们用ipvlan发送对外数据就要简单得多,因为这种方式没有内部额外的softirq处理开销。
|
||||
|
||||
现在我们还可以看一下,在实际生产环境中,一个应用程序跑在使用veth接口的容器中,跟这个应用程序跑在使用ipvlan接口的容器中,两者的网络延时差异是怎样的。
|
||||
|
||||
下面这张图是网络延时的监控图,图里蓝色的线表示程序运行在veth容器中,黄色线表示程序运行在ipvlan的容器里,绿色的线代表程序直接运行在物理机上。
|
||||
|
||||
从这张延时(Latency)图里,我们可以看到,在veth容器里程序的网络延时要明显高一些,而程序在ipvlan容器里的网络延时已经比较接近物理机上的网络延时了。
|
||||
|
||||
|
||||
|
||||
所以对于网络延时敏感的应用程序,我们可以考虑使用ipvlan/macvlan的容器网络配置方式来替换缺省的veth网络配置。
|
||||
|
||||
重点小结
|
||||
|
||||
好了,今天的内容讲完了,我们来做个总结。今天我们主要讨论了容器网络接口对容器中应用程序网络延时的影响。
|
||||
|
||||
容器通常缺省使用veth虚拟网络接口,不过veth接口会有比较大的网络延时。我们可以使用netperf这个工具来比较网络延时,相比物理机上的网络延时,使用veth接口容器的网络延时会增加超过10%。
|
||||
|
||||
我们通过对veth实现的代码做分析,可以看到由于veth接口是成对工作,在对外发送数据的时候,peer veth接口都会raise softirq来完成一次收包操作,这样就会带来数据包处理的额外开销。
|
||||
|
||||
如果要减小容器网络延时,就可以给容器配置ipvlan/macvlan的网络接口来替代veth网络接口。Ipvlan/macvlan直接在物理网络接口上虚拟出接口,在发送对外数据包的时候可以直接通过物理接口完成,没有节点内部类似veth的那种softirq的开销。容器使用ipvlan/maclan的网络接口,它的网络延时可以非常接近物理网络接口的延时。
|
||||
|
||||
对于延时敏感的应用程序,我们可以考虑使用ipvlan/macvlan网络接口的容器。不过,由于ipvlan/macvlan网络接口直接挂载在物理网络接口上,对于需要使用iptables规则的容器,比如Kubernetes里使用service的容器,就不能工作了。这就需要你结合实际应用的需求做个判断,再选择合适的方案。
|
||||
|
||||
思考题
|
||||
|
||||
在这节课的最后,我提到“由于ipvlan/macvlan网络接口直接挂载在物理网络接口上,对于需要使用iptables规则的容器,比如Kubernetes里使用service的容器,就不能工作了”,请你思考一下这个判断背后的具体原因。
|
||||
|
||||
欢迎你在留言区写下你的思考和疑问。如果这篇文章让你有所收获,也欢迎分享给你的朋友,一起交流进步。
|
||||
|
||||
|
||||
|
||||
|
238
专栏/容器实战高手课/18容器网络配置(3):容器中的网络乱序包怎么这么高?.md
Normal file
238
专栏/容器实战高手课/18容器网络配置(3):容器中的网络乱序包怎么这么高?.md
Normal file
@ -0,0 +1,238 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 容器网络配置(3):容器中的网络乱序包怎么这么高?
|
||||
你好,我是程远。这一讲,我们来聊一下容器中发包乱序的问题。
|
||||
|
||||
这个问题也同样来自于工作实践,我们的用户把他们的应用程序从物理机迁移到容器之后,从网络监控中发现,容器中数据包的重传的数量要比在物理机里高了不少。
|
||||
|
||||
在网络的前面几讲里,我们已经知道了容器网络缺省的接口是veth,veth接口都是成对使用的。容器通过veth接口向外发送数据,首先需要从veth的一个接口发送给跟它成对的另一个接口。
|
||||
|
||||
那么这种接口会不会引起更多的网络重传呢?如果会引起重传,原因是什么,我们又要如何解决呢?接下来我们就带着这三个问题开始今天的学习。
|
||||
|
||||
问题重现
|
||||
|
||||
我们可以在容器里运行一下 iperf3 命令,向容器外部发送一下数据,从iperf3的输出”Retr”列里,我们可以看到有多少重传的数据包。
|
||||
|
||||
比如下面的例子里,我们可以看到有162个重传的数据包。
|
||||
|
||||
# iperf3 -c 192.168.147.51
|
||||
Connecting to host 192.168.147.51, port 5201
|
||||
[ 5] local 192.168.225.12 port 51700 connected to 192.168.147.51 port 5201
|
||||
[ ID] Interval Transfer Bitrate Retr Cwnd
|
||||
[ 5] 0.00-1.00 sec 1001 MBytes 8.40 Gbits/sec 162 192 KBytes
|
||||
…
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
[ ID] Interval Transfer Bitrate Retr
|
||||
[ 5] 0.00-10.00 sec 9.85 GBytes 8.46 Gbits/sec 162 sender
|
||||
[ 5] 0.00-10.04 sec 9.85 GBytes 8.42 Gbits/sec receiver
|
||||
|
||||
iperf Done.
|
||||
|
||||
|
||||
网络中发生了数据包的重传,有可能是数据包在网络中丢了,也有可能是数据包乱序导致的。那么,我们怎么来判断到底是哪一种情况引起的重传呢?
|
||||
|
||||
最直接的方法就是用tcpdump去抓包,不过对于大流量的网络,用tcpdump抓包瞬间就会有几个GB的数据。可是这样做的话,带来的额外系统开销比较大,特别是在生产环境中这个方法也不太好用。
|
||||
|
||||
所以这里我们有一个简单的方法,那就是运行netstat命令来查看协议栈中的丢包和重传的情况。比如说,在运行上面的iperf3命令前后,我们都在容器的Network Namespace里运行一下netstat看看重传的情况。
|
||||
|
||||
我们会发现,一共发生了162次(604-442)快速重传(fast retransmits),这个数值和iperf3中的Retr列里的数值是一样的。
|
||||
|
||||
-bash-4.2# nsenter -t 51598 -n netstat -s | grep retran
|
||||
454 segments retransmited
|
||||
442 fast retransmits
|
||||
-bash-4.2# nsenter -t 51598 -n netstat -s | grep retran
|
||||
616 segments retransmited
|
||||
604 fast retransmits
|
||||
|
||||
|
||||
问题分析
|
||||
|
||||
快速重传(fast retransmit)
|
||||
|
||||
在刚才的问题重现里,我们运行netstat命令后,统计了快速重传的次数。那什么是快速重传(fast retransmit)呢?这里我给你解释一下。
|
||||
|
||||
我们都知道TCP协议里,发送端(sender)向接受端(receiver)发送一个数据包,接受端(receiver)都回应ACK。如果超过一个协议栈规定的时间(RTO),发送端没有收到ACK包,那么发送端就会重传(Retransmit)数据包,就像下面的示意图一样。
|
||||
|
||||
|
||||
|
||||
不过呢,这样等待一个超时之后再重传数据,对于实际应用来说太慢了,所以TCP协议又定义了快速重传 (fast retransmit)的概念。它的基本定义是这样的:如果发送端收到3个重复的ACK,那么发送端就可以立刻重新发送ACK对应的下一个数据包。
|
||||
|
||||
就像下面示意图里描述的那样,接受端没有收到Seq 2这个包,但是收到了Seq 3–5的数据包,那么接收端在回应Ack的时候,Ack的数值只能是2。这是因为按顺序来说收到Seq 1的包之后,后面Seq 2一直没有到,所以接收端就只能一直发送Ack 2。
|
||||
|
||||
那么当发送端收到3个重复的Ack 2后,就可以马上重新发送 Seq 2这个数据包了,而不用再等到重传超时之后了。
|
||||
|
||||
|
||||
|
||||
虽然TCP快速重传的标准定义是需要收到3个重复的Ack,不过你会发现在Linux中常常收到一个Dup Ack(重复的Ack)后,就马上重传数据了。这是什么原因呢?
|
||||
|
||||
这里先需要提到 SACK 这个概念,SACK也就是选择性确认(Selective Acknowledgement)。其实跟普通的ACK相比呢,SACK会把接收端收到的所有包的序列信息,都反馈给发送端。
|
||||
|
||||
你看看下面这张图,就能明白这是什么意思了。
|
||||
|
||||
|
||||
|
||||
那有了SACK,对于发送端来说,在收到SACK之后就已经知道接收端收到了哪些数据,没有收到哪些数据。
|
||||
|
||||
在Linux内核中会有个判断(你可以看看下面的这个函数),大概意思是这样的:如果在接收端收到的数据和还没有收到的数据之间,两者数据量差得太大的话(超过了reordering*mss_cache),也可以马上重传数据。
|
||||
|
||||
这里你需要注意一下,这里的数据量差是根据bytes来计算的,而不是按照包的数目来计算的,所以你会看到即使只收到一个SACK,Linux也可以重发数据包。
|
||||
|
||||
static bool tcp_force_fast_retransmit(struct sock *sk)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
|
||||
return after(tcp_highest_sack_seq(tp),
|
||||
tp->snd_una + tp->reordering * tp->mss_cache);
|
||||
}
|
||||
|
||||
|
||||
好了,了解了快速重传的概念之后,我们再来看看,如果netstat中有大量的”fast retransmits”意味着什么?
|
||||
|
||||
如果你再用netstat查看”reordering”,就可以看到大量的SACK发现的乱序包。
|
||||
|
||||
-bash-4.2# nsenter -t 51598 -n netstat -s | grep reordering
|
||||
Detected reordering 501067 times using SACK
|
||||
|
||||
|
||||
其实在云平台的这种网络环境里,网络包乱序+SACK之后,产生的数据包重传的量要远远高于网络丢包引起的重传。
|
||||
|
||||
比如说像下面这张图里展示的这样,Seq 2与Seq 3这两个包如果乱序的话,那么就会引起Seq 2的立刻重传。
|
||||
|
||||
|
||||
|
||||
Veth接口的数据包的发送
|
||||
|
||||
现在我们知道了网络包乱序会造成数据包的重传,接着我们再来看看容器的veth接口配置有没有可能会引起数据包的乱序。
|
||||
|
||||
在上一讲里,我们讲过通过veth接口从容器向外发送数据包,会触发peer veth设备去接收数据包,这个接收的过程就是一个网络的softirq的处理过程。
|
||||
|
||||
在触发softirq之前,veth接口会模拟硬件接收数据的过程,通过enqueue_to_backlog()函数把数据包放到某个CPU对应的数据包队列里(softnet_data)。
|
||||
|
||||
static int netif_rx_internal(struct sk_buff *skb)
|
||||
{
|
||||
int ret;
|
||||
|
||||
net_timestamp_check(netdev_tstamp_prequeue, skb);
|
||||
|
||||
trace_netif_rx(skb);
|
||||
|
||||
#ifdef CONFIG_RPS
|
||||
if (static_branch_unlikely(&rps_needed)) {
|
||||
struct rps_dev_flow voidflow, *rflow = &voidflow;
|
||||
int cpu;
|
||||
|
||||
preempt_disable();
|
||||
rcu_read_lock();
|
||||
|
||||
cpu = get_rps_cpu(skb->dev, skb, &rflow);
|
||||
if (cpu < 0)
|
||||
cpu = smp_processor_id();
|
||||
|
||||
ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
|
||||
|
||||
rcu_read_unlock();
|
||||
preempt_enable();
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
unsigned int qtail;
|
||||
|
||||
ret = enqueue_to_backlog(skb, get_cpu(), &qtail);
|
||||
put_cpu();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
从上面的代码,我们可以看到,在缺省的状况下(也就是没有RPS的情况下),enqueue_to_backlog()把数据包放到了“当前运行的CPU”(get_cpu())对应的数据队列中。如果是从容器里通过veth对外发送数据包,那么这个“当前运行的CPU”就是容器中发送数据的进程所在的CPU。
|
||||
|
||||
对于多核的系统,这个发送数据的进程可以在多个CPU上切换运行。进程在不同的CPU上把数据放入队列并且raise softirq之后,因为每个CPU上处理softirq是个异步操作,所以两个CPU network softirq handler处理这个进程的数据包时,处理的先后顺序并不能保证。
|
||||
|
||||
所以,veth对的这种发送数据方式增加了容器向外发送数据出现乱序的几率。
|
||||
|
||||
|
||||
|
||||
RSS和RPS
|
||||
|
||||
那么对于veth接口的这种发包方式,有办法减少一下乱序的几率吗?
|
||||
|
||||
其实,我们在上面netif_rx_internal()那段代码中,有一段在”#ifdef CONFIG_RPS”中的代码。
|
||||
|
||||
我们看到这段代码中在调用enqueue_to_backlog()的时候,传入的CPU并不是当前运行的CPU,而是通过get_rps_cpu()得到的CPU,那么这会有什么不同呢?这里的RPS又是什么意思呢?
|
||||
|
||||
要解释RPS呢,需要先看一下RSS,这个RSS不是我们之前说的内存RSS,而是和网卡硬件相关的一个概念,它是Receive Side Scaling的缩写。
|
||||
|
||||
现在的网卡性能越来越强劲了,从原来一条RX队列扩展到了N条RX队列,而网卡的硬件中断也从一个硬件中断,变成了每条RX队列都会有一个硬件中断。
|
||||
|
||||
每个硬件中断可以由一个CPU来处理,那么对于多核的系统,多个CPU可以并行的接收网络包,这样就大大地提高了系统的网络数据的处理能力.
|
||||
|
||||
同时,在网卡硬件中,可以根据数据包的4元组或者5元组信息来保证同一个数据流,比如一个TCP流的数据始终在一个RX队列中,这样也能保证同一流不会出现乱序的情况。
|
||||
|
||||
下面这张图,大致描述了一下RSS是怎么工作的。
|
||||
|
||||
|
||||
|
||||
RSS的实现在网卡硬件和驱动里面,而RPS(Receive Packet Steering)其实就是在软件层面实现类似的功能。它主要实现的代码框架就在上面的netif_rx_internal()代码里,原理也不难。
|
||||
|
||||
就像下面的这张示意图里描述的这样:在硬件中断后,CPU2收到了数据包,再一次对数据包计算一次四元组的hash值,得到这个数据包与CPU1的映射关系。接着会把这个数据包放到CPU1对应的softnet_data数据队列中,同时向CPU1发送一个IPI的中断信号。
|
||||
|
||||
这样一来,后面CPU1就会继续按照Netowrk softirq的方式来处理这个数据包了。
|
||||
|
||||
|
||||
|
||||
RSS和RPS的目的都是把数据包分散到更多的CPU上进行处理,使得系统有更强的网络包处理能力。在把数据包分散到各个CPU时,保证了同一个数据流在一个CPU上,这样就可以减少包的乱序。
|
||||
|
||||
明白了RPS的概念之后,我们再回头来看veth对外发送数据时候,在enqueue_to_backlog()的时候选择CPU的问题。显然,如果对应的veth接口上打开了RPS的配置以后,那么对于同一个数据流,就可以始终选择同一个CPU了。
|
||||
|
||||
其实我们打开RPS的方法挺简单的,只要去/sys目录下,在网络接口设备接收队列中修改队列里的rps_cpus的值,这样就可以了。rps_cpus是一个16进制的数,每个bit代表一个CPU。
|
||||
|
||||
比如说,我们在一个12CPU的节点上,想让host上的veth接口在所有的12个CPU上,都可以通过RPS重新分配数据包。那么就可以执行下面这段命令:
|
||||
|
||||
# cat /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
|
||||
000
|
||||
# echo fff > /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
|
||||
# cat /sys/devices/virtual/net/veth57703b6/queues/rx-0/rps_cpus
|
||||
fff
|
||||
|
||||
|
||||
重点小结
|
||||
|
||||
好了,今天的内容讲完了,我们做个总结。我们今天讨论的是容器中网络包乱序引起重传的问题。
|
||||
|
||||
由于在容器平台中看到大部分的重传是快速重传(fast retransmits),我们先梳理了什么是快速重传。快速重传的基本定义是:如果发送端收到3个重复的ACK,那么发送端就可以立刻重新发送ACK对应的下一个数据包,而不用等待发送超时。
|
||||
|
||||
不过我们在Linux系统上还会看到发送端收到一个重复的ACK就快速重传的,这是因为Linux下对SACK做了一个特别的判断之后,就可以立刻重传数据包。
|
||||
|
||||
我们再对容器云平台中的快速重传做分析,就会发现这些重传大部分是由包的乱序触发的。
|
||||
|
||||
通过对容器veth网络接口进一步研究,我们知道它可能会增加数据包乱序的几率。同时在这个分析过程中,我们也看到了Linux网络RPS的特性。
|
||||
|
||||
RPS和RSS的作用类似,都是把数据包分散到更多的CPU上进行处理,使得系统有更强的网络包处理能力。它们的区别是RSS工作在网卡的硬件层,而RPS工作在Linux内核的软件层。
|
||||
|
||||
在把数据包分散到各个CPU时,RPS保证了同一个数据流是在一个CPU上的,这样就可以有效减少包的乱序。那么我们可以把RPS的这个特性配置到veth网络接口上,来减少数据包乱序的几率。
|
||||
|
||||
不过,我这里还要说明的是,RPS的配置还是会带来额外的系统开销,在某些网络环境中会引起softirq CPU使用率的增大。那接口要不要打开RPS呢?这个问题你需要根据实际情况来做个权衡。
|
||||
|
||||
同时你还要注意,TCP的乱序包,并不一定都会产生数据包的重传。想要减少网络数据包的重传,我们还可以考虑协议栈中其他参数的设置,比如/proc/sys/net/ipv4/tcp_reordering。
|
||||
|
||||
思考题
|
||||
|
||||
在这一讲中,我们提到了Linux内核中的tcp_force_fast_retransmit()函数。那么你可以想想看,这个函数中的tp->recording和内核参数 /proc/sys/net/ipv4/tcp_reordering是什么关系?它们对数据包的重传会带来什么影响?
|
||||
|
||||
static bool tcp_force_fast_retransmit(struct sock *sk)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
|
||||
return after(tcp_highest_sack_seq(tp),
|
||||
tp->snd_una + tp->reordering * tp->mss_cache);
|
||||
}
|
||||
|
||||
|
||||
欢迎你在留言区分享你的思考或疑问。如果学完这一讲让你有所收获,也欢迎转发给你的同事、或者朋友,一起交流探讨。
|
||||
|
||||
|
||||
|
||||
|
223
专栏/容器实战高手课/19容器安全(1):我的容器真的需要privileged权限吗.md
Normal file
223
专栏/容器实战高手课/19容器安全(1):我的容器真的需要privileged权限吗.md
Normal file
@ -0,0 +1,223 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 容器安全(1):我的容器真的需要privileged权限吗
|
||||
你好,我是程远。从今天这一讲,我们进入到了容器安全的模块。
|
||||
|
||||
容器安全是一个很大的话题,容器的安全性很大程度是由容器的架构特性所决定的。比如容器与宿主机共享Linux内核,通过Namespace来做资源的隔离,通过shim/runC的方式来启动等等。
|
||||
|
||||
这些容器架构特性,在你选择使用容器之后,作为使用容器的用户,其实你已经没有多少能力去对架构这个层面做安全上的改动了。你可能会说用Kata Container、gVisor 就是安全“容器”了。不过,Kata或者gVisor只是兼容了容器接口标准,而内部的实现完全是另外的技术了。
|
||||
|
||||
那么对于使用容器的用户,在运行容器的时候,在安全方面可以做些什么呢?我们主要可以从这两个方面来考虑:第一是赋予容器合理的capabilities,第二是在容器中以非root用户来运行程序。
|
||||
|
||||
为什么是这两点呢?我通过两讲的内容和你讨论一下,这一讲我们先来看容器的capabilities的问题。
|
||||
|
||||
问题再现
|
||||
|
||||
刚刚使用容器的同学,往往会发现用缺省 docker run的方式启动容器后,在容器里很多操作都是不允许的,即使是以root用户来运行程序也不行。
|
||||
|
||||
我们用下面的例子来重现一下这个问题。我们先运行make image 做个容器镜像,然后运行下面的脚本:
|
||||
|
||||
# docker run --name iptables -it registry/iptables:v1 bash
|
||||
[root@0b88d6486149 /]# iptables -L
|
||||
iptables v1.8.4 (nf_tables): Could not fetch rule set generation id: Permission denied (you must be root)
|
||||
|
||||
[root@0b88d6486149 /]# id
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
|
||||
|
||||
在这里,我们想在容器中运行 iptables 这个命令,来查看一下防火墙的规则,但是执行命令之后,你会发现结果输出中给出了”Permission denied (you must be root)“的错误提示,这个提示要求我们用root用户来运行。
|
||||
|
||||
不过在容器中,我们现在已经是以root用户来运行了,么为什么还是不可以运行”iptables”这条命令呢?
|
||||
|
||||
你肯定会想到,是不是容器中又做了别的权限限制?如果你去查一下资料,就会看到启动容器有一个”privileged”的参数。我们可以试一下用上这个参数,没错,我们用了这个参数之后,iptables这个命令就执行成功了。
|
||||
|
||||
# docker stop iptables;docker rm iptables
|
||||
iptables
|
||||
iptables
|
||||
# docker run --name iptables --privileged -it registry/iptables:v1 bash
|
||||
[root@44168f4b9b24 /]# iptables -L
|
||||
Chain INPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain FORWARD (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain OUTPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
|
||||
看上去,我们用了一个配置参数就已经解决了问题,似乎很容易。不过这里我们可以进一步想想,用”privileged”参数来解决问题,是不是一个合理的方法呢?用它会有什么问题吗?
|
||||
|
||||
要回答这些问题,我们先来了解一下”privileged”是什么意思。从Docker的代码里,我们可以看到,如果配置了privileged的参数的话,就会获取所有的capabilities,那什么是capabilities呢?
|
||||
|
||||
if ec.Privileged {
|
||||
p.Capabilities = caps.GetAllCapabilities()
|
||||
}
|
||||
|
||||
|
||||
基本概念
|
||||
|
||||
Linux capabilities
|
||||
|
||||
要了解Linux capabilities的定义,我们可以先查看一下”Linux Programmer’s Manual”中关于Linux capabilities的描述。
|
||||
|
||||
在Linux capabilities出现前,进程的权限可以简单分为两类,第一类是特权用户的进程(进程的有效用户ID是0,简单来说,你可以认为它就是root用户的进程),第二类是非特权用户的进程(进程的有效用户ID是非0,可以理解为非root用户进程)。
|
||||
|
||||
特权用户进程可以执行Linux系统上的所有操作,而非特权用户在执行某些操作的时候就会被内核限制执行。其实这个概念,也是我们通常对Linux中root用户与非root用户的理解。
|
||||
|
||||
从kernel 2.2开始,Linux把特权用户所有的这些“特权”做了更详细的划分,这样被划分出来的每个单元就被称为capability。
|
||||
|
||||
所有的capabilities都在Linux capabilities的手册列出来了,你也可以在内核的文件capability.h中看到所有capabilities的定义。
|
||||
|
||||
对于任意一个进程,在做任意一个特权操作的时候,都需要有这个特权操作对应的capability。
|
||||
|
||||
比如说,运行iptables命令,对应的进程需要有CAP_NET_ADMIN这个capability。如果要mount一个文件系统,那么对应的进程需要有CAP_SYS_ADMIN这个capability。
|
||||
|
||||
我还要提醒你的是,CAP_SYS_ADMIN这个capability里允许了大量的特权操作,包括文件系统,交换空间,还有对各种设备的操作,以及系统调试相关的调用等等。
|
||||
|
||||
在普通Linux节点上,非root用户启动的进程缺省没有任何Linux capabilities,而root用户启动的进程缺省包含了所有的Linux capabilities。
|
||||
|
||||
我们可以做个试验,对于root用户启动的进程,如果把CAP_NET_ADMIN这个capability移除,看看它是否还可以运行iptables。
|
||||
|
||||
在这里我们要用到capsh这个工具,对这个工具不熟悉的同学可以查看超链接。接下来,我们就用capsh执行下面的这个命令:
|
||||
|
||||
# sudo /usr/sbin/capsh --keep=1 --user=root --drop=cap_net_admin -- -c './iptables -L;sleep 100'
|
||||
Chain INPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain FORWARD (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain OUTPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
iptables: Permission denied (you must be root).
|
||||
|
||||
|
||||
这时候,我们可以看到即使是root用户,如果把”CAP_NET_ADMIN”给移除了,那么在执行iptables的时候就会看到”Permission denied (you must be root).“的提示信息。
|
||||
|
||||
同时,我们可以通过/proc文件系统找到对应进程的status,这样就能确认进程中的CAP_NET_ADMIN是否已经被移除了。
|
||||
|
||||
# ps -ef | grep sleep
|
||||
root 22603 22275 0 19:44 pts/1 00:00:00 sudo /usr/sbin/capsh --keep=1 --user=root --drop=cap_net_admin -- -c ./iptables -L;sleep 100
|
||||
root 22604 22603 0 19:44 pts/1 00:00:00 /bin/bash -c ./iptables -L;sleep 100
|
||||
|
||||
# cat /proc/22604/status | grep Cap
|
||||
CapInh: 0000000000000000
|
||||
CapPrm: 0000003fffffefff
|
||||
CapEff: 0000003fffffefff
|
||||
CapBnd: 0000003fffffefff
|
||||
CapAmb: 0000000000000000
|
||||
|
||||
|
||||
运行上面的命令查看 /proc//status里Linux capabilities的相关参数之后,我们可以发现,输出结果中包含5个Cap参数。
|
||||
|
||||
这里我给你解释一下, 对于当前进程,直接影响某个特权操作是否可以被执行的参数,是”CapEff”,也就是”Effective capability sets”,这是一个bitmap,每一个bit代表一项capability是否被打开。
|
||||
|
||||
在Linux内核capability.h里把CAP_NET_ADMIN的值定义成12,所以我们可以看到”CapEff”的值是”0000003fffffefff”,第4个数值是16进制的”e”,而不是f。
|
||||
|
||||
这表示CAP_NET_ADMIN对应的第12-bit没有被置位了(0xefff = 0xffff & (~(1 << 12))),所以这个进程也就没有执行iptables命令的权限了。
|
||||
|
||||
对于进程status中其他几个capabilities相关的参数,它们还需要和应用程序文件属性中的capabilities协同工作,这样才能得到新启动的进程最终的capabilities参数的值。
|
||||
|
||||
我们看下面的图,结合这张图看后面的讲解:
|
||||
|
||||
|
||||
|
||||
如果我们要新启动一个程序,在Linux里的过程就是先通过fork()来创建出一个子进程,然后调用execve()系统调用读取文件系统里的程序文件,把程序文件加载到进程的代码段中开始运行。
|
||||
|
||||
就像图片所描绘的那样,这个新运行的进程里的相关capabilities参数的值,是由它的父进程以及程序文件中的capabilities参数值计算得来的。
|
||||
|
||||
具体的计算过程你可以看Linux capabilities的手册中的描述,也可以读一下网上的这两篇文章:
|
||||
|
||||
|
||||
Capabilities: Why They Exist and How They Work
|
||||
Linux Capabilities in Practice
|
||||
|
||||
|
||||
我就不对所有的进程和文件的capabilities集合参数和算法挨个做解释了,感兴趣的话你可以自己详细去看看。
|
||||
|
||||
这里你只要记住最重要的一点,文件中可以设置capabilities参数值,并且这个值会影响到最后运行它的进程。比如,我们如果把iptables的应用程序加上 CAP_NET_ADMIN的capability,那么即使是非root用户也有执行iptables的权限了。
|
||||
|
||||
$ id
|
||||
uid=1000(centos) gid=1000(centos) groups=1000(centos),10(wheel)
|
||||
$ sudo setcap cap_net_admin+ep ./iptables
|
||||
$ getcap ./iptables
|
||||
./iptables = cap_net_admin+ep
|
||||
$./iptables -L
|
||||
Chain INPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain FORWARD (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
DOCKER-USER all -- anywhere anywhere
|
||||
DOCKER-ISOLATION-STAGE-1 all -- anywhere anywhere
|
||||
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
|
||||
DOCKER all -- anywhere anywhere
|
||||
ACCEPT all -- anywhere anywhere
|
||||
ACCEPT all -- anywhere anywhere
|
||||
…
|
||||
|
||||
|
||||
好了,关于Linux capabilities的内容到这里我们就讲完了,其实它就是把Linux root用户原来所有的特权做了细化,可以更加细粒度地给进程赋予不同权限。
|
||||
|
||||
解决问题
|
||||
|
||||
我们搞懂了Linux capabilities之后,那么对privileged的容器也很容易理解了。Privileged的容器也就是允许容器中的进程可以执行所有的特权操作。
|
||||
|
||||
因为安全方面的考虑,容器缺省启动的时候,哪怕是容器中root用户的进程,系统也只允许了15个capabilities。这个你可以查看runC spec文档中的security 部分,你也可以查看容器init进程status里的Cap参数,看一下容器中缺省的capabilities。
|
||||
|
||||
# docker run --name iptables -it registry/iptables:v1 bash
|
||||
[root@e54694652a42 /]# cat /proc/1/status |grep Cap
|
||||
CapInh: 00000000a80425fb
|
||||
CapPrm: 00000000a80425fb
|
||||
CapEff: 00000000a80425fb
|
||||
CapBnd: 00000000a80425fb
|
||||
CapAmb: 0000000000000000
|
||||
|
||||
|
||||
我想提醒你,当我们发现容器中运行某个程序的权限不够的时候,并不能“偷懒”把容器设置为”privileged”,也就是把所有的capabilities都赋予了容器。
|
||||
|
||||
因为容器中的权限越高,对系统安全的威胁显然也是越大的。比如说,如果容器中的进程有了CAP_SYS_ADMIN的特权之后,那么这些进程就可以在容器里直接访问磁盘设备,直接可以读取或者修改宿主机上的所有文件了。
|
||||
|
||||
所以,在容器平台上是基本不允许把容器直接设置为”privileged”的,我们需要根据容器中进程需要的最少特权来赋予capabilities。
|
||||
|
||||
我们结合这一讲开始的例子来说说。在开头的例子中,容器里需要使用iptables。因为使用iptables命令,只需要设置CAP_NET_ADMIN这个capability就行。那么我们只要在运行Docker的时候,给这个容器再多加一个NET_ADMIN参数就可以了。
|
||||
|
||||
# docker run --name iptables --cap-add NET_ADMIN -it registry/iptables:v1 bash
|
||||
[root@cfedf124dcf1 /]# iptables -L
|
||||
Chain INPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain FORWARD (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
Chain OUTPUT (policy ACCEPT)
|
||||
target prot opt source destination
|
||||
|
||||
|
||||
重点小结
|
||||
|
||||
这一讲我们主要学习了如何给容器赋予合理的capabilities。
|
||||
|
||||
那么,我们自然需要先来理解什么是Linux capabilities。其实Linux capabilities就是把Linux root用户原来所有的特权做了细化,可以更加细粒度地给进程赋予不同权限。
|
||||
|
||||
对于Linux中的每一个特权操作都有一个对应的capability,对于一个capability,有的对应一个特权操作,有的可以对应很多个特权操作。
|
||||
|
||||
每个Linux进程有5个capabilities集合参数,其中Effective集合里的capabilities决定了当前进程可以做哪些特权操作,而其他集合参数会和应用程序文件的capabilities集合参数一起来决定新启动程序的capabilities集合参数。
|
||||
|
||||
对于容器的root用户,缺省只赋予了15个capabilities。如果我们发现容器中进程的权限不够,就需要分析它需要的最小capabilities集合,而不是直接赋予容器”privileged”。
|
||||
|
||||
因为”privileged”包含了所有的Linux capabilities, 这样”privileged”就可以轻易获取宿主机上的所有资源,这会对宿主机的安全产生威胁。所以,我们要根据容器中进程需要的最少特权来赋予capabilities。
|
||||
|
||||
思考题
|
||||
|
||||
你可以查看一下你的Linux系统里ping程序文件有哪些capabilities,看看有什么办法,能让Linux普通用户没有执行ping的能力。
|
||||
|
||||
欢迎你在留言区和我交流互动。如果学完这一讲让你有所收获,也欢迎转发给你的同事、或者朋友,一起交流探讨容器安全的问题。
|
||||
|
||||
|
||||
|
||||
|
226
专栏/容器实战高手课/20容器安全(2):在容器中,我不以root用户来运行程序可以吗?.md
Normal file
226
专栏/容器实战高手课/20容器安全(2):在容器中,我不以root用户来运行程序可以吗?.md
Normal file
@ -0,0 +1,226 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 容器安全(2):在容器中,我不以root用户来运行程序可以吗?
|
||||
你好,我是程远。
|
||||
|
||||
在[上一讲]里,我们学习了Linux capabilities的概念,也知道了对于非privileged的容器,容器中root用户的capabilities是有限制的,因此容器中的root用户无法像宿主机上的root用户一样,拿到完全掌控系统的特权。
|
||||
|
||||
那么是不是让非privileged的容器以root用户来运行程序,这样就能保证安全了呢?这一讲,我们就来聊一聊容器中的root用户与安全相关的问题。
|
||||
|
||||
问题再现
|
||||
|
||||
说到容器中的用户(user),你可能会想到,在Linux Namespace中有一项隔离技术,也就是User Namespace。
|
||||
|
||||
不过在容器云平台Kubernetes上目前还不支持User Namespace,所以我们先来看看在没有User Namespace的情况下,容器中用root用户运行,会发生什么情况。
|
||||
|
||||
首先,我们可以用下面的命令启动一个容器,在这里,我们把宿主机上/etc目录以volume的形式挂载到了容器中的/mnt目录下面。
|
||||
|
||||
# docker run -d --name root_example -v /etc:/mnt centos sleep 3600
|
||||
|
||||
|
||||
然后,我们可以看一下容器中的进程”sleep 3600”,它在容器中和宿主机上的用户都是root,也就是说,容器中用户的uid/gid和宿主机上的完全一样。
|
||||
|
||||
# docker exec -it root_example bash -c "ps -ef | grep sleep"
|
||||
root 1 0 0 01:14 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
|
||||
|
||||
# ps -ef | grep sleep
|
||||
root 5473 5443 0 18:14 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
|
||||
|
||||
|
||||
虽然容器里root用户的capabilities被限制了一些,但是在容器中,对于被挂载上来的/etc目录下的文件,比如说shadow文件,以这个root用户的权限还是可以做修改的。
|
||||
|
||||
# docker exec -it root_example bash
|
||||
[root@9c7b76232c19 /]# ls /mnt/shadow -l
|
||||
---------- 1 root root 586 Nov 26 13:47 /mnt/shadow
|
||||
[root@9c7b76232c19 /]# echo "hello" >> /mnt/shadow
|
||||
|
||||
|
||||
接着我们看看后面这段命令输出,可以确认在宿主机上文件被修改了。
|
||||
|
||||
# tail -n 3 /etc/shadow
|
||||
grafana:!!:18437::::::
|
||||
tcpdump:!!:18592::::::
|
||||
hello
|
||||
|
||||
|
||||
这个例子说明容器中的root用户也有权限修改宿主机上的关键文件。
|
||||
|
||||
当然在云平台上,比如说在Kubernetes里,我们是可以限制容器去挂载宿主机的目录的。
|
||||
|
||||
不过,由于容器和宿主机是共享Linux内核的,一旦软件有漏洞,那么容器中以root用户运行的进程就有机会去修改宿主机上的文件了。比如2019年发现的一个RunC的漏洞 CVE-2019-5736, 这导致容器中root用户有机会修改宿主机上的RunC程序,并且容器中的root用户还会得到宿主机上的运行权限。
|
||||
|
||||
问题分析
|
||||
|
||||
对于前面的问题,接下来我们就来讨论一下解决办法,在讨论问题的过程中,也会涉及一些新的概念,主要有三个。
|
||||
|
||||
方法一:Run as non-root user(给容器指定一个普通用户)
|
||||
|
||||
我们如果不想让容器以root用户运行,最直接的办法就是给容器指定一个普通用户uid。这个方法很简单,比如可以在docker启动容器的时候加上”-u”参数,在参数中指定uid/gid。
|
||||
|
||||
具体的操作代码如下:
|
||||
|
||||
# docker run -ti --name root_example -u 6667:6667 -v /etc:/mnt centos bash
|
||||
bash-4.4$ id
|
||||
uid=6667 gid=6667 groups=6667
|
||||
bash-4.4$ ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
6667 1 0 1 01:27 pts/0 00:00:00 bash
|
||||
6667 8 1 0 01:27 pts/0 00:00:00 ps -ef
|
||||
|
||||
|
||||
还有另外一个办法,就是我们在创建容器镜像的时候,用Dockerfile为容器镜像里建立一个用户。
|
||||
|
||||
为了方便你理解,我还是举例说明。就像下面例子中的nonroot,它是一个用户名,我们用USER关键字来指定这个nonroot用户,这样操作以后,容器里缺省的进程都会以这个用户启动。
|
||||
|
||||
这样在运行Docker命令的时候就不用加”-u”参数来指定用户了。
|
||||
|
||||
# cat Dockerfile
|
||||
FROM centos
|
||||
|
||||
RUN adduser -u 6667 nonroot
|
||||
USER nonroot
|
||||
|
||||
# docker build -t registry/nonroot:v1 .
|
||||
…
|
||||
|
||||
# docker run -d --name root_example -v /etc:/mnt registry/nonroot:v1 sleep 3600
|
||||
050809a716ab0a9481a6dfe711b332f74800eff5fea8b4c483fa370b62b4b9b3
|
||||
|
||||
# docker exec -it root_example bash
|
||||
[nonroot@050809a716ab /]$ id
|
||||
uid=6667(nonroot) gid=6667(nonroot) groups=6667(nonroot)
|
||||
[nonroot@050809a716ab /]$ ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
nonroot 1 0 0 01:43 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
|
||||
|
||||
|
||||
好,在容器中使用普通用户运行之后,我们再看看,现在能否修改被挂载上来的/etc目录下的文件? 显然,现在不可以修改了。
|
||||
|
||||
[nonroot@050809a716ab /]$ echo "hello" >> /mnt/shadow
|
||||
bash: /mnt/shadow: Permission denied
|
||||
|
||||
|
||||
那么是不是只要给容器中指定了一个普通用户,这个问题就圆满解决了呢?其实在云平台上,这么做还是会带来别的问题,我们一起来看看。
|
||||
|
||||
由于用户uid是整个节点中共享的,那么在容器中定义的uid,也就是宿主机上的uid,这样就很容易引起uid的冲突。
|
||||
|
||||
比如说,多个客户在建立自己的容器镜像的时候都选择了同一个uid 6667。那么当多个客户的容器在同一个节点上运行的时候,其实就都使用了宿主机上uid 6667。
|
||||
|
||||
我们都知道,在一台Linux系统上,每个用户下的资源是有限制的,比如打开文件数目(open files)、最大进程数目(max user processes)等等。一旦有很多个容器共享一个uid,这些容器就很可能很快消耗掉这个uid下的资源,这样很容易导致这些容器都不能再正常工作。
|
||||
|
||||
要解决这个问题,必须要有一个云平台级别的uid管理和分配,但选择这个方法也要付出代价。因为这样做是可以解决问题,但是用户在定义自己容器中的uid的时候,他们就需要有额外的操作,而且平台也需要新开发对uid平台级别的管理模块,完成这些事情需要的工作量也不少。
|
||||
|
||||
方法二:User Namespace(用户隔离技术的支持)
|
||||
|
||||
那么在没有使用User Namespace的情况,对于容器平台上的用户管理还是存在问题。你可能会想到,我们是不是应该去尝试一下User Namespace?
|
||||
|
||||
好的,我们就一起来看看使用User Namespace对解决用户管理问题有没有帮助。首先,我们简单了解一下User Namespace的概念。
|
||||
|
||||
User Namespace隔离了一台Linux节点上的User ID(uid)和Group ID(gid),它给Namespace中的uid/gid的值与宿主机上的uid/gid值建立了一个映射关系。经过User Namespace的隔离,我们在Namespace中看到的进程的uid/gid,就和宿主机Namespace中看到的uid和gid不一样了。
|
||||
|
||||
你可以看下面的这张示意图,应该就能很快知道User Namespace大概是什么意思了。比如namespace_1里的uid值是0到999,但其实它在宿主机上对应的uid值是1000到1999。
|
||||
|
||||
还有一点你要注意的是,User Namespace是可以嵌套的,比如下面图里的namespace_2里可以再建立一个namespace_3,这个嵌套的特性是其他Namespace没有的。
|
||||
|
||||
|
||||
|
||||
我们可以启动一个带User Namespace的容器来感受一下。这次启动容器,我们用一下podman这个工具,而不是Docker。
|
||||
|
||||
跟Docker相比,podman不再有守护进程dockerd,而是直接通过fork/execve的方式来启动一个新的容器。这种方式启动容器更加简单,也更容易维护。
|
||||
|
||||
Podman的命令参数兼容了绝大部分的docker命令行参数,用过Docker的同学也很容易上手podman。你感兴趣的话,可以跟着这个手册在你自己的Linux系统上装一下podman。
|
||||
|
||||
那接下来,我们就用下面的命令来启动一个容器:
|
||||
|
||||
# podman run -ti -v /etc:/mnt --uidmap 0:2000:1000 centos bash
|
||||
|
||||
|
||||
我们可以看到,其他参数和前面的Docker命令是一样的。
|
||||
|
||||
这里我们在命令里增加一个参数,”–uidmap 0:2000:1000”,这个是标准的User Namespace中uid的映射格式:”ns_uid:host_uid:amount”。
|
||||
|
||||
那这个例子里的”0:2000:1000”是什么意思呢?我给你解释一下。
|
||||
|
||||
第一个0是指在新的Namespace里uid从0开始,中间的那个2000指的是Host Namespace里被映射的uid从2000开始,最后一个1000是指总共需要连续映射1000个uid。
|
||||
|
||||
所以,我们可以得出,这个容器里的uid 0是被映射到宿主机上的uid 2000的。这一点我们可以验证一下。
|
||||
|
||||
首先,我们先在容器中以用户uid 0运行一下 sleep 这个命令:
|
||||
|
||||
# id
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
# sleep 3600
|
||||
|
||||
|
||||
然后就是第二步,到宿主机上查看一下这个进程的uid。这里我们可以看到,进程uid的确是2000了。
|
||||
|
||||
# ps -ef |grep sleep
|
||||
2000 27021 26957 0 01:32 pts/0 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 3600
|
||||
|
||||
|
||||
第三步,我们可以再回到容器中,仍然以容器中的root对被挂载上来的/etc目录下的文件做操作,这时可以看到操作是不被允许的。
|
||||
|
||||
# echo "hello" >> /mnt/shadow
|
||||
bash: /mnt/shadow: Permission denied
|
||||
# id
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
|
||||
|
||||
好了,通过这些操作以及和前面User Namespace的概念的解释,我们可以总结出容器使用User Namespace有两个好处。
|
||||
|
||||
第一,它把容器中root用户(uid 0)映射成宿主机上的普通用户。
|
||||
|
||||
作为容器中的root,它还是可以有一些Linux capabilities,那么在容器中还是可以执行一些特权的操作。而在宿主机上uid是普通用户,那么即使这个用户逃逸出容器Namespace,它的执行权限还是有限的。
|
||||
|
||||
第二,对于用户在容器中自己定义普通用户uid的情况,我们只要为每个容器在节点上分配一个uid范围,就不会出现在宿主机上uid冲突的问题了。
|
||||
|
||||
因为在这个时候,我们只要在节点上分配容器的uid范围就可以了,所以从实现上说,相比在整个平台层面给容器分配uid,使用User Namespace这个办法要方便得多。
|
||||
|
||||
这里我额外补充一下,前面我们说了Kubernetes目前还不支持User Namespace,如果你想了解相关工作的进展,可以看一下社区的这个PR。
|
||||
|
||||
方法三:rootless container(以非root用户启动和管理容器)
|
||||
|
||||
前面我们已经讨论了,在容器中以非root用户运行进程可以降低容器的安全风险。除了在容器中使用非root用户,社区还有一个rootless container的概念。
|
||||
|
||||
这里rootless container中的”rootless”不仅仅指容器中以非root用户来运行进程,还指以非root用户来创建容器,管理容器。也就是说,启动容器的时候,Docker或者podman是以非root用户来执行的。
|
||||
|
||||
这样一来,就能进一步提升容器中的安全性,我们不用再担心因为containerd或者RunC里的代码漏洞,导致容器获得宿主机上的权限。
|
||||
|
||||
我们可以参考redhat blog里的这篇文档, 在宿主机上用redhat这个用户通过podman来启动一个容器。在这个容器中也使用了User Namespace,并且把容器中的uid 0映射为宿主机上的redhat用户了。
|
||||
|
||||
$ id
|
||||
uid=1001(redhat) gid=1001(redhat) groups=1001(redhat)
|
||||
$ podman run -it ubi7/ubi bash ### 在宿主机上以redhat用户启动容器
|
||||
[root@206f6d5cb033 /]# id ### 容器中的用户是root
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
[root@206f6d5cb033 /]# sleep 3600 ### 在容器中启动一个sleep 进程
|
||||
# ps -ef |grep sleep ###在宿主机上查看容器sleep进程对应的用户
|
||||
redhat 29433 29410 0 05:14 pts/0 00:00:00 sleep 3600
|
||||
|
||||
|
||||
目前Docker和podman都支持了rootless container,Kubernetes对rootless container支持的工作也在进行中。
|
||||
|
||||
重点小结
|
||||
|
||||
我们今天讨论的内容是root用户与容器安全的问题。
|
||||
|
||||
尽管容器中root用户的Linux capabilities已经减少了很多,但是在没有User Namespace的情况下,容器中root用户和宿主机上的root用户的uid是完全相同的,一旦有软件的漏洞,容器中的root用户就可以操控整个宿主机。
|
||||
|
||||
为了减少安全风险,业界都是建议在容器中以非root用户来运行进程。不过在没有User Namespace的情况下,在容器中使用非root用户,对于容器云平台来说,对uid的管理会比较麻烦。
|
||||
|
||||
所以,我们还是要分析一下User Namespace,它带来的好处有两个。一个是把容器中root用户(uid 0)映射成宿主机上的普通用户,另外一个好处是在云平台里对于容器uid的分配要容易些。
|
||||
|
||||
除了在容器中以非root用户来运行进程外,Docker和podman都支持了rootless container,也就是说它们都可以以非root用户来启动和管理容器,这样就进一步降低了容器的安全风险。
|
||||
|
||||
思考题
|
||||
|
||||
我在这一讲里提到了rootless container,不过对于rootless container的支持,还存在着不少的难点,比如容器网络的配置、Cgroup的配置,你可以去查阅一些资料,看看podman是怎么解决这些问题的。
|
||||
|
||||
欢迎你在留言区提出你的思考和疑问。如果这一讲对你有帮助,也欢迎转发给你的同事、朋友,一起交流学习。
|
||||
|
||||
|
||||
|
||||
|
178
专栏/容器实战高手课/加餐01案例分析:怎么解决海量IPVS规则带来的网络延时抖动问题?.md
Normal file
178
专栏/容器实战高手课/加餐01案例分析:怎么解决海量IPVS规则带来的网络延时抖动问题?.md
Normal file
@ -0,0 +1,178 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐01 案例分析:怎么解决海量IPVS规则带来的网络延时抖动问题?
|
||||
你好,我是程远。
|
||||
|
||||
今天,我们进入到了加餐专题部分。我在结束语的彩蛋里就和你说过,在这个加餐案例中,我们会用到perf、ftrace、bcc/ebpf这几个Linux调试工具,了解它们的原理,熟悉它们在调试问题的不同阶段所发挥的作用。
|
||||
|
||||
加餐内容我是这样安排的,专题的第1讲我先完整交代这个案例的背景,带你回顾我们当时整个的调试过程和思路,然后用5讲内容,对这个案例中用到的调试工具依次进行详细讲解。
|
||||
|
||||
好了,话不多说。这一讲,我们先来整体看一下这个容器网络延时的案例。
|
||||
|
||||
问题的背景
|
||||
|
||||
在2020年初的时候,我们的一个用户把他们的应用从虚拟机迁移到了Kubernetes平台上。迁移之后,用户发现他们的应用在容器中的出错率很高,相比在之前虚拟机上的出错率要高出一个数量级。
|
||||
|
||||
那为什么会有这么大的差别呢?我们首先分析了应用程序的出错日志,发现在Kubernetes平台上,几乎所有的出错都是因为网络超时导致的。
|
||||
|
||||
经过网络环境排查和比对测试,我们排除了网络设备上的问题,那么这个超时就只能是容器和宿主机上的问题了。
|
||||
|
||||
这里要先和你说明的是,尽管应用程序的出错率在容器中比在虚拟机里高出一个数量级,不过这个出错比例仍然是非常低的,在虚拟机中的出错率是0.001%,而在容器中的出错率是0.01%~0.04%。
|
||||
|
||||
因为这个出错率还是很低,所以对于这种低概率事件,我们想复现和排查问题,难度就很大了。
|
||||
|
||||
当时我们查看了一些日常的节点监控数据,比如CPU使用率、Load Average、内存使用、网络流量和丢包数量、磁盘I/O,发现从这些数据中都看不到任何的异常。
|
||||
|
||||
既然常规手段无效,那我们应该如何下手去调试这个问题呢?
|
||||
|
||||
你可能会想到用 tcpdump看一看,因为它是网络抓包最常见的工具。其实我们当时也这样想过,不过马上就被自己否定了,因为这个方法存在下面三个问题。
|
||||
|
||||
第一,我们遇到的延时问题是偶尔延时,所以需要长时间地抓取数据,这样抓取的数据量就会很大。
|
||||
|
||||
第二,在抓完数据之后,需要单独设计一套分析程序来找到长延时的数据包。
|
||||
|
||||
第三,即使我们找到了长延时的数据包,也只是从实际的数据包层面证实了问题。但是这样做无法取得新进展,也无法帮助我们发现案例中网络超时的根本原因。
|
||||
|
||||
调试过程
|
||||
|
||||
对于这种非常偶然的延时问题,之前我们能做的是依靠经验,去查看一些可疑点碰碰“运气”。
|
||||
|
||||
不过这一次,我们想用更加系统的方法来调试这个问题。所以接下来,我会从ebpf破冰,perf进一步定位以及用ftrace最终锁定这三个步骤,带你一步步去解决这个复杂的网络延时问题。
|
||||
|
||||
ebpf的破冰
|
||||
|
||||
我们的想法是这样的:因为延时产生在节点上,所以可以推测,这个延时有很大的概率发生在Linux内核处理数据包的过程中。
|
||||
|
||||
沿着这个思路,还需要进一步探索。我们想到,可以给每个数据包在内核协议栈关键的函数上都打上时间戳,然后计算数据包在每两个函数之间的时间差,如果这个时间差比较大,就可以说明问题出在这两个内核函数之间。
|
||||
|
||||
要想找到内核协议栈中的关键函数,还是比较容易的。比如下面的这张示意图里,就列出了Linux内核在接收数据包和发送数据包过程中的主要函数:
|
||||
|
||||
|
||||
找到这些主要函数之后,下一个问题就是,想给每个数据包在经过这些函数的时候打上时间戳做记录,应该用什么方法呢?接下来我们一起来看看。
|
||||
|
||||
在不修改内核源代码的情况,要截获内核函数,我们可以利用kprobe或者tracepoint的接口。
|
||||
|
||||
使用这两种接口的方法也有两种:一是直接写kernel module来调用kprobe或者tracepoint的接口,第二种方法是通过ebpf的接口来调用它们。在后面的课程里,我还会详细讲解ebpf、kprobe、tracepoint,这里你先有个印象就行。
|
||||
|
||||
在这里,我们选择了第二种方法,也就是使用ebpf来调用kprobe或者tracepoint接口,记录数据包处理过程中这些协议栈函数的每一次调用。
|
||||
|
||||
选择ebpf的原因主要是两个:一是ebpf的程序在内核中加载会做很严格的检查,这样在生产环境中使用比较安全;二是ebpf map功能可以方便地进行内核态与用户态的通讯,这样实现一个工具也比较容易。
|
||||
|
||||
决定了方法之后,这里我们需要先实现一个ebpf工具,然后用这个工具来对内核网络函数做trace。
|
||||
|
||||
我们工具的具体实现是这样的,针对用户的一个TCP/IP数据流,记录这个流的数据发送包与数据接收包的传输过程,也就是数据发送包从容器的Network Namespace发出,一直到它到达宿主机的eth0的全过程,以及数据接收包从宿主机的eth0返回到容器Network Namespace的eth0的全程。
|
||||
|
||||
在收集了数十万条记录后,我们对数据做了分析,找出前后两步时间差大于50毫秒(ms)的记录。最后,我们终于发现了下面这段记录:
|
||||
|
||||
|
||||
|
||||
在这段记录中,我们先看一下“Network Namespace”这一列。编号3对应的Namespace ID 4026535252是容器里的,而ID4026532057是宿主机上的Host Namespace。
|
||||
|
||||
数据包从1到7的数据表示了,一个数据包从容器里的eth0通过veth发到宿主机上的peer veth cali29cf0fa56ce,然后再通过路由从宿主机的obr0(openvswitch)接口和eth0接口发出。
|
||||
|
||||
为了方便你理解,我在下面画了一张示意图,描述了这个数据包的传输过程:
|
||||
|
||||
|
||||
|
||||
在这个过程里,我们发现了当数据包从容器的eth0发送到宿主机上的cali29cf0fa56ce,也就是从第3步到第4步之间,花费的时间是10865291752980718-10865291551180388=201800330。
|
||||
|
||||
因为时间戳的单位是纳秒ns,而201800330超过了200毫秒(ms),这个时间显然是不正常的。
|
||||
|
||||
你还记得吗?我们在容器网络模块的[第17讲]说过veth pair之间数据的发送,它会触发一个softirq,并且在我们ebpf的记录中也可以看到,当数据包到达cali29cf0fa56ce后,就是softirqd进程在CPU32上对它做处理。
|
||||
|
||||
那么这时候,我们就可以把关注点放到CPU32的softirq处理上了。我们再仔细看看CPU32上的si(softirq)的CPU使用情况(运行top命令之后再按一下数字键1,就可以列出每个CPU的使用率了),会发现在CPU32上时不时出现si CPU使用率超过20%的现象。
|
||||
|
||||
具体的输出情况如下:
|
||||
|
||||
%Cpu32 : 8.7 us, 0.0 sy, 0.0 ni, 62.1 id, 0.0 wa, 0.0 hi, 29.1 si, 0.0 st
|
||||
|
||||
|
||||
其实刚才说的这点,在最初的节点监控数据上,我们是不容易注意到的。这是因为我们的节点上有80个CPU,单个CPUsi偶尔超过20%,平均到80个CPU上就只有 0.25%了。要知道,对于一个普通节点,1%的si使用率都是很正常的。
|
||||
|
||||
好了,到这里我们已经缩小了问题的排查范围。可以看到,使用了ebpf帮助我们在毫无头绪的情况,找到了一个比较明确的方向。那么下一步,我们自然要顺藤摸瓜,进一步去搞清楚,为什么在CPU32上的softirq CPU使用率会时不时突然增高?
|
||||
|
||||
perf 定位热点
|
||||
|
||||
对于查找高CPU使用率情况下的热点函数,perf显然是最有力的工具。我们只需要执行一下后面的这条命令,看一下CPU32上的函数调用的热度。
|
||||
|
||||
# perf record -C 32 -g -- sleep 10
|
||||
|
||||
|
||||
为了方便查看,我们可以把 perf record 输出的结果做成一个火焰图,具体的方法我在下一讲里介绍,这里你需要先理解定位热点的整体思路。
|
||||
|
||||
|
||||
|
||||
结合前面的数据分析,我们已经知道了问题出现在softirq的处理过程中,那么在查看火焰图的时候,就要特别关注在softirq中被调用到的函数。
|
||||
|
||||
从上面这张图里,我们可以看到,run_timer_softirq所占的比例是比较大的,而在run_timer_softirq中的绝大部分比例又是被一个叫作estimation_timer()的函数所占用的。
|
||||
|
||||
运行完perf之后,我们离真相又近了一步。现在,我们知道了CPU32上softirq的繁忙是因为TIMER softirq引起的,而TIMER softirq里又在不断地调用 estimation_timer() 这个函数。
|
||||
|
||||
沿着这个思路继续分析,对于TIMER softirq的高占比,一般有这两种情况,一是softirq发生的频率很高,二是softirq中的函数执行的时间很长。
|
||||
|
||||
那怎么判断具体是哪种情况呢?我们用/proc/softirqs查看CPU32上TIMER softirq每秒钟的次数,就会发现TIMER softirq在CPU32上的频率其实并不高。
|
||||
|
||||
这样第一种情况就排除了,那我们下面就来看看,Timer softirq中的那个函数estimation_timer(),是不是它的执行时间太长了?
|
||||
|
||||
ftrace 锁定长延时函数
|
||||
|
||||
我们怎样才能得到estimation_timer()函数的执行时间呢?
|
||||
|
||||
你还记得,我们在容器I/O与内存[那一讲]里用过的ftrace么?当时我们把ftrace的tracer设置为function_graph,通过这个办法查看内核函数的调用时间。在这里我们也可以用同样的方法,查看estimation_timer()的调用时间。
|
||||
|
||||
这时候,我们会发现在CPU32上的estimation_timer()这个函数每次被调用的时间都特别长,比如下面图里的记录,可以看到CPU32上的时间高达310毫秒!
|
||||
|
||||
|
||||
|
||||
现在,我们可以确定问题就出在estimation_timer()这个函数里了。
|
||||
|
||||
接下来,我们需要读一下estimation_timer()在内核中的源代码,看看这个函数到底是干什么的,它为什么耗费了这么长的时间。其实定位到这一步,后面的工作就比较容易了。
|
||||
|
||||
estimation_timer()是IPVS模块中每隔2秒钟就要调用的一个函数,它主要用来更新节点上每一条IPVS规则的状态。Kubernetes Cluster里每建一个service,在所有的节点上都会为这个service建立相应的IPVS规则。
|
||||
|
||||
通过下面这条命令,我们可以看到节点上IPVS规则的数目:
|
||||
|
||||
# ipvsadm -L -n | wc -l
|
||||
79004
|
||||
|
||||
|
||||
我们的节点上已经建立了将近80K条IPVS规则,而estimation_timer()每次都需要遍历所有的规则来更新状态,这样就导致estimation_timer()函数时间开销需要上百毫秒。
|
||||
|
||||
我们还有最后一个问题,estimation_timer()是TIMER softirq里执行的函数,那它为什么会影响到网络RX softirq的延时呢?
|
||||
|
||||
这个问题,我们只要看一下softirq的处理函数__do_softirq(),就会明白了。因为在同一个CPU上,__do_softirq()会串行执行每一种类型的softirq,所以TIMER softirq执行的时间长了,自然会影响到下一个RX softirq的执行。
|
||||
|
||||
好了,分析这里,这个网络延时问题产生的原因我们已经完全弄清楚了。接下来,我带你系统梳理一下这个问题的解决思路。
|
||||
|
||||
问题小结
|
||||
|
||||
首先回顾一下今天这一讲的问题,我们分析了一个在容器平台的生产环境中,用户的应用程序网络延时的问题。这个延时只是偶尔发生,并且出错率只有0.01%~0.04%,所以我们从常规的监控数据中无法看到任何异常。
|
||||
|
||||
那调试这个问题该如何下手呢?
|
||||
|
||||
我们想到的方法是使用ebpf调用kprobe/tracepoint的接口,这样就可以追踪数据包在内核协议栈主要函数中花费的时间。
|
||||
|
||||
我们实现了一个ebpf工具,并且用它缩小了排查范围,我们发现当数据包从容器的veth接口发送到宿主机上的veth接口,在某个CPU上的softirq的处理会有很长的延时。并且由此发现了,在对应的CPU上si的CPU使用率时不时会超过20%。
|
||||
|
||||
找到了这个突破口之后,我们用perf工具专门查找了这个CPU上的热点函数,发现TIMER softirq中调用estimation_timer()的占比是比较高的。
|
||||
|
||||
接下来,我们使用ftrace进一步确认了,在这个特定CPU上estimation_timer()所花费的时间需要几百毫秒。
|
||||
|
||||
通过这些步骤,我们最终锁定了问题出在IPVS的这个estimation_timer()函数里,也找到了问题的根本原因:在我们的节点上存在大量的IPVS规则,每次遍历这些规则都会消耗很多时间,最终导致了网络超时现象。
|
||||
|
||||
知道了原因之后,因为我们在生产环境中并不需要读取IPVS规则状态,所以为了快速解决生产环境上的问题,我们可以使用内核livepatch的机制在线地把estimation_timer()函数替换成了一个空函数。
|
||||
|
||||
这样,我们就暂时规避了因为estimation_timer()耗时长而影响其他softirq的问题。至于长期的解决方案,我们可以把IPVS规则的状态统计从TIMER softirq中转移到kernel thread中处理。
|
||||
|
||||
思考题
|
||||
|
||||
如果不使用ebpf工具,你还有什么方法来找到这个问题的突破口呢?
|
||||
|
||||
欢迎你在留言区和我交流讨论。如果这一讲的内容对你有帮助的话,也欢迎转发给你的朋友、同事,和他一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
285
专栏/容器实战高手课/加餐02理解perf:怎么用perf聚焦热点函数?.md
Normal file
285
专栏/容器实战高手课/加餐02理解perf:怎么用perf聚焦热点函数?.md
Normal file
@ -0,0 +1,285 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐02 理解perf:怎么用perf聚焦热点函数?
|
||||
你好,我是程远。今天我要和你聊一聊容器中如何使用perf。
|
||||
|
||||
[上一讲]中,我们分析了一个生产环境里的一个真实例子,由于节点中的大量的IPVS规则导致了容器在往外发送网络包的时候,时不时会有很高的延时。在调试分析这个网络延时问题的过程中,我们会使用多种Linux内核的调试工具,利用这些工具,我们就能很清晰地找到这个问题的根本原因。
|
||||
|
||||
在后面的课程里,我们会挨个来讲解这些工具,其中perf工具的使用相对来说要简单些,所以这一讲我们先来看perf这个工具。
|
||||
|
||||
问题回顾
|
||||
|
||||
在具体介绍perf之前,我们先来回顾一下,上一讲中,我们是在什么情况下开始使用perf工具的,使用了perf工具之后给我们带来了哪些信息。
|
||||
|
||||
在调试网路延时的时候,我们使用了ebpf的工具之后,发现了节点上一个CPU,也就是CPU32的Softirq CPU Usage(在运行top时,%Cpu那行中的si数值就是Softirq CPU Usage)时不时地会增高一下。
|
||||
|
||||
在发现CPU Usage异常增高的时候,我们肯定想知道是什么程序引起了CPU Usage的异常增高,这时候我们就可以用到perf了。
|
||||
|
||||
具体怎么操作呢?我们可以通过抓取数据、数据读取和异常聚焦三个步骤来实现。
|
||||
|
||||
第一步,抓取数据。当时我们运行了下面这条perf命令,这里的参数 -C 32 是指定只抓取CPU32的执行指令;-g 是指call-graph enable,也就是记录函数调用关系; sleep 10 主要是为了让perf抓取10秒钟的数据。
|
||||
|
||||
# perf record -C 32 -g -- sleep 10
|
||||
|
||||
|
||||
执行完 perf record 之后,我们可以用 perf report 命令进行第二步,也就是读取数据。为了更加直观地看到CPU32上的函数调用情况,我给你生成了一个火焰图(火焰图的生产方法,我们在后面介绍)。
|
||||
|
||||
通过这个火焰图,我们发现了在Softirq里TIMER softirq (run_timer_softirq)的占比很高,并且timer主要处理的都是estimation_timer()这个函数,也就是看火焰图X轴占比比较大的函数。这就是第三步异常聚焦,也就是说我们通过perf在CPU Usage异常的CPU32上,找到了具体是哪一个内核函数使用占比较高。这样在后面的调试分析中,我们就可以聚焦到这个内核函数estimation_timer() 上了。
|
||||
|
||||
|
||||
|
||||
好了,通过回顾我们在网络延时例子中是如何使用perf的,我们知道了这一点,perf可以在CPU Usage增高的节点上找到具体的引起CPU增高的函数,然后我们就可以有针对性地聚焦到那个函数做分析。
|
||||
|
||||
既然perf工具这么有用,想要更好地使用这个工具,我们就要好好认识一下它,那我们就一起看看perf的基本概念和常用的使用方法。
|
||||
|
||||
如何理解Perf的概念和工作机制?
|
||||
|
||||
Perf这个工具最早是Linux内核著名开发者Ingo Molnar开发的,它的源代码在内核源码tools目录下,在每个Linux发行版里都有这个工具,比如CentOS里我们可以运行 yum install perf 来安装,在Ubuntu里我们可以运行 apt install linux-tools-common 来安装。
|
||||
|
||||
Event
|
||||
|
||||
第一次上手使用perf的时候,我们可以先运行一下 perf list 这个命令,然后就会看到perf列出了大量的event,比如下面这个例子就列出了常用的event。
|
||||
|
||||
# perf list
|
||||
…
|
||||
branch-instructions OR branches [Hardware event]
|
||||
branch-misses [Hardware event]
|
||||
bus-cycles [Hardware event]
|
||||
cache-misses [Hardware event]
|
||||
cache-references [Hardware event]
|
||||
cpu-cycles OR cycles [Hardware event]
|
||||
instructions [Hardware event]
|
||||
ref-cycles [Hardware event]
|
||||
|
||||
alignment-faults [Software event]
|
||||
bpf-output [Software event]
|
||||
context-switches OR cs [Software event]
|
||||
cpu-clock [Software event]
|
||||
cpu-migrations OR migrations [Software event]
|
||||
dummy [Software event]
|
||||
emulation-faults [Software event]
|
||||
major-faults [Software event]
|
||||
minor-faults [Software event]
|
||||
page-faults OR faults [Software event]
|
||||
task-clock [Software event]
|
||||
…
|
||||
|
||||
block:block_bio_bounce [Tracepoint event]
|
||||
block:block_bio_complete [Tracepoint event]
|
||||
block:block_bio_frontmerge [Tracepoint event]
|
||||
block:block_bio_queue [Tracepoint event]
|
||||
block:block_bio_remap [Tracepoint event]
|
||||
|
||||
|
||||
从这里我们可以了解到event都有哪些类型, perf list 列出的每个event后面都有一个”[]“,里面写了这个event属于什么类型,比如”Hardware event”、”Software event”等。完整的event类型,我们在内核代码枚举结构perf_type_id里可以看到。
|
||||
|
||||
接下来我们就说三个主要的event,它们分别是Hardware event、Software event还有Tracepoints event。
|
||||
|
||||
Hardware event
|
||||
|
||||
Hardware event来自处理器中的一个PMU(Performance Monitoring Unit),这些event数目不多,都是底层处理器相关的行为,perf中会命名几个通用的事件,比如cpu-cycles,执行完成的instructions,Cache相关的cache-misses。
|
||||
|
||||
不同的处理器有自己不同的PMU事件,对于Intel x86处理器,PMU的使用和编程都可以在“Intel 64 and IA-32 Architectures Developer’s Manual: Vol. 3B”(Intel 架构的开发者手册)里查到。
|
||||
|
||||
我们运行一下 perf stat ,就可以看到在这段时间里这些Hardware event发生的数目。
|
||||
|
||||
# perf stat
|
||||
^C
|
||||
Performance counter stats for 'system wide':
|
||||
|
||||
58667.77 msec cpu-clock # 63.203 CPUs utilized
|
||||
258666 context-switches # 0.004 M/sec
|
||||
2554 cpu-migrations # 0.044 K/sec
|
||||
30763 page-faults # 0.524 K/sec
|
||||
21275365299 cycles # 0.363 GHz
|
||||
24827718023 instructions # 1.17 insn per cycle
|
||||
5402114113 branches # 92.080 M/sec
|
||||
59862316 branch-misses # 1.11% of all branches
|
||||
|
||||
0.928237838 seconds time elapsed
|
||||
|
||||
|
||||
Software event
|
||||
|
||||
Software event是定义在Linux内核代码中的几个特定的事件,比较典型的有进程上下文切换(内核态到用户态的转换)事件context-switches、发生缺页中断的事件page-faults等。
|
||||
|
||||
为了让你更容易理解,这里我举个例子。就拿page-faults这个perf事件来说,我们可以看到,在内核代码处理缺页中断的函数里,就是调用了perf_sw_event()来注册了这个page-faults。
|
||||
|
||||
/*
|
||||
* Explicitly marked noinline such that the function tracer sees this as the
|
||||
* page_fault entry point. __do_page_fault 是Linux内核处理缺页中断的主要函数
|
||||
*/
|
||||
static noinline void
|
||||
__do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
|
||||
unsigned long address)
|
||||
{
|
||||
prefetchw(¤t->mm->mmap_sem);
|
||||
|
||||
if (unlikely(kmmio_fault(regs, address)))
|
||||
return;
|
||||
|
||||
/* Was the fault on kernel-controlled part of the address space? */
|
||||
if (unlikely(fault_in_kernel_space(address)))
|
||||
do_kern_addr_fault(regs, hw_error_code, address);
|
||||
else
|
||||
do_user_addr_fault(regs, hw_error_code, address);
|
||||
/* 在do_user_addr_fault()里面调用了perf_sw_event() */
|
||||
|
||||
}
|
||||
|
||||
/* Handle faults in the user portion of the address space */
|
||||
static inline
|
||||
void do_user_addr_fault(struct pt_regs *regs,
|
||||
unsigned long hw_error_code,
|
||||
unsigned long address)
|
||||
{
|
||||
…
|
||||
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, regs, address);
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
Tracepoints event
|
||||
|
||||
你可以在 perf list 中看到大量的Tracepoints event,这是因为内核中很多关键函数里都有Tracepoints。它的实现方式和Software event类似,都是在内核函数中注册了event。
|
||||
|
||||
不过,这些tracepoints不仅是用在perf中,它已经是Linux内核tracing的标准接口了,ftrace,ebpf等工具都会用到它,后面我们还会再详细介绍tracepoint。
|
||||
|
||||
好了,讲到这里,你要重点掌握的内容是,event是perf工作的基础,主要有两种:有使用硬件的PMU里的event,也有在内核代码中注册的event。
|
||||
|
||||
那么在这些event都准备好了之后,perf又是怎么去使用这些event呢?前面我也提到过,有计数和采样两种方式,下面我们分别来看看。
|
||||
|
||||
计数(count)
|
||||
|
||||
计数的这种工作方式比较好理解,就是统计某个event在一段时间里发生了多少次。
|
||||
|
||||
那具体我们怎么进行计数的呢?perf stat 这个命令就是来查看event的数目的,前面我们已经运行过 perf stat 来查看所有的Hardware events。
|
||||
|
||||
这里我们可以加上”-e”参数,指定某一个event来看它的计数,比如page-faults,这里我们看到在当前CPU上,这个event在1秒钟内发生了49次:
|
||||
|
||||
# perf stat -e page-faults -- sleep 1
|
||||
|
||||
Performance counter stats for 'sleep 1':
|
||||
|
||||
49 page-faults
|
||||
|
||||
1.001583032 seconds time elapsed
|
||||
|
||||
0.001556000 seconds user
|
||||
0.000000000 seconds sys
|
||||
|
||||
|
||||
采样(sample)
|
||||
|
||||
说完了计数,我们再来看看采样。在开头回顾网路延时问题的时候,我提到通过 perf record -C 32 -g -- sleep 10 这个命令,来找到CPU32上CPU开销最大的Softirq相关函数。这里使用的 perf record 命令就是通过采样来得到热点函数的,我们来分析一下它是怎么做的。
|
||||
|
||||
perf record 在不加 -e 指定event的时候,它缺省的event就是Hardware event cycles。我们先用 perf stat来查看1秒钟cycles事件的数量,在下面的例子里这个数量是1878165次。
|
||||
|
||||
我们可以想一下,如果每次cycles event发生的时候,我们都记录当时的IP(就是处理器当时要执行的指令地址)、IP所属的进程等信息的话,这样系统的开销就太大了。所以perf就使用了对event采样的方式来记录IP、进程等信息。
|
||||
|
||||
# perf stat -e cycles -- sleep 1
|
||||
|
||||
Performance counter stats for 'sleep 1':
|
||||
|
||||
1878165 cycles
|
||||
|
||||
|
||||
Perf对event的采样有两种模式:
|
||||
|
||||
第一种是按照event的数目(period),比如每发生10000次cycles event就记录一次IP、进程等信息, perf record 中的 -c 参数可以指定每发生多少次,就做一次记录。
|
||||
|
||||
比如在下面的例子里,我们指定了每10000 cycles event做一次采样之后,在1秒里总共就做了191次采样,比我们之前看到1秒钟1878165次cycles的次数要少多了。
|
||||
|
||||
# perf record -e cycles -c 10000 -- sleep 1
|
||||
[ perf record: Woken up 1 times to write data ]
|
||||
[ perf record: Captured and wrote 0.024 MB perf.data (191 samples) ]
|
||||
|
||||
|
||||
第二种是定义一个频率(frequency), perf record 中的 -F 参数就是指定频率的,比如 perf record -e cycles -F 99 -- sleep 1 ,就是指采样每秒钟做99次。
|
||||
|
||||
在 perf record 运行结束后,会在磁盘的当前目录留下perf.data这个文件,里面记录了所有采样得到的信息。然后我们再运行 perf report 命令,查看函数或者指令在这些采样里的分布比例,后面我们会用一个例子说明。
|
||||
|
||||
好,说到这里,我们已经把perf的基本概念和使用机制都讲完了。接下来,我们看看在容器中怎么使用perf?
|
||||
|
||||
容器中怎样使用perf?
|
||||
|
||||
如果你的container image是基于Ubuntu或者CentOS等Linux发行版的,你可以尝试用它们的package repo安装perf的包。不过,这么做可能会有个问题,我们在前面介绍perf的时候提过,perf是和Linux kernel一起发布的,也就是说perf版本最好是和Linux kernel使用相同的版本。
|
||||
|
||||
如果容器中perf包是独立安装的,那么容器中安装的perf版本可能会和宿主机上的内核版本不一致,这样有可能导致perf无法正常工作。
|
||||
|
||||
所以,我们在容器中需要跑perf的时候,最好从相应的Linux kernel版本的源代码里去编译,并且采用静态库(-static)的链接方式。然后,我们把编译出来的perf直接copy到容器中就可以使用了。
|
||||
|
||||
如何在Linux kernel源代码里编译静态链接的perf,你可以参考后面的代码:
|
||||
|
||||
# cd $(KERNEL_SRC_ROOT)/tools/perf
|
||||
# vi Makefile.perf
|
||||
#### ADD “LDFLAGS=-static” in Makefile.perf
|
||||
# make clean; make
|
||||
# file perf
|
||||
perf: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=9a42089e52026193fabf693da3c0adb643c2313e, with debug_info, not stripped, too many notes (256)
|
||||
# ls -lh perf
|
||||
-rwxr-xr-x 1 root root 19M Aug 14 07:08 perf
|
||||
|
||||
|
||||
我这里给了一个带静态链接perf(kernel 5.4)的container image例子,你可以运行 make image 来生成这个image。
|
||||
|
||||
在容器中运行perf,还要注意一个权限的问题,有两点注意事项需要你留意。
|
||||
|
||||
第一点,Perf 通过系统调用perf_event_open()来完成对perf event的计数或者采样。不过Docker使用seccomp(seccomp是一种技术,它通过控制系统调用的方式来保障Linux安全)会默认禁止perf_event_open()。
|
||||
|
||||
所以想要让Docker启动的容器可以运行perf,我们要怎么处理呢?
|
||||
|
||||
其实这个也不难,在用Docker启动容器的时候,我们需要在seccomp的profile里,允许perf_event_open()这个系统调用在容器中使用。在我们的例子中,启动container的命令里,已经加了这个参数允许了,参数是”–security-opt seccomp=unconfined”。
|
||||
|
||||
第二点,需要允许容器在没有SYS_ADMIN这个capability(Linux capability我们在[第19讲]说过)的情况下,也可以让perf访问这些event。那么现在我们需要做的就是,在宿主机上设置出 echo -1 > /proc/sys/kernel/perf_event_paranoid,这样普通的容器里也能执行perf了。
|
||||
|
||||
完成了权限设置之后,在容器中运行perf,就和在VM/BM上运行没有什么区别了。
|
||||
|
||||
最后,我们再来说一下我们在定位CPU Uage异常时最常用的方法,常规的步骤一般是这样的:
|
||||
|
||||
首先,调用 perf record 采样几秒钟,一般需要加 -g 参数,也就是call-graph,还需要抓取函数的调用关系。在多核的机器上,还要记得加上 -a 参数,保证获取所有CPU Core上的函数运行情况。至于采样数据的多少,在讲解perf概念的时候说过,我们可以用 -c 或者 -F 参数来控制。
|
||||
|
||||
接着,我们需要运行 perf report 读取数据。不过很多时候,为了更加直观地看到各个函数的占比,我们会用 perf script 命令把perf record生成的perf.data转化成分析脚本,然后用FlameGraph工具来读取这个脚本,生成火焰图。
|
||||
|
||||
下面这组命令,就是刚才说过的使用perf的常规步骤:
|
||||
|
||||
# perf record -a -g -- sleep 60
|
||||
# perf script > out.perf
|
||||
# git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
|
||||
# FlameGraph/stackcollapse-perf.pl out.perf > out.folded
|
||||
# FlameGraph/flamegraph.pl out.folded > out.sv
|
||||
|
||||
|
||||
重点总结
|
||||
|
||||
我们这一讲学习了如何使用perf,这里我来给你总结一下重点。
|
||||
|
||||
首先,我们在线上网络延时异常的那个实际例子中使用了perf。我们发现可以用perf工具,通过抓取数据、数据读取和异常聚焦这三个步骤的操作,在CPU Usage增高的节点上找到具体引起CPU增高的函数。
|
||||
|
||||
之后我带你更深入地学习了perf是什么,它的工作方式是怎样的?这里我把perf的重点再给你强调一遍:
|
||||
|
||||
Perf的实现基础是event,有两大类,一类是基于硬件PMU的,一类是内核中的软件注册。而Perf 在使用时的工作方式也是两大类,计数和采样。
|
||||
|
||||
先看一下计数,它执行的命令是 perf stat,用来查看每种event发生的次数;
|
||||
|
||||
采样执行的命令是perf record,它可以使用period方式,就是每N个event发生后记录一次event发生时的IP/进程信息,或者用frequency方式,每秒钟以固定次数来记录信息。记录的信息会存在当前目录的perf.data文件中。
|
||||
|
||||
如果我们要在容器中使用perf,要注意这两点:
|
||||
|
||||
1.容器中的perf版本要和宿主机内核版本匹配,可以直接从源代码编译出静态链接的perf。
|
||||
2.我们需要解决两个权限的问题,一个是seccomp对系统调用的限制,还有一个是内核对容器中没有SYC_ADMIN capability的限制。
|
||||
|
||||
在我们日常分析系统性能异常的时候,使用perf最常用的方式是perf record获取采样数据,然后用FlameGraph工具来生成火焰图。
|
||||
|
||||
思考题
|
||||
|
||||
你可以在自己的一台Linux机器上运行一些带负载的程序,然后使用perf并且生成火焰图,看看开销最大的函数是哪一个。
|
||||
|
||||
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
|
||||
|
333
专栏/容器实战高手课/加餐03理解ftrace(1):怎么应用ftrace查看长延时内核函数?.md
Normal file
333
专栏/容器实战高手课/加餐03理解ftrace(1):怎么应用ftrace查看长延时内核函数?.md
Normal file
@ -0,0 +1,333 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐03 理解ftrace(1):怎么应用ftrace查看长延时内核函数?
|
||||
你好,我是程远。
|
||||
|
||||
上一讲里,我们一起学习了perf这个工具。在我们的案例里,使用perf找到了热点函数之后,我们又使用了ftrace这个工具,最终锁定了长延时的函数estimation_timer()。
|
||||
|
||||
那么这一讲,我们就来学习一下ftrace这个工具,主要分为两个部分来学习。
|
||||
|
||||
第一部分讲解ftrace的最基本的使用方法,里面也会提到在我们的案例中是如何使用的。第二部分我们一起看看Linux ftrace是如何实现的,这样可以帮助你更好地理解Linux的ftrace工具。
|
||||
|
||||
ftrace的基本使用方法
|
||||
|
||||
ftrace这个工具在2008年的时候就被合入了Linux内核,当时的版本还是Linux2.6.x。从ftrace的名字function tracer,其实我们就可以看出,它最初就是用来trace内核中的函数的。
|
||||
|
||||
当然了,现在ftrace的功能要更加丰富了。不过,function tracer作为ftrace最基本的功能,也是我们平常调试Linux内核问题时最常用到的功能。那我们就先来看看这个最基本,同时也是最重要的function tracer的功能。
|
||||
|
||||
ftrace的操作都可以在tracefs这个虚拟文件系统中完成,对于CentOS,这个tracefs的挂载点在/sys/kernel/debug/tracing下:
|
||||
|
||||
# cat /proc/mounts | grep tracefs
|
||||
tracefs /sys/kernel/debug/tracing tracefs rw,relatime 0 0
|
||||
|
||||
|
||||
你可以进入到 /sys/kernel/debug/tracing目录下,看一下这个目录下的文件:
|
||||
|
||||
# cd /sys/kernel/debug/tracing
|
||||
# ls
|
||||
available_events dyn_ftrace_total_info kprobe_events saved_cmdlines_size set_graph_notrace trace_clock tracing_on
|
||||
available_filter_functions enabled_functions kprobe_profile saved_tgids snapshot trace_marker tracing_thresh
|
||||
available_tracers error_log max_graph_depth set_event stack_max_size trace_marker_raw uprobe_events
|
||||
buffer_percent events options set_event_pid stack_trace trace_options uprobe_profile
|
||||
buffer_size_kb free_buffer per_cpu set_ftrace_filter stack_trace_filter trace_pipe
|
||||
buffer_total_size_kb function_profile_enabled printk_formats set_ftrace_notrace synthetic_events trace_stat
|
||||
current_tracer hwlat_detector README set_ftrace_pid timestamp_mode tracing_cpumask
|
||||
dynamic_events instances saved_cmdlines set_graph_function trace tracing_max_latency
|
||||
|
||||
|
||||
tracefs虚拟文件系统下的文件操作,其实和我们常用的Linux proc和sys虚拟文件系统的操作是差不多的。通过对某个文件的echo操作,我们可以向内核的ftrace系统发送命令,然后cat某个文件得到ftrace的返回结果。
|
||||
|
||||
对于ftrace,它的输出结果都可以通过 cat trace 这个命令得到。在缺省的状态下ftrace的tracer是nop,也就是ftrace什么都不做。因此,我们从cat trace中也看不到别的,只是显示了trace输出格式。
|
||||
|
||||
# pwd
|
||||
/sys/kernel/debug/tracing
|
||||
# cat trace
|
||||
|
||||
# tracer: nop
|
||||
#
|
||||
# entries-in-buffer/entries-written: 0/0 #P:12
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
|
||||
|
||||
下面,我们可以执行 echo function > current_tracer 来告诉ftrace,我要启用function tracer。
|
||||
|
||||
# cat current_tracer
|
||||
nop
|
||||
# cat available_tracers
|
||||
hwlat blk mmiotrace function_graph wakeup_dl wakeup_rt wakeup function nop
|
||||
# echo function > current_tracer
|
||||
# cat current_tracer
|
||||
function
|
||||
|
||||
|
||||
在启动了function tracer之后,我们再查看一下trace的输出。这时候我们就会看到大量的输出,每一行的输出就是当前内核中被调用到的内核函数,具体的格式你可以参考trace头部的说明。
|
||||
|
||||
# cat trace | more
|
||||
# tracer: function
|
||||
#
|
||||
# entries-in-buffer/entries-written: 615132/134693727 #P:12
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: lock_page_memcg <-page_remove_rmap
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: PageHuge <-page_remove_rmap
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: unlock_page_memcg <-page_remove_rmap
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: __unlock_page_memcg <-unlock_page_memcg
|
||||
systemd-udevd-20472 [011] .... 2148512.735026: __tlb_remove_page_size <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: vm_normal_page <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: mark_page_accessed <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: page_remove_rmap <-unmap_page_range
|
||||
systemd-udevd-20472 [011] .... 2148512.735027: lock_page_memcg <-page_remove_rmap
|
||||
…
|
||||
|
||||
|
||||
看到这个trace输出,你肯定会觉得输出的函数太多了,查看起来太困难了。别担心,下面我给你说个技巧,来解决输出函数太多的问题。
|
||||
|
||||
其实在实际使用的时候,我们可以利用ftrace里的filter参数做筛选,比如我们可以通过set_ftrace_filter只列出想看到的内核函数,或者通过set_ftrace_pid只列出想看到的进程。
|
||||
|
||||
为了让你加深理解,我给你举个例子,比如说,如果我们只是想看do_mount这个内核函数有没有被调用到,那我们就可以这么操作:
|
||||
|
||||
# echo nop > current_tracer
|
||||
# echo do_mount > set_ftrace_filter
|
||||
# echo function > current_tracer
|
||||
|
||||
|
||||
在执行了mount命令之后,我们查看一下trace。
|
||||
|
||||
这时候,我们就只会看到一条do_mount()函数调用的记录,我们一起来看看,输出结果里的几个关键参数都是什么意思。
|
||||
|
||||
输出里”do_mount <- ksys_mount”表示do_mount()函数是被ksys_mount()这个函数调用到的,”2159455.499195”表示函数执行时的时间戳,而”[005]“是内核函数do_mount()被执行时所在的CPU编号,还有”mount-20889”,它是do_mount()被执行时当前进程的pid和进程名。
|
||||
|
||||
# mount -t tmpfs tmpfs /tmp/fs
|
||||
# cat trace
|
||||
# tracer: function
|
||||
#
|
||||
# entries-in-buffer/entries-written: 1/1 #P:12
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
mount-20889 [005] .... 2159455.499195: do_mount <-ksys_mount
|
||||
|
||||
|
||||
这里我们只能判断出,ksys mount()调用了do mount()这个函数,这只是一层调用关系,如果我们想要看更加完整的函数调用栈,可以打开ftrace中的func_stack_trace选项:
|
||||
|
||||
# echo 1 > options/func_stack_trace
|
||||
|
||||
|
||||
打开以后,我们再来做一次mount操作,就可以更清楚地看到do_mount()是系统调用(syscall)之后被调用到的。
|
||||
|
||||
# umount /tmp/fs
|
||||
# mount -t tmpfs tmpfs /tmp/fs
|
||||
# cat trace
|
||||
|
||||
# tracer: function
|
||||
#
|
||||
# entries-in-buffer/entries-written: 3/3 #P:12
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
mount-20889 [005] .... 2159455.499195: do_mount <-ksys_mount
|
||||
mount-21048 [000] .... 2162013.660835: do_mount <-ksys_mount
|
||||
mount-21048 [000] .... 2162013.660841: <stack trace>
|
||||
=> do_mount
|
||||
=> ksys_mount
|
||||
=> __x64_sys_mount
|
||||
=> do_syscall_64
|
||||
=> entry_SYSCALL_64_after_hwframe
|
||||
|
||||
|
||||
结合刚才说的内容,我们知道了,通过function tracer可以帮我们判断内核中函数是否被调用到,以及函数被调用的整个路径 也就是调用栈。
|
||||
|
||||
这样我们就理清了整体的追踪思路:如果我们通过perf发现了一个内核函数的调用频率比较高,就可以通过function tracer工具继续深入,这样就能大概知道这个函数是在什么情况下被调用到的。
|
||||
|
||||
那如果我们还想知道,某个函数在内核中大致花费了多少时间,就像加餐第一讲案例中我们就拿到了estimation_timer()时间开销,又要怎么做呢?
|
||||
|
||||
这里需要用到ftrace中的另外一个tracer,它就是function_graph。我们可以在刚才的ftrace的设置基础上,把current_tracer设置为function_graph,然后就能看到do_mount()这个函数调用的时间了。
|
||||
|
||||
# echo function_graph > current_tracer
|
||||
# umount /tmp/fs
|
||||
# mount -t tmpfs tmpfs /tmp/fs
|
||||
# cat trace
|
||||
# tracer: function_graph
|
||||
#
|
||||
# CPU DURATION FUNCTION CALLS
|
||||
# | | | | | | |
|
||||
0) ! 175.411 us | do_mount();
|
||||
|
||||
|
||||
通过function_graph tracer,还可以让我们看到每个函数里所有子函数的调用以及时间,这对我们理解和分析内核行为都是很有帮助的。
|
||||
|
||||
比如说,我们想查看kfree_skb()这个函数是怎么执行的,就可以像下面这样配置:
|
||||
|
||||
# echo '!do_mount ' >> set_ftrace_filter ### 先把之前的do_mount filter给去掉。
|
||||
# echo kfree_skb > set_graph_function ### 设置kfree_skb()
|
||||
# echo nop > current_tracer ### 暂时把current_tracer设置为nop, 这样可以清空trace
|
||||
# echo function_graph > current_tracer ### 把current_tracer设置为function_graph
|
||||
|
||||
|
||||
设置完成之后,我们再来看trace的输出。现在,我们就可以看到kfree_skb()下的所有子函数的调用,以及它们花费的时间了。
|
||||
|
||||
具体输出如下,你可以做个参考:
|
||||
|
||||
# cat trace | more
|
||||
# tracer: function_graph
|
||||
#
|
||||
# CPU DURATION FUNCTION CALLS
|
||||
# | | | | | | |
|
||||
0) | kfree_skb() {
|
||||
0) | skb_release_all() {
|
||||
0) | skb_release_head_state() {
|
||||
0) | nf_conntrack_destroy() {
|
||||
0) | destroy_conntrack [nf_conntrack]() {
|
||||
0) 0.205 us | nf_ct_remove_expectations [nf_conntrack]();
|
||||
0) | nf_ct_del_from_dying_or_unconfirmed_list [nf_conntrack]() {
|
||||
0) 0.282 us | _raw_spin_lock();
|
||||
0) 0.679 us | }
|
||||
0) 0.193 us | __local_bh_enable_ip();
|
||||
0) | nf_conntrack_free [nf_conntrack]() {
|
||||
0) | nf_ct_ext_destroy [nf_conntrack]() {
|
||||
0) 0.177 us | nf_nat_cleanup_conntrack [nf_nat]();
|
||||
0) 1.377 us | }
|
||||
0) | kfree_call_rcu() {
|
||||
0) | __call_rcu() {
|
||||
0) 0.383 us | rcu_segcblist_enqueue();
|
||||
0) 1.111 us | }
|
||||
0) 1.535 us | }
|
||||
0) 0.446 us | kmem_cache_free();
|
||||
0) 4.294 us | }
|
||||
0) 6.922 us | }
|
||||
0) 7.665 us | }
|
||||
0) 8.105 us | }
|
||||
0) | skb_release_data() {
|
||||
0) | skb_free_head() {
|
||||
0) 0.470 us | page_frag_free();
|
||||
0) 0.922 us | }
|
||||
0) 1.355 us | }
|
||||
0) + 10.192 us | }
|
||||
0) | kfree_skbmem() {
|
||||
0) 0.669 us | kmem_cache_free();
|
||||
0) 1.046 us | }
|
||||
0) + 13.707 us | }
|
||||
|
||||
|
||||
好了,对于ftrace的最基本的、也是最重要的内核函数相关的tracer,我们已经知道怎样操作了。那你有没有好奇过,这个ftrace又是怎么实现的呢?下面我们就来看一下。
|
||||
|
||||
ftrace的实现机制
|
||||
|
||||
下面这张图描述了ftrace实现的high level的架构,用户通过tracefs向内核中的function tracer发送命令,然后function tracer把收集到的数据写入一个ring buffer,再通过tracefs输出给用户。
|
||||
|
||||
|
||||
|
||||
这里的整个过程看上去比较好理解。不过还是有一个问题,不知道你有没有思考过,
|
||||
|
||||
frace可以收集到内核中任意一个函数被调用的情况,这点是怎么做到的?
|
||||
|
||||
你可能想到,这是因为在内核的每个函数中都加上了hook点了吗?这时我们来看一下内核的源代码,显然并没有这样的hook点。那Linux到底是怎么实现的呢?
|
||||
|
||||
其实这里ftrace是利用了gcc编译器的特性,再加上几步非常高明的代码段替换操作,就很完美地实现了对内核中所有函数追踪的接口(这里的“所有函数”不包括“inline函数”)。下面我们一起看一下这个实现。
|
||||
|
||||
Linux内核在编译的时候,缺省会使用三个gcc的参数”-pg -mfentry -mrecord-mcount”。
|
||||
|
||||
其中,”-pg -mfentry”这两个参数的作用是,给编译出来的每个函数开头都插入一条指令”callq <fentry>“。
|
||||
|
||||
你如果编译过内核,那么你可以用”objdump -D vmlinux”来查看一下内核函数的汇编,比如do_mount()函数的开头几条汇编就是这样的:
|
||||
|
||||
ffffffff81309550 <do_mount>:
|
||||
ffffffff81309550: e8 fb 83 8f 00 callq ffffffff81c01950 <__fentry__>
|
||||
ffffffff81309555: 55 push %rbp
|
||||
ffffffff81309556: 48 89 e5 mov %rsp,%rbp
|
||||
ffffffff81309559: 41 57 push %r15
|
||||
ffffffff8130955b: 49 89 d7 mov %rdx,%r15
|
||||
ffffffff8130955e: ba 00 00 ed c0 mov $0xc0ed0000,%edx
|
||||
ffffffff81309563: 41 56 push %r14
|
||||
ffffffff81309565: 49 89 fe mov %rdi,%r14
|
||||
ffffffff81309568: 41 55 push %r13
|
||||
ffffffff8130956a: 4d 89 c5 mov %r8,%r13
|
||||
ffffffff8130956d: 41 54 push %r12
|
||||
ffffffff8130956f: 53 push %rbx
|
||||
ffffffff81309570: 48 89 cb mov %rcx,%rbx
|
||||
ffffffff81309573: 81 e1 00 00 ff ff and $0xffff0000,%ecx
|
||||
ffffffff81309579: 48 83 ec 30 sub $0x30,%rsp
|
||||
...
|
||||
|
||||
|
||||
而”-mrecord-mcount”参数在最后的内核二进制文件vmlinux中附加了一个mcount_loc的段,这个段里记录了所有”callq <fentry>“指令的地址。这样我们很容易就能找到每个函数的这个入口点。
|
||||
|
||||
为了方便你理解,我画了一张示意图,我们编译出来的vmlinux就像图里展示的这样:
|
||||
|
||||
|
||||
|
||||
不过你需要注意的是,尽管通过编译的方式,我们可以给每个函数都加上一个额外的hook点,但是这个额外”fentry”函数调用的开销是很大的。
|
||||
|
||||
即使”fentry“函数中只是一个retq指令,也会使内核性能下降13%,这对于Linux内核来说显然是不可以被接受的。那我们应该怎么办呢?
|
||||
|
||||
ftrace在内核启动的时候做了一件事,就是把内核每个函数里的第一条指令”callq <fentry>“(5个字节),替换成了”nop”指令(0F 1F 44 00 00),也就是一条空指令,表示什么都不做。
|
||||
|
||||
虽然是空指令,不过在内核的代码段里,这相当于给每个函数预留了5个字节。这样在需要的时候,内核可以再把这5个字节替换成callq指令,call的函数就可以指定成我们需要的函数了。
|
||||
|
||||
同时,内核的mcount_loc段里,虽然已经记录了每个函数”callq <fentry>“的地址,不过对于ftrace来说,除了地址之外,它还需要一些额外的信息。
|
||||
|
||||
因此,在内核启动初始化的时候,ftrace又申请了新的内存来存放mcount_loc段中原来的地址信息,外加对每个地址的控制信息,最后释放了原来的mcount_loc段。
|
||||
|
||||
所以Linux内核在机器上启动之后,在内存中的代码段和数据结构就会发生变化。你可以参考后面这张图,它描述了变化后的情况:
|
||||
|
||||
|
||||
|
||||
当我们需要用function tracer来trace某一个函数的时候,比如”echo do_mount > set_ftrace_filter”命令执行之后,do_mount()函数的第一条指令就会被替换成调用ftrace_caller的指令。
|
||||
|
||||
你可以查看后面的示意图,结合这张图来理解刚才的内容。
|
||||
|
||||
|
||||
|
||||
这样,每调用一次do_mount()函数,它都会调用function_trace_call()函数,把ftrace function trace信息放入ring buffer里,再通过tracefs输出给用户。
|
||||
|
||||
重点小结
|
||||
|
||||
这一讲我们主要讲解了Linux ftrace这个工具。
|
||||
|
||||
首先我们学习了ftrace最基本的操作,对内核函数做trace。在这里最重要的有两个tracers,分别是function和function_graph。
|
||||
|
||||
function tracer可以用来记录内核中被调用到的函数的情况。在实际使用的时候,我们可以设置一些ftrace的filter来查看某些我们关心的函数,或者我们关心的进程调用到的函数。
|
||||
|
||||
我们还可以设置func_stack_trace选项,来查看被trace函数的完整调用栈。
|
||||
|
||||
而function_graph trracer可以用来查看内核函数和它的子函数调用关系以及调用时间,这对我们理解内核的行为非常有帮助。
|
||||
|
||||
讲完了ftrace的基本操作之后,我们又深入研究了ftrace在Linux中的实现机制。
|
||||
|
||||
在ftrace实现过程里,最重要的一个环节是利用gcc编译器的特性,为每个内核函数二进制码中预留了5个字节,这样内核函数就可以调用调试需要的函数,从而实现了ftrace的功能。
|
||||
|
||||
思考题
|
||||
|
||||
我们讲ftrace实现机制时,说过内核中的“inline函数”不能被ftrace到,你知道这是为什么吗?那么内核中的”static函数”可以被ftrace追踪到吗?
|
||||
|
||||
欢迎你在留言区跟我分享你的思考与疑问,如果这一讲对你有启发,也欢迎转发给你的同事、朋友,跟他一起交流学习。
|
||||
|
||||
|
||||
|
||||
|
340
专栏/容器实战高手课/加餐04理解ftrace(2):怎么理解ftrace背后的技术tracepoint和kprobe?.md
Normal file
340
专栏/容器实战高手课/加餐04理解ftrace(2):怎么理解ftrace背后的技术tracepoint和kprobe?.md
Normal file
@ -0,0 +1,340 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐04 理解ftrace(2):怎么理解ftrace背后的技术tracepoint和kprobe?
|
||||
你好,我是程远。
|
||||
|
||||
前面两讲,我们分别学习了perf和ftrace这两个最重要 Linux tracing工具。在学习过程中,我们把重点放在了这两个工具最基本的功能点上。
|
||||
|
||||
不过你学习完这些之后,我们内核调试版图的知识点还没有全部点亮。
|
||||
|
||||
如果你再去查看一些perf、ftrace或者其他Linux tracing相关资料,你可能会常常看到两个单词,“tracepoint”和“kprobe”。你有没有好奇过,这两个名词到底是什么意思,它们和perf、ftrace这些工具又是什么关系呢?
|
||||
|
||||
这一讲,我们就来学习这两个在Linux tracing系统中非常重要的概念,它们就是tracepoint和kprobe。
|
||||
|
||||
tracepoint和kprobe的应用举例
|
||||
|
||||
如果你深入地去看一些perf或者ftrace的功能,这时候你会发现它们都有跟tracepoint、kprobe相关的命令。我们先来看几个例子,通过这几个例子,你可以大概先了解一下tracepoint和kprobe的应用,这样我们后面做详细的原理介绍时,你也会更容易理解。
|
||||
|
||||
首先看看tracepoint,tracepoint其实就是在Linux内核的一些关键函数中埋下的hook点,这样在tracing的时候,我们就可以在这些固定的点上挂载调试的函数,然后查看内核的信息。
|
||||
|
||||
我们通过下面的这个 perf list 命令,就可以看到所有的tracepoints:
|
||||
|
||||
# perf list | grep Tracepoint
|
||||
alarmtimer:alarmtimer_cancel [Tracepoint event]
|
||||
alarmtimer:alarmtimer_fired [Tracepoint event]
|
||||
alarmtimer:alarmtimer_start [Tracepoint event]
|
||||
alarmtimer:alarmtimer_suspend [Tracepoint event]
|
||||
block:block_bio_backmerge [Tracepoint event]
|
||||
block:block_bio_bounce [Tracepoint event]
|
||||
block:block_bio_complete [Tracepoint event]
|
||||
block:block_bio_frontmerge [Tracepoint event]
|
||||
block:block_bio_queue [Tracepoint event]
|
||||
…
|
||||
|
||||
|
||||
至于ftrace,你在tracefs文件系统中,也会看到一样的tracepoints:
|
||||
|
||||
# find /sys/kernel/debug/tracing/events -type d | sort
|
||||
/sys/kernel/debug/tracing/events
|
||||
/sys/kernel/debug/tracing/events/alarmtimer
|
||||
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_cancel
|
||||
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_fired
|
||||
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_start
|
||||
/sys/kernel/debug/tracing/events/alarmtimer/alarmtimer_suspend
|
||||
/sys/kernel/debug/tracing/events/block
|
||||
/sys/kernel/debug/tracing/events/block/block_bio_backmerge
|
||||
/sys/kernel/debug/tracing/events/block/block_bio_bounce
|
||||
/sys/kernel/debug/tracing/events/block/block_bio_complete
|
||||
/sys/kernel/debug/tracing/events/block/block_bio_frontmerge
|
||||
|
||||
…
|
||||
|
||||
|
||||
为了让你更好理解,我们就拿“do_sys_open”这个tracepoint做例子。在内核函数do_sys_open()中,有一个trace_do_sys_open()调用,其实它这就是一个tracepoint:
|
||||
|
||||
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
|
||||
{
|
||||
struct open_flags op;
|
||||
int fd = build_open_flags(flags, mode, &op);
|
||||
struct filename *tmp;
|
||||
|
||||
if (fd)
|
||||
return fd;
|
||||
|
||||
tmp = getname(filename);
|
||||
if (IS_ERR(tmp))
|
||||
return PTR_ERR(tmp);
|
||||
|
||||
fd = get_unused_fd_flags(flags);
|
||||
if (fd >= 0) {
|
||||
struct file *f = do_filp_open(dfd, tmp, &op);
|
||||
if (IS_ERR(f)) {
|
||||
put_unused_fd(fd);
|
||||
fd = PTR_ERR(f);
|
||||
} else {
|
||||
fsnotify_open(f);
|
||||
fd_install(fd, f);
|
||||
trace_do_sys_open(tmp->name, flags, mode);
|
||||
}
|
||||
}
|
||||
putname(tmp);
|
||||
return fd;
|
||||
}
|
||||
|
||||
|
||||
接下来,我们可以通过perf命令,利用tracepoint来查看一些内核函数发生的频率,比如在节点上,统计10秒钟内调用do_sys_open成功的次数,也就是打开文件的次数。
|
||||
|
||||
# # perf stat -a -e fs:do_sys_open -- sleep 10
|
||||
|
||||
Performance counter stats for 'system wide':
|
||||
|
||||
7 fs:do_sys_open
|
||||
|
||||
10.001954100 seconds time elapsed
|
||||
|
||||
|
||||
同时,如果我们把tracefs中do_sys_open的tracepoint打开,那么在ftrace的trace输出里,就可以看到具体do_sys_open每次调用成功时,打开的文件名、文件属性、对应的进程等信息。
|
||||
|
||||
# pwd
|
||||
/sys/kernel/debug/tracing
|
||||
# echo 1 > events/fs/do_sys_open/enable
|
||||
|
||||
# cat trace
|
||||
# tracer: nop
|
||||
#
|
||||
# _-----=> irqs-off
|
||||
# / _----=> need-resched
|
||||
# | / _---=> hardirq/softirq
|
||||
# || / _--=> preempt-depth
|
||||
# ||| / delay
|
||||
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
|
||||
# | | | |||| | |
|
||||
systemd-1 [011] .... 17133447.451839: do_sys_open: "/proc/22597/cgroup" 88000 666
|
||||
bash-4118 [009] .... 17133450.076026: do_sys_open: "/" 98800 0
|
||||
salt-minion-7101 [010] .... 17133450.478659: do_sys_open: "/etc/hosts" 88000 666
|
||||
systemd-journal-2199 [011] .... 17133450.487930: do_sys_open: "/proc/6989/cgroup" 88000 666
|
||||
systemd-journal-2199 [011] .... 17133450.488019: do_sys_open: "/var/log/journal/d4f76e4bf5414ac78e1c534ebe5d0a72" 98800 0
|
||||
systemd-journal-2199 [011] .... 17133450.488080: do_sys_open: "/proc/6989/comm" 88000 666
|
||||
systemd-journal-2199 [011] .... 17133450.488114: do_sys_open: "/proc/6989/cmdline" 88000 666
|
||||
systemd-journal-2199 [011] .... 17133450.488143: do_sys_open: "/proc/6989/status" 88000 666
|
||||
systemd-journal-2199 [011] .... 17133450.488185: do_sys_open: "/proc/6989/sessionid" 88000 666
|
||||
…
|
||||
|
||||
|
||||
请注意,Tracepoint是在内核中固定的hook点,并不是在所有的函数中都有tracepoint。
|
||||
|
||||
比如在上面的例子里,我们看到do_sys_open()调用到了do_filp_open(),但是do_filp_open()函数里是没有tracepoint的。那如果想看到do_filp_open()函数被调用的频率,或者do_filp_open()在被调用时传入参数的情况,我们又该怎么办呢?
|
||||
|
||||
这时候,我们就需要用到kprobe了。kprobe可以动态地在所有的内核函数(除了inline函数)上挂载probe函数。我们还是结合例子做理解,先看看perf和ftraces是怎么利用kprobe来做调试的。
|
||||
|
||||
比如对于do_filp_open()函数,我们可以通过perf probe添加一下,然后用perf stat 看看在10秒钟的时间里,这个函数被调用到的次数。
|
||||
|
||||
# perf probe --add do_filp_open
|
||||
# perf stat -a -e probe:do_filp_open -- sleep 10
|
||||
|
||||
Performance counter stats for 'system wide':
|
||||
|
||||
11 probe:do_filp_open
|
||||
|
||||
10.001489223 seconds time elapsed
|
||||
|
||||
|
||||
我们也可以通过ftrace的tracefs给do_filp_open()添加一个kprobe event,这样就能查看do_filp_open()每次被调用的时候,前面两个参数的值了。
|
||||
|
||||
这里我要给你说明一下,在写入kprobe_event的时候,对于参数的定义我们用到了“%di”和“%si”。这是x86处理器里的寄存器,根据x86的Application Binary Interface的文档,在函数被调用的时候,%di存放了第一个参数,%si存放的是第二个参数。
|
||||
|
||||
# echo 'p:kprobes/myprobe do_filp_open dfd=+0(%di):u32 pathname=+0(+0(%si)):string' > /sys/kernel/debug/tracing/kprobe_event
|
||||
|
||||
|
||||
完成上面的写入之后,我们再enable这个新建的kprobe event。这样在trace中,我们就可以看到每次do_filp_open()被调用时前两个参数的值了。
|
||||
|
||||
# echo 1 > /sys/kernel/debug/tracing/events/kprobes/myprobe/enable
|
||||
|
||||
# cat /sys/kernel/debug/tracing/trace
|
||||
…
|
||||
irqbalance-1328 [005] .... 2773211.189573: myprobe: (do_filp_open+0x0/0x100) dfd=4294967295 pathname="/proc/interrupts"
|
||||
irqbalance-1328 [005] .... 2773211.189740: myprobe: (do_filp_open+0x0/0x100) dfd=638399 pathname="/proc/stat"
|
||||
irqbalance-1328 [005] .... 2773211.189800: myprobe: (do_filp_open+0x0/0x100) dfd=638399 pathname="/proc/irq/8/smp_affinity"
|
||||
bash-15864 [004] .... 2773211.219048: myprobe: (do_filp_open+0x0/0x100) dfd=14819 pathname="/sys/kernel/debug/tracing/"
|
||||
bash-15864 [004] .... 2773211.891472: myprobe: (do_filp_open+0x0/0x100) dfd=6859 pathname="/sys/kernel/debug/tracing/"
|
||||
bash-15864 [004] .... 2773212.036449: myprobe: (do_filp_open+0x0/0x100) dfd=4294967295 pathname="/sys/kernel/debug/tracing/"
|
||||
bash-15864 [004] .... 2773212.197525: myprobe: (do_filp_open+0x0/0x100) dfd=638259 pathname="/sys/kernel/debug/tracing/
|
||||
…
|
||||
|
||||
|
||||
好了,我们通过perf和ftrace的几个例子,简单了解了tracepoint和kprobe是怎么用的。那下面我们再来看看它们的实现原理。
|
||||
|
||||
Tracepoint
|
||||
|
||||
刚才,我们已经看到了内核函数do_sys_open()里调用了trace_do_sys_open()这个treacepoint,那这个tracepoint是怎么实现的呢?我们还要再仔细研究一下。
|
||||
|
||||
如果你在内核代码中,直接搜索“trace_do_sys_open”字符串的话,并不能找到这个函数的直接定义。这是因为在Linux中,每一个tracepoint的相关数据结构和函数,主要是通过”DEFINE_TRACE”和”DECLARE_TRACE”这两个宏来定义的。
|
||||
|
||||
完整的“DEFINE_TRACE”和“DECLARE_TRACE”宏里,给每个tracepoint都定义了一组函数。在这里,我会选择最主要的几个函数,把定义一个tracepoint的过程给你解释一下。
|
||||
|
||||
首先,我们来看“trace_##name”这个函数(提示一下,这里的“##”是C语言的预编译宏,表示把两个字符串连接起来)。
|
||||
|
||||
对于每个命名为“name”的tracepoint,这个宏都会帮助它定一个函数。这个函数的格式是这样的,以“trace_”开头,再加上tracepoint的名字。
|
||||
|
||||
我们举个例子吧。比如说,对于“do_sys_open”这个tracepoint,它生成的函数名就是trace_do_sys_open。而这个函数会被内核函数do_sys_open()调用,从而实现了一个内核的tracepoint。
|
||||
|
||||
static inline void trace_##name(proto) \
|
||||
{ \
|
||||
if (static_key_false(&__tracepoint_##name.key)) \
|
||||
__DO_TRACE(&__tracepoint_##name, \
|
||||
TP_PROTO(data_proto), \
|
||||
TP_ARGS(data_args), \
|
||||
TP_CONDITION(cond), 0); \
|
||||
if (IS_ENABLED(CONFIG_LOCKDEP) && (cond)) { \
|
||||
rcu_read_lock_sched_notrace(); \
|
||||
rcu_dereference_sched(__tracepoint_##name.funcs);\
|
||||
rcu_read_unlock_sched_notrace(); \
|
||||
} \
|
||||
}
|
||||
|
||||
|
||||
在这个tracepoint函数里,主要的功能是这样实现的,通过__DO_TRACE来调用所有注册在这个tracepoint上的probe函数。
|
||||
|
||||
#define __DO_TRACE(tp, proto, args, cond, rcuidle) \
|
||||
…
|
||||
|
||||
it_func_ptr = rcu_dereference_raw((tp)->funcs); \
|
||||
\
|
||||
if (it_func_ptr) { \
|
||||
do { \
|
||||
it_func = (it_func_ptr)->func; \
|
||||
__data = (it_func_ptr)->data; \
|
||||
((void(*)(proto))(it_func))(args); \
|
||||
} while ((++it_func_ptr)->func); \
|
||||
}
|
||||
…
|
||||
|
||||
…
|
||||
|
||||
|
||||
而probe函数的注册,它可以通过宏定义的“registertrace##name”函数完成。
|
||||
|
||||
static inline int \
|
||||
register_trace_##name(void (*probe)(data_proto), void *data) \
|
||||
{ \
|
||||
return tracepoint_probe_register(&__tracepoint_##name, \
|
||||
(void *)probe, data); \
|
||||
}
|
||||
|
||||
|
||||
我们可以自己写一个简单kernel module来注册一个probe函数,把它注册到已有的treacepoint上。这样,这个probe函数在每次tracepoint点被调用到的时候就会被执行。你可以动手试一下。
|
||||
|
||||
好了,说到这里,tracepoint的实现方式我们就讲完了。简单来说就是在内核代码中需要被trace的地方显式地加上hook点,然后再把自己的probe函数注册上去,那么在代码执行的时候,就可以执行probe函数。
|
||||
|
||||
Kprobe
|
||||
|
||||
我们已经知道了,tracepoint为内核trace提供了hook点,但是这些hook点需要在内核源代码中预先写好。如果在debug的过程中,我们需要查看的内核函数中没有hook点,就需要像前面perf/ftrace的例子中那样,要通过Linux kprobe机制来加载probe函数。
|
||||
|
||||
那我们要怎么来理解kprobe的实现机制呢?
|
||||
|
||||
你可以先从内核samples代码里,看一下
|
||||
|
||||
kprobe_example.c代码。这段代码里实现了一个kernel module,可以在内核中任意一个函数名/符号对应的代码地址上注册三个probe函数,分别是“pre_handler”、 “post_handler”和“fault_handler”。
|
||||
|
||||
#define MAX_SYMBOL_LEN 64
|
||||
static char symbol[MAX_SYMBOL_LEN] = "_do_fork";
|
||||
module_param_string(symbol, symbol, sizeof(symbol), 0644);
|
||||
|
||||
/* For each probe you need to allocate a kprobe structure */
|
||||
static struct kprobe kp = {
|
||||
.symbol_name = symbol,
|
||||
};
|
||||
|
||||
…
|
||||
|
||||
static int __init kprobe_init(void)
|
||||
{
|
||||
int ret;
|
||||
kp.pre_handler = handler_pre;
|
||||
kp.post_handler = handler_post;
|
||||
kp.fault_handler = handler_fault;
|
||||
|
||||
ret = register_kprobe(&kp);
|
||||
if (ret < 0) {
|
||||
pr_err("register_kprobe failed, returned %d\n", ret);
|
||||
return ret;
|
||||
}
|
||||
pr_info("Planted kprobe at %p\n", kp.addr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
当这个内核函数被执行的时候,已经注册的probe函数也会被执行 (handler_fault只有在发生异常的时候才会被调用到)。
|
||||
|
||||
比如,我们加载的这个kernel module不带参数,那么缺省的情况就是这样的:在“_do_fork”内核函数的入口点注册了这三个probe函数。
|
||||
|
||||
当_do_fork()函数被调用到的时候,换句话说,也就是创建新的进程时,我们通过dmesg就可以看到probe函数的输出了。
|
||||
|
||||
[8446287.087641] <_do_fork> pre_handler: p->addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
|
||||
[8446287.087643] <_do_fork> post_handler: p->addr = 0x00000000d301008e, flags = 0x246
|
||||
[8446288.019731] <_do_fork> pre_handler: p->addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
|
||||
[8446288.019733] <_do_fork> post_handler: p->addr = 0x00000000d301008e, flags = 0x246
|
||||
[8446288.022091] <_do_fork> pre_handler: p->addr = 0x00000000d301008e, ip = ffffffffb1e8c9d1, flags = 0x246
|
||||
[8446288.022093] <_do_fork> post_handler: p->addr = 0x00000000d301008e, flags = 0x246
|
||||
|
||||
|
||||
kprobe的基本工作原理其实也很简单。当kprobe函数注册的时候,其实就是把目标地址上内核代码的指令码,替换成了“cc”,也就是int3指令。这样一来,当内核代码执行到这条指令的时候,就会触发一个异常而进入到Linux int3异常处理函数do_int3()里。
|
||||
|
||||
在do_int3()这个函数里,如果发现有对应的kprobe注册了probe,就会依次执行注册的pre_handler(),原来的指令,最后是post_handler()。
|
||||
|
||||
|
||||
|
||||
理论上kprobe其实只要知道内核代码中任意一条指令的地址,就可以为这个地址注册probe函数,kprobe结构中的“addr”成员就可以接受内核中的指令地址。
|
||||
|
||||
static int __init kprobe_init(void)
|
||||
{
|
||||
int ret;
|
||||
kp.addr = (kprobe_opcode_t *)0xffffffffb1e8ca02; /* 把一条指令的地址赋值给 kprobe.addr */
|
||||
kp.pre_handler = handler_pre;
|
||||
kp.post_handler = handler_post;
|
||||
kp.fault_handler = handler_fault;
|
||||
|
||||
ret = register_kprobe(&kp);
|
||||
if (ret < 0) {
|
||||
pr_err("register_kprobe failed, returned %d\n", ret);
|
||||
return ret;
|
||||
}
|
||||
pr_info("Planted kprobe at %p\n", kp.addr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
还要说明的是,如果内核可以使用我们上一讲ftrace对函数的trace方式,也就是函数头上预留了“callq <fentry>”的5个字节(在启动的时候被替换成了nop)。Kprobe对于函数头指令的trace方式,也会用“ftrace_caller”指令替换的方式,而不再使用int3指令替换。
|
||||
|
||||
不论是哪种替换方式,kprobe的基本实现原理都是一样的,那就是把目标指令替换,替换的指令可以使程序跑到一个特定的handler里,去执行probe的函数。
|
||||
|
||||
重点小结
|
||||
|
||||
这一讲我们主要学习了tracepoint和kprobe,这两个概念在Linux tracing系统中非常重要。
|
||||
|
||||
为什么说它们重要呢?因为从Linux tracing系统看,我的理解是可以大致分成大致这样三层。
|
||||
|
||||
第一层是最基础的提供数据的机制,这里就包含了tracepoints、kprobes,还有一些别的events,比如perf使用的HW/SW events。
|
||||
|
||||
第二层是进行数据收集的工具,这里包含了ftrace、perf,还有ebpf。
|
||||
|
||||
第三层是用户层工具。虽然有了第二层,用户也可以得到数据。不过,对于大多数用户来说,第二层使用的友好程度还不够,所以又有了这一层。
|
||||
|
||||
|
||||
|
||||
很显然,如果要对Linux内核调试,很难绕过tracepoint和kprobe。如果不刨根问底的话,前面我们讲的perf、trace工具对你来说还是黑盒。因为你只是知道了这些工具怎么用,但是并不知道它们依赖的底层技术。
|
||||
|
||||
在后面介绍ebpf的时候,我们还会继续学习ebpf是如何使用tracepoint和kprobe来做Linux tracing的,希望你可以把相关知识串联起来。
|
||||
|
||||
思考题
|
||||
|
||||
想想看,当我们用kprobe为一个内核函数注册了probe之后,怎样能看到对应内核函数的第一条指令被替换了呢?
|
||||
|
||||
欢迎你在留言区记录你的思考或者疑问。如果这一讲对你有帮助,也欢迎你转发给同事、朋友,跟他们一起交流、进步。
|
||||
|
||||
|
||||
|
||||
|
326
专栏/容器实战高手课/加餐05eBPF:怎么更加深入地查看内核中的函数?.md
Normal file
326
专栏/容器实战高手课/加餐05eBPF:怎么更加深入地查看内核中的函数?.md
Normal file
@ -0,0 +1,326 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐05 eBPF:怎么更加深入地查看内核中的函数?
|
||||
你好,我是程远。
|
||||
|
||||
今天这一讲,我们聊一聊eBPF。在我们专题加餐第一讲的分析案例时就说过,当我们碰到网络延时问题,在毫无头绪的情况下,就是依靠了我们自己写的一个eBPF工具,找到了问题的突破口。
|
||||
|
||||
由此可见,eBPF在内核问题追踪上的重要性是不言而喻的。那什么是eBPF,它的工作原理是怎么样,它的编程模型又是怎样的呢?
|
||||
|
||||
在这一讲里,我们就来一起看看这几个问题。
|
||||
|
||||
eBPF的概念
|
||||
|
||||
eBPF,它的全称是“Extended Berkeley Packet Filter”。从名字看,你可能会觉奇怪,似乎它就是一个用来做网络数据包过滤的模块。
|
||||
|
||||
其实这么想也没有错,eBPF的概念最早源自于BSD操作系统中的BPF(Berkeley Packet Filter),1992伯克利实验室的一篇论文 “The BSD Packet Filter: A New Architecture for User-level Packet Capture”。这篇论文描述了,BPF是如何更加高效灵活地从操作系统内核中抓取网络数据包的。
|
||||
|
||||
我们很熟悉的tcpdump工具,它就是利用了BPF的技术来抓取Unix操作系统节点上的网络包。Linux系统中也沿用了BPF的技术。
|
||||
|
||||
那BPF是怎样从内核中抓取数据包的呢?我借用BPF论文中的图例来解释一下:
|
||||
|
||||
|
||||
|
||||
结合这张图,我们一起看看BPF实现有哪些特点。
|
||||
|
||||
第一,内核中实现了一个虚拟机,用户态程序通过系统调用,把数据包过滤代码载入到个内核态虚拟机中运行,这样就实现了内核态对数据包的过滤。这一块对应图中灰色的大方块,也就是BPF的核心。
|
||||
|
||||
第二,BPF模块和网络协议栈代码是相互独立的,BPF只是通过简单的几个hook点,就能从协议栈中抓到数据包。内核网络协议代码变化不影响BPF的工作,图中右边的“protocol stack”方块就是指内核网络协议栈。
|
||||
|
||||
第三,内核中的BPF filter模块使用buffer与用户态程序进行通讯,把filter的结果返回给用户态程序(例如图中的 network monitor),这样就不会产生内核态与用户态的上下文切换(context switch)。
|
||||
|
||||
在BPF实现的基础上,Linux在2014年内核3.18的版本上实现了eBPF,全名是Extended BPF,也就是BPF的扩展。这个扩展主要做了下面这些改进。
|
||||
|
||||
首先,对虚拟机做了增强,扩展了寄存器和指令集的定义,提高了虚拟机的性能,并且可以处理更加复杂的程序。
|
||||
|
||||
其次,增加了eBPF maps,这是一种存储类型,可以保存状态信息,从一个BPF事件的处理函数传递给另一个,或者保存一些统计信息,从内核态传递给用户态程序。
|
||||
|
||||
最后,eBPF可以处理更多的内核事件,不再只局限在网络事件上。你可以这样来理解,eBPF的程序可以在更多内核代码hook点上注册了,比如tracepoints、kprobes等。
|
||||
|
||||
在Brendan Gregg 写的书《BPF Performance Tools》里有一张eBPF的架构图,这张图对eBPF内核部分的模块和工作流的描述还是挺完整的,我也推荐你阅读这本书。图书的网上预览部分也可以看到这张图,我把它放在这里,你可以先看一下。
|
||||
|
||||
这里我想提醒你,我们在后面介绍例子程序的时候,你可以回头再来看看这张图,那时你会更深刻地理解这张图里的模块。
|
||||
|
||||
|
||||
当BPF增强为eBPF之后, 它的应用范围自然也变广了。从单纯的网络包抓取,扩展到了下面的几个领域:
|
||||
|
||||
|
||||
网络领域,内核态网络包的快速处理和转发,你可以看一下XDP(eXpress Data Path)。
|
||||
安全领域,通过LSM(Linux Security Module)的hook点,eBPF可以对Linux内核做安全监控和访问控制,你可以参考KRSI(Kernel Runtime Security Instrumentation)的文档。
|
||||
内核追踪/调试,eBPF能通过tracepoints、kprobes、 perf-events等hook点来追踪和调试内核,这也是我们在调试生产环境中,解决容器相关问题时使用的方法。
|
||||
|
||||
|
||||
eBPF的编程模型
|
||||
|
||||
前面说了很多eBPF概念方面的内容,如果你是刚接触eBPF,也许还不能完全理解。所以接下来,我们看一下eBPF编程模型,然后通过一个编程例子,再帮助你理解eBPF。
|
||||
|
||||
eBPF程序其实也是遵循了一个固定的模式,Daniel Thompson的“Kernel analysis using eBPF”里的一张图解读得非常好,它很清楚地说明了eBPF的程序怎么编译、加载和运行的。
|
||||
|
||||
|
||||
|
||||
结合这张图,我们一起分析一下eBPF的运行原理。
|
||||
|
||||
一个eBPF的程序分为两部分,第一部分是内核态的代码,也就是图中的foo_kern.c,这部分的代码之后会在内核eBPF的虚拟机中执行。第二部分是用户态的代码,对应图中的foo_user.c。它的主要功能是负责加载内核态的代码,以及在内核态代码运行后通过eBPF maps从内核中读取数据。
|
||||
|
||||
然后我们看看eBPF内核态程序的编译,因为内核部分的代码需要被编译成eBPF bytecode二进制文件,也就是eBPF的虚拟机指令,而在Linux里,最常用的GCC编译器不支持生成eBPF bytecode,所以这里必须要用 Clang/LLVM 来编译,编译后的文件就是foo_kern.o。
|
||||
|
||||
foo_user.c编译链接后就会生成一个普通的用户态程序,它会通过bpf() 系统调用做两件事:第一是去加载eBPF bytecode文件foo_kern.o,使foo_kern.o这个eBPF bytecode在内核eBPF的虚拟机中运行;第二是创建eBPF maps,用于内核态与用户态的通讯。
|
||||
|
||||
接下来,在内核态,eBPF bytecode会被加载到eBPF内核虚拟机中,这里你可以参考一下前面的eBPF架构图。
|
||||
|
||||
执行BPF程序之前,BPF Verifier先要对eBPF bytecode进行很严格的指令检查。检查通过之后,再通过JIT(Just In Time)编译成宿主机上的本地指令。
|
||||
|
||||
编译成本地指令之后,eBPF程序就可以在内核中运行了,比如挂载到tracepoints hook点,或者用kprobes来对内核函数做分析,然后把得到的数据存储到eBPF maps中,这样foo_user这个用户态程序就可以读到数据了。
|
||||
|
||||
我们学习eBPF的编程的时候,可以从编译和执行Linux内核中 samples/bpf 目录下的例子开始。在这个目录下的例子里,包含了eBPF各种使用场景。每个例子都有两个.c文件,命名规则都是xxx_kern.c和xxx_user.c ,编译和运行的方式就和我们刚才讲的一样。
|
||||
|
||||
本来我想拿samples/bpf 目录下的一个例子来具体说明的,不过后来我在github上看到了一个更好的例子,它就是ebpf-kill-example。下面,我就用这个例子来给你讲一讲,如何编写eBPF程序,以及eBPF代码需要怎么编译与运行。
|
||||
|
||||
我们先用git clone取一下代码:
|
||||
|
||||
# git clone https://github.com/niclashedam/ebpf-kill-example
|
||||
# cd ebpf-kill-example/
|
||||
# ls
|
||||
docs img LICENSE Makefile README.md src test
|
||||
|
||||
|
||||
这里你可以先看一下Makefile,请注意编译eBPF程序需要Clang/LLVM,以及由Linux内核源代码里的tools/lib/bpf中生成的libbpf.so库和相关的头文件。如果你的OS是Ubuntu,可以运行make deps;make kernel-src这个命令,准备好编译的环境。
|
||||
|
||||
# cat Makefile
|
||||
…
|
||||
deps:
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential git make gcc clang libelf-dev gcc-multilib
|
||||
|
||||
kernel-src:
|
||||
git clone --depth 1 --single-branch --branch ${LINUX_VERSION} https://github.com/torvalds/linux.git kernel-src
|
||||
cd kernel-src/tools/lib/bpf && make && make install prefix=../../../../
|
||||
…
|
||||
|
||||
|
||||
完成上面的步骤后,在src/目录下,我们可以看到两个文件,分别是bpf_program.c和loader.c。
|
||||
|
||||
在这个例子里,bpf_program.c对应前面说的foo_kern.c 文件,也就是说eBPF内核态的代码在bpf_program.c里面。而loader.c就是eBPF用户态的代码,它主要负责把eBPF bytecode加载到内核中,并且通过eBPF Maps读取内核中返回的数据。
|
||||
|
||||
# ls src/
|
||||
bpf_program.c loader.c
|
||||
|
||||
|
||||
我们先看一下bpf_program.c中的内容:
|
||||
|
||||
# cat src/bpf_program.c
|
||||
#include <linux/bpf.h>
|
||||
#include <stdlib.h>
|
||||
#include "bpf_helpers.h"
|
||||
|
||||
//这里定义了一个eBPF Maps
|
||||
//Data in this map is accessible in user-space
|
||||
struct bpf_map_def SEC("maps") kill_map = {
|
||||
.type = BPF_MAP_TYPE_HASH,
|
||||
.key_size = sizeof(long),
|
||||
.value_size = sizeof(char),
|
||||
.max_entries = 64,
|
||||
};
|
||||
|
||||
// This is the tracepoint arguments of the kill functions
|
||||
// /sys/kernel/debug/tracing/events/syscalls/sys_enter_kill/format
|
||||
struct syscalls_enter_kill_args {
|
||||
long long pad;
|
||||
|
||||
long syscall_nr;
|
||||
long pid;
|
||||
long sig;
|
||||
};
|
||||
|
||||
// 这里定义了BPF_PROG_TYPE_TRACEPOINT类型的BPF Program
|
||||
SEC("tracepoint/syscalls/sys_enter_kill")
|
||||
int bpf_prog(struct syscalls_enter_kill_args *ctx) {
|
||||
// Ignore normal program terminations
|
||||
if(ctx->sig != 9) return 0;
|
||||
|
||||
// We can call glibc functions in eBPF
|
||||
long key = labs(ctx->pid);
|
||||
int val = 1;
|
||||
|
||||
// Mark the PID as killed in the map
|
||||
bpf_map_update_elem(&kill_map, &key, &val, BPF_NOEXIST);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// All eBPF programs must be GPL licensed
|
||||
char _license[] SEC("license") = "GPL";
|
||||
|
||||
|
||||
在这一小段代码中包含了eBPF代码最重要的三个要素,分别是:
|
||||
|
||||
|
||||
BPF Program Types
|
||||
BPF Maps
|
||||
BPF Helpers
|
||||
|
||||
|
||||
“BPF Program Types”定义了函数在eBPF内核态的类型,这个类型决定了这个函数会在内核中的哪个hook点执行,同时也决定了函数的输入参数的类型。在内核代码bpf_prog_type的枚举定义里,你可以看到eBPF支持的所有“BPF Program Types”。
|
||||
|
||||
比如在这个例子里的函数bpf_prog(),通过SEC()这个宏,我们可以知道它的类型是 BPF_PROG_TYPE_TRACEPOINT,并且它注册在syscalls subsystem下的 sys_enter_kill这个tracepoint上。
|
||||
|
||||
既然我们知道了具体的tracepoint,那么这个tracepoint的注册函数的输入参数也就固定了。在这里,我们就把参数组织到syscalls_enter_kill_args{}这个结构里,里面最主要的信息就是kill()系统调用中,输入信号的编号sig和信号发送目标进程的pid。
|
||||
|
||||
“BPF Maps”定义了key/value 对的一个存储结构,它用于eBPF内核态程序之间,或者内核态程序与用户态程序之间的数据通讯。eBPF中定义了不同类型的Maps,在内核代码bpf_map_type的枚举定义中,你可以看到完整的定义。
|
||||
|
||||
在这个例子里,定义的kill_map是BPF_MAP_TYPE_HASH 类型,这里也用到了SEC()这个宏,等会儿我们再解释,先看其他的。
|
||||
|
||||
kill_map是HASH Maps里的一个key,它是一个long数据类型,value是一个char字节。bpf_prog()函数在系统调用kill()的tracepoint上运行,可以得到目标进程的pid参数,Maps里的key值就是这个pid参数来赋值的,而val只是简单赋值为1。
|
||||
|
||||
然后,这段程序调用了一个函数bpf_map_update_elem(),把这组新的key/value对写入了到kill_map中。这个函数bpf_map_update_elem()就是我们要说的第三个要素BPF Helpers。
|
||||
|
||||
我们再看一下“BPF Helpers”,它定义了一组可以在eBPF内核态程序中调用的函数。
|
||||
|
||||
尽管eBPF程序在内核态运行,但是跟kernel module不一样,eBPF程序不能调用普通内核export出来的函数,而是只能调用在内核中为eBPF事先定义好的一些接口函数。这些接口函数叫作BPF Helpers,具体有哪些你可以在”Linux manual page”中查看。
|
||||
|
||||
看明白这段代码之后,我们就可以运行 make build 命令,把C代码编译成eBPF bytecode了。这里生成了 src/bpf_program.o 这个文件:
|
||||
|
||||
# make build
|
||||
clang -O2 -target bpf -c src/bpf_program.c -Ikernel-src/tools/testing/selftests/bpf -Ikernel-src/tools/lib/bpf -o src/bpf_program.o
|
||||
|
||||
# ls -l src/bpf_program.o
|
||||
-rw-r----- 1 root root 1128 Jan 24 00:50 src/bpf_program.o
|
||||
|
||||
|
||||
接下来,你可以用LLVM工具来看一下eBPF bytecode里的内容,这样做可以确认下面两点。
|
||||
|
||||
|
||||
编译生成了BPF虚拟机的汇编指令,而不是x86的指令。
|
||||
在代码中用SEC宏添加的“BPF Program Types”和“BPF Maps”信息也在后面的section里。
|
||||
|
||||
|
||||
查看eBPF bytecode信息的操作如下:
|
||||
|
||||
### 用objdump来查看bpf_program.o里的汇编指令
|
||||
# llvm-objdump -D src/bpf_program.o
|
||||
…
|
||||
|
||||
Disassembly of section tracepoint/syscalls/sys_enter_kill:
|
||||
|
||||
0000000000000000 <bpf_prog>:
|
||||
0: 79 12 18 00 00 00 00 00 r2 = *(u64 *)(r1 + 24)
|
||||
1: 55 02 10 00 09 00 00 00 if r2 != 9 goto +16 <LBB0_2>
|
||||
2: 79 11 10 00 00 00 00 00 r1 = *(u64 *)(r1 + 16)
|
||||
3: bf 12 00 00 00 00 00 00 r2 = r1
|
||||
4: c7 02 00 00 3f 00 00 00 r2 s>>= 63
|
||||
5: 0f 21 00 00 00 00 00 00 r1 += r2
|
||||
6: af 21 00 00 00 00 00 00 r1 ^= r2
|
||||
7: 7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1
|
||||
8: b7 01 00 00 01 00 00 00 r1 = 1
|
||||
9: 63 1a f4 ff 00 00 00 00 *(u32 *)(r10 - 12) = r1
|
||||
10: bf a2 00 00 00 00 00 00 r2 = r10
|
||||
11: 07 02 00 00 f8 ff ff ff r2 += -8
|
||||
12: bf a3 00 00 00 00 00 00 r3 = r10
|
||||
13: 07 03 00 00 f4 ff ff ff r3 += -12
|
||||
14: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
|
||||
16: b7 04 00 00 01 00 00 00 r4 = 1
|
||||
17: 85 00 00 00 02 00 00 00 call 2
|
||||
…
|
||||
|
||||
### 用readelf读到bpf_program.o中的ELF section信息。
|
||||
# llvm-readelf -sections src/bpf_program.o
|
||||
There are 9 section headers, starting at offset 0x228:
|
||||
|
||||
Section Headers:
|
||||
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
|
||||
…
|
||||
[ 3] tracepoint/syscalls/sys_enter_kill PROGBITS 0000000000000000 000040 0000a0 00 AX 0 0 8
|
||||
[ 4] .reltracepoint/syscalls/sys_enter_kill REL 0000000000000000 000190 000010 10 8 3 8
|
||||
[ 5] maps PROGBITS 0000000000000000 0000e0 00001c 00 WA 0 0 4
|
||||
|
||||
|
||||
好了,看完了eBPF程序的内核态部分,我们再来看看它的用户态部分loader.c:
|
||||
|
||||
# cat src/loader.c
|
||||
#include "bpf_load.h"
|
||||
#include <unistd.h>
|
||||
#include <stdio.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// Load our newly compiled eBPF program
|
||||
if (load_bpf_file("src/bpf_program.o") != 0) {
|
||||
printf("The kernel didn't load the BPF program\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
printf("eBPF will listen to force kills for the next 30 seconds!\n");
|
||||
sleep(30);
|
||||
|
||||
// map_fd is a global variable containing all eBPF map file descriptors
|
||||
int fd = map_fd[0], val;
|
||||
long key = -1, prev_key;
|
||||
|
||||
// Iterate over all keys in the map
|
||||
while(bpf_map_get_next_key(fd, &prev_key, &key) == 0) {
|
||||
printf("%ld was forcefully killed!\n", key);
|
||||
prev_key = key;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
这部分的代码其实也很简单,主要就是做了两件事:
|
||||
|
||||
|
||||
通过执行load_bpf_file()函数,加载内核态代码生成的eBPF bytecode,也就是编译后得到的文件“src/bpf_program.o”。
|
||||
等待30秒钟后,从BPF Maps读取key/value对里的值。这里的值就是前面内核态的函数bpf_prog(),在kill()系统调用的tracepoint上执行这个函数以后,写入到BPF Maps里的值。
|
||||
|
||||
|
||||
至于读取BPF Maps的部分,就不需要太多的解释了,这里我们主要看一下load_bpf_file()这个函数,load_bpf_file()是Linux内核代码samples/bpf/bpf_load.c 里封装的一个函数。
|
||||
|
||||
这个函数可以读取eBPF bytecode中的信息,然后决定如何在内核中加载BPF Program,以及创建 BPF Maps。这里用到的都是bpf()这个系统调用,具体的代码你可以去看一下内核中bpf_load.c和bpf.c这两个文件。
|
||||
|
||||
理解了用户态的load.c这段代码后,我们最后编译一下,就生成了用户态的程序ebpf-kill-example:
|
||||
|
||||
# make
|
||||
clang -O2 -target bpf -c src/bpf_program.c -Ikernel-src/tools/testing/selftests/bpf -Ikernel-src/tools/lib/bpf -o src/bpf_program.o
|
||||
clang -O2 -o src/ebpf-kill-example -lelf -Ikernel-src/samples/bpf -Ikernel-src/tools/lib -Ikernel-src/tools/perf -Ikernel-src/tools/include -Llib64 -lbpf \
|
||||
kernel-src/samples/bpf/bpf_load.c -DHAVE_ATTR_TEST=0 src/loader.c
|
||||
|
||||
# ls -l src/ebpf-kill-example
|
||||
-rwxr-x--- 1 root root 23400 Jan 24 01:28 src/ebpf-kill-example
|
||||
|
||||
|
||||
你可以运行一下这个程序,如果在30秒以内有别的程序执行了 kill -9 <pid>,那么在内核中的eBPF代码就可以截获这个操作,然后通过eBPF Maps把信息传递给用户态进程,并且把这个信息打印出来了。
|
||||
|
||||
# LD_LIBRARY_PATH=lib64/:$LD_LIBRARY_PATH ./src/ebpf-kill-example &
|
||||
[1] 1963961
|
||||
# eBPF will listen to force kills for the next 30 seconds!
|
||||
# kill -9 1
|
||||
# 1 was forcefully killed!
|
||||
|
||||
|
||||
重点小结
|
||||
|
||||
今天我们一起学习了eBPF,接下来我给你总结一下重点。
|
||||
|
||||
eBPF对早年的BPF技术做了增强之后,为Linux网络, Linux安全以及Linux内核的调试和跟踪这三个领域提供了强大的扩展接口。
|
||||
|
||||
虽然整个eBPF技术是很复杂的,不过对于用户编写eBPF的程序,还是有一个固定的模式。
|
||||
|
||||
eBPF的程序都分为两部分,一是内核态的代码最后会被编译成eBPF bytecode,二是用户态代码,它主要是负责加载eBPF bytecode,并且通过eBPF Maps与内核态代码通讯。
|
||||
|
||||
这里我们重点要掌握eBPF程序里的三个要素,eBPF Program Types,eBPF Maps和eBPF Helpers。
|
||||
|
||||
eBPF Program Types可以定义函数在eBPF内核态的类型。eBPF Maps定义了key/value对的存储结构,搭建了eBPF Program之间以及用户态和内核态之间的数据交换的桥梁。eBPF Helpers是内核事先定义好了接口函数,方便eBPF程序调用这些函数。
|
||||
|
||||
理解了这些概念后,你可以开始动手编写eBPF的程序了。不过,eBPF程序的调试并不方便,基本只能依靠bpf_trace_printk(),同时也需要我们熟悉eBPF虚拟机的汇编指令。这些就需要你在实际的操作中,不断去积累经验了。
|
||||
|
||||
思考题
|
||||
|
||||
请你在ebpf-kill-example 这个例子的基础上,做一下修改,让用户态程序也能把调用kill()函数的进程所对应的进程号打印出来。
|
||||
|
||||
欢迎你在留言区记录你的思考或疑问。如果这一讲让你有所收获,也欢迎转发给你的朋友,同事,跟他一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
339
专栏/容器实战高手课/加餐06BCC:入门eBPF的前端工具.md
Normal file
339
专栏/容器实战高手课/加餐06BCC:入门eBPF的前端工具.md
Normal file
@ -0,0 +1,339 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐06 BCC:入门eBPF的前端工具
|
||||
你好,我是程远。
|
||||
|
||||
今天是我们专题加餐的最后一讲,明天就是春节了,我想给还在学习的你点个赞。这里我先给你拜个早年,祝愿你牛年工作顺利,健康如意!
|
||||
|
||||
上一讲,我们学习了eBPF的基本概念,以及eBPF编程的一个基本模型。在理解了这些概念之后,从理论上来说,你就能自己写出eBPF的程序,对Linux系统上的一些问题做跟踪和调试了。
|
||||
|
||||
不过,从上一讲的例子里估计你也发现了,eBPF的程序从编译到运行还是有些复杂。
|
||||
|
||||
为了方便我们用eBPF的程序跟踪和调试系统,社区有很多eBPF的前端工具。在这些前端工具中,BCC提供了最完整的工具集,以及用于eBPF工具开发的Python/Lua/C++的接口。那么今天我们就一起来看看,怎么使用BCC这个eBPF的前端工具。
|
||||
|
||||
如何使用BCC工具
|
||||
|
||||
BCC(BPF Compiler Collection)这个社区项目开始于2015年,差不多在内核中支持了eBPF的特性之后,BCC这个项目就开始了。
|
||||
|
||||
BCC的目标就是提供一个工具链,用于编写、编译还有内核加载eBPF程序,同时BCC也提供了大量的eBPF的工具程序,这些程序能够帮我们做Linux的性能分析和跟踪调试。
|
||||
|
||||
这里我们可以先尝试用几个BCC的工具,通过实际操作来了解一下BCC。
|
||||
|
||||
大部分Linux发行版本都有BCC的软件包,你可以直接安装。比如我们可以在Ubuntu 20.04上试试,用下面的命令安装BCC:
|
||||
|
||||
# apt install bpfcc-tools
|
||||
|
||||
|
||||
安装完BCC软件包之后,你在Linux系统上就会看到多了100多个BCC的小工具 (在Ubuntu里,这些工具的名字后面都加了bpfcc的后缀):
|
||||
|
||||
# ls -l /sbin/*-bpfcc | more
|
||||
-rwxr-xr-x 1 root root 34536 Feb 7 2020 /sbin/argdist-bpfcc
|
||||
-rwxr-xr-x 1 root root 2397 Feb 7 2020 /sbin/bashreadline-bpfcc
|
||||
-rwxr-xr-x 1 root root 6231 Feb 7 2020 /sbin/biolatency-bpfcc
|
||||
-rwxr-xr-x 1 root root 5524 Feb 7 2020 /sbin/biosnoop-bpfcc
|
||||
-rwxr-xr-x 1 root root 6439 Feb 7 2020 /sbin/biotop-bpfcc
|
||||
-rwxr-xr-x 1 root root 1152 Feb 7 2020 /sbin/bitesize-bpfcc
|
||||
-rwxr-xr-x 1 root root 2453 Feb 7 2020 /sbin/bpflist-bpfcc
|
||||
-rwxr-xr-x 1 root root 6339 Feb 7 2020 /sbin/btrfsdist-bpfcc
|
||||
-rwxr-xr-x 1 root root 9973 Feb 7 2020 /sbin/btrfsslower-bpfcc
|
||||
-rwxr-xr-x 1 root root 4717 Feb 7 2020 /sbin/cachestat-bpfcc
|
||||
-rwxr-xr-x 1 root root 7302 Feb 7 2020 /sbin/cachetop-bpfcc
|
||||
-rwxr-xr-x 1 root root 6859 Feb 7 2020 /sbin/capable-bpfcc
|
||||
-rwxr-xr-x 1 root root 53 Feb 7 2020 /sbin/cobjnew-bpfcc
|
||||
-rwxr-xr-x 1 root root 5209 Feb 7 2020 /sbin/cpudist-bpfcc
|
||||
-rwxr-xr-x 1 root root 14597 Feb 7 2020 /sbin/cpuunclaimed-bpfcc
|
||||
-rwxr-xr-x 1 root root 8504 Feb 7 2020 /sbin/criticalstat-bpfcc
|
||||
-rwxr-xr-x 1 root root 7095 Feb 7 2020 /sbin/dbslower-bpfcc
|
||||
-rwxr-xr-x 1 root root 3780 Feb 7 2020 /sbin/dbstat-bpfcc
|
||||
-rwxr-xr-x 1 root root 3938 Feb 7 2020 /sbin/dcsnoop-bpfcc
|
||||
-rwxr-xr-x 1 root root 3920 Feb 7 2020 /sbin/dcstat-bpfcc
|
||||
-rwxr-xr-x 1 root root 19930 Feb 7 2020 /sbin/deadlock-bpfcc
|
||||
-rwxr-xr-x 1 root root 7051 Dec 10 2019 /sbin/deadlock.c-bpfcc
|
||||
-rwxr-xr-x 1 root root 6830 Feb 7 2020 /sbin/drsnoop-bpfcc
|
||||
-rwxr-xr-x 1 root root 7658 Feb 7 2020 /sbin/execsnoop-bpfcc
|
||||
-rwxr-xr-x 1 root root 10351 Feb 7 2020 /sbin/exitsnoop-bpfcc
|
||||
-rwxr-xr-x 1 root root 6482 Feb 7 2020 /sbin/ext4dist-bpfcc
|
||||
...
|
||||
|
||||
|
||||
这些工具几乎覆盖了Linux内核中各个模块,它们可以对Linux某个模块做最基本的profile。你可以看看下面这张图,图里把BCC的工具与Linux中的各个模块做了一个映射。
|
||||
|
||||
|
||||
|
||||
在BCC的github repo里,也有很完整的文档和例子来描述每一个工具。Brendan D. Gregg写了一本书,书名叫《BPF Performance Tools》(我们上一讲也提到过这本书),这本书从Linux CPU/Memory/Filesystem/Disk/Networking等角度介绍了如何使用BCC工具,感兴趣的你可以自行学习。
|
||||
|
||||
为了让你更容易理解,这里我给你举两个例子。
|
||||
|
||||
第一个是使用opensnoop工具,用它来监控节点上所有打开文件的操作。这个命令有时候也可以用来查看某个文件被哪个进程给动过。
|
||||
|
||||
比如说,我们先启动opensnoop,然后在其他的console里运行 touch test-open 命令,这时候我们就会看到 touch 命令在启动时读取到的库文件和配置文件,以及最后建立的“test-open”这个文件。
|
||||
|
||||
# opensnoop-bpfcc
|
||||
PID COMM FD ERR PATH
|
||||
2522843 touch 3 0 /etc/ld.so.cache
|
||||
2522843 touch 3 0 /lib/x86_64-linux-gnu/libc.so.6
|
||||
2522843 touch 3 0 /usr/lib/locale/locale-archive
|
||||
2522843 touch 3 0 /usr/share/locale/locale.alias
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_IDENTIFICATION
|
||||
2522843 touch 3 0 /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MEASUREMENT
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_TELEPHONE
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_ADDRESS
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_NAME
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_PAPER
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MESSAGES
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MESSAGES/SYS_LC_MESSAGES
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_MONETARY
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_COLLATE
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_TIME
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_NUMERIC
|
||||
2522843 touch 3 0 /usr/lib/locale/C.UTF-8/LC_CTYPE
|
||||
2522843 touch 3 0 test-open
|
||||
|
||||
|
||||
第二个是使用softirqs这个命令,查看节点上各种类型的softirqs花费时间的分布图 (直方图模式)。
|
||||
|
||||
比如在下面这个例子里,每一次timer softirq执行时间在0~1us时间区间里的有16次,在2-3us时间区间里的有49次,以此类推。
|
||||
|
||||
在我们分析网络延时的时候,也用过这个softirqs工具,用它来确认timer softirq花费的时间。
|
||||
|
||||
# softirqs-bpfcc -d
|
||||
Tracing soft irq event time... Hit Ctrl-C to end.
|
||||
^C
|
||||
|
||||
softirq = block
|
||||
usecs : count distribution
|
||||
0 -> 1 : 2 |******************** |
|
||||
2 -> 3 : 3 |****************************** |
|
||||
4 -> 7 : 2 |******************** |
|
||||
8 -> 15 : 4 |****************************************|
|
||||
|
||||
softirq = rcu
|
||||
usecs : count distribution
|
||||
0 -> 1 : 189 |****************************************|
|
||||
2 -> 3 : 52 |*********** |
|
||||
4 -> 7 : 21 |**** |
|
||||
8 -> 15 : 5 |* |
|
||||
16 -> 31 : 1 | |
|
||||
|
||||
softirq = net_rx
|
||||
usecs : count distribution
|
||||
0 -> 1 : 1 |******************** |
|
||||
2 -> 3 : 0 | |
|
||||
4 -> 7 : 2 |****************************************|
|
||||
8 -> 15 : 0 | |
|
||||
16 -> 31 : 2 |****************************************|
|
||||
|
||||
softirq = timer
|
||||
usecs : count distribution
|
||||
0 -> 1 : 16 |************* |
|
||||
2 -> 3 : 49 |****************************************|
|
||||
4 -> 7 : 43 |*********************************** |
|
||||
8 -> 15 : 5 |**** |
|
||||
16 -> 31 : 13 |********** |
|
||||
32 -> 63 : 13 |********** |
|
||||
|
||||
softirq = sched
|
||||
usecs : count distribution
|
||||
0 -> 1 : 18 |****** |
|
||||
2 -> 3 : 107 |****************************************|
|
||||
4 -> 7 : 20 |******* |
|
||||
8 -> 15 : 1 | |
|
||||
16 -> 31 : 1 | |
|
||||
|
||||
|
||||
BCC中的工具数目虽然很多,但是你用过之后就会发现,它们的输出模式基本上就是上面我说的这两种。
|
||||
|
||||
第一种类似事件模式,就像opensnoop的输出一样,发生一次就输出一次;第二种是直方图模式,就是把内核中执行函数的时间做个统计,然后用直方图的方式输出,也就是 softirqs -d 的执行结果。
|
||||
|
||||
用过BCC工具之后,我们再来看一下BCC工具的工作原理,这样以后你有需要的时候,自己也可以编写和部署一个BCC工具了。
|
||||
|
||||
BCC的工作原理
|
||||
|
||||
让我们来先看一下BCC工具的代码结构。
|
||||
|
||||
因为目前BCC的工具都是用python写的,所以你直接可以用文本编辑器打开节点上的一个工具文件。比如打开/sbin/opensnoop-bpfcc文件(也可在github bcc项目中查看 opensnoop.py),这里你可以看到大概200行左右的代码,代码主要分成了两部分。
|
||||
|
||||
第一部分其实是一块C代码,里面定义的就是eBPF内核态的代码,不过它是以python字符串的形式加在代码中的。
|
||||
|
||||
我在下面列出了这段C程序的主干,其实就是定义两个eBPF Maps和两个eBPF Programs的函数:
|
||||
|
||||
# define BPF program
|
||||
bpf_text = """
|
||||
#include <uapi/linux/ptrace.h>
|
||||
#include <uapi/linux/limits.h>
|
||||
#include <linux/sched.h>
|
||||
|
||||
…
|
||||
|
||||
BPF_HASH(infotmp, u64, struct val_t); //BPF_MAP_TYPE_HASH
|
||||
BPF_PERF_OUTPUT(events); // BPF_MAP_TYPE_PERF_EVENT_ARRAY
|
||||
|
||||
int trace_entry(struct pt_regs *ctx, int dfd, const char __user *filename, int flags)
|
||||
{
|
||||
…
|
||||
}
|
||||
|
||||
int trace_return(struct pt_regs *ctx)
|
||||
{
|
||||
…
|
||||
}
|
||||
“””
|
||||
|
||||
|
||||
第二部分就是用python写的用户态代码,它的作用是加载内核态eBPF的代码,把内核态的函数trace_entry()以kprobe方式挂载到内核函数do_sys_open(),把trace_return()以kproberet方式也挂载到do_sys_open(),然后从eBPF Maps里读取数据并且输出。
|
||||
|
||||
…
|
||||
# initialize BPF
|
||||
b = BPF(text=bpf_text)
|
||||
b.attach_kprobe(event="do_sys_open", fn_name="trace_entry")
|
||||
b.attach_kretprobe(event="do_sys_open", fn_name="trace_return")
|
||||
…
|
||||
# loop with callback to print_event
|
||||
b["events"].open_perf_buffer(print_event, page_cnt=64)
|
||||
start_time = datetime.now()
|
||||
while not args.duration or datetime.now() - start_time < args.duration:
|
||||
try:
|
||||
b.perf_buffer_poll()
|
||||
except KeyboardInterrupt:
|
||||
exit()
|
||||
…
|
||||
|
||||
|
||||
从代码的结构看,其实这和我们上一讲介绍的eBPF标准的编程模式是差不多的,只是用户态的程序是用python来写的。不过这里有一点比较特殊,用户态在加载程序的时候,输入的是C程序的文本而不是eBPF bytecode。
|
||||
|
||||
BCC可以这么做,是因为它通过pythonBPF() 加载C代码之后,调用libbcc库中的函数bpf_module_create_c_from_string() 把C代码编译成了eBPF bytecode。也就是说,libbcc库中集成了clang/llvm的编译器。
|
||||
|
||||
def __init__(self, src_file=b"", hdr_file=b"", text=None, debug=0,
|
||||
cflags=[], usdt_contexts=[], allow_rlimit=True, device=None):
|
||||
"""Create a new BPF module with the given source code.
|
||||
...
|
||||
self.module = lib.bpf_module_create_c_from_string(text, self.debug,cflags_array, len(cflags_array), allow_rlimit, device)
|
||||
...
|
||||
|
||||
|
||||
我们弄明白libbcc库的作用之后,再来整体看一下BCC工具的工作方式。为了让你理解,我给你画了一张示意图:
|
||||
|
||||
|
||||
|
||||
BCC的这种设计思想是为了方便eBPF程序的开发和使用,特别是eBPF内核态的代码对当前运行的内核版本是有依赖的,比如在4.15内核的节点上编译好的bytecode,放到5.4内核的节点上很有可能是运行不了的。
|
||||
|
||||
那么让编译和运行都在同一个节点,出现问题就可以直接修改源代码文件了。你有没有发现,这么做有点像把C程序的处理当成python的处理方式。
|
||||
|
||||
BCC的这种设计思想虽然有好处,但是也带来了问题。其实问题也是很明显的,首先我们需要在运行BCC工具的节点上必须安装内核头文件,这个在编译内核态eBPF C代码的时候是必须要做的。
|
||||
|
||||
其次,在libbcc的库里面包含了clang/llvm的编译器,这不光占用磁盘空间,在运行程序前还需要编译,也会占用节点的CPU和Memory,同时也让BCC工具的启动时间变长。这两个问题都会影响到BCC生产环境中的使用。
|
||||
|
||||
BCC工具的发展
|
||||
|
||||
那么我们有什么办法来解决刚才说的问题呢?eBPF的技术在不断进步,最新的BPF CO-RE技术可以解决这个问题。我们下面就来看BPF CO-RE是什么意思。
|
||||
|
||||
CO-RE是“Compile Once – Run Everywhere”的缩写,BPF CO-RE通过对Linux内核、用户态BPF loader(libbpf库)以及Clang编译器的修改,来实现编译出来的eBPF程序可以在不同版本的内核上运行。
|
||||
|
||||
不同版本的内核上,用CO-RE编译出来的eBPF程序都可以运行。在Linux内核和BPF程序之间,会通过BTF(BPF Type Format)来协调不同版本内核中数据结构的变量偏移或者变量长度变化等问题。
|
||||
|
||||
在BCC的github repo里,有一个目录libbpf-tools,在这个目录下已经有一些重写过的BCC工具的源代码,它们并不是用python+libbcc的方式实现的,而是用到了libbpf+BPF CO-RE的方式。
|
||||
|
||||
如果你的系统上有高于版本10的CLANG/LLVM编译器,就可以尝试编译一下libbpf-tools下的工具。这里可以加一个“V=1”参数,这样我们就能清楚编译的步骤了。
|
||||
|
||||
# git remote -v
|
||||
origin https://github.com/iovisor/bcc.git (fetch)
|
||||
origin https://github.com/iovisor/bcc.git (push)
|
||||
# cd libbpf-tools/
|
||||
# make V=1
|
||||
mkdir -p .output
|
||||
mkdir -p .output/libbpf
|
||||
make -C /root/bcc/src/cc/libbpf/src BUILD_STATIC_ONLY=1 \
|
||||
OBJDIR=/root/bcc/libbpf-tools/.output//libbpf DESTDIR=/root/bcc/libbpf-tools/.output/ \
|
||||
INCLUDEDIR= LIBDIR= UAPIDIR= \
|
||||
Install
|
||||
…
|
||||
|
||||
ar rcs /root/bcc/libbpf-tools/.output//libbpf/libbpf.a …
|
||||
|
||||
…
|
||||
|
||||
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 \
|
||||
-I.output -c opensnoop.bpf.c -o .output/opensnoop.bpf.o && \
|
||||
llvm-strip -g .output/opensnoop.bpf.o
|
||||
bin/bpftool gen skeleton .output/opensnoop.bpf.o > .output/opensnoop.skel.h
|
||||
cc -g -O2 -Wall -I.output -c opensnoop.c -o .output/opensnoop.o
|
||||
cc -g -O2 -Wall .output/opensnoop.o /root/bcc/libbpf-tools/.output/libbpf.a .output/trace_helpers.o .output/syscall_helpers.o .output/errno_helpers.o -lelf -lz -o opensnoop
|
||||
|
||||
…
|
||||
|
||||
|
||||
我们梳理一下编译的过程。首先这段代码生成了libbpf.a这个静态库,然后逐个的编译每一个工具。对于每一个工具的代码结构是差不多的,编译的方法也是差不多的。
|
||||
|
||||
我们拿opensnoop做例子来看一下,它的源代码分为两个文件。opensnoop.bpf.c是内核态的eBPF代码,opensnoop.c是用户态的代码,这个和我们之前学习的eBPF代码的标准结构是一样的。主要不同点有下面这些。
|
||||
|
||||
内核态的代码不再逐个include内核代码的头文件,而是只要include一个“vmlinux.h”就可以。在“vmlinux.h”中包含了所有内核的数据结构,它是由内核文件vmlinux中的BTF信息转化而来的。
|
||||
|
||||
# cat opensnoop.bpf.c | head
|
||||
// SPDX-License-Identifier: GPL-2.0
|
||||
// Copyright (c) 2019 Facebook
|
||||
// Copyright (c) 2020 Netflix
|
||||
#include "vmlinux.h"
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include "opensnoop.h"
|
||||
|
||||
#define TASK_RUNNING 0
|
||||
|
||||
const volatile __u64 min_us = 0;
|
||||
|
||||
|
||||
我们使用bpftool这个工具,可以把编译出来的opensnoop.bpf.o重新生成为一个C语言的头文件opensnoop.skel.h。这个头文件中定义了加载eBPF程序的函数,eBPF bytecode的二进制流也直接写在了这个头文件中。
|
||||
|
||||
bin/bpftool gen skeleton .output/opensnoop.bpf.o > .output/opensnoop.skel.h
|
||||
|
||||
|
||||
用户态的代码opensnoop.c直接include这个opensnoop.skel.h,并且调用里面的eBPF加载的函数。这样在编译出来的可执行程序opensnoop,就可以直接运行了,不用再找eBPF bytecode文件或者eBPF内核态的C文件。并且这个opensnoop程序可以运行在不同版本内核的节点上(当然,这个内核需要打开CONFIG_DEBUG_INFO_BTF这个编译选项)。
|
||||
|
||||
比如,我们可以把在kernel5.4节点上编译好的opensnoop程序copy到一台kernel5.10.4的节点来运行:
|
||||
|
||||
# uname -r
|
||||
5.10.4
|
||||
# ls -lh opensnoop
|
||||
-rwxr-x--- 1 root root 235K Jan 30 23:08 opensnoop
|
||||
# ./opensnoop
|
||||
PID COMM FD ERR PATH
|
||||
2637411 opensnoop 24 0 /etc/localtime
|
||||
1 systemd 28 0 /proc/746/cgroup
|
||||
|
||||
|
||||
从上面的代码我们会发现,这时候的opensnoop不依赖任何的库函数,只有一个文件,strip后的文件大小只有235KB,启动运行的时候,既不不需要读取外部的文件,也不会做额外的编译。
|
||||
|
||||
重点小结
|
||||
|
||||
好了,今天我们主要讲了eBPF的一个前端工具BCC,我来给你总结一下。
|
||||
|
||||
在我看来,对于把eBPF运用于Linux内核的性能分析和跟踪调试这个领域,BCC是社区中最有影响力的一个项目。BCC项目提供了eBPF工具开发的Python/Lua/C++的接口,以及上百个基于eBPF的工具。
|
||||
|
||||
对不熟悉eBPF的同学来说,可以直接拿这些工具来调试Linux系统中的问题。而对于了解eBPF的同学,也可以利用BCC提供的接口,开发自己需要的eBPF工具。
|
||||
|
||||
BCC工具目前主要通过ptyhon+libbcc的模式在目标节点上运行,但是这个模式需要节点有内核头文件以及内嵌在libbcc中的Clang/LLVM编译器,每次程序启动的时候还需要再做一次编译。
|
||||
|
||||
为了弥补这个缺点,BCC工具开始向libbpf+BPF CO-RE的模式转变。用这种新模式编译出来的BCC工具程序,只需要很少的系统资源就可以在目标节点上运行,并且不受内核版本的限制。
|
||||
|
||||
除了BCC之外,你还可以看一下bpftrace、ebpf-exporter等eBPF的前端工具。
|
||||
|
||||
bpftrace提供了类似awk和C语言混合的一种语言,在使用时也很类似awk,可以用一两行的命令来完成一次eBPF的调用,它能做一些简单的内核事件的跟踪。当然它也可以编写比较复杂的eBPF程序。
|
||||
|
||||
ebpf-exporter可以把eBPF程序收集到的metrics以Prometheus的格式对外输出,然后通过Grafana的dashboard,可以对内核事件做长期的以及更加直观的监控。
|
||||
|
||||
总之,前面提到的这些工具,你都可以好好研究一下,它们可以帮助你对容器云平台上的节点做内核级别的监控与诊断。
|
||||
|
||||
思考题
|
||||
|
||||
这一讲的最后,我给你留一道思考题吧。
|
||||
|
||||
你可以动手操作一下,尝试编译和运行BCC项目中libbpf-tools目录下的工具。
|
||||
|
||||
欢迎你在留言区记录你的心得或者疑问。如果这一讲对你有帮助,也欢迎分享给你的同事、朋友,和他一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
91
专栏/容器实战高手课/结束语跳出舒适区,突破思考的惰性.md
Normal file
91
专栏/容器实战高手课/结束语跳出舒适区,突破思考的惰性.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 跳出舒适区,突破思考的惰性
|
||||
你好,我是程远。
|
||||
|
||||
今天是我们专栏必学内容的最后一讲。当你读到这一讲内容的时候,刚好是元旦。首先我要祝你元旦快乐,2021年一切顺利!
|
||||
|
||||
在过去的二十多讲内容里,我们从基础开始,一起学习了容器进程、内存、存储、网络以及安全这几部分的内容。在每一讲里,我们都会从一个实际问题或者现象出发,然后一步步去分析和解决问题。一路走来,真是百感交集,我有好多心里话,但又不知该从何说起。
|
||||
|
||||
所以最后一讲,我想和你聊聊我个人的一些成长感悟,在辞旧迎新的元旦,正适合回顾过去和展望未来。所以这既是专栏的一次总结交流,也是我们开启新征程的“号角”。
|
||||
|
||||
在多年以前,我在书里读到一句话,说的是“每个人都有潜在的能量,只是很容易被习惯所掩盖,被时间所迷离,被惰性所消磨。”
|
||||
|
||||
今天再次回看这段话,还真是一语中的,感触良多,回想起专栏写作的整个过程,这件事带给我的最大感悟就是:跳出自己的舒适区,才能有所突破。
|
||||
|
||||
突破舒适区是很难的事儿
|
||||
|
||||
我们都知道,突破舒适区是一件很难的事儿。这里我给你分享一个我自己的故事,也许你也会从这个故事里找到自己的影子。
|
||||
|
||||
记得在2年前,我参加过eBay的一个内部培训,培训的目标就是要让自己有所“突破”。我必须承认,这个培训是我经历过的所有培训中最接地气的一个培训,在培训过程里我也是情绪激昂的,准备带着学到的东西回到工作里去大展身手,好好突破一番的。
|
||||
|
||||
不过等培训结束,再回到日常工作的时候,之前的雄心壮志、激情澎湃又被日常的琐事所淹没,积蓄的那股劲儿又慢慢被消磨了。周围的同事会开玩笑地对我说:“程远啊,我觉得你没有突破啊。”
|
||||
|
||||
其实,我心里也知道,所谓的“突破”就要跳出自己的舒适区。不过我始终不知道怎么跳出来,哪怕自己手上的工作再多,工作到再晚,但这仍然是处于自己舒适区。这是因为这一切的工作节奏还有思考的问题,都是我自己熟悉的。
|
||||
|
||||
这种熟悉很可能让我们沉湎其中,裹足不前。那问题来了,意识到自己处于舒适区,产生想要“跳出去”的念头的确是良好开局,难的是怎么有效突破。这就要聊到突破方法路径的问题了,我想结合自己的感悟给你说一说。
|
||||
|
||||
主动迎接挑战,在实战中进步
|
||||
|
||||
不知道你有没有听过热力学里熵增的定律,大概说的是:封闭系统的熵(能量)会不可逆地增加,最终导致整个系统崩溃。那怎么才能保持这个系统的活力呢?就是能量交换,不断去引入外部的能量,也就是负熵。
|
||||
|
||||
我们可以引申一下,自然会想到走出舒适区这件事,也是同样的道理。我们要有一种冒险家的勇气,主动去迎接挑战,在实战里迫使自己不断进步。
|
||||
|
||||
其实选择做这样一个专栏,对我来说就是走出舒适区的一项“挑战”。在今年7月份,那还是我们这个专栏筹备的前期,我当时就一个想法,就是把我这些年来在容器方面的积累给记录下来。
|
||||
|
||||
从7月份决定写容器这个专栏开始,到现在差不多也有半年的时间了,我真的觉得,在工作的同时把写专栏的这件事给坚持下来,真的是一件不容易的事情。这里不仅仅是一个简单的时间投入问题,更多的是迫使自己再去思考的问题。
|
||||
|
||||
估计你也发现了,我每一讲都涉及不少知识点。我在专栏写作的过程中,花时间最多的就是怎么把问题说清楚,这里要解释哪些关键知识点,适合用什么样的例子做解释,每个知识点要讲到什么程度,需要查阅哪些代码和资料来保证自己所讲内容的正确性。
|
||||
|
||||
这样的思考模式和我日常思考工作问题的模式是完全不同的。但也正是借着这样的机会,我才从自己原先的舒适区里跳了出来,工作之余同时也在思考写专栏的问题,每天都有大量的context switch,也就是上下文切换。
|
||||
|
||||
我很高兴自己可以坚持下来,完成了专栏的主体部分。可以说,这门课既是容器的实战课,也是我自己走出舒适区的实战训练。
|
||||
|
||||
突破舒适区,本质是突破思考的惰性
|
||||
|
||||
这次的专栏写作,还让我意识到,突破舒适区的本质就是突破思考的惰性。只有不断思考,才能推着自己不断往前走,才能让我们更从容地解决工作上的问题。
|
||||
|
||||
在2020年的12月初,Kubernetes宣布不再支持dockershim,也就是说Kubernetes节点上不能再直接用Docker来启动容器了。当时我看到这条新闻,觉得这是理所当然的,因为我们的容器云平台上在2019年初就从Docker迁移到了Containerd。
|
||||
|
||||
不过,后来我在专栏留言回复的过程中,连续有三位同学留言,问我怎么看Kubernetes的这个决定,这让我又回忆起了当初我们团队是怎么做的迁移决定。
|
||||
|
||||
这件事还要追溯到2018年的时候,我们发现kubelet通过CRI接口就可以集成Containerd了,于是我们就开始思考,是不是应该用Containerd来替换Docker呢?
|
||||
|
||||
当时我们看到的好处有两点。第一点是这样替换之后架构上的优势,CRI可以说是kubelet连接Runtime的标准了,而用Dockershim接Docker再转Containerd,这样很累赘。第二点好处就是降低了维护成本。Containerd只是Docker中的一部分,维护Containerd明显要比维护庞大的Docker容易。
|
||||
|
||||
当然,这么做的挑战也是很大的。当时,我们在生产环境中已经有2万台物理机节点以及几十万个容器,而且那时候业界还几乎没有人在生产环境中用kubelet直接调用Containerd。没有前人的尝试可以借鉴,只能咬牙打一场硬仗。
|
||||
|
||||
后来我们通过一个多月的测试,发现直接使用Containerd,无论是稳定性还是性能都没有问题。有了实际测试做保障,我们在2019年初又花了3个月时间,才把生产环境上的Docker全部替换成Containerd。
|
||||
|
||||
这样的结果看似轻描淡写,一两句话就带过了。但实际过程里,已经不是过五关斩六将了,而是一直在发现问题、解决问题,大大小小的战役才汇聚成了最后的战果。其实,我在这个专栏里和你分享的一些容器问题,也来源于我们当时的迁移实践。
|
||||
|
||||
现在回想起来,当初的这个决定无疑是非常正确的了。不过再想想,如果当时看到Kubernetes的变化,我们没有主动思考,等到现在Kubernetes宣布不再支持Dockershim才去做应对,结果又会怎样呢?
|
||||
|
||||
这个问题,我觉得用数字来说话更直观。刚才提到当时迁移的时候,有2万台物理机节点以及几十万个容器。但如果等到现在才迁移,我们需要面对的就是6万台物理机和上百万的容器了。
|
||||
|
||||
你看,无论是写专栏也好,还是我们实际工作也好,呆在舒适区里,短期成本看着挺小,不需要你大动干戈,消耗脑细胞和精力。但是,当你习惯了这种思考的惰性,就会变成温水煮青蛙而不自知,等到外部条件发生变化时会很被动。
|
||||
|
||||
最后的彩蛋
|
||||
|
||||
前面我们聊了很多突破舒适区的事儿,不知道你有没有被触动呢?
|
||||
|
||||
其实学习也好,工作也罢,就是要有一种突破意识,走出舒适区,才能“开疆拓土”。那为了让你我都知行合一,我还要给你聊聊后面的专题加餐安排。
|
||||
|
||||
在开篇词我也提到了这个安排。虽然这一讲是我们课程的结束语,但我们课程的内容并没有结束。在这个专题里,我选择了一个真实案例。那这个案例我是怎么选的呢?
|
||||
|
||||
其实这是2020年初,我们在生产环境里遇到的一个真实的容器网络问题。我觉得这是一个很好的调试案例,它的好就在于可以用到Linux内核的最主要的几个调试工具,包括perf,ftrace和ebpf。我们逐个使用这些工具,就可以层层递进地揭开问题的本质。
|
||||
|
||||
通过这个案例的学习,我会带你掌握每种工具的特性。这样你在理解了容器基本原理的基础上,就能利用这些好的工具系统化地分析生产环境中碰到的容器问题了,就像我们开篇中说的那样——变黑盒为白盒。
|
||||
|
||||
写完结束语之后,我会认真为你准备这个专题加餐。而这一个月的时间,你还可以继续消化理解课程主体部分的内容,打牢基础,这样对你学习后面的专题加餐也有很大帮助。
|
||||
|
||||
最后的最后,我想和你说的是,希望你我都能主动思考,不断突破自己,走出舒适区,一起共勉吧!
|
||||
|
||||
这里我为你准备了一份毕业问卷,题目不多,希望你可以花两分钟填一下。也十分期待能听到你的声音,说说你对这门课程的想法和建议。
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user