Cilium Hikayesi: Küçük Bir Kod Değişikliğinin Sağladığı Dikkat Çekici Ağ Kararlılığı İyileştirmesi

Giriş
Kısa bir süre önce eski iş arkadaşımın Cilium projesine gönderdiği bir PR'a göz atma fırsatı buldum.
bpf:nat: Restore ORG NAT entry if it's not found
(Test kodu hariç) değişikliğin kendisi, bir if bloğu eklemekten ibaret kadar küçük. Ancak bu değişikliğin doğurduğu etki çok büyük ve tek bir basit fikrin sistem kararlılığına ne kadar büyük katkı sağlayabileceği beni kişisel olarak ilgilendirdi. Bu nedenle, ağ alanında uzmanlığı olmayan kişilerin de bu örneği kolayca anlayabilmesi için bu olayı bir hikâye şeklinde anlatmaya karar verdim.
Arka Plan Bilgisi
Akıllı telefon kadar modern insanın vazgeçemediği bir gereç varsa, bu muhtemelen Wi-Fi yönlendiricisidir. Wi-Fi yönlendiricisi, Wi-Fi adı verilen iletişim standardı üzerinden cihazlarla haberleşir ve sahip olduğu genel IP adresini birden fazla cihazın kullanabilmesi için paylaşır. Burada ortaya çıkan teknik ilginç nokta şudur: Bu paylaşım nasıl gerçekleştirilir?
Bu süreçte kullanılan teknoloji Network Address Translation (NAT) teknolojisidir. NAT, TCP veya UDP iletişiminin IP adresi ve port bilgisinin bir kombinasyonundan oluştuğu gerçeğinden yola çıkarak, özel IP:Port ile yürütülen iç iletişimi, şu an kullanılmayan genel IP:Port çiftine eşleyerek dış dünya ile iletişim kurulmasını sağlayan bir teknolojidir.
NAT
NAT cihazı, iç ağdaki bir cihaz dış internete bağlanmak istediğinde, o cihazın özel IP adresi ve port numarası kombinasyonunu kendi genel IP adresi ve kullanılmayan rastgele bir port numarasıyla değiştirir. Bu dönüşüm bilgisi, NAT cihazının içinde NAT tablosu adı verilen bir yapıya kaydedilir.
Örneğin, evdeki bir akıllı telefonun (özel IP: a.a.a.a, port: 50000) bir web sunucusuna (genel IP: c.c.c.c, port: 80) bağlanmak istediğini varsayalım.
1Akıllı telefon (a.a.a.a:50000) ==> Yönlendirici (b.b.b.b) ==> Web sunucusu (c.c.c.c:80)
Yönlendirici, akıllı telefonun isteğini aldığında aşağıdaki gibi bir TCP Paketi görecektir.
1# Yönlendiricinin aldığı TCP paketi, akıllı telefon => yönlendirici
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
Bu paket olduğu gibi web sunucusuna (c.c.c.c) gönderilirse, özel IP adresine sahip akıllı telefona (a.a.a.a) yanıt geri dönemeyecektir. Bu nedenle yönlendirici önce mevcut iletişimde kullanılmayan rastgele bir port (örneğin: 60000) bulur ve bunu iç NAT tablosuna kaydeder.
1# Yönlendirici iç NAT tablosu
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
Yönlendirici NAT tablosuna yeni bir kayıt ekledikten sonra, akıllı telefondan aldığı TCP paketinin kaynak IP adresi ve port numarasını kendi genel IP adresi (b.b.b.b) ve yeni atadığı port numarasıyla (60000) değiştirerek web sunucusuna iletir.
1# Yönlendiricinin gönderdiği TCP paketi, yönlendirici => web sunucusu
2# SNAT uygulandı
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Artık web sunucusu (c.c.c.c), bu isteği yönlendiricinin (b.b.b.b) 60000 numaralı portundan gelen bir istek olarak algılar ve yanıt paketini yönlendiriciye şu şekilde gönderir.
1# Yönlendiricinin aldığı TCP paketi, web sunucusu => yönlendirici
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Yönlendirici bu yanıt paketini aldığında, NAT tablosunda hedef IP adresi (b.b.b.b) ve port numarasına (60000) karşılık gelen özgün özel IP adresini (a.a.a.a) ve port numarasını (50000) bulur ve paketin hedefini akıllı telefon olarak değiştirir.
1# Yönlendiricinin gönderdiği TCP paketi, yönlendirici => akıllı telefon
2# DNAT uygulandı
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Bu süreç sayesinde akıllı telefon, sanki kendisi doğrudan genel bir IP adresine sahipmiş gibi web sunucusuyla iletişim kurduğunu hisseder. NAT sayesinde tek bir genel IP adresiyle birden fazla iç cihaz aynı anda internet kullanabilir.
Kubernetes
Kubernetes, son dönemde ortaya çıkmış teknolojiler arasında en karmaşık ve en gelişmiş ağ yapısına sahiptir. Elbette, daha önce bahsettiğimiz NAT de bu yapının birçok yerinde kullanılmaktadır. Bunun en tipik iki örneği aşağıdaki gibidir.
Pod içinden küme dışıyla iletişim kurulurken
Kubernetes kümesindeki podlar genellikle yalnızca küme ağı içinde iletişim kurabilen özel IP adresleri alır. Dolayısıyla bir podun dış internetle iletişim kurabilmesi için, kümeden dışarı çıkan trafik için NAT gerekir. Bu NAT işlemi genellikle, ilgili podun çalıştığı Kubernetes düğümünde (kümedeki her bir sunucuda) gerçekleştirilir. Pod dışarıya doğru bir paket gönderdiğinde, bu paket önce podun ait olduğu düğüme iletilir. Düğüm, paketin kaynak IP adresini (podun özel IP adresini) kendi genel IP adresiyle değiştirir, kaynak portunu da uygun şekilde değiştirerek paketi dış dünyaya iletir. Bu süreç, daha önce Wi-Fi yönlendiricisi örneğinde açıklanan NAT sürecine benzemektedir.
Örneğin, bir Kubernetes kümesindeki bir podun (10.0.1.10, port: 40000) dış dünyadaki bir API sunucusuna (203.0.113.45, port: 443) bağlandığını varsayalım. Bu durumda Kubernetes düğümü, podtan aşağıdaki gibi bir paket alır.
1# Düğümün aldığı TCP paketi, pod => düğüm
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Düğüm aşağıdaki bilgileri kaydettikten sonra
1# Düğüm iç NAT tablosu (örnek)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
aşağıdaki gibi SNAT uygulayarak paketi dış dünyaya gönderir.
1# Düğümün gönderdiği TCP paketi, düğüm => API sunucusu
2# SNAT uygulandı
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
Bundan sonraki süreç, daha önce anlatılan akıllı telefon ve yönlendirici örneğiyle aynı adımları izler.
Küme dışından NodePort aracılığıyla Pod ile iletişim kurulurken
Kubernetes'te bir servisi dış dünyaya açmanın yollarından biri NodePort servisi kullanmaktır. NodePort servisi, küme içindeki tüm düğümlerde belirli bir portu (NodePort) açık tutar ve bu porta gelen trafiği, servise ait podlara iletir. Dış kullanıcılar, küme düğümlerinin IP adresi ve NodePort üzerinden servise erişebilir.
Bu noktada NAT önemli bir rol oynar ve özellikle DNAT (Destination NAT) ile SNAT (Source NAT) aynı anda gerçekleşir. Dış dünyadan belirli bir düğümün NodePort'una trafik geldiğinde, Kubernetes ağı bu trafiği nihai olarak ilgili servisi sunan podlara iletmelidir. Bu süreçte önce DNAT gerçekleşir ve paketin hedef IP adresi ile port numarası podun IP adresi ve port numarasıyla değiştirilir.
Örneğin, küme dışındaki bir kullanıcının (203.0.113.10, port: 30000), Kubernetes kümesindeki bir düğümün (192.168.1.5) NodePort'u (30001) üzerinden bir servise eriştiğini varsayalım. Bu servisin iç olarak IP adresi 10.0.2.15 ve portu 8080 olan bir podu gösterdiğini kabul edelim.
1Dış kullanıcı (203.0.113.10:30000) ==> Kubernetes düğümü (dış:192.168.1.5:30001 / iç: 10.0.1.1:42132) ==> Kubernetes podu (10.0.2.15:8080)
Burada Kubernetes düğümü, dışarıdan erişilebilen IP adresi 192.168.1.5 ile Kubernetes iç ağında geçerli olan IP adresi 10.0.1.1'in ikisine de sahiptir. (Kullanılan CNI türüne göre bu konudaki politikalar çeşitlenebilir, ancak bu yazıda açıklamalar Cilium esas alınarak yapılacaktır.)
Dış kullanıcının isteği düğüme ulaştığında, düğüm bu isteği işleyecek olan pod'a iletmelidir. Bu amaçla düğüm, paketin hedef IP adresi ve port numarasını değiştirmek için aşağıdaki DNAT kuralını uygular.
1# Düğümün podlara göndermek üzere hazırladığı TCP paketi
2# DNAT uygulandıktan sonra
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Burada önemli nokta şudur: Pod bu isteğe yanıt verirken, paketin kaynak IP adresi kendi IP adresi (10.0.2.15) olur ve hedef IP adresi ise isteği gönderen dış kullanıcının IP adresi (203.0.113.10) olur. Bu durumda dış kullanıcı, hiç istekte bulunmadığı, var olmayan bir IP adresinden yanıt almış olur ve bu paketi doğrudan DROP eder. Bu nedenle Kubernetes düğümü, pod dışarıya yanıt paketi gönderirken ek olarak SNAT uygular ve paketin kaynak IP adresini düğümün IP adresine değiştirir (192.168.1.5 veya iç ağ IP adresi olan 10.0.1.1; bu durumda 10.0.1.1 kullanılarak ilerlenir).
1# Düğümün podlara göndermek üzere hazırladığı TCP paketi
2# DNAT ve SNAT uygulandıktan sonra
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Artık bu paketi alan pod, yanıtını başlangıçta NodePort üzerinden isteği alan düğüme gönderir ve düğüm, aynı DNAT ve SNAT sürecini ters yönde uygulayarak bilgiyi dış kullanıcıya geri iletir. Bu süreçte her düğüm aşağıdaki gibi bilgileri saklayacaktır.
1# Düğüm iç DNAT tablosu
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Düğüm iç SNAT tablosu
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Ana Bölüm
Genel olarak Linux üzerinde bu NAT süreçleri iptables aracılığıyla, conntrack adı verilen bir alt sistem tarafından yönetilir ve çalıştırılır. Nitekim flannel veya calico gibi diğer CNI projeleri de bu mekanizmayı kullanarak yukarıdaki sorunu çözmektedir. Ancak sorun şudur: Cilium, eBPF adı verilen teknolojiyi kullanırken geleneksel Linux ağ yığınını tamamen göz ardı etmektedir. 🤣

Sonuç olarak Cilium, yukarıdaki görselde gösterildiği gibi, geleneksel Linux ağ yığınının yaptığı işlerden yalnızca Kubernetes ortamında gerekli olan işlevleri doğrudan uygulamayı tercih etmiştir. Bu nedenle, daha önce bahsedilen SNAT süreci için Cilium, SNAT tablosunu LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) biçiminde doğrudan yönetmektedir.
1# Cilium SNAT tablosu
2# !Kolay anlatım için bir örnek. Gerçek tanım: https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip | src port | dst ip | dst port | protocol, conntrack vb. diğer meta veriler
4----------------------------------------------
5| | | | |
Hash Table yapısında olduğu gibi, hızlı sorgulama için bir anahtar değer bulunur ve Cilium, anahtar olarak src ip, src port, dst ip, dst port kombinasyonunu kullanmaktadır.
Sorunun Tespiti
Olgu 1: Sorgulama
Bu yapının getirdiği sorun şudur: eBPF'ten geçen bir paketin, NAT sürecinde SNAT veya DNAT işlemi gerektirip gerektirmediğini doğrulamak için yukarıdaki Hash Table'ın sorgulanması gerekir. Daha önce gördüğümüz gibi, SNAT sürecinde iki tür paket bulunur: 1. İçeriden dışarıya çıkan paket ve 2. Bu paketin yanıtı olarak dıştan içeriye gelen paket. Bu iki paket de NAT dönüşümüne ihtiyaç duyar, ancak src ip ile port ve dst ip ile port değerleri birbirinin tersine döner.
Bu nedenle hızlı sorgulama için ya src ve dst değerleri ters çevrilmiş hâliyle bir anahtar daha Hash Table'a eklenmelidir ya da SNAT ile ilgisi olmayan paketler dahil tüm paketler için aynı Hash Table iki kez sorgulanmalıdır. Elbette Cilium, daha iyi performans için RevSNAT adlı yapı ile aynı veriyi iki kez kaydetme yöntemini benimsemiştir.
Olgu 2: LRU
Bunun yanı sıra, tüm donanımların sınırsız kaynağa sahip olamayacağı ve özellikle yüksek performans gerektiren donanım düzeyindeki bir mantık olduğu için dinamik veri yapılarının da kullanılamayacağı bir ortamda, kaynak yetersiz kaldığında mevcut verilerin tahliye (evict) edilmesi gerekir. Cilium bu sorunu, Linux'un varsayılan olarak sunduğu temel veri yapısı olan LRU Hash Map'i kullanarak çözmüştür.
Olgu 1 + Olgu 2 = Bağlantı Kaybı
https://github.com/cilium/cilium/issues/31643
Yani SNAT uygulanmış tek bir TCP (veya UDP) bağlantısı için durum şöyledir:
- Tek bir Hash Table içinde, dışarı çıkan ve içeri gelen paketlere ait aynı veri iki kez kaydedilmiştir ve
- LRU mantığına göre bu iki veriden biri her an kaybolabilir.
Bu durumda, dışarı giden ya da dışarıdan gelen paketlere ait NAT bilgilerinden (bundan sonra entry olarak anılacaktır) herhangi biri LRU tarafından silinirse, NAT düzgün şekilde uygulanamaz ve bu da tüm bağlantının kopmasına yol açabilir.
Çözüm
Burada daha önce bahsettiğimiz şu PR'lar devreye girer:
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
Önceden, bir paket eBPF'ten geçerken SNAT tablosunda src ip, src port, dst ip ve dst port kombinasyonuyla bir anahtar oluşturulup sorgulama yapılıyordu. Anahtar yoksa, SNAT kurallarına göre yeni bir NAT bilgisi oluşturuluyor ve tabloya kaydediliyordu. Yeni bir bağlantı söz konusuysa bu normal iletişimle sonuçlanacaktır. Ancak LRU nedeniyle anahtar istenmeden silinmişse, mevcut iletişimde kullanılan port yerine farklı bir portla yeni bir NAT uygulanacak, bu da paketi alan tarafın paketi reddetmesine ve bağlantının RST paketiyle sonlanmasına yol açacaktır.
Bu noktada yukarıdaki PR'ın izlediği yaklaşım oldukça basittir:
Herhangi bir yönde bir paket gözlemlendiğinde, bu paketin ters yönüne ait entry'yi de yeniden güncelleyelim.
Herhangi bir yönde iletişim gözlemlendiğinde, her iki yöndeki entry de yeniden güncellenir ve böylece LRU mantığındaki tahliye önceliğinden uzaklaşırlar. Bu sayede yalnızca bir tarafın entry'sinin silinip tüm iletişimin çökmesi senaryosunun olasılığı azaltılabilir.
Bu oldukça basit bir yaklaşımdır ve ilk bakışta basit bir fikir gibi görünebilir. Ancak bu yaklaşım sayesinde, yanıt paketine ait NAT bilgisinin önce sona erip bağlantının kopması sorunu etkili bir şekilde çözülmüş ve sistemin kararlılığı önemli ölçüde artırılmıştır. Ayrıca ağ kararlılığı açısından aşağıdaki sonuçları elde eden önemli bir iyileştirme olduğu söylenebilir.

Sonuç
Bu PR'ın, NAT'ın nasıl çalıştığına dair temel bilgisayar bilimi bilgisinden başlayarak, karmaşık bir sistemin içinde bile basit bir fikrin ne kadar büyük bir değişim yaratabileceğini gösteren çok güzel bir örnek olduğunu düşünüyorum.
Tabii ki, bu yazıda karmaşık sistemin örneğini doğrudan göstermedim. Ancak bu PR'ı düzgün anlayabilmek için DeepSeek V3 0324'e Please kelimesini de ekleyerek neredeyse üç saat boyunca yalvardım. Sonuç olarak Cilium hakkındaki bilgime +1 ekledim ve aşağıdaki gibi bir diyagram elde ettim. 😇
Ayrıca, konuları ve PR'ları okurken, daha önce yazdığım bazı kodların bu sorunlara yol açmış olabileceğine dair uğursuz bir his duydum. Bu his, bu tür bir yazıyı yazarak bir nevi kendime ödül verme dürtüsüne dönüştü. 😇
Not 1
Bu yazıda, bu sorunu gidermek için oldukça etkili bir geçici çözüm de bulunmaktadır. Sorunun temel nedeni NAT tablosu alanının yetersiz kalmasıdır, bu yüzden NAT tablosunun boyutunu artırmak yeterlidir. :-D
Aynı sorunla karşılaşan biri, bu sorunu bildirmeden ve sadece NAT tablosunun boyutunu büyüterek ortamı terk edebilecekken, kendisiyle doğrudan ilgili olmamasına rağmen bu sorunu titizlikle analiz eden, anlayan, nesnel kanıt verileriyle birlikte Cilium ekosistemine katkıda bulunan gyutaeb kullanıcısının bu çabasına hayran kaldım ve kendisine saygı duyuyorum.
Bu yazıyı kaleme almaya karar vermemin nedeni de tam olarak budur.
Not 2
Bu hikâye, Go dilini profesyonel olarak ele alan Gosuda ile aslında doğrudan uyumlu bir konu değil. Ancak Go dili ile bulut ekosistemi arasında yakın bir ilişki vardır ve Cilium katkıda bulunanları Go dilinde belirli bir bilgi birikimine sahip olduğundan, kişisel blogda yayımlanabilecek bu içeriği Gosuda'ya taşımayı denedim.
Yöneticilerden biri (bizzat ben) izin verdiği için sorun olmayacağını düşünüyorum.
Eğer uygun olmadığını düşünüyorsanız, bu yazı ne zaman silinebilir, bu yüzden lütfen hemen PDF olarak kaydedin. ;)
Not 3
Bu yazının hazırlanmasında Cline ve Llama 4 Maverick'ten büyük ölçüde yardım aldım. Analize Gemini ile başlamış ve DeepSeek'e yalvarmış olsam da, asıl yardımı Llama 4'ten aldım. Llama 4 çok iyi, mutlaka deneyin.