四、OpenFaaS社区版基于Keda实现函数扩缩容

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. 如果函数实例数大于 1,然后创建了此函数的 ScaledObject,马上会因为没获取到激活指标,而直接把函数实例缩为 0。
  2. 如果先创建了某函数的 ScaledObject,再部署一个函数,函数一部署就会被删除,因为来不及获取指标。
  3. 手动将函数实例数从 0 扩为 1,因为没有激活指标,所以立马给你删了。
  4. 通过 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 加上 ZeroStartAtZeroAt 俩字段,记录缩为 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.gocrd,不过它打包时会自动在镜像里生成,可以不用管。
当然,也可以运行make manifestsmake 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-operatorkeda-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

镜像文件主要是改DockerfileDockerfile.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字段,在其后添加 zeroAtzeroStartAt定义

scaleTargetKind:
  type: string
zeroAt:
  format: date-time
  type: string
zeroStartAt:
  format: date-time
  type: string

添加打印显示数据,可以在执行kubectl get scaledobjects时把ZeroAtZeroStartAt显示出来,依旧是修改自定义资源 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查询添加的字段ZEROSTARTATZEROAT数据:

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

ZEROATZEROSTARTAT的时间间隔通常为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

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

唉真难起名字

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值