Kubernetes Tuning 1: Service Nesnesini Devreden Çıkararak RPS'i Nasıl %80 Düşürdük?
10 min read

Kubernetes Tuning 1: Service Nesnesini Devreden Çıkararak RPS'i Nasıl %80 Düşürdük?

Kubernetes Tuning serisinin ilk yazısı olan Service nesnelerini devreden çıkararak nasıl RPS değerlerini önemli ölçüde düşürdüğümüzü anlatıyorum.
Kubernetes Tuning 1: Service Nesnesini Devreden Çıkararak RPS'i Nasıl %80 Düşürdük?

Kubernetes Service nesnesi, ortada birden fazla pod olduğunda onlarla iletişime geçebilmemiz için bize tek bir giriş noktası vererek işleri kolaylaştırır. Peki trafiği podlara eşit dağıtma konusunda gerçekten de verimli çalışıyor mu? Hadi detaylıca inceleyelim.

Service nesneleri, aynı hizmeti sağlayan bir pod grubuna tek ve sabit bir giriş noktası oluşturur. Böylece bir frontend uygulaması, iletişime geçeceği pod ip adreslerinin ne olduğunu bilmesine gerek kalmadan sadece tek bir ip ve port adresi ile yüzlerce dinamik pod havuzuna rahatlıkla erişim sağlayabilir.

Service nesnesi için detaylı yazıma aşağıdaki bağlantıdan ulaşabilirsiniz.

Kubernetes Service Nedir?
Kubernetes Service, bir Pod grubuna sabit bir adresten erişmek için kullanılan bir Kubernetes nesnesidir.

Service nesnesinin aslında fiziksel bir karşılığı yoktur. Clusterınızda yer alan nodelar üzerinde netstat -ntlp komutu çalıştırdığınızda service nesnelerinin sahip olduğu ip ve portları göremediğinizi farkedeceksiniz. Öyleyse trafik nasıl akmaya devam ediyor?

Tam burada devreye kube-proxy girer. Service ip adresleri, cluster veritabanında depolanır (default etcd veya farklı bir db) ve kube-proxy tarafından düzenli olarak okunarak iptables rewrite kuralları oluşturulur. Böylece ilgili service ip adresine denk gelen bir istek geldiğinde, bu istek iptables kurallarına göre hedeflere yani podlara yönlendirilir.

Iptables'in nasıl çalıştığına dair zevkli bir yazıya şu bağlantıdan ulaşabilirsiniz.

Turning IPTables into a TCP load balancer for fun and profit
In this technical deep dive into iptables, the Linux network security configuration utility, we’ll see why and how to build a sophisticated TCP router and load balancer suitable to handle IoT applications traffic.

Artık sorunlu kısıma yavaş yavaş gelelim. Kube-proxy'nin modlarından biri olan ve default gelen iptables, round robin algoritmasını içermez çünkü load balancing için değil, firewall'lar için tasarlanmıştır. Fakat yukarıdaki yazıyı okuduysanız, statistic modülünün random veya nth modlarıyla birlikte load balancing yeteneği iptables'a kazandırılır.

Öyleyse sorun ortadan kalktı mı? Hayır.

Kube-proxy, O(n) complexity ile iptables tablosunu düzenler. Bu sebeple service ve bu service nesnelerinin yönlendireceği pod sayısı arttıkça, iptables tablosunun işlenmesi de uzun zaman almaya başlayacaktır. İlk performans sorunları da burada kendini göstermeye başlar.

Bu sorunu Kube-proxy modlarından O(1) complexity'e sahip olan ve doğal olarak round robin ve daha bir çok load balancing algoritmasına sahip olan IPVS modu ile çözebiliyoruz.

IPVS, direkt olarak load balancing için tasarlanmıştır ve Kubernetes'in kendi dökümanında da net olarak söylediği gibi daha düşük bir latency ve yönlendirme kurallarında daha performanslı sonuçlar sağlar.

💡
...That means kube-proxy in IPVS mode redirects traffic with lower latency than kube-proxy in iptables mode, with much better performance when synchronizing proxy rules... [Link]

Özetle kube-proxy modunuzu değiştirerek aşağıdaki bağlantıda yer alan sonuçlardaki gibi bir performans artışı yaşayabilirsiniz.

Comparing kube-proxy modes: iptables or IPVS? | Tigera
kube-proxy is a key component of any Kubernetes deployment. Its role is to load-balance traffic that is destined for services (via cluster IPs and node ports) to the correct backend pods. Kube-proxy can run in…

Öyleyse sorun ortadan kalktı mı? Yine Hayır.

Sahi, sorun neydi ki?

Artık sorunun kendisini tanımlayalım. Kubernetes Service nesnesini kullanarak ve kube-proxy'nin modlarından yararlanarak bir load balancing yapmaya çalıştığımızda, eğer HTTP keep-alive aktif olan uygulamalarınız varsa load balancing tamamen anlamını yitirir hale geliyor.

Frontend uygulamanız, bir backend uygulamasına HTTP istek attığında, yeni bir TCP bağlantısı açılır ve istek sonunda kapanır. Her istekte bu açma kapama işlemi tekrarlandığı için latency süresi de doğal olarak artar.

Her HTTP isteğinde bir TCP bağlantısı açmak yerine eski bağlantıyı kapatmayıp belirlediğimiz süre boyunca aynı bağlantıyı kullanarak bu latency süresini azaltırız. Ve neredeyse herkes iyi bir çözüm olduğu için bunu yapabilmeyi sağlayan HTTP keep-alive yeteneğini uygulamalarında kullanır. Bu gerçekten iyi bir çözümdür ve latency süresini önemli ölçüde azaltır.

Şekil 1: Keep-alive özelliği TCP bağlantısını açık tutar ve latency sürelerini düşürür.

Tabi çok ciddi bir sorunu da beraberinde getirir. Uygulamalarınız keep-alive kullanarak TCP bağlantısını sonlandırmadığı sürece aynı kaynaktan gelen tüm istekler aynı poda düşmeye devam eder!

Şekil 2: Açık kalan TCP bağlantısı yüzünden aynı kaynaktan gelen tüm istekler aynı poda düşmeye devam eder

Iptables veya IPVS hangi modu kullanırsanız kullanın, halen açık bir TCP bağlantınız olduğu için hedef seçme kuralları tekrar işletilmez ve trafik TCP bağlantısı kapanana kadar aynı pod üzerinden akmaya devam eder. Bu şekilde latency süresini düşüreyim derken scability yeteneğinizi kaybetmiş olursunuz. Hatta tüm istekler aynı poda geldiği için yoğun trafik altında podun kaynak yetersizliği yüzünden azalttığınız latency süresinin çok daha fazlasıyla yüz yüze bile gelebilirsiniz. Nerede kaldı load balancing :)

Peki bunu nasıl çözebiliriz?

  1. HTTP keep-alive özelliğini kapatabilir ve böylece her istekte kuralların işletilerek yeni bir hedef pod seçilmesi sağlanabilir. Tabi latency süreniz tekrar uzar. Kulağa hoş bir yöntem gibi gelmiyor.
  2. Kubernetes API aracılığıyla ilgili Service nesnesine sorularak endpointlerinin (yani hedef pod adresleri, bakınız daha önce anlattım) listesi uygulamanın içinden elde edilir ve service adresi yerine bu hedefler arasından her istekte yeni bir tanesi seçilerek istekler atılır. Tabi bu bir nevi load balancing işini kendinizin yapması demek (client-side load balancing) ve böyle bir iş yüküne değer mi? Hiç sanmıyorum. Ayrıca sadece HTTP değil, gRPC, RSockets vs birçok haberleşme protokolü var. Hangi birini implement edeceksiniz?
  3. Bir önceki maddede bahsettiğim implementasyonları yapan ve başarılı şekilde çalışan Istio, Linkerd, Traefik meshing gibi birçok meshing projesi var. Fakat zaten HPA, VPA, Karpenter vs gibi birçoğuyla boğuşurken sistemi daha karmaşık hale getirmek ve ekstra onların sorunlarıyla ve optimizasyonlarıyla uğraşmak ne kadar efektif bir çözüm sayılabilir? Bu maddede duraktan inenler olabilir ama ben basitlikten yanayım ve devam ediyorum.
  4. Hadi Service nesnesini aradan çıkaralım 👹

Service Nesnesini Aradan Çıkarmak

Kubernetes Service nesnesini iki senaryo için çokça kullanıyoruz. Bu senaryoları detaylı inceleyip performanslarını artıralım.

1. ALB üzerinden gelen trafiği Nodeport ile yönlendirmek (!)

Cluster dışından frontend uygulamamıza erişmek istediğimizde ve bu uygulama birden fazla pod üzerinde çalıştığında Service nesnemizin tipini genelde Nodeport verip node ip adresleri üzerinden onlara erişmeye çalışırız.

Şekil 3: Node portlar üzerinden gelen trafik Service nesnesine, oradan da podlara dağıtılır.

Bu yöntemi kullanırken, nodeları ALB'e manual olarak tek tek eklemek yerine ALB Controller tercih ederiz. Çünkü nodelar spot makineler gibi dinamik olabilir veya service güncellemelerinde her seferinde manual işlem yapmak istemeyiz.

Şekil 4: ALB Controller clusterınızı izleyerek ALB kayıtlarını ve güncellemelerini otomatik gerçekleştirir

ALB Controller kurulumda ise çok büyük oranla instance modu tercih ediliyor ve işleyiş Şekil 3'te çizdiğim şema gibi çalışmaya başlıyor.

Şimdi bu senaryodaki sorunları listeleyelim:

  1. Node port üzerinden gelen trafik Service nesnesi ile yönlendirildiği için yukarıda anlattığımız keep-alive sorununu yaşıyoruz ve aslında ALB kullanmamıza rağmen load balancing nimetlerinden yararlanamamış oluyoruz.
  2. Clusterınızda 100 adet node olduğunu varsayalım. Fakat erişmeye çalıştığınız service altında sadece 1 adet pod var. Bu bir adet pod için ALB target group içine 100 tane node ip adresi ve portunu kaydediyorsunuz. Halbuki sadece bir adet hedef mevcut. Bu da hem daha fazla latency (hedef pod aynı node üzerinde yer almayabilir), hemde target group kurallarının şişmesi demek. Ekstra olarak service nesnesi halen devrede olduğundan yine keep-alive sorununa takılacağımızı artık söylemiyorum bile.
Şekil 5: ALB trafiği node portlara, oradan da Service üzerinden poda iletir.

Çözüme gelirsek, bu genele yayılmış yanlış kullanımı düzeltmek için ilk yapacağımız şey ALB controller modunu instance tipinden ip tipine çevirmek olacak. Böylece artık target group içine node ip adresleri ve portları yerine direkt ilgili Service altındaki pod ip adresleri ve portları yazılacak ve Service üzerinden değil, direkt pod ip adresine erişim sağlayacağız.

Şekil 6: ALB trafiği direkt olarak hedef pod ip adresine iletir.

Bu sayede Service nesnesi devreden çıkmış olacak ve load balancing gerçek anlamda ALB nin sorumluluğunda kalacak. Ayrıca keep-alive olsa bile ALB hedef ip adresleri arasında gerçek bir round robin algoritması uygulayabilecek.

Üstelik Ingress nesnenize ekleyeceğiniz basit bir anotasyon ile ip mod geçişini yapmanız oldukça kolay bir iş.

annotations = {
  ...
  "alb.ingress.kubernetes.io/target-type" = "ip"
  ...
}

Şimdi en heyecanlı kısmına gelelim. Böyle yaptığımızda bizi nasıl bir sonuç bekliyor? Biz kendi ip mode geçişimizi yaptığımızda podlarımızın RPS grafiği tam da şöyle oldu:

Şekil 7: Düzensiz RPS değerleri bir anda standart hale gelmeye başladı

Saat 11:45 civarında ip mode geçişini yaptığımızda, artık tüm podların saniyede aldığı istek sayısı tutarlı ve dengeli bir hale gelmeye başladı. Daha öncesinden keep-alive bağlantılar sebebiyle bir poda TCP bağlantı açıldığında aynı kaynaktan gelen tüm istekler yine aynı poda gitmeye devam ediyordu.

Şekil 8: Başka bir uygulamanın ip mode geçişi sonrası RPS grafiği

Bu da bazı podları 150 RPS değerlerine çıkarırken bazı podların yatış pozisyonunda kalmasına sebep oluyordu. Grafikten de göreceğiniz gibi dengesiz bir RPS dağılımı bu geçişle birlikte dengeli bir hale geldi ve yükü tüm podlar üzerine eşit dağıttı. RPS değeri yüksek olan podlardaki resource kullanımını ve limitler sebebiyle doğan pressure ve performans problemleri de bir anda çözülmüş oldu. Artık uygulama daha performanslı ve düşük latency ile çalışmaya başladı.

İlk madde dışarıdan aldığımız trafik üzerineydi. Şimdi internal haberleşme kısmını ele alalım.

2. Uygulamalar Arası Haberleşmeyi Service Nesnesi Üzerinden Yapmak (!)

Microservice mimarisine sahip ekosisteminizde uygulamalarımızın haberleşmesi için yine Service nesnelerini kullanıyoruz. Bu oldukça mantıklı çünkü bir uygulama, iletişime geçeceği diğer uygulamaya ait yüzlerce podun ip adresini bilemez. Tek bir giriş noktası olan hedef uygulamaya ait Service nesnesi üzerinden haberleşmeye çalışır.

Şekil 9: Uygulamalar hedef uygulamaya onların Service nesneleri üzerinden ulaşır

Aynı şeyleri tekrar yazmaya gerek yok. Görselde farkedeceğiniz üzere Service nesneleri yine keep-alive yüzünden doğru bir load balancing yapamayacak ve App C nin Pod 1 inden App A ya atılan istekler hem aynı kırmızı ok üzerinden aynı poda düşmeye devam edecek.

Bu sorunu çözmek için yine ALB controller'ın ip mode özelliğini kullanacağız.

Neden internal trafiği ALB'e taşıyıp tekrar cluster içine sokalım diyenleri duyuyorum. Fakat işini doğru yapamayan bir mekanizma varsa neden onu kullanmakta ısrarcı olalım ki?

Öyleyse uygulamalarımızın properties dosyalarındaki hedef adres olarak kullandığımız internal service adreslerini (ör: service-a.namespace.svc.local) artık ip mode kullanan load balancer adresimizle değiştirmeye başlayabiliriz.

Bu değişiklik sonrası şemamız şuna dönmüş olacak:

Şekil 10: Uygulamalar arasındaki iletişim artık Service nesneleri değil, ALB üzerinden gerçekleşir

Biz bu değişikliği yaptığımızda sonuçlar yine muhteşem oldu. Internal iletişimde trafik alan podlarımızın RPS değerleri yine stabil ve dengeli hale gelmeye başladı.

Şekil 11: 220 RPS değerine ulaşan podlar bir anda 30 RPS değerine düştü
Şekil 12: Başka bir uygulamanın RPS değerlerinin düzenli hale gelmesi

Bu grafikler gibi bir çok uygulamamız artık daha stabil ve kararlı çalışıp eşit bir trafik dağılımına sahip olmaya başladı. Tabi bunun bize getirdiği avantajlarda şunlar oldu:

  1. Round robin uygulaması gerçek anlamıyla uygulanmaya başlandığı için tüm podlara eşit trafik dağılımı sağlandı
  2. RPS değerleri yüksek olan ve ağır trafik altında ezilen podlar, kaynak sınırlamaları sebebiyle düşük performansla çalışıyorlardı. Artık aynı değerde sahip oldukları kaynaklarla daha düşük latency ve daha yüksek performans sunabiliyorlar
  3. Özellikle Nodeport tipindeki servislerin trafiği önce bir node'dan diğer node'a taşımaları (hedef pod aynı node üzerinde olmayabilir) sebebiyle gelen latency ve network maaliyeti önemli ölçüde azaldı
  4. Grafiklerimiz bir insanın hayatına kadın eli değmişçesine düzenli bir hale geldi ve ortaya görsel bir şölen çıkardı :)

Bu deneyimi sizlerle paylaşarak neredeyse kimsenin farkında olmadığı bir durum için farkındalık oluşturmak istedim. Umarım faydalı olmuştur.