일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
- Salting
- eks
- logback
- Helm
- SW 마에스트로
- Benchmarks
- zset
- Leaderboard
- slow query
- spring boot
- yml
- Kubernetes
- Software maestro
- hammerDB
- Database
- Debezium
- minreplica
- 0 replica
- blue-green
- SW Maestro
- Grafana
- keda
- docket
- propogation
- Strimzi
- 스프링부트
- traceId
- 동등성
- Kafka
- MSSQL
- Today
- Total
김태오
Keda를 사용한 pod replica 0으로 scale in 하기 본문
쿠버네티스에는 기본적인 스케일러로 HPA(Horizontal Pod Autoscaler)가 있다. Scale out, scale in 을 위해 존재하며 pod relica 개수를 조절해준다.
이를 조절하는 기준으로 yaml 혹은 api 등으로 명시한 resource metric(CPU, memory 등) 이 target utilization 과의 상하여부를 따져 한다.
간단한 yaml 을 살펴보자.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: example-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: example-deployment
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 80
이런 식의 HPA에서는 Resource인 cpu가 80을 넘을 시 replica가 하나 생성되고, 내려갈 시 하나가 사라지는 방식이라고 보면 된다.
물론 이런 cpu 계산이 여러 개의 pod들이 있음에도 어떻게 이루어지는지 궁금해진다.
이는 아래 형태의 식을 통해 계산된다.
desiredReplicas = ceil[currentReplicas * (currentMetricValue / targetMetricValue)]
가령, 현재 4개의 replica가 존재하며 80%의 averageUtilization을 선언했으며, 현재 pod 들의 평균 cpu 가 120%라고 가정하자.
desiredReplicas = ceil[4 * (120 / 80)] = ceil[6] = 6 replicas
위의 수식을 적용하면, 4개에서 6개의 replica로 scale out 되어야 함이 도출된다.
이와 같이 HPA를 잘 사용하던 와중, 문득 resource 사용량이 전혀 없는 새벽의 개발/릴리즈서버에서는 replica 개수를 0으로 줄이는게 경제적이라는 생각을 했다. 그래서 minReplica를 0으로 두고 적용해본 결과, 1 미만으로는 replica개수가 절대 줄어들지 않았다.
개발자 입장에서 오픈소스 프로젝트의 가장 큰 이점은 소스코드를 까볼 수 있는 것이니, k8s HPA 쪽 코드를 까보았다.
func startHPAControllerWithMetricsClient(ctx context.Context, controllerContext ControllerContext, metricsClient metrics.MetricsClient) (controller.Interface, bool, error) {
hpaClient := controllerContext.ClientBuilder.ClientOrDie("horizontal-pod-autoscaler")
hpaClientConfig := controllerContext.ClientBuilder.ConfigOrDie("horizontal-pod-autoscaler")
// we don't use cached discovery because DiscoveryScaleKindResolver does its own caching,
// so we want to re-fetch every time when we actually ask for it
scaleKindResolver := scale.NewDiscoveryScaleKindResolver(hpaClient.Discovery())
scaleClient, err := scale.NewForConfig(hpaClientConfig, controllerContext.RESTMapper, dynamic.LegacyAPIPathResolverFunc, scaleKindResolver)
if err != nil {
return nil, false, err
}
go podautoscaler.NewHorizontalController(
ctx,
hpaClient.CoreV1(),
scaleClient,
hpaClient.AutoscalingV2(),
controllerContext.RESTMapper,
metricsClient,
controllerContext.InformerFactory.Autoscaling().V2().HorizontalPodAutoscalers(),
controllerContext.InformerFactory.Core().V1().Pods(),
controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerSyncPeriod.Duration,
controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerDownscaleStabilizationWindow.Duration,
controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerTolerance,
controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerCPUInitializationPeriod.Duration,
controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerInitialReadinessDelay.Duration,
).Run(ctx, int(controllerContext.ComponentConfig.HPAController.ConcurrentHorizontalPodAutoscalerSyncs))
return nil, true, nil
}
일단 새로운 HPA를 만드는 구간이 보인다. 여기에서 NewHorizontalController로 가보면,
replicaCalc := NewReplicaCalculator(
metricsClient,
hpaController.podLister,
tolerance,
cpuInitializationPeriod,
delayOfInitialReadinessStatus,
)
이런게 눈에 띄는데, 간단히 필드들을 살펴보면,
metricsClient : CPU 혹은 memory util 을 가져와 취합하기 위한 클라
podLister : 실제 pod 의 현재 readiness, health 등의 pod metric을 가져오기 위한 클라
tolerance : replica 개수 계산 시에 이 숫자를 넘어야 scaling이 됨
cpuInitializationPeriod : pod 이 최초 생성되었을 때 cpu 가 불안정함으로 이 기간에는 계산에서 제외시킴
delayOfInitialReadinessStatus : 이건 pod이 제외되는 게 아니라 그냥 HPA가 replica scaling을 하지 않을 기간이다
이제 replica_calculator.go 의 GetResourceReplicas function으로 들어가보면, 마지막에 어떻게 replica 개수가 계산되는지 자세히 볼 수 있다.
// re-run the utilization calculation with our new numbers
newUsageRatio, _, _, err := metricsclient.GetResourceUtilizationRatio(metrics, requests, targetUtilization)
if err != nil {
return 0, utilization, rawUtilization, time.Time{}, err
}
if math.Abs(1.0-newUsageRatio) <= c.tolerance || (usageRatio < 1.0 && newUsageRatio > 1.0) || (usageRatio > 1.0 && newUsageRatio < 1.0) {
// return the current replicas if the change would be too small,
// or if the new usage ratio would cause a change in scale direction
return currentReplicas, utilization, rawUtilization, timestamp, nil
}
newReplicas := int32(math.Ceil(newUsageRatio * float64(len(metrics))))
if (newUsageRatio < 1.0 && newReplicas > currentReplicas) || (newUsageRatio > 1.0 && newReplicas < currentReplicas) {
// return the current replicas if the change of metrics length would cause a change in scale direction
return currentReplicas, utilization, rawUtilization, timestamp, nil
}
// return the result, where the number of replicas considered is
// however many replicas factored into our calculation
return newReplicas, utilization, rawUtilization, timestamp, nil
그런데 이곳에는 newReplicas 가 0이면 안된다는 어떠한 validation도 찾을 수 없다. 중간중간 나오는 private 함수들을 보았들 때도 마찬가지다.
그렇다면 다시 horizontal.go 로 향해본다.
여기에는
var minReplicas int32
if hpa.Spec.MinReplicas != nil {
minReplicas = *hpa.Spec.MinReplicas
} else {
// Default value
minReplicas = 1
}
rescale := true
logger := klog.FromContext(ctx)
if currentReplicas == 0 && minReplicas != 0 {
// Autoscaling is disabled for this resource
desiredReplicas = 0
rescale = false
setCondition(hpa, autoscalingv2.ScalingActive, v1.ConditionFalse, "ScalingDisabled", "scaling is disabled since the replica count of the target is zero")
} else if currentReplicas > hpa.Spec.MaxReplicas {
rescaleReason = "Current number of replicas above Spec.MaxReplicas"
desiredReplicas = hpa.Spec.MaxReplicas
} else if currentReplicas < minReplicas {
rescaleReason = "Current number of replicas below Spec.MinReplicas"
desiredReplicas = minReplicas
}
이런 스니펫이 존재하는데, minReplicas는 사용자가 선언하는 변수 중 하나로, 위에서 제시했던 yaml에는 2로 선언되어 있다. resource metric이 아무리 적어진다 한들, 최소한 minReplicas 개수의 replica는 유지하고 싶다는 뜻이다. 이를 선언하지 않으면 1이 된다는 것을 볼 수 있으며, 0으로 선언할 수 없다는 것은 Spec.MinReplicas에서 확인할 수 있다.
// minReplicas is the lower limit for the number of replicas to which the autoscaler
// can scale down. It defaults to 1 pod. minReplicas is allowed to be 0 if the
// alpha feature gate HPAScaleToZero is enabled and at least one Object or External
// metric is configured. Scaling is active as long as at least one metric value is
// available.
// +optional
MinReplicas *int32 `json:"minReplicas,omitempty" protobuf:"varint,2,opt,name=minReplicas"`
주석상으로 특별한 alpha feature gate를 선언하지 않는 이상 0으로 minReplicas를 선언하지 못함을 알 수 있다. 이후 validate.go에서도 코드상으로 minReplicas가 0이 될 수 없음을 확인할 수 있다.
func validateMetrics(metrics []autoscaling.MetricSpec, fldPath *field.Path, minReplicas *int32) field.ErrorList {
allErrs := field.ErrorList{}
hasObjectMetrics := false
hasExternalMetrics := false
for i, metricSpec := range metrics {
idxPath := fldPath.Index(i)
if targetErrs := validateMetricSpec(metricSpec, idxPath); len(targetErrs) > 0 {
allErrs = append(allErrs, targetErrs...)
}
if metricSpec.Type == autoscaling.ObjectMetricSourceType {
hasObjectMetrics = true
}
if metricSpec.Type == autoscaling.ExternalMetricSourceType {
hasExternalMetrics = true
}
}
if minReplicas != nil && *minReplicas == 0 {
if !hasObjectMetrics && !hasExternalMetrics {
allErrs = append(allErrs, field.Forbidden(fldPath, "must specify at least one Object or External metric to support scaling to zero replicas"))
}
}
return allErrs
}
그런데 alpha feature gate 는 아직 오픈소스에 도입되지 않은, unstable한 실험적 기능을 위해 있는 설정이다. 결국 상용에서 k8s만을 사용할 때 minReplicas 개수를 0으로 줄일 수 없음을 뜻한다.
여기에서 나오는게 Keda인데, k8s와 마찬가지로 CNCF graduate project이며, HPA대용으로 널리 사용되는 스케일러이다.
'대용'에서 알 수 있듯, HPA와 Keda를 동시에 선언하는 것은 불가능하다.
Keda를 이해하기 위한 핵심적인 플로우 이미지이다. 가장 중요한 부분은 Keda는 replica 개수를 0->1, 1->0 로 조절하는 것을 담당하며, HPA가 나머지를 담당한다는 것이다. 자세한 것은 Keda docs에서 찾아볼 수 있다.
다음은 pod replica를 0으로 만들 수 있는 yaml 파일이다.
{{- if or (eq .Values.env "develop") (eq .Values.env "release") }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: some-application-{{ .Values.env }}-scaledobject
namespace: some-application
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: some-application-{{ .Values.env }}
cooldownPeriod: 300
minReplicaCount: 0
maxReplicaCount: 10
triggers:
- type: cron
metadata:
timezone: Asia/Seoul
start: "30 08 * * *"
end: "30 19 * * *"
desiredReplicas: "1"
- type: memory
metricType: Utilization
metadata:
value: "90"
{{- end }}
우선 개발/릴리즈 pod들만 0으로 스케일링하기 때문에 Values 등의 템플릿에 환경을 선언하여 가져다 쓰면 편리하다.
HPA에서도 볼 수 있었던 minReplicas, maxReplicas가 상이한 변수명으로 선언되어 있고, cooldownPeriod 라는 변수도 있다. 이는 폭발적인 트래픽 등 급격한 resource 의 변동 감지시 잠깐 안정화될 때까지 기다려주는 시간이라고 보면 된다.
Keda에서 가장 중요한 것이 trigger인데, 방대한 종류의 trigger사용이 가능하다. 가령 cloudwatch 상에서 로그 수집이 적어질 때 pod 개수를 조절할 수도 있고, prometheus의 커스텀 메트릭을 사용한 조절도 가능하다.
초기 목표가 특정 시간에 pod 개수를 0으로 스케일링하는 것이었기에, cron type 의 trigger를 생성하여, 오전 08:30~19:30에 pod 개수를 1로 잡고, 외의 시간에는 선언하지 않는다.
추가로 memory 타입의 trigger도 생성했는데, 이렇듯 여러 개의 trigger가 존재할 시 각 trigger 계산의 최댓값에 해당하는 replica가 생성된다. (https://github.com/kedacore/keda/issues/5078)