Kubernetes API'sine Giden JSON'da Boş Harita Tuzağı
Kubernetes şemasında bir harita alanı boşken JSON'da dizi mi nesne mi yazılacağı, dile göre değişen sinsi bir hataya yol açar. Nedeni ve önlemesi.
Sorunun kökü
Kubernetes API sunucusuna bir nesne gönderdiğinizde, metadata.labels ve metadata.annotations gibi alanlar şemada map[string]string olarak tanımlıdır. Sorun bu alanlar boş olduğunda çıkar. JSON'da "boş" iki farklı biçimde yazılabilir: boş dizi [] ya da boş nesne {}. İkisi de günlük konuşmada "içinde hiçbir şey yok" demektir, ama API sunucusu yalnızca birini kabul eder. Diğerini gönderirseniz nesnenin tamamı reddedilir; Kubernetes'te kısmi kabul yoktur, ya bütün istek geçer ya da hiçbiri geçmez.
// reddedilir: boş dizi
{ "metadata": { "annotations": [] } }
// kabul edilir: boş nesne
{ "metadata": { "annotations": {} } }
Bu, bir operatör, controller ya da CI aracı yazan çoğu geliştiricinin er ya da geç karşılaştığı ama nedenini anlamadan "bir şekilde çözülen" bir hatadır. Sunucunun döndürdüğü mesaj da yardımcı olmaz: cannot unmarshal array into Go struct field ObjectMeta.metadata.annotations of type map[string]string gibi bir satır, JSON'un kendi belirsizliğine değil Go'nun tip sistemine işaret eder. Okuyan kişi önce kendi kodunu değil API sunucusunu şüpheli bulur, çünkü hata mesajı sorunun asıl yerini göstermez.
Hatanın sinsi tarafı zamanlamasıdır. Kod, en az bir etiket ya da açıklama taşıyan nesnelerde kusursuz çalışır; testlerin büyük kısmı da genelde dolu örneklerle yazılır. Sorun yalnızca gerçekten boş bir haritayla karşılaşıldığında ortaya çıkar, bu da genelde üretimde, ilk kez rastlanan bir uç durumdur.
Diller bu belirsizliği nasıl taşır
Sorunun büyüklüğü, istemciyi yazdığınız dile göre değişir.
Go'da risk düşüktür çünkü dil map ve slice'ı ayrı tiplerde tutar: map[string]string{} her zaman {} olarak, []string{} her zaman [] olarak kodlanır. Tek gerçek tuzak nil map'tir; var m map[string]string tanımlandığında değer null olarak yazılır. Çoğu alan bunu kabul eder ama structural schema kuralları sıkı tutulan bazı CRD'lerde bu da reddedilebilir.
PHP'de risk yüksektir çünkü dilin ayrı bir map tipi yoktur; liste de ilişkisel dizi de aynı array tipindedir. json_encode([]) içeriği ne olursa olsun her zaman [] üretir. Boş bir haritayı nesne olarak kodlamak için elle (object) cast'i ya da JsonSerializable arayüzü üzerinden özel bir davranış tanımlamak gerekir. Bu, PHP ile yazılmış herhangi bir Kubernetes istemcisinde en sık görülen versiyondur.
Python'da native dict ve list ayrıdır, json.dumps({}) doğru şekilde {} üretir. Risk daha çok, bir haritayı bir liste yapısından (örneğin anahtar değer çiftlerinin listesinden) türetip son adımda dict() çevirmeyi unutmaktan doğar.
JavaScript ve TypeScript'te de ayrım native olarak vardır, ama tip sistemi zayıf çalıştığı için bir yardımcı fonksiyon "boş" değeri varsayılan olarak [] döndürüyorsa (birçok yardımcı kütüphanede "boş koleksiyon" denince akla ilk gelen budur) bu değer, hiç fark edilmeden bir map alanına sızabilir.
Ortak nokta şu: risk dilin kendi tip sisteminde değil, geliştiricinin "boş" ile "harita" arasında bilinçli bir bağ kurup kurmadığındadır. Struct ve şema üzerinden çalışan diller güvenlidir; dinamik tipli diller ve elle üretilmiş JSON riskli olandır.
Uygulanabilir önlemler
Üç savunma katmanı birlikte işe yarar, tek başına hiçbiri yeterli değildir.
Birincisi, kaynak kodunda: harita alanı boş olabiliyorsa, boşluğu temsil eden değeri açıkça map tipine sabitleyin. PHP'de $labels === [] ? (object) [] : $labels, TypeScript'te dönüş tipi Record<string, string> olarak işaretlenmiş bir yardımcı fonksiyon, Python'da tip ipucu (dict[str, str]) ve statik analiz aracının (mypy, pyright) bunu denetlemesi. Bu, "unuttum" ihtimalini çalışma zamanından derleme ya da lint aşamasına taşır.
İkincisi, sunucuya göndermeden önce doğrulama: kubectl apply --dry-run=server -f manifest.yaml isteği gerçek API sunucusuna gönderir ama hiçbir şeyi kalıcı yazmaz. Şema hatası varsa aynı 400 hatasını, üretime hiç dokunmadan, CI hattı içinde alırsınız. Canlı bir kümeye erişimin olmadığı, yalnızca manifest üreten bir adımda ise kubeconform gibi bağımsız şema doğrulayıcılar aynı işi bir kümeye bağlanmadan görür; ikisi birbirinin yerine değil, birbirini tamamlayan iki kapı olarak düşünülmelidir. --dry-run=server gerçek API sunucusunun kabul edip etmeyeceğini söyler, bağımsız doğrulayıcı ise kümeye hiç ihtiyaç duymadan hızlı geri bildirim verir.
Bunun tipik sonucu şudur: bir dağıtım betiği elli nesneyi art arda oluşturur, ilk kırk dokuzu en az bir etiket taşıdığı için sorunsuz geçer, ellinci nesne ise küçük bir yapılandırma farkıyla boş bir haritayla gelir ve tüm dağıtım orada durur. Hata, kodun yeni yazıldığı gün değil, haftalar sonra ilk kez "boş" bir girdiyle karşılaşıldığında ortaya çıkar; bu da onu hem bulmayı hem de "neden şimdiye kadar çalışıyordu" sorusunu cevaplamayı zorlaştırır.
Üçüncüsü, testler: bir istemci kütüphanesi ya da controller yazıyorsanız, boş harita durumunu ayrı bir birim testiyle koruma altına alın. Kritik nokta, testi DEĞER üzerinden değil KODLANMIŞ ÇIKTI üzerinden yazmaktır. PHP'de [] == (object) [] ifadesi true döner, çünkü dilin gevşek karşılaştırma operatörü ikisini eşit sayar; bu yüzden expect($x)->toEqual([]) gibi bir iddia, alttaki kodlama hatasını hiçbir zaman yakalamaz. Doğrusu, nesneyi gerçekten JSON'a çevirip elde edilen string'i karşılaştırmaktır: json_encode($x) === '{}'. Aynı ilke başka dillerde de geçerlidir, sınanması gereken şey çalışma zamanındaki temsil değil, telin üzerinden geçen bayt dizisidir.
Kendi CRD'nizi yazıyorsanız bir katman daha
Kendi Custom Resource Definition'ınızı tanımlıyorsanız, şemayı type: object ve mümkün olduğunda additionalProperties ile sıkı tutmak sizi yalnızca kendi istemcinizin değil, o CRD'yi tüketen üçüncü taraf istemcilerin hatalarından da korur. Structural schema kuralı gevşetilmiş bir CRD (x-kubernetes-preserve-unknown-fields: true ile), bu tür hataları API sunucusu seviyesinde değil çok daha geç, kendi kontrolcünüzün kod satırında patlatır. O noktada hata mesajı sizin kodunuzu işaret eder, halbuki kök neden çağıranın gönderdiği veridir. Şemayı sıkı tutmak, hatayı en erken ve en doğru katmanda yakalamanın yoludur.
Ne zaman bu kadar dikkat gerekmez
Resmi bir istemci kütüphanesi kullanıyorsanız (Go için client-go, Python için resmi kubernetes client, Java için fabric8), bu kütüphaneler tip güvenli struct'lar üzerinden çalışır ve serileştirmeyi kendileri yapar. Elle JSON ya da YAML üretmediğiniz sürece bu tuzağa girmezsiniz, çünkü struct tanımı zaten map alanını doğru tipte tutar.
Sorun asıl, bir controller'ı hızlıca bir betikle, elle JSON üreterek ya da tip denetimi zayıf bir dille yazdığınızda ortaya çıkar. Böyle bir durumdaysanız ve resmi istemciyi bağımlılık olarak eklemek işin ölçeğine göre orantısız geliyorsa, en azından boş harita alanlarını kontrol eden tek bir yardımcı fonksiyon yazıp bunu kod tabanının her yerinde kullanmak, riski pratikte sıfıra indirmeye yeter. Asıl kaçınılması gereken, aynı kontrolü her çağrı noktasında ayrı ayrı, hatırlanarak yazmaya çalışmaktır; bir noktada mutlaka unutulur ve hata yalnızca boş bir nesneyle karşılaşıldığında, en beklenmedik anda ortaya çıkar.