first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View 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之后需要如何重新设置等等。
当我们一起把容器知识的框架搭建起来,把里面的核心概念、底层逻辑掌握之后,你其实就可以解决容器的大部分问题了。但是,我知道,你一定还有个问题,那就是工具呢?不讲了吗?我真的可以水到渠成吗?
不要着急,这里我要做个特别说明,课程结束后,我会给你做一个专题加餐。目前,我是这么设计的,我选择了一个真实案例,就是在生产环境中容器网络延时不稳定的问题。
在这个案例中我们会用到perfftracebcc/ebpf这几个Linux调试工具了解它们的原理熟悉它们在调试问题的不同阶段所发挥的作用然后用这些工具一起来解决现实场景中复杂的容器问题。
为什么一定要把这个专题放到课程结束后呢?因为我需要给你留一段消化吸收的时间,这里我安排了一个月时间。
希望你能利用这一个月,把整个课程的内容复习一遍,把基本功打扎实,你才能在专题学习里彻底掌握这几个工具,遇到类似问题时也能有清晰的解决思路,这样这个专题的学习效率也才能更高。
之所以一定要这么安排,也是想跟你表达我的一个观点,就是工具很重要,但是工具不是最重要的。
所有学习,我们一定是先掌握知识体系,一定不能陷入唯工具论的思维框架里。我知道,这样的安排似乎只是我的一家之言,但这恰恰就是我想通过这门课交付给你的,因为这些真的是我自己的经验之谈,是我的受益点。这么学看似慢了,但其实只有这样,我们走的才是捷径。
好了,介绍完了课程设计和学习目标,还有一件事特别重要,我要特别提醒下。
在这个容器课程中每一讲里都会有一些小例子所以需要你有一台安装有Linux的机器或者用VirtualBox安装一个虚拟机来跑Linux。Linux的版本建议是CentOS 8 或者是Ubuntu 20.04。
希望你提前做好准备,这样在学习的过程中,你就能跟着我的讲解进行一些实际的操作,对容器知识也会有更加深刻的印象。
你还可以拉上身边的小伙伴,组团来学习这门课程,共同学习、互相鼓励的氛围会让你的学习体验更好。另外,有什么想法或者疑问,你都可以通过留言区和我交流、互动。
最后,我想和你说,容器是一个很好的技术窗口,它可以帮助你在这个瞬息万变的计算机世界里看到后面那些“不变”的技术,只有掌握好那些“不变”的技术,你才可以更加从容地去接受技术的瞬息万变。
我希望,这个专栏能帮你打开容器这扇窗,让你看到更精彩的风景,建立起你自己的容器知识体系。从今天开始,跟着我一起搞懂容器,提升实力,吃透原理,在技术之路上一起前进吧!

View 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 NamespacePID其实就是进程的编号。这个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中都有一套独立的网络接口比如这里的loeth0还有独立的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 NamespaceMount Namespace保证了每个容器都有自己独立的文件目录结构。
Namespace的类型还有很多我们查看”Linux Programmers Manual”可以看到Linux中所有的Namespacecgroup/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 Programmers 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命令启动这个容器。
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View 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信号。
在这一讲中,我们主要用到 SIGTERM15和SIGKILL9这两个信号所以这里你主要了解这两个信号就可以了其他信号以后用到时再做介绍。
进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。
第一个选择是忽略Ignore就是对这个信号不做任何处理但是有两个信号例外对于SIGKILL和SIGSTOP这个两个信号进程是不能忽略的。这是因为它们的主要作用是为Linux kernel和超级用户提供删除任意进程的特权。
第二个选择就是捕获Catch这个是指让用户进程可以注册自己针对这个信号的handler。具体怎么做我们目前暂时涉及不到你先知道就行我们在后面课程会进行详细介绍。
对于捕获SIGKILL和SIGSTOP这两个信号也同样例外这两个信号不能有用户自己的处理代码只能执行系统的缺省行为。
还有一个选择是缺省行为DefaultLinux为每个信号都定义了一个缺省的行为你可以在Linux系统中运行 man 7 signal来查看每个信号的缺省行为。
对于大部分的信号而言应用程序不需要注册自己的handler使用系统缺省定义行为就可以了。
我刚才说了SIGTERM15和SIGKILL9这两个信号是我们重点掌握的。现在我们已经讲解了信号的概念和处理方式我就拿这两个信号为例再带你具体分析一下。
首先我们来看SIGTERM15这个信号是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程序里注册了两个handlerbit 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这两个特权信号
对于其他的信号如果用户自己注册了handler1号进程可以响应。
思考题
这一讲的最开始有这样一个C语言的init进程它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM会发生什么情况呢
欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑欢迎你把这篇文章分享给他说不定就帮他解决了一个难题。

View 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_INTERRUPTIBLETASK_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就会被设置为3276832K如果机器中的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, &current_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进程创建了子进程BB又创建了自己的子进程C如果C运行完之后退出成了僵尸进程B进程还在运行而容器的init进程还在不断地调用waitpid()那C这个僵尸进程可以被回收吗
欢迎留言和我分享你的想法如果你的朋友也被僵尸进程占用资源而困扰欢迎你把这篇文章分享给他也许就能帮他解决一个问题

View 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 Programmers 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 Programmers 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;
}
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎你分享给自己的朋友,共同学习和进步。

View File

@ -0,0 +1,311 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 容器CPU1怎么限制容器的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_INTERRUPTIBLETASK_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就会被设置为3276832K如果机器中的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, &current_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进程创建了子进程BB又创建了自己的子进程C如果C运行完之后退出成了僵尸进程B进程还在运行而容器的init进程还在不断地调用waitpid()那C这个僵尸进程可以被回收吗
欢迎留言和我分享你的想法如果你的朋友也被僵尸进程占用资源而困扰欢迎你把这篇文章分享给他也许就能帮他解决一个问题

View File

@ -0,0 +1,184 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 容器CPU2如何正确地拿到容器CPU的开销
你好我是程远。今天我们聊一聊如何正确地拿到容器CPU的开销。
为啥要解决这个问题呢,还是来源于实际工作中的需要。
无论是容器的所有者还是容器平台的管理者我们想要精准地对运行着众多容器的云平台做监控快速排查例如应用的处理能力下降节点负载过高等问题就绕不开容器CPU开销。因为CPU开销的异常往往是程序异常最明显的一个指标。
在一台物理机器或者虚拟机里如果你想得到这个节点的CPU使用率最常用的命令就是top了吧top一下子就能看到整个节点当前的CPU使用情况。
那么在容器里top命令也可以做到这点吗想要知道答案我们还是得实际动手试一试。
问题重现
实际上你在使用容器的时候如果运行top命令来查看当前容器总共使用了多少CPU你肯定马上就会失望了。
这是因为我们在容器中运行top命令虽然可以看到容器中每个进程的CPU使用率但是top中”%Cpu(s)“那一行中显示的数值并不是这个容器的CPU整体使用率而是容器宿主机的CPU使用率。
就像下面的这个例子我们在一个12个CPU的宿主机上启动一个容器然后在容器里运行top命令。
这时我们可以看到容器里有两个进程threads-cpu总共消耗了200%的CPU2 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个CPU200%就是使用了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 programmers manual 里定义了。在这里我们只需要重点关注这两项数值stat文件中的第14项utime和第15项stime。
那么这两项数值utime和stime是什么含义呢utime是表示进程的用户态部分在Linux调度中获得CPU的ticksstime是表示进程的内核态部分在Linux调度中获得CPU的ticks。
看到这个解释你可能又冒出一个新问题疑惑ticks是什么?这个ticks就是Linux操作系统中的一个时间单位你可以理解成类似秒毫秒的概念。
在Linux中有个自己的时钟它会周期性地产生中断。每次中断都会触发Linux内核去做一次进程调度而这一次中断就是一个tick。因为是周期性的中断比如1秒钟100次中断那么一个tick作为一个时间单位看的话也就是1/100秒。
我给你举个例子说明假如进程的utime是130ticks就相当于130 * 1100=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。那么这三个值相乘你是不是也知道了它的意思呢就是在这“瞬时”的时间et1个CPU所包含的ticks数目。
解释了这些参数,我们可以把这个公式简化一下,就是下面这样:
进程的CPU使用率=进程的ticks/单个CPU总ticks*100.0
知道了这个公式就需要上手来验证一下这个方法对不对怎么验证呢我们可以启动一个消耗CPU的小程序然后读取一下进程对应的/proc/[pid]/stat中的utime和stime然后用这个方法来计算一下进程使用率这个百分比值并且和top的输出对比一下看看是否一致。
先启动一个消耗200%的小程序它的PID是10021CPU使用率是200%。
然后我们查看这个进程对应的stat文件/proc/10021/stat间隔1秒钟输出第二次因为stat文件内容很多我们知道utime和stime第14和15项所以我们这里只截取了前15项的输出。这里可以看到utime_1 = 399stime_1=0utime_2=600stime_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 programmers 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的相关问题。如果这篇文章让你有所收获也欢迎你分享给更多的朋友一起学习进步。

View 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 average2.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 AverageIETF还有一个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%的CPU2个线程就是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 id49.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都被占满了我们可以看到整个节点的idleid也已经是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几乎为0idle为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的值。
欢迎在留言区晒出你的经历和疑问。如果有收获,也欢迎你把这篇文章分享给你的朋友,一起学习和讨论。

View File

@ -0,0 +1,183 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 容器内存:我的容器为什么被杀了?
你好,我是程远。
从这一讲内容开始我们进入容器内存这个模块。在使用容器的时候一定会伴随着Memory Cgroup。而Memory Cgroup给Linux原本就复杂的内存管理带来了新的变化下面我们就一起来学习这一块内容。
今天这一讲,我们来解决容器在系统中消失的问题。
不知道你在使用容器时有没有过这样的经历一个容器在系统中运行一段时间后突然消失了看看自己程序的log文件也没发现什么错误不像是自己程序Crash但是容器就是消失了。
那么这是怎么回事呢?接下来我们就一起来“破案”。
问题再现
容器在系统中被杀掉其实只有一种情况那就是容器中的进程使用了太多的内存。具体来说就是容器里所有进程使用的内存量超过了容器所在Memory Cgroup里的内存限制。这时Linux系统就会主动杀死容器中的一个进程往往这会导致整个容器的退出。
我们可以做个简单的容器模拟一下这种容器被杀死的场景。做容器的Dockerfile和代码你可以从这里获得。
接下来我们用下面的这个脚本来启动容器我们先把这个容器的Cgroup内存上限设置为512MB536870912 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_bytesmemory.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看看接下来会发生什么
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎分享给你的朋友。

View File

@ -0,0 +1,186 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 Page Cache为什么我的容器内存使用量总是在临界点
你好,我是程远。
上一讲我们讲了Memory Cgroup是如何控制一个容器的内存的。我们已经知道了如果容器使用的物理内存超过了Memory Cgroup里的memory.limit_in_bytes值那么容器中的进程会被OOM Killer杀死。
不过在一些容器的使用场景中比如容器里的应用有很多文件读写你会发现整个容器的内存使用量已经很接近Memory Cgroup的上限值了但是在容器中我们接着再申请内存还是可以申请出来并且没有发生OOM。
这是怎么回事呢?今天这一讲我就来聊聊这个问题。
问题再现
我们可以用这里的代码做个容器镜像然后用下面的这个脚本启动容器并且设置容器Memory Cgroup里的内存上限值是100MB104857600bytes
#!/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这两个值。
如下图所示我们可以看到容器内存的上限值设置为104857600bytes100MB而这时整个容器的已使用内存显示为104767488bytes这个值已经非常接近上限值了。
我们把容器内存上限值和已使用的内存数值做个减法104857600104767488= 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已经有了106728KB100MB)但是实际的物理内存RSStop命令里显示的是RES就是Resident的简写和RSS是一个意思在这里只有688KB。
接着我们在程序里等待30秒之后我们再对这块申请的空间里写入20MB的数据。
sleep(30);
memset(p, 0x00, 20 * MB)
当我们用memset()函数对这块地址空间写入20MB的数据之后我们再用top查看这时候可以看到虚拟地址空间VIRT还是106728不过物理内存RSSRES的值变成了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来决定是否启动内存的回收。
内存回收的算法会根据不同类型的内存以及内存的最近最少用原则就是LRULeast 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的内存值到了54759424bytes54MB左右吧。总的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值。
欢迎在留言区写下你的思考或疑问,我们一起交流探讨。如果这篇文章让你有所收获,也欢迎你分享给更多的朋友,一起学习进步。

View 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内存一直在512MBRES: 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的取值范围是0100缺省值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 : 140Page 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分区中会有数据写入吗
欢迎在留言区分享你的收获和疑问。如果这篇文章让你有所收获,也欢迎分享给你的朋友,一起交流和学习。

View 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/OAsync 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的带宽IOPSI/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命令牵涉到四类目录分别是loweruppermerged和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后端文件系统比如XFSExt4的读写接口。但是它只实现了同步I/Osync 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看看会发生什么
欢迎在留言区和我分享你的思考和疑问。如果这篇文章让你有所收获,也欢迎分享给你的同事、朋友,一起学习探讨。

View 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和限制的数据量呢
欢迎你在留言区分享你的思考或疑问。如果这篇文章让你有所收获,也欢迎转发给你的同事、朋友,一起交流和学习。

View 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和限制的数据量呢
欢迎你在留言区分享你的思考或疑问。如果这篇文章让你有所收获,也欢迎转发给你的同事、朋友,一起交流和学习。

View 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而在虚拟机里的写入数据块时间就比较平稳一直在3050us这个范围内。
通过这个小程序,我们再现了问题,那我们就来分析一下,为什么会产生这样的结果。
时间波动是因为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相关的内核参数了我们需要知道所有相关参数的含义才能判断出最后真正导致问题发生的原因。
现在我们挨个来看一下。为了方便后面的讲述我们可以设定一个比值AA等于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
欢迎你在留言区提出你的思考或是疑问。如果这篇文章对你有帮助的话,也欢迎你分享给你的朋友、同事,一起学习进步。

View 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 Programmers Manual里对Network Namespace有一个段简短的描述在里面就列出了最主要的几部分资源它们都是通过Network Namespace隔离的。
我把这些资源给你做了一个梳理:
第一种网络设备这里指的是loeth0等网络设备。你可以通过 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/下的网络参数,你可以试试看具体怎么修改。
欢迎你在留言区分享你的收获和疑问。如果这篇文章对你有帮助,也欢迎转发给你的同事和朋友,一起交流探讨。

View 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 Namespaceeth0 是这个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上加上一个IP172.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.116 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_hostdocker0宿主机的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我们应该怎么配置网络呢
欢迎你在留言区分享你的思考和问题。如果这篇文章对你有启发,也欢迎分享给你的朋友,一起学习进步。

View File

@ -0,0 +1,253 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 容器网络配置2容器网络延时要比宿主机上的高吗
你好,我是程远。
在上一讲里我们学习了在容器中的网络接口配置重点讲解的是veth的接口配置方式这也是绝大部分容器用的缺省的网络配置方式。
不过呢从veth的这种网络接口配置上看一个数据包要从容器里发送到宿主机外需要先从容器里的eth0 (veth_container) 把包发送到宿主机上veth_host然后再在宿主机上通过nat或者路由的方式经过宿主机上的eth0向外发送。
这种容器向外发送数据包的路径相比宿主机上直接向外发送数据包的路径很明显要多了一次接口层的发送和接收。尽管veth是虚拟网络接口在软件上还是会增加一些开销。
如果我们的应用程序对网络性能有很高的要求特别是之前运行在物理机器上现在迁移到容器上的如果网络配置采用veth方式就会出现网络延时增加的现象。
那今天我们就来聊一聊,容器网络接口对于容器中应用程序网络延时有怎样的影响,还有这个问题应该怎么解决。
问题重现
对于这种veth接口配置导致网络延时增加的现象我们可以通过运行netperfNetperf是一个衡量网络性能的工具它可以提供单向吞吐量和端到端延迟的测试来模拟一下。
这里我们需要两台虚拟机或者物理机,这两台机器需要同处于一个二层的网络中。
具体的配置示意图如下:
首先我们需要在第一台机器上启动一个veth接口的容器容器的启动和宿主机上的配置你可以参考一下这里的脚本。在第二台机器上我们只要启动一个netserver就可以了。
然后呢我们分别在容器里和宿主机上运行与netserver交互的netperf再比较一下它们延时的差异。
我们可以运行netperf的TCP_RR测试用例TCP_RR是netperf里专门用来测试网络延时的缺省每次运行10秒钟。运行以后我们还要计算平均每秒钟TCP request/response的次数这个次数越高就说明延时越小。
接下来我们先在第一台机器的宿主机上直接运行netperf的TCP_RR测试用例3轮得到的值分别是2504.922410.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的容器就不能工作了”请你思考一下这个判断背后的具体原因。
欢迎你在留言区写下你的思考和疑问。如果这篇文章让你有所收获,也欢迎分享给你的朋友,一起交流进步。

View File

@ -0,0 +1,238 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 容器网络配置3容器中的网络乱序包怎么这么高
你好,我是程远。这一讲,我们来聊一下容器中发包乱序的问题。
这个问题也同样来自于工作实践,我们的用户把他们的应用程序从物理机迁移到容器之后,从网络监控中发现,容器中数据包的重传的数量要比在物理机里高了不少。
在网络的前面几讲里我们已经知道了容器网络缺省的接口是vethveth接口都是成对使用的。容器通过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 35的数据包那么接收端在回应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来计算的而不是按照包的数目来计算的所以你会看到即使只收到一个SACKLinux也可以重发数据包。
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的实现在网卡硬件和驱动里面而RPSReceive 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);
}
欢迎你在留言区分享你的思考或疑问。如果学完这一讲让你有所收获,也欢迎转发给你的同事、或者朋友,一起交流探讨。

View 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 Programmers 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的能力
欢迎你在留言区和我交流互动如果学完这一讲让你有所收获也欢迎转发给你的同事或者朋友一起交流探讨容器安全的问题

View 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 IDuid和Group IDgid它给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 containerKubernetes对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是怎么解决这些问题的。
欢迎你在留言区提出你的思考和疑问。如果这一讲对你有帮助,也欢迎转发给你的同事、朋友,一起交流学习。

View 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然后再通过路由从宿主机的obr0openvswitch接口和eth0接口发出。
为了方便你理解,我在下面画了一张示意图,描述了这个数据包的传输过程:
在这个过程里我们发现了当数据包从容器的eth0发送到宿主机上的cali29cf0fa56ce也就是从第3步到第4步之间花费的时间是10865291752980718-10865291551180388=201800330。
因为时间戳的单位是纳秒ns而201800330超过了200毫秒ms这个时间显然是不正常的。
你还记得吗?我们在容器网络模块的[第17讲]说过veth pair之间数据的发送它会触发一个softirq并且在我们ebpf的记录中也可以看到当数据包到达cali29cf0fa56ce后就是softirqd进程在CPU32上对它做处理。
那么这时候我们就可以把关注点放到CPU32的softirq处理上了。我们再仔细看看CPU32上的sisoftirq的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工具你还有什么方法来找到这个问题的突破口呢
欢迎你在留言区和我交流讨论。如果这一讲的内容对你有帮助的话,也欢迎转发给你的朋友、同事,和他一起学习进步。

View 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来自处理器中的一个PMUPerformance Monitoring Unit这些event数目不多都是底层处理器相关的行为perf中会命名几个通用的事件比如cpu-cycles执行完成的instructionsCache相关的cache-misses。
不同的处理器有自己不同的PMU事件对于Intel x86处理器PMU的使用和编程都可以在“Intel 64 and IA-32 Architectures Developers 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(&current->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的标准接口了ftraceebpf等工具都会用到它后面我们还会再详细介绍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
我这里给了一个带静态链接perfkernel 5.4的container image例子你可以运行 make image 来生成这个image。
在容器中运行perf还要注意一个权限的问题有两点注意事项需要你留意。
第一点Perf 通过系统调用perf_event_open()来完成对perf event的计数或者采样。不过Docker使用seccompseccomp是一种技术它通过控制系统调用的方式来保障Linux安全会默认禁止perf_event_open()。
所以想要让Docker启动的容器可以运行perf我们要怎么处理呢
其实这个也不难在用Docker启动容器的时候我们需要在seccomp的profile里允许perf_event_open()这个系统调用在容器中使用。在我们的例子中启动container的命令里已经加了这个参数允许了参数是”security-opt seccomp=unconfined”。
第二点需要允许容器在没有SYS_ADMIN这个capabilityLinux 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并且生成火焰图看看开销最大的函数是哪一个。
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@ -0,0 +1,333 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐03 理解ftrace1怎么应用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追踪到吗
欢迎你在留言区跟我分享你的思考与疑问,如果这一讲对你有启发,也欢迎转发给你的同事、朋友,跟他一起交流学习。

View File

@ -0,0 +1,340 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐04 理解ftrace2怎么理解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的应用这样我们后面做详细的原理介绍时你也会更容易理解。
首先看看tracepointtracepoint其实就是在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之后怎样能看到对应内核函数的第一条指令被替换了呢
欢迎你在留言区记录你的思考或者疑问。如果这一讲对你有帮助,也欢迎你转发给同事、朋友,跟他们一起交流、进步。

View File

@ -0,0 +1,326 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐05 eBPF怎么更加深入地查看内核中的函数
你好,我是程远。
今天这一讲我们聊一聊eBPF。在我们专题加餐第一讲的分析案例时就说过当我们碰到网络延时问题在毫无头绪的情况下就是依靠了我们自己写的一个eBPF工具找到了问题的突破口。
由此可见eBPF在内核问题追踪上的重要性是不言而喻的。那什么是eBPF它的工作原理是怎么样它的编程模型又是怎样的呢
在这一讲里,我们就来一起看看这几个问题。
eBPF的概念
eBPF它的全称是“Extended Berkeley Packet Filter”。从名字看你可能会觉奇怪似乎它就是一个用来做网络数据包过滤的模块。
其实这么想也没有错eBPF的概念最早源自于BSD操作系统中的BPFBerkeley Packet Filter1992伯克利实验室的一篇论文 “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之后 它的应用范围自然也变广了。从单纯的网络包抓取,扩展到了下面的几个领域:
网络领域内核态网络包的快速处理和转发你可以看一下XDPeXpress Data Path
安全领域通过LSMLinux Security Module的hook点eBPF可以对Linux内核做安全监控和访问控制你可以参考KRSIKernel 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进行很严格的指令检查。检查通过之后再通过JITJust 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 TypeseBPF 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()函数的进程所对应的进程号打印出来。
欢迎你在留言区记录你的思考或疑问。如果这一讲让你有所收获,也欢迎转发给你的朋友,同事,跟他一起学习进步。

View File

@ -0,0 +1,339 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐06 BCC入门eBPF的前端工具
你好,我是程远。
今天是我们专题加餐的最后一讲,明天就是春节了,我想给还在学习的你点个赞。这里我先给你拜个早年,祝愿你牛年工作顺利,健康如意!
上一讲我们学习了eBPF的基本概念以及eBPF编程的一个基本模型。在理解了这些概念之后从理论上来说你就能自己写出eBPF的程序对Linux系统上的一些问题做跟踪和调试了。
不过从上一讲的例子里估计你也发现了eBPF的程序从编译到运行还是有些复杂。
为了方便我们用eBPF的程序跟踪和调试系统社区有很多eBPF的前端工具。在这些前端工具中BCC提供了最完整的工具集以及用于eBPF工具开发的Python/Lua/C++的接口。那么今天我们就一起来看看怎么使用BCC这个eBPF的前端工具。
如何使用BCC工具
BCCBPF 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执行时间在01us时间区间里的有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.debugcflags_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 loaderlibbpf库以及Clang编译器的修改来实现编译出来的eBPF程序可以在不同版本的内核上运行
不同版本的内核上用CO-RE编译出来的eBPF程序都可以运行在Linux内核和BPF程序之间会通过BTFBPF 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目录下的工具。
欢迎你在留言区记录你的心得或者疑问。如果这一讲对你有帮助,也欢迎分享给你的同事、朋友,和他一起学习进步。

View 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内核的最主要的几个调试工具包括perfftrace和ebpf。我们逐个使用这些工具就可以层层递进地揭开问题的本质。
通过这个案例的学习,我会带你掌握每种工具的特性。这样你在理解了容器基本原理的基础上,就能利用这些好的工具系统化地分析生产环境中碰到的容器问题了,就像我们开篇中说的那样——变黑盒为白盒。
写完结束语之后,我会认真为你准备这个专题加餐。而这一个月的时间,你还可以继续消化理解课程主体部分的内容,打牢基础,这样对你学习后面的专题加餐也有很大帮助。
最后的最后,我想和你说的是,希望你我都能主动思考,不断突破自己,走出舒适区,一起共勉吧!
这里我为你准备了一份毕业问卷,题目不多,希望你可以花两分钟填一下。也十分期待能听到你的声音,说说你对这门课程的想法和建议。