From 263242a5e1f0747838b286c69eac9220949f3169 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 4 Feb 2024 20:32:44 +0700 Subject: [PATCH 01/12] Add topologySpreadConstraints configuration to pod spec. --- e2e/tests/test_e2e.py | 73 ++++++++++++++++---- manifests/postgresql.crd.yaml | 6 ++ pkg/apis/acid.zalan.do/v1/crds.go | 10 +++ pkg/apis/acid.zalan.do/v1/postgresql_type.go | 37 +++++----- pkg/cluster/cluster.go | 5 ++ pkg/cluster/k8sres.go | 14 ++++ pkg/cluster/k8sres_test.go | 43 ++++++++++++ 7 files changed, 158 insertions(+), 30 deletions(-) diff --git a/e2e/tests/test_e2e.py b/e2e/tests/test_e2e.py index b9a2a27d4..2718a84ab 100644 --- a/e2e/tests/test_e2e.py +++ b/e2e/tests/test_e2e.py @@ -559,7 +559,7 @@ def compare_config(): pg_patch_config["spec"]["patroni"]["slots"][slot_to_change]["database"] = "bar" del pg_patch_config["spec"]["patroni"]["slots"][slot_to_remove] - + k8s.api.custom_objects_api.patch_namespaced_custom_object( "acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", pg_delete_slot_patch) @@ -576,7 +576,7 @@ def compare_config(): self.eventuallyEqual(lambda: self.query_database(leader.metadata.name, "postgres", get_slot_query%("database", slot_to_change))[0], "bar", "The replication slot cannot be updated", 10, 5) - + # make sure slot from Patroni didn't get deleted self.eventuallyEqual(lambda: len(self.query_database(leader.metadata.name, "postgres", get_slot_query%("slot_name", patroni_slot))), 1, "The replication slot from Patroni gets deleted", 10, 5) @@ -932,7 +932,7 @@ def test_ignored_annotations(self): }, } } - + old_sts_creation_timestamp = sts.metadata.creation_timestamp k8s.api.apps_v1.patch_namespaced_stateful_set(sts.metadata.name, sts.metadata.namespace, annotation_patch) old_svc_creation_timestamp = svc.metadata.creation_timestamp @@ -1369,7 +1369,7 @@ def test_persistent_volume_claim_retention_policy(self): } k8s.update_config(patch_scaled_policy_retain) self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync") - + # decrease the number of instances k8s.api.custom_objects_api.patch_namespaced_custom_object( 'acid.zalan.do', 'v1', 'default', 'postgresqls', 'acid-minimal-cluster', pg_patch_scale_down_instances) @@ -1646,7 +1646,6 @@ def test_node_readiness_label(self): # toggle pod anti affinity to move replica away from master node self.assert_distributed_pods(master_nodes) - @timeout_decorator.timeout(TEST_TIMEOUT_SEC) def test_overwrite_pooler_deployment(self): pooler_name = 'acid-minimal-cluster-pooler' @@ -1799,7 +1798,7 @@ def test_password_rotation(self): }, } k8s.api.core_v1.patch_namespaced_secret( - name="foo-user.acid-minimal-cluster.credentials.postgresql.acid.zalan.do", + name="foo-user.acid-minimal-cluster.credentials.postgresql.acid.zalan.do", namespace="default", body=secret_fake_rotation) @@ -1816,7 +1815,7 @@ def test_password_rotation(self): "enable_password_rotation": "true", "inherited_annotations": "environment", "password_rotation_interval": "30", - "password_rotation_user_retention": "30", # should be set to 60 + "password_rotation_user_retention": "30", # should be set to 60 }, } k8s.update_config(enable_password_rotation) @@ -1885,7 +1884,7 @@ def test_password_rotation(self): self.assertTrue("environment" in db_user_secret.metadata.annotations, "Added annotation was not propagated to secret") # disable password rotation for all other users (foo_user) - # and pick smaller intervals to see if the third fake rotation user is dropped + # and pick smaller intervals to see if the third fake rotation user is dropped enable_password_rotation = { "data": { "enable_password_rotation": "false", @@ -2385,6 +2384,56 @@ def test_taint_based_eviction(self): # toggle pod anti affinity to move replica away from master node self.assert_distributed_pods(master_nodes) + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) + def test_topology_spread_constraints(self): + ''' + Enable topologySpreadConstraints for pods + ''' + k8s = self.k8s + cluster_labels = "application=spilo,cluster-name=acid-minimal-cluster" + + # Verify we are in good state from potential previous tests + self.eventuallyEqual(lambda: k8s.count_running_pods(), 2, "No 2 pods running") + + master_nodes, replica_nodes = k8s.get_cluster_nodes() + self.assertNotEqual(master_nodes, []) + self.assertNotEqual(replica_nodes, []) + + # Patch label to nodes for topologySpreadConstraints + patch_node_label = { + "metadata": { + "labels": { + "topology.kubernetes.io/zone": "zalando" + } + } + } + k8s.api.core_v1.patch_node(master_nodes[0], patch_node_label) + k8s.api.core_v1.patch_node(replica_nodes[0], patch_node_label) + + # Scale-out postgresql pods + k8s.api.custom_objects_api.patch_namespaced_custom_object("acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", + {"spec": {"numberOfInstances": 6}}) + self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync") + self.eventuallyEqual(lambda: k8s.count_pods_with_label(cluster_labels), 6, "Postgresql StatefulSet are scale to 6") + self.eventuallyEqual(lambda: k8s.count_running_pods(), 6, "All pods are running") + + worker_node_1 = 0 + worker_node_2 = 0 + pods = k8s.api.core_v1.list_namespaced_pod('default', label_selector=cluster_labels) + for pod in pods.items: + if pod.spec.node_name == 'postgres-operator-e2e-tests-worker': + worker_node_1 += 1 + elif pod.spec.node_name == 'postgres-operator-e2e-tests-worker2': + worker_node_2 += 1 + + self.assertEqual(worker_node_1, worker_node_2) + self.assertEqual(worker_node_1, 3) + self.assertEqual(worker_node_2, 3) + + # Scale-it postgresql pods to previous replicas + k8s.api.custom_objects_api.patch_namespaced_custom_object("acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", + {"spec": {"numberOfInstances": 2}}) + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) def test_zz_cluster_deletion(self): ''' @@ -2460,7 +2509,7 @@ def test_zz_cluster_deletion(self): self.eventuallyEqual(lambda: k8s.count_deployments_with_label(cluster_label), 0, "Deployments not deleted") self.eventuallyEqual(lambda: k8s.count_pdbs_with_label(cluster_label), 0, "Pod disruption budget not deleted") self.eventuallyEqual(lambda: k8s.count_secrets_with_label(cluster_label), 8, "Secrets were deleted although disabled in config") - self.eventuallyEqual(lambda: k8s.count_pvcs_with_label(cluster_label), 3, "PVCs were deleted although disabled in config") + self.eventuallyEqual(lambda: k8s.count_pvcs_with_label(cluster_label), 6, "PVCs were deleted although disabled in config") except timeout_decorator.TimeoutError: print('Operator log: {}'.format(k8s.get_operator_log())) @@ -2502,7 +2551,7 @@ def assert_distributed_pods(self, target_nodes, cluster_labels='cluster-name=aci # if nodes are different we can quit here if master_nodes[0] not in replica_nodes: - return True + return True # enable pod anti affintiy in config map which should trigger movement of replica patch_enable_antiaffinity = { @@ -2526,7 +2575,7 @@ def assert_distributed_pods(self, target_nodes, cluster_labels='cluster-name=aci } k8s.update_config(patch_disable_antiaffinity, "disable antiaffinity") self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync") - + k8s.wait_for_pod_start('spilo-role=replica,' + cluster_labels) k8s.wait_for_running_pods(cluster_labels, 2) @@ -2537,7 +2586,7 @@ def assert_distributed_pods(self, target_nodes, cluster_labels='cluster-name=aci # if nodes are different we can quit here for target_node in target_nodes: if (target_node not in master_nodes or target_node not in replica_nodes) and master_nodes[0] in replica_nodes: - print('Pods run on the same node') + print('Pods run on the same node') return False except timeout_decorator.TimeoutError: diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index 39d751cef..23921934a 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -582,6 +582,12 @@ spec: - PreferNoSchedule tolerationSeconds: type: integer + topologySpreadConstraints: + type: array + nullable: true + items: + type: object + x-kubernetes-preserve-unknown-fields: true useLoadBalancer: type: boolean description: deprecated diff --git a/pkg/apis/acid.zalan.do/v1/crds.go b/pkg/apis/acid.zalan.do/v1/crds.go index 3f6bf25d9..062db9ba7 100644 --- a/pkg/apis/acid.zalan.do/v1/crds.go +++ b/pkg/apis/acid.zalan.do/v1/crds.go @@ -895,6 +895,16 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{ }, }, }, + "topologySpreadConstraints": { + Type: "array", + Nullable: true, + Items: &apiextv1.JSONSchemaPropsOrArray{ + Schema: &apiextv1.JSONSchemaProps{ + Type: "object", + XPreserveUnknownFields: util.True(), + }, + }, + }, "useLoadBalancer": { Type: "boolean", Description: "deprecated", diff --git a/pkg/apis/acid.zalan.do/v1/postgresql_type.go b/pkg/apis/acid.zalan.do/v1/postgresql_type.go index ef6dfe7ff..fd3f6807c 100644 --- a/pkg/apis/acid.zalan.do/v1/postgresql_type.go +++ b/pkg/apis/acid.zalan.do/v1/postgresql_type.go @@ -63,24 +63,25 @@ type PostgresSpec struct { UsersWithSecretRotation []string `json:"usersWithSecretRotation,omitempty"` UsersWithInPlaceSecretRotation []string `json:"usersWithInPlaceSecretRotation,omitempty"` - NumberOfInstances int32 `json:"numberOfInstances"` - MaintenanceWindows []MaintenanceWindow `json:"maintenanceWindows,omitempty"` - Clone *CloneDescription `json:"clone,omitempty"` - Databases map[string]string `json:"databases,omitempty"` - PreparedDatabases map[string]PreparedDatabase `json:"preparedDatabases,omitempty"` - SchedulerName *string `json:"schedulerName,omitempty"` - NodeAffinity *v1.NodeAffinity `json:"nodeAffinity,omitempty"` - Tolerations []v1.Toleration `json:"tolerations,omitempty"` - Sidecars []Sidecar `json:"sidecars,omitempty"` - InitContainers []v1.Container `json:"initContainers,omitempty"` - PodPriorityClassName string `json:"podPriorityClassName,omitempty"` - ShmVolume *bool `json:"enableShmVolume,omitempty"` - EnableLogicalBackup bool `json:"enableLogicalBackup,omitempty"` - LogicalBackupRetention string `json:"logicalBackupRetention,omitempty"` - LogicalBackupSchedule string `json:"logicalBackupSchedule,omitempty"` - StandbyCluster *StandbyDescription `json:"standby,omitempty"` - PodAnnotations map[string]string `json:"podAnnotations,omitempty"` - ServiceAnnotations map[string]string `json:"serviceAnnotations,omitempty"` + NumberOfInstances int32 `json:"numberOfInstances"` + MaintenanceWindows []MaintenanceWindow `json:"maintenanceWindows,omitempty"` + Clone *CloneDescription `json:"clone,omitempty"` + Databases map[string]string `json:"databases,omitempty"` + PreparedDatabases map[string]PreparedDatabase `json:"preparedDatabases,omitempty"` + SchedulerName *string `json:"schedulerName,omitempty"` + NodeAffinity *v1.NodeAffinity `json:"nodeAffinity,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + Tolerations []v1.Toleration `json:"tolerations,omitempty"` + Sidecars []Sidecar `json:"sidecars,omitempty"` + InitContainers []v1.Container `json:"initContainers,omitempty"` + PodPriorityClassName string `json:"podPriorityClassName,omitempty"` + ShmVolume *bool `json:"enableShmVolume,omitempty"` + EnableLogicalBackup bool `json:"enableLogicalBackup,omitempty"` + LogicalBackupRetention string `json:"logicalBackupRetention,omitempty"` + LogicalBackupSchedule string `json:"logicalBackupSchedule,omitempty"` + StandbyCluster *StandbyDescription `json:"standby,omitempty"` + PodAnnotations map[string]string `json:"podAnnotations,omitempty"` + ServiceAnnotations map[string]string `json:"serviceAnnotations,omitempty"` // MasterServiceAnnotations takes precedence over ServiceAnnotations for master role if not empty MasterServiceAnnotations map[string]string `json:"masterServiceAnnotations,omitempty"` // ReplicaServiceAnnotations takes precedence over ServiceAnnotations for replica role if not empty diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index e9a691faa..a13e0a587 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -499,6 +499,11 @@ func (c *Cluster) compareStatefulSetWith(statefulSet *appsv1.StatefulSet) *compa needsRollUpdate = true reasons = append(reasons, "new statefulset's pod affinity does not match the current one") } + if !reflect.DeepEqual(c.Statefulset.Spec.Template.Spec.TopologySpreadConstraints, statefulSet.Spec.Template.Spec.TopologySpreadConstraints) { + needsReplace = true + needsRollUpdate = true + reasons = append(reasons, "new statefulset's pod topologySpreadConstraints does not match the current one") + } if len(c.Statefulset.Spec.Template.Spec.Tolerations) != len(statefulSet.Spec.Template.Spec.Tolerations) { needsReplace = true needsRollUpdate = true diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 4db23976c..613374e98 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -604,6 +604,13 @@ func generatePodAntiAffinity(podAffinityTerm v1.PodAffinityTerm, preferredDuring return podAntiAffinity } +func generateTopologySpreadConstraints(labels labels.Set, topologySpreadConstraints []v1.TopologySpreadConstraint) []v1.TopologySpreadConstraint { + for _, topologySpreadConstraint := range topologySpreadConstraints { + topologySpreadConstraint.LabelSelector = &metav1.LabelSelector{MatchLabels: labels} + } + return topologySpreadConstraints +} + func tolerations(tolerationsSpec *[]v1.Toleration, podToleration map[string]string) []v1.Toleration { // allow to override tolerations by postgresql manifest if len(*tolerationsSpec) > 0 { @@ -809,6 +816,7 @@ func (c *Cluster) generatePodTemplate( initContainers []v1.Container, sidecarContainers []v1.Container, sharePgSocketWithSidecars *bool, + topologySpreadConstraintsSpec []v1.TopologySpreadConstraint, tolerationsSpec *[]v1.Toleration, spiloRunAsUser *int64, spiloRunAsGroup *int64, @@ -878,6 +886,10 @@ func (c *Cluster) generatePodTemplate( podSpec.PriorityClassName = priorityClassName } + if len(topologySpreadConstraintsSpec) > 0 { + podSpec.TopologySpreadConstraints = generateTopologySpreadConstraints(labels, topologySpreadConstraintsSpec) + } + if sharePgSocketWithSidecars != nil && *sharePgSocketWithSidecars { addVarRunVolume(&podSpec) } @@ -1469,6 +1481,7 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef initContainers, sidecarContainers, c.OpConfig.SharePgSocketWithSidecars, + spec.TopologySpreadConstraints, &tolerationSpec, effectiveRunAsUser, effectiveRunAsGroup, @@ -2356,6 +2369,7 @@ func (c *Cluster) generateLogicalBackupJob() (*batchv1.CronJob, error) { []v1.Container{}, []v1.Container{}, util.False(), + []v1.TopologySpreadConstraint{}, &tolerationsSpec, nil, nil, diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 137c24081..910ca4e26 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -3984,3 +3984,46 @@ func TestGenerateCapabilities(t *testing.T) { } } } + +func TestTopologySpreadConstraints(t *testing.T) { + clusterName := "acid-test-cluster" + namespace := "default" + + pg := acidv1.Postgresql{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + }, + Spec: acidv1.PostgresSpec{ + NumberOfInstances: 1, + Resources: &acidv1.Resources{ + ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")}, + ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")}, + }, + Volume: acidv1.Volume{ + Size: "1G", + }, + }, + } + + cluster := New( + Config{ + OpConfig: config.Config{ + PodManagementPolicy: "ordered_ready", + }, + }, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + cluster.Name = clusterName + cluster.Namespace = namespace + cluster.labelsSet(true) + + s, err := cluster.generateStatefulSet(&pg.Spec) + assert.NoError(t, err) + assert.Contains(t, s.Spec.Template.Spec.TopologySpreadConstraints, v1.TopologySpreadConstraint{ + MaxSkew: int32(1), + TopologyKey: "topology.kubernetes.io/zone", + WhenUnsatisfiable: v1.DoNotSchedule, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: cluster.labelsSet(true), + }, + }) +} From 63f865776e8ed705d2fe43036d95a4439ae076ff Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 18 Nov 2024 14:16:48 +0700 Subject: [PATCH 02/12] Modify the UT. --- pkg/cluster/k8sres_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 910ca4e26..6df1c1abd 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4003,6 +4003,16 @@ func TestTopologySpreadConstraints(t *testing.T) { Volume: acidv1.Volume{ Size: "1G", }, + TopologySpreadConstraints: []v1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "topology.kubernetes.io/zone", + WhenUnsatisfiable: v1.DoNotSchedule, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: cluster.labelsSet(true), + }, + }, + }, }, } From 89e2405b8fe4f3817053254d961b98b6a7acbc7c Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 18 Nov 2024 14:23:36 +0700 Subject: [PATCH 03/12] Correct the assert with the list of constraints. --- pkg/cluster/k8sres_test.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 6df1c1abd..4273631ca 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4028,12 +4028,14 @@ func TestTopologySpreadConstraints(t *testing.T) { s, err := cluster.generateStatefulSet(&pg.Spec) assert.NoError(t, err) - assert.Contains(t, s.Spec.Template.Spec.TopologySpreadConstraints, v1.TopologySpreadConstraint{ - MaxSkew: int32(1), - TopologyKey: "topology.kubernetes.io/zone", - WhenUnsatisfiable: v1.DoNotSchedule, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: cluster.labelsSet(true), + assert.Contains(t, s.Spec.Template.Spec.TopologySpreadConstraints, []v1.TopologySpreadConstraint{ + { + MaxSkew: int32(1), + TopologyKey: "topology.kubernetes.io/zone", + WhenUnsatisfiable: v1.DoNotSchedule, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: cluster.labelsSet(true), + }, }, }) } From 8c9840c5da325eeb552934c65da8494a733da489 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 18 Nov 2024 15:24:49 +0700 Subject: [PATCH 04/12] Fix UT --- pkg/cluster/k8sres_test.go | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 4273631ca..c3e89352d 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -3988,6 +3988,9 @@ func TestGenerateCapabilities(t *testing.T) { func TestTopologySpreadConstraints(t *testing.T) { clusterName := "acid-test-cluster" namespace := "default" + labelSelector := &metav1.LabelSelector{ + MatchLabels: cluster.labelsSet(true), + } pg := acidv1.Postgresql{ ObjectMeta: metav1.ObjectMeta{ @@ -4008,9 +4011,7 @@ func TestTopologySpreadConstraints(t *testing.T) { MaxSkew: 1, TopologyKey: "topology.kubernetes.io/zone", WhenUnsatisfiable: v1.DoNotSchedule, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: cluster.labelsSet(true), - }, + LabelSelector: labelSelector, }, }, }, @@ -4028,14 +4029,11 @@ func TestTopologySpreadConstraints(t *testing.T) { s, err := cluster.generateStatefulSet(&pg.Spec) assert.NoError(t, err) - assert.Contains(t, s.Spec.Template.Spec.TopologySpreadConstraints, []v1.TopologySpreadConstraint{ - { - MaxSkew: int32(1), - TopologyKey: "topology.kubernetes.io/zone", - WhenUnsatisfiable: v1.DoNotSchedule, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: cluster.labelsSet(true), - }, - }, - }) + assert.Contains(t, s.Spec.Template.Spec.TopologySpreadConstraints, v1.TopologySpreadConstraint{ + MaxSkew: int32(1), + TopologyKey: "topology.kubernetes.io/zone", + WhenUnsatisfiable: v1.DoNotSchedule, + LabelSelector: labelSelector, + }, + ) } From ad4dd0098d364255b9bc431fa90d29ebcf7ba430 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 8 Mar 2025 13:03:15 +0700 Subject: [PATCH 05/12] Run update-codegen.sh to add deepcopy for new field to the api. --- pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 5d0a5b341..8749ec33c 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -789,6 +789,13 @@ func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) { *out = new(corev1.NodeAffinity) (*in).DeepCopyInto(*out) } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]corev1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations *out = make([]corev1.Toleration, len(*in)) From f52d9bc8fb03328ae28364c55adc4ddd09516717 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 8 Mar 2025 13:10:22 +0700 Subject: [PATCH 06/12] Reuse configured TopologySpreadConstraints for logical backup. --- pkg/cluster/k8sres.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 613374e98..02da1f339 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -2360,6 +2360,8 @@ func (c *Cluster) generateLogicalBackupJob() (*batchv1.CronJob, error) { tolerationsSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration) + topologySpreadConstraintsSpec := generateTopologySpreadConstraints(labels, spec.TopologySpreadConstraints) + // re-use the method that generates DB pod templates if podTemplate, err = c.generatePodTemplate( c.Namespace, @@ -2369,7 +2371,7 @@ func (c *Cluster) generateLogicalBackupJob() (*batchv1.CronJob, error) { []v1.Container{}, []v1.Container{}, util.False(), - []v1.TopologySpreadConstraint{}, + topologySpreadConstraintsSpec, &tolerationsSpec, nil, nil, From a40ba6e0de4a59ae62344272349075f4ab1da791 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 8 Mar 2025 13:27:29 +0700 Subject: [PATCH 07/12] Remove x-kubernetes-preserve-unknown-fields and XPreserveUnknownFields. --- manifests/postgresql.crd.yaml | 12 +++++++++++- pkg/apis/acid.zalan.do/v1/crds.go | 23 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index 23921934a..1a3edabfc 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -587,7 +587,17 @@ spec: nullable: true items: type: object - x-kubernetes-preserve-unknown-fields: true + properties: + maxSkew: + type: integer + format: int32 + topologyKey: + type: string + whenUnsatisfiable: + type: string + enum: + - DoNotSchedule + - ScheduleAnyway useLoadBalancer: type: boolean description: deprecated diff --git a/pkg/apis/acid.zalan.do/v1/crds.go b/pkg/apis/acid.zalan.do/v1/crds.go index 062db9ba7..b77bba1fd 100644 --- a/pkg/apis/acid.zalan.do/v1/crds.go +++ b/pkg/apis/acid.zalan.do/v1/crds.go @@ -111,6 +111,7 @@ var OperatorConfigCRDResourceColumns = []apiextv1.CustomResourceColumnDefinition var min0 = 0.0 var min1 = 1.0 +var minLength1 int64 = 1 var minDisable = -1.0 // PostgresCRDResourceValidation to check applied manifest parameters @@ -900,8 +901,26 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{ Nullable: true, Items: &apiextv1.JSONSchemaPropsOrArray{ Schema: &apiextv1.JSONSchemaProps{ - Type: "object", - XPreserveUnknownFields: util.True(), + Type: "object", + Properties: map[string]apiextv1.JSONSchemaProps{ + "maxSkew": { + Type: "integer", + Format: "int32", + Minimum: &min1, + }, + "topologyKey": { + Type: "string", + MinLength: &minLength1, + }, + "whenUnsatisfiable": { + Type: "string", + Enum: []apiextv1.JSON{ + {Raw: []byte(`"DoNotSchedule"`)}, + {Raw: []byte(`"ScheduleAnyway"`)}, + }, + }, + }, + Required: []string{"maxSkew", "topologyKey", "whenUnsatisfiable"}, }, }, }, From 8a0f3895c40c38235aafe692ee88715cadeddfa6 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 18 Oct 2025 22:36:36 +0700 Subject: [PATCH 08/12] Remove unnecessary len check. --- pkg/cluster/k8sres.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index c49ecb980..1d93b07cb 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -885,9 +885,7 @@ func (c *Cluster) generatePodTemplate( podSpec.PriorityClassName = priorityClassName } - if len(topologySpreadConstraintsSpec) > 0 { - podSpec.TopologySpreadConstraints = generateTopologySpreadConstraints(labels, topologySpreadConstraintsSpec) - } + podSpec.TopologySpreadConstraints = generateTopologySpreadConstraints(labels, topologySpreadConstraintsSpec) if sharePgSocketWithSidecars != nil && *sharePgSocketWithSidecars { addVarRunVolume(&podSpec) From fc8d6cf82183f815dde2240285d48f683455d6e5 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 18 Oct 2025 22:56:38 +0700 Subject: [PATCH 09/12] Add topologySpreadConstraint example in the complete manifest. --- manifests/complete-postgres-manifest.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/manifests/complete-postgres-manifest.yaml b/manifests/complete-postgres-manifest.yaml index 44d317123..2cce6dcb0 100644 --- a/manifests/complete-postgres-manifest.yaml +++ b/manifests/complete-postgres-manifest.yaml @@ -232,6 +232,12 @@ spec: # values: # - enabled +# Add topology spread constraint to distribute PostgreSQL pods across all nodes labeled with "topology.kubernetes.io/zone". +# topologySpreadConstraint: +# - maxSkew: 1 +# topologyKey: topology.kubernetes.io/zone +# whenUnsatisfiable: DoNotSchedule + # Enables change data capture streams for defined database tables # streams: # - applicationId: test-app From a7cc221d63909562fb7ba7748decc4aea71aab69 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 19 Oct 2025 13:08:09 +0700 Subject: [PATCH 10/12] Add support for helm chart. --- charts/postgres-operator/crds/postgresqls.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/charts/postgres-operator/crds/postgresqls.yaml b/charts/postgres-operator/crds/postgresqls.yaml index 8083e5e1d..60e01a6b4 100644 --- a/charts/postgres-operator/crds/postgresqls.yaml +++ b/charts/postgres-operator/crds/postgresqls.yaml @@ -584,6 +584,23 @@ spec: - PreferNoSchedule tolerationSeconds: type: integer + topologySpreadConstraints: + type: array + nullable: true + items: + type: object + properties: + maxskew: + type: integer + format: int32 + minimum: 1 + topologyKey: + type: string + whenUnsatisfiable: + type: string + enum: + - DoNotSchedule + - ScheduleAnyway useLoadBalancer: type: boolean description: deprecated From bb676f594927a308c0c03e90ce1d59cac8d5dc3a Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 19 Oct 2025 13:29:26 +0700 Subject: [PATCH 11/12] Add documentation for topologySpreadConstraint. --- docs/user.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/user.md b/docs/user.md index c1a7c7d45..d52ddd3f3 100644 --- a/docs/user.md +++ b/docs/user.md @@ -714,7 +714,7 @@ but Kubernetes will not spin up the pod if the requested HugePages cannot be all For more information on HugePages in Kubernetes, see also [https://kubernetes.io/docs/tasks/manage-hugepages/scheduling-hugepages/](https://kubernetes.io/docs/tasks/manage-hugepages/scheduling-hugepages/) -## Use taints, tolerations and node affinity for dedicated PostgreSQL nodes +## Use taints, tolerations, node affinity and topology spread constraint for dedicated PostgreSQL nodes To ensure Postgres pods are running on nodes without any other application pods, you can use [taints and tolerations](https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/) @@ -755,6 +755,23 @@ spec: If you need to define a `nodeAffinity` for all your Postgres clusters use the `node_readiness_label` [configuration](administrator.md#node-readiness-labels). +If you need PostgreSQL Pods to run on separate nodes, you can use the +[topologySpreadConstraints](https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/) to control how they are distributed across your cluster. +This ensures they are spread among failure domains such as +regions, zones, nodes, or other user-defined topology domains. + +```yaml +apiVersion: "acid.zalan.do/v1" +kind: postgresql +metadata: + name: acid-minimal-cluster +spec: + topologySpreadConstraints: + - maxskew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule +``` + ## In-place major version upgrade Starting with Spilo 13, operator supports in-place major version upgrade to a @@ -1053,7 +1070,7 @@ spec: - all volumeSource: emptyDir: {} - sidecars: + sidecars: - name: "container-name" image: "company/image:tag" volumeMounts: From fa6dadd691d644bed827ffa2b10de84e3179e4df Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 19 Oct 2025 14:24:18 +0700 Subject: [PATCH 12/12] Update e2e test to patch topologySpreadConstraints into the postgresqls manifest. --- e2e/tests/test_e2e.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/e2e/tests/test_e2e.py b/e2e/tests/test_e2e.py index 2718a84ab..482dac618 100644 --- a/e2e/tests/test_e2e.py +++ b/e2e/tests/test_e2e.py @@ -2410,9 +2410,23 @@ def test_topology_spread_constraints(self): k8s.api.core_v1.patch_node(master_nodes[0], patch_node_label) k8s.api.core_v1.patch_node(replica_nodes[0], patch_node_label) - # Scale-out postgresql pods - k8s.api.custom_objects_api.patch_namespaced_custom_object("acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", - {"spec": {"numberOfInstances": 6}}) + # Patch topologySpreadConstraint and scale-out postgresql pods to postgresqls manifest. + patch_topologySpreadConstraint_config = { + "spec": { + "numberOfInstances": 6, + "topologySpreadConstraint": [ + { + "maxskew": 1, + "topologyKey": "topology.kubernetes.io/zone", + "whenUnsatisfiable": "DoNotSchedule" + } + ] + } + } + k8s.api.custom_objects_api.patch_namespaced_custom_object( + "acid.zalan.do", "v1", "default", + "postgresqls", "acid-minimal-cluster", + patch_topologySpreadConstraint_config) self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync") self.eventuallyEqual(lambda: k8s.count_pods_with_label(cluster_labels), 6, "Postgresql StatefulSet are scale to 6") self.eventuallyEqual(lambda: k8s.count_running_pods(), 6, "All pods are running") @@ -2430,9 +2444,17 @@ def test_topology_spread_constraints(self): self.assertEqual(worker_node_1, 3) self.assertEqual(worker_node_2, 3) - # Scale-it postgresql pods to previous replicas - k8s.api.custom_objects_api.patch_namespaced_custom_object("acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", - {"spec": {"numberOfInstances": 2}}) + # Reset configurations + patch_topologySpreadConstraint_config = { + "spec": { + "numberOfInstances": 2, + "topologySpreadConstraint": [] + } + } + k8s.api.custom_objects_api.patch_namespaced_custom_object( + "acid.zalan.do", "v1", "default", + "postgresqls", "acid-minimal-cluster", + patch_topologySpreadConstraint_config) @timeout_decorator.timeout(TEST_TIMEOUT_SEC) def test_zz_cluster_deletion(self):