降本提效是创新开发的永恒话题。过去多年来,开发者纷纷拥抱容器技术以提高部署效率,降低运维负担。随着像Docker这类容器引擎使用量不断增长,作为Docker管理系统的Kubernetes(简称K8s)顺势而出,帮助开发者构建并简化复杂的容器编排工作。
延伸阅读,点击链接了解 Akamai Cloud Computing
本文Akamai将带大家一起看看,如何准确确定Kubernetes集群的规模,并根据需求更灵活、动态地对集群规模进行缩放,从而在满足负载需求的同时最大限度降低成本。
一、高效确定Kubernetes集群的最优规模
每当我们需要创建Kubernetes集群时,肯定首先都会问自己:该用什么类型的工作节点?具体需要多少个?
例如,当我们正在使用Linode Kubernetes引擎(LKE)等托管式Kubernetes服务,到底该使用8个2GB的Linode实例,还是2个8GB的Linode实例来实现所需计算能力?
在回答这个问题之前需要注意:无论自建K8s集群或任何云平台上托管的K8s,并非所有工作节点中的资源都可以用于运行工作负载。
1.Kubernetes节点预留
在Kubernetes节点中,CPU和内存会被划分给:
- 操作系统
- Kubelet、CNI、CRI、CSI(和系统守护程序)
- Pod
- 驱逐阈值
假设有个只有一个Linode 2GB计算实例的集群(包含1个vCPU和2GB内存),以下资源会被保留给kubelet和操作系统:
- 500MB内存。
- 60m的CPU。
此外,还有100MB内存为驱逐阈值保留。
总的来说,此时我们有30%的内存和6%的CPU是不能被工作负载使用的。
每个云提供商都有各自定义限制的方式,但在CPU方面他们似乎不约而同进行了以下限制:
- 第一个核心的6%;
- 下一个核心的1%(最多2个核心);
- 接下来的2个核心的0.5%(最多4个);以及
- 四个以上核心的0.25%。
至于内存方面的限制,不同提供商之间有很大差异。但一般来说,内存的预留往往遵循以下限制:
- 前4GB内存的25%;
- 接下来4GB内存的20%(最多8GB);
- 接下来8GB内存的10%(最多16GB);
- 下一个112GB内存的6%(最多128GB);以及
- 超过128GB的任何内存的2%。
既然知道了工作节点内资源的分配方式,那么我们该选择哪种实例?答案因具体情况而异,我们需要根据工作负载的实际情况来选择最佳工作节点。
2.剖析应用程序
Kubernetes中有两种方法来指定容器可以使用多少内存和CPU:
- 请求:通常与正常操作时的应用程序消耗量相匹配。
- 限制:设置允许的最大资源数量。
Kubernetes调度程序使用请求来确定在集群中分配Pod的位置。由于调度程序不知道消耗情况(Pod尚未启动),因此它需要一个提示。这些“提示”就是请求;我们可以为内存和CPU分别设置请求。
kubelet使用限制在内存使用超出允许范围时停止进程。如果使用的CPU时间超过允许的范围,kubelet也会限制该进程。但是,该如何选择适当的请求和限制值呢?
我们可以测量工作负载性能(例如平均值、95和99百分位数等)并将其用作请求和限制。为了简化该过程,可以通过两个便利的工具来加速分析:
- Vertical Pod Autoscaler
- Kubernetes Resource Recommender
VPA会收集内存和CPU利用率数据,并运行一个回归算法,为我们的部署建议请求和限制。这是一个官方的Kubernetes项目,也可以用于自动调整值:我们可以让控制器直接在YAML中更新请求和限制。
KRR的工作原理类似,但它利用了我们通过Prometheus导出的数据。作为第一步,工作负载应该被配置为将度量数据导出到Prometheus。一旦存储了所有度量数据,就可以使用KRR来分析数据并建议请求和限制。
在具备了(粗略的)资源需求概念后,终于可以继续选择一个实例类型了。
3.选择实例类型
假设估算自己的工作负载需要2GB的内存请求,并且估计至少需要约10个副本。我们可以排除大多数小于2GB的小型实例。此时也许可以直接使用某些大型实例,例如Linode 32GB。
接下来,可以将内存和CPU除以可部署在该实例上的最大Pod数量(例如在LKE中的110个),以获得内存和CPU的单元数量。
例如,Linode 32GB的CPU和内存单元为:
- 内存单元为257MB(即(32GB – 3.66GB预留)/ 110)
- CPU单元为71m(即(8000m – 90m预留)/ 110)
在最后一步中,我们可以使用这些单元来估算有多少工作负载可以适应节点。
假设想要部署一个Spring Boot,请求为6GB和1 vCPU,这相当于:
- 适合6GB的最小单元是24个单元(24 * 257MB = 6.1GB)
- 适合1 vCPU的最小单元是15个单元(15 * 71m = 1065m)
这些数字表明,内存耗尽之前受限会将CPU耗尽,并且最多可以在集群中部署(110/24)4个应用程序。
当我们在此实例上运行四个工作负载时,将使用:
- 24个内存单元* 4 = 96个单元,有14个未使用(约12%)
- 15个vCPU单元 * 4 = 60个单元,有50个未使用(约45%)
还不错,但能做得更好吗?让我们尝试使用Linode 64GB实例(64GB / 16 vCPU)。
假设要部署相同的应用程序,数字会发生一些变化:
- 内存单元约为527MB(即(64GB – 6.06GB预留)/ 110)。
- CPU单元约为145m(即(16000m – 110m预留)/ 110)。
- 适合6GB的最小单元是12个单元(12 * 527MB = 6.3GB)。
- 适合1 vCPU的最小单元是7个单元(7 * 145m = 1015m)。
可以在这个实例中放多少工作负载?由于将耗尽内存,并且每个工作负载需要12个单元,所以最大应用程序数是9(即110/12)。
计算效率/浪费比例将会发现:
- 12个内存单元 * 9 = 108个单元,有2个未使用(约2%)
- 7个vCPU单元 * 9 = 63个单元,有47个未使用(约42%)
虽然浪费的CPU数量几乎与前一个实例相同,但内存利用率得到了显着改善。
最后,我们还可以比较一下成本:
- Linode 32GB实例最多可以容纳4个工作负载。在这样的总容量下,每个Pod的成本为每月48美元(即实例成本192美元除以4个工作负载)。
- Linode 64 GB实例最多可以容纳9个工作负载。在这样的总容量下,每个Pod的成本为每月42.6美元(即实例成本384美元除以9个工作负载)。
换句话说,选择较大的实例可以为我们每月每个工作负载节省多达6美元。
4.使用计算器对比不同节点
如果想测试更多实例该怎么办?进行这些计算需要很多工作。我们可以使用learnsk8s计算器加快该过程。
使用该计算器的第一步是输入内存和CPU请求。系统会自动计算保留的资源并提供利用率和成本建议。此外还有一些额外的实用功能:按照应用程序用量分配最接近的CPU和内存请求。如果应用程序偶尔会突发高CPU或内存使用率,也可以灵活应对。
但是当所有Pod都将所有资源使用到极限会发生什么?这可能导致超额承诺。我们可以通过门户中的小组件了解CPU和内存超额承诺的百分比。那么当超额承诺时具体又会发生什么?
- 如果内存超额承诺,kubelet将驱逐Pod并将其移动到集群中的其他位置。
- 如果CPU超额承诺,工作负载将按比例使用可用的CPU。
最后,我们还可以使用DaemonSets和Agent小组件,这是一个方便的机制,可以模拟在所有节点上运行的Pod。例如,LKE将Cilium和CSI插件部署为DaemonSets。这些Pod使用的资源对工作负载不可用,应从计算中减去。该小组件可以帮我们做到这一点!
二、按需开关更省钱
为了尽可能降低基础设施成本,我们可以在不使用某些资源时将其关闭。然而此时的挑战之处在于,必要时该如何将资源自动打开。接下来我们一起看看如何使用Linode Kubernetes Engine(LKE)部署一个Kubernetes集群,并使用Kubernetes Events-Driven Autoscaler(KEDA)将其收缩到“零”,然后恢复原状。
1.为何要收缩到零
假设我们在Kubernetes上运行了一个常见的资源密集型应用,但我们只需要在工作时间里运行。此时可能会希望在大家都下班后将其关闭,并在上班时间自动重新打开。
虽然可以使用CronJob来缩放实例,但这只是权宜之计,只能按照预先设定的时间表照计划运行。
周末怎么办?公共假期又如何处理?如果整个团队都生病无法到岗呢?
与其编制一个不断增长的规则列表,不如根据流量来扩展我们的工作负载。当流量增加时,可以扩展副本数量;当没有流量时,可以将整个应用关闭。当应用关闭后又收到新的传入请求后,Kubernetes会启动至少一个副本来处理这些流量。
接下来一起看看该如何拦截去往应用程序的所有流量,监控流量,并设置Autoscaler调整副本数量或关闭应用。
2.创建集群
首先需要创建一个Kubernetes集群。可使用下列命令创建一个集群并保存kubeconfig文件。
$ linode-cli lke cluster-create \
--label cluster-manager \
--region eu-west \
--k8s_version 1.23
$ linode-cli lke kubeconfig-view "insert cluster id here" --text | tail +2 | base64 -d > kubeconfig
通过下列命令验证安装过程已成功完成:
$ kubectl get pods -A --kubecnotallow=kubeconfig
用环境变量导出kubeconfig文件通常是一种比较方便的做法。为此可以运行:
$ export KUBECONFIG=${PWD}/kubeconfig
$ kubectl get pods
接着需要部署应用程序。
3.部署应用程序
apiVersion: apps/v1
kind: Deployment
metadata:
name: podinfo
spec:
selector:
matchLabels:
app: podinfo
template:
metadata:
labels:
app: podinfo
spec:
containers:
- name: podinfo
image: stefanprodan/podinfo
ports:
- containerPort: 9898
---
apiVersion: v1
kind: Service
metadata:
name: podinfo
spec:
ports:
- port: 80
targetPort: 9898
selector:
app: podinfo
使用下列命令提交YAML文件:
terminal|command=1|title=bash
$ kubectl apply -f 1-deployment.yaml
随后即可访问该应用,为此请打开浏览器并访问localhost:8080。
$ kubectl port-forward svc/podinfo 8080:80
接着应该就能看到这个应用了。
接下来需要安装KEDA,也就是本例中将会用到的Autoscaler。
4.KEDA:Kubernetes事件驱动的Autoscaler
Kubernetes提供的Horizontal Pod Autoscaler(HPA)可以作为控制器动态增减副本数量。然而HPA有一些不足之处:
- 无法拆箱即用,需要安装Metrics Server汇总和暴露指标。
- 无法缩放至零副本。
- 只能根据指标缩放副本,并且无法拦截HTTP流量。
好在并非只能使用官方提供的Autoscaler,我们还可以使用KEDA。KEDA是一种为下列三个组件打造的Autoscaler:
- Scaler
- Metrics Adapter
- Controller
Scaler类似于适配器,可以从数据库、消息代理、遥测系统等处收集指标。例如,HTTP Scaler这个适配器就可以拦截并收集HTTP流量。我们可以在这里看到一个 使用RabbitMQ的Scaler范例。
Metrics Adapter负责以Kubernetes指标管道可以使用的格式导出Scaler所收集的指标。
最后,Controller可以将所有这些组件紧密结合在一起:
- 使用适配器收集指标,并将其暴露给指标API。
- 注册并管理KEDA指定的自定义资源定义(CRD),例如ScaledObject、TriggerAuthentication等。
- 代替我们创建并管理Horizontal Pod Autoscaler。
理论上的介绍就是这些了,一起看看它们实际上是如何起效的。
我们可以使用Helm快速安装Controller,详细的说明和介绍请参阅Helm官网。
$ helm repo add kedacore https://kedacore.github.io/charts
$ helm install keda kedacore/keda
KEDA默认并不包含HTTP Scaler,因此需要单独安装:
$ helm install http-add-on kedacore/keda-add-ons-http
随后就可以扩展我们的应用了。
5.定义Autoscaling策略
KEDA的HTTP加载项会暴露出一个CRD,借此我们可以描述应用程序的扩展方式。一起看一个例子:
kind: HTTPScaledObject
apiVersion: http.keda.sh/v1alpha1
metadata:
name: podinfo
spec:
host: example.com
targetPendingRequests: 100
scaleTargetRef:
deployment: podinfo
service: podinfo
port: 80
replicas:
min: 0
max: 10
该文件会指示拦截器将有关http://example.com的请求转发给podinfo服务。
其中还包含了需要扩展的部署的名称,本例中为podinfo。
使用下列命令将YAML提交至集群:
$ kubectl apply -f scaled-object.yaml
提交了上述定义后,Pod被删除了!为何会这样?
在创建了HTTPScaledObject后,KEDA会立即将该部署收缩到零,因为目前没有流量。
为了进行扩展,我们必须向应用发出HTTP请求。试试看连接到该服务并发出一个请求。
$ kubectl port-forward svc/podinfo 8080:80
这个命令被挂起了!
这种现象是合理的,因为目前没有可以为请求提供服务的Pod。但Kubernetes为何没有将该部署扩展为1?
6.测试KEDA拦截器
在使用Helm安装加载项时,会创建一个名为keda-add-ons-http-interceptor-proxy的Kubernetes服务。为了让自动扩展能够正常起效,HTTP流量必须首先通过该服务进行路由。我们可以用kubectl port-forward进行测试:
$ kubectl port-forward svc/keda-add-ons-http-interceptor-proxy 8080:8080
这一次我们无法在浏览器中访问该URL。
一个KEDA HTTP拦截器可以处理多个部署,那么它如何知道要将流量路由到哪里?
kind: HTTPScaledObject
apiVersion: http.keda.sh/v1alpha1
metadata:
name: podinfo
spec:
host: example.com
targetPendingRequests: 100
scaleTargetRef:
deployment: podinfo
service: podinfo
port: 80
replicas:
min: 0
max: 10
针对这种情况,HTTPScaledObject使用了一个host 段。在本例中,我们需要假装请求来自http://example.com。为此需要设置Host头:
$ curl localhost:8080 -H 'Host: example.com'
我们将收到一个回应,尽管略微有些延迟。
检查Pod会发现,部署已经被扩展至一个副本:
$ kubectl get pods
那么刚才到底发生了什么?
在将流量路由至KEDA的服务时,拦截器会追踪尚未收到回复的未决HTTP请求数量。KEDA Scaler会定期检查拦截器的队列大小,并存储相关指标信息。
KEDA Controller会监控指标,并根据需要增大或减小副本数量。本例中有一个未决请求,此时KEDA Controller将部署扩展为一个副本就已足够。
我们可以通过下列方式获取每个拦截器的未决HTTP请求队列状态:
$ kubectl proxy &
$ curl -L localhost:8001/api/v1/namespaces/default/services/keda-add-ons-http-interceptor-admin:9090/proxy/queue
{"example.com":0,"localhost:8080":0}
由于这种设计的存在,我们必须慎重决定该用何种方式将流量路由给应用。KEDA只能在流量可被拦截的情况下才会对部署进行扩展。
如果有一个现有的入口Controller,并且希望使用该Controller将流量转发给应用,那么还需要修改入口清单,将流量转发给HTTP加载项服务。一起看一个例子。
7.将KEDA HTTP加载项与入口配合使用
我们可以使用Helm安装Nginx-ingress controller:
$ helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace
随后写一个入口清单,将流量路由给podinfo:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: podinfo
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keda-add-ons-http-interceptor-proxy # <- this
port:
number: 8080
通过下列命令可以获取负载均衡器的IP地址:
LB_IP=$(kubectl get services -l "app.kubernetes.io/compnotallow=controller" -o jsnotallow="{.items[0].status.loadBalancer.ingress
[0].ip}" -n ingress-nginx)
最后使用下列命令向应用发出一个请求:
curl $LB_IP -H "Host: example.com"
起作用了!如果等待足够长的时间,我们还将注意到,该部署最终被收缩到零。
三、通过Autoscaler实现Kubernetes的伸缩
在设计Kubernetes集群时,我们可能经常需要回答以下问题:
- 集群伸缩需要多长时间?
- 在新Pod创建之前需要等待多长时间?
有四个主要因素会影响集群的伸缩:
- Horizontal Pod Autoscaler的反应时间;
- Cluster Autoscaler的反应时间;
- 节点预配时间;以及
- Pod创建时间。
下文将依次讨论这些因素。
默认情况下,kubelet每10秒从Pod中提取一次CPU使用情况数据,而Metrics Server每1分钟从kubelet获取一次这些数据。Horizontal Pod Autoscaler每30秒检查一次CPU和内存度量。
如果度量超过阈值,Autoscaler会增加Pod的副本数,并在采取进一步行动之前暂停3分钟。在最糟糕的情况下,可能要等待长达3分钟才能添加或删除Pod,但平均而言,用户应该期望等待1分钟后Horizontal Pod Autoscaler即可触发伸缩。
Horizontal Pod Autoscaler的反应时间
Cluster Autoscaler会检查是否有待处理的Pod,并增加集群的大小。检测到需要扩展集群可能需要:
- 在具有少于100个节点和3000个Pod的集群上最多需要30秒,平均延迟约为5秒;或
- 在具有100个以上节点的集群上最多需要60秒的延迟,平均延迟约为15秒。
Cluster Autoscaler的反应时间
Linode上的节点预配,也就是从Cluster Autoscaler触发API到新创建节点上可以调度Pod,这一过程需要大约3-4分钟时间。
Linode的预配时间
简而言之,对于小规模集群,我们会面临:
- HPA延迟:1m +
- CA延迟:0m30s +
- 云提供商:4m +
- 容器运行时:0m30s +
——————————————
总计6m
端到端Autoscaler反应时间
对于具有100个以上节点的集群,总延迟可能为6分30秒…… 这是一个相当长的时间,那么该如何解决这个问题?可以主动调整工作负载,或者如果非常了解流量模式,也可以提前伸缩。
1.使用KEDA进行预伸缩
如果流量的变化模式可预测,那么在高峰之前扩展工作负载(和节点)就是可行的。
Kubernetes没有提供根据日期或时间扩展工作负载的机制,但我们可以使用上文提到的KEDA实现目标。
使用Helm安装KEDA:
$ helm repo add kedacore https://kedacore.github.io/charts
$ helm install keda kedacore/keda
安装好Prometheus和KEDA后,创建一个部署。
apiVersion: apps/v1
kind: Deployment
metadata:
name: podinfo
spec:
replicas: 1
selector:
matchLabels:
app: podinfo
template:
metadata:
labels:
app: podinfo
spec:
containers:
- name: podinfo
image: stefanprodan/podinfo
用下列命令将资源提交到集群:
$ kubectl apply -f deployment.yaml
KEDA在现有的Horizontal Pod Autoscaler之上工作,并使用名为ScaleObject的自定义资源定义(CRD)进行包装。下列ScaledObject使用Cron Scaler定义了更改副本数的时间窗口:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: cron-scaledobject
namespace: default
spec:
maxReplicaCount: 10
minReplicaCount: 1
scaleTargetRef:
name: podinfo
triggers:
- type: cron
metadata:
timezone: Europe/London
start: 23 * * * *
end: 28 * * * *
desiredReplicas: "5"
用下列命令提交对象:
$ kubectl apply -f scaled-object.yaml
接下来会发生什么?什么也不会发生。自动伸缩只会在23 * * * *到28 * * * *之间触发。在Cron Guru的帮助下,我们可以将这两个Cron表达式翻译成:
- 从第23分钟开始(例如2:23、3:23等)。
- 在第28分钟停止(例如2:28、3:28等)。
如果等到开始时间,我们将注意到副本数增加到5。
使用KEDA通过Cron表达式进行伸缩
在第28分钟后,副本数是否恢复到1?是的,自动伸缩器会恢复为minReplicaCount中指定的副本数。
如果在其中一个时间间隔内增加副本数会发生什么?如果在23和28分钟之间,我们将部署的副本数扩展到10,KEDA将覆盖我们的更改并设置计数。如果在第28分钟后重复相同实验,副本数将设置为10。
在了解了理论后,让我们看一些实际用例。
2.在工作时间内伸缩
假设我们在开发环境中部署了一个应该在工作时间段内处于活跃状态,并且在夜间应该关闭的工作负载。
可以使用以下ScaledObject:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: cron-scaledobject
namespace: default
spec:
maxReplicaCount: 10
minReplicaCount: 0
scaleTargetRef:
name: podinfo
triggers:
- type: cron
metadata:
timezone: Europe/London
start: 0 9 * * *
end: 0 17 * * *
desiredReplicas: "10"
默认副本数为零,但在工作时间(上午9点到下午5点)期间,副本会扩展到10个。
仅在工作时间内扩展工作负载
我们还可以扩展Scaled Object以排除周末:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: cron-scaledobject
namespace: default
spec:
maxReplicaCount: 10
minReplicaCount: 0
scaleTargetRef:
name: podinfo
triggers:
- type: cron
metadata:
timezone: Europe/London
start: 0 9 * * 1-5
end: 0 17 * * 1-5
desiredReplicas: "10"
这样,工作负载将仅在周一至周五的9点到17点活跃。由于可以组合多个触发器,因此还可以包括一些例外情况。
3.在周末伸缩
我们可能计划在星期三让工作负载保持更长时间,为此可使用以下定义:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: cron-scaledobject
namespace: default
spec:
maxReplicaCount: 10
minReplicaCount: 0
scaleTargetRef:
name: podinfo
triggers:
- type: cron
metadata:
timezone: Europe/London
start: 0 9 * * 1-5
end: 0 17 * * 1-5
desiredReplicas: "10"
- type: cron
metadata:
timezone: Europe/London
start: 0 17 * * 3
end: 0 21 * * 3
desiredReplicas: "10"
在此定义中,工作负载会在周一至周五的9点到17点之间处于活动状态,但星期三会从9点持续到21点。
总结
按需缩放是一种有效降低成本的方法。Kubernetes作为一种容器编排平台,提供了自动化管理和部署容器化应用程序的功能,使得按需缩放变得更加容易实现。
根据本文提供的思路,我们可以根据应用程序的需求变化情况,动态调整资源,并在需要时自动扩展或缩减规模,从而降低成本并提高资源利用率。
本文所涉及的内容,不仅适用于Linode平台上提供的托管式Kubernetes集群,也同样适用于大家在本地环境或其他云平台上部署的集群。希望这些内容对大家有所帮助,也欢迎关注Akamai机构号,了解更多通过云平台降本增效的技巧。
—————————————————————————————————————————————————
如您所在的企业也在考虑采购云服务或进行云迁移,
点击链接了解Akamai Linode的解决方案