自我反思检索增强生成(Self-Reflective Retrieval-Augmented Generation,SELF-RAG)是一种通过检索和自我反思提高 LLM 质量和事实准确性的框架,而不损害 LLM 的原始创造力和多功能性。本文将详细介绍 SELF-RAG 框架。
SELF-RAG 允许语言模型 \(LM\) 根据检索到的段落生成信息,并且通过自我批判生成的内容来生成特殊 token。这些特殊的 token 称之为 reflection token(反思 token),表示是否需要检索或确认输出的相关性、或完整性。相比之下,常规的 RAG 方法会无差别地检索段落,且不确保引用来源的是否完全支持输入。
具体而言,SELF-RAG 首先确定是否通过在继续生成时使用检索到的段落来帮助生成,如果是,它会输出一个检索标记,调用一个检索模型(第一步)。接下来,SELF-RAG 同时处理多个检索到的段落,评估它们的相关性,然后生成相应的任务输出(第二步)。然后生成评论标记,对自己的输出进行批评并选择最佳输出(第三步),评价标准是事实准确性和整体质量。接下来将进一步介绍 SELF-RAG 的几个重要概念与算法。
给定输入 \(x\),SELF-RAG 会训练语言模型 \(M\) 顺序生成文本 \(y\),且 \(y\) 由多个段落组成,记为 \(y=[y_1,...,y_t]\),其中,\(y_t\) 表示第 \(t\) 个段落的 token 序列,\(y_t\) 中的生成 token 包括原始文本和反思 token。
而反思 token 主要有 4 种,分别为 Retrieve
,IsRel
,IsSup
,IsUse
,其含义分别如下:
表示对于给定输入,判断是否需要额外检索信息。
比如:
再比如:
表示对于给定输入,检索出的信息是否提供了有用信息来解决输入问题。
比如:
评估检索信息中提供的信息是否完全支持输出,输出为 “Fully supported, partially supported, no support”。
比如:
表示回答是否对问题有用,输出为 “5,4,3,2,1”,5 为非常有用,1 为几乎不切题或完全不相关。
比如:
给定一组输入输出数据 \(D=\{X, Y\}\),Generator 模型 \(M\),Critic 模型 \(C\)。
手动标记每个段落的反思 token 是不现实的,而我们可以使用像 GPT-4 这样的最先进的大语言模型来生成反思 token。通过引导 GPT-4 生成反思 token,可以将其知识提炼到内部的评论者模型 \(\mathcal{C}\) 中,从而创建了监督数据。如下图所示:
对每组反思 token,从原始训练数据中随机采样 \(\{X^{sample},Y^{sample}\}\sim \{X,Y\}\)。由于每组反思 token 有自己的定义和输入,我们会针对性使用不同的 prompt。
这里以 Retrieve
为例,通过使用类型特定的指令来引导 GPT-4,比如给定一条指令,在原始任务输入 \(x\) 和输出 \(y\) 上进行少量示范,判断从网络中找到一些外部文档是否有助于生成更好的响应,以生成适当的反思 token:\(p(r|I,x,y)\) 。
生成数据 \(\mathcal{D}_{critic}\) 后,使用预训练语言模型 \(LM\) 初始化评论者模型 \(\mathcal{C}\),并用 \(\mathcal{D}_{critic}\) 对其进行训练。其目标函数为(对每对 reflection token 来说):
\[\max_{c} \mathbb{E}_{((x,y),r)\sim D_{critic} } \log {p_c}(r|x,y), r \]初始模型可以是任意的预训练语言模型 \(LM\),评论者模型在大多数 reflection token 类别上都与基于 GPT-4 的预测达成了超过 90% 的一致性。
给定一个输入输出对 \((x,y)\),使用检索和评论者模型来扩充原始输出 \(y\),从而创建监督数据,精确地模拟 SELF-RAG 推理时的过程。整个过程如下:
对 \(y_t\in y\),运行批评者模型 \(\mathcal C\) 来评估需要额外的检索信息来帮助增强生成。如果需要,则加上 \(Retrieve=Yes\) token,并且使用 \(\mathcal R\) 来获取前 \(K\) 个信息段落 \(D\)。对每个段落来说,\(\mathcal C\) 会进一步评估相关性并预测 \(IsRel\)。如果某个段落是相关的,则 \(\mathcal C\) 会进一步评估该段落是否支持模型的输出,并预测 \(IsSup\)。评论 token \(IsRel\) 和 \(IsSup\) 会被附加到检索的段落或输出后面。在最后的输出 \(y\) 中,\(\mathcal C\) 会预测整体效用 token \(IsUSE\),并将带有反思 token 和原始输入对的扩充输出添加到 \(D_{gen}\)。
通过使用精选的增强语料库,以及 reflection token \(D_{gen}\) 来训练生成器模型 \(M\)。目标函数为:
\[\max_{\mathcal{M} } \mathbb{E}_{(x,y,r)\sim D_{gen} } \log {p_\mathcal{M} }(y,r|x). \]与评判模型 \(\mathcal{C}\) 训练不同,生成器 \(\mathcal{M}\) 学习预测目标输出以及 reflection tokens。训练期间,将检索到的文本块(由 <p>
和 </p>
标记)进行遮挡以进行损失计算,这意味着模型在计算损失时不考虑这些检索到的文本块。原始词汇 \(\mathcal{V}\) 通过一组 reflection tokens(如 <Critique>
和 <Retrieve>
)进行扩展,这表示这些 tokens 被加入到词汇中,使模型能够使用这些特定的 tokens 来生成输出。
最后再来介绍一下 SELF-RAG 的推理过程。如下图所示:
对于每个输入 \(x\) 和前一代生成的 \(y_{<t}\),模型解码检索 token 以评估检索的效用。如果不需要检索,模型将直接预测下一段输出,这与标准的语言模型行为一致。如果需要检索,模型会生成:一个评估检索段落的相关性的反思 token、下一个回答段落、以及评估回答段落是否被检索信息支持的反思 token。最后,一个评估整体效用的新的反思 token。每生成一个回答,SELF-RAG 都会并行处理多个段落,并且使用其自动生成的反思 token 来控制生成的输出。
生成反思 token 以自我评估输出使得在推理阶段 SELF-RAG 更加可控,能够调整其行为以满足多样的任务要求。对于要求事实准确性的任务,目标是使模型更频繁地检索段落,以确保输出与现有证据紧密对齐。相反,在更为开放的任务中,例如撰写个人经历文章,重点转向更少的检索,优先考虑整体创造力或效用。接下来,将介绍在推理过程中如何实施控制以满足这些不同目标的方法。
SELF-RAG 可以动态决定何时检索文本段落,这是通过预测 Retrieve token 来完成的。此外,框架还允许设定一个阈值。具体而言,如果生成的 token 是 Retrieve=Yes,且在所有输出 token 中的标准化值超过了指定的阈值,则触发检索。
在每个段落步骤 \(t\) 中,当需要检索时,基于硬性或软性条件,\(\mathcal R\) 检索 \(K\) 个段落,并且生成器模型 \(\mathcal M\) 并行处理每个段落并输出 \(K\) 个不同的候选值。我们进行段落级的 Beam Search(使用 Beam 大小为 \(B\))以获取每个时间戳 \(t\) 的前 \(B\) 个段落,并在生成结束时返回最佳序列。
每个段落 \(y_t\) 相对于段落 \(d\) 的分数通过评论者模型的评分 \(\mathcal S\) 进行更新,该评分是每个评论 token 类型的标准化概率的线性加权和。对于每个评论 token 组 \(G\)(例如 \(IsREL\)),我们将其在时间戳 \(t\) 的分数表示为 \(s^G_t\),然后按以下方式计算段落分数:
\[\displaylines{ f(y_t,d,Critique)=p(y_t|x,d,y_{<t})+\mathcal S(Critique), \text{where} \\ \mathcal S(\text{Critique})=\sum_{G\in \mathcal G} \omega ^Gs^G_t \space \text{for} \space \mathcal{G} =\{\text{IsREL, IsSUP, IsUSE}\} } \]其中,\(s^G_t=\frac{p_t(\widehat{r} )}{ {\textstyle \sum_{i=1}^{N^G}}(P_t(r_i)) } \) 代表最理想的反射 token 的生成概率 \(\widehat{r}(e.g.,\text{IsREL}=\text{Relevant})\),其中 \(N_G\) 个不同的令牌表示 \(G\) 的不同可能值。权重 \(\omega ^G\) 为可以调整的超参,以自定义模型在推理期间的行为。另外,通过调整这些权重,可以强调某些期望的行为并降低其他行为。
]]>/dev/fuse
的块设备。FUSE daemon 通过 /dev/fuse
读取请求,并将结果写入 /dev/fuse
,这个 FUSE 设备就充当了 FUSE daemon 与内核通信的桥梁。
在 Kubernetes 环境中,如果需要在 Pod 中运行 FUSE daemon,通常是将其设置为特权容器。当 Pod 为特权时,自然所有的权限都会有,甚至也直接享用宿主机的设备。但非特权 Pod 想要运行用户态的文件系统有点困难,主要需要两点:
/dev/fuse
设备的读写权限;本篇文章主要讲解在没有特权的情况下,如何在 Pod 中运行用户态文件系统。
首先,mount
属于管理级别的系统调用,需要 CAP_SYS_ADMIN
权限,参考 capability 文档:
CAP_SYS_ADMIN
Note: this capability is overloaded; see Notes to kernel
developers, below.
* Perform a range of system administration operations
including: quotactl(2), mount(2), umount(2),
pivot_root(2), swapon(2), swapoff(2), sethostname(2),
and setdomainname(2);
CAP_SYS_ADMIN
可以在 Pod 的 .securityContext.capabilities
中设置,如下:
securityContext:
capabilities:
add:
- SYS_ADMIN
其次,有的系统开启了 Linux 内核安全模块 AppArmor,默认的 AppArmor 配置也是没有 mount
权限的,需要额外配置 mount
权限,如下:
#include <tunables/global>
profile app flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
mount,
umount,
capability sys_admin,
...
}
在每台节点上配置好之后,在 pod 中加入 container.apparmor.security.beta.kubernetes.io/app: localhost/app
的注解,以声明使用这份 AppArmor 配置,详细信息可以参考《如何使用 AppArmor 限制应用的权限》。
一个用户态的文件系统包含一个内核模块和一个用户空间 daemon 进程。内核模块加载时被注册成 Linux 虚拟文件系统的一个 fuse 文件系统驱动。此外,还注册了一个 /dev/fuse
的块设备。该块设备作为 fuse daemon 进程与内核通信的桥梁。而对于用户空间的 fuse daemon 来说,访问 /dev/fuse
设备是至关重要的。
在 Kubernetes 环境中,如果要将宿主机的某个块设备挂载进 pod 中,可以使用 Device Plugins。而 Device Plugins 需要第三方服务自己提供,实现起来也比较简单。
对于 FUSE 设备的 Device Plugins 来说,社区也有很多实现,不过都大同小异,只需要在 Device Plugins 接口 Allocate
中将宿主机的 /dev/fuse
目录挂载进容器的 /dev/fuse
并给与 rwm
权限即可。比如:
func (m *FuseDevicePlugin) Allocate(ctx context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
devs := m.devs
var responses pluginapi.AllocateResponse
for _, req := range reqs.ContainerRequests {
for _, id := range req.DevicesIDs {
log.Printf("Allocate device: %s", id)
if !deviceExists(devs, id) {
return nil, fmt.Errorf("invalid allocation request: unknown device: %s", id)
}
}
response := new(pluginapi.ContainerAllocateResponse)
response.Devices = []*pluginapi.DeviceSpec{
{
ContainerPath: "/dev/fuse",
HostPath: "/dev/fuse",
Permissions: "rwm",
},
}
responses.ContainerResponses = append(responses.ContainerResponses, response)
}
return &responses, nil
}
上述 Device Plugins 的完整代码实现详见zwwhdls/node-device-plugin。
在 Pod 中使用只需要在 resources
中申明即可,比如:
apiVersion: v1
kind: Pod
metadata:
name: fuse
spec:
containers:
- name: test
image: centos
command: [ "sleep", "infinity" ]
resources:
limits:
hdls.me/fuse: "1"
requests:
hdls.me/fuse: "1"
本文主要讲解了非特权 Pod 如何运行用户态文件系统,主要需要给与挂载所需权限即 CAP_SYS_ADMIN
并将宿主机的块设备 /dev/fuse
挂载进 Pod 中,其中挂载块设备需要做一些开发工作,实现一个 Device Plugin。然后就可以愉快地在非特权 Pod 中运行 FUSE daemon 了。
在开启了 AppArmor 的系统中,容器运行时会给容器使用默认的权限配置,当然,应用也可以使用自定义配置。本文将讲述如何在容器中使用 AppArmor。
AppArmor 是一个 Linux 内核安全模块,允许系统管理员使用每个程序的配置文件来限制程序的功能。配置文件可以允许网络访问、原始套接字访问以及在匹配路径上读取、写入或执行文件的权限等功能。
不过,并不是所有的系统都支持 AppArmor。默认情况下,有几个发行版支持该模块,如 Ubuntu 和 SUSE,还有许多发行版提供可选支持。可以通过以下命令检查模块是否已启用 AppArmor:
$ cat /sys/module/apparmor/parameters/enabled
Y
AppArmor 在以下两种类型的配置文件模式下运行:
配置文件是位于 /etc/apparmor.d/
目录下的文本文件。这些文件以它们分析的可执行文件的完整路径命名,但将 /
替换为 .
。例如,tcpdump
命令位于 /usr/sbin/tcpdump
,等效的 AppArmor 配置文件将命名为 usr.sbin.tcpdump
。
也可以设置自己的配置文件,比如 sample
profile 设置限制所有文件的写权限:
$ cat <<EOF >/etc/apparmor.d/containers/sample
#include <tunables/global>
profile juicefs flags=(attach_disconnected) {
#include <abstractions/base>
file,
mount,
}
EOF
将上述配置生效:
$ apparmor_parser /etc/apparmor.d/containers/sample
$ apparmor_status
apparmor module is loaded.
35 profiles are loaded.
35 profiles are in enforce mode.
...
sample
...
AppArmor 主要支持 Capability、File、Network 三种规则:
capability sys_admin,
表示允许执行系统管理任务。/home/** rw,
表示对 /home
下所有文件具备读写权限;mount options=ro /dev/foo,
表示允许以只读方式挂载到 /dev/foo
路径;network tcp,
表示支持所有 tcp 类型的网络操作;AppArmor 的配置文件定义的十分灵活,更多具体使用可以参见 AppArmor 文档。
在主机上配置好 AppArmor 配置文件后,我们来看如何在容器中使用。
当容器引擎为 Docker 时,作为对比,首先运行一个普通的 nginx 容器,并创建一个 test 文件:
$ docker run --rm -it nginx /bin/bash
root@45bf95280766:/# cd
root@45bf95280766:~# touch test
root@45bf95280766:~# ls
test
接下来运行一个使用上述限制所有文件的写权限的 AppArmor 配置文件 sample 的容器,并创建一个 test 文件:
$ docker run --rm -it --security-opt "apparmor=sample" nginx /bin/bash
root@1d1d8f6b1aa0:/# cd ~
root@1d1d8f6b1aa0:~# touch test
touch: cannot touch 'test': Permission denied
我们可以看到 AppArmor 配置文件阻止了创建文件操作。
当容器引擎为 Containerd 时,做一样的测试:
$ nerdctl run --rm -it docker.io/library/nginx:latest /bin/bash
root@09e6c02616a7:/# cd ~
root@09e6c02616a7:~# touch test
root@09e6c02616a7:~#
root@09e6c02616a7:~# ls
test
在容器中使用 sample 配置文件:
$ nerdctl run --rm -it --security-opt "apparmor=sample" docker.io/library/nginx:latest /bin/bash
root@8be22275bc9d:/# cd
root@8be22275bc9d:~# touch test
touch: cannot touch 'test': Permission denied
同样,AppArmor 配置文件也阻止了创建文件操作。
如何在 Kubernetes 中使用呢?方式为在 Pod 的 annotation 中声明哪个容器使用哪个配置文件,其 key 为 container.apparmor.security.beta.kubernetes.io/<container_name>
,value 有 3 个不同的值:
以上面创建的配置文件 sample
为例:
apiVersion: v1
kind: Pod
metadata:
name: test
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/sample
spec:
containers:
- args:
- -c
- sleep 1000000
command:
- /bin/sh
image: ubuntu
name: app
同样测试 pod 中是否有创建文件的权限:
$ kubectl exec -it test -- bash
root@test:/# cd
root@test:~# touch test
touch: cannot touch 'test': Permission denied
在开启了 AppArmor 的系统中,使用 AppArmor 对节点及 Pod 的保护是非常有必要的,但是 AppArmor 的配置也是比较棘手的。不过社区中已经有较为成熟的解决方案,比如对于快速生成 AppArmor 配置文件,可以用工具 bane。对于每个节点均配置同样的配置文件,可以使用 DaemonSet 来实现,参考案例;也可以节点初始化脚本(例如 Salt、Ansible 等)或镜像;也可以通过将配置文件复制到每个节点并通过 SSH 加载它们,参考示例。
]]>本文展示的完整的项目代码可见:https://github.com/zwwhdls/csi-hdls
其实 CSI Driver 无非就是实现一些接口,实现第三方存储的逻辑。短短一句话包含的工作是很大的,也比较繁琐,需要理解清楚 CSI 的工作原理,但好在工作是有迹可循的。
近期开发了一个脚手架工具 CSIbuilder,其原理类似于 kubebuilder,用户只需要输入几行命令,就可以搭建一个 CSI Driver 的代码框架,然后再填入自己的逻辑即可。
使用过程很简单,首先下载二进制包:
weiwei@hdls-mbp $ curl -L -o csibuilder.tar https://github.com/zwwhdls/csibuilder/releases/download/v0.1.0/csibuilder-darwin-amd64.tar
weiwei@hdls-mbp $ tar -zxvf csibuilder.tar && chmod +x csibuilder && mv csibuilder /usr/local/bin/
新建一个 golang 项目的工作目录:
weiwei@hdls-mbp $ export GO111MODULE=on
weiwei@hdls-mbp $ mkdir $GOPATH/src/csi-hdls
weiwei@hdls-mbp $ cd $GOPATH/src/csi-hdls
使用 csibuilder
进行 repo 初始化:
weiwei@hdls-mbp $ csibuilder init --repo hdls --owner "zwwhdls"
Init CSI Driver Project for you...
Update dependencies:
$ go mod tidy
go: warning: "all" matched no packages
Next: define a csi driver with:
$ csibuilder create api
创建一个名为 hdls
的 CSI Driver:
weiwei@hdls-mbp $ csibuilder create --csi hdls
Writing scaffold for you to edit...
Update dependencies:
$ go mod tidy
go: finding module for package google.golang.org/grpc/status
go: finding module for package github.com/container-storage-interface/spec/lib/go/csi
go: finding module for package google.golang.org/grpc
go: finding module for package google.golang.org/grpc/codes
go: finding module for package k8s.io/klog
go: found k8s.io/klog in k8s.io/klog v1.0.0
go: found github.com/container-storage-interface/spec/lib/go/csi in github.com/container-storage-interface/spec v1.7.0
go: found google.golang.org/grpc in google.golang.org/grpc v1.50.1
go: found google.golang.org/grpc/codes in google.golang.org/grpc v1.50.1
go: found google.golang.org/grpc/status in google.golang.org/grpc v1.50.1
Scaffolding complete. Enjoy your new project!
默认使用 go 1.18
,也可以在 init
阶段通过参数 --goversion=1.19
来指定使用的 go 版本。
然后就能看到项目里已经初始化好了 CSI Driver 的代码文件和部署 yaml:
weiwei@hdls-mbp $ tree
.
├── Dockerfile
├── Makefile
├── PROJECT
├── deploy
│ ├── clusterrole.yaml
│ ├── clusterrolebinding.yaml
│ ├── csidriver.yaml
│ ├── daemonset.yaml
│ ├── serviceaccount.yaml
│ └── statefulset.yaml
├── go.mod
├── go.sum
├── hack
│ └── boilerplate.go.txt
├── main.go
└── pkg
└── csi
├── controller.go
├── driver.go
├── identity.go
├── node.go
└── version.go
4 directories, 18 files
Pod 挂载的过程在上一篇文章《浅析 CSI 工作原理》 中已经详细介绍过了,我们知道 CSI 工作最重要的组件是 CSI Node,也就是其 NodePublishVolume
和 NodeUnpublishVolume
两个接口。
kubelet 调用接口时传进来的参数都可以在 request
中找到,volumeID
为 Pod 所使用的 PV 的 id(即 pv.spec.csi.volumeHandle);target
为 Pod 内所需要挂载 volume 的路径;options
为本次挂载的挂载参数。
我们所需要做的就是将我们的存储挂载到 Pod 内的 target
路径。这里以 nfs 为例:
func (n *nodeService) NodePublishVolume(ctx context.Context, request *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
...
volCtx := request.GetVolumeContext()
klog.Infof("NodePublishVolume: volume context: %v", volCtx)
hostPath := volCtx["hostPath"]
subPath := volCtx["subPath"]
sourcePath := hostPath
if subPath != "" {
sourcePath = path.Join(hostPath, subPath)
exists, err := mount.PathExists(sourcePath)
if err != nil {
return nil, status.Errorf(codes.Internal, "Could not check volume path %q exists: %v", sourcePath, err)
}
if !exists {
klog.Infof("volume not existed")
err := os.MkdirAll(sourcePath, 0755)
if err != nil {
return nil, status.Errorf(codes.Internal, "Could not make directory for meta %q", sourcePath)
}
}
}
klog.Infof("NodePublishVolume: binding %s at %s", hostPath, target)
if err := n.Mount(sourcePath, target, "none", mountOptions); err != nil {
os.Remove(target)
return nil, status.Errorf(codes.Internal, "Could not bind %q at %q: %v", hostPath, target, err)
}
return &csi.NodePublishVolumeResponse{}, nil
}
我们可以在 PV.spec.parameter 中传入 nfs server 的一些参数,从 request.GetVolumeContext()
中可以获取到,然后再以 mount bind 的方式挂载到 target
。为了逻辑简单,这里我们假定参数 hostPath
为宿主机上已经挂载好的 nfs 路径。
而 NodeUnpublishVolume
接口就是上面 NodePublishVolume
接口的反向操作。我们只需要将 target
路径 umount
掉即可,代码如下:
// NodeUnpublishVolume unmount the volume from the target path
func (n *nodeService) NodeUnpublishVolume(ctx context.Context, request *csi.NodeUnpublishVolumeRequest) (*csi.NodeUnpublishVolumeResponse, error) {
target := request.GetTargetPath()
if len(target) == 0 {
return nil, status.Error(codes.InvalidArgument, "Target path not provided")
}
// TODO modify your volume umount logic here
...
klog.Infof("NodeUnpublishVolume: unmounting %s", target)
if err := n.Unmount(target); err != nil {
return nil, status.Errorf(codes.Internal, "Could not unmount %q: %v", target, err)
}
return &csi.NodeUnpublishVolumeResponse{}, nil
}
CSI Controller 接口主要是配合实现 PV 的自动创建,只有在使用 StorageClass 时才会工作。这里的逻辑通常为在文件系统下创建一个子目录,并将其放在 PV 的 spec
中。在本文展示的 demo 中,创建子目录这一步也是在 CSI Node 的接口中实现,所以在 CSI Controller 的接口只需要将子目录传在 PV 的参数中即可。
CreateVolume
的代码如下:
func (d *controllerService) CreateVolume(ctx context.Context, request *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
if len(request.Name) == 0 {
return nil, status.Error(codes.InvalidArgument, "Volume Name cannot be empty")
}
if request.VolumeCapabilities == nil {
return nil, status.Error(codes.InvalidArgument, "Volume Capabilities cannot be empty")
}
requiredCap := request.CapacityRange.GetRequiredBytes()
volCtx := make(map[string]string)
for k, v := range request.Parameters {
volCtx[k] = v
}
volCtx["subPath"] = request.Name
volume := csi.Volume{
VolumeId: request.Name,
CapacityBytes: requiredCap,
VolumeContext: volCtx,
}
return &csi.CreateVolumeResponse{Volume: &volume}, nil
}
返回值中的 VolumeContext
会放在自动创建的 PV 的 spec.csi.volumeAttributes
中。
以上就是实现一个 CSI Driver 最简单可用功能的完整展示,有了 csibuilder 之后,CSI Driver 的代码编写也变得异常简单。不过 CSI 的功能远不止这些,还包括 attach、stage、expand、snapshot 等。csibuilder 目前还是一个刚刚完工的状态,在后续的迭代中会陆续支持这些功能。
]]>为了避免这种竞争条件,Kubernetes 提供了 Leader 选举的模式,多副本之间相互竞争 Leader,只有成为 Leader 才工作,否则一直等待。本文将从 Leader 选举的原理以及作为用户如何使用等方面,介绍如何在 Kubernetes 中实现组件的高可用。
Leader 选举的原理主要是利用 Lease、ConfigMap、Endpoint 资源实现乐观锁,Lease 资源中定义了 Leader 的 id、抢占时间等信息;ConfigMap 和 Endpoint 在其 annotation 中定义 control-plane.alpha.kubernetes.io/leader
为 leader。是的,没错,如果我们自己实现,随便定义自己的喜欢的字段也行,这里其实是利用了 resourceVersion 来实现的乐观锁。
原理如下图所示,多个副本之间会竞争同一个资源,抢占到了锁就成为 Leader,并定期更新;抢占不到则原地等待,不断尝试抢占。
client-go
中提供了锁的工具方法,k8s 的组件也是直接通过 client-go
来使用的。接下来我们来分析 client-go
提供的工具方法如何实现 Leader 选举。
首先会根据定义的名称获取锁,没有则创建;随后判断当前锁有没有 Leader 以及 Leader 的租期是否到期,没有则抢占锁,否则返回并等待。
其中,抢占锁的过程势必会存在 update 资源的操作,而 k8s 通过版本号的乐观锁实现了 update 操作的原子性。在 update 资源时,ApiServer 会对比 resourceVersion,如果不一致将返回冲突错误。通过这种方式,update 操作的安全性就得到了保证。
抢占锁的代码如下:
func (le *LeaderElector) tryAcquireOrRenew(ctx context.Context) bool {
now := metav1.Now()
leaderElectionRecord := rl.LeaderElectionRecord{
HolderIdentity: le.config.Lock.Identity(),
LeaseDurationSeconds: int(le.config.LeaseDuration / time.Second),
RenewTime: now,
AcquireTime: now,
}
// 1. obtain or create the ElectionRecord
oldLeaderElectionRecord, oldLeaderElectionRawRecord, err := le.config.Lock.Get(ctx)
if err != nil {
if !errors.IsNotFound(err) {
klog.Errorf("error retrieving resource lock %v: %v", le.config.Lock.Describe(), err)
return false
}
if err = le.config.Lock.Create(ctx, leaderElectionRecord); err != nil {
klog.Errorf("error initially creating leader election record: %v", err)
return false
}
le.setObservedRecord(&leaderElectionRecord)
return true
}
// 2. Record obtained, check the Identity & Time
if !bytes.Equal(le.observedRawRecord, oldLeaderElectionRawRecord) {
le.setObservedRecord(oldLeaderElectionRecord)
le.observedRawRecord = oldLeaderElectionRawRecord
}
if len(oldLeaderElectionRecord.HolderIdentity) > 0 &&
le.observedTime.Add(le.config.LeaseDuration).After(now.Time) &&
!le.IsLeader() {
klog.V(4).Infof("lock is held by %v and has not yet expired", oldLeaderElectionRecord.HolderIdentity)
return false
}
// 3. We're going to try to update. The leaderElectionRecord is set to it's default
// here. Let's correct it before updating.
if le.IsLeader() {
leaderElectionRecord.AcquireTime = oldLeaderElectionRecord.AcquireTime
leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions
} else {
leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions + 1
}
// update the lock itself
if err = le.config.Lock.Update(ctx, leaderElectionRecord); err != nil {
klog.Errorf("Failed to update lock: %v", err)
return false
}
le.setObservedRecord(&leaderElectionRecord)
return true
}
client-go
仓库提供了一个 example
,我们启动一个进程后,可以看到其 Lease 信息如下:
$ kubectl get lease demo -oyaml
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
...
spec:
acquireTime: "2022-07-23T14:28:41.381108Z"
holderIdentity: "1"
leaseDurationSeconds: 60
leaseTransitions: 0
renewTime: "2022-07-23T14:28:41.397199Z"
释放锁的逻辑是在 Leader 退出前,也是执行 update 操作,将 Lease 的 leader 信息清空。
func (le *LeaderElector) release() bool {
if !le.IsLeader() {
return true
}
now := metav1.Now()
leaderElectionRecord := rl.LeaderElectionRecord{
LeaderTransitions: le.observedRecord.LeaderTransitions,
LeaseDurationSeconds: 1,
RenewTime: now,
AcquireTime: now,
}
if err := le.config.Lock.Update(context.TODO(), leaderElectionRecord); err != nil {
klog.Errorf("Failed to release lock: %v", err)
return false
}
le.setObservedRecord(&leaderElectionRecord)
return true
}
将上一步启动的进程 kill 后,再看其 Lease 信息:
$ kubectl get lease demo -oyaml
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
...
spec:
acquireTime: "2022-07-23T14:29:26.557658Z"
holderIdentity: ""
leaseDurationSeconds: 1
leaseTransitions: 0
renewTime: "2022-07-23T14:29:26.557658Z"
我们在实现自己的 Controller 的时候,通常是使用 controller runtime 工具,而 controller runtime 早已将 Leader 选举的逻辑做好了封装。
主要逻辑在两处,一是 Lease 基础信息的定义,根据用户的定义补充基础信息,如当前运行的 namespace 作为 leader 的 namespace、根据 host 生成随机的 id 等。
func NewResourceLock(config *rest.Config, recorderProvider recorder.Provider, options Options) (resourcelock.Interface, error) {
if options.LeaderElectionResourceLock == "" {
options.LeaderElectionResourceLock = resourcelock.LeasesResourceLock
}
// LeaderElectionID must be provided to prevent clashes
if options.LeaderElectionID == "" {
return nil, errors.New("LeaderElectionID must be configured")
}
// Default the namespace (if running in cluster)
if options.LeaderElectionNamespace == "" {
var err error
options.LeaderElectionNamespace, err = getInClusterNamespace()
if err != nil {
return nil, fmt.Errorf("unable to find leader election namespace: %w", err)
}
}
// Leader id, needs to be unique
id, err := os.Hostname()
if err != nil {
return nil, err
}
id = id + "_" + string(uuid.NewUUID())
// Construct clients for leader election
rest.AddUserAgent(config, "leader-election")
corev1Client, err := corev1client.NewForConfig(config)
if err != nil {
return nil, err
}
coordinationClient, err := coordinationv1client.NewForConfig(config)
if err != nil {
return nil, err
}
return resourcelock.New(options.LeaderElectionResourceLock,
options.LeaderElectionNamespace,
options.LeaderElectionID,
corev1Client,
coordinationClient,
resourcelock.ResourceLockConfig{
Identity: id,
EventRecorder: recorderProvider.GetEventRecorderFor(id),
})
}
二是启动 leader 选举,注册 lock 信息、租期时间、callback 函数等信息,再启动选举进程:
func (cm *controllerManager) startLeaderElection(ctx context.Context) (err error) {
l, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
Lock: cm.resourceLock,
LeaseDuration: cm.leaseDuration,
RenewDeadline: cm.renewDeadline,
RetryPeriod: cm.retryPeriod,
Callbacks: leaderelection.LeaderCallbacks{
OnStartedLeading: func(_ context.Context) {
if err := cm.startLeaderElectionRunnables(); err != nil {
cm.errChan <- err
return
}
close(cm.elected)
},
OnStoppedLeading: func() {
if cm.onStoppedLeading != nil {
cm.onStoppedLeading()
}
cm.gracefulShutdownTimeout = time.Duration(0)
cm.errChan <- errors.New("leader election lost")
},
},
ReleaseOnCancel: cm.leaderElectionReleaseOnCancel,
})
if err != nil {
return err
}
// Start the leader elector process
go func() {
l.Run(ctx)
<-ctx.Done()
close(cm.leaderElectionStopped)
}()
return nil
}
有了 controller runtime 对选举逻辑的包装,我们在使用的时候,就方便很多。根据 Controller Runtime 的使用姿势 一文的介绍,我们可以在初始化 Controller 的时候,定义 Lease 的信息:
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// 1. init Manager
mgr, _ := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Port: 9443,
LeaderElection: true,
LeaderElectionID: "demo.xxx",
})
// 2. init Reconciler(Controller)
_ = ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}).
Complete(&ApplicationReconciler{})
...
只要在初始化时,加入 LeaderElection: true
,以及 LeaderElectionID
,即 Lease 的 name,保证集群内唯一即可。其他的信息 controller runtime 都会帮你填充。
在生产环境中,高可用是一个很重要的功能,没有高可用的服务没人敢上生产。Kubernetes 基于 etcd 的 modifiedindex 实现了 resourceVersion 的乐观锁,通过这个乐观锁,Leader 选举机制才能够被多副本使用,避免竞争条件。我们在实现自己的 Controller 的时候只需要巧妙利用这一机制,就可以轻松实现高可用。
]]>翻译自 《SIGTERM: Graceful termination of Linux containers (exit code 143)》
原文链接:https://komodor.com/learn/sigterm-signal-15-exit-code-143-linux-graceful-termination/
SIGTERM
(信号 15)在基于 Unix 的操作系统(如 Linux)中用于终止进程。SIGTERM
信号提供了一种优雅的方式来终止程序,使其有机会准备关闭并执行清理任务,或者在某些情况下拒绝关闭。Unix/Linux 进程可以以多种方式处理 SIGTERM
,包括阻塞和忽略。
SIGTERM
是 Unix/Linux kill 命令的默认行为,当用户执行 kill 时,操作系统会在后台向进程发送 SIGTERM
。如果过程不在 Docker 容器中,通过 SIGTERM
信号终止的容器在其日志中显示退出码 143。如果您是 Kubernetes 用户,本文将帮助您了解 Kubernetes 终止容器时幕后发生的情况,以及如何在 Kubernetes 中使用 SIGTERM
信号。
SIGTERM
(Unix 信号 15)是一个“礼貌”的 Unix 信号,默认情况下会终止进程,但可以被进程处理或忽略。这使进程有机会在关闭之前完成基本操作或执行清理。目的是不管它是否成功结束,都要杀死进程,但是给它一个机会先清理进程。
SIGKILL
(Unix 信号 9)是一个“残酷”的 Unix 信号,它会立即终止进程。无法处理或忽略 SIGKILL
,因此进程没有机会进行清理。SIGKILL
应该被 Unix/Linux 用户用作最后的手段,因为它可能导致错误和数据损坏。
在某些情况下,即使发送了 SIGKILL
,内核也可能无法终止进程。如果一个进程正在等待网络或磁盘 I/O,而内核无法阻止它,它就会成为僵尸进程。需要重新启动才能从系统中清除僵尸进程。
退出码 143 和 137 与 Docker 容器中的 SIGTERM
和 SIGKILL
一一对应:
SIGTERM
SIGKILL
在 Unix/Linux 中结束进程最常用的方法是使用 kill 命令,如下所示:kill [ID]
。默认情况下,kill 命令会向进程发送 SIGTERM
信号。
如需找到 [ID]
(进程 ID),请使用命令 ps -aux
,它会列出所有正在运行的进程。
在极端情况下,您可能需要立即使用 SIGKILL
终止进程。使用此命令发送 SIGKILL
:kill -9 [ID]
当您列出正在运行的进程时,您可能会发现在 CMD
列中显示 defunct
的进程。这些是没有正确终止的僵尸进程。僵尸进程的特征是:
僵尸进程会一直出现在进程表中,直到其父进程关闭或操作系统重新启动。在许多情况下,僵尸进程会在进程表中累积,因为多个子进程被父进程 fork 出来,但没有被成功杀死。为避免这种情况,请确保您的应用程序的 sigaction
事务忽略 SIGCHLD
信号。
如果您是 Kubernetes 用户,您可以通过终止 pod 向容器发送 SIGTERM
。每当 pod 终止时,默认情况下,Kubernetes 都会向 pod 中的容器发送 SIGTERM
信号。
由于扩容或部署操作,Pod 通常会自动终止。要手动终止 pod,您可以发送 kubectl delete
命令或 API 调用来终止 pod。
请注意,在默认为 30 秒的宽限期之后,Kubernetes 会发送 SIGKILL
以立即终止容器。
Kubernetes 管理容器集群,会在您的应用程序上执行许多自动化操作。例如,它可以对应用程序扩容或缩容、更新以及删除。因此,在很多情况下 Kubernetes 需要关闭一个 pod(带有一个或多个容器),即使它们运行正常。
在某些情况下,Kubernetes 会因为 Pod 出现故障,或者因为主机上的资源不足(称为驱逐)而关闭 Pod。每当 Kubernetes 出于任何原因需要终止 pod 时,它都会向 pod 中运行的容器发送 SIGTERM
。
Kubernetes 终止 pod 的完整过程如下:
Terminating
状态:然后 Kubernetes 将其从所有服务中删除,并停止接收新流量。此时,在 pod 上运行的容器并不会感知到这一变化。SIGTERM
信号(在下一步中发送),但如果由于任何原因无法执行,则可以使用 preStop hook,且无需更改应用程序的代码。SIGTERM
信号发送到 pod:Kubernetes 将 SIGTERM
发送到 pod 中的所有容器。理想情况下,您的应用程序应该处理 SIGTERM
信号并启动干净的关闭过程。请注意,即使处理了 preStop hook,您仍然需要测试并了解您的应用程序如何处理 SIGTERM
。对 preStop 和 SIGTERM
的冲突或重复反应可能导致生产问题。SIGTERM
后,Kubernetes 会等待 TerminationGracePeriod
,默认为 30 秒,以允许容器关闭。您可以在每个 pod 的 YAML 模板中自定义宽限期。注意:Kubernetes 不会等待 preStop hook 完成,它从发送 SIGTERM
信号的那一刻开始计算宽限期。如果容器在宽限期结束之前自行退出,Kubernetes 将停止等待并进入下一步。SIGKILL
信号:所有正在运行的容器进程在主机上立即终止,并且 kubelet 将清理所有相关的 Kubernetes 对象。为确保 pod 终止不会中断您的应用程序并影响最终用户,您应该处理 pod 的终止。
实际上,这意味着需要确保您的应用程序处理 SIGTERM
信号并在收到信号时执行有序的关闭过程。这应该包括完成事务、保存临时数据、关闭网络连接和清理不需要的数据。
请注意,与常规 Linux 系统不同,在 Kubernetes 中,在宽限期后,SIGTERM
后面跟着 SIGKILL
。所以你必须准备关闭容器,不能简单地忽略它。
处理优雅终止的另一个选项是 preStop hook,允许您在不更改应用程序代码的情况下执行关闭过程。如果您使用 preStop hook,请确保其执行的操作不会与应用程序在收到 SIGTERM
信号时执行的操作重复或冲突。通常最好处理 SIGTERM
或 preStop 其中之一,以避免冲突。
任何导致 pod 关闭的 Kubernetes 错误都会触发 SIGTERM 信号发送到 pod 内的容器:
kubectl describe pod
看到 Kubernetes 错误。SIGTERM
正常终止,则为 143,如果在宽限期后强制终止,则为 137。SIGTERM
和 SIGKILL
信号。一个例外是 OOMKilled
错误。这是由于容器或 pod 超出主机上分配给它们的内存而发生的 Kubernetes 错误。当容器或 Pod 因 OOMKilled
而终止时,Kubernetes 会立即发送 SIGKILL
信号,而不使用 SIGTERM
和宽限期。
在 Kubernetes 上运行应用程序时,您必须确保 ingress controllers 不会出现停机。否则,每当 controller 重新启动或重新部署时,用户都会遇到速度变慢或服务中断的情况。如果一个 ingress pod 被终止,可能会导致连接断开,在生产中必须避免这种情况。
如果你使用的是官方的 NGINX Ingress Controller,当 controller Pod 被终止时,Kubernetes 会像往常一样发送一个 SIGTERM
信号。
然而,NGINX controller 并没有按照 Kubernetes 期望的方式处理 SIGTERM
:
SIGTERM
时,它会立即关闭。基本上,NGINX 将 SIGTERM
视为 SIGKILL
。SIGQUIT
信号时,它会执行正常关闭。正如我们在上面的处理 SIGTERM
和 preStop 部分中所讨论的,Kubernetes 提供了第二个处理优雅终止的方案 - preStop hook。您可以在发送 SIGTERM
之前使用 preStop 挂钩向 NGINX 发送 SIGQUIT
信号。这避免了 NGINX 突然关闭,并使其有机会优雅地终止。
翻译自 《SIGSEGV: Segmentation fault in Linux containers (exit code 139)》
原文链接:https://komodor.com/learn/`SIGSEGV`-segmentation-faults-signal-11-exit-code-139/
SIGSEGV
,也称为分段违规或分段错误,是基于 Unix 的操作系统(如 Linux)使用的信号。它表示程序尝试在其分配的内存之外进行写入或读取,由于编程错误、软件或硬件兼容性问题或恶意攻击(例如缓冲区溢出)。
SIGSEGV
由以下代码表示:
SIGSEGV
是操作系统信号 11SIGSEGV
错误而终止时,它会抛出退出码 139SIGSEGV
的默认操作是进程异常终止。此外,还可能发生以下情况:
SIGSEGV
信号在日志中被记录地更加详细;SIGSEGV
是 Kubernetes 中容器终止的常见原因。但是,Kubernetes 不会直接触发 SIGSEGV
。要解决此问题,您需要调试有问题的容器或底层主机。
SIGSEGV
和 SIGABRT
是两个可以导致进程终止的 Unix 信号。
SIGSEGV
由操作系统触发,它检测到一个进程存在内存违规,可能因此终止它。
SIGABRT
(信号中止)是由进程本身触发的信号。它异常终止进程,关闭并刷新打开的流。一旦被触发,就不能被进程阻塞(类似于SIGKILL
,不同的是SIGKILL
是由操作系统触发的)。
在发送 SIGABRT
信号之前,进程可以:
libc
库中的 abort()
函数,解锁 SIGABRT
信号。然后进程可以通过触发 SIGABRT
自行中止assert()
宏,如果断言为假,则使用 SIGABRT
中止程序。退出码 139 和 134 与 Docker 容器中的 SIGSEGV
和 SIGABRT
并行:
SIGSEGV
SIGABRT
并被异常终止现代通用计算系统包括内存管理单元 (MMU)。 MMU 可以在 Linux 等操作系统中实现内存保护,防止不同进程访问或修改彼此的内存,除非通过严格控制的 API。这简化了故障排除并使进程更具弹性,因为它们被彼此隔离开来了。
当进程尝试使用 MMU 未分配给它的内存地址时,会发生 SIGSEGV
信号或分段错误。这可能由于三个常见原因而发生:
在基于 Unix 的操作系统上,默认情况下,SIGSEGV
信号将导致违规进程异常终止。
除了终止进程外,操作系统还可以生成 core 文件来辅助调试,也可以执行其他平台相关的操作。例如,在 Linux 上,您可以使用 grsecurity
实用程序详细记录 SIGSEGV
信号,以监控相关的安全风险,例如缓冲区溢出。
在 Linux 和 Windows 上,操作系统允许进程处理它们对分段错误的响应。例如,该程序可以收集堆栈跟踪信息,其中包含处理器寄存器值和分段错误中涉及的内存地址等信息。
segvcatch
就是一个例子,它是一个支持多个操作系统的 C++ 库,能够将分段错误和其他与硬件相关的异常转换为软件语言异常。这使得使用简单的 try/catch
代码处理“硬”错误成为可能,例如分段错误。这使得软件可以识别分段错误并在程序执行期间进行纠正。
在对分段错误进行故障排除或测试程序以避免这些错误时,可能需要故意引发分段违规以调查其影响。大多数操作系统都可以以这样一种方式处理 SIGSEGV
,即使发生分段错误,它们也允许程序运行,以便进行调查和记录。
SIGSEGV
故障与 Kubernetes 用户和管理员高度相关。容器由于分段违规而失败是很常见的。
但是,与 SIGTERM
和 SIGKILL
等其他信号不同,Kubernetes 不会直接触发 SIGSEGV
信号。相反,当容器被发现执行内存违规时,Kubernetes 节点上的主机可以触发 SIGSEGV
。然后容器终止,Kubernetes 检测到这一点,并可能根据 pod 配置尝试重新启动它。
当 Docker 容器被 SIGSEGV
信号终止时,它会抛出退出码 139。这可以表明:
要调试和解决容器上的 SIGSEGV
问题,请执行以下步骤:
SIGSEGV
错误在 kubelet 日志中如下所示:
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x1bdaed0]
docker pull [image-id]
为由 SIGSEGV
终止的容器拉取镜像。curl
或 vim
)。kubectl
执行到容器中。查看您是否可以复现 SIGSEGV
错误以确认导致问题的库。上述过程可以帮助您解决直接的 SIGSEGV
错误,但在许多情况下,故障排除可能会变得非常复杂,并且需要涉及多个组件的非线性调查。
翻译自 《SIGKILL : Fast termination of Linux containers (signal 9)》
原文链接:https://komodor.com/learn/what-is-sigkill-signal-9-fast-termination-of-linux-containers/
SIGKILL
是一种通信类型,称为信号,在 Unix 或类 Unix 的操作系统(如 Linux)中用于立即终止进程。 Linux 操作员以及 Kubernetes 等容器编排器在需要关闭基于 Unix 的操作系统上的容器或 Pod 时使用。
信号是发送到正在运行的程序的标准化消息,触发特定操作(例如终止或处理错误),是一种进程间通信(IPC)。当操作系统向目标进程发送信号时,它会等待原子指令完成,然后中断进程的执行,并对信号进行处理。
SIGKILL
指示进程立即终止,且不能被忽略或阻止。该进程被杀死时,其运行的线程也会被杀死。如果 SIGKILL
信号未能终止进程及其线程,则表明操作系统出现故障。
这是杀死进程的最有力的方式,但是也可能会产生意想不到的后果,因为并不确定该进程是否已完成其清理操作。SIGKILL
可能导致数据丢失或损坏,所以只有在没有其他选择的情况下才应该使用。在 Kubernetes 中,SIGKILL
信号发送前总是会先发送 SIGTERM
,让容器有机会优雅地退出。
在 Linux 和其他类 Unix 操作系统中,有几种操作系统信号可用于终止进程。
最常见的类型是:
SIGKILL
(也称为 Unix 信号 15):突然终止进程,产生致命错误。它在终止进程时总是有效的,但可能会产生意想不到的后果。SIGTERM
(也称为 Unix 信号 9):尝试终止进程,但可以通过各种方式阻止或处理。这是杀死进程的更温和的方法。如果您是 Unix/Linux 用户,以下过程展示如何直接终止进程:
ps -aux
显示属于所有用户和系统守护程序的所有正在运行的进程的详细列表。kill [ID]
命令尝试使用 SIGTERM
信号杀死进程kill -9 [ID]
命令立即使用 SIGKILL
信号终止进程SIGKILL
立即终止正在运行的进程。对于简单的程序,这可能是安全的,但大多数程序都很复杂并且由多个程序组成,即使是看似微不足道的程序,也需要在退出前执行清理操作。
如果程序在收到 SIGKILL
信号时尚未完成清理,则数据可能会丢失或损坏。所以,您应该仅在以下情况下使用 SIGKILL:
如果您是 Kubernetes 用户,您可以通过使用 kubectl delete
命令终止 pod 来向容器发送 SIGKILL
。
Kubernetes 会首先向 pod 中的容器发送 SIGTERM
信号。默认情况下,Kubernetes 给容器 30 秒的宽限期,然后发送 SIGKILL
立即终止它们。
Kubernetes 在部署过程中执行 scale-down 事件或更新 pod 时,会分三个阶段终止容器:
SIGTERM
信号。您可以处理此信号以优雅地终止容器上运行的应用程序,并执行自定义的清理任务。SIGKILL
信号,这会导致容器立即关闭。重要的是要认识到,虽然可以在容器的日志中捕获 SIGTERM
,但不能捕获 SIGKILL
命令,因为它会立即终止容器。
任何导致 pod 关闭的 Kubernetes 错误都会导致 SIGTERM
信号发送到 pod 内的容器,然后是 SIGKILL
。
在 OOMKilled
错误的情况下,容器或 pod 因为超出主机上分配的内存而被杀死,kubelet 会立即向容器发送 SIGKILL
信号。
要对被 Kubernetes 终止的容器进行故障排除:
kubectl describe pod
看到 Kubernetes 错误;SIGTERM
正常终止,您将看到退出代码 143,如果使用 SIGKILL
强制终止,您将看到退出码 137;SIGTERM
和 SIGKILL
信号。就像 Kubernetes 可以发送 SIGTERM
或 SIGKILL
信号来关闭常规容器一样,也将这些信号发送到 Nginx Ingress Controller pod。然而,NGINX 处理信号却有所不同:
SIGTERM
、SIGINT
时:NGINX 执行快速关闭。主进程指示工作进程退出,仅等待 1 秒,然后向其发送 SIGKILL
信号。QUIT
:NGINX 执行正常关闭。关闭监听端口以避免接收更多请求,关闭空闲连接,只有在所有工作进程退出后才退出。因此,在某种意义上,NGINX 将 SIGTERM
和 SIGINT
视为 SIGKILL
。如果控制器在收到信号时正在处理请求,它将断开连接,将导致 HTTP 服务器错误。为了防止这种情况,您应该使用 QUIT
命令关闭 NGINX Ingress Controllers。
在标准的 nginx-ingress-controller
镜像(版本 0.24.1)中,有一个命令可以向 NGINX 发送适当的终止信号。运行此脚本,通过向其发送 QUIT
信号来优雅地关闭 NGINX:
/usr/local/openresty/nginx/sbin/nginx -c /etc/nginx/nginx.conf -s quit
while pgrep -x nginx; do
sleep 1
done
SIGKILL
完全由操作系统(内核)处理。当为特定进程发送 SIGKILL
时,内核调度程序立即停止为进程提供 CPU 时间来执行用户空间代码。当调度器做出这个决定时,如果进程有线程在不同的 CPU 或内核上执行代码,那么这些线程也会停止。
当传递 SIGKILL
信号时,如果进程或线程正在执行系统调用或 I/O 操作,内核会将进程切换到“dying”状态。内核调度 CPU 时间以允许垂死的进程解决其剩余的问题。
不可中断的操作将一直运行直到它们完成(但在运行更多用户空间代码之前检查“dying”状态)。可中断操作,当它们识别出进程“dying”时,会提前终止。当所有操作完成后,该进程被赋予“dead”状态。
当内核操作完成时,内核开始清理进程,就像程序正常退出时一样。给进程一个高于 128 的结果码,表明它被信号杀死了。被 SIGKILL
杀死的进程没有机会处理收到的 SIGKILL
消息。
在这个阶段,进程转换为“僵尸”状态,并使用 SIGCHLD
信号通知父进程。僵尸状态表示进程已被杀死,但是父进程可以使用 wait(2)
系统调用读取死进程的退出码。僵尸进程消耗的唯一资源是进程表中的一个槽,它存储了进程 ID、退出和其他启用故障排除的“关键统计信息”。
如果僵尸进程在几分钟内保持活动状态,这可能表明其父进程的工作流存在问题。
]]>翻译自 《Exit Codes in Containers and Kubernetes – The Complete Guide》
原文链接:https://komodor.com/learn/exit-codes-in-containers-and-kubernetes-the-complete-guide/
当容器终止时,容器引擎使用退出码来报告容器终止的原因。如果您是 Kubernetes 用户,容器故障是 pod 异常最常见的原因之一,了解容器退出码可以帮助您在排查时找到 pod 故障的根本原因。
以下是容器使用的最常见的退出码:
退出码 | 名称 | 含义 |
---|---|---|
0 | 正常退出 | 开发者用来表明容器是正常退出 |
1 | 应用错误 | 容器因应用程序错误或镜像规范中的错误引用而停止 |
125 | 容器未能运行 | docker run 命令没有执行成功 |
126 | 命令调用错误 | 无法调用镜像中指定的命令 |
127 | 找不到文件或目录 | 找不到镜像中指定的文件或目录 |
128 | 退出时使用的参数无效 | 退出是用无效的退出码触发的(有效代码是 0-255 之间的整数) |
134 | 异常终止 (SIGABRT) | 容器使用 abort() 函数自行中止 |
137 | 立即终止 (SIGKILL) | 容器被操作系统通过 SIGKILL 信号终止 |
139 | 分段错误 (SIGSEGV) | 容器试图访问未分配给它的内存并被终止 |
143 | 优雅终止 (SIGTERM) | 容器收到即将终止的警告,然后终止 |
255 | 退出状态超出范围 | 容器退出,返回可接受范围之外的退出代码,表示错误原因未知 |
下面我们将解释如何在宿主机和 Kubernetes 中对失败的容器进行故障排除,并提供有关上面列出的所有退出代码的更多详细信息。
为了更好地理解容器故障的原因,让我们先讨论容器的生命周期。以 Docker 为例 —— 在任何给定时间,Docker 容器都会处于以下几种状态之一:
docker create
后但实际运行容器之前的状态)docker start
或 docker run
时会发生这种情况,使用 docker start
或 docker run
可能会发生这种情况。docker pause
命令时会发生这种情况当一个容器达到 Exited 状态时,Docker 会在日志中报告一个退出码,告诉你容器发生了什么导致它退出。
下面我们将更详细地介绍每个退出码。
退出代码 0 由开发人员在任务完成后故意停止容器时触发。从技术上讲,退出代码 0 意味着前台进程未附加到特定容器。
退出代码 1 表示容器由于以下原因之一停止:
退出码 125 表示该命令用于运行容器。例如 docker run
在 shell 中被调用但没有成功执行。以下是可能发生这种情况的常见原因:
docker run --abcd
;docker start
而不是 docker run
;退出码 126 表示无法调用容器镜像中使用的命令。这通常是用于运行容器的持续集成脚本中缺少依赖项或错误的原因。
退出码 127 表示容器中指定的命令引用了不存在的文件或目录。
与退出码 126 相同,识别失败的命令,并确保容器镜像中引用的文件名或文件路径真实有效。
退出码 128 表示容器内的代码触发了退出命令,但没有提供有效的退出码。 Linux exit
命令只允许 0-255 之间的整数,因此如果进程以退出码 3.5 退出,则日志将报告退出代码 128。
exit
命令,并更正它以提供有效的退出代码。退出码 134 表示容器自身异常终止,关闭进程并刷新打开的流。此操作是不可逆的,类似 SIGKILL
(请参阅下面的退出码 137)。进程可以通过执行以下操作之一来触发 SIGABRT
:
libc
库中的 abort()
函数;assert()
宏,用于调试。如果断言为假,则该过程中止。SIGABRT
信号;退出码 137 表示容器已收到来自主机操作系统的 SIGKILL
信号。该信号指示进程立即终止,没有宽限期。可能的原因是:
docker kill
命令时;kill -9
命令触发;docker inspect
命令将指示 OOMKilled
错误。SIGKILL
之前是否之前收到过 SIGTERM
信号(优雅终止);SIGTERM
信号,请检查您的容器进程是否处理 SIGTERM
并能够正常终止;SIGTERM
并且容器报告了 OOMKilled
错误,则排查主机上的内存问题。退出码 139 表示容器收到了来自操作系统的 SIGSEGV
信号。这表示分段错误 —— 内存违规,由容器试图访问它无权访问的内存位置引起。SIGSEGV
错误有三个常见原因:
SIGSEGV
。在 Linux 和 Windows 上,您都可以处理容器对分段错误的响应。例如,容器可以收集和报告堆栈跟踪;SIGSEGV
进行进一步的故障排除,您可能需要将操作系统设置为即使在发生分段错误后也允许程序运行,以便进行调查和调试。然后,尝试故意造成分段错误并调试导致问题的库;退出码 143 表示容器收到来自操作系统的 SIGTERM
信号,该信号要求容器正常终止,并且容器成功正常终止(否则您将看到退出码 137)。该退出码可能的原因是:
docker stop
或 docker-compose down
命令时;检查主机日志,查看操作系统发送 SIGTERM
信号的上下文。如果您使用的是 Kubernetes,请检查 kubelet 日志,查看 pod 是否以及何时关闭。
一般来说,退出码 143 不需要故障排除。这意味着容器在主机指示后正确关闭。
当您看到退出码 255 时,意味着容器的 entrypoint 以该状态停止。这意味着容器停止了,但不知道是什么原因。
本文基于对 Kubernetes v1.23.1 的源码阅读
Kubernetes 提供了一种 Pod 优雅退出机制,使 Pod 在退出前可以完成一些清理工作。但若执行清理工作时出错了,Pod 能正常退出吗?多久能退出?退出时间可以指定吗?系统有默认参数吗?这其中有若干细节值得我们去注意,本文就从这些细节出发,梳理清楚每种情况下 Kubernetes 的组件的各项行为及其参数设定。
Pod 正常退出是指非被驱逐时退出,包括人为删除、执行出错被删除等。在 Pod 退出时,kubelet 删除容器之前,会先执行 pod 的 preStop
,允许 pod 在退出前执行一段脚本用以清除必要的资源等。然而 preStop
也有执行失败或者直接 hang 住的情况,这个时候 preStop
并不会阻止 pod 的退出,kubelet 也不会重复执行,而是会等一段时间,超过这个时间会直接删除容器,保证整个系统的稳定。
整个过程在函数 killContainer
中,我们在 pod 优雅退出时,需要明确的是,kubelet 的等待时间由那几个因素决定,用户可以设置的字段和系统组件的参数是如何共同作用的。
kubelet 计算 gracePeriod 的过程为:
DeletionGracePeriodSeconds
不为 nil,表示是 ApiServer 删除的,gracePeriod 直接取值;Spec.TerminationGracePeriodSeconds
不为 nil,再看 pod 删除的原因是什么;
startupProbe
失败,gracePeriod 取值为 startupProbe
中设置的 TerminationGracePeriodSeconds
livenessProbe
失败,gracePeriod 取值为 livenessProbe
中设置的 TerminationGracePeriodSeconds
获得到 gracePeriod 之后,kubelet 执行 pod 的 preStop
,函数 executePreStopHook
中会起一个 goroutine ,并计算其执行的时间,gracePeriod 再减去该时间,就是最终传给 runtime 的删除容器的 timeout 时间。所以,若我们设置了 pod preStop,需要同时考虑到 preStop 的执行时间以及容器退出的时间,可以给 TerminationGracePeriodSeconds 设置一个大于 preStop + 容器退出的时间。
func (m *kubeGenericRuntimeManager) killContainer(pod *v1.Pod, containerID kubecontainer.ContainerID, containerName string, message string, reason containerKillReason, gracePeriodOverride *int64) error {
...
// From this point, pod and container must be non-nil.
gracePeriod := int64(minimumGracePeriodInSeconds)
switch {
case pod.DeletionGracePeriodSeconds != nil:
gracePeriod = *pod.DeletionGracePeriodSeconds
case pod.Spec.TerminationGracePeriodSeconds != nil:
gracePeriod = *pod.Spec.TerminationGracePeriodSeconds
switch reason {
case reasonStartupProbe:
if containerSpec.StartupProbe != nil && containerSpec.StartupProbe.TerminationGracePeriodSeconds != nil {
gracePeriod = *containerSpec.StartupProbe.TerminationGracePeriodSeconds
}
case reasonLivenessProbe:
if containerSpec.LivenessProbe != nil && containerSpec.LivenessProbe.TerminationGracePeriodSeconds != nil {
gracePeriod = *containerSpec.LivenessProbe.TerminationGracePeriodSeconds
}
}
}
// Run internal pre-stop lifecycle hook
if err := m.internalLifecycle.PreStopContainer(containerID.ID); err != nil {
return err
}
// Run the pre-stop lifecycle hooks if applicable and if there is enough time to run it
if containerSpec.Lifecycle != nil && containerSpec.Lifecycle.PreStop != nil && gracePeriod > 0 {
gracePeriod = gracePeriod - m.executePreStopHook(pod, containerID, containerSpec, gracePeriod)
}
// always give containers a minimal shutdown window to avoid unnecessary SIGKILLs
if gracePeriod < minimumGracePeriodInSeconds {
gracePeriod = minimumGracePeriodInSeconds
}
if gracePeriodOverride != nil {
gracePeriod = *gracePeriodOverride
}
err := m.runtimeService.StopContainer(containerID.ID, gracePeriod)
...
return nil
}
在上面分析的过程中,kubelet 调用 runtime 接口之前,会再判断一步 gracePeriodOverride
,若传进来的值不为空,直接用该值覆盖前面的 gracePeriod
。
kubelet 计算 gracePeriodOverride
的主要过程如下:
DeletionGracePeriodSeconds
;func calculateEffectiveGracePeriod(status *podSyncStatus, pod *v1.Pod, options *KillPodOptions) (int64, bool) {
gracePeriod := status.gracePeriod
// this value is bedrock truth - the apiserver owns telling us this value calculated by apiserver
if override := pod.DeletionGracePeriodSeconds; override != nil {
if gracePeriod == 0 || *override < gracePeriod {
gracePeriod = *override
}
}
// we allow other parts of the kubelet (namely eviction) to request this pod be terminated faster
if options != nil {
if override := options.PodTerminationGracePeriodSecondsOverride; override != nil {
if gracePeriod == 0 || *override < gracePeriod {
gracePeriod = *override
}
}
}
// make a best effort to default this value to the pod's desired intent, in the event
// the kubelet provided no requested value (graceful termination?)
if gracePeriod == 0 && pod.Spec.TerminationGracePeriodSeconds != nil {
gracePeriod = *pod.Spec.TerminationGracePeriodSeconds
}
// no matter what, we always supply a grace period of 1
if gracePeriod < 1 {
gracePeriod = 1
}
return gracePeriod, status.gracePeriod != 0 && status.gracePeriod != gracePeriod
}
在上面分析 kubelet 处理 pod 的退出时间时,我们会发现 kubelet 会首先用 pod 的 DeletionGracePeriodSeconds
,而该值正是 ApiServer 删除 pod 时写入的。本节我们来分析 ApiServer 删除 pod 时的行为。
ApiServer 中计算 pod 的 GracePeriodSeconds 过程为:
options.GracePeriodSeconds
不为空,则设置为该值;否则设置为 spec 中用户指定的 Spec.TerminationGracePeriodSeconds
(默认为 30s);其中,options.GracePeriodSeconds
为 kubectl 删除 pod 时,可以指定的参数 --grace-period
;或者程序里调用 ApiServer 接口时指定的参数,如 client-go 中的 DeleteOptions.GracePeriodSeconds
。
func (podStrategy) CheckGracefulDelete(ctx context.Context, obj runtime.Object, options *metav1.DeleteOptions) bool {
if options == nil {
return false
}
pod := obj.(*api.Pod)
period := int64(0)
// user has specified a value
if options.GracePeriodSeconds != nil {
period = *options.GracePeriodSeconds
} else {
// use the default value if set, or deletes the pod immediately (0)
if pod.Spec.TerminationGracePeriodSeconds != nil {
period = *pod.Spec.TerminationGracePeriodSeconds
}
}
// if the pod is not scheduled, delete immediately
if len(pod.Spec.NodeName) == 0 {
period = 0
}
// if the pod is already terminated, delete immediately
if pod.Status.Phase == api.PodFailed || pod.Status.Phase == api.PodSucceeded {
period = 0
}
if period < 0 {
period = 1
}
// ensure the options and the pod are in sync
options.GracePeriodSeconds = &period
return true
}
另外,在 kubelet 驱逐 pod 时,pod 的优雅退出时间是被覆盖的。
func (m *managerImpl) synchronize(diskInfoProvider DiskInfoProvider, podFunc ActivePodsFunc) []*v1.Pod {
...
// we kill at most a single pod during each eviction interval
for i := range activePods {
pod := activePods[i]
gracePeriodOverride := int64(0)
if !isHardEvictionThreshold(thresholdToReclaim) {
gracePeriodOverride = m.config.MaxPodGracePeriodSeconds
}
message, annotations := evictionMessage(resourceToReclaim, pod, statsFunc)
if m.evictPod(pod, gracePeriodOverride, message, annotations) {
metrics.Evictions.WithLabelValues(string(thresholdToReclaim.Signal)).Inc()
return []*v1.Pod{pod}
}
}
...
}
其 override 值为 EvictionMaxPodGracePeriod
,且只有软驱逐时有效,该值为 kubelet 的驱逐相关的配置参数:
// Map of signal names to quantities that defines hard eviction thresholds. For example: {"memory.available": "300Mi"}.
EvictionHard map[string]string
// Map of signal names to quantities that defines soft eviction thresholds. For example: {"memory.available": "300Mi"}.
EvictionSoft map[string]string
// Map of signal names to quantities that defines grace periods for each soft eviction signal. For example: {"memory.available": "30s"}.
EvictionSoftGracePeriod map[string]string
// Duration for which the kubelet has to wait before transitioning out of an eviction pressure condition.
EvictionPressureTransitionPeriod metav1.Duration
// Maximum allowed grace period (in seconds) to use when terminating pods in response to a soft eviction threshold being met.
EvictionMaxPodGracePeriod int32
kubelet 驱逐 pod 的函数是启动时注入的,函数如下:
func killPodNow(podWorkers PodWorkers, recorder record.EventRecorder) eviction.KillPodFunc {
return func(pod *v1.Pod, isEvicted bool, gracePeriodOverride *int64, statusFn func(*v1.PodStatus)) error {
// determine the grace period to use when killing the pod
gracePeriod := int64(0)
if gracePeriodOverride != nil {
gracePeriod = *gracePeriodOverride
} else if pod.Spec.TerminationGracePeriodSeconds != nil {
gracePeriod = *pod.Spec.TerminationGracePeriodSeconds
}
// we timeout and return an error if we don't get a callback within a reasonable time.
// the default timeout is relative to the grace period (we settle on 10s to wait for kubelet->runtime traffic to complete in sigkill)
timeout := int64(gracePeriod + (gracePeriod / 2))
minTimeout := int64(10)
if timeout < minTimeout {
timeout = minTimeout
}
timeoutDuration := time.Duration(timeout) * time.Second
// open a channel we block against until we get a result
ch := make(chan struct{}, 1)
podWorkers.UpdatePod(UpdatePodOptions{
Pod: pod,
UpdateType: kubetypes.SyncPodKill,
KillPodOptions: &KillPodOptions{
CompletedCh: ch,
Evict: isEvicted,
PodStatusFunc: statusFn,
PodTerminationGracePeriodSecondsOverride: gracePeriodOverride,
},
})
// wait for either a response, or a timeout
select {
case <-ch:
return nil
case <-time.After(timeoutDuration):
recorder.Eventf(pod, v1.EventTypeWarning, events.ExceededGracePeriod, "Container runtime did not kill the pod within specified grace period.")
return fmt.Errorf("timeout waiting to kill pod")
}
}
}
killPodNow
函数是 kubelet 在驱逐 pod 时所调用的函数,gracePeriodOverride
为软驱逐时设置的参数,当其没有设置时,gracePeriod
依然取值 pod.Spec.TerminationGracePeriodSeconds
。然后该函数会调用 podWorkers.UpdatePod
,传入相应参数,并且设置一个跟 gracePeriod
相关的超时时间,等待其返回。
Pod 的优雅退出是由 preStop 实现的,本文就 Pod 正常退出和被驱逐时,Pod 的退出时间受哪些因素影响,各参数之间是如何相互作用的做了简要的分析。了解了这些细节后,我们对 Pod 的退出流程就有了一个更加全面的认知。
]]>Fluid 的整个架构主要分为两个部分。
一是 Controller,包括 RuntimeController 及 DatasetController,分别管理 Runtime 和 Dataset 的生命周期,二者共同作用,以 helm chart 为基础,快速搭建出一套完整的分布式缓存系统,通常是 master + worker + fuse 的形式,向上提供服务。
master
是缓存系统的核心组件,通常是一个 pod;
worker
是组成缓存集群的组件,可以是多个 pod,可以设置个数;
fuse
是提供 POSIX 接口服务的组件;
二是调度器,在有缓存的情况下,调度器会根据 worker
的节点信息,使得上层应用 pod 尽可能调度到有缓存的节点。
Fluid 有两个最主要的概念:Runtime 和 Dataset。
Runtime 指的是提供分布式缓存的系统,目前 Fluid 支持的 Runtime 类型有 JuiceFS、Alluxio、JindoFS,其中 Alluxio、JindoFS 都是典型的分布式缓存引擎;JuiceFS 是一款分布式文件系统,具备分布式缓存能力。本文会以 JuiceFS 为例,介绍 Fluid 的核心功能。
Dataset 是指数据集,是逻辑上相关的一组数据的集合,会被运算引擎使用,比如大数据的 Spark,AI 场景的 TensorFlow。对应到 JuiceFS 中,Dataset 也可以理解为一个 JuiceFS 的 Volume。
整个过程如下,Dataset Controller 监听 Dataset,Runtime Controller 监听对应的 Runtime,当二者一致时,RuntimeController 会启动 Engine,Engine 创建出对应的 Chart,里面包含 Master、Worker、FUSE 组件。同时,Runtime Controller 会定期同步数据(如总数据量、当前使用数据量等)状态更新 Dataset 和 Runtime 的状态信息。
下面以 JuiceFS 为例,搭建一套 Fluid 环境,搭建好后组件如下:
$ kubectl -n fluid-system get po
NAME READY STATUS RESTARTS AGE
csi-nodeplugin-fluid-fczdj 2/2 Running 0 116s
csi-nodeplugin-fluid-g6gm8 2/2 Running 0 117s
csi-nodeplugin-fluid-twr4m 2/2 Running 0 116s
dataset-controller-5bc4bcb77d-844rz 1/1 Running 0 116s
fluid-webhook-7b4f48f647-s8c9w 1/1 Running 0 116s
juicefsruntime-controller-5d95878575-hj785 1/1 Running 0 116s
各个组件的作用:
dataset-controller
:管理 Dataset 的生命周期
juicefsruntime-controller
:管理 JuiceFSRuntime 生命周期,并快速搭建 JuiceFS 环境;
fluid-webhook
:实现 Fluid 应用的缓存调度工作;
csi-nodeplugin
:实现各引擎的挂载路径与应用之间的连接工作;
然后创建 Runtime 和 Dataset。具体操作可以参考官方文档:https://github.com/fluid-cloudnative/fluid/blob/master/docs/zh/samples/juicefs_runtime.md
$ kubectl get po
NAME READY STATUS RESTARTS AGE
jfsdemo-worker-0 1/1 Running 0 58m
jfsdemo-worker-1 1/1 Running 0 58m
JuiceFSRuntime 和 Dataset 创建好后,Runtime Controller 会根据其提供的参数创建 JuiceFS 的环境。JuiceFS 相对其他 Runtime 的特殊之处在于其没有 master 组件(因为其分布式缓存实现方式的特殊性),所以这里只看到启动了 worker 组件,构成了一个独立缓存集群(目前只有云服务版支持)。
Runtime Controller 启动 JuiceFS 的环境的方法是启动一个 helm chart,其渲染的 values.yaml 以 ConfigMap 的形式保存在集群中。查看 chart 如下:
$ helm list
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
fluid default 1 2022-05-06 22:44:51.999227 +0800 +0800 deployed fluid-0.8.0 0.8.0-6a5acd3
jfsdemo-ee default 1 2022-05-12 19:18:00.069524643 +0800 +0800 deployed juicefs-0.2.0 v0.17.2
这里需要注意的是,从 v0.7.0 版本开始,Fluid 采用了 FUSE 客户端懒启动的方式,在有应用工作的时候,才启动 FUSE pod,以免造成不必要的资源浪费。
启动一个使用 Fluid Dataset 的应用:
$ kubectl get po
NAME READY STATUS RESTARTS AGE
demo-app 1/1 Running 0 25s
jfsdemo-fuse-pd9zq 1/1 Running 0 25s
jfsdemo-worker-0 1/1 Running 0 60m
jfsdemo-worker-1 1/1 Running 0 60m
可以看到,应用启动后,FUSE 组件也启动成功。
Fluid 另一个重要的功能是数据预加速。为了保证应用在访问数据时的性能,可以通过数据预加载提前将远程存储系统中的数据拉取到靠近计算结点的分布式缓存引擎中,使得消费该数据集的应用能够在首次运行时即可享受到缓存带来的加速效果。
在 Fluid 中,数据加速对应的是 DataLoad,也是一个 CRD,DatasetController 负责监听该资源,根据对应的 DataSet 启动 Job,执行数据预热操作。同时 Runtime Controller 向 Worker 同步缓存的数据信息,并更新 Dataset 的状态。
仍然以 JuiceFS DataLoad 为例:
apiVersion: data.fluid.io/v1alpha1
kind: DataLoad
metadata:
name: jfs-load
spec:
dataset:
name: jfsdemo
namespace: default
target:
- path: /dir1
- path: /dir2
比如原本文件系统中有两个子目录 /dir1
和 /dir2
,target 中指定表示两个目录的数据都预热到缓存集群中。预热启动后:
$ kubectl get po
NAME READY STATUS RESTARTS AGE
jfs-load-ee-loader-job-mdjp2 0/1 Completed 0 15s
jfsdemo-ee-worker-0 1/1 Running 0 103m
jfsdemo-ee-worker-1 1/1 Running 0 103m
DatesetController 启动 dataload job 的方式也是启动一个 chart,其中包括一个 job 和 configMap:
$ helm list
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
fluid default 1 2022-05-06 22:44:51.999227 +0800 +0800 deployed fluid-0.8.0 0.8.0-6a5acd3
jfs-load-ee-loader default 1 2022-05-12 21:00:57.738708755 +0800 +0800 deployed fluid-dataloader-0.1.0 0.1.0
jfsdemo-ee default 1 2022-05-12 19:18:00.069524643 +0800 +0800 deployed juicefs-0.2.0 v0.17.2
当名为 loader-job
的 pod 状态为 Completed
时,表示预热过程已经完成。此时 DataLoad 的状态也变成了 Complete
:
$ kubectl get dataload
NAME DATASET PHASE AGE DURATION
jfs-load-ee jfsdemo-ee Complete 34s 18s
而 Dataset 的 CACHED
和 CACHED PERCENTAGE
数值也会更新。CACHED
表示已缓存的数量,CACHED PERCENTAGE
表示缓存数据的比例,即 CACHED
/UFS TOTAL SIZE
。
$ kubectl get dataset
NAME UFS TOTAL SIZE CACHED CACHE CAPACITY CACHED PERCENTAGE PHASE AGE
jfsdemo-ee 7.00GiB 4.70GiB 80.00GiB 67.2% Bound 84m
本文以 JuiceFS Runtime 为例,介绍了 Fluid 的两大核心功能,数据编排和数据加速。Fluid 适配了多种分布式缓存引擎,以统一的数据编排方式为上层数据密集型应用提供服务,使其不再拘泥于底层缓存引擎的部署和诸多参数细节,能够专注于自身的数据计算。
]]>controller-runtime 是 Kubernetes 社区提供的相对较好用的能够快速搭建一套对 ApiServer 进行 watch 的工具。本文会对 controller-runtime 的工作原理及不同场景下的使用方法做一个简单的总结和介绍。
controller-runtime 的架构可以用下图概括。注:Webhook 不在本文讨论范围内,故图中舍去了 Webhook。
主要分为用户创建的 Manager 和 Reconciler 以及 Controller Runtime 自己启动的 Cache 和 Controller。先看用户侧的,Manager 是用户初始化的时候需要创建的,用来启动 Controller Runtime 的组件;Reconciler 是用户自己需要提供的组件,用于处理自己的业务逻辑。
而 controller-runtime 侧的组件,Cache 顾名思义就是缓存,用于建立 Informer 对 ApiServer 进行连接 watch 资源,并将 watch 到的 object 推入队列;Controller 一方面会向 Informer 注册 eventHandler,另一方面会从队列中拿数据并执行用户侧 Reconciler 的函数。
controller-runtime 侧整个工作流程如下:
首先 Controller 会先向 Informer 注册特定资源的 eventHandler;然后 Cache 会启动 Informer,Informer 向 ApiServer 发出请求,建立连接;当 Informer 检测到有资源变动后,使用 Controller 注册进来的 eventHandler 判断是否推入队列中;当队列中有元素被推入时,Controller 会将元素取出,并执行用户侧的 Reconciler。
下面介绍几种不同场景下的使用方法。
controller-runtime 的用法我们已经很熟悉了,最简单的用法可以用下面的代码表达:
func start() {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// 1. init Manager
mgr, _ := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Port: 9443,
})
// 2. init Reconciler(Controller)
_ = ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}).
Complete(&ApplicationReconciler{})
// 3. start Manager
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
}
}
type ApplicationReconciler struct {
}
func (a ApplicationReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
return reconcile.Result{}, nil
}
第一步即初始化 Manager,同时生成一个默认配置的 Cache。
第二步是初始化 Controller。
ctrl.NewControllerManagedBy
:用于创建 Controller,同时将第一步生成的 Manager 的一些配置注入到 Controller 中;For
:Controller Runtime 提供的快捷方法,用来指定 watch 的资源类型;Owns
:有时候也会用到 Owns
方法,表示某资源是我关心资源的从属,其 event 也会进去 Controller 的队列中;Complete
也是一种快捷方法,用于生成 Controller,将用户的 Reconciler 注册进 Controller,并生成 watch 资源的默认 eventHandler,同时执行 Controller 的 watch
函数;用户的 Reconciler 只需要实现 reconcile.Reconciler
接口即可。
最后一步就是启动 Manager,这一步中会同时启动 Cache,即启动 Informer,以及启动 Controller。
在整个架构中,Informer 扮演的角色是对 ApiServer 进行 ListWatch,检测到自己感兴趣的资源变化时,会根据注册的 eventHandler 进行处理,并判断是否需要推入队列。
所以,在使用过程中,我们可以在创建 Controller 时,将 Informer 的 eventHandler 函数注册进去,如下:
func start() {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// 1. init Manager
mgr, _ := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Port: 9443,
})
// 2. init Reconciler(Controller)
c, _ := controller.New("app", mgr, controller.Options{Reconciler: &ApplicationReconciler{}})
_ = c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForObject{}, predicate.Funcs{
CreateFunc: func(event event.CreateEvent) bool {
...
},
UpdateFunc: func(updateEvent event.UpdateEvent) bool {
...
},
DeleteFunc: func(deleteEvent event.DeleteEvent) bool {
...
},
})
// 3. start Manager
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
}
}
在 predicate 中添加资源入 Queue 前的判断逻辑,可以有效防止队列被推入过多无用的资源。若我们 Reconciler 需要检测多种资源,这里 Controller 可以针对不同的资源类型,分别执行 watch,每次注册不同的 eventHandler。
另外,我们还可以在 Informer 的 ListWatch 函数中添加有效的 LabelSelector 或 FieldSelector,进一步减少检测到的无效资源,在集群资源量大的情况下,也可以起到减少 ApiServer 压力的作用。具体如下:
func start() {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// 1. init Manager
mgr, _ := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Port: 9443,
NewCache: cache.BuilderWithOptions(cache.Options{
Scheme: scheme,
SelectorsByObject: cache.SelectorsByObject{
&corev1.Pod{}: {
Label: labels.SelectorFromSet(labels.Set{}),
},
&corev1.Node{}: {
Field: fields.SelectorFromSet(fields.Set{"metadata.name": "node01"}),
},
},
}),
})
// 2. init Reconciler(Controller)
c, _ := controller.New("app", mgr, controller.Options{Reconciler: &ApplicationReconciler{}})
_ = c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForObject{}, predicate.Funcs{
CreateFunc: func(event event.CreateEvent) bool {
...
},
UpdateFunc: func(updateEvent event.UpdateEvent) bool {
...
},
DeleteFunc: func(deleteEvent event.DeleteEvent) bool {
...
},
})
// 3. start Manager
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
}
}
这里需要注意的是,controller-runtime 在 v0.11.0 版本中才开放设置 cache selector。
方法是在初始化 Manager 时,使用 cache.BuilderWithOptions
函数,将 LabelSelector 或 FieldSelector 注册进去,同时需要将 scheme 注册进去,以便 cache 生成的 Informer 对 ApiServer 发出请求时,同时给出资源 scheme。
这里可以看下源码,Cache 会生成 3 种 Informer,分别为 structured
unstructured
及 metadata
。启动时也会同时启动这 3 种 Informer。如下:
func NewInformersMap(config *rest.Config,
scheme *runtime.Scheme,
mapper meta.RESTMapper,
resync time.Duration,
namespace string,
selectors SelectorsByGVK,
disableDeepCopy DisableDeepCopyByGVK,
) *InformersMap {
return &InformersMap{
structured: newStructuredInformersMap(config, scheme, mapper, resync, namespace, selectors, disableDeepCopy),
unstructured: newUnstructuredInformersMap(config, scheme, mapper, resync, namespace, selectors, disableDeepCopy),
metadata: newMetadataInformersMap(config, scheme, mapper, resync, namespace, selectors, disableDeepCopy),
Scheme: scheme,
}
}
// Start calls Run on each of the informers and sets started to true. Blocks on the context.
func (m *InformersMap) Start(ctx context.Context) error {
go m.structured.Start(ctx)
go m.unstructured.Start(ctx)
go m.metadata.Start(ctx)
<-ctx.Done()
return nil
}
其中,structured
为确定类型的资源,需要在 scheme 中注册对应的资源类型;unstructured
是不确定类型的资源;metadata
则是采用 protobuf
形式请求 ApiServer。
以 structured
为例:
func createStructuredListWatch(gvk schema.GroupVersionKind, ip *specificInformersMap) (*cache.ListWatch, error) {
// Kubernetes APIs work against Resources, not GroupVersionKinds. Map the
// groupVersionKind to the Resource API we will use.
mapping, err := ip.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil, err
}
client, err := apiutil.RESTClientForGVK(gvk, false, ip.config, ip.codecs)
if err != nil {
return nil, err
}
listGVK := gvk.GroupVersion().WithKind(gvk.Kind + "List")
listObj, err := ip.Scheme.New(listGVK)
if err != nil {
return nil, err
}
// TODO: the functions that make use of this ListWatch should be adapted to
// pass in their own contexts instead of relying on this fixed one here.
ctx := context.TODO()
// Create a new ListWatch for the obj
return &cache.ListWatch{
ListFunc: func(opts metav1.ListOptions) (runtime.Object, error) {
ip.selectors(gvk).ApplyToList(&opts)
res := listObj.DeepCopyObject()
namespace := restrictNamespaceBySelector(ip.namespace, ip.selectors(gvk))
isNamespaceScoped := namespace != "" && mapping.Scope.Name() != meta.RESTScopeNameRoot
err := client.Get().NamespaceIfScoped(namespace, isNamespaceScoped).Resource(mapping.Resource.Resource).VersionedParams(&opts, ip.paramCodec).Do(ctx).Into(res)
return res, err
},
// Setup the watch function
WatchFunc: func(opts metav1.ListOptions) (watch.Interface, error) {
ip.selectors(gvk).ApplyToList(&opts)
// Watch needs to be set to true separately
opts.Watch = true
namespace := restrictNamespaceBySelector(ip.namespace, ip.selectors(gvk))
isNamespaceScoped := namespace != "" && mapping.Scope.Name() != meta.RESTScopeNameRoot
return client.Get().NamespaceIfScoped(namespace, isNamespaceScoped).Resource(mapping.Resource.Resource).VersionedParams(&opts, ip.paramCodec).Watch(ctx)
},
}, nil
}
可以看到,在 Informer 的 ListWatch 接口中,p.selectors(gvk).ApplyToList(&opts)
会将我们一开始注册进来的 selector
添加到后面的 list/watch
请求中。
在上面一个例子中,我们提到 metadata
采用 protobuf
序列化形式请求 ApiServer,相比默认的序列化类型 json,protobuf 形式的请求效率更高,在大规模环境中性能更好。不过,不是所有的资源类型都支持 protobuf 格式,比如 CRD 就不支持。
还有一个需要注意的点是,在 Metadata 的数据中,watch 到的数据只有 metadata,没有 spec 和 status。使用示例如下:
func start() {
scheme := runtime.NewScheme()
// 1. init Manager
mgr, _ := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Port: 9443,
NewCache: cache.BuilderWithOptions(cache.Options{
Scheme: scheme,
SelectorsByObject: cache.SelectorsByObject{
&corev1.Pod{}: {
Label: labels.SelectorFromSet(labels.Set{}),
},
&corev1.Node{}: {
Field: fields.SelectorFromSet(fields.Set{"metadata.name": "node01"}),
},
},
}),
})
// 2. init Reconciler(Controller)
c, _ := controller.New("app", mgr, controller.Options{})
_ = ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}).
Complete(&ApplicationReconciler{})
u := &metav1.PartialObjectMetadata{}
u.SetGroupVersionKind(schema.GroupVersionKind{
Kind: "Pod",
Group: "",
Version: "v1",
})
_ = c.Watch(&source.Kind{Type: u}, &handler.EnqueueRequestForObject{}, predicate.Funcs{
CreateFunc: func(event event.CreateEvent) bool {
return true
},
UpdateFunc: func(updateEvent event.UpdateEvent) bool {
return true
},
DeleteFunc: func(deleteEvent event.DeleteEvent) bool {
return true
},
})
// 3. start Manager
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
}
}
在 Cache 的 metadata 数据中,采用的数据格式是 meta.v1.PartialObjectMetadata
,其使用前提是用户只关心资源的 metadata,对其 spec 及 status 并不关心,所以在对 ApiServer 的 ListWatch 函数中,只获取其 metadata。源码如下:
// PartialObjectMetadata is a generic representation of any object with ObjectMeta. It allows clients
// to get access to a particular ObjectMeta schema without knowing the details of the version.
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type PartialObjectMetadata struct {
TypeMeta `json:",inline"`
// Standard object's metadata.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
// +optional
ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
}
func createMetadataListWatch(gvk schema.GroupVersionKind, ip *specificInformersMap) (*cache.ListWatch, error) {
// Kubernetes APIs work against Resources, not GroupVersionKinds. Map the
// groupVersionKind to the Resource API we will use.
mapping, err := ip.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil, err
}
// Always clear the negotiated serializer and use the one
// set from the metadata client.
cfg := rest.CopyConfig(ip.config)
cfg.NegotiatedSerializer = nil
// grab the metadata client
client, err := metadata.NewForConfig(cfg)
if err != nil {
return nil, err
}
ctx := context.TODO()
// create the relevant listwatch
return &cache.ListWatch{
ListFunc: func(opts metav1.ListOptions) (runtime.Object, error) {
ip.selectors(gvk).ApplyToList(&opts)
var (
list *metav1.PartialObjectMetadataList
err error
)
namespace := restrictNamespaceBySelector(ip.namespace, ip.selectors(gvk))
if namespace != "" && mapping.Scope.Name() != meta.RESTScopeNameRoot {
list, err = client.Resource(mapping.Resource).Namespace(namespace).List(ctx, opts)
} else {
list, err = client.Resource(mapping.Resource).List(ctx, opts)
}
if list != nil {
for i := range list.Items {
list.Items[i].SetGroupVersionKind(gvk)
}
}
return list, err
},
// Setup the watch function
WatchFunc: func(opts metav1.ListOptions) (watch.Interface, error) {
ip.selectors(gvk).ApplyToList(&opts)
// Watch needs to be set to true separately
opts.Watch = true
var (
watcher watch.Interface
err error
)
namespace := restrictNamespaceBySelector(ip.namespace, ip.selectors(gvk))
if namespace != "" && mapping.Scope.Name() != meta.RESTScopeNameRoot {
watcher, err = client.Resource(mapping.Resource).Namespace(namespace).Watch(ctx, opts)
} else {
watcher, err = client.Resource(mapping.Resource).Watch(ctx, opts)
}
if watcher != nil {
watcher = newGVKFixupWatcher(gvk, watcher)
}
return watcher, err
},
}, nil
}
可以看到,controller-runtime 使用的是 client-go.metadata.Client
,这个 Client 的接口返回的数据格式是 PartialObjectMetadata
。
controller-runtime 是一种很好用的生成资源控制器的工具,在平时的开发过程中,我们可以利用 controller-runtime 快速生成我们需要的资源控制器。同时,controller-runtime 也提供了很多方法,让我们不仅可以快速构建控制器,也可以针对不同的业务需求,进行灵活的配置,达到预期的效果。
]]>本文基于对 Kubernetes v1.22.1 的源码阅读
Kubelet 出于对节点的保护,允许在节点资源不足的情况下,开启对节点上 Pod 进行驱逐的功能。最近对 Kubelet 的驱逐机制有所研究,发现其中有很多值得学习的地方,总结下来和大家分享。
Kubelet 的驱逐功能需要在配置中打开,并且配置驱逐的阈值。Kubelet 的配置中与驱逐相关的参数如下:
type KubeletConfiguration struct {
...
// Map of signal names to quantities that defines hard eviction thresholds. For example: {"memory.available": "300Mi"}.
EvictionHard map[string]string
// Map of signal names to quantities that defines soft eviction thresholds. For example: {"memory.available": "300Mi"}.
EvictionSoft map[string]string
// Map of signal names to quantities that defines grace periods for each soft eviction signal. For example: {"memory.available": "30s"}.
EvictionSoftGracePeriod map[string]string
// Duration for which the kubelet has to wait before transitioning out of an eviction pressure condition.
EvictionPressureTransitionPeriod metav1.Duration
// Maximum allowed grace period (in seconds) to use when terminating pods in response to a soft eviction threshold being met.
EvictionMaxPodGracePeriod int32
// Map of signal names to quantities that defines minimum reclaims, which describe the minimum
// amount of a given resource the kubelet will reclaim when performing a pod eviction while
// that resource is under pressure. For example: {"imagefs.available": "2Gi"}
EvictionMinimumReclaim map[string]string
...
}
其中,EvictionHard 表示硬驱逐,一旦达到阈值,就直接驱逐;EvictionSoft 表示软驱逐,即可以设置软驱逐周期,只有超过软驱逐周期后,才启动驱逐,周期用 EvictionSoftGracePeriod 设置;EvictionMinimumReclaim 表示设置最小可用的阈值,比如 imagefs。
可以设置的驱逐信号有:
Eviction Manager的主要工作在 synchronize
函数里。有两个地方触发 synchronize
任务,一个是 monitor 任务,每 10s 触发一次;另一个是根据用户配置的驱逐信号,启动的 notifier
任务,用来监听内核事件。
notifier
由 eviction manager 中的 thresholdNotifier
启动,用户配置的每一个驱逐信号,都对应一个 thresholdNotifier
,而 thresholdNotifier
和 notifier
通过 channel 通信,当 notifier
向 channel 中发送消息时,对应的 thresholdNotifier
便触发一次 synchronize
逻辑。
notifier
采用的是内核的 cgroups Memory thresholds,cgroups 允许用户态进程通过 eventfd
来设置当 memory.usage_in_bytes
达到某阈值时,内核给应用发送通知。具体做法是向 cgroup.event_control
写入 "<event_fd> <fd of memory.usage_in_bytes> <threshold>"
。
notifier
的初始化代码如下(为了方便阅读,删除了部分不相干代码),主要是找到 memory.usage_in_bytes
的文件描述符 watchfd
,cgroup.event_control
的文件描述符 controlfd
,完成 cgroup memory thrsholds
的注册。
func NewCgroupNotifier(path, attribute string, threshold int64) (CgroupNotifier, error) {
var watchfd, eventfd, epfd, controlfd int
watchfd, err = unix.Open(fmt.Sprintf("%s/%s", path, attribute), unix.O_RDONLY|unix.O_CLOEXEC, 0)
defer unix.Close(watchfd)
controlfd, err = unix.Open(fmt.Sprintf("%s/cgroup.event_control", path), unix.O_WRONLY|unix.O_CLOEXEC, 0)
defer unix.Close(controlfd)
eventfd, err = unix.Eventfd(0, unix.EFD_CLOEXEC)
defer func() {
// Close eventfd if we get an error later in initialization
if err != nil {
unix.Close(eventfd)
}
}()
epfd, err = unix.EpollCreate1(unix.EPOLL_CLOEXEC)
defer func() {
// Close epfd if we get an error later in initialization
if err != nil {
unix.Close(epfd)
}
}()
config := fmt.Sprintf("%d %d %d", eventfd, watchfd, threshold)
_, err = unix.Write(controlfd, []byte(config))
return &linuxCgroupNotifier{
eventfd: eventfd,
epfd: epfd,
stop: make(chan struct{}),
}, nil
}
notifier 在启动时还会通过 epoll 来监听上述的 eventfd
,当监听到内核发送的事件时,说明使用的内存已超过阈值,便向 channel 中发送信号。
func (n *linuxCgroupNotifier) Start(eventCh chan<- struct{}) {
err := unix.EpollCtl(n.epfd, unix.EPOLL_CTL_ADD, n.eventfd, &unix.EpollEvent{
Fd: int32(n.eventfd),
Events: unix.EPOLLIN,
})
for {
select {
case <-n.stop:
return
default:
}
event, err := wait(n.epfd, n.eventfd, notifierRefreshInterval)
if err != nil {
klog.InfoS("Eviction manager: error while waiting for memcg events", "err", err)
return
} else if !event {
// Timeout on wait. This is expected if the threshold was not crossed
continue
}
// Consume the event from the eventfd
buf := make([]byte, eventSize)
_, err = unix.Read(n.eventfd, buf)
if err != nil {
klog.InfoS("Eviction manager: error reading memcg events", "err", err)
return
}
eventCh <- struct{}{}
}
}
synchronize
逻辑每次执行都会判断 10s 内 notifier
是否有更新,并重新启动 notifier
。cgroup memory threshold
的计算方式为内存总量减去用户设置的驱逐阈值。
Eviction Manager 的主逻辑 synchronize
细节比较多,这里就不贴源码了,梳理下来主要是以下几个事项:
threshold
并重新启动 notifier
;对 pod 的驱逐顺序主要取决于三个因素:
三个因素的判断顺序也是根据注册进 orderedBy
的顺序。这里 orderedBy
函数的多级排序也是 Kubernetes 里一个值得学习(抄作业)的一个实现,感兴趣的读者可以自行查阅源码。
// rankMemoryPressure orders the input pods for eviction in response to memory pressure.
// It ranks by whether or not the pod's usage exceeds its requests, then by priority, and
// finally by memory usage above requests.
func rankMemoryPressure(pods []*v1.Pod, stats statsFunc) {
orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods)
}
接下来就是驱逐 Pod 的实现。Eviction Manager 驱逐 Pod 就是干净利落的 kill,里面具体的实现这里不展开分析,值得注意的是在驱逐之前有一个判断,如果 IsCriticalPod
返回为 true 则不驱逐。
func (m *managerImpl) evictPod(pod *v1.Pod, gracePeriodOverride int64, evictMsg string, annotations map[string]string) bool {
// If the pod is marked as critical and static, and support for critical pod annotations is enabled,
// do not evict such pods. Static pods are not re-admitted after evictions.
// https://github.com/kubernetes/kubernetes/issues/40573 has more details.
if kubelettypes.IsCriticalPod(pod) {
klog.ErrorS(nil, "Eviction manager: cannot evict a critical pod", "pod", klog.KObj(pod))
return false
}
// record that we are evicting the pod
m.recorder.AnnotatedEventf(pod, annotations, v1.EventTypeWarning, Reason, evictMsg)
// this is a blocking call and should only return when the pod and its containers are killed.
klog.V(3).InfoS("Evicting pod", "pod", klog.KObj(pod), "podUID", pod.UID, "message", evictMsg)
err := m.killPodFunc(pod, true, &gracePeriodOverride, func(status *v1.PodStatus) {
status.Phase = v1.PodFailed
status.Reason = Reason
status.Message = evictMsg
})
if err != nil {
klog.ErrorS(err, "Eviction manager: pod failed to evict", "pod", klog.KObj(pod))
} else {
klog.InfoS("Eviction manager: pod is evicted successfully", "pod", klog.KObj(pod))
}
return true
}
再看看 IsCriticalPod
的代码:
func IsCriticalPod(pod *v1.Pod) bool {
if IsStaticPod(pod) {
return true
}
if IsMirrorPod(pod) {
return true
}
if pod.Spec.Priority != nil && IsCriticalPodBasedOnPriority(*pod.Spec.Priority) {
return true
}
return false
}
// IsMirrorPod returns true if the passed Pod is a Mirror Pod.
func IsMirrorPod(pod *v1.Pod) bool {
_, ok := pod.Annotations[ConfigMirrorAnnotationKey]
return ok
}
// IsStaticPod returns true if the pod is a static pod.
func IsStaticPod(pod *v1.Pod) bool {
source, err := GetPodSource(pod)
return err == nil && source != ApiserverSource
}
func IsCriticalPodBasedOnPriority(priority int32) bool {
return priority >= scheduling.SystemCriticalPriority
}
从代码看,如果 Pod 是 Static、Mirror、Critical Pod 都不驱逐。其中 Static 和 Mirror 都是从 Pod 的 annotation 中判断;而 Critical 则是通过 Pod 的 Priority 值判断的,如果 Priority 为 system-cluster-critical
/system-node-critical
都属于 Critical Pod。
不过这里值得注意的是,官方文档里提及 Critical Pod 是说,如果非 Static Pod 被标记为 Critical,并不完全保证不会被驱逐:https://kubernetes.io/docs/tasks/administer-cluster/guaranteed-scheduling-critical-addon-pods 。因此,很有可能是社区并没有想清楚这种情况是否要驱逐,并不排除后面会改变这段逻辑,不过也有可能是文档没有及时更新🌚。
本文主要分析了 Kubelet 的 Eviction Manager,包括其对 Linux CGroup 事件的监听、判断 Pod 驱逐的优先级等。了解了这些之后,我们就可以根据自身应用的重要性来设置优先级,甚至设置成 Critical Pod。
]]>我会通过两篇文章介绍下 CSI,本篇是第一篇,重点介绍 CSI 的基本组件和工作原理,本文基于 Kubernetes 作为 CSI 的 COs(Container Orchestration Systems)。第二篇将拿几个典型的 CSI 项目分析具体实现。
CSI 的 cloud providers 有两种类型,一种为 in-tree 类型,一种为 out-of-tree 类型。前者是指运行在 k8s 核心组件内部的存储插件;后者是指独立在 k8s 组件之外运行的存储插件。本文主要介绍 out-of-tree 类型的插件。
out-of-tree 类型的插件主要是通过 gRPC 接口跟 k8s 组件交互,并且 k8s 提供了大量的 SideCar 组件来配合 CSI 插件实现丰富的功能。对于 out-of-tree 类型的插件来说,所用到的组件分为 SideCar 组件和第三方需要实现的插件。
监听 VolumeAttachment 对象,并调用 CSI driver Controller 服务的 ControllerPublishVolume
和 ControllerUnpublishVolume
接口,用来将 volume 附着到 node 上,或从 node 上删除。
如果存储系统需要 attach/detach 这一步,就需要使用到这个组件,因为 K8s 内部的 Attach/Detach Controller 不会直接调用 CSI driver 的接口。
监听 PVC 对象,并调用 CSI driver Controller 服务的 CreateVolume
和 DeleteVolume
接口,用来提供一个新的 volume。前提是 PVC 中指定的 StorageClass 的 provisioner 字段和 CSI driver Identity 服务的 GetPluginInfo
接口的返回值一样。一旦新的 volume 提供出来,K8s 就会创建对应的 PV。
而如果 PVC 绑定的 PV 的回收策略是 delete,那么 external-provisioner 组件监听到 PVC 的删除后,会调用 CSI driver Controller 服务的 DeleteVolume
接口。一旦 volume 删除成功,该组件也会删除相应的 PV。
该组件还支持从快照创建数据源。如果在 PVC 中指定了 Snapshot CRD 的数据源,那么该组件会通过 SnapshotContent
对象获取有关快照的信息,并将此内容在调用 CreateVolume
接口的时候传给 CSI driver,CSI driver 需要根据数据源快照来创建 volume。
监听 PVC 对象,如果用户请求在 PVC 对象上请求更多存储,该组件会调用 CSI driver Controller 服务的 NodeExpandVolume
接口,用来对 volume 进行扩容。
该组件需要与 Snapshot Controller 配合使用。Snapshot Controller 会根据集群中创建的 Snapshot 对象创建对应的 VolumeSnapshotContent,而 external-snapshotter 负责监听 VolumeSnapshotContent 对象。当监听到 VolumeSnapshotContent 时,将其对应参数通过 CreateSnapshotRequest
传给 CSI driver Controller 服务,调用其 CreateSnapshot
接口。该组件还负责调用 DeleteSnapshot
、ListSnapshots
接口。
负责监测 CSI driver 的健康情况,并通过 Liveness Probe 机制汇报给 k8s,当监测到 CSI driver 有异常时负责重启 pod。
通过直接调用 CSI driver Node 服务的 NodeGetInfo
接口,将 CSI driver 的信息通过 kubelet 的插件注册机制在对应节点的 kubelet 上进行注册。
通过调用 CSI driver Controller 服务的 ListVolumes
或者 ControllerGetVolume
接口,来检查 CSI volume 的健康情况,并上报在 PVC 的 event 中。
通过调用 CSI driver Node 服务的 NodeGetVolumeStats
接口,来检查 CSI volume 的健康情况,并上报在 pod 的 event 中。
第三方存储提供方(即 SP,Storage Provider)需要实现 Controller 和 Node 两个插件,其中 Controller 负责 Volume 的管理,以 StatefulSet 形式部署;Node 负责将 Volume mount 到 pod 中,以 DaemonSet 形式部署在每个 node 中。
CSI 插件与 kubelet 以及 k8s 外部组件是通过 Unix Domani Socket gRPC 来进行交互调用的。CSI 定义了三套 RPC 接口,SP 需要实现这三组接口,以便与 k8s 外部组件进行通信。三组接口分别是:CSI Identity、CSI Controller 和 CSI Node,下面详细看看这些接口定义。
用于提供 CSI driver 的身份信息,Controller 和 Node 都需要实现。接口如下:
service Identity {
rpc GetPluginInfo(GetPluginInfoRequest)
returns (GetPluginInfoResponse) {}
rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
returns (GetPluginCapabilitiesResponse) {}
rpc Probe (ProbeRequest)
returns (ProbeResponse) {}
}
GetPluginInfo
是必须要实现的,node-driver-registrar 组件会调用这个接口将 CSI driver 注册到 kubelet;GetPluginCapabilities
是用来表明该 CSI driver 主要提供了哪些功能。
用于实现创建/删除 volume、attach/detach volume、volume 快照、volume 扩缩容等功能,Controller 插件需要实现这组接口。接口如下:
service Controller {
rpc CreateVolume (CreateVolumeRequest)
returns (CreateVolumeResponse) {}
rpc DeleteVolume (DeleteVolumeRequest)
returns (DeleteVolumeResponse) {}
rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
returns (ControllerPublishVolumeResponse) {}
rpc ControllerUnpublishVolume (ControllerUnpublishVolumeRequest)
returns (ControllerUnpublishVolumeResponse) {}
rpc ValidateVolumeCapabilities (ValidateVolumeCapabilitiesRequest)
returns (ValidateVolumeCapabilitiesResponse) {}
rpc ListVolumes (ListVolumesRequest)
returns (ListVolumesResponse) {}
rpc GetCapacity (GetCapacityRequest)
returns (GetCapacityResponse) {}
rpc ControllerGetCapabilities (ControllerGetCapabilitiesRequest)
returns (ControllerGetCapabilitiesResponse) {}
rpc CreateSnapshot (CreateSnapshotRequest)
returns (CreateSnapshotResponse) {}
rpc DeleteSnapshot (DeleteSnapshotRequest)
returns (DeleteSnapshotResponse) {}
rpc ListSnapshots (ListSnapshotsRequest)
returns (ListSnapshotsResponse) {}
rpc ControllerExpandVolume (ControllerExpandVolumeRequest)
returns (ControllerExpandVolumeResponse) {}
rpc ControllerGetVolume (ControllerGetVolumeRequest)
returns (ControllerGetVolumeResponse) {
option (alpha_method) = true;
}
}
在上面介绍 k8s 外部组件的时候已经提到,不同的接口分别提供给不同的组件调用,用于配合实现不同的功能。比如 CreateVolume
/DeleteVolume
配合 external-provisioner 实现创建/删除 volume 的功能;ControllerPublishVolume
/ControllerUnpublishVolume
配合 external-attacher 实现 volume 的 attach/detach 功能等。
用于实现 mount/umount volume、检查 volume 状态等功能,Node 插件需要实现这组接口。接口如下:
service Node {
rpc NodeStageVolume (NodeStageVolumeRequest)
returns (NodeStageVolumeResponse) {}
rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
returns (NodeUnstageVolumeResponse) {}
rpc NodePublishVolume (NodePublishVolumeRequest)
returns (NodePublishVolumeResponse) {}
rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
returns (NodeUnpublishVolumeResponse) {}
rpc NodeGetVolumeStats (NodeGetVolumeStatsRequest)
returns (NodeGetVolumeStatsResponse) {}
rpc NodeExpandVolume(NodeExpandVolumeRequest)
returns (NodeExpandVolumeResponse) {}
rpc NodeGetCapabilities (NodeGetCapabilitiesRequest)
returns (NodeGetCapabilitiesResponse) {}
rpc NodeGetInfo (NodeGetInfoRequest)
returns (NodeGetInfoResponse) {}
}
NodeStageVolume
用来实现多个 pod 共享一个 volume 的功能,支持先将 volume 挂载到一个临时目录,然后通过 NodePublishVolume
将其挂载到 pod 中;NodeUnstageVolume
为其反操作。
下面来看看 pod 挂载 volume 的整个工作流程。整个流程流程分别三个阶段:Provision/Delete、Attach/Detach、Mount/Unmount,不过不是每个存储方案都会经历这三个阶段,比如 NFS 就没有 Attach/Detach 阶段。
整个过程不仅仅涉及到上面介绍的组件的工作,还涉及 ControllerManager 的 AttachDetachController 组件和 PVController 组件以及 kubelet。下面分别详细分析一下 Provision、Attach、Mount 三个阶段。
先来看 Provision 阶段,整个过程如上图所示。其中 extenal-provisioner 和 PVController 均 watch PVC 资源。
volume.beta.kubernetes.io/storage-provisioner={csi driver name}
;CreateVolume
接口;CreateVolume
接口返回成功时,extenal-provisioner 会在集群中创建对应的 PV;Attach 阶段是指将 volume 附着到节点上,整个过程如上图所示。
ControllerPublishVolume
接口;ControllerPublishVolume
接口调用成功后,external-attacher 将对应的 VolumeAttachment 对象的 Attached 状态设为 true;最后一步将 volume 挂载到 pod 里的过程涉及到 kubelet。整个流程简单地说是,对应节点上的 kubelet 在创建 pod 的过程中,会调用 CSI Node 插件,执行 mount 操作。下面再针对 kubelet 内部的组件细分进行分析。
首先 kubelet 创建 pod 的主函数 syncPod
中,kubelet 会调用其子组件 volumeManager 的 WaitForAttachAndMount
方法,等待 volume mount 完成:
func (kl *Kubelet) syncPod(o syncPodOptions) error {
...
// Volume manager will not mount volumes for terminated pods
if !kl.podIsTerminated(pod) {
// Wait for volumes to attach/mount
if err := kl.volumeManager.WaitForAttachAndMount(pod); err != nil {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedMountVolume, "Unable to attach or mount volumes: %v", err)
klog.Errorf("Unable to attach or mount volumes for pod %q: %v; skipping pod", format.Pod(pod), err)
return err
}
}
...
}
volumeManager 中包含两个组件:desiredStateOfWorldPopulator 和 reconciler。这两个组件相互配合就完成了 volume 在 pod 中的 mount 和 umount 过程。整个过程如下:
desiredStateOfWorldPopulator 和 reconciler 的协同模式是生产者和消费者的模式。volumeManager 中维护了两个队列(严格来讲是 interface,但这里充当了队列的作用),即 DesiredStateOfWorld 和 ActualStateOfWorld,前者维护的是当前节点中 volume 的期望状态;后者维护的是当前节点中 volume 的实际状态。
而 desiredStateOfWorldPopulator 在自己的循环中只做了两个事情,一个是从 kubelet 的 podManager 中获取当前节点新建的 Pod,将其需要挂载的 volume 信息记录到 DesiredStateOfWorld 中;另一件事是从 podManager 中获取当前节点中被删除的 pod,检查其 volume 是否在 ActualStateOfWorld 的记录中,如果没有,将其在 DesiredStateOfWorld 中也删除,从而保证 DesiredStateOfWorld 记录的是节点中所有 volume 的期望状态。相关代码如下(为了精简逻辑,删除了部分代码):
// Iterate through all pods and add to desired state of world if they don't
// exist but should
func (dswp *desiredStateOfWorldPopulator) findAndAddNewPods() {
// Map unique pod name to outer volume name to MountedVolume.
mountedVolumesForPod := make(map[volumetypes.UniquePodName]map[string]cache.MountedVolume)
...
processedVolumesForFSResize := sets.NewString()
for _, pod := range dswp.podManager.GetPods() {
dswp.processPodVolumes(pod, mountedVolumesForPod, processedVolumesForFSResize)
}
}
// processPodVolumes processes the volumes in the given pod and adds them to the
// desired state of the world.
func (dswp *desiredStateOfWorldPopulator) processPodVolumes(
pod *v1.Pod,
mountedVolumesForPod map[volumetypes.UniquePodName]map[string]cache.MountedVolume,
processedVolumesForFSResize sets.String) {
uniquePodName := util.GetUniquePodName(pod)
...
for _, podVolume := range pod.Spec.Volumes {
pvc, volumeSpec, volumeGidValue, err :=
dswp.createVolumeSpec(podVolume, pod, mounts, devices)
// Add volume to desired state of world
_, err = dswp.desiredStateOfWorld.AddPodToVolume(
uniquePodName, pod, volumeSpec, podVolume.Name, volumeGidValue)
dswp.actualStateOfWorld.MarkRemountRequired(uniquePodName)
}
}
而 reconciler 就是消费者,它主要做了三件事:
unmountVolumes()
:在 ActualStateOfWorld 中遍历 volume,判断其是否在 DesiredStateOfWorld 中,如果不在,则调用 CSI Node 的接口执行 unmount,并在 ActualStateOfWorld 中记录;mountAttachVolumes()
:从 DesiredStateOfWorld 中获取需要被 mount 的 volume,调用 CSI Node 的接口执行 mount 或扩容,并在 ActualStateOfWorld 中做记录;unmountDetachDevices()
: 在 ActualStateOfWorld 中遍历 volume,若其已经 attach,但没有使用的 pod,并在 DesiredStateOfWorld 也没有记录,则将其 unmount/detach 掉。我们以 mountAttachVolumes()
为例,看看其如何调用 CSI Node 的接口。
func (rc *reconciler) mountAttachVolumes() {
// Ensure volumes that should be attached/mounted are attached/mounted.
for _, volumeToMount := range rc.desiredStateOfWorld.GetVolumesToMount() {
volMounted, devicePath, err := rc.actualStateOfWorld.PodExistsInVolume(volumeToMount.PodName, volumeToMount.VolumeName)
volumeToMount.DevicePath = devicePath
if cache.IsVolumeNotAttachedError(err) {
...
} else if !volMounted || cache.IsRemountRequiredError(err) {
// Volume is not mounted, or is already mounted, but requires remounting
err := rc.operationExecutor.MountVolume(
rc.waitForAttachTimeout,
volumeToMount.VolumeToMount,
rc.actualStateOfWorld,
isRemount)
...
} else if cache.IsFSResizeRequiredError(err) {
err := rc.operationExecutor.ExpandInUseVolume(
volumeToMount.VolumeToMount,
rc.actualStateOfWorld)
...
}
}
}
执行 mount 的操作全在 rc.operationExecutor
中完成,再看 operationExecutor 的代码:
func (oe *operationExecutor) MountVolume(
waitForAttachTimeout time.Duration,
volumeToMount VolumeToMount,
actualStateOfWorld ActualStateOfWorldMounterUpdater,
isRemount bool) error {
...
var generatedOperations volumetypes.GeneratedOperations
generatedOperations = oe.operationGenerator.GenerateMountVolumeFunc(
waitForAttachTimeout, volumeToMount, actualStateOfWorld, isRemount)
// Avoid executing mount/map from multiple pods referencing the
// same volume in parallel
podName := nestedpendingoperations.EmptyUniquePodName
return oe.pendingOperations.Run(
volumeToMount.VolumeName, podName, "" /* nodeName */, generatedOperations)
}
该函数先构造执行函数,再执行,那么再看构造函数:
func (og *operationGenerator) GenerateMountVolumeFunc(
waitForAttachTimeout time.Duration,
volumeToMount VolumeToMount,
actualStateOfWorld ActualStateOfWorldMounterUpdater,
isRemount bool) volumetypes.GeneratedOperations {
volumePlugin, err :=
og.volumePluginMgr.FindPluginBySpec(volumeToMount.VolumeSpec)
mountVolumeFunc := func() volumetypes.OperationContext {
// Get mounter plugin
volumePlugin, err := og.volumePluginMgr.FindPluginBySpec(volumeToMount.VolumeSpec)
volumeMounter, newMounterErr := volumePlugin.NewMounter(
volumeToMount.VolumeSpec,
volumeToMount.Pod,
volume.VolumeOptions{})
...
// Execute mount
mountErr := volumeMounter.SetUp(volume.MounterArgs{
FsUser: util.FsUserFrom(volumeToMount.Pod),
FsGroup: fsGroup,
DesiredSize: volumeToMount.DesiredSizeLimit,
FSGroupChangePolicy: fsGroupChangePolicy,
})
// Update actual state of world
markOpts := MarkVolumeOpts{
PodName: volumeToMount.PodName,
PodUID: volumeToMount.Pod.UID,
VolumeName: volumeToMount.VolumeName,
Mounter: volumeMounter,
OuterVolumeSpecName: volumeToMount.OuterVolumeSpecName,
VolumeGidVolume: volumeToMount.VolumeGidValue,
VolumeSpec: volumeToMount.VolumeSpec,
VolumeMountState: VolumeMounted,
}
markVolMountedErr := actualStateOfWorld.MarkVolumeAsMounted(markOpts)
...
return volumetypes.NewOperationContext(nil, nil, migrated)
}
return volumetypes.GeneratedOperations{
OperationName: "volume_mount",
OperationFunc: mountVolumeFunc,
EventRecorderFunc: eventRecorderFunc,
CompleteFunc: util.OperationCompleteHook(util.GetFullQualifiedPluginNameForVolume(volumePluginName, volumeToMount.VolumeSpec), "volume_mount"),
}
}
这里先去注册到 kubelet 的 CSI 的 plugin 列表中找到对应的插件,然后再执行 volumeMounter.SetUp
,最后更新 ActualStateOfWorld 的记录。这里负责执行 external CSI 插件的是 csiMountMgr,代码如下:
func (c *csiMountMgr) SetUp(mounterArgs volume.MounterArgs) error {
return c.SetUpAt(c.GetPath(), mounterArgs)
}
func (c *csiMountMgr) SetUpAt(dir string, mounterArgs volume.MounterArgs) error {
csi, err := c.csiClientGetter.Get()
...
err = csi.NodePublishVolume(
ctx,
volumeHandle,
readOnly,
deviceMountPath,
dir,
accessMode,
publishContext,
volAttribs,
nodePublishSecrets,
fsType,
mountOptions,
)
...
return nil
}
可以看到,在 kubelet 中调用 CSI Node NodePublishVolume
/NodeUnPublishVolume
接口的是 volumeManager 的 csiMountMgr。至此,整个 Pod 的 volume 流程就已经梳理清楚了。
本文从 CSI 的组件、CSI 接口、以及 volume 如何挂载到 pod 上的流程,三个方面入手,分析了 CSI 整个体系工作的过程。CSI 是整个容器生态的标准存储接口,CO 通过 gRPC 方式和 CSI 插件通信,而为了做到普适,k8s 设计了很多外部组件来配合 CSI 插件来实现不同的功能,从而保证了 k8s 内部逻辑的纯粹以及 CSI 插件的简单易用。
]]>春节假期在家维护「家庭级 Kubernetes 集群」时,萌生了写一个网络插件的想法,于是基于 cni/plugin 仓库已有的轮子,写了 Village Net( https://github.com/zwwhdls/village-net )。以这个网络插件为例,本文着重介绍如何实现一个 CNI 插件。
要了解如何实现一个 CNI 插件,需要先了解 CNI 的工作原理。CNI 是 Container Network Interface 的缩写,是一个接口协议,用于配置容器的网络。容器管理系统提供容器所在的 network namespace 之后,CNI 负责将 network interface 插入到该 network namespace 中,并配置相应的 ip 和路由。
CNI 其实是容器运行时系统和 CNI Plugin 的一个连接桥梁,CNI 将容器的运行时的信息以及网络配置信息传递 Plugin,由各个 Plugin 实现后续工作,所以 CNI Plugin 才是容器网络的具体实现。可以总结为下面这张图:
现在我们知道 CNI Plugin 是容器网络的具体实现。在集群里,每个 Plugin 以二进制的形式存在,由 kubelet 通过 CNI 接口来调用每个插件执行。具体的流程如下:
CNI Plugin 可以分为三类:Main、IPAM 和 Meta。其中 Main 和 IPAM 插件相辅相成,完成了为容器创建网络环境的基本工作。
IPAM (IP Address Management) 插件主要用来负责分配IP地址。官方提供的可使用插件包括下面几种:
Main 插件主要用来创建具体的网络设备的二进制文件。官方提供的可使用插件包括下面几种:
由CNI社区维护的内部插件,目前主要包括:
CNI Plugin 的仓库在:https://github.com/containernetworking/plugins 。在里面可以看到每种类型 Plugin 的具体实现。每个 Plugin 都需要实现以下三个方法,再在 main 中注册一下。
func cmdCheck(args *skel.CmdArgs) error {
...
}
func cmdAdd(args *skel.CmdArgs) error {
...
}
func cmdDel(args *skel.CmdArgs) error {
...
}
以 host-local 为例,注册的方法如下,需要指明上面实现的三个方法、支持的版本、以及 Plugin 的名称。
func main() {
skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("host-local"))
}
了解了 Plugin 的工作原理之后,再来看下 CNI 的具体工作原理。CNI 的仓库在:https://github.com/containernetworking/cni 。本文分析的代码以当前最新版本 v0.8.1 为基准。
社区提供了一个工具 cnitool,是模拟 CNI 接口被调用的工具,可以在一个已存在的 network namespace 中增加或删除网络设备。
先来看下 cnitool 的执行逻辑:
func main() {
...
netconf, err := libcni.LoadConfList(netdir, os.Args[2])
...
netns := os.Args[3]
netns, err = filepath.Abs(netns)
...
// Generate the containerid by hashing the netns path
s := sha512.Sum512([]byte(netns))
containerID := fmt.Sprintf("cnitool-%x", s[:10])
cninet := libcni.NewCNIConfig(filepath.SplitList(os.Getenv(EnvCNIPath)), nil)
rt := &libcni.RuntimeConf{
ContainerID: containerID,
NetNS: netns,
IfName: ifName,
Args: cniArgs,
CapabilityArgs: capabilityArgs,
}
switch os.Args[1] {
case CmdAdd:
result, err := cninet.AddNetworkList(context.TODO(), netconf, rt)
if result != nil {
_ = result.Print()
}
exit(err)
case CmdCheck:
err := cninet.CheckNetworkList(context.TODO(), netconf, rt)
exit(err)
case CmdDel:
exit(cninet.DelNetworkList(context.TODO(), netconf, rt))
}
}
从上面的代码中可以看出,先是从 cni 配置文件中解析出配置 netconf,然后获取 netns、containerId 等信息作为容器的运行时信息传给接口 cninet.AddNetworkList。
接下来看下接口 AddNetworkList 的实现:
// AddNetworkList executes a sequence of plugins with the ADD command
func (c *CNIConfig) AddNetworkList(ctx context.Context, list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {
var err error
var result types.Result
for _, net := range list.Plugins {
result, err = c.addNetwork(ctx, list.Name, list.CNIVersion, net, result, rt)
if err != nil {
return nil, err
}
}
...
return result, nil
}
显然,该函数的作用就是按顺序执行各个 Plugin 的 addNetwork 操作。再看下 addNetwork 函数:
func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) {
c.ensureExec()
pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path)
...
newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt)
...
return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)
}
对每个插件的 addNetwork 操作分为三个部分:
事实上,invoke.ExecPluginWithResult 仅仅是一个包装函数,里面调用了一下 exec.ExecPlugin 就返回了,这里我们看一下 exec.ExecPlugin 的实现:
func (e *RawExec) ExecPlugin(ctx context.Context, pluginPath string, stdinData []byte, environ []string) ([]byte, error) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
c := exec.CommandContext(ctx, pluginPath)
c.Env = environ
c.Stdin = bytes.NewBuffer(stdinData)
c.Stdout = stdout
c.Stderr = stderr
// Retry the command on "text file busy" errors
for i := 0; i <= 5; i++ {
err := c.Run()
...
// All other errors except than the busy text file
return nil, e.pluginErr(err, stdout.Bytes(), stderr.Bytes())
}
...
}
看到这里,我们也就看到了整个 CNI 的核心逻辑,出乎意料的简单,仅仅是 exec 了插件的可执行文件,发生错误的时候重试 5 次。
至此,整个 CNI 的执行流程已经非常清晰了,简而言之,一个 CNI 插件就是一个可执行文件,从配置文件中获取网络的配置信息,从容器运行时获取容器的信息,前者以标准输入的形式,后者以环境变量的形式传给各个插件,最终以配置文件中定义的顺序依次调用各个插件,并且将前一个插件的执行结果包含在配置信息中传给下一个插件。
尽管如此,我们目前熟悉的成熟的网络插件的方案(如 calico),通常都不是依次调用 Plugin,而是只调用 main 插件,在 main 插件中调用 ipam 插件,并当场获取执行结果。
了解了 CNI 插件的具体工作原理之后,再来看看 kubelet 如何使用 CNI 插件。
kubelet 在创建 pod 的时候,会调用 CNI 插件为 pod 创建网络环境。源码如下,可以看到 kubelet 在 SetUpPod 函数(pkg/kubelet/dockershim/network/cni/cni.go)中调用了 plugin.addToNetwork 函数:
func (plugin *cniNetworkPlugin) SetUpPod(namespace string, name string, id kubecontainer.ContainerID, annotations, options map[string]string) error {
if err := plugin.checkInitialized(); err != nil {
return err
}
netnsPath, err := plugin.host.GetNetNS(id.ID)
...
if plugin.loNetwork != nil {
if _, err = plugin.addToNetwork(cniTimeoutCtx, plugin.loNetwork, name, namespace, id, netnsPath, annotations, options); err != nil {
return err
}
}
_, err = plugin.addToNetwork(cniTimeoutCtx, plugin.getDefaultNetwork(), name, namespace, id, netnsPath, annotations, options)
return err
}
再来看看 addToNetwork 函数,该函数首先会去构建 pod 的运行时信息,再读取 CNI 插件的网络配置信息,即 /etc/cni/net.d 目录下的配置文件。组装好 plugin 需要的参数后调用 cni 的接口 cniNet.AddNetworkList。源码如下:
func (plugin *cniNetworkPlugin) addToNetwork(ctx context.Context, network *cniNetwork, podName string, podNamespace string, podSandboxID kubecontainer.ContainerID, podNetnsPath string, annotations, options map[string]string) (cnitypes.Result, error) {
rt, err := plugin.buildCNIRuntimeConf(podName, podNamespace, podSandboxID, podNetnsPath, annotations, options)
...
pdesc := podDesc(podNamespace, podName, podSandboxID)
netConf, cniNet := network.NetworkConfig, network.CNIConfig
...
res, err := cniNet.AddNetworkList(ctx, netConf, rt)
...
return res, nil
}
在了解了整个 CNI 的执行流程后,我们模拟一下 CNI 的执行过程。我们用 cnitool 工具,main 插件选择 bridge,ipam 插件选择 host-local,来模拟容器网络配置。
首先将 CNI Plugin 编译成可执行文件,可以执行运行官方仓库中的 build_linux.sh 脚本:
$ mkdir -p $GOPATH/src/github.com/containernetworking/plugins
$ git clone https://github.com/containernetworking/plugins.git $GOPATH/src/github.com/containernetworking/plugins
$ cd $GOPATH/src/github.com/containernetworking/plugins
$ ./build_linux.sh
$ ls
bandwidth dhcp flannel host-local loopback portmap sbr tuning village-ipam vrf
bridge firewall host-device ipvlan macvlan ptp static village vlan
接着创建我们自己的网络配置文件,main 插件选择 bridge,ipam 插件选择 host-local,并指定可用 ip 段。
$ mkdir -p /etc/cni/net.d
$ cat >/etc/cni/net.d/10-hdlsnet.conf <<EOF
{
"cniVersion": "0.2.0",
"name": "hdls-net",
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"subnet": "10.22.0.0/16",
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
}
EOF
$ cat >/etc/cni/net.d/99-loopback.conf <<EOF
{
"cniVersion": "0.2.0",
"name": "lo",
"type": "loopback"
}
EOF
$ ip netns add hdls
最后将 CNI_PATH 指定为上面编译好的插件可执行文件的路径,再运行官方仓库的 cnitool 工具:
$ mkdir -p $GOPATH/src/github.com/containernetworking/cni
$ git clone https://github.com/containernetworking/cni.git $GOPATH/src/github.com/containernetworking/cni
$ export CNI_PATH=$GOPATH/src/github.com/containernetworking/plugins/bin
$ go run cnitool.go add hdls-net /var/run/netns/hdls
\{
"cniVersion": "0.2.0",
"ip4": {
"ip": "10.22.0.2/16",
"gateway": "10.22.0.1",
"routes": [
{
"dst": "0.0.0.0/0"
}
]
},
"dns": {}
}#
结果表面为这个 network namespace hdls-net 分配的 ip 为 10.22.0.2,其实也就是说我们手动创建的容器 ip 为 10.22.0.2。
获得了容器的 ip 后,检验是可以 ping 通的,用 nsenter 命令进入到容器的 namespace 中也可以发现该容器的默认网络设备 eth0 也创建出来了:
$ ping 10.22.0.2
PING 10.22.0.2 (10.22.0.2) 56(84) bytes of data.
64 bytes from 10.22.0.2: icmp_seq=1 ttl=64 time=0.039 ms
64 bytes from 10.22.0.2: icmp_seq=2 ttl=64 time=0.046 ms
64 bytes from 10.22.0.2: icmp_seq=3 ttl=64 time=0.042 ms
64 bytes from 10.22.0.2: icmp_seq=4 ttl=64 time=0.073 ms
^C
--- 10.22.0.2 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3000ms
rtt min/avg/max/mdev = 0.039/0.050/0.073/0.013 ms
$ nsenter --net=/var/run/netns/hdls bash
[root@node-3 ~]# ip l
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
3: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether be:6b:0c:93:3a:75 brd ff:ff:ff:ff:ff:ff link-netnsid 0
[root@node-3 ~]#
最后我们再来检查一下宿主机的网络设备,发现和容器的 eth0 相对应的 veth 设备对也创建出来了:
$ ip l
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: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 00:0c:29:9a:04:8d 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:22:86:98:d9 brd ff:ff:ff:ff:ff:ff
4: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 76:32:56:61:e4:f5 brd ff:ff:ff:ff:ff:ff
5: veth3e674876@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni0 state UP mode DEFAULT group default
link/ether 62:b3:06:15:f9:39 brd ff:ff:ff:ff:ff:ff link-netnsid 0
之所以选择 Village Net 作为插件的名字,是希望通过 macvlan 实现一个基于二层的网络插件。而对于一个二层网络来说,内部通讯像极了一个小村庄,通讯基本靠吼(arp),当然还有村通网的含义,虽然简陋,但足够好用。
选择 macvlan 实现网络插件的原因在于,对于一个「家庭级 Kubernetes 集群」来说,节点的数目并不多,但是服务并不少,只能通过端口映射(nodeport)对服务进行区分,而因为所有的机器本来就在同一个交换机上,IP 相对富裕,macvlan/ipvlan 都是简单且好实现的方案。考虑到基于 mac 可以利用 dhcp 服务,甚至可以基于 mac 对 pod 的 ip 进行固定,因此便尝试使用 macvlan 实现网络插件。
但是 macvlan 在跨 net namespace 中存在不少问题,比如存在独立 net namespace 时,流量会跨过 host 的协议栈,导致了基于 iptables/ipvs 的 cluster ip 无法正常工作。
当然,也正是相同原因,只是使用 macvlan 时,宿主机和容器的网络是不互通的,不过可以创建额外的 macvlan bridge 解决。
为了解决 cluster ip 无法正常工作的问题,便舍弃了只是用 macvlan 的念头,使用多网络接口进行组网。
每个 Pod 都有两个网络接口,一个是基于 bridge 的 eth0,并作为默认网关,同时,在宿主机上会添加相关路由以确保可以跨节点通信。第二个接口是 bridge 模式的 macvlan,并为这个设备分配宿主机网段的 ip。
和前面提到的 CNI 的工作流程一致,village net 也是分为 main 插件和 ipam 插件。
ipam 的主要任务是基于配置从两个网段中个分配出一个可用 IP,main 插件是基于两个网段的 IP 创建出 bridge、veth、macvlan 设备,并进行配置。
Village Net 的实现还是比较简单,甚至还需要部分手动操作,比如 bridge 的路由部分。但是功能上基本达到预期,而且对 cni 的坑完整的梳理了一遍。cni 本身并不复杂,但是有很多细节是在一开始做的时候没有考虑到的,甚至最后只是通过了若干 workaround 绕过。如果后面还有时间和精力放在网络插件上,再考虑如何优化。<( ̄▽ ̄)/
]]>简单来说,容器 runtime 为容器提供 network namespace,网络插件负责将 network interface 插入该 network namespace 中并且在宿主机做一些必要的配置,最后对 namespace 中的 interface 进行 IP 和路由的配置。
所以网络插件的主要工作就在于为容器提供网络环境,包括为 pod 设置 ip 地址、配置路由保证集群内网络的通畅。目前比较流行的网络插件是 Flannel 和 Calico。
Flannel 主要提供的是集群内的 Overlay 网络,支持三种后端实现,分别是:UDP 模式、VXLAN 模式、host-gw 模式。
UDP 模式,是 Flannel 项目最早支持的一种方式,却也是性能最差的一种方式。这种模式提供的是一个三层的 Overlay 网络,即:它首先对发出端的 IP 包进行 UDP 封装,然后在接收端进行解封装拿到原始的 IP 包,进而把这个 IP 包转发给目标容器。工作原理如下图所示。
node1 上的 pod1 请求 node2 上的 pod2 时,流量的走向如下:
flannel0 是一个 TUN 设备(Tunnel 设备)。在 Linux 中,TUN 设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN 设备的功能:在操作系统内核和用户应用程序之间传递 IP 包。
可以看到,这种模式性能差的原因在于,整个包的 UDP 封装过程是 flanneld 程序做的,也就是用户态,而这就带来了一次内核态向用户态的转换,以及一次用户态向内核态的转换。在上下文切换和用户态操作的代价其实是比较高的,而 UDP 模式因为封包拆包带来了额外的性能消耗。
VXLAN,即 Virtual Extensible LAN(虚拟可扩展局域网),是 Linux 内核本身就支持的一种网络虚似化技术。通过利用 Linux 内核的这种特性,也可以实现在内核态的封装和解封装的能力,从而构建出覆盖网络。其工作原理如下图所示:
VXLAN 模式的 flannel 会在节点上创建一个叫 flannel.1 的 VTEP (VXLAN Tunnel End Point,虚拟隧道端点) 设备,跟 UDP 模式一样,该设备将二层数据帧封装在 UDP 包里,再转发出去,而与 UDP 模式不一样的是,整个封装的过程是在内核态完成的。
node1 上的 pod1 请求 node2 上的 pod2 时,流量的走向如下:
在 node1 上部署一个 nginx pod1,node2 上部署一个 nginx pod2。然后在 pod1 的容器中 curl pod2 容器的 80 端口。
集群网络环境如下:
node1 网卡 ens33:192.168.50.10
pod1 veth:10.244.0.7
node1 cni0:10.244.0.1/24
node1 flannel.1:10.244.0.0/32
node2 网卡 ens33:192.168.50.11
pod2 veth:10.244.1.9
node2 cni0:10.244.1.1/24
node2 flannel.1:10.244.1.0/32
node1 上的路由信息如下:
➜ ~ ip route
default via 192.168.50.1 dev ens33
10.244.0.0/24 dev cni0 proto kernel scope link src 10.244.0.1
10.244.1.0/24 via 10.244.1.0 dev flannel.1 onlink
10.244.2.0/24 via 10.244.2.0 dev flannel.1 onlink
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
192.168.50.0/24 dev ens33 proto kernel scope link src 192.168.50.10 metric 100
node1 的网卡 ens33 的抓包情况:
只能看到源 ip 为 node1 ip、目的 ip 为 node2 ip 的 UDP 包。由于 flannel.1 进行了一层 UDP 封包,这里我们在 Wireshark 中设置一下将 UDP 包解析为 VxLAN 格式(端口为 8472),设置过程为 Analyze->Decode As:
然后再来看一下 node1 网卡上收到的包:
可以看到源 ip 为 pod1 ip、目的 ip 为 pod2 ip,并且该 IP 包被封装在 UDP 包中。
最后一种 host-gw 模式是一种纯三层网络方案。其工作原理为将每个 Flannel 子网的“下一跳”设置成了该子网对应的宿主机的 IP 地址,这台主机会充当这条容器通信路径里的“网关”。这样 IP 包就能通过二层网络达到目的主机,而正是因为这一点,host-gw 模式要求集群宿主机之间的网络是二层连通的,如下图所示。
宿主机上的路由信息是 flanneld 设置的,因为 flannel 子网和主机的信息保存在 etcd 中,所以 flanneld 只需要 watch 这些数据的变化,实时更新路由表即可。在这种模式下,容器通信的过程就免除了额外的封包和解包带来的性能损耗。
node1 上的 pod1 请求 node2 上的 pod2 时,流量的走向如下:
Calico 没有使用 CNI 的网桥模式,而是将节点当成边界路由器,组成了一个全连通的网络,通过 BGP 协议交换路由。所以,Calico 的 CNI 插件还需要在宿主机上为每个容器的 Veth Pair 设备配置一条路由规则,用于接收传入的 IP 包。
Calico 的组件:
三个组件都是通过一个 DaemonSet 安装的。CNI 插件是通过 initContainer 安装的;而 Felix 和 BIRD 是同一个 pod 的两个 container。
Calico 采用的 BGP,就是在大规模网络中实现节点路由信息共享的一种协议。全称是 Border Gateway Protocol,即:边界网关协议。它是一个 Linux 内核原生就支持的、专门用在大规模数据中心里维护不同的 “自治系统” 之间路由信息的、无中心的路由协议。
由于没有使用 CNI 的网桥,Calico 的 CNI 插件需要为每个容器设置一个 Veth Pair 设备,然后把其中的一端放置在宿主机上,还需要在宿主机上为每个容器的 Veth Pair 设备配置一条路由规则,用于接收传入的 IP 包。如下图所示:
可以使用 calicoctl 查看 node1 的节点连接情况:
~ calicoctl get no
NAME
node1
node2
node3
~ calicoctl node status
Calico process is running.
IPv4 BGP status
+---------------+-------------------+-------+------------+-------------+
| PEER ADDRESS | PEER TYPE | STATE | SINCE | INFO |
+---------------+-------------------+-------+------------+-------------+
| 192.168.50.11 | node-to-node mesh | up | 2020-09-28 | Established |
| 192.168.50.12 | node-to-node mesh | up | 2020-09-28 | Established |
+---------------+-------------------+-------+------------+-------------+
IPv6 BGP status
No IPv6 peers found.
可以看到整个 calico 集群上有 3 个节点,node1 和另外两个节点处于连接状态,模式为 “Node-to-Node Mesh”。再看下 node1 上的路由信息如下:
~ ip route
default via 192.168.50.1 dev ens33 proto static metric 100
10.244.104.0/26 via 192.168.50.11 dev ens33 proto bird
10.244.135.0/26 via 192.168.50.12 dev ens33 proto bird
10.244.166.128 dev cali717821d73f3 scope link
blackhole 10.244.166.128/26 proto bird
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
192.168.50.0/24 dev ens33 proto kernel scope link src 192.168.50.10 metric 100
其中,第 2 条的路由规则表明 10.244.104.0/26 网段的数据包通过 bird 协议由 ens33 设备发往网关 192.168.50.11。这也就定义了目的 ip 为 node2 上 pod 请求的走向。第 3 条路由规则与之类似。
与上面一样,从 node1 上的 pod1 发送一个 http 请求到 node2 上的 pod2。
集群网络环境如下:
node1 网卡 ens33:192.168.50.10
pod1 ip:10.244.166.128
node2 网卡 ens33:192.168.50.11
pod2 ip:10.244.104.1
node1 的网卡 ens33 的抓包情况:
IPIP 模式为了解决两个 node 不在一个子网的问题。只要将名为 calico-node 的 daemonset 的环境变量 CALICO_IPV4POOL_IPIP 设置为 "Always" 即可。如下:
- name: CALICO_IPV4POOL_IPIP
value: "Off"
IPIP 模式的 calico 使用了 tunl0 设备,这是一个 IP 隧道设备。IP 包进入 tunl0 后,内核会将原始 IP 包直接封装在宿主机的 IP 包中;封装后的 IP 包的目的地址为下一跳地址,即 node2 的 IP 地址。由于宿主机之间已经使用路由器配置了三层转发,所以这个 IP 包在离开 node 1 之后,就可以经过路由器,最终发送到 node 2 上。如下图所示。
由于 IPIP 模式的 Calico 额外多出了封包和拆包的过程,集群的网络性能受到了影响,所以在集群的二层网络通的情况下,建议不要使用 IPIP 模式。
看下 node1 上的路由信息:
~ ip route
default via 192.168.50.1 dev ens33 proto static metric 100
10.244.104.0/26 via 192.168.50.11 dev tunl0 proto bird onlink
10.244.135.0/26 via 192.168.50.12 dev tunl0 proto bird onlink
blackhole 10.244.166.128/26 proto bird
10.244.166.129 dev calif3c799362a5 scope link
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
192.168.50.0/24 dev ens33 proto kernel scope link src 192.168.50.10 metric 100
可以看到,与之前不一样的是,目的 IP 为 node2 上的 Pod 的数据包是经由 tunl0 发送到网关 192.168.50.11。
从 node1 上的 pod1 发送一个 http 请求到 node2 上的 pod2。
集群网络环境如下:
node1 网卡 ens33:192.168.50.10
node1 tunl0:10.244.166.128/32
pod1 ip:10.244.166.129
node2 网卡 ens33:192.168.50.11
pod2 ip:10.244.104.2
node2 tunl0:10.244.104.0/32
tunl0 设备的抓包情况:
node1 网卡 ens33 的抓包情况:
可以看到,IP 包在 tunl0 设备中被封装进了另一个 IP 包,其目的 IP 为 node2 的 IP,源 IP 为 node1 的 IP。
Kubernetes 的集群网络插件实现方案有很多种,本文主要分析了社区比较常见的两种 Flannel 和 Calico 的工作原理,针对集群内不同节点的 pod 间通信的场景,抓包分析了网络包的走向。
Flannel 主要提供了 Overlay 的网络方案,UDP 模式由于其封包拆包的过程涉及了多次上下文的切换,导致性能很差,逐渐被社区抛弃;VXLAN 模式的封包拆包过程均在内核态,性能要比 UDP 好很多,也是最经常使用的模式;host-gw 模式不涉及封包拆包,所以性能相对较高,但要求节点间二层互通。
Calico 主要采用了 BGP 协议交换路由,没有采用 cni0 网桥,当二层网络不通的时候,可以采用 IPIP 模式,但由于涉及到封包拆包的过程,性能相对较弱,与 Flannel 的 VXLAN 模式相当。
]]>Kubernetes 对每个 Pod 都设有 QoS 类型,通过这个 QoS 类型来对 Pod 进行服务质量管理。一共分为以下三类,优先级依次递减:
当机器资源不足时,kubelet 会根据 Pod 的 QoS 等级进行不公平对待。对于可抢占资源,比如 CPU,资源紧俏时,会按照请求的比例分配时间片,而如果达到 Pod 的 CPU 资源 limit 上限,CPU 会减速;而对于不可抢占资源,比如内存和磁盘,资源紧俏时,会按照 QoS 等级驱逐或者 OOMKill pod。
Cgroups 是 Control Groups 的缩写,是 Linux 内核提供的一种可以限制、记录、隔离进程组 (process groups) 所使用的物理资源 (如 cpu、memory、i/o 等等) 的机制。
cgroups 结构体就用来表示一个 control groups 对某一个或者某几个 cgroups 子系统的资源限制。通常可以组织成一颗树,每一棵 cgroups 结构体组成的树称之为一个 cgroups 层级结构。cgroups 层级结构可以 attach 一个或者几个 cgroups 子系统,当前层级结构可以对其 attach 的 cgroups 子系统进行资源的限制;每一个 cgroups 子系统只能被 attach 到一个 cpu 层级结构中。
其中,cpu 子系统限制进程对 CPU 的访问,每个参数独立存在于 cgroups 虚拟文件系统的伪文件中,参数解释如下:
key | desc |
---|---|
cpu.shares | cgroup对时间的分配。比如cgroup A设置的是 100,cgroup B设置的是 200,那么B中的任务获取cpu的时间,是A中任务的2倍。 |
cpu.cfs_period_us | 完全公平调度器的调整时间配额的周期。 |
cpu.cfs_quota_us | 完全公平调度器的周期当中可以占用的时间。 |
cpu.stat | cpu 运行统计值 |
在 kubernetes 中为了限制容器资源的使用,避免容器之间争抢资源或者容器影响所在的宿主机,kubelet 组件需要使用 cgroups 限制容器资源的使用量,cgroups 目前支持对进程多种资源的限制,而 kubelet 只支持限制 cpu、memory、pids、hugetlb 几种资源。
kubelet 启动后,会解析节点上的 root cgroups,然后在其下面创建一个叫做 kubepods 的子 cgroups。在 kubepods 下面创建三层 cgroups 文件树:
先来看看 kubelet 如何解析根组 Cgroups。
kubelet 管理 pod QoS 等级是通过组件 qosContainerManager,该组件是组件 containerManager 的成员,在构造 containerManager 的时候构造 qosContainerManager,看下代码:
func NewContainerManager(mountUtil mount.Interface, cadvisorInterface cadvisor.Interface, nodeConfig NodeConfig, failSwapOn bool, devicePluginEnabled bool, recorder record.EventRecorder) (ContainerManager, error) {
...
cgroupRoot := ParseCgroupfsToCgroupName(nodeConfig.CgroupRoot)
cgroupManager := NewCgroupManager(subsystems, nodeConfig.CgroupDriver)
if nodeConfig.CgroupsPerQOS {
if nodeConfig.CgroupRoot == "" {
return nil, fmt.Errorf("invalid configuration: cgroups-per-qos was specified and cgroup-root was not specified. To enable the QoS cgroup hierarchy you need to specify a valid cgroup-root")
}
if !cgroupManager.Exists(cgroupRoot) {
return nil, fmt.Errorf("invalid configuration: cgroup-root %q doesn't exist", cgroupRoot)
}
klog.Infof("container manager verified user specified cgroup-root exists: %v", cgroupRoot)
cgroupRoot = NewCgroupName(cgroupRoot, defaultNodeAllocatableCgroupName)
}
klog.Infof("Creating Container Manager object based on Node Config: %+v", nodeConfig)
qosContainerManager, err := NewQOSContainerManager(subsystems, cgroupRoot, nodeConfig, cgroupManager)
if err != nil {
return nil, err
}
...
}
在构造 qosContainerManager 之前,会先将 kubelet 管理的 pod cgroup 根目录组建好,传入构造函数中,即 <rootCgroup>/kubepods
。
构造完毕后,containerManager 启动的时候,会初始化并启动所有的子组件,其中就包括 qosContainerManager,启动 qosContainerManager 的方式是调用 setupNode 函数:
func (cm *containerManagerImpl) Start(node *v1.Node,
activePods ActivePodsFunc,
sourcesReady config.SourcesReady,
podStatusProvider status.PodStatusProvider,
runtimeService internalapi.RuntimeService) error {
...
// cache the node Info including resource capacity and
// allocatable of the node
cm.nodeInfo = node
if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.LocalStorageCapacityIsolation) {
rootfs, err := cm.cadvisorInterface.RootFsInfo()
if err != nil {
return fmt.Errorf("failed to get rootfs info: %v", err)
}
for rName, rCap := range cadvisor.EphemeralStorageCapacityFromFsInfo(rootfs) {
cm.capacity[rName] = rCap
}
}
// Ensure that node allocatable configuration is valid.
if err := cm.validateNodeAllocatable(); err != nil {
return err
}
// Setup the node
if err := cm.setupNode(activePods); err != nil {
return err
}
...
return nil
}
在 setupNode 函数中,会调用 createNodeAllocatableCgroups 函数来解析节点的根 cgroups,然后启动 qosContainerManager。
func (cm *containerManagerImpl) setupNode(activePods ActivePodsFunc) error {
...
// Setup top level qos containers only if CgroupsPerQOS flag is specified as true
if cm.NodeConfig.CgroupsPerQOS {
if err := cm.createNodeAllocatableCgroups(); err != nil {
return err
}
err = cm.qosContainerManager.Start(cm.getNodeAllocatableAbsolute, activePods)
if err != nil {
return fmt.Errorf("failed to initialize top level QOS containers: %v", err)
}
}
...
}
最后我们仔细看下 createNodeAllocatableCgroups 这个函数,探究一下 kubelet 如何解析节点的根 cgroups:
func (cm *containerManagerImpl) createNodeAllocatableCgroups() error {
nodeAllocatable := cm.internalCapacity
// Use Node Allocatable limits instead of capacity if the user requested enforcing node allocatable.
nc := cm.NodeConfig.NodeAllocatableConfig
if cm.CgroupsPerQOS && nc.EnforceNodeAllocatable.Has(kubetypes.NodeAllocatableEnforcementKey) {
nodeAllocatable = cm.getNodeAllocatableInternalAbsolute()
}
cgroupConfig := &CgroupConfig{
Name: cm.cgroupRoot,
// The default limits for cpu shares can be very low which can lead to CPU starvation for pods.
ResourceParameters: getCgroupConfig(nodeAllocatable),
}
if cm.cgroupManager.Exists(cgroupConfig.Name) {
return nil
}
if err := cm.cgroupManager.Create(cgroupConfig); err != nil {
klog.Errorf("Failed to create %q cgroup", cm.cgroupRoot)
return err
}
return nil
}
func getCgroupConfig(rl v1.ResourceList) *ResourceConfig {
// TODO(vishh): Set CPU Quota if necessary.
if rl == nil {
return nil
}
var rc ResourceConfig
if q, exists := rl[v1.ResourceMemory]; exists {
// Memory is defined in bytes.
val := q.Value()
rc.Memory = &val
}
if q, exists := rl[v1.ResourceCPU]; exists {
// CPU is defined in milli-cores.
val := MilliCPUToShares(q.MilliValue())
rc.CpuShares = &val
}
if q, exists := rl[pidlimit.PIDs]; exists {
val := q.Value()
rc.PidsLimit = &val
}
rc.HugePageLimit = HugePageLimits(rl)
return &rc
}
可以看到,createNodeAllocatableCgroups 函数本质就是根据 cm.internalCapacity 所获得的节点可用的 cpu、内存、Pid、HugePageLimits 等信息进行根组 Cgroup 的设置,这里面设置了 memory limit、cpu share、Pid limit 和 hugePageLimit 四个资源限制。根组 cgroup 的 name 就是构建 qosContainerManager 之前的时候传进来的 kubepods 目录。
其中 cm.internalCapacity 是在 containerManager 初始化的时候通过 CAdvisor 获取的;当用户使用 Node Allocatable 特性时,根据用户填入的参数来设置节点预留资源。
根组 Cgroups 设置好之后,再来看看 qosContainerManager 的启动函数。
如下图所示,首先会获取根组 cgroup (路径为
然后创建 QoS 级别的 cgroup,其中 Guaranteed 类型 cgroup 路径就是根组;Burstable 类型 cgroup 路径为
最后,起一个协程,定时执行 UpdateCgroups 函数,根据每个 QoS 类型的 pod 情况同步对应的 cgroup。
源码如下:
func (m *qosContainerManagerImpl) Start(getNodeAllocatable func() v1.ResourceList, activePods ActivePodsFunc) error {
cm := m.cgroupManager
rootContainer := m.cgroupRoot
if !cm.Exists(rootContainer) {
return fmt.Errorf("root container %v doesn't exist", rootContainer)
}
// Top level for Qos containers are created only for Burstable
// and Best Effort classes
qosClasses := map[v1.PodQOSClass]CgroupName{
v1.PodQOSBurstable: NewCgroupName(rootContainer, strings.ToLower(string(v1.PodQOSBurstable))),
v1.PodQOSBestEffort: NewCgroupName(rootContainer, strings.ToLower(string(v1.PodQOSBestEffort))),
}
// Create containers for both qos classes
for qosClass, containerName := range qosClasses {
resourceParameters := &ResourceConfig{}
// the BestEffort QoS class has a statically configured minShares value
if qosClass == v1.PodQOSBestEffort {
minShares := uint64(MinShares)
resourceParameters.CpuShares = &minShares
}
// containerConfig object stores the cgroup specifications
containerConfig := &CgroupConfig{
Name: containerName,
ResourceParameters: resourceParameters,
}
// for each enumerated huge page size, the qos tiers are unbounded
m.setHugePagesUnbounded(containerConfig)
// check if it exists
if !cm.Exists(containerName) {
if err := cm.Create(containerConfig); err != nil {
return fmt.Errorf("failed to create top level %v QOS cgroup : %v", qosClass, err)
}
} else {
// to ensure we actually have the right state, we update the config on startup
if err := cm.Update(containerConfig); err != nil {
return fmt.Errorf("failed to update top level %v QOS cgroup : %v", qosClass, err)
}
}
}
// Store the top level qos container names
m.qosContainersInfo = QOSContainersInfo{
Guaranteed: rootContainer,
Burstable: qosClasses[v1.PodQOSBurstable],
BestEffort: qosClasses[v1.PodQOSBestEffort],
}
m.getNodeAllocatable = getNodeAllocatable
m.activePods = activePods
// update qos cgroup tiers on startup and in periodic intervals
// to ensure desired state is in sync with actual state.
go wait.Until(func() {
err := m.UpdateCgroups()
if err != nil {
klog.Warningf("[ContainerManager] Failed to reserve QoS requests: %v", err)
}
}, periodicQOSCgroupUpdateInterval, wait.NeverStop)
return nil
}
再看下 UpdateCgroups 的内容。
首先,对于 BestEffort 和 Burstable 的两类 qos,重新计算 cpushare 值。规则是 BestEffort 类型的 cpushare 设置为 2;Burstable 类型的 cpushare 为其下所有 pod 的 cpushare 总和。
其次,如果开启了内存大页,则设置 huge page 相关 cgroup。
然后,如果开启了 qos reserve,则针对 Burstable 和 BestEffort 两类 QoS,计算memory limit。规则是,Burstable 的 memory limit 为节点总量减去 Guaranteed pod 的 memory limit 总和;BestEffort 的 memory limit 为 Burstable 的 memory limit 减去 Burstable pod 的 memory limit 总和。计算公式如下:
burstableLimit := allocatable - (qosMemoryRequests[v1.PodQOSGuaranteed] * percentReserve / 100)
bestEffortLimit := burstableLimit - (qosMemoryRequests[v1.PodQOSBurstable] * percentReserve / 100)
其中,qos-reserved 这个参数的使用场景为:在某些场景下我们希望能够尽可能保证 Guaranteed pod 这种高级别 pod 的资源,尤其是不可抢占资源(如内存),不要被低 级别的 pod 抢占,此时就可以使用 --qos-reserved 为高级别 pod 进行预留资源。所以 Burstable 的 cgroup 需要为比他等级高的 Guaranteed pod 的内存资源做预留,而 BestEffort 需要为 Burstable 和 Guaranteed 都要预留内存资源。
最后,将新的数据更新写入 QoS 级别的 cgroup 数据。整体代码如下:
func (m *qosContainerManagerImpl) UpdateCgroups() error {
m.Lock()
defer m.Unlock()
qosConfigs := map[v1.PodQOSClass]*CgroupConfig{
v1.PodQOSBurstable: {
Name: m.qosContainersInfo.Burstable,
ResourceParameters: &ResourceConfig{},
},
v1.PodQOSBestEffort: {
Name: m.qosContainersInfo.BestEffort,
ResourceParameters: &ResourceConfig{},
},
}
// update the qos level cgroup settings for cpu shares
if err := m.setCPUCgroupConfig(qosConfigs); err != nil {
return err
}
// update the qos level cgroup settings for huge pages (ensure they remain unbounded)
if err := m.setHugePagesConfig(qosConfigs); err != nil {
return err
}
if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.QOSReserved) {
for resource, percentReserve := range m.qosReserved {
switch resource {
case v1.ResourceMemory:
m.setMemoryReserve(qosConfigs, percentReserve)
}
}
updateSuccess := true
for _, config := range qosConfigs {
err := m.cgroupManager.Update(config)
if err != nil {
updateSuccess = false
}
}
...
}
for _, config := range qosConfigs {
err := m.cgroupManager.Update(config)
if err != nil {
klog.Errorf("[ContainerManager]: Failed to update QoS cgroup configuration")
return err
}
}
...
return nil
}
在 cgroupManager.Update() 中,根据上面计算所得的所有 cgroup,调用 runc 进行 cgroup 写入,支持的 cgroupfs 包括了 MemoryGroup、CpuGroup、PidsGroup,均在包 "github.com/opencontainers/runc/libcontainer/cgroups/fs"
中,源码如下:
func setSupportedSubsystemsV1(cgroupConfig *libcontainerconfigs.Cgroup) error {
for sys, required := range getSupportedSubsystems() {
if _, ok := cgroupConfig.Paths[sys.Name()]; !ok {
if required {
return fmt.Errorf("failed to find subsystem mount for required subsystem: %v", sys.Name())
}
...
continue
}
if err := sys.Set(cgroupConfig.Paths[sys.Name()], cgroupConfig); err != nil {
return fmt.Errorf("failed to set config for supported subsystems : %v", err)
}
}
return nil
}
func getSupportedSubsystems() map[subsystem]bool {
supportedSubsystems := map[subsystem]bool{
&cgroupfs.MemoryGroup{}: true,
&cgroupfs.CpuGroup{}: true,
&cgroupfs.PidsGroup{}: false,
}
// not all hosts support hugetlb cgroup, and in the absent of hugetlb, we will fail silently by reporting no capacity.
supportedSubsystems[&cgroupfs.HugetlbGroup{}] = false
if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.SupportPodPidsLimit) || utilfeature.DefaultFeatureGate.Enabled(kubefeatures.SupportNodePidsLimit) {
supportedSubsystems[&cgroupfs.PidsGroup{}] = true
}
return supportedSubsystems
}
拿 cpuGroup 举例,本质就是写入 cpu.shares、cpu.cfs_period_us、cpu.cfs_quota_us 文件。源码如下:
func (s *CpuGroup) Set(path string, cgroup *configs.Cgroup) error {
if cgroup.Resources.CpuShares != 0 {
shares := cgroup.Resources.CpuShares
if err := fscommon.WriteFile(path, "cpu.shares", strconv.FormatUint(shares, 10)); err != nil {
return err
}
...
}
if cgroup.Resources.CpuPeriod != 0 {
if err := fscommon.WriteFile(path, "cpu.cfs_period_us", strconv.FormatUint(cgroup.Resources.CpuPeriod, 10)); err != nil {
return err
}
}
if cgroup.Resources.CpuQuota != 0 {
if err := fscommon.WriteFile(path, "cpu.cfs_quota_us", strconv.FormatInt(cgroup.Resources.CpuQuota, 10)); err != nil {
return err
}
}
return s.SetRtSched(path, cgroup)
}
下面来看下 pod 级别的 cgroup 创建过程。
在创建 pod 的时候,syncPod 函数会调用 podContainerManagerImpl 结构体里面的 EnsureExists 方法,该方法会检测 pod 是否已存在,若不存在,会调用 ResourceConfigForPod 函数计算出该 pod 的 resource,再创建出其对应的 cgroup。源码如下:
func (m *podContainerManagerImpl) EnsureExists(pod *v1.Pod) error {
podContainerName, _ := m.GetPodContainerName(pod)
// check if container already exist
alreadyExists := m.Exists(pod)
if !alreadyExists {
// Create the pod container
containerConfig := &CgroupConfig{
Name: podContainerName,
ResourceParameters: ResourceConfigForPod(pod, m.enforceCPULimits, m.cpuCFSQuotaPeriod),
}
if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.SupportPodPidsLimit) && m.podPidsLimit > 0 {
containerConfig.ResourceParameters.PidsLimit = &m.podPidsLimit
}
if err := m.cgroupManager.Create(containerConfig); err != nil {
return fmt.Errorf("failed to create container for %v : %v", podContainerName, err)
}
}
if err := m.applyLimits(pod); err != nil {
return fmt.Errorf("failed to apply resource limits on container for %v : %v", podContainerName, err)
}
return nil
}
我们接着来看看 kubelet 如何计算 pod 的 resource。
首先,将 pod 的所有 container 中的 cpu request/limits 和 memory limit 统计出来,cpu request/limits 转换成 cpuShares、cpuQuota;计算出大页 limit;再根据每个 container 的 resource 声明,判断是哪种 QoS;最后再统计 pod 的 cgroup:
源码如下:
func ResourceConfigForPod(pod *v1.Pod, enforceCPULimits bool, cpuPeriod uint64) *ResourceConfig {
// sum requests and limits.
reqs, limits := resource.PodRequestsAndLimits(pod)
cpuRequests := int64(0)
cpuLimits := int64(0)
memoryLimits := int64(0)
if request, found := reqs[v1.ResourceCPU]; found {
cpuRequests = request.MilliValue()
}
if limit, found := limits[v1.ResourceCPU]; found {
cpuLimits = limit.MilliValue()
}
if limit, found := limits[v1.ResourceMemory]; found {
memoryLimits = limit.Value()
}
// convert to CFS values
cpuShares := MilliCPUToShares(cpuRequests)
cpuQuota := MilliCPUToQuota(cpuLimits, int64(cpuPeriod))
// track if limits were applied for each resource.
memoryLimitsDeclared := true
cpuLimitsDeclared := true
// map hugepage pagesize (bytes) to limits (bytes)
hugePageLimits := map[int64]int64{}
for _, container := range pod.Spec.Containers {
if container.Resources.Limits.Cpu().IsZero() {
cpuLimitsDeclared = false
}
if container.Resources.Limits.Memory().IsZero() {
memoryLimitsDeclared = false
}
containerHugePageLimits := HugePageLimits(container.Resources.Requests)
for k, v := range containerHugePageLimits {
if value, exists := hugePageLimits[k]; exists {
hugePageLimits[k] = value + v
} else {
hugePageLimits[k] = v
}
}
}
// quota is not capped when cfs quota is disabled
if !enforceCPULimits {
cpuQuota = int64(-1)
}
// determine the qos class
qosClass := v1qos.GetPodQOS(pod)
// build the result
result := &ResourceConfig{}
if qosClass == v1.PodQOSGuaranteed {
result.CpuShares = &cpuShares
result.CpuQuota = &cpuQuota
result.CpuPeriod = &cpuPeriod
result.Memory = &memoryLimits
} else if qosClass == v1.PodQOSBurstable {
result.CpuShares = &cpuShares
if cpuLimitsDeclared {
result.CpuQuota = &cpuQuota
result.CpuPeriod = &cpuPeriod
}
if memoryLimitsDeclared {
result.Memory = &memoryLimits
}
} else {
shares := uint64(MinShares)
result.CpuShares = &shares
}
result.HugePageLimit = hugePageLimits
return result
}
最后再来看看 container 级别的 cgroup。
container 在创建的时候,会先获取其父 cgroup,即 pod 级别的 cgroup,存放在 podSandboxConfig 中,然后会调用 generateContainerConfig 生成容器级别的各类 cgroup 信息,源码如下:
func (m *kubeGenericRuntimeManager) startContainer(podSandboxID string, podSandboxConfig *runtimeapi.PodSandboxConfig, spec *startSpec, pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, podIP string, podIPs []string) (string, error) {
container := spec.container
...
containerConfig, cleanupAction, err := m.generateContainerConfig(container, pod, restartCount, podIP, imageRef, podIPs, target)
if cleanupAction != nil {
defer cleanupAction()
}
...
containerID, err := m.runtimeService.CreateContainer(podSandboxID, containerConfig, podSandboxConfig)
if err != nil {
s, _ := grpcstatus.FromError(err)
m.recordContainerEvent(pod, container, containerID, v1.EventTypeWarning, events.FailedToCreateContainer, "Error: %v", s.Message())
return s.Message(), ErrCreateContainer
}
...
return "", nil
}
再来看下生成 containerConfig 的代码,其实这部分很简单,就是通过 container 中的 resources 生成容器的所有 cgroup 信息,源码如下:
func (m *kubeGenericRuntimeManager) generateContainerConfig(container *v1.Container, pod *v1.Pod, restartCount int, podIP, imageRef string, podIPs []string, nsTarget *kubecontainer.ContainerID) (*runtimeapi.ContainerConfig, func(), error) {
opts, cleanupAction, err := m.runtimeHelper.GenerateRunContainerOptions(pod, container, podIP, podIPs)
...
config := &runtimeapi.ContainerConfig{
Metadata: &runtimeapi.ContainerMetadata{
Name: container.Name,
Attempt: restartCountUint32,
},
Image: &runtimeapi.ImageSpec{Image: imageRef},
Command: command,
Args: args,
WorkingDir: container.WorkingDir,
Labels: newContainerLabels(container, pod),
Annotations: newContainerAnnotations(container, pod, restartCount, opts),
Devices: makeDevices(opts),
Mounts: m.makeMounts(opts, container),
LogPath: containerLogsPath,
Stdin: container.Stdin,
StdinOnce: container.StdinOnce,
Tty: container.TTY,
}
// set platform specific configurations.
if err := m.applyPlatformSpecificContainerConfig(config, container, pod, uid, username, nsTarget); err != nil {
return nil, cleanupAction, err
}
...
return config, cleanupAction, nil
}
func (m *kubeGenericRuntimeManager) applyPlatformSpecificContainerConfig(config *runtimeapi.ContainerConfig, container *v1.Container, pod *v1.Pod, uid *int64, username string, nsTarget *kubecontainer.ContainerID) error {
config.Linux = m.generateLinuxContainerConfig(container, pod, uid, username, nsTarget)
return nil
}
func (m *kubeGenericRuntimeManager) generateLinuxContainerConfig(container *v1.Container, pod *v1.Pod, uid *int64, username string, nsTarget *kubecontainer.ContainerID) *runtimeapi.LinuxContainerConfig {
lc := &runtimeapi.LinuxContainerConfig{
Resources: &runtimeapi.LinuxContainerResources{},
SecurityContext: m.determineEffectiveSecurityContext(pod, container, uid, username),
}
...
// set linux container resources
var cpuShares int64
cpuRequest := container.Resources.Requests.Cpu()
cpuLimit := container.Resources.Limits.Cpu()
memoryLimit := container.Resources.Limits.Memory().Value()
oomScoreAdj := int64(qos.GetContainerOOMScoreAdjust(pod, container, int64(m.machineInfo.MemoryCapacity)))
if cpuRequest.IsZero() && !cpuLimit.IsZero() {
cpuShares = milliCPUToShares(cpuLimit.MilliValue())
} else {
// if cpuRequest.Amount is nil, then milliCPUToShares will return the minimal number
// of CPU shares.
cpuShares = milliCPUToShares(cpuRequest.MilliValue())
}
lc.Resources.CpuShares = cpuShares
if memoryLimit != 0 {
lc.Resources.MemoryLimitInBytes = memoryLimit
}
lc.Resources.OomScoreAdj = oomScoreAdj
if m.cpuCFSQuota {
cpuPeriod := int64(quotaPeriod)
if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.CPUCFSQuotaPeriod) {
cpuPeriod = int64(m.cpuCFSQuotaPeriod.Duration / time.Microsecond)
}
cpuQuota := milliCPUToQuota(cpuLimit.MilliValue(), cpuPeriod)
lc.Resources.CpuQuota = cpuQuota
lc.Resources.CpuPeriod = cpuPeriod
}
lc.Resources.HugepageLimits = GetHugepageLimitsFromResources(container.Resources)
return lc
}
containerConfig 生成之后,再调用 CRI 的接口创建 container,其中包括 container 级别的 cgroup 创建。
众所周知,当节点内存不足时,会触发系统的 OOM Killer,而不同 QoS 类型的 pod,kubelet 必须保证 kill 的顺序不同,下面我们就来看看 kubelet 如何保证 pod 被 kill 的顺序。
Linux 有一种 OOM KILLER 的机制,它会在系统内存耗尽的情况下,启用自己算法有选择性的杀掉一些进程。跟系统 OOM 有关的几个文件:
/proc/<pid>/oom_score_adj
:在计算最终的 badness score 时,会在计算结果是中加上 oom_score_adj,这样用户就可以通过该在值来保护某个进程不被杀死或者每次都杀某个进程,其取值范围为 -1000 到 1000。如果将该值设置为-1000,则进程永远不会被杀死,因为此时 badness score 永远返回 0。
/proc/<pid>/oom_adj
:该设置参数的存在是为了和旧版本的内核兼容。其设置范围为-17到15。
/proc/<pid>/oom_score
:这个值是系统综合进程的内存消耗量、CPU时间(utime + stime)、存活时间(uptime - start time)和oom_adj计算出的。
OOM killer 机制主要根据 oom_score 和 oom_score_adj 来决定杀死哪一个进程。
有了 OOM Killer 的基础,我们再来看下 kubelet 是怎么给不同种类的进程设置的 oom_score_adj。从代码中收集如下:
dockerOOMScoreAdj = -999
KubeletOOMScoreAdj int = -999
KubeProxyOOMScoreAdj int = -999
defaultSandboxOOMAdj int = -998
guaranteedOOMScoreAdj int = -998
besteffortOOMScoreAdj int = 1000
dockershim 进程(dockerOOMScoreAdj)、kubelet 进程(KubeletOOMScoreAdj)、kubeproxy 进程(KubeProxyOOMScoreAdj),oom_score_adj 设置成了 -999,表明十分重要,但没有到 -1000。
pod pause 容器(defaultSandboxOOMAdj)以及 Guaranteed 类型的 pod(guaranteedOOMScoreAdj),oom_score_adj 设置成了 -998,表明也很重要,但相对上面那些组件,这些还是可以 kill 的。
而 BestEffort 类型的 pod(besteffortOOMScoreAdj) ,oom_score_adj 设置成了 1000,也就说明了 BestEffort 类型的 Pod 是最容易被 kill 掉的。
最后再来看下 Burstable 类型 的 pod,它的计算公式是:
min{max[1000 - (1000 * memoryRequest) / memoryCapacity, 1000 + guaranteedOOMScoreAdj], 999}
可以看出 Burstable 类型的 pod,得分一定比 Guaranteed 大,比 BestEffort 小,并且 memory 的 request 值越大,则越不容易被 kill。源码如下:
func GetContainerOOMScoreAdjust(pod *v1.Pod, container *v1.Container, memoryCapacity int64) int {
if types.IsCriticalPod(pod) {
// Critical pods should be the last to get killed.
return guaranteedOOMScoreAdj
}
switch v1qos.GetPodQOS(pod) {
case v1.PodQOSGuaranteed:
// Guaranteed containers should be the last to get killed.
return guaranteedOOMScoreAdj
case v1.PodQOSBestEffort:
return besteffortOOMScoreAdj
}
memoryRequest := container.Resources.Requests.Memory().Value()
oomScoreAdjust := 1000 - (1000*memoryRequest)/memoryCapacity
// A guaranteed pod using 100% of memory can have an OOM score of 10. Ensure
// that burstable pods have a higher OOM score adjustment.
if int(oomScoreAdjust) < (1000 + guaranteedOOMScoreAdj) {
return (1000 + guaranteedOOMScoreAdj)
}
// Give burstable pods a higher chance of survival over besteffort pods.
if int(oomScoreAdjust) == besteffortOOMScoreAdj {
return int(oomScoreAdjust - 1)
}
return int(oomScoreAdjust)
}
总的来说,k8s 中提供了三类的 QoS,分别是 Guaranteed,Burstable 和 BestEffort,kubelet 为不同类型的 pod 创建了不同的 cgroups,从而保证不同类型的 pod 获得的资源不同,尽量保证高优先级的服务质量,提升系统稳定性。
]]>本文基于对 Kubernetes v1.19.0-rc.3 的源码阅读
Kubelet 作为 Kubernetes 的四大组件之一,维护了 pod 的整个生命周期,并且是 Kubernetes 创建 pod 的最后一环。本篇文章就来介绍一下 Kubelet 如何创建 pod。
首先看一个 Kubelet 的组件架构图,如下所示:
可以看到 Kubelet 主要分成三层:API 层、syncLoop 层、CRI 及以下。API 层很好理解,就是给外部提供接口的部分;syncLoop 层就是 Kubelet 的核心工作层,Kubelet 的主要工作都是围绕这个 syncLoop 展开,即控制循环,由生产者和消费者通过事件驱动循环运行;CRI 提供了容器和镜像的服务的接口,容器运行时可以以 CRI 插件的形式接入。
我们主要来看下 syncLoop 层的几个重要组件:
PLEG:调用容器运行时的接口来获取本节点 containers/sandboxes 的信息,与本地维护的 pod cache 进行对比,生成对应的 PodLifecycleEvent,然后通过 eventChannel 发送到 Kubelet syncLoop,然后由定时任务来同步 pod,最终达到用户的期望状态。
CAdvisor:集成在 Kubelet 中的容器监控工具,用于收集本节点和容器的监控信息。
PodWorkers:注册了多个 pod handler,分别处理 pod 的不同时间,包括创建、更新、删除等。
oomWatcher:系统 OOM 的监听器,会与 CAdvisor 模块之间建立 SystemOOM,通过 Watch 方式从 CAdvisor 那里收到的 OOM 信号,并产生相关事件。
containerGC:负责清理节点上的无用 container,具体的垃圾回收操作由容器运行时来实现。
imageGC:负责 node 节点的镜像回收,当本地的存放镜像的本地磁盘空间达到某阈值的时候,会触发镜像的回收,删除掉不被 pod 所使用的镜像。
Managers:包含各种 manager,管理与 pod 相关的各类资源。如 imageManager、volumeManager、evictionManager、statusManager、probeManager、runtimeManager、podManager。各 manager 各司其职,在 SyncLoop 中协同工作。
如上面所说,Kubelet 的工作主要是围绕一个 SyncLoop 来展开,借助 go channel,各组件监听 loop 消费事件,或者往里面生产 pod 相关的事件,整个控制循环由事件驱动运行。可以用下图来表示:
比如新建 pod 过程中,当一个 pod 被调度到某个 node 之后,就会触发 Kubelet 在循环控制里注册的 handler,如上图中的 HandlePods 部分。此时,Kubelet 检查 pod 在 Kubelet 内存中的状态,判断这是需要创建的 pod,从而触发 Handler 里的 ADD 事件对应的逻辑处理。
我们来看下这个主循环 SyncLoop:
func (kl *kubelet) syncLoop(updates <-chan kubetypes.PodUpdate, handler SyncHandler) {
klog.Info("Starting kubelet main sync loop.")
// The syncTicker wakes up Kubelet to checks if there are any pod workers
// that need to be sync'd. A one-second period is sufficient because the
// sync interval is defaulted to 10s.
syncTicker := time.NewTicker(time.Second)
defer syncTicker.Stop()
housekeepingTicker := time.NewTicker(housekeepingPeriod)
defer housekeepingTicker.Stop()
plegCh := kl.pleg.Watch()
const (
base = 100 * time.Millisecond
max = 5 * time.Second
factor = 2
)
duration := base
// Responsible for checking limits in resolv.conf
// The limits do not have anything to do with individual pods
// Since this is called in syncLoop, we don't need to call it anywhere else
if kl.dnsConfigurer != nil && kl.dnsConfigurer.ResolverConfig != "" {
kl.dnsConfigurer.CheckLimitsForResolvConf()
}
for {
if err := kl.runtimeState.runtimeErrors(); err != nil {
klog.Errorf("skipping pod synchronization - %v", err)
// exponential backoff
time.Sleep(duration)
duration = time.Duration(math.Min(float64(max), factor*float64(duration)))
continue
}
// reset backoff if we have a success
duration = base
kl.syncLoopMonitor.Store(kl.clock.Now())
if !kl.syncLoopIteration(updates, handler, syncTicker.C, housekeepingTicker.C, plegCh) {
break
}
kl.syncLoopMonitor.Store(kl.clock.Now())
}
}
SyncLoop 起了一个死循环,循环里只调用了 syncLoopIteration 方法。而 syncLoopIteration 会对传入的所有 channel 遍历,发现任何一个管道有消息就交给 handler 去处理。
这些 channel 包括:
syncLoopIteration 的代码:
func (kl *kubelet) syncLoopIteration(configCh <-chan kubetypes.PodUpdate, handler SyncHandler,
syncCh <-chan time.Time, housekeepingCh <-chan time.Time, plegCh <-chan *pleg.PodLifecycleEvent) bool {
select {
case u, open := <-configCh:
// Update from a config source; dispatch it to the right handler
// callback.
if !open {
klog.Errorf("Update channel is closed. Exiting the sync loop.")
return false
}
switch u.Op {
case kubetypes.ADD:
klog.V(2).Infof("SyncLoop (ADD, %q): %q", u.Source, format.Pods(u.Pods))
// After restarting, Kubelet will get all existing pods through
// ADD as if they are new pods. These pods will then go through the
// admission process and *may* be rejected. This can be resolved
// once we have checkpointing.
handler.HandlePodAdditions(u.Pods)
case kubetypes.UPDATE:
klog.V(2).Infof("SyncLoop (UPDATE, %q): %q", u.Source, format.PodsWithDeletionTimestamps(u.Pods))
handler.HandlePodUpdates(u.Pods)
case kubetypes.REMOVE:
klog.V(2).Infof("SyncLoop (REMOVE, %q): %q", u.Source, format.Pods(u.Pods))
handler.HandlePodRemoves(u.Pods)
case kubetypes.RECONCILE:
klog.V(4).Infof("SyncLoop (RECONCILE, %q): %q", u.Source, format.Pods(u.Pods))
handler.HandlePodReconcile(u.Pods)
case kubetypes.DELETE:
klog.V(2).Infof("SyncLoop (DELETE, %q): %q", u.Source, format.Pods(u.Pods))
// DELETE is treated as a UPDATE because of graceful deletion.
handler.HandlePodUpdates(u.Pods)
case kubetypes.SET:
// TODO: Do we want to support this?
klog.Errorf("Kubelet does not support snapshot update")
default:
klog.Errorf("Invalid event type received: %d.", u.Op)
}
kl.sourcesReady.AddSource(u.Source)
case e := <-plegCh:
if e.Type == pleg.ContainerStarted {
// record the most recent time we observed a container start for this pod.
// this lets us selectively invalidate the runtimeCache when processing a delete for this pod
// to make sure we don't miss handling graceful termination for containers we reported as having started.
kl.lastContainerStartedTime.Add(e.ID, time.Now())
}
if isSyncPodWorthy(e) {
// PLEG event for a pod; sync it.
if pod, ok := kl.podManager.GetPodByUID(e.ID); ok {
klog.V(2).Infof("SyncLoop (PLEG): %q, event: %#v", format.Pod(pod), e)
handler.HandlePodSyncs([]*v1.Pod{pod})
} else {
// If the pod no longer exists, ignore the event.
klog.V(4).Infof("SyncLoop (PLEG): ignore irrelevant event: %#v", e)
}
}
if e.Type == pleg.ContainerDied {
if containerID, ok := e.Data.(string); ok {
kl.cleanUpContainersInPod(e.ID, containerID)
}
}
case <-syncCh:
// Sync pods waiting for sync
podsToSync := kl.getPodsToSync()
if len(podsToSync) == 0 {
break
}
klog.V(4).Infof("SyncLoop (SYNC): %d pods; %s", len(podsToSync), format.Pods(podsToSync))
handler.HandlePodSyncs(podsToSync)
case update := <-kl.livenessManager.Updates():
if update.Result == proberesults.Failure {
// The liveness manager detected a failure; sync the pod.
// We should not use the pod from livenessManager, because it is never updated after
// initialization.
pod, ok := kl.podManager.GetPodByUID(update.PodUID)
if !ok {
// If the pod no longer exists, ignore the update.
klog.V(4).Infof("SyncLoop (container unhealthy): ignore irrelevant update: %#v", update)
break
}
klog.V(1).Infof("SyncLoop (container unhealthy): %q", format.Pod(pod))
handler.HandlePodSyncs([]*v1.Pod{pod})
}
case <-housekeepingCh:
if !kl.sourcesReady.AllReady() {
// If the sources aren't ready or volume manager has not yet synced the states,
// skip housekeeping, as we may accidentally delete pods from unready sources.
klog.V(4).Infof("SyncLoop (housekeeping, skipped): sources aren't ready yet.")
} else {
klog.V(4).Infof("SyncLoop (housekeeping)")
if err := handler.HandlePodCleanups(); err != nil {
klog.Errorf("Failed cleaning pods: %v", err)
}
}
}
return true
}
Kubelet 创建 pod 的过程是由 configCh 中的 ADD 事件触发的,那么下面主要看下 Kubelet 接收到 ADD 事件后的主要流程。
当 configCh 中出现了 ADD 事件,loop 会触发 SyncHandler 的 HandlePodAdditions 方法。这个方法的流程可以用下面这张流程图描述:
首先 handler 会将所有的 pod 安装创建时间进行排序,然后逐个进行处理。
首先将 pod 添加到 podManager 中,以方便后续操作;然后判断其是否为 mirror pod,如果是将作为 mirror pod 处理,否则按照正常 pod 处理,这里解释一下 mirror pod:
mirror pod 是 static pod 在 kueblet 在 apiserver 创建的一份副本。由于 static pod 是由 Kubelet 直接管理的,apiserver 并不会感知到 static pod 的存在,其生命周期都由 Kubelet 直接托管。为了可以通过 kubectl 命令查看对应的 pod,并且可以通过 kubectl logs 命令直接查看到static pod 的日志信息,Kubelet 通过 apiserver 为每一个 static pod 创建一个对应的 mirror pod。
接着判断 pod 是否能再该节点上运行,也就是所谓的 Kubelet 中的 pod 准入控制,准入控制主要包括这几方面:
当所有的条件都满足后,最后触发 podWorker 同步 pod。
HandlePodAdditions 对应的代码如下:
func (kl *kubelet) HandlePodAdditions(pods []*v1.Pod) {
start := kl.clock.Now()
sort.Sort(sliceutils.PodsByCreationTime(pods))
for _, pod := range pods {
existingPods := kl.podManager.GetPods()
// Always add the pod to the pod manager. Kubelet relies on the pod
// manager as the source of truth for the desired state. If a pod does
// not exist in the pod manager, it means that it has been deleted in
// the apiserver and no action (other than cleanup) is required.
kl.podManager.AddPod(pod)
if kubetypes.IsMirrorPod(pod) {
kl.handleMirrorPod(pod, start)
continue
}
if !kl.podIsTerminated(pod) {
// Only go through the admission process if the pod is not
// terminated.
// We failed pods that we rejected, so activePods include all admitted
// pods that are alive.
activePods := kl.filterOutTerminatedPods(existingPods)
// Check if we can admit the pod; if not, reject it.
if ok, reason, message := kl.canAdmitPod(activePods, pod); !ok {
kl.rejectPod(pod, reason, message)
continue
}
}
mirrorPod, _ := kl.podManager.GetMirrorPodByPod(pod)
kl.dispatchWork(pod, kubetypes.SyncPodCreate, mirrorPod, start)
kl.probeManager.AddPod(pod)
}
}
接下来看看 podWorker 的工作。podWorker 维护了一个 map 叫 podUpdates,以 pod uid 为 key,为每个 pod 维护一个 channel;当 pod 有事件过来的时候,首先从这个 map 里获取对应的 channel,然后启动一个 goroutine 监听这个 channel,并执行 managePodLoop;另一方面 podWorker 向这个 channel 中传入需要同步的 pod。
managePodLoop 接收到事件后,会先从 pod cache 中获取该 pod 最新的 status,以确保当前处理的 pod 是最新状态;然后调用 syncPod 方法,将其同步后的结果记录在 workQueue 中,等待下一次定时同步任务处理。
整个过程如下图所示:
podWorker 中处理 pod 事件的代码:
func (p *podWorkers) UpdatePod(options *UpdatePodOptions) {
pod := options.Pod
uid := pod.UID
var podUpdates chan UpdatePodOptions
var exists bool
p.podLock.Lock()
defer p.podLock.Unlock()
if podUpdates, exists = p.podUpdates[uid]; !exists {
podUpdates = make(chan UpdatePodOptions, 1)
p.podUpdates[uid] = podUpdates
go func() {
defer runtime.HandleCrash()
p.managePodLoop(podUpdates)
}()
}
if !p.isWorking[pod.UID] {
p.isWorking[pod.UID] = true
podUpdates <- *options
} else {
// if a request to kill a pod is pending, we do not let anything overwrite that request.
update, found := p.lastUndeliveredWorkUpdate[pod.UID]
if !found || update.UpdateType != kubetypes.SyncPodKill {
p.lastUndeliveredWorkUpdate[pod.UID] = *options
}
}
}
func (p *podWorkers) managePodLoop(podUpdates <-chan UpdatePodOptions) {
var lastSyncTime time.Time
for update := range podUpdates {
err := func() error {
podUID := update.Pod.UID
status, err := p.podCache.GetNewerThan(podUID, lastSyncTime)
if err != nil {
p.recorder.Eventf(update.Pod, v1.EventTypeWarning, events.FailedSync, "error determining status: %v", err)
return err
}
err = p.syncPodFn(syncPodOptions{
mirrorPod: update.MirrorPod,
pod: update.Pod,
podStatus: status,
killPodOptions: update.KillPodOptions,
updateType: update.UpdateType,
})
lastSyncTime = time.Now()
return err
}()
// notify the call-back function if the operation succeeded or not
if update.OnCompleteFunc != nil {
update.OnCompleteFunc(err)
}
if err != nil {
// IMPORTANT: we do not log errors here, the syncPodFn is responsible for logging errors
klog.Errorf("Error syncing pod %s (%q), skipping: %v", update.Pod.UID, format.Pod(update.Pod), err)
}
p.wrapUp(update.Pod.UID, err)
}
}
上述 podWorker 在 managePodLoop 中调用的 syncPod 方法,其实是 Kubelet 对象的 SyncPod 方法,在文件 pkg/kubelet/kubelet.go 中。
这个方法是真正与 container runtime 层交互的。首先会判断是否是 kill 事件,如果是,直接调用 runtime 的 killPod;然后判断是否可以在节点上运行,这里就是上面讲到的 Kubelet 的准入控制;再判断 CNI 插件是否 ready,如果不 ready,则只在 pod 使用 host network 的时候创建并更新 pod 的 cgroups;接着再判断是否是静态 pod,如果是就创建相应的 mirror pod;然后创建 pod 需要挂载的目录;最后调用 runtime 的 syncPod。整个流程如下所示:
Kubelet 的 syncPod 代码如下,为了理解主流程,我删除了部分优化代码,感兴趣的同学可以自行查阅源码:
func (kl *kubelet) syncPod(o syncPodOptions) error {
// pull out the required options
pod := o.pod
mirrorPod := o.mirrorPod
podStatus := o.podStatus
updateType := o.updateType
// if we want to kill a pod, do it now!
if updateType == kubetypes.SyncPodKill {
...
if err := kl.killPod(pod, nil, podStatus, killPodOptions.PodTerminationGracePeriodSecondsOverride); err != nil {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedToKillPod, "error killing pod: %v", err)
// there was an error killing the pod, so we return that error directly
utilruntime.HandleError(err)
return err
}
return nil
}
...
runnable := kl.canRunPod(pod)
if !runnable.Admit {
...
}
...
// If the network plugin is not ready, only start the pod if it uses the host network
if err := kl.runtimeState.networkErrors(); err != nil && !kubecontainer.IsHostNetworkPod(pod) {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.NetworkNotReady, "%s: %v", NetworkNotReadyErrorMsg, err)
return fmt.Errorf("%s: %v", NetworkNotReadyErrorMsg, err)
}
...
if !kl.podIsTerminated(pod) {
...
if !(podKilled && pod.Spec.RestartPolicy == v1.RestartPolicyNever) {
if !pcm.Exists(pod) {
if err := kl.containerManager.UpdateQOSCgroups(); err != nil {
klog.V(2).Infof("Failed to update QoS cgroups while syncing pod: %v", err)
}
...
}
}
}
// Create Mirror Pod for Static Pod if it doesn't already exist
if kubetypes.IsStaticPod(pod) {
...
}
if mirrorPod == nil || deleted {
node, err := kl.GetNode()
if err != nil || node.DeletionTimestamp != nil {
klog.V(4).Infof("No need to create a mirror pod, since node %q has been removed from the cluster", kl.nodeName)
} else {
klog.V(4).Infof("Creating a mirror pod for static pod %q", format.Pod(pod))
if err := kl.podManager.CreateMirrorPod(pod); err != nil {
klog.Errorf("Failed creating a mirror pod for %q: %v", format.Pod(pod), err)
}
}
}
}
// Make data directories for the pod
if err := kl.makePodDataDirs(pod); err != nil {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedToMakePodDataDirectories, "error making pod data directories: %v", err)
klog.Errorf("Unable to make pod data directories for pod %q: %v", format.Pod(pod), err)
return err
}
// Volume manager will not mount volumes for terminated pods
if !kl.podIsTerminated(pod) {
// Wait for volumes to attach/mount
if err := kl.volumeManager.WaitForAttachAndMount(pod); err != nil {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedMountVolume, "Unable to attach or mount volumes: %v", err)
klog.Errorf("Unable to attach or mount volumes for pod %q: %v; skipping pod", format.Pod(pod), err)
return err
}
}
// Fetch the pull secrets for the pod
pullSecrets := kl.getPullSecretsForPod(pod)
// Call the container runtime's SyncPod callback
result := kl.containerRuntime.SyncPod(pod, podStatus, pullSecrets, kl.backOff)
kl.reasonCache.Update(pod.UID, result)
if err := result.Error(); err != nil {
// Do not return error if the only failures were pods in backoff
for _, r := range result.SyncResults {
if r.Error != kubecontainer.ErrCrashLoopBackOff && r.Error != images.ErrImagePullBackOff {
// Do not record an event here, as we keep all event logging for sync pod failures
// local to container runtime so we get better errors
return err
}
}
return nil
}
return nil
}
创建 pod 的整个流程到这里就走到了 runtime 层的 syncPod 部分,下面来看下这里的流程:
流程很清晰,首先计算 pod 的 sandbox 和容器的变化,如果 sandbox 发生了变化,就将 pod kill 掉,再 kill 掉其相关的容器;接着为 pod 创建 sandbox(无论是需要新建的 pod 还是 sandbox 发生变化被删除的 pod);后面就是启动 ephemeral 容器、init 容器以及业务容器。
其中,ephemeral 容器是 k8s v1.16 版本的新特性,该容器在现有 Pod 中临时运行,为了完成用户启动的操作,例如故障排查。
整个代码如下,这里同样为了显示主流程,删除了一些优化代码:
func (m *kubeGenericRuntimeManager) SyncPod(pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, backOff *flowcontrol.Backoff) (result kubecontainer.PodSyncResult) {
// Step 1: Compute sandbox and container changes.
podContainerChanges := m.computePodActions(pod, podStatus)
klog.V(3).Infof("computePodActions got %+v for pod %q", podContainerChanges, format.Pod(pod))
if podContainerChanges.CreateSandbox {
ref, err := ref.GetReference(legacyscheme.Scheme, pod)
if err != nil {
klog.Errorf("Couldn't make a ref to pod %q: '%v'", format.Pod(pod), err)
}
...
}
// Step 2: Kill the pod if the sandbox has changed.
if podContainerChanges.KillPod {
killResult := m.killPodWithSyncResult(pod, kubecontainer.ConvertPodStatusToRunningPod(m.runtimeName, podStatus), nil)
result.AddPodSyncResult(killResult)
...
} else {
// Step 3: kill any running containers in this pod which are not to keep.
for containerID, containerInfo := range podContainerChanges.ContainersToKill {
...
if err := m.killContainer(pod, containerID, containerInfo.name, containerInfo.message, nil); err != nil {
killContainerResult.Fail(kubecontainer.ErrKillContainer, err.Error())
klog.Errorf("killContainer %q(id=%q) for pod %q failed: %v", containerInfo.name, containerID, format.Pod(pod), err)
return
}
}
}
...
// Step 4: Create a sandbox for the pod if necessary.
podSandboxID := podContainerChanges.SandboxID
if podContainerChanges.CreateSandbox {
var msg string
var err error
...
podSandboxID, msg, err = m.createPodSandbox(pod, podContainerChanges.Attempt)
if err != nil {
...
}
klog.V(4).Infof("Created PodSandbox %q for pod %q", podSandboxID, format.Pod(pod))
...
}
...
// Step 5: start ephemeral containers
if utilfeature.DefaultFeatureGate.Enabled(features.EphemeralContainers) {
for _, idx := range podContainerChanges.EphemeralContainersToStart {
start("ephemeral container", ephemeralContainerStartSpec(&pod.Spec.EphemeralContainers[idx]))
}
}
// Step 6: start the init container.
if container := podContainerChanges.NextInitContainerToStart; container != nil {
// Start the next init container.
if err := start("init container", containerStartSpec(container)); err != nil {
return
}
...
}
// Step 7: start containers in podContainerChanges.ContainersToStart.
for _, idx := range podContainerChanges.ContainersToStart {
start("container", containerStartSpec(&pod.Spec.Containers[idx]))
}
return
}
最后再来看一下 sandbox 为何物。在计算机安全领域,沙箱(Sandbox)是一种程序的隔离运行机制,其目的是限制不可信进程的权限。docker 在容器中运用了这种技术,为每个容器创建一个 sandbox,定义了其 cgroup 及各种 namespace,做到容器的隔离;k8s 中每个 pod 共享一个 sandbox,所以同一个 pod 的所有容器才能够互通,且与外界隔离。
我们看一下 Kubelet 为 pod 创建 sandbox 的过程。首先定义 pod 的 DNS 配置、HostName、日志路径以及 sandbox 端口,这些都是 pod 中容器共享的;然后为 pod 定义 linux 配置,包括父亲 cgroup、IPC/Network/Pid namespace、sysctls、Linux 权限;所有都配置好后,才调用 CRI 接口创建 sandbox。整个流程如下:
源代码:
func (m *kubeGenericRuntimeManager) createPodSandbox(pod *v1.Pod, attempt uint32) (string, string, error) {
podSandboxConfig, err := m.generatePodSandboxConfig(pod, attempt)
...
// Create pod logs directory
err = m.osInterface.MkdirAll(podSandboxConfig.LogDirectory, 0755)
...
podSandBoxID, err := m.runtimeService.RunPodSandbox(podSandboxConfig, runtimeHandler)
...
return podSandBoxID, "", nil
}
func (m *kubeGenericRuntimeManager) generatePodSandboxConfig(pod *v1.Pod, attempt uint32) (*runtimeapi.PodSandboxConfig, error) {
podUID := string(pod.UID)
podSandboxConfig := &runtimeapi.PodSandboxConfig{
Metadata: &runtimeapi.PodSandboxMetadata{
Name: pod.Name,
Namespace: pod.Namespace,
Uid: podUID,
Attempt: attempt,
},
Labels: newPodLabels(pod),
Annotations: newPodAnnotations(pod),
}
dnsConfig, err := m.runtimeHelper.GetPodDNS(pod)
...
podSandboxConfig.DnsConfig = dnsConfig
if !kubecontainer.IsHostNetworkPod(pod) {
podHostname, podDomain, err := m.runtimeHelper.GeneratePodHostNameAndDomain(pod)
podHostname, err = util.GetNodenameForKernel(podHostname, podDomain, pod.Spec.SetHostnameAsFQDN)
podSandboxConfig.Hostname = podHostname
}
logDir := BuildPodLogsDirectory(pod.Namespace, pod.Name, pod.UID)
podSandboxConfig.LogDirectory = logDir
portMappings := []*runtimeapi.PortMapping{}
for _, c := range pod.Spec.Containers {
containerPortMappings := kubecontainer.MakePortMappings(&c)
...
}
if len(portMappings) > 0 {
podSandboxConfig.PortMappings = portMappings
}
lc, err := m.generatePodSandboxLinuxConfig(pod)
...
podSandboxConfig.Linux = lc
return podSandboxConfig, nil
}
// generatePodSandboxLinuxConfig generates LinuxPodSandboxConfig from v1.Pod.
func (m *kubeGenericRuntimeManager) generatePodSandboxLinuxConfig(pod *v1.Pod) (*runtimeapi.LinuxPodSandboxConfig, error) {
cgroupParent := m.runtimeHelper.GetPodCgroupParent(pod)
lc := &runtimeapi.LinuxPodSandboxConfig{
CgroupParent: cgroupParent,
SecurityContext: &runtimeapi.LinuxSandboxSecurityContext{
Privileged: kubecontainer.HasPrivilegedContainer(pod),
SeccompProfilePath: v1.SeccompProfileRuntimeDefault,
},
}
sysctls := make(map[string]string)
if utilfeature.DefaultFeatureGate.Enabled(features.Sysctls) {
if pod.Spec.SecurityContext != nil {
for _, c := range pod.Spec.SecurityContext.Sysctls {
sysctls[c.Name] = c.Value
}
}
}
lc.Sysctls = sysctls
if pod.Spec.SecurityContext != nil {
sc := pod.Spec.SecurityContext
if sc.RunAsUser != nil {
lc.SecurityContext.RunAsUser = &runtimeapi.Int64Value{Value: int64(*sc.RunAsUser)}
}
if sc.RunAsGroup != nil {
lc.SecurityContext.RunAsGroup = &runtimeapi.Int64Value{Value: int64(*sc.RunAsGroup)}
}
lc.SecurityContext.NamespaceOptions = namespacesForPod(pod)
if sc.FSGroup != nil {
lc.SecurityContext.SupplementalGroups = append(lc.SecurityContext.SupplementalGroups, int64(*sc.FSGroup))
}
if groups := m.runtimeHelper.GetExtraSupplementalGroupsForPod(pod); len(groups) > 0 {
lc.SecurityContext.SupplementalGroups = append(lc.SecurityContext.SupplementalGroups, groups...)
}
if sc.SupplementalGroups != nil {
for _, sg := range sc.SupplementalGroups {
lc.SecurityContext.SupplementalGroups = append(lc.SecurityContext.SupplementalGroups, int64(sg))
}
}
if sc.SELinuxOptions != nil {
lc.SecurityContext.SelinuxOptions = &runtimeapi.SELinuxOption{
User: sc.SELinuxOptions.User,
Role: sc.SELinuxOptions.Role,
Type: sc.SELinuxOptions.Type,
Level: sc.SELinuxOptions.Level,
}
}
}
return lc, nil
}
Kubelet 的核心工作都是围绕控制循环展开的,而控制循环中借助了 go 的 channel,以 channel 为基础,生产者和消费者协同工作使得控制循环得以运作,从而达到期望状态。
]]>在看进程调度算法前,先来看看进程调度算法都遵循哪些指标。目前只考虑周转时间和响应时间。
周转时间:任务完成时间减去任务到达系统的时间。
响应时间:从任务到达系统到首次运行的时间。
性能和公平在调度系统往往是矛盾的,例如,调度程序可以优化性能,但代价是以阻止一些任务运行,这就降低了公平。
FIFO 就是先到先出调度 (First In First Out, FIFO),有时也叫先到先服务调度 (First Come First Served, FCFS)。
优点:很简单,易于实现。
缺点:当先到达的任务耗时较长时,FIFO 调度算法的平均周转时间会很长。
最短任务优先 (Shortest Job First, SJF) 算法的策略:先运行最短的任务,然后是次短的任务,如此下去。
在所有任务同时到达的假设下,SJF 确实是最优调度算法。但任务有先有后的时候,SJF 就不理想了,如下图所示。
为了解决 SJF 的问题,引入了最短完成时间优先 (Shortest Time-to-Completion First, STFT) 算法,前提是操作系统需要允许任务抢占。
STCF 算法在 SJF 的基础上添加抢占,又称抢占式最短作业优先 (Preemptive Shortest Job First, PSJF)。每当新工作进入系统时,就会确定剩余工作和新工作中,谁的剩余时间最少,然后调度该工作。
在知道任务长度,且任务只使用 CPU,而衡量指标只有周转时间时,STCF 是一个很好的策略。但考虑到响应时间,STCF 算法表现的相当糟糕,比如说你在终端前输入,不得不等待 10s 才能看到系统的回应,只是因为其他一些工作已经在这之前被调度。
为了解决 STCF 算法响应时间长的问题,引入了轮转调度 (Round-Robin, RR)。
轮转调度的思想:RR 在一个时间片 (time slice,有时称为调度因子,scheduling quantum) 内运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。RR 有时被称为时间切片,时间片长度必须是时钟中断周期的倍数。
时间片长度对于 RR 是至关重要的。时间片越短,RR 在响应时间上表现越好。然而,时间片太短是有问题的:突然上下文切换的成本将影响整体性能。因此,系统设计者需要权衡时间片的长度,使其足够长,以便摊销上下文切换成本,而又不会使系统不及时响应。
注:上下文切换的成本不仅仅来自保存和恢复少量寄存器的操作系统操作。程序运行时,它们在 CPU 高速缓存、TLB、分支预测器和其他片上硬件中建立了大量的状态。切换到另一个工作会导致此状态被刷新,且与当前运行的作业相关的新状态被引入,可能导致显著的性能成本。
当运行的程序在进行 I/O 操作的时候,在 I/O 期间不会使用 CPU,但它被阻塞等待 I/O 完成,这时调度程序应该在 CPU 上安排另一项工作。
而在 I/O 完成时,会产生中断,操作系统运行并将发出 I/O 的进程从阻塞状态移回就绪状态。当然,它甚至可以决定在那个时候运行该项工作。操作系统应该如何处理每项工作?
假设有两项工作 A 和 B,每项工作需要 50ms 的CPU时间。但是A先运行10ms,然后发出I/O请求(假设 I/O 每个都需要 10ms),而B只是使用CPU 50ms,不执行I/O。调度程序先运行A,然后运行B。
左图所示的调度是非常糟糕的。常见的方法是将 A 的每个 10ms 的子工作视为一项独立的工作。因此,当系统启动时,它的选择是调度 10ms 的 A,还是 50ms 的 B。STCF 会选择较短的 A。然后,A 的工作已完成,只剩下 B,并开始运行。然后提交 A 的一个新子工作,它抢占 B 并运行 10ms。这样做可以实现重叠,一个进程在等待另一个进程的 I/O 完成时使用 CPU,系统因此得到更好的利用。
操作系统常常不知道工作要运行多久,而这又是 SJF 等算法所必需的;而轮转调度虽然降低了响应时间,周转时间却很差。因此引入了多级反馈队列(Multi-level Feedback Queue,简称MLFQ)。MLFQ 需要解决两方面的问题:首先它要优化周转时间,这可以通过优先执行较短的工作来实现;其次,MLFQ 希望给用户提供较好的交互体验,因此需要降低响应时间。
MLFQ 中有许多独立的队列,每个队列有不同的优先级。任何时刻,一个工作只能存在于一个队列中。MLFQ 总是优先执行较高优先级的工作(即那些在较高级队列中的工作)。每个队列中可能会有多个工作,它们具有同样的优先级。在这种情况下,我们就对这些工作采用轮转调度。
多级反馈队列由几个基本规则:
规则1:如果 A 的优先级大于 B 的优先级,运行 A 不运行 B。
规则2:如果 A 的优先级等于 B 的优先级,轮转运行 A 和 B。
规则3:工作进入系统时,放在最高优先级(最上层)队列。这一规则使得多级反馈队列算法类似 SJF,保证了良好的响应时间。
规则4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次 CPU),就降低其优先级(移入低一级队列)。这一规则防止进程主动放弃 CPU,从而造成其他进程饥饿。
规则5:每经过一段时间,就将系统中所有工作重新加入最高优先级队列。这一规则解决了两个问题:一是防止长进程饥饿,二是如果一个 CPU 密集型工作变成了交互型,当它优先级提升时,调度程序会正确对待它。
比例份额(proportional-share)调度程序,有时也称为公平份额(fair-share)调度程序。比例份额算法认为,调度程序的最终目标是确保每个工作获得一定比例的 CPU 时间,而不是优化周转时间和响应时间。它的基本思想很简单:每隔一段时间,都会举行一次彩票抽奖,以确定接下来应该运行哪个进程。越是应该频繁运行的进程,越是应该拥有更多地赢得彩票的机会。
在彩票调度中,彩票数代表了进程占有某个资源的份额。一个进程拥有的彩票数占总彩票数的百分比,就是它占有资源的份额。
假设有两个进程A和B,A拥有75张彩票,B拥有25张。因此我们希望A占用75%的CPU时间,而B占用25%。
通过不断且定时地抽取彩票,彩票调度从概率上获得这种份额比例。抽取彩票的过程很简单:调度程序知道总共的彩票数(在我们的例子中,有100张)。调度程序抽取中奖彩票,这是从0和99之间的一个数,拥有这个数对应的彩票的进程中奖。假设进程A拥有0到74共75张彩票,进程B拥有75到99的25张,中奖的彩票就决定了运行A或B。调度程序然后加载中奖进程的状态,并运行它。彩票调度利用了随机性,这导致了从概率上满足期望的比例。随着这两个工作运行得时间越长,它们得到的CPU时间比例就会越接近期望。
彩票调度实现起来非常简单,只需要一个随机数生成器来选择中奖彩票和一个记录系统中所有进程的数据结构,以及所有彩票的总数。假设我们使用列表记录进程,下面的例子中有A、B和C这3个进程,每个进程有一定数量的彩票。
虽然随机方式可以使得调度程序的实现简单,但偶尔并不能产生正确的比例,尤其在工作运行时间很短的情况下。由于这个原因,Waldspurger 提出了步长调度。
系统中的每个工作都有自己的步长,这个值与票数值成反比。A、B、C这3个工作的票数分别是100、50和250,我们通过用一个大数分别除以他们的票数来获得每个进程的步长。比如用10000除以这些票数值,得到了3个进程的步长分别为100、200和40。我们称这个值为每个进程的步长(stride)。每次进程运行后,我们会让它的计数器(称为行程值)增加它的步长,记录它的总体进展。
基本思路:当需要进行调度时,选择目前拥有最小行程值的进程,并且在运行后将该进程的行程值增加一个步长。
伪代码:
current = remove_min(queue); // pick client with minimum pass
schedule(current); // use resource for quantum
current->pass += current->stride; // compute next pass using stride
insert(queue, current); // put back into the queue
相比于步长调度,彩票调度的优势是不需要全局状态。假如一个新的进程在步长调度执行过程中加入系统,应该怎么设置它的行程值呢?设置成0吗?这样的话,它就独占CPU了。而彩票调度算法不需要对每个进程记录全局状态,只需要用新进程的票数更新全局的总票数就可以了。因此彩票调度算法能够更合理地处理新加入的进程。
以上调度算法都是单处理器的调度,而当操作系统采用多核 CPU 的时候,必须考虑多处理器调度问题。
多处理器与单 CPU 之间的基本区别,核心在于对硬件缓存的使用,以及多处理器之间共享数据的方式。
在单 CPU 系统中,存在所及的硬件缓存,一般来说会让处理器更快地执行程序。缓存是很小但很快的存储设备,通常拥有内存中最热的数据的备份。相比之下,内存很大且拥有所有的数据,但访问速度很慢。通过将频繁访问的数据放在缓存中,系统似乎拥有又大又快的内存。
而当多 CPU 的情况下,会存在缓存一致性问题。硬件提供了这个问题的基本解决方案:通过监控内存访问,硬件可以保证获得正确的数据,并保证共享内存的唯一性。
多处理器调度还需要考虑的一个问题:缓存亲和度。一个进程在某个 CPU 上运行时,会在该 CPU 的缓存中维护许多状态,所以,多处理器调度应该考虑到这种缓存亲和性,并尽可能将进程保持在同一个 CPU 上。
一个多处理器系统的调度程序,最基本的方式是简单地复用单处理器调度的基本架构,将所有需要调度的工作放入一个单独的队列中,这种方式称之为单队列多处理器调度 (Single Queue Multiprocessor Scheduling, SQMS)。
SQMS 的缺陷:
为了解决这个问题,大多数 SQMS 调度程序都引入了一些亲和机制,尽可能让进城在同一个 CPU 上运行。例如,针对同样 5 个工作的调度如下,A、B、C、D 4个工作都保持在同一个 CPU 上,只有 E 不断的来回迁移。
正是由于单队列调度的这些问题,有些系统使用了多队列的方案,比如每个 CPU 一个队列,我们称之为多队列多处理器调度 (Multi-Queue Multiprocessor Scheduling, MQMS)。
在 MQMS 中,基本调度框架包含多个调度队列,每个队列可以使用不同的调度规则。当工作进入系统后,系统会依照一些启发性规则将其放入某个调度队列。每个 CPU 调度之间相互独立,就避免了单队列的方式中由于数据共享及同步带来的问题。
例如,假设系统中有两个 CPU,工作 A、B、C、D 进入系统,每个 CPU 都有自己的调度队列,操作系统觉得每个工作放入哪个队列,比如:
根据不同队列的调度策略,每个 CPU 从两个工作中选择,决定谁将运行,比如利用轮转,调度结果可能如下所示:
但这样势必会存在负载不均的问题。
比如说工作 C 这时执行完毕,现在调度队列如下:
而轮转调度结果如下:
更糟的是,假如 A 和 C 都执行完毕,系统只有 B 和 D,调度队列看起来如下:
因此 CPU 时间线看起来令人难过:
而此时,解决这种负载不均问题的解法,是迁移 (migration)。通过工作的跨 CPU 迁移,可以真正实现负载均衡。
上面那个例子中,将 B 或 D 迁移到 CPU 0 中,即可实现负载均衡。但在较早的例子中,A 独自留在 CPU 0 上,B 和 D 在 CPU 1 上交替运行。这种情况下,单次迁移不能解决问题,应该不断地迁移一个或多个工作。一种可能的解决方案时不断切换工作,如下面的时间线所示。可以看到,开始的时候,A 独享 CPU 0,B 和 D 在 CPU 1 上,一段时间片后,B 迁移到 CPU 0 和 A 竞争,D 则独享 CPU 1 一段时间。这样就实现了负载均衡。
实现任务迁移的一个最基本的方法是工作窃取 (work stealing)。通过这种方法,工作量较少的队列不定期的 ”偷看“ 其他队列是不是比自己的工作多。如果目标队列比源队列更满,就从目标队列 ”窃取” 一个或多个工作,实现负载均衡。
在构建多处理器调度程序方面,Linux 社区一直没有达成共识。一直以来存在 3 种不同的调度程序:O(1) 调度程序、完全公平调度程序 (CFS) 以及 BF 调度程序 (BFS) 。
O(1) 、CFS 采用多队列,而 BFS 采用单队列;而 O(1) 调度程序是基于优先级的 (类似于 MLFQ) ,随着时间推移改变进程的优先级,然后调度最高优先级进程,来实现各种调度目标;CFS 是确定的比例调度方法 (类似于步长调度);BFS 也是基于比例调度,但采用了更复杂的防范,称之为最早最合适虚拟截止时间优先算法 (EEVEF)。
]]>本文参考《操作系统导论》
操作系统提供了一个易用的物理内存抽象:地址空间。地址空间是运行的程序看到的系统中的内存。
一个进程的地址空间包含运行的程序的所有内存状态。每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定向到内存中实际的位置。
为了完成地址转换,每个 CPU 需要两个硬件寄存器:基址 (base) 寄存器和界限 (bound) 寄存器。程序的起始地址存放在基址寄存器。进程产生的所有内存引用,都会被处理器通过以下方式转换成物理地址:
physical address = virtual address + base
界限寄存器的作用在于,确保了进程产生的所有地址都在进程的地址 “界限” 中。
操作系统和硬件支持结合,实现了虚拟内存,而为了实现虚拟内存,操作系统所需要做的工作如下:
为了解决连续内存的浪费问题,操作系统引入了分段。
具体来说,在 MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有 3 个逻辑不同的段:代码、栈和堆。
分段机制使得操作系统能够将不同的段放入不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。如下图所示:
而如何从一个虚拟地址中识别出对应的段是哪一个,主要有两个方法:
分段带来一些新的问题。
第一个是段寄存器的值必须被保存和恢复。每个进程都有自己独立的虚拟地址空间,操作系统必须在进程运行前,确保这些寄存器被正确的赋值。
第二个也是更重要的问题是分段会带来外部碎片。空闲空间被分割成不同大小的小块,成为碎片,后续的请求可能会失败,因为没有一块足够大的连续空闲空间,即使这时总的空闲空间超出了请求的大小。
解决外部碎片的一种方法是紧凑物理内存,重新安排原有的段,但内存紧凑成本很高;另一种简单的方法是使用空闲列表(free-list)管理算法,试图保留大额内存用于分配。目前已经有数百种方法,包括经典算法:
还有一些改进策略:
然而不管算法多么精妙,外部碎片仍然存在,无法完全消除。唯一真正解决的办法就是完全避免这个问题,永远不要分配不同大小的内存块,这也是分页被引入的原因。
分页不是将一个进程的地址空间分割成几个不同长度的逻辑段 (即代码、堆、段),而是分割成固定大小的单元,每个单元称为一页。相应的,我们把物理内存看成是定长槽块的阵列,叫做页帧。每个页帧包含一个虚拟内存页。
操作系统为每个进程保存一个数据结构,称为页表。主要用来为地址空间的每个虚拟页面保存地址转换,从而让我们知道每个页在物理内存中的位置。
虚拟地址分成两个组件:虚拟页号(VPN)和页内的偏移量(offset)
通过虚拟页号,我们现在可以检索页表,找到虚拟页所在的物理页面。因此,我们可以通过用物理帧号替换虚拟页号来转换此虚拟地址,然后将载入发送给物理内存。偏移量保持不变,因为偏移量只是告诉我们页面中的哪个字节是我们想要的。如下图所示:
简言之,页表就是一种数据结构,用于将虚拟地址 (或者实际上,是虚拟页号) 映射到物理地址 (物理帧号)。因此任何数据结构都可以采用,最简单的形式成为线性页表,就是一个数组。操作系统通过虚拟页号 (VPN) 检索该数组,并在该索引处查找页表项 (PTE) ,以找到期望的物理帧号 (PFN)。
对于每个内存引用,分页都需要我们执行一个额外的内存引用,以便首先从页表中获取地址转换。额外的内存引用开销很大,而且在这种情况下,可能会使进程减慢两倍或更多。
分页虽然看起来是内存虚拟化需求的一个很好的解决方案,但这两个关键问题必须先克服。
为了解决页表内存开销过多的问题,Multics 的创造者提出了分页和分段结合的想法。解决方法是,不是为进程的整个地址空间提供单个页表,而是为每个逻辑分担提供一个页表。
在分段中,有一个基址寄存器用来存放每个段在物理内存中的位置,还有一个界限寄存器用来存放该段的大小。在这里依然使用这些结构,不过,基址不是指向段本身,而是保存该段的页表的物理地址;界限寄存器用来指示页表的结尾(即它有多少有效位)。
这种杂合方案的关键区别在于,每个分段都有界限寄存器,每个界限寄存器保存了段中最大有效页的值。例如,如果代码段使用前3个页,则代码段页表将只有3个项分配给它,并且界限寄存器将被设置为3。与线性页表相比,杂合方法实现了显著的内存节省,栈和堆之间未分配的页不再占用页表中的空间 (仅将其标记为无效)。
而这种方法的弊端在于,一是它仍然要求使用分段,如果有一个大而稀疏的堆,仍然可能导致大量的页表浪费;二是外部碎片再次出现,尽管大部分内存是以页表大小单位管理的,但页表现在可以是任意大小 (PTE 的倍数)。
多级页表也是用来解决页表占用太多内存的问题,去掉页表中的所有无效区域,而不是将它们全部保留在内存中。多级页表将线性页表变成了树。
首先,将页表分成页大小的单元;然后,如果整页的页表项 (PTE) 无效,就完全不分配该页的页表。为了追踪页表的也是否有效 (以及如果有效,它在内存中的位置),使用了名为页目录的新结构。页目录可以告诉你页表的页在哪里,或者页表的整个页不包含有效页。
下面的图展示了一个例子,左边是经典的线性页表,即使地址空间的大部分中间区域无效 (即页表的中间两页),我们仍然需要为这些区域分配页表空间;右边是一个多级页表,页目录仅将这些区域分配页表空间 (即第一个和最后一个),页表的这两页就驻留在内存中。因此,我们可以形象地看到多级页表的工作方式:只是让线性页表的一部分消失 (释放这些帧用作其他用途),并用页目录记录页表的哪些页也被分配。
在一个简单的两级页表中,页目录为每页页表包含了一项。由多个页目录项 (PDE) 组成,PDE 至少拥有有效位 (valid bit) 和页帧号 (PFN),类似于 PTE。但这个有效位的含义稍有不同:如果 PDE 项是有效的,则意味着该项指向的页表 (通过 PTE) 中至少有一页是有效的,即在该 PDE 所指向的页中,至少一个 PTE,其有效位被设置为 1。如果 PDE 项无效,则 PDE 的其余部分没有定义。
多级页表分配的页表空间,与你正在使用的地址空间内存量成比例,因此通常很紧凑,并且支持稀疏的地址空间。
如果仔细构建,页表的每个部分都可以整齐的放入一页中,从而更容易管理内存。有了多级页表,我们增加了一个间接层,使用了页目录,指向页表的各个部分,这种间接方式,让我们能够将页表页放在物理内存的任何地方。
多级页表是有成本的,在 TLB 未命中时,需要从内存加载两次,才能从页表中获取正确的地址转换信息 (一次用于页目录,另一次用于 PTE 本身),而用线性页表只需要一次加载。
另一个明显的缺点是复杂性。无论是硬件还是操作系统来处理页表查找,这样做无疑都比简单的线性页表查找更复杂。
为了解决分页所带来的额外内存访问的问题,操作系统需要一些额外的帮助,因此引入了地址转换旁路缓冲寄存器 (TLB),就是频繁发生的虚拟到物理地址转换的硬件缓存。
首先从虚拟地址中提取页号 (VPN),然后检查 TLB 是否有该 VPN 的转换映射;
如果有,我们就有了 TLB 命中,意味着 TLB 有该页的转换映射,就可以从相关的 TLB 项中取出页诊号 (PFN) 与原来虚拟地址中的偏移量组合成期望的物理地址;
如果没有 (TLB 未命中),在不同的系统中表现不一样:
TLB 中包含的虚拟到物理地址映射只对当前进程有效,对其他进程是没有意义的。所以在上下文切换时,TLB 的管理有两种方法。
如果是软件管理 TLB 的系统,可以在发生上下文切换时,通过一条显式指令来完成;如果是硬件管理 TLB 系统,则可以在页表基址寄存器内容发生变化时清空 TLB。不论哪种情况,情况操作都是把全部有效位置为 0,本质上清空了 TLB。
但该方法有一定开销:每次进程运行,当它访问数据和代码页时,都会触发 TLB 未命中,如果操作系统频繁切换进程,这种开销会很高。
增加硬件支持,实现跨上下文切换的 TLB 共享。比如有的系统在 TLB 中添加一个地址空间标识符 (ASID),可以把 ASID 看做是进程标识符,但通常比 PID 位数少一位。TLB 因此可以同时缓存不同进程的地址空间映射,没有任何冲突。
为了支持更大的地址空间,操作系统需要把当前没有在用的那部分地址空间找个地方存储起来。硬盘通常能够满足这个需求,在我们的存储层级结构中,大而慢的硬盘位于底层,内存之上。增加交换空间让操作系统为多个并发运行的进程都提供巨大地址空间的假象。
在硬盘上开辟一部分空间用于物理页的移入和移出,在操作系统中这样的空间称为交换空间,因为我们将内存中的页交换到其中,并在需要的时候又交换回去。因此,我们会假设操作系统能够以页为大小为单元读取或者写入交换空间,为了达到这个目的。
硬件通过页表中的存在位,来判断是否在内存中。如果存在位设置为1,则表示该页存在于物理内存中,并且所有内容都正常进行;如果存在位设置为0,则页不在内存中,而在硬盘上。
访问不在物理内存中的页,这种行为通常被称为页错误。这时 “页错误处理程序” 被执行,处理页错误。
处理页错误的流程:
如上图所示,当操作系统接收到页错误时,会先找可用的物理帧,如果找不到,操作系统会执行交换算法,踢出一些页,释放物理帧,并将请求发送到硬盘,将页读取到内存中。
当硬盘 I/O 完成时,操作系统会更新页表,将此页标记为存在,更新页表项的 PFN 字段以记录新获取页的内存位置,并重试指令。下一次重新访问 TLB 还是未命中,然而这次因为页在内存中,因此会将页表中的地址更新到 TLB 中。
最后的重试操作会在 TLB 中找到转换映射,从已转换的内存物理地址,获取所需的数据或指令。
为了保证有少量的空闲内存,大多数操作系统会设置高水位线 (HW) 和低水位线 (LW)。
原理是:当操作系统发现有少于 LW 个页可用时,后台负责释放内存的线程会开始运行,直到有 HW 个可用的物理页。这个后台线程有时称为交换守护进程 (swap daemon) 或页守护进程 (page daemon),然后会进入休眠状态。
当内存不够时,由于内存压力迫使操作系统换出一些页,为常用的页腾出空间,确定要踢出哪些页封装在操作系统的替换策略中。交换策略有很多,如下:
最优替换策略能达到总体未命中数量最少,即替换内存中在最远将来才会被访问到的页,可以达到缓存未命中率最低。但很难实现。
FIFO 策略,即页在进入系统时,简单地放入一个队列,当发生替换时,队列尾部的页被踢出。
FIFO 有个很大的优势:实现相当简单。但其根本无法确定页的重要性,即使页 0 已被多次访问, FIFO 仍然会将其踢出。且 FIFO 会引起 Belady 异常。
LRU 策略是替换最近最少使用的页。
LRU 目前看来优于 FIFO 策略及随机策略,但随着系统中页的数量的增长,扫描所有页的时间字段只是为了找到最精确最少使用的页,这个代价太大。
Clock 算法是近似 LRU 的一种算法,也是许多现代系统的做法。该算法需要硬件增加一个使用位。
过程:
考虑到内存中的页是否被修改,硬件增加一个修改位。每次写入页时都会设置此位,因此可以将其合并到页面替换算法中。如果页已被修改并因此变脏,则提出就必须将它写回磁盘,这很昂贵;如果没有被修改,踢出就没有成本。因此,一些虚拟系统更倾向于踢出干净页,而不是脏页。
本文就操作系统的内存虚拟化部分做了简单总结,包括分段、分页、TLB 以及交换空间。通过这些,操作系统实现了虚拟内存系统,从而保证内存对程序的透明,程序访问内存的高效,以及进程之间的相互隔离。
]]>本文参考《操作系统导论》