Mohamed Ahmed的客座文章,最初发表在Magalix博客上
一个优秀的云原生应用程序设计应该声明它需要正确操作的任何特定资源。Kubernetes使用这些需求来做出最有效的决策,以确保应用程序的最大性能和可用性。
此外,直接了解应用程序需求,可以使你在集群节点的硬件规范方面,做出具有成本效益的决策。我们将在本文中,探讨声明存储、CPU和内存资源需求的最佳实践。我们还将讨论如果不指定这些依赖项时,Kubernetes的行为。
存储的依赖性
让我们研究一下应用程序最常见的运行时需求:持久存储。默认情况下,对正在运行的容器的文件系统所做的任何修改,都会在容器重新启动时丢失。Kubernetes提供了两个解决方案来确保更改的持久性:emptyDir和持久卷(Persistent Volumes)。
使用持久卷,你可以存储即使整个Pod终止或重新启动也不会被删除的数据。有几种方法可以为集群提供后端存储。它取决于集群所在的环境(在本地或在云上,和云供应商)。在接下来的实验中,我们使用主机的磁盘作为持久卷后端存储。使用持久卷的供应存储涉及两个步骤:
在接下来的实验中,我们使用主机的本地磁盘创建一个持久卷。创建一个新的YAML定义文件PV.yaml,并添加以下行:
apiVersion: v1
kind: PersistentVolume
metadata:
name: hostpath-vol
spec:
storageClassName: local
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
hostPath:
path: "/tmp/data"
这个定义创建一个持久卷(Persistent Volume,简称PV),它使用主机磁盘作为后端存储。卷安装在主机上的/tmp/data目录上。我们需要在应用配置之前创建这个目录:
mkdir /tmp/data
$ kubectl apply -f PV.yaml
persistentvolume/hostpath-vol created
现在,可以创建一个持久的卷声明(Persistent Volume Claim,简称PVC),并通过挂载点将其用于Pod来存储数据。下面的定义文件创建了一个PVC和一个使用它的Pod:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-pvc
spec:
storageClassName: local
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
---
apiVersion: v1
kind: Pod
metadata:
name: pvc-example
spec:
containers:
- image: alpine
name: pvc-example
command: ['sh', '-c', 'sleep 10000']
volumeMounts:
- mountPath: "/data"
name: my-vol
volumes:
- name: my-vol
persistentVolumeClaim:
claimName: my-pvc
应用这个定义文件创建PVC,跟着是Pod:
$ kubectl apply -f pvc_pod.yaml
persistentvolumeclaim/my-pvc created
pod/pvc-example created
在容器内/data上创建或修改的任何数据都将持久化到主机的磁盘上。你可以通过登录到容器中,在/data下创建文件,重新启动Pod,然后确保该文件仍然存在于Pod上,来检查这一点。你还可以注意到,在/tmp/data中创建的文件可以立即用于Pod及其容器。
请注意,在前面的实验室中,我们只使用了一个节点,所以当我们需要调度需要PVC才能正常工作的Pod时,应该不会有任何问题。但是,如果我们处于多节点环境中,在使用Kubernetes时经常出现这种情况,而某个给定节点无法提供持久卷,那么Pod将永远不会被调度到这个节点。如果集群中的所有节点都不能提供请求的卷,则可能出现更糟糕的情况。在这种情况下,Pod根本不会被调度。
hostPort的依赖性
如果使用hostPort选项,则显式地允许从主机外部访问内部容器端口。使用主机端口的Pod在同一主机上不能有多个副本,因为端口冲突。如果没有节点可以提供所需的端口,假设它是一个标准端口号,比如端口80或443,那么hostPort选项中使用的Pod将永远不会被调度。此外,这在Pod和它的宿主节点之间创建了一对一的关系。因此,在有四个节点的集群中,最多只能有四个使用hostPort选项的pod,假设每个节点上都有可用的端口。
配置的依赖
几乎所有的应用程序都可以通过变量进行定制。例如,MySQL至少需要初始的根凭证;WordPress需要数据库主机和名称,等等。Kubernetes提供了configMaps,用于将变量注入到Pods内部的容器中,并提供了Secrets,用于诸如帐户凭证等机密变量。让我们来看一个如何使用configMaps向Pod提供变量的快速示例:
下面的定义文件创建了一个configMap和一个Pod,它使用在这个configMap中定义的变量:
kind: ConfigMap
apiVersion: v1
metadata:
name: myconfigmap
data:
# Configuration values can be set as key-value properties
dbhost: db.example.com
dbname: mydb
---
kind: Pod
apiVersion: v1
metadata:
name: mypod
spec:
containers:
- name: mycontainer
image: nginx
envFrom:
- configMapRef:
name: myconfigmap
现在,让我们应用这个配置,并确保我们可以使用容器内的环境变量:
$ kubectl apply -f pod.yml
configmap/myconfigmap created
pod/mypod created
$ kubectl exec -it mypod -- bash
root@mypod:/# echo $dbhost
db.example.com
root@mypod:/# echo $dbname
mydb
但是,这就产生了它自己的依赖项:如果configMap不可用,那么容器可能无法正常工作。在我们的示例中,如果这个容器是一个需要持续数据库连接才能工作的应用程序,那么如果它无法获得数据库名称和主机,则可能根本无法工作。
对于Secrets也是如此,在生成任何客户机容器之前,必须是第一手可用的。
资源的依赖关系
到目前为止,我们讨论了影响Pod调度哪个节点的不同运行时依赖关系(如果有的话),以及为了使Pod正确工作,必须利用的各种先决条件。但是,你还必须考虑容器的容量要求。
可控和不可控资源
在设计应用程序时,我们需要知道该应用程序可能消耗的资源类型。一般来说,资源可分为两大类:
声明Pod资源需求
这两种资源类型之间的区别对于一个好的设计是至关重要的。Kubernetes允许你声明Pod运行所需的CPU和内存的数量。有两个参数,你可以用于此声明:
让我们快速举一个例子,假设一个应用程序需要至少512MB和25%的CPU内核才能运行。这样一个Pod的定义文件可能是这样的:
kind: Pod
apiVersion: v1
metadata:
name: mypod
spec:
containers:
- name: mycontainer
image: myapp
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 750Mi
当调度程序设法部署这个Pod时,它将搜索至少有512MB空闲内存的节点。如果找到了合适的节点,Pod将在其上调度。否则,Pod将永远无法部署。请注意,在决定将Pod部署到何处时,调度程序只考虑请求字段。
如何计算资源请求和限制?
内存以字节计算,但允许使用Mi和Gi等单位来指定请求的数量。注意,不应该指定高于节点上的内存量的内存限制。如果你这么做了,Pod就永远不会被调度。此外,由于内存是不可共享的资源,如果容器试图请求超过限制的内存,那么它将被杀死。通过更高级别的控制器,如ReplicaSet或Deployment,创建的容器在崩溃或终止时自动重新启动。因此,总是建议你通过控制器创建Pods。
CPU是通过millicores计算的。1 core=1000 millicores。因此,如果你期望你的容器至少需要半核心操作,你将请求设置为500m。但是,由于CPU属于可共享资源,所以当容器请求的CPU数量超过限制时,它不会被终止。相反,Kubelet会对容器进行节流,这可能会对容器的性能产生负面影响。这里建议你使用活性(liveness)和准备(readiness)探测来确保应用程序的延迟不会影响你的业务需求。
当你(不)指定请求和限制时会发生什么?
大多数Pod定义示例都忽略了请求(requests)和限制(limits)参数。在设计集群时,并不严格要求你包含它们。添加或忽略请求和限制将影响Pod接收到的服务质量(QoS),具体如下:
最低优先级的Pod:当你不指定请求和限制时,Kubelet会尽最大努力来处理你的Pod。在这种情况下,Pod的优先级最低。如果节点耗尽了不可共享的资源,则会首先杀死“尽最大努力处理”的Pod。
中等优先级的Pod:如果你定义了两个参数并将请求设置为小于限制,那么Kubernetes将以Burstable方式管理你的Pod。当节点耗尽了不可共享的资源时,只有在没有运行“尽最大努力处理”的Pod时,才会杀死Burstable的Pod。
最高优先级的Pod:当你将请求和限制设置为相等的值时,你的Pod将被视为最高优先级。这就好像你在说,“我需要这个Pod消耗的内存不少于x,CPU不多于y”。在这种情况下,在节点耗尽可共享资源的情况下,Kubernetes不会终止这些Pods,直到“尽最大努力处理”的Pod,并且终止了Burstable Pods。这是最高优先级的Pod。
我们可以总结Kubelet如何处理Pod优先级如下:
Pod优先级和抢占
有时,你可能需要更细粒度的控制,以便在资源缺乏的情况下,首先驱逐哪个Pod。如果将请求和限制设置为相等的值,则可以保证最后驱逐给定的Pod。但是,考虑这样一个场景:你有两个Pod,一个托管你的核心应用程序,另一个托管它的数据库。你需要这些Pod在与它们共存的其它Pod中具有最高的优先级。但是你还有一个额外的要求:你希望在数据库Pod被清除之前先清除应用程序Pod。幸运的是,Kubernetes有一个特性可以满足这种需求:Pod优先级(Priority)和抢占(Preemption)。Pod优先级和抢占在Kubernetes 1.14或更高时是稳定的。从Kubernetes 1.11版(beta版)开始,该特性就默认启用了。如果集群版本小于1.11,则需要显式启用此功能。
因此,回到我们的示例场景,我们需要两个高优先级的Pod,但是其中一个比另一个更重要。我们首先创建一个PriorityClass,然后一个Pod使用这个PriorityClass:
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000
---
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- image: redis
name: mycontainer
priorityClassName: high-priority
定义文件创建两个对象:PriorityClass和Pod。 让我们仔细看看PriorityClass对象:
第1行:API版本。如前所述,从Kubernetes 1.14开始,PriorityClass是稳定的。
第2行:对象类型
第3行和第4行:我们在其中定义对象名称的元数据。
第5行:我们指定相对于集群中其他Pod计算优先级的值。值越高表示优先级越高。
接下来,我们通过引用其名称定义使用此PriorityClass的Pod。
鉴于Pod的PriorityClass值,如何调度它们?
当我们有多个具有不同PriorityClass值的Pod时,准入控制器首先根据Pod的优先级对Pod进行排序。优先级最高的Pod(那些具有最高PriorityClass编号的Pod)将首先被调度,只要没有其它约束阻止它们的调度。
现在,如果没有可用资源来调度高优先级Pod的节点会发生什么?调度程序将从节点中逐出(抢占)较低优先级的Pod,以便为较高优先级的Pod提供足够的空间。调度程序将继续优先级较低的Pod,直到有足够的空间容纳更多较高优先级的Pod。
在设计集群时,此功能可以为你提供帮助,以确保除非没有其他选择,否则永远不会驱逐优先级最高的Pod(例如核心应用程序和数据库)。同时,被优先调度。
使用QoS和Pod优先级时设计中要考虑的事项
你可能会问,将资源和限制(QoS)与PriorityClass参数结合使用时,会发生什么情况。它们是否相互重叠或重叠?在下面的几行中,我们向你展示一些影响计划决策的重要事项:
Kubelet使用QoS来控制和管理Pod中节点的有限资源。仅当节点开始耗尽可共享资源时,才会进行QoS驱逐(请参阅本文前面的讨论)。Kubelet在考虑抢占优先级之前先考虑QoS。
调度程序在QoS之前考虑Pod的PriorityClass。除非需要安排较高优先级的Pod,并且节点没有足够的空间容纳它们,否则它不会尝试驱逐Pod。
当调度程序决定抢占优先级较低的Pod时,它将尝试进行干净关机,并遵守宽限期(默认为30秒)。但是,它不支持PodDisruptionBudget,这可能会导致破坏几个低优先级Pod的群集仲裁。
总结