OpenFaaS 社区版不能设置每个函数的存活时间,也不能单独设置每个函数的扩缩容指标。虽然它的商业版有个伸缩组件,但是无法使用。不过,天无绝人之路,我们可以借助 KEDA(Kubernetes Event-driven Autoscaling)实现每个函数自定义伸缩。
KEDA 是一个基于事件的自动伸缩器, 相比 HPA 只能利用监控数据进行伸缩, KEDA 可以利用更多数据源进行伸缩,比如队列消息、数据库、Redis 等,当然也包括监控数据。在使用 KEDA 时,也会借助 HPA 的能力,创建 HPA 对象。
一、部署 KEDA
这里装的的是 v2.11.0 版的,支持 k8s v1.25、v1.26 、v1.27 ,我的 k8s 版本是 v1.23,貌似装了也没啥问题。
官方部署文档:https://keda.sh/docs/2.11/deploy/
一般来说,部署核心组件即可,也就是不包括admission webhooks
。不过,KEDA 镜像托管在 ghcr.io
, 国内估计很难下载,这里我保存了一份在docker hub
:
- glxfcx/keda-operator:2.11.0
- glxfcx/keda-metrics-apiserver:2.11.0
部署核心组件
包括 keda-operator、keda-metrics-apiserver
kubectl apply -f https://github.com/kedacore/keda/releases/download/v2.11.0/keda-2.11.0-core.yaml
部署所有组件
包括 keda-operator、keda-metrics-apiserver、keda-admission-webhooks
kubectl apply -f https://github.com/kedacore/keda/releases/download/v2.11.0/keda-2.11.0.yaml
其中:
- keda-operator:负责处理 KEDA 内置对象、HPA 对象
- keda-metrics-apiserver:提供给 HPA 的 external 类型指标,借助 HPA 实现弹性
- keda-admission-webhooks :负责验证资源更改,拒绝任何不符合规则的资源
查看 Pod 是否正常,这里我只部署了俩组件
kubectl -n keda get pod
NAME READY STATUS RESTARTS AGE
keda-metrics-apiserver-7db4fb85bd-bqdfn 1/1 Running 1 (12m ago) 12m
keda-operator-cd79c5558-gvdsr 1/1 Running 1 (12m ago) 12m
部署出了个问题
The CustomResourceDefinition “scaledjobs.keda.sh” is invalid: metadata.annotations: Too long: must have at most 262144 bytes
解决办法:删除已经创建的资源,把apply换成create重新创建就可以了
!!!注意:必须禁用掉 OpenFaaS 的 alertmanager 组件,不然会对缩放有影响。
!!!注意:faas-nets源码里设置了函数最大副本数为10,如果想改,可以看我之前的文章。
二、配置 ScaledObject
ScaledObject 对象是 KEDA 的核心对象,它定义了伸缩的目标对象、触发器、伸缩策略等。ScaledJob 与 ScaledObject 类似,只是它的目标对象是 Job。
这里我使用之前文章里自定义的指标gateway_function_requests_total
,它表示函数请求总数。
vim hellofaas.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: hello-faas.openfaas-fn
namespace: openfaas-fn
spec:
scaleTargetRef:
name: hello-faas
pollingInterval: 15
cooldownPeriod: 60
minReplicaCount: 0
maxReplicaCount: 20
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.openfaas.svc:9090
threshold: '30'
metricName: gateway_function_requests_total
query: sum (rate(gateway_function_requests_total{function_name="hello-faas.openfaas-fn"}[1m]))
其中:
- scaleTargetRef:指定伸缩的目标对象
- pollingInterval:指定触发器的轮询间隔,Prometheus指标采样间隔为 15s,这里也设置为 15s
- cooldownPeriod:指定副本数变为 0 的冷却时间
- minReplicaCount:最小副本数
- maxReplicaCount: 最大副本数
- triggers 指定触发器,这里使用一个 Prometheus触发器,它的参数有:
- serverAddress:Prometheus 服务地址
- metricName:指标名称 (这个在新版本中取消了)
- threshold:扩容阈值
- query:Prometheus 查询语句
Pod 的副本数 = query / threshold。这里按照每个 Pod 处理 30 QPS,设置 Pod 的数量。
最后执行kubectl apply -f hellofaas.yaml
将其应用。
三、测试函数扩缩容
准备监控指标
# QPS
sum by (function_name) (rate(gateway_function_requests_total{function_name="hello-faas.openfaas-fn"}[1m]))
# service_count 函数实例数量
gateway_service_count{function_name="hello-faas.openfaas-fn"}
# scaler_metrics keda获取的指标值,用于和QPS对比
keda_scaler_metrics_value{scaledObject="hello-faas.openfaas-fn"}
# scaler_active keda缩放器是否激活,为1激活
keda_scaler_active{scaledObject="hello-faas.openfaas-fn"}
然后在 grafana 里配置好即可
压力测试函数
先使用 hey 测试 OpenFaaS 部署的一个函数 hello-faas
。
hey -z 1m -c 5 -q 50 -t 30 http://127.0.0.1:31112/function/hello-faas
这里的 -z 1m -c 5 -q 50 -t 30 参数的意思是,使用 5 个客户端,以 QPS 为 50 的每秒请求数,持续 1 分钟,每个请求的 timeout 时间为 30s。
查看下图可以发现,根据 QPS 值 167 以及 ScaledObject 设置的 threshold 30,可以计算最终扩容的函数实例数为 6 (167 除以 30 向上取整)。
注意:HPA 控制器为了避免副本倍增过快还加了个约束:单次倍增的倍数不能超过 2 倍,而如果原副本数小于 2,则可以一次性扩容到 4 副本。
可以通过kubectl describe hpa/keda-hpa-hello-faas.openfaas-fn -n openfaas-fn
查询对应的 HPA 对象。
四、扩缩容高级配置
快速扩容、延时缩容
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: hello-faas.openfaas-fn
namespace: openfaas-fn
spec:
scaleTargetRef:
name: hello-faas
pollingInterval: 15
cooldownPeriod: 300
minReplicaCount: 0
maxReplicaCount: 20
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 15
policies:
- type: Pods
value: 3
periodSeconds: 15
scaleDown:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 3
periodSeconds: 15
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.openfaas.svc:9090
threshold: '30'
metricName: gateway_function_requests_total
query: sum (rate(gateway_function_requests_total{function_name="hello-faas.openfaas-fn"}[1m]))
在 advanced 参数中有:
- stabilizationWindowSeconds:指标稳定时间,也就是指标达到阈值后,需要持续多久才会触发伸缩
- scaleUp:扩容策略
- scaleDown:缩容策略
- policies:策略列表
- type:指缩容的类型
- periodSeconds:指每隔多久执行一次策略
- value:指每次执行策略时,增加或减少的 Pod 数量
这里的意思是: 扩容时,持续 15s 就会触发伸缩,每隔 15s 扩容 3 个 Pod;缩容时,持续 60s 才会触发伸缩,每隔 15s 缩容 3 个 Pod。
注意:如果 cooldownPeriod 设置的时间小于 scaleDown 策略的 stabilizationWindowSeconds,那么 scaleDown 策略来不及执行,副本数会直接为 0。 所以上面配置将 cooldownPeriod 设置为 5分钟,这也是默认的值。
五、缩容为 0 的问题
鼓捣了很久发现有几个问题:
- 如果函数实例数大于 1,然后创建了此函数的 ScaledObject,马上会因为没获取到激活指标,而直接把函数实例缩为 0。
- 如果先创建了某函数的 ScaledObject,再部署一个函数,函数一部署就会被删除,因为来不及获取指标。
- 手动将函数实例数从 0 扩为 1,因为没有激活指标,所以立马给你删了。
- 通过 http 请求调用函数,OpenFaaS 将函数实例数从 0 扩为 1,同时指标
gateway_function_requests_total
也更改了,但可能会因为 KEAD 缩放器轮询获取指标较晚,缩放器先一步判定没激活直接给缩为 0,结果下一次缩放器检测到这个指标更改,于是激活了,又通过 KEDA 把函数实例扩为 1。 结果就导致 KEDA 先删了OpenFaaS 创建的,自己再创建一个函数实例。
查看了下资料,发现没其它配置,鉴于学习的目的,所以动手改下 KEDA 源码。
六、修改 KEDA 源码
大概思路就是:当 KEDA 发现某个 ScaledObject
要缩为 0 时,先看缓存是否记录了此对象正在缩,如果没有,就设置 ScaledObject
的一个字段ZeroStartAt
,表示这个时候开始缩。然后判断 ZeroStartAt
加上 cooldownPeriod
是否在当前时间之前,如果是之前就表示已经过了cooldownPeriod
的时间了,可以缩。否则就等过了cooldownPeriod
的时间再缩。
下载源码
git clone https://github.com/kedacore/keda
修改 api/keda/v1alpha1/scaledobject_types.go
给 ScaledObjectStatus
加上 ZeroStartAt
和 ZeroAt
俩字段,记录缩为 0 的开始和结束时间。
type ScaledObjectStatus struct {
// 省略代码
// +optional
HpaName string `json:"hpaName,omitempty"`
// +optional
ZeroStartAt *metav1.Time `json:"zeroStartAt,omitempty"`
// +optional
ZeroAt *metav1.Time `json:"zeroAt,omitempty"`
}
一般来说,修改了这些字段得重新生成zz_generated.deepcopy.go
和crd
,不过它打包时会自动在镜像里生成,可以不用管。
当然,也可以运行make manifests
和make generate
来生成。
修改 pkg/scaling/executor/scale_executor.go
给 scaleExecutor
加上 zeroStartCache
字段,记录是否开始进行缩为 0 的操作。记得导入sync
包。
type scaleExecutor struct {
client runtimeclient.Client
scaleClient scale.ScalesGetter
reconcilerScheme *runtime.Scheme
logger logr.Logger
recorder record.EventRecorder
// 添加如下,记录是否开始进行缩为 0 的操作
zeroStartCache *sync.Map
}
func NewScaleExecutor(client runtimeclient.Client, scaleClient scale.ScalesGetter, reconcilerScheme *runtime.Scheme, recorder record.EventRecorder) ScaleExecutor {
return &scaleExecutor{
client: client,
scaleClient: scaleClient,
reconcilerScheme: reconcilerScheme,
logger: logf.Log.WithName("scaleexecutor"),
recorder: recorder,
// 添加如下,赋值
zeroStartCache: &sync.Map{},
}
}
// 添加updateTime函数,用于修改各种时间
func (e *scaleExecutor) updateTime(ctx context.Context, logger logr.Logger, object interface{}, needToWait bool, field string) error {
if !needToWait {
return e.updateLastActiveTime(ctx, logger, object)
}
now := metav1.Now()
transform := func(runtimeObj runtimeclient.Object, target interface{}) error {
now, ok := target.(metav1.Time)
if !ok {
return fmt.Errorf("transform target is not metav1.Time type %v", target)
}
switch obj := runtimeObj.(type) {
case *kedav1alpha1.ScaledObject:
if field == "LastActiveTime" {
obj.Status.LastActiveTime = &now
obj.Status.ZeroStartAt = &now
} else if field == "ZeroStartAt" {
obj.Status.ZeroStartAt = &now
} else if field == "ZeroAt" {
obj.Status.ZeroAt = &now
}
case *kedav1alpha1.ScaledJob:
obj.Status.LastActiveTime = &now
default:
}
return nil
}
return kedautil.TransformObject(ctx, e.client, logger, object, now, transform)
}
修改 pkg/scaling/executor/scale_scaledobjects.go
在函数外定义个常量,表示一个标签。通过查询 ScaledObject
对象是否包含这个标签,判断是不是要等一会再缩为0
// 添加如下标签
const scaledObjectWaitLabel = "wait.a.miniute"
func (e *scaleExecutor) RequestScale(ctx context.Context, scaledObject *kedav1alpha1.ScaledObject, isActive bool, isError bool) {
// 省略一堆代码
minReplicas := int32(0)
if scaledObject.Spec.MinReplicaCount != nil {
minReplicas = *scaledObject.Spec.MinReplicaCount
}
// 添加如下,检测当前对象是否需要缩
needToWait := false
if scaledObject.Labels != nil {
value, found := scaledObject.Labels[scaledObjectWaitLabel]
if found && value == "true" {
needToWait = true
}
}
if isActive {
switch {
// 省略代码
// 在这个default里改
default:
// 将e.updateLastActiveTime改为e.updateTime
err := e.updateTime(ctx, logger, scaledObject, needToWait, "LastActiveTime")
if err != nil {
logger.Error(err, "Error updating last active time")
return
}
}
} else {
// isActive == false
// 省略代码
// 在这个case里改
case scaledObject.Spec.IdleReplicaCount != nil && currentReplicas > *scaledObject.Spec.IdleReplicaCount,
currentReplicas > 0 && minReplicas == 0:
// 修改e.scaleToZeroOrIdle的参数,添加needToWait
e.scaleToZeroOrIdle(ctx, logger, scaledObject, currentScale, needToWait)
}
}
// 省略代码
}
// 添加最后一个参数needToWait
func (e *scaleExecutor) scaleToZeroOrIdle(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, scale *autoscalingv1.Scale, needToWait bool) {
// 从函数开头,添加如下
// 导入"k8s.io/client-go/tools/cache"这个包
key, err := cache.MetaNamespaceKeyFunc(scaledObject)
if err != nil {
logger.Error(err, "Error getting key for scaledObject")
return
}
// 添加判断,这个对象需要等一等再缩为0
if needToWait {
_, isZeroStarted := e.zeroStartCache.Load(key)
if !isZeroStarted || scaledObject.Status.ZeroStartAt == nil {
err := e.updateTime(ctx, logger, scaledObject, needToWait, "ZeroStartAt")
if err != nil {
logger.Error(err, "Error updating zero start time")
return
}
e.zeroStartCache.Store(key, true)
// 开一个协程,判断数量
go e.checkReplicaCountIsZero(scaledObject, logger, key)
logger.Info("Update zero start time", "ZeroStartAt", scaledObject.Status.ZeroStartAt)
}
}
var cooldownPeriod time.Duration
if scaledObject.Spec.CooldownPeriod != nil {
cooldownPeriod = time.Second * time.Duration(*scaledObject.Spec.CooldownPeriod)
} else {
cooldownPeriod = time.Second * time.Duration(defaultCooldownPeriod)
}
// 添加一点条件,不用等的就执行以前的逻辑(即没指标直接为0)
if (needToWait && scaledObject.Status.ZeroStartAt.Add(cooldownPeriod).Before(time.Now())) ||
(!needToWait && (scaledObject.Status.LastActiveTime == nil || scaledObject.Status.LastActiveTime.Add(cooldownPeriod).Before(time.Now()))) {
idleValue, scaleToReplicas := getIdleOrMinimumReplicaCount(scaledObject)
if err == nil {
// 添加,缩为0就删除缓存,并记录缩为0的时间
if needToWait {
e.zeroStartCache.Delete(key)
e.updateTime(ctx, logger, scaledObject, needToWait, "ZeroAt")
}
msg := "Successfully set ScaleTarget replicas count to ScaledObject"
// 省略代码
}
// 省略代码
}
// 添加checkReplicaCountIsZero函数,用于循环检测实例数是否为0。
// 因为可能在缩为0的倒计时过程中,通过手动直接将Pod缩为0,会出现zeroStartCache没被执行Delete的情况,这样这个Pod扩为1时会延续那个倒计时导致直接被删除掉。
// 这里只考虑了Pod数量不由KEDA控制导致的缓存问题。如果是KEDA挂了重启,那么缓存全没了,也就是全得重新计时,暂不考虑这个问题
func (e *scaleExecutor) checkReplicaCountIsZero(scaledObject *kedav1alpha1.ScaledObject, logger logr.Logger, key string) {
// 这里直接用的scaledObject的PollingInterval作为轮询间隔
pollInterval := time.Second * time.Duration(30)
if scaledObject.Spec.PollingInterval != nil {
pollInterval = time.Second * time.Duration(*scaledObject.Spec.PollingInterval)
}
stopCh := make(chan struct{})
// 导入"k8s.io/apimachinery/pkg/util/wait"包
wait.Until(func() {
deployment := &appsv1.Deployment{}
err := e.client.Get(context.TODO(), client.ObjectKey{Name: scaledObject.Spec.ScaleTargetRef.Name, Namespace: scaledObject.Namespace}, deployment)
if err != nil {
logger.Error(err, "Error getting information on the current Scale (ie. replicas count) on the scaleTarget")
close(stopCh)
}
if *deployment.Spec.Replicas == 0 {
e.zeroStartCache.Delete(key)
logger.V(1).Info("Detected replicas count is zero", "name", scaledObject.Name)
close(stopCh)
}
}, pollInterval, stopCh)
logger.V(1).Info("Detected end")
}
至此,源码修改完毕。
七、重新部署 KEDA
因为修改了scaledObject
对象的字段,所以必须把要用的组件都重新打包部署,即keda-operator
和keda-metrics-apiserver
都得打包一遍,如果用到了keda-admission-webhooks
,也得打包。我这里不用它,就只打包前两者。
Dockerfile
镜像打包没什么问题,主要就是使用的镜像源可能国内难以下载,我在docker hub
存了一份:
ghcr.io/kedacore/build-tools:1.20.5 => glxfcx/keda-build-tools:1.20.5
gcr.io/distroless/static:nonroot => glxfcx/distroless-static-nonroot:latest
镜像文件主要是改Dockerfile
和Dockerfile.adapter
FROM glxfcx/keda-build-tools:1.20.5 AS builder
# 省略其它
FROM glxfcx/distroless-static-nonroot:latest
Makefile
在原先的Makefile
里修改,先设置版本和镜像仓库
我这里没用它的 docker-build,而是自己加了 manager-bp 和 adapter-bp 命令
# 这里设置版本
ifeq '${E2E_IMAGE_TAG}' ''
VERSION ?= 2.11.0.1
SUFFIX =
endif
# 设置镜像仓库
IMAGE_REGISTRY ?= xxx
docker-build: ## Build docker images with the KEDA Operator and Metrics Server.
DOCKER_BUILDKIT=1 docker build . -t ${IMAGE_CONTROLLER} --build-arg BUILD_VERSION=${VERSION} --build-arg GIT_VERSION=${GIT_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT}
DOCKER_BUILDKIT=1 docker build -f Dockerfile.adapter -t ${IMAGE_ADAPTER} . --build-arg BUILD_VERSION=${VERSION} --build-arg GIT_VERSION=${GIT_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT}
DOCKER_BUILDKIT=1 docker build -f Dockerfile.webhooks -t ${IMAGE_WEBHOOKS} . --build-arg BUILD_VERSION=${VERSION} --build-arg GIT_VERSION=${GIT_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT}
# 构建keda-operator
manager-bp:
docker build . -t ${IMAGE_CONTROLLER} --build-arg BUILD_VERSION=${VERSION} --build-arg GIT_VERSION=${GIT_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT}
docker push $(IMAGE_CONTROLLER)
# 构建keda-metrics-apiserver
adapter-bp:
docker build -f Dockerfile.adapter -t ${IMAGE_ADAPTER} . --build-arg BUILD_VERSION=${VERSION} --build-arg GIT_VERSION=${GIT_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT}
docker push $(IMAGE_ADAPTER)
构建
make manager-bp
make adapter-bp
修改 crd
修改自定义资源 scaledobjects
的对象定义,在keda-2.11.0-core.yaml
里搜索scaleTargetKind
字段,在其后添加 zeroAt
和zeroStartAt
定义
scaleTargetKind:
type: string
zeroAt:
format: date-time
type: string
zeroStartAt:
format: date-time
type: string
添加打印显示数据,可以在执行kubectl get scaledobjects
时把ZeroAt
和ZeroStartAt
显示出来,依旧是修改自定义资源 scaledobjects
的对象定义的additionalPrinterColumns
下的定义。(也可以不用改这个)
- additionalPrinterColumns:
# 省略
- jsonPath: .status.conditions[?(@.type=="Paused")].status
name: Paused
type: string
- jsonPath: .status.zeroStartAt
name: ZeroStartAt
type: string
- jsonPath: .status.zeroAt
name: ZeroAt
type: string
部署
将镜像推送到仓库后,修改keda-2.11.0-core.yaml
内组件的镜像地址为自己构建的,再apply即可
kubectl apply -f keda-2.11.0-core.yaml
修改 ScaledObject 配置
给ScaledObject
添加wait.a.miniute: "true"
的标签,重新apply。
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: hello-faas.openfaas-fn
namespace: openfaas-fn
labels:
wait.a.miniute: "true"
spec:
# 省略
最终,在不触发指标更改的情况下,手动扩容再也不会立马缩为0。
可以通过kubectl get scaledobjects -n openfaas-fn
查询添加的字段ZEROSTARTAT
和ZEROAT
数据:
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX TRIGGERS AUTHENTICATION READY ACTIVE FALLBACK PAUSED AGE ZEROSTARTAT ZEROAT
hello-faas.openfaas-fn apps/v1.Deployment hello-faas 0 20 prometheus True False False Unknown 39m 2023-06-25T13:47:36Z 2023-06-25T13:48:36Z
ZEROAT
和ZEROSTARTAT
的时间间隔通常为cooldownPeriod
的值。
参考链接:
https://keda.sh/docs/2.10/concepts/scaling-deployments/
https://mp.weixin.qq.com/s/EoyNEFqi4mIB2zFwaejqug
https://blog.csdn.net/weixin_67470255/article/details/126258629