diff --git a/runtimes/nodejs/package-lock.json b/runtimes/nodejs/package-lock.json index da22a42e09..45ab8ef6d2 100644 --- a/runtimes/nodejs/package-lock.json +++ b/runtimes/nodejs/package-lock.json @@ -1738,15 +1738,6 @@ "@types/xml2js": "*" } }, - "node_modules/@types/fs-extra": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.8.tgz", - "integrity": "sha512-bnlTVTwq03Na7DpWxFJ1dvnORob+Otb8xHyUqUWhqvz/Ksg8+JXPlR52oeMSZ37YEOa5PyccbgUNutiQdi13TA==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -7958,15 +7949,6 @@ "@types/xml2js": "*" } }, - "@types/fs-extra": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.8.tgz", - "integrity": "sha512-bnlTVTwq03Na7DpWxFJ1dvnORob+Otb8xHyUqUWhqvz/Ksg8+JXPlR52oeMSZ37YEOa5PyccbgUNutiQdi13TA==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", diff --git a/server/package-lock.json b/server/package-lock.json index b0370b4532..e6e011ec70 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "@nestjs/schedule": "^2.1.0", "@nestjs/swagger": "^6.1.3", "@nestjs/throttler": "^3.1.0", + "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "compression": "^1.7.4", "cron-validate": "^1.4.5", @@ -8935,10 +8936,8 @@ }, "node_modules/class-transformer": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "optional": true, - "peer": true + "resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { "version": "0.14.0", @@ -24077,10 +24076,8 @@ }, "class-transformer": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "optional": true, - "peer": true + "resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "class-validator": { "version": "0.14.0", diff --git a/server/package.json b/server/package.json index a76c9b8c78..357cf5b8f6 100644 --- a/server/package.json +++ b/server/package.json @@ -41,6 +41,7 @@ "@nestjs/schedule": "^2.1.0", "@nestjs/swagger": "^6.1.3", "@nestjs/throttler": "^3.1.0", + "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "compression": "^1.7.4", "cron-validate": "^1.4.5", diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 8bea8a7239..c4c38a2247 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -65,6 +65,11 @@ export class ApplicationController { @ApiResponseObject(ApplicationWithRelations) @Post() async create(@Req() req: IRequest, @Body() dto: CreateApplicationDto) { + const error = dto.autoscaling.validate() + if (error) { + return ResponseUtil.error(error) + } + const user = req.user // check regionId exists @@ -274,6 +279,11 @@ export class ApplicationController { @Body() dto: UpdateApplicationBundleDto, @Req() req: IRequest, ) { + const error = dto.autoscaling.validate() + if (error) { + return ResponseUtil.error(error) + } + const app = await this.application.findOne(appid) const user = req.user const regionId = app.regionId diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 0efca4eaac..be6d4c5096 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -69,6 +69,7 @@ export class ApplicationService { { appid, resource: this.buildBundleResource(dto), + autoscaling: this.buildAutoscalingConfig(dto), isTrialTier: isTrialTier, createdAt: new Date(), updatedAt: new Date(), @@ -292,11 +293,13 @@ export class ApplicationService { ) { const db = SystemDatabase.db const resource = this.buildBundleResource(dto) + const autoscaling = this.buildAutoscalingConfig(dto) + const res = await db .collection('ApplicationBundle') .findOneAndUpdate( { appid }, - { $set: { resource, updatedAt: new Date(), isTrialTier } }, + { $set: { resource, autoscaling, updatedAt: new Date(), isTrialTier } }, { projection: { 'bundle.resource.requestCPU': 0, @@ -386,4 +389,16 @@ export class ApplicationService { return resource } + + private buildAutoscalingConfig(dto: UpdateApplicationBundleDto) { + const autoscaling = { + enable: false, + minReplicas: 1, + maxReplicas: 5, + targetCPUUtilizationPercentage: null, + targetMemoryUtilizationPercentage: null, + ...dto.autoscaling, + } + return autoscaling + } } diff --git a/server/src/application/dto/create-autoscaling.dto.ts b/server/src/application/dto/create-autoscaling.dto.ts new file mode 100644 index 0000000000..d7261d43fa --- /dev/null +++ b/server/src/application/dto/create-autoscaling.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + Max, + Min, + ValidateIf, +} from 'class-validator' + +export class CreateAutoscalingDto { + @ApiProperty({ default: false }) + @IsNotEmpty() + @IsBoolean() + enable: boolean + + @ApiProperty({ default: 1 }) + @IsNotEmpty() + @IsInt() + @Min(1) + @Max(19) + @ValidateIf(({ enable }) => enable) + minReplicas: number + + @ApiProperty({ default: 5 }) + @IsNotEmpty() + @IsInt() + @Min(2) + @Max(20) + @ValidateIf(({ enable }) => enable) + maxReplicas: number + + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + @ValidateIf(({ enable }) => enable) + targetCPUUtilizationPercentage?: number + + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + @ValidateIf(({ enable }) => enable) + targetMemoryUtilizationPercentage?: number + + validate() { + if (this.enable) { + if (this.maxReplicas <= this.minReplicas) { + return 'Max replicas must be smaller than min replicas.' + } + if ( + !this.targetCPUUtilizationPercentage && + !this.targetMemoryUtilizationPercentage + ) { + return 'Either targetCPUUtilizationPercentage or targetMemoryUtilizationPercentage must be specified.' + } + if ( + this.targetCPUUtilizationPercentage && + this.targetMemoryUtilizationPercentage + ) { + return 'TargetCPUUtilizationPercentage and TargetMemoryUtilizationPercentage cannot be specified simultaneously.' + } + } + return null + } +} diff --git a/server/src/application/dto/update-application.dto.ts b/server/src/application/dto/update-application.dto.ts index 451d5bfa6a..a4a37007fa 100644 --- a/server/src/application/dto/update-application.dto.ts +++ b/server/src/application/dto/update-application.dto.ts @@ -1,6 +1,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { IsIn, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' +import { + IsIn, + IsInt, + IsNotEmpty, + IsString, + Length, + ValidateNested, +} from 'class-validator' import { ApplicationState } from '../entities/application' +import { CreateAutoscalingDto } from './create-autoscaling.dto' +import { Type } from 'class-transformer' const STATES = [ ApplicationState.Running, @@ -64,4 +73,9 @@ export class UpdateApplicationBundleDto { @IsNotEmpty() @IsInt() storageCapacity: number + + @ApiProperty({ type: CreateAutoscalingDto }) + @ValidateNested() + @Type(() => CreateAutoscalingDto) + autoscaling: CreateAutoscalingDto } diff --git a/server/src/application/entities/application-bundle.ts b/server/src/application/entities/application-bundle.ts index d4f36302cb..353d760af1 100644 --- a/server/src/application/entities/application-bundle.ts +++ b/server/src/application/entities/application-bundle.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' +import { Autoscaling } from './application-configuration' export class ApplicationBundleResource { @ApiProperty({ example: 500 }) @@ -53,6 +54,9 @@ export class ApplicationBundle { @ApiProperty() resource: ApplicationBundleResource + @ApiProperty() + autoscaling: Autoscaling + @ApiPropertyOptional() isTrialTier?: boolean diff --git a/server/src/application/entities/application-configuration.ts b/server/src/application/entities/application-configuration.ts index 4a5652a166..449d469392 100644 --- a/server/src/application/entities/application-configuration.ts +++ b/server/src/application/entities/application-configuration.ts @@ -9,6 +9,23 @@ export class EnvironmentVariable { value: string } +export class Autoscaling { + @ApiProperty() + enable: boolean + + @ApiProperty() + minReplicas: number + + @ApiProperty() + maxReplicas: number + + @ApiProperty() + targetCPUUtilizationPercentage?: number + + @ApiProperty() + targetMemoryUtilizationPercentage?: number +} + export class ApplicationConfiguration { @ApiProperty({ type: String }) _id?: ObjectId diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index c128f182b6..334d5b1fca 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -1,4 +1,9 @@ -import { V1Deployment, V1DeploymentSpec } from '@kubernetes/client-node' +import { + V1Deployment, + V1DeploymentSpec, + V2HorizontalPodAutoscaler, + V2HorizontalPodAutoscalerSpec, +} from '@kubernetes/client-node' import { Injectable, Logger } from '@nestjs/common' import { GetApplicationNamespaceByAppId } from '../utils/getter' import { @@ -53,18 +58,23 @@ export class InstanceService { if (!res.service) { await this.createService(app, labels) } + + if (!res.hpa) { + await this.createHorizontalPodAutoscaler(app, labels) + } } public async remove(appid: string) { const app = await this.applicationService.findOneUnsafe(appid) const region = app.region - const { deployment, service } = await this.get(appid) + const { deployment, service, hpa } = await this.get(appid) const namespace = await this.cluster.getAppNamespace(region, app.appid) if (!namespace) return // namespace not found, nothing to do const appsV1Api = this.cluster.makeAppsV1Api(region) const coreV1Api = this.cluster.makeCoreV1Api(region) + const hpaV2Api = this.cluster.makeHorizontalPodAutoscalingV2Api(region) // ensure deployment deleted if (deployment) { @@ -76,6 +86,14 @@ export class InstanceService { const name = appid await coreV1Api.deleteNamespacedService(name, namespace.metadata.name) } + + if (hpa) { + await hpaV2Api.deleteNamespacedHorizontalPodAutoscaler( + appid, + namespace.metadata.name, + ) + } + this.logger.log(`remove k8s deployment ${deployment?.metadata?.name}`) } @@ -84,18 +102,19 @@ export class InstanceService { const region = app.region const namespace = await this.cluster.getAppNamespace(region, app.appid) if (!namespace) { - return { deployment: null, service: null } + return { deployment: null, service: null, hpa: null, app } } const deployment = await this.getDeployment(app) const service = await this.getService(app) - return { deployment, service } + const hpa = await this.getHorizontalPodAutoscaler(app) + return { deployment, service, hpa, app } } public async restart(appid: string) { const app = await this.applicationService.findOneUnsafe(appid) const region = app.region - const { deployment } = await this.get(appid) + const { deployment, hpa } = await this.get(appid) if (!deployment) { await this.create(appid) return @@ -114,6 +133,9 @@ export class InstanceService { ) this.logger.log(`restart k8s deployment ${res.body?.metadata?.name}`) + + // reapply hpa when application is restarted + await this.reapplyHorizontalPodAutoscaler(app, hpa) } private async createDeployment(app: ApplicationWithRelations, labels: any) { @@ -365,4 +387,153 @@ export class InstanceService { } return spec } + + private async createHorizontalPodAutoscaler( + app: ApplicationWithRelations, + labels: any, + ) { + if (!app.bundle.autoscaling.enable) return null + + const spec = this.makeHorizontalPodAutoscalerSpec(app) + const hpaV2Api = this.cluster.makeHorizontalPodAutoscalingV2Api(app.region) + const namespace = GetApplicationNamespaceByAppId(app.appid) + const res = await hpaV2Api.createNamespacedHorizontalPodAutoscaler( + namespace, + { + apiVersion: 'autoscaling/v2', + kind: 'HorizontalPodAutoscaler', + spec, + metadata: { + name: app.appid, + labels, + }, + }, + ) + this.logger.log(`create k8s hpa ${res.body?.metadata?.name}`) + return res.body + } + + private async getHorizontalPodAutoscaler(app: ApplicationWithRelations) { + const appid = app.appid + const hpaV2Api = this.cluster.makeHorizontalPodAutoscalingV2Api(app.region) + + try { + const hpaName = appid + const namespace = GetApplicationNamespaceByAppId(appid) + const res = await hpaV2Api.readNamespacedHorizontalPodAutoscaler( + hpaName, + namespace, + ) + return res.body + } catch (error) { + if (error?.response?.body?.reason === 'NotFound') return null + throw error + } + } + + private makeHorizontalPodAutoscalerSpec(app: ApplicationWithRelations) { + const { + minReplicas, + maxReplicas, + targetCPUUtilizationPercentage, + targetMemoryUtilizationPercentage, + } = app.bundle.autoscaling + + const metrics: V2HorizontalPodAutoscalerSpec['metrics'] = [] + + if (targetCPUUtilizationPercentage) { + metrics.push({ + type: 'Resource', + resource: { + name: 'cpu', + target: { + type: 'Utilization', + averageUtilization: targetCPUUtilizationPercentage, + }, + }, + }) + } + + if (targetMemoryUtilizationPercentage) { + metrics.push({ + type: 'Resource', + resource: { + name: 'memory', + target: { + type: 'Utilization', + averageUtilization: targetMemoryUtilizationPercentage, + }, + }, + }) + } + + const spec: V2HorizontalPodAutoscalerSpec = { + scaleTargetRef: { + apiVersion: 'apps/v1', + kind: 'Deployment', + name: app.appid, + }, + minReplicas, + maxReplicas, + metrics, + behavior: { + scaleDown: { + policies: [ + { + type: 'Pods', + value: 1, + periodSeconds: 60, + }, + ], + }, + scaleUp: { + policies: [ + { + type: 'Pods', + value: 1, + periodSeconds: 60, + }, + ], + }, + }, + } + return spec + } + + public async reapplyHorizontalPodAutoscalerByAppid(appid: string) { + const { hpa, app } = await this.get(appid) + if (!hpa) { + const labels = { [LABEL_KEY_APP_ID]: appid } + return await this.createHorizontalPodAutoscaler(app, labels) + } + return await this.reapplyHorizontalPodAutoscaler(app, hpa) + } + + private async reapplyHorizontalPodAutoscaler( + app: ApplicationWithRelations, + oldHpa: V2HorizontalPodAutoscaler, + ) { + const { region, appid } = app + const hpaV2Api = this.cluster.makeHorizontalPodAutoscalingV2Api(region) + const namespace = GetApplicationNamespaceByAppId(appid) + + const hpa = oldHpa + hpa.spec = this.makeHorizontalPodAutoscalerSpec(app) + + if (!app.bundle.autoscaling.enable) { + if (!hpa) return + await hpaV2Api.deleteNamespacedHorizontalPodAutoscaler( + app.appid, + namespace, + ) + this.logger.log(`delete k8s hpa ${app.appid}`) + } else { + await hpaV2Api.replaceNamespacedHorizontalPodAutoscaler( + app.appid, + namespace, + hpa, + ) + this.logger.log(`reapply k8s hpa ${app.appid}`) + } + } } diff --git a/server/src/region/cluster/cluster.service.ts b/server/src/region/cluster/cluster.service.ts index 92f02debbb..d52aa59df0 100644 --- a/server/src/region/cluster/cluster.service.ts +++ b/server/src/region/cluster/cluster.service.ts @@ -164,4 +164,9 @@ export class ClusterService { const kc = this.loadKubeConfig(region) return kc.makeApiClient(k8s.CustomObjectsApi) } + + makeHorizontalPodAutoscalingV2Api(region: Region) { + const kc = this.loadKubeConfig(region) + return kc.makeApiClient(k8s.AutoscalingV2Api) + } }