From 0ffca8bef89cbea35bb372954030fca8f258651b Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 4 Feb 2024 20:32:44 +0700 Subject: [PATCH 01/13] Add topologySpreadConstraints configuration to pod spec. --- e2e/tests/test_e2e.py | 73 ++++++++++++++++---- manifests/postgresql.crd.yaml | 6 ++ pkg/apis/acid.zalan.do/v1/postgresql_type.go | 23 +++--- pkg/cluster/cluster.go | 5 ++ pkg/cluster/k8sres.go | 14 ++++ pkg/cluster/k8sres_test.go | 43 ++++++++++++ 6 files changed, 141 insertions(+), 23 deletions(-) diff --git a/e2e/tests/test_e2e.py b/e2e/tests/test_e2e.py index f473b5cc4..6a0bf60cc 100644 --- a/e2e/tests/test_e2e.py +++ b/e2e/tests/test_e2e.py @@ -559,7 +559,7 @@ class EndToEndTestCase(unittest.TestCase): 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 @@ class EndToEndTestCase(unittest.TestCase): 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 @@ class EndToEndTestCase(unittest.TestCase): }, } } - + 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 @@ -1370,7 +1370,7 @@ class EndToEndTestCase(unittest.TestCase): } 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) @@ -1647,7 +1647,6 @@ class EndToEndTestCase(unittest.TestCase): # 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' @@ -1800,7 +1799,7 @@ class EndToEndTestCase(unittest.TestCase): }, } 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) @@ -1817,7 +1816,7 @@ class EndToEndTestCase(unittest.TestCase): "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) @@ -1886,7 +1885,7 @@ class EndToEndTestCase(unittest.TestCase): 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", @@ -2386,6 +2385,56 @@ class EndToEndTestCase(unittest.TestCase): # 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): ''' @@ -2461,7 +2510,7 @@ class EndToEndTestCase(unittest.TestCase): 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())) @@ -2503,7 +2552,7 @@ class EndToEndTestCase(unittest.TestCase): # 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 = { @@ -2527,7 +2576,7 @@ class EndToEndTestCase(unittest.TestCase): } 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) @@ -2538,7 +2587,7 @@ class EndToEndTestCase(unittest.TestCase): # 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 c8bf1b07d..92aada54e 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -4057,6 +4057,12 @@ spec: type: string type: object type: array + topologySpreadConstraints: + type: array + nullable: true + items: + type: object + x-kubernetes-preserve-unknown-fields: true useLoadBalancer: description: |- deprecated load balancer settings maintained for backward compatibility diff --git a/pkg/apis/acid.zalan.do/v1/postgresql_type.go b/pkg/apis/acid.zalan.do/v1/postgresql_type.go index b304db652..9f8d43b77 100644 --- a/pkg/apis/acid.zalan.do/v1/postgresql_type.go +++ b/pkg/apis/acid.zalan.do/v1/postgresql_type.go @@ -92,17 +92,18 @@ type PostgresSpec struct { Clone *CloneDescription `json:"clone,omitempty"` // Note: usernames specified here as database owners must be declared // in the users key of the spec key. - 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"` + 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"` // +kubebuilder:validation:Pattern=`^(\d+|\*)(/\d+)?(\s+(\d+|\*)(/\d+)?){4}$` LogicalBackupSchedule string `json:"logicalBackupSchedule,omitempty"` StandbyCluster *StandbyDescription `json:"standby,omitempty"` diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index 7204a92bd..edf27bce3 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -500,6 +500,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 224ef26f6..cb39036f1 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -612,6 +612,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 { @@ -817,6 +824,7 @@ func (c *Cluster) generatePodTemplate( initContainers []v1.Container, sidecarContainers []v1.Container, sharePgSocketWithSidecars *bool, + topologySpreadConstraintsSpec []v1.TopologySpreadConstraint, tolerationsSpec *[]v1.Toleration, spiloRunAsUser *int64, spiloRunAsGroup *int64, @@ -886,6 +894,10 @@ func (c *Cluster) generatePodTemplate( podSpec.PriorityClassName = priorityClassName } + if len(topologySpreadConstraintsSpec) > 0 { + podSpec.TopologySpreadConstraints = generateTopologySpreadConstraints(labels, topologySpreadConstraintsSpec) + } + if sharePgSocketWithSidecars != nil && *sharePgSocketWithSidecars { addVarRunVolume(&podSpec) } @@ -1484,6 +1496,7 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef initContainers, sidecarContainers, c.OpConfig.SharePgSocketWithSidecars, + spec.TopologySpreadConstraints, &tolerationSpec, effectiveRunAsUser, effectiveRunAsGroup, @@ -2378,6 +2391,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 e39e18cd5..ea94281ad 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4271,3 +4271,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 6482ae642fb29c7ceb3a148655aa4ea242c19005 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 18 Nov 2024 14:16:48 +0700 Subject: [PATCH 02/13] 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 ea94281ad..ccaff1337 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4290,6 +4290,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 ecc16d676f25e5d217ec10c0072056ed4f0d3495 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 18 Nov 2024 14:23:36 +0700 Subject: [PATCH 03/13] 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 ccaff1337..c888347d8 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4315,12 +4315,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 6f2689c026465e521af75d5f3c085dbe04957bfa Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 18 Nov 2024 15:24:49 +0700 Subject: [PATCH 04/13] 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 c888347d8..dc54e7909 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4275,6 +4275,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{ @@ -4295,9 +4298,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, }, }, }, @@ -4315,14 +4316,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 eda68acacb21112566a317cc0b04c96fd8283fd3 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 8 Mar 2025 13:03:15 +0700 Subject: [PATCH 05/13] 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 79e0787bc..4210df67f 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 aaa1054ce58b753788af1ecd0ea5708cfe8e21d8 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 8 Mar 2025 13:10:22 +0700 Subject: [PATCH 06/13] 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 cb39036f1..e8c8730b6 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -2382,6 +2382,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, @@ -2391,7 +2393,7 @@ func (c *Cluster) generateLogicalBackupJob() (*batchv1.CronJob, error) { []v1.Container{}, []v1.Container{}, util.False(), - []v1.TopologySpreadConstraint{}, + topologySpreadConstraintsSpec, &tolerationsSpec, nil, nil, From 5d9867292a9edb4a5945b6f7f7890e7794b0cd84 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 8 Mar 2025 13:27:29 +0700 Subject: [PATCH 07/13] Remove x-kubernetes-preserve-unknown-fields and XPreserveUnknownFields. --- manifests/postgresql.crd.yaml | 12 +++++++++++- pkg/apis/acid.zalan.do/v1/crds.go | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index 92aada54e..77700097a 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -4062,7 +4062,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: description: |- deprecated load balancer settings maintained for backward compatibility diff --git a/pkg/apis/acid.zalan.do/v1/crds.go b/pkg/apis/acid.zalan.do/v1/crds.go index 1ab85c905..7f536a006 100644 --- a/pkg/apis/acid.zalan.do/v1/crds.go +++ b/pkg/apis/acid.zalan.do/v1/crds.go @@ -56,6 +56,7 @@ var OperatorConfigCRDResourceColumns = []apiextv1.CustomResourceColumnDefinition } var min1 = 1.0 +var minLength1 int64 = 1 var minDisable = -1.0 // OperatorConfigCRDResourceValidation to check applied manifest parameters From b36e3e665b5ab6b890ae946196da36d0fea067a2 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 18 Oct 2025 22:36:36 +0700 Subject: [PATCH 08/13] 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 e8c8730b6..c07808088 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -894,9 +894,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 e9915f37eeae8721589573ff0e237352728adc78 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sat, 18 Oct 2025 22:56:38 +0700 Subject: [PATCH 09/13] 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 7677dca62..31fda38cf 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 a7ee87cf9ea394a60de93d2871314f585764b2b2 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 19 Oct 2025 13:08:09 +0700 Subject: [PATCH 10/13] 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 cbf5c98ef..8381a6ee8 100644 --- a/charts/postgres-operator/crds/postgresqls.yaml +++ b/charts/postgres-operator/crds/postgresqls.yaml @@ -589,6 +589,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 720836c30d2e949d668aec33a4910c7ba3650be9 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 19 Oct 2025 13:29:26 +0700 Subject: [PATCH 11/13] 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 db33d0bd6..b22912714 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 @@ -1064,7 +1081,7 @@ spec: - all volumeSource: emptyDir: {} - sidecars: + sidecars: - name: "container-name" image: "company/image:tag" volumeMounts: From 1312388a865365099b9806c00257e6fa2668add6 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Sun, 19 Oct 2025 14:24:18 +0700 Subject: [PATCH 12/13] 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 6a0bf60cc..c745af5a4 100644 --- a/e2e/tests/test_e2e.py +++ b/e2e/tests/test_e2e.py @@ -2411,9 +2411,23 @@ class EndToEndTestCase(unittest.TestCase): 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") @@ -2431,9 +2445,17 @@ class EndToEndTestCase(unittest.TestCase): 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): From fa9f3d697ed69a3c159aef22a6b7e528f9c88386 Mon Sep 17 00:00:00 2001 From: Trung Minh Lai Date: Mon, 26 Jan 2026 21:48:08 +0700 Subject: [PATCH 13/13] Revert unnecessary change. --- e2e/tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/test_e2e.py b/e2e/tests/test_e2e.py index c745af5a4..30a1ebf12 100644 --- a/e2e/tests/test_e2e.py +++ b/e2e/tests/test_e2e.py @@ -2532,7 +2532,7 @@ class EndToEndTestCase(unittest.TestCase): 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), 6, "PVCs were deleted although disabled in config") + self.eventuallyEqual(lambda: k8s.count_pvcs_with_label(cluster_label), 3, "PVCs were deleted although disabled in config") except timeout_decorator.TimeoutError: print('Operator log: {}'.format(k8s.get_operator_log()))