Skip to content

Commit cbad4a2

Browse files
authored
fix: pdb validity check & deletion (#599)
# Description ## Background When creating Pod Disruption Budget, Kubernetes gives two ways for user to specify the allowed disruption amount, use `minAvailable` or `maxUnavailable` . The example of behavior is as below: ``` current replica = 2 Use MinAvailable: min available = 80% allowed disruption = current replica - min available replica = 2 - ceil(80% * 2) = 2 - 2 = 0 Use MaxUnavailable: max unavailable = 20% allowed disruption = ceil(20% * 2) = ceil(0.4) = 1 ``` ## Issue ### 1. Miscalculation on PDB validity check Merlin do a validity check on the PDB configuration, if the configuration doesn’t allow for any disruption, then Merlin will not create the PDB, this to avoid Kubernetes can’t remove any replica because all replica must be up. When the configuration in Merlin use the `maxUnavailability`, the calculation will be ([ref1](https://github.com/caraml-dev/merlin/blob/8c4930dc2c3f3edf3f1a862a99dd3cb5730c50cb/api/cluster/pdb.go#L88) & [ref2](https://github.com/caraml-dev/merlin/blob/8c4930dc2c3f3edf3f1a862a99dd3cb5730c50cb/api/cluster/pdb.go#L94-L96)): ``` minPercentage = (100 - maxUnavailability)% if not (minPercentage * minReplica < minReplica) don't create PDB ``` Which doesn’t reflect the real calculation on Kubernetes. Ex: ``` replica: 5 maxUnavailability: 10% Kubernetes calculation: allowed disruption = ceil(10% * 5) = ceil(0.5) = 1 Merlin calculation: minPercentage = (100 - 10)% = 90% minAvailableReplica = ceil(90% * 5) = ceil(4.5) = 5 allowed disruption = 5 - 5 = 0 ``` And therefore in the example above, Merlin will not create any PDB configuration because it thinks that if the PDB is created, it might not allowed any pod to be disrupted. Merlin avoid this because in the event of node scale down, the pods can’t be removed and causing deadlock. But actually, the Kubernetes calculation does allow for 1 pod to be disrupted. ### 2. Unused PDB not deleted When a model is redeployed, it will create a new revision ID, and there's a chance that unused PDB for the previous revision is not deleted. Scenario: 1. Current state of model: has predictor & transformer PDB 2. User redeploy a new model version and this version doesn't have PDB 3. Newer model version is deployed, old unused PDB is not deleted (because Merlin doesn't check or delete if previously PDB exist) **Notes:** the old PDB will not affect the new model deployment, because in the `selector.matchLabels` there's a label of `inferenceservice:{modelName}-{versionID}-{revisionID}`, and the revisionID will be incremented on every new deployment. So it is safe to not delete PDB, but it will going to confuse user. # Modifications Changes: - Fix the validity checking of the PDB. The `minAvailable` will use the current behavior, but if Merlin config uses `maxUnavailable` it will create PDB as long as the number is bigger than 0. - Merlin will create PDB with a fix name of `{name}-{versionID}-{componentType}-pdb`, where `componentType` is either `predictor` or `transformer`. The changes in the code will compare those two fix names with the new PDBs name, then for name that doesn't exist in new PDBs name, delete it. # Tests <!-- Besides the existing / updated automated tests, what specific scenarios should be tested? Consider the backward compatibility of the changes, whether corner cases are covered, etc. Please describe the tests and check the ones that have been completed. Eg: - [x] Deploying new and existing standard models - [ ] Deploying PyFunc models --> # Checklist - [x] Added PR label - [x] Added unit test, integration, and/or e2e tests - [x] Tested locally - [ ] Updated documentation - [ ] Update Swagger spec if the PR introduce API changes - [ ] Regenerated Golang and Python client if the PR introduces API changes # Release Notes <!-- Does this PR introduce a user-facing change? If no, just write "NONE" in the release-note block below. If yes, a release note is required. Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". For more information about release notes, see kubernetes' guide here: http://git.k8s.io/community/contributors/guide/release-notes.md --> ```release-note fix wrong validation check of PDB and delete unused PDB from previous version model ```
1 parent 24d79eb commit cbad4a2

File tree

4 files changed

+436
-107
lines changed

4 files changed

+436
-107
lines changed

api/cluster/controller.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,21 @@ func (c *controller) Deploy(ctx context.Context, modelService *models.Service) (
288288
inferenceURL = vsCfg.getInferenceURL(vs)
289289
}
290290

291-
// Delete previous inference service
291+
// Delete previous inference service and pdb
292292
if modelService.CurrentIsvcName != "" {
293293
if err := c.deleteInferenceService(ctx, modelService.CurrentIsvcName, modelService.Namespace); err != nil {
294294
log.Errorf("unable to delete prevision revision %s with error %v", modelService.CurrentIsvcName, err)
295295
return nil, errors.Wrapf(err, fmt.Sprintf("%v (%s)", ErrUnableToDeletePreviousInferenceService, modelService.CurrentIsvcName))
296296
}
297+
298+
unusedPdbs, err := c.getUnusedPodDisruptionBudgets(ctx, modelService)
299+
if err != nil {
300+
log.Warnf("unable to get model name %s, version %s unused pdb: %v", modelService.ModelName, modelService.ModelVersion, err)
301+
} else {
302+
if err := c.deletePodDisruptionBudgets(ctx, unusedPdbs); err != nil {
303+
log.Warnf("unable to delete model name %s, version %s unused pdb: %v", modelService.ModelName, modelService.ModelVersion, err)
304+
}
305+
}
297306
}
298307

299308
return &models.Service{

api/cluster/controller_test.go

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ func TestController_DeployInferenceService_NamespaceCreation(t *testing.T) {
353353
}
354354

355355
func TestController_DeployInferenceService(t *testing.T) {
356-
defaultMaxUnavailablePDB := 20
356+
minAvailablePercentage := 80
357357
deployTimeout := 2 * tickDurationSecond * time.Second
358358

359359
model := &models.Model{
@@ -729,8 +729,8 @@ func TestController_DeployInferenceService(t *testing.T) {
729729
DefaultModelResourceRequests: &config.ResourceRequests{},
730730
DefaultTransformerResourceRequests: &config.ResourceRequests{},
731731
PodDisruptionBudget: config.PodDisruptionBudgetConfig{
732-
Enabled: true,
733-
MaxUnavailablePercentage: &defaultMaxUnavailablePDB,
732+
Enabled: true,
733+
MinAvailablePercentage: &minAvailablePercentage,
734734
},
735735
StandardTransformer: config.StandardTransformerConfig{
736736
ImageName: "ghcr.io/caraml-dev/merlin-transformer-test",
@@ -758,6 +758,223 @@ func TestController_DeployInferenceService(t *testing.T) {
758758
}
759759
}
760760

761+
func TestController_DeployInferenceService_PodDisruptionBudgetsRemoval(t *testing.T) {
762+
err := models.InitKubernetesLabeller("gojek.com/", "dev")
763+
assert.Nil(t, err)
764+
765+
minAvailablePercentage := 80
766+
767+
model := &models.Model{
768+
Name: "my-model",
769+
}
770+
project := mlp.Project{
771+
Name: "my-project",
772+
}
773+
version := &models.Version{
774+
ID: 1,
775+
}
776+
revisionID := models.ID(5)
777+
778+
isvcName := models.CreateInferenceServiceName(model.Name, version.ID.String(), revisionID.String())
779+
statusReady := createServiceReadyStatus(isvcName, project.Name, baseUrl)
780+
statusReady.Components = make(map[kservev1beta1.ComponentType]kservev1beta1.ComponentStatusSpec)
781+
pdb := &policyv1.PodDisruptionBudget{}
782+
vs := fakeVirtualService(model.Name, version.ID.String())
783+
784+
metadata := models.Metadata{
785+
App: "mymodel",
786+
Component: models.ComponentModelVersion,
787+
Stream: "mystream",
788+
Team: "myteam",
789+
}
790+
modelSvc := &models.Service{
791+
Name: isvcName,
792+
ModelName: model.Name,
793+
ModelVersion: version.ID.String(),
794+
RevisionID: revisionID,
795+
Namespace: project.Name,
796+
Metadata: metadata,
797+
CurrentIsvcName: isvcName,
798+
}
799+
800+
defaultMatchLabel := map[string]string{
801+
"gojek.com/app": "mymodel",
802+
"gojek.com/component": "model-version",
803+
"gojek.com/environment": "dev",
804+
"gojek.com/orchestrator": "merlin",
805+
"gojek.com/stream": "mystream",
806+
"gojek.com/team": "myteam",
807+
"serving.kserve.io/inferenceservice": "mymodel-1-r4",
808+
"model-version-id": "1",
809+
}
810+
811+
tests := []struct {
812+
name string
813+
modelService *models.Service
814+
existingPdbs *policyv1.PodDisruptionBudgetList
815+
createPdbResult *pdbReactor
816+
deletedPdbResult *pdbReactor
817+
wantPdbs []string
818+
}{
819+
{
820+
name: "success: remove pdbs",
821+
modelService: modelSvc,
822+
existingPdbs: &policyv1.PodDisruptionBudgetList{
823+
Items: []policyv1.PodDisruptionBudget{
824+
{
825+
ObjectMeta: metav1.ObjectMeta{
826+
Name: "my-model-1-r4-predictor-pdb",
827+
Labels: defaultMatchLabel,
828+
Namespace: modelSvc.Namespace,
829+
},
830+
},
831+
{
832+
ObjectMeta: metav1.ObjectMeta{
833+
Name: "my-model-1-r4-transformer-pdb",
834+
Labels: defaultMatchLabel,
835+
Namespace: modelSvc.Namespace,
836+
},
837+
},
838+
},
839+
},
840+
createPdbResult: &pdbReactor{pdb, nil},
841+
deletedPdbResult: &pdbReactor{err: nil},
842+
wantPdbs: []string{
843+
"my-model-1-r4-predictor-pdb",
844+
"my-model-1-r4-transformer-pdb",
845+
},
846+
},
847+
{
848+
name: "success: only remove pdb with same labels",
849+
modelService: modelSvc,
850+
existingPdbs: &policyv1.PodDisruptionBudgetList{
851+
Items: []policyv1.PodDisruptionBudget{
852+
{
853+
ObjectMeta: metav1.ObjectMeta{
854+
Name: "my-model-1-r4-predictor-pdb",
855+
Labels: defaultMatchLabel,
856+
Namespace: modelSvc.Namespace,
857+
},
858+
},
859+
{
860+
ObjectMeta: metav1.ObjectMeta{
861+
Name: "my-model-2-r4-predictor-pdb",
862+
Labels: map[string]string{
863+
"gojek.com/app": "mymodel",
864+
"gojek.com/component": "model-version",
865+
"gojek.com/environment": "dev",
866+
"gojek.com/orchestrator": "merlin",
867+
"gojek.com/stream": "mystream",
868+
"gojek.com/team": "myteam",
869+
"component": "predictor",
870+
"serving.kserve.io/inferenceservice": "mymodel-2-r4",
871+
"model-version-id": "2",
872+
},
873+
Namespace: modelSvc.Namespace,
874+
},
875+
},
876+
},
877+
},
878+
createPdbResult: &pdbReactor{pdb, nil},
879+
deletedPdbResult: &pdbReactor{err: nil},
880+
wantPdbs: []string{
881+
"my-model-1-r4-predictor-pdb",
882+
},
883+
},
884+
{
885+
name: "success: no pdb found or to delete",
886+
modelService: modelSvc,
887+
existingPdbs: &policyv1.PodDisruptionBudgetList{
888+
Items: []policyv1.PodDisruptionBudget{},
889+
},
890+
createPdbResult: &pdbReactor{pdb, nil},
891+
deletedPdbResult: &pdbReactor{err: nil},
892+
wantPdbs: []string{},
893+
},
894+
{
895+
name: "fail deleting pdb should not return error",
896+
modelService: modelSvc,
897+
existingPdbs: &policyv1.PodDisruptionBudgetList{
898+
Items: []policyv1.PodDisruptionBudget{
899+
{
900+
ObjectMeta: metav1.ObjectMeta{
901+
Name: "my-model-1-r4-predictor-pdb",
902+
Labels: defaultMatchLabel,
903+
Namespace: modelSvc.Namespace,
904+
},
905+
},
906+
},
907+
},
908+
createPdbResult: &pdbReactor{pdb, nil},
909+
deletedPdbResult: &pdbReactor{err: ErrUnableToDeletePDB},
910+
wantPdbs: []string{},
911+
},
912+
}
913+
914+
for _, tt := range tests {
915+
t.Run(tt.name, func(t *testing.T) {
916+
knClient := knservingfake.NewSimpleClientset()
917+
918+
kfClient := fakekserve.NewSimpleClientset().ServingV1beta1().(*fakekservev1beta1.FakeServingV1beta1)
919+
isvcWatchReactor := newIsvcWatchReactor(&kservev1beta1.InferenceService{
920+
ObjectMeta: metav1.ObjectMeta{Name: isvcName, Namespace: project.Name},
921+
Status: statusReady,
922+
})
923+
kfClient.WatchReactionChain = []ktesting.WatchReactor{isvcWatchReactor}
924+
925+
kfClient.PrependReactor(getMethod, inferenceServiceResource, func(action ktesting.Action) (handled bool, ret runtime.Object, err error) {
926+
return true, &kservev1beta1.InferenceService{Status: statusReady}, nil
927+
})
928+
929+
v1Client := fake.NewSimpleClientset().CoreV1()
930+
931+
policyV1Client := fake.NewSimpleClientset(tt.existingPdbs).PolicyV1().(*fakepolicyv1.FakePolicyV1)
932+
policyV1Client.Fake.PrependReactor(patchMethod, pdbResource, func(action ktesting.Action) (handled bool, ret runtime.Object, err error) {
933+
return true, tt.createPdbResult.pdb, tt.createPdbResult.err
934+
})
935+
936+
deletedPdbNames := []string{}
937+
policyV1Client.Fake.PrependReactor(deleteMethod, pdbResource, func(action ktesting.Action) (handled bool, ret runtime.Object, err error) {
938+
deletedPdbNames = append(deletedPdbNames, action.(ktesting.DeleteAction).GetName())
939+
return true, nil, tt.deletedPdbResult.err
940+
})
941+
942+
istioClient := fakeistio.NewSimpleClientset().NetworkingV1beta1().(*fakeistionetworking.FakeNetworkingV1beta1)
943+
istioClient.PrependReactor(patchMethod, virtualServiceResource, func(action ktesting.Action) (handled bool, ret runtime.Object, err error) {
944+
return true, vs, nil
945+
})
946+
947+
deployConfig := config.DeploymentConfig{
948+
DeploymentTimeout: 2 * tickDurationSecond * time.Second,
949+
NamespaceTimeout: 2 * tickDurationSecond * time.Second,
950+
DefaultModelResourceRequests: &config.ResourceRequests{},
951+
DefaultTransformerResourceRequests: &config.ResourceRequests{},
952+
MaxAllowedReplica: 4,
953+
PodDisruptionBudget: config.PodDisruptionBudgetConfig{
954+
Enabled: true,
955+
MinAvailablePercentage: &minAvailablePercentage,
956+
},
957+
StandardTransformer: config.StandardTransformerConfig{},
958+
UserContainerCPUDefaultLimit: userContainerCPUDefaultLimit,
959+
UserContainerCPULimitRequestFactor: userContainerCPULimitRequestFactor,
960+
UserContainerMemoryLimitRequestFactor: userContainerMemoryLimitRequestFactor,
961+
}
962+
963+
containerFetcher := NewContainerFetcher(v1Client, clusterMetadata)
964+
templater := clusterresource.NewInferenceServiceTemplater(deployConfig)
965+
966+
ctl, _ := newController(knClient.ServingV1(), kfClient, v1Client, nil, policyV1Client, istioClient, deployConfig, containerFetcher, templater)
967+
iSvc, err := ctl.Deploy(context.Background(), tt.modelService)
968+
969+
if tt.deletedPdbResult.err == nil {
970+
assert.ElementsMatch(t, deletedPdbNames, tt.wantPdbs)
971+
}
972+
assert.NoError(t, err)
973+
assert.NotNil(t, iSvc)
974+
})
975+
}
976+
}
977+
761978
func TestGetCurrentDeploymentScale(t *testing.T) {
762979
testNamespace := "test-namespace"
763980
var testDesiredReplicas int32 = 5

0 commit comments

Comments
 (0)