From 6467ad7c4482e4c2c6b5cb7250c51c62ac7373f6 Mon Sep 17 00:00:00 2001 From: Sang Yeon Cho Date: Wed, 13 May 2026 19:42:18 +0900 Subject: [PATCH] feat: add pod_sysctls operator config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `pod_sysctls` option to KubernetesMetaConfiguration that populates `pod.spec.securityContext.sysctls` in the operator-generated StatefulSet pod template. Motivation ---------- Cluster-wide mutating admission webhooks (e.g. internal platform policies that inject TCP keepalive sysctls so connections through L4 load balancers don't go stale) modify the StatefulSet pod template after the operator creates it. The StatefulSet comparator in `compareStatefulSetWith` uses `reflect.DeepEqual` on `pod.spec.securityContext`, so any field the operator does not set (like `Sysctls`) but the webhook injects produces a permanent diff. Every sync (`resync_period`, default 30m) then sees "pod template security context in spec does not match the current one" and triggers a rolling update + Patroni switchover, indefinitely. By declaring the same sysctls list in the operator config, the operator-generated template matches the webhook-mutated cluster state and no spurious rolling restart is triggered. Implementation -------------- * `pkg/util/config/config.go`: add `PodSysctls []v1.Sysctl` to the `Resources` config struct. * `pkg/apis/acid.zalan.do/v1/operator_configuration_type.go`: add matching CRD field. * `pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go`: handle the new slice in DeepCopyInto. * `pkg/apis/acid.zalan.do/v1/crds.go`, `manifests/operatorconfiguration.crd.yaml`, `charts/postgres-operator/crds/operatorconfigurations.yaml`: extend the CRD openAPI schema (array of `{name, value}` objects). * `pkg/controller/operator_config.go`: copy the CRD field into the internal config struct. * `pkg/cluster/k8sres.go`: in `generatePodTemplate`, set `securityContext.Sysctls = c.OpConfig.PodSysctls` when non-empty. * `pkg/cluster/k8sres_test.go`: new `TestPodSysctls` covering both the configured and the empty case. * `manifests/postgresql-operator-default-configuration.yaml`, `charts/postgres-operator/values.yaml`, `docs/reference/operator_parameters.md`: example usage and reference documentation. Notes ----- * Available in the OperatorConfiguration CRD mode only (matches the existing pattern for complex-typed options such as `sidecars`). * The list is applied verbatim — the order and values must match what the external mutator expects, or the comparator will still flag a diff. Signed-off-by: Sang Yeon Cho --- .../crds/operatorconfigurations.yaml | 12 ++++ charts/postgres-operator/values.yaml | 10 ++++ docs/reference/operator_parameters.md | 12 ++++ manifests/operatorconfiguration.crd.yaml | 12 ++++ ...gresql-operator-default-configuration.yaml | 3 + pkg/apis/acid.zalan.do/v1/crds.go | 17 ++++++ .../v1/operator_configuration_type.go | 1 + .../acid.zalan.do/v1/zz_generated.deepcopy.go | 5 ++ pkg/cluster/k8sres.go | 4 ++ pkg/cluster/k8sres_test.go | 58 +++++++++++++++++++ pkg/controller/operator_config.go | 1 + pkg/util/config/config.go | 1 + 12 files changed, 136 insertions(+) diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 58e84bd2f..3f80d4c03 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -336,6 +336,18 @@ spec: pod_service_account_role_binding_definition: type: string default: "" + pod_sysctls: + type: array + items: + type: object + required: + - name + - value + properties: + name: + type: string + value: + type: string pod_terminate_grace_period: type: string default: "5m" diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index 426e4267d..c3a902279 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -200,6 +200,16 @@ configKubernetes: # role binding definition as JSON/YAML string to be used by pod service account # pod_service_account_role_binding_definition: "" + # list of sysctls applied to the pod-level securityContext of every Postgres pod. + # Useful when a mutating admission webhook (e.g. internal platform policy) injects + # sysctls into the pod template — declare them here so the operator-generated + # template matches the webhook-mutated cluster state and no needless rolling + # restart is triggered on every sync. The list is applied verbatim, so the order + # and values must match what the webhook expects. + # pod_sysctls: + # - name: "net.ipv4.tcp_keepalive_time" + # value: "600" + # Postgres pods are terminated forcefully after this timeout pod_terminate_grace_period: 5m # template for database user secrets generated by the operator, diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index 5662d6b8e..e69d6dfe2 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -525,6 +525,18 @@ configuration they are grouped under the `kubernetes` key. PodSecruityPolicy allows the capabilities listed here. Otherwise, the container will not start. The default is empty. +* **pod_sysctls** + list of sysctls applied to the pod-level `securityContext.sysctls` of every + Postgres pod (and any sidecar/init containers sharing the pod). Each entry is + `{name, value}`. Useful when a cluster-wide mutating admission webhook + (e.g. an internal platform policy) injects sysctls into the pod template; + declaring the same list here lets the operator-generated template match the + webhook-mutated cluster state, so the statefulset comparator does not flag a + spurious diff and trigger a rolling update on every sync. The list is applied + verbatim, so the order and values must match what the webhook expects. The + default is empty (no sysctls). Only available in the OperatorConfiguration + CRD configuration mode. + * **master_pod_move_timeout** The period of time to wait for the success of migration of master pods from an unschedulable node. The migration includes Patroni switchovers to diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index 466f5190e..2b02dee4d 100644 --- a/manifests/operatorconfiguration.crd.yaml +++ b/manifests/operatorconfiguration.crd.yaml @@ -334,6 +334,18 @@ spec: pod_service_account_role_binding_definition: type: string default: "" + pod_sysctls: + type: array + items: + type: object + required: + - name + - value + properties: + name: + type: string + value: + type: string pod_terminate_grace_period: type: string default: "5m" diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 5251f9a06..5b372d945 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -102,6 +102,9 @@ configuration: # pod_service_account_definition: "" pod_service_account_name: postgres-pod # pod_service_account_role_binding_definition: "" + # pod_sysctls: + # - name: "net.ipv4.tcp_keepalive_time" + # value: "600" pod_terminate_grace_period: 5m secret_name_template: "{username}.{cluster}.credentials.{tprkind}.{tprgroup}" share_pgsocket_with_sidecars: false diff --git a/pkg/apis/acid.zalan.do/v1/crds.go b/pkg/apis/acid.zalan.do/v1/crds.go index b89cb1448..cc3c6a863 100644 --- a/pkg/apis/acid.zalan.do/v1/crds.go +++ b/pkg/apis/acid.zalan.do/v1/crds.go @@ -1506,6 +1506,23 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{ "pod_service_account_role_binding_definition": { Type: "string", }, + "pod_sysctls": { + Type: "array", + Items: &apiextv1.JSONSchemaPropsOrArray{ + Schema: &apiextv1.JSONSchemaProps{ + Type: "object", + Required: []string{"name", "value"}, + Properties: map[string]apiextv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + "value": { + Type: "string", + }, + }, + }, + }, + }, "pod_terminate_grace_period": { Type: "string", }, diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index cd11b9173..5f4b69092 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -67,6 +67,7 @@ type KubernetesMetaConfiguration struct { SpiloRunAsGroup *int64 `json:"spilo_runasgroup,omitempty"` SpiloFSGroup *int64 `json:"spilo_fsgroup,omitempty"` AdditionalPodCapabilities []string `json:"additional_pod_capabilities,omitempty"` + PodSysctls []v1.Sysctl `json:"pod_sysctls,omitempty"` WatchedNamespace string `json:"watched_namespace,omitempty"` PDBNameFormat config.StringTemplate `json:"pdb_name_format,omitempty"` PDBMasterLabelSelector *bool `json:"pdb_master_label_selector,omitempty"` 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..6a44cffbc 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -188,6 +188,11 @@ func (in *KubernetesMetaConfiguration) DeepCopyInto(out *KubernetesMetaConfigura *out = make([]string, len(*in)) copy(*out, *in) } + if in.PodSysctls != nil { + in, out := &in.PodSysctls, &out.PodSysctls + *out = make([]corev1.Sysctl, len(*in)) + copy(*out, *in) + } if in.PDBMasterLabelSelector != nil { in, out := &in.PDBMasterLabelSelector, &out.PDBMasterLabelSelector *out = new(bool) diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 9bc39a9db..d5d310db2 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -845,6 +845,10 @@ func (c *Cluster) generatePodTemplate( securityContext.FSGroup = spiloFSGroup } + if len(c.OpConfig.PodSysctls) > 0 { + securityContext.Sysctls = c.OpConfig.PodSysctls + } + podSpec := v1.PodSpec{ ServiceAccountName: podServiceAccountName, TerminationGracePeriodSeconds: &terminateGracePeriodSeconds, diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 6bd87366d..b639eb49f 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -1475,6 +1475,64 @@ func TestNodeAffinity(t *testing.T) { assert.Equal(t, s.Spec.Template.Spec.Affinity.NodeAffinity, nodeAff, "cluster template has correct node affinity") } +func TestPodSysctls(t *testing.T) { + spec := acidv1.PostgresSpec{ + TeamID: "myapp", 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"}, + } + + t.Run("sysctls applied to pod securityContext when configured", func(t *testing.T) { + sysctls := []v1.Sysctl{ + {Name: "net.ipv4.tcp_keepalive_time", Value: "600"}, + {Name: "net.ipv4.tcp_keepalive_intvl", Value: "20"}, + {Name: "net.ipv4.tcp_keepalive_probes", Value: "3"}, + } + cluster := New( + Config{ + OpConfig: config.Config{ + PodManagementPolicy: "ordered_ready", + ProtectedRoles: []string{"admin"}, + Auth: config.Auth{ + SuperUsername: superUserName, + ReplicationUsername: replicationUserName, + }, + Resources: config.Resources{ + PodSysctls: sysctls, + }, + }, + }, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + + s, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + assert.NotNil(t, s.Spec.Template.Spec.SecurityContext, "pod SecurityContext should not be nil") + assert.Equal(t, sysctls, s.Spec.Template.Spec.SecurityContext.Sysctls, + "pod securityContext.sysctls should match operator configuration") + }) + + t.Run("sysctls omitted when not configured", func(t *testing.T) { + cluster := New( + Config{ + OpConfig: config.Config{ + PodManagementPolicy: "ordered_ready", + ProtectedRoles: []string{"admin"}, + Auth: config.Auth{ + SuperUsername: superUserName, + ReplicationUsername: replicationUserName, + }, + }, + }, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + + s, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + assert.Nil(t, s.Spec.Template.Spec.SecurityContext.Sysctls, + "pod securityContext.sysctls should be nil when pod_sysctls is empty") + }) +} + func TestPodAffinity(t *testing.T) { clusterName := "acid-test-cluster" namespace := "default" diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index 4650fe8d7..7982af5d1 100644 --- a/pkg/controller/operator_config.go +++ b/pkg/controller/operator_config.go @@ -80,6 +80,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur result.SpiloRunAsGroup = fromCRD.Kubernetes.SpiloRunAsGroup result.SpiloFSGroup = fromCRD.Kubernetes.SpiloFSGroup result.AdditionalPodCapabilities = fromCRD.Kubernetes.AdditionalPodCapabilities + result.PodSysctls = fromCRD.Kubernetes.PodSysctls result.ClusterDomain = util.Coalesce(fromCRD.Kubernetes.ClusterDomain, "cluster.local") result.WatchedNamespace = fromCRD.Kubernetes.WatchedNamespace result.PDBNameFormat = fromCRD.Kubernetes.PDBNameFormat diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index aca9754a9..85fd868e5 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -39,6 +39,7 @@ type Resources struct { SpiloPrivileged bool `name:"spilo_privileged" default:"false"` SpiloAllowPrivilegeEscalation *bool `name:"spilo_allow_privilege_escalation" default:"true"` AdditionalPodCapabilities []string `name:"additional_pod_capabilities" default:""` + PodSysctls []v1.Sysctl `name:"pod_sysctls"` ClusterLabels map[string]string `name:"cluster_labels" default:"application:spilo"` InheritedLabels []string `name:"inherited_labels" default:""` InheritedAnnotations []string `name:"inherited_annotations" default:""`