Skip to content

Commit 35093c6

Browse files
sbueringerk8s-infra-cherrypick-robot
authored andcommitted
Reduce memory usage of default webhooks
1 parent 4dbfa5c commit 35093c6

2 files changed

Lines changed: 193 additions & 6 deletions

File tree

pkg/webhook/admission/defaulter_custom.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,7 @@ func (h *defaulterForType[T]) Handle(ctx context.Context, req Request) Response
129129
return Errored(http.StatusBadRequest, err)
130130
}
131131

132-
// Keep a copy of the object if needed
133-
var originalObj T
134-
if !h.removeUnknownOrOmitableFields {
135-
originalObj = obj.DeepCopyObject().(T)
136-
}
132+
originalObj := obj.DeepCopyObject().(T)
137133

138134
// Default the object
139135
if err := h.defaulter.Default(ctx, obj); err != nil {
@@ -144,6 +140,19 @@ func (h *defaulterForType[T]) Handle(ctx context.Context, req Request) Response
144140
return Denied(err.Error())
145141
}
146142

143+
// If the object is not changed, there's no reason to go through the expensive patch calculation below.
144+
// Note: While jsonpatch.CreatePatch short-circuits if both byte arrays are equal this is likely never the case.
145+
// * json.Marshal that we use below sorts fields alphabetically
146+
// * for builtin types the apiserver also sorts alphabetically (but it seems like it adds an empty line at the end)
147+
// * for CRDs the apiserver uses the field order in the OpenAPI schema which very likely is not alphabetically sorted
148+
if reflect.DeepEqual(originalObj, obj) {
149+
return Response{
150+
AdmissionResponse: admissionv1.AdmissionResponse{
151+
Allowed: true,
152+
},
153+
}
154+
}
155+
147156
// Create the patch
148157
marshalled, err := json.Marshal(obj)
149158
if err != nil {

pkg/webhook/admission/defaulter_custom_test.go

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,24 @@ limitations under the License.
1515
package admission
1616

1717
import (
18+
"bytes"
1819
"context"
1920
"maps"
2021
"net/http"
22+
"testing"
2123

2224
. "github.com/onsi/ginkgo/v2"
2325
. "github.com/onsi/gomega"
2426
"gomodules.xyz/jsonpatch/v2"
25-
2627
admissionv1 "k8s.io/api/admission/v1"
28+
corev1 "k8s.io/api/core/v1"
29+
"k8s.io/apimachinery/pkg/api/resource"
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2731
"k8s.io/apimachinery/pkg/runtime"
2832
"k8s.io/apimachinery/pkg/runtime/schema"
33+
"k8s.io/apimachinery/pkg/runtime/serializer/json"
34+
"k8s.io/apimachinery/pkg/util/intstr"
35+
"k8s.io/utils/ptr"
2936
)
3037

3138
var _ = Describe("Defaulter Handler", func() {
@@ -167,3 +174,174 @@ func (d *TestCustomDefaulter) Default(ctx context.Context, obj runtime.Object) e
167174
o.TotalReplicas = 0
168175
return nil
169176
}
177+
178+
func BenchmarkDefaulter(b *testing.B) {
179+
scheme := runtime.NewScheme()
180+
_ = corev1.AddToScheme(scheme)
181+
182+
b.Run("noop", func(b *testing.B) {
183+
req := createTestRequest("test-hostname")
184+
handler := WithDefaulter(scheme, &PodDefaulter{
185+
defaultHostname: "test-hostname", // defaulting will be no-op as hostname is already "test-hostname"
186+
})
187+
b.ResetTimer()
188+
b.ReportAllocs()
189+
for i := 0; i < b.N; i++ {
190+
_ = handler.Handle(b.Context(), req)
191+
}
192+
})
193+
194+
b.Run("with changes", func(b *testing.B) {
195+
req := createTestRequest("")
196+
handler := WithDefaulter(scheme, &PodDefaulter{
197+
defaultHostname: "test-hostname", // will be used to default hostname
198+
})
199+
b.ResetTimer()
200+
b.ReportAllocs()
201+
for i := 0; i < b.N; i++ {
202+
_ = handler.Handle(b.Context(), req)
203+
}
204+
})
205+
}
206+
207+
type PodDefaulter struct {
208+
defaultHostname string
209+
}
210+
211+
func (p PodDefaulter) Default(_ context.Context, pod *corev1.Pod) error {
212+
if pod.Spec.Hostname == "" && p.defaultHostname != "" {
213+
pod.Spec.Hostname = p.defaultHostname
214+
}
215+
return nil
216+
}
217+
218+
func createTestRequest(hostname string) Request {
219+
return Request{
220+
AdmissionRequest: admissionv1.AdmissionRequest{
221+
UID: "12345",
222+
Kind: metav1.GroupVersionKind{
223+
Group: "",
224+
Version: "v1",
225+
Kind: "Pod",
226+
},
227+
Object: runtime.RawExtension{Raw: mustEncodeLikeAPIServer(createSuperPod(hostname))},
228+
Operation: admissionv1.Create,
229+
},
230+
}
231+
}
232+
233+
func createSuperPod(hostname string) *corev1.Pod {
234+
return &corev1.Pod{
235+
ObjectMeta: metav1.ObjectMeta{
236+
Name: "monolith-service-7f8d9b",
237+
Namespace: "prod",
238+
Labels: map[string]string{
239+
"app": "processor", "env": "prod", "version": "1.4.2",
240+
"pci-compliant": "true", "team": "data-eng",
241+
},
242+
Annotations: map[string]string{
243+
"agent-inject": "true",
244+
"checksum/config": "e3b0c23238fc1c149afbf4c8996fb92427ae",
245+
},
246+
Finalizers: []string{"cleanup.finalizer.my.io"},
247+
},
248+
Spec: corev1.PodSpec{
249+
Hostname: hostname,
250+
InitContainers: []corev1.Container{
251+
{
252+
Name: "vault-init", Image: "vault:1.13.1",
253+
SecurityContext: &corev1.SecurityContext{RunAsUser: ptr.To[int64](1000)},
254+
},
255+
},
256+
Containers: []corev1.Container{
257+
{
258+
Name: "main-app",
259+
Image: "my-priv-reg.io/analytics:v1.4.2",
260+
Env: []corev1.EnvVar{
261+
{Name: "DB_URL", Value: "postgres://db:5432"},
262+
{
263+
Name: "POD_IP",
264+
ValueFrom: &corev1.EnvVarSource{
265+
FieldRef: &corev1.ObjectFieldSelector{FieldPath: "status.podIP"},
266+
},
267+
},
268+
},
269+
Resources: corev1.ResourceRequirements{
270+
Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")},
271+
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")},
272+
},
273+
LivenessProbe: &corev1.Probe{
274+
ProbeHandler: corev1.ProbeHandler{
275+
HTTPGet: &corev1.HTTPGetAction{Path: "/health", Port: intstr.FromInt(8080)},
276+
},
277+
InitialDelaySeconds: 30,
278+
},
279+
},
280+
{
281+
Name: "log-shipper",
282+
Image: "fluentbit:2.1.4",
283+
Args: []string{"--config", "/etc/fluent-bit/fluent-bit.conf"},
284+
},
285+
},
286+
Affinity: &corev1.Affinity{
287+
NodeAffinity: &corev1.NodeAffinity{
288+
RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
289+
NodeSelectorTerms: []corev1.NodeSelectorTerm{
290+
{
291+
MatchExpressions: []corev1.NodeSelectorRequirement{
292+
{Key: "topology.kubernetes.io/zone", Operator: corev1.NodeSelectorOpIn, Values: []string{"us-east-1a", "us-east-1b"}},
293+
},
294+
},
295+
},
296+
},
297+
},
298+
},
299+
TopologySpreadConstraints: []corev1.TopologySpreadConstraint{
300+
{
301+
MaxSkew: 1,
302+
TopologyKey: "kubernetes.io/hostname",
303+
WhenUnsatisfiable: corev1.DoNotSchedule,
304+
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "processor"}},
305+
},
306+
},
307+
SecurityContext: &corev1.PodSecurityContext{
308+
RunAsNonRoot: ptr.To(true),
309+
SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault},
310+
},
311+
TerminationGracePeriodSeconds: ptr.To[int64](60),
312+
ReadinessGates: []corev1.PodReadinessGate{
313+
{ConditionType: "target-health.lbv3.k8s.cloud/my-tg"},
314+
},
315+
Volumes: []corev1.Volume{
316+
{
317+
Name: "config",
318+
VolumeSource: corev1.VolumeSource{
319+
ConfigMap: &corev1.ConfigMapVolumeSource{
320+
LocalObjectReference: corev1.LocalObjectReference{Name: "app-config"},
321+
},
322+
},
323+
},
324+
},
325+
},
326+
}
327+
}
328+
329+
func mustEncodeLikeAPIServer(obj runtime.Object) []byte {
330+
// 1. Create a NewSerializer
331+
// Options: false (not pretty printed), false (not YAML)
332+
s := json.NewSerializerWithOptions(
333+
json.DefaultMetaFactory,
334+
nil,
335+
nil,
336+
json.SerializerOptions{Yaml: false, Pretty: false, Strict: false},
337+
)
338+
339+
// 2. Use a Buffer for efficiency
340+
buf := new(bytes.Buffer)
341+
err := s.Encode(obj, buf)
342+
if err != nil {
343+
panic(err)
344+
}
345+
346+
return buf.Bytes()
347+
}

0 commit comments

Comments
 (0)