Kubernetes Resource Request & Limit Tanımları ve .Net Core Garbage Collector

Serdar Kalaycı
Intertech
Published in
6 min readAug 4, 2020

Bu yazımda Kubernetes üzerinde çalıştırılan uygulamalarda sıklıkla ihmal edildiğini gördüğüm Resource Requests ve Limits tanımlarından, bu tanımların Kubernetes için neden önemli olduğundan bahsedeceğim. Ardından “managed” yani hafıza kullanımını kendisi yöneten diller (.Net, Java vb) için bu tanımların nasıl daha da kritik hale geldiğini bir örnek ile aktaracağım.

Kubernetes Scheduler

Kubernetes dokümantasyonu Scheduler kavramını aşağıdaki bir cümle ile açıklıyor:

In Kubernetes, scheduling refers to making sure that Pods are matched to Nodes so that Kubelet can run them.

Özetle Scheduler, Kubernetes bir pod çalıştırması gerektiğinde bu podun Kubernetes’e ait hangi node üzerinde çalıştırılacağına karar veren mekanizma. Bu kararı verirken baktığı birden çok kriter var. Örneğin Taints & Tolerations kavramları bazı node’lara Türkçe’ye “kusur” şeklinde çevirebileceğimiz taint adı verilen özellikler atarken (örneğin node01 storage ihtiyacı olan podları çalıştırmasın veya node03 cluster dışına açılacak podları çalıştırmasın gibi) podlara da bu kusurları kabul edebileceğini belirten toleration özellikleri atayarak sadece bu kusurlara uyan podların bu nodelar üzerinde çalışmasını sağlayabiliriz. NodeSelector ile podlarımızın doğrudan belirli bir label atanmış node’lar üzerinde çalışmasını sağlayabilir ve hatta NodeAffinity ve AntiAffinity tanımları ile podların tercih ettikleri özellikleri sağlayan nodelara yerleştirilmesini sağlayabiliriz. Ancak bu türden yönlendirmeler yapılmaz ise veya bu yönlendirmelere rağmen elinde birden fazla node seçeneği kalırsa Scheduler kaynak optimizasyonu yapmak yönünde karar alarak podları yerleştirmeye programlanmıştır. Yani olası nodelar içinde kaynakları en az kullanımda olandan yana tercihini kullanacaktır. Ancak Scheduler bu noktada yaratmaya çalıştığı pod hakkında bir fikir sahibi olmadığından sadece node metriklerine bakarak bu kararı verecektir.

Kubernetes Resources

Kubernetes temel olarak CPU ve Memory şeklinde iki kaynak tipi tanımlar (Kubernetes v1.14 sonrasında hugepages isimli üçüncü bir kaynak tipi de tanımlıyor ancak bu yazının konusu olmadığından buraya not olarak bırakıp devam ediyorum). Kubetnetes için 1 CPU, üzerinde çalıştığı sistem bir sanallaştırma sistemi ise 1 vCPU, bare metal üzerine kurulmuş ise 1 Hyperthread anlamına gelir ve bu kaynağı tanımlarken 1 miliCPU seviyesine kadar detayda tanım yapılabilir. Memory kaynağı byte cinsinden ölçülür ve integer olarak doğrudan byte değeri verilebileceği gibi tek harfli ve çift harfli kısaltmalar da katsayı olarak kullanılabilir (128974848, 129e6, 129M, 123Mi gibi).

Not: Günlük konuşmalarımızda genellikle 1024 byte için Kilobyte, 1024 Kilobyte için Megabyte terimlerini kullansak da aslında bu katsayılar 1000n çarpanını ifade etmektedir. Binary sistem için daha doğru olan 210*n çarpanı Kibibyte, Mebibyte şeklinde isimlendirilmektedir. Kubernetes tarafından da tek harfli kısaltmalar (K, M, G, T, P, E) Kilo = 1000, Mega = 1000000 şeklinde, iki harfli kısaltmalar (Ki, Mi, Gi, Ti, Pi, Ei) Kibi = 1024, Mebi = 1048576 şeklinde hesaplanacaktır.

Resource Requests

Kubernetes, Container tanımlarımızda (elbette Deployment veya ReplicaSet tanımlarının Pod spec kısımlarında) podumuzun çalışması için ihtiyaç duyacağı kaynak miktarlarını tanımlamamıza olanak verir. Bu tanımın yapılmasının birden fazla avantajı vardır.

Başlangıç olarak, Kubernetes scheduling yani podun çalıştırılacağı node seçimi sırasında uygulamamızın ihtiyaç duyacağını belirttiğimiz kaynakları sağlayabilecek nodelar arasından bir seçim yapar. Yani bizim istediğimizi belirttiğimiz kaynak miktarını sağlayamayan nodelar doğrudan seçim dışı bırakılır.

İkinci olarak istediğimizi belirttiğimiz kaynak miktarı hiç bir node tarafından karşılanamayacak durumdaysa Kubernetes yaratmaya çalıştığımız podu Pending fazında bırakarak bizi haberdar eder. Eğer container tanımımızda ihtiyaç duyacağımız kaynakları belirtmemiş olsaydık ve uygulamamız üzerinde çalıştırdığımız Kubernetes tarafından karşılanamayacak ölçüde kaynağa ihtiyaç duysaydı Scheduler tarafından bir node üzerine atanacak, orada çalışır hale getirilmeye çalışacak ve başarısız olunacak, bu işlem defalarca tekrar edilerek bir süre sonra CrashLoopBackOff durumuna düşecek ve bizim bunun nedeninin kaynak eksikliği olduğunu anlamamız oldukça zor olacaktı.

Son olarak uygulamamızın yoğun kullanım sırasında yatayda genişletilmesi için Horizontal Pod Autoscaler tanımı yapmak istediğimizde Kubernetes hedeflenen kaynak kullanım oranını Resource Request tanımında yaptığımız değerleri baz alarak yapacaktır.

Resource Limits

Container tanımı yapılırken uygulamamızın kullanabileceği maksimum kaynak miktarlarını da tanımlamamız mümkündür. Uygulama, memory için verdiğimiz limiti aştığında container terminate edilir ve Kubelet tarafından yeniden başlatılır. CPU limitine ulaşıldığında pod terminate edilmez, node üzerindeki kaynak durumuna bağlı olarak bir süreliğine limiti aşmasına izin de verilebilir. Bu tanımların yapılması, beklenenden daha fazla kaynak tüketimi gerçekleştiren bir uygulamanın (örneğin sonsuz döngüye girmiş ve aşırı CPU kullanan veya bir memory leak nedeniyle kullandığı hafıza miktarı sürekli artan bir uygulama) node üzerindeki bütün kaynakları tüketmesini engellenmesi açısından kritiktir.

Garbage Collector

Tüm “Managed” diller geliştiricileri hafıza yönetimi yükünden kurtarmak için Garbage Collector (GC) adı verilen bir mekanizma içerir. Bu mekanizmanın amacı geliştiricileri hafıza yönetimi gibi zorlu bir konudan muaf tutmak olsa da, bu tür dillerle yazılım geliştirenlerin kullandıkları platformun Garbage Collector mekanizmasının nasıl çalıştığını iyi bilmesi performans ve kaynak optimizasyonu açısından çok kritiktir. Örnek olarak .Net ve Java için Garbage Collector çalışma prensiplerini aşağıdaki linklerden bulabilirsiniz:

https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals

https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html

Garbage Collector mekanizmaları hakkında burada yazdıklarımı .Net CLR dokümantasyonunu temel alarak aktaracağım, ancak burada dikkate alacağımız temel prensipler tüm GC mekanizmaları için küçük farklar dışında aynı olacaktır.

GC mekanizmaları, belirli aralıklarla uygulamanın kullandığı hafıza bloğunu inceleyerek artık referansı kalmamış objeleri hafızadan siler, referansı var olan objeleri bir üst jenerasyona taşır (Generation konusu bu yazının kapsamında olmadığından detaya girmeyeceğim ancak okumanızı tavsiye ederim https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals#generations) ve hafızada parçalanmış halde duran objeleri bir araya toplayarak hafızayı defragmante eder.

Tüm bu işlemlerin yapılması sırasında bu hafıza bloklarındaki veriler kararsız bir konumda olacağından o uygulamanın tüm çalışan thread’leri bekleme konumuna alınmalıdır.

İşler her zaman buradaki kadar senkronize de gitmeyecektir

Çalışma zamanı, GC zamanı geldiğine karar verdiğinde tüm thread’lere durdurma sinyali gönderir, çalışan tüm thread’ler durabileceği ilk noktada (mutex bulunmayan, catch, finally gibi blokların dışında) durur ve GC çalışmasını bekler. Bu durum da GC işini uygulama açısından maliyetli bir iş haline getirir. Çalışma zamanının “daha fazla hafıza kullanımı” ile “uygulamanın iş yapan tüm parçalarını bir süreliğine durdurma” arasında optimum bir denge sağlaması gereklidir. Çalışma zamanları (uygulama kodunda GC özellikle çalıştırılmadığı sürece) tercihlerini uygulama performansından yana kullanır ve “memory pressure” hissetmediği yani kullanabileceği hafıza bulunduğunu düşündüğü sürece GC çalıştırmaktan büyük oranda kaçınır.

Bu iki kavramın birbiriyle ilgisi ne?

Geliştirme yaptığımız bilgisayarlarda kullandığımız kaynaklar çok yüksek olmadığı için ve geliştirme ortamında kullandığımız araç gerecin tüketimi nedeniyle oluşan doğal “memory pressure” nedeniyle uygulamamızın kullandığı kaynak miktarı gözümüze çarpacak seviyelere çıkmaz, ancak çok daha yüksek miktarlarda hafızaya sahip Kubernetes worker node makinelerinde basit uygulamalarımızın bile bir süre sonra gigabyte seviyesinde hafıza kullandığını görebiliriz. Kubernetes, uygulamanızın ne iş yaptığını bilmeyeceğinden, kendisine bir resource request ve limit verilmediği taktirde uygulamaların ihtiyaçlarını karşılamaya çalışacaktır, bu da ilk çalışan uygulamaların kaynakları tüketmesine ve sonraki uygulamaların schedule edilememesine neden olacaktır.

Kubernetes resource request ve limit ayarlarının uygulamalarımız üzerindeki etkisini görebilmek amacıyla geliştirdiğim örnek uygulama, tek bir metoda sahip .Net Core WebAPI uygulaması. Uygulamanın standart metotları dışında Liveness probe için hazırlanmış endpoint, 5 saniyede bir çağrılmakta ve uygulama ayakta olduğu sürece doğrudan HTTP 200 — OK dönmektedir. Readiness probe için hazırlanmış endpoint ise 30 saniyede bir çağrılmakta ve uygulamamızın normalde bağlandığı Postgres veritabanına bağlanarak “select 1” şeklinde sorgu çalıştırarak bağlantının sağlığını kontrol etmekte, bağlantıda sorun yaşanmadığı durumda HTTP 200 — OK dönmekte, aksi halde HTTP 500 — Internal Server Error dönmektedir. Uygulama, sadece Kubernetes tarafından yaratılan bu yük altında iken CPU ve Memory grafiği aşağıdaki gibi oluşmaktadır.

Bu basitlikte bir uygulamanın 20 dakika gibi bir zaman aralığında 430 MiB gibi bir hafıza kullanımı bandına oturması, gerçek yük altında, ve hatta olması gerektiği gibi birden fazla pod halinde çalıştırılması halinde Kubernetes cluster üzerinde yaratacağı toplam yükü düşünmek bile korku verici hale geliyor.

Aynı uygulamayı Kubernetes üzerine deploy ederken kullandığımız yaml dosyasına Resource Request ve Limit tanımlarını aşağıdaki gibi eklediğimizde grafik bir alttaki hale dönüşüyor.

Uygulamada response süreleri açısından bir fark yaşanmadan kullandığı hafıza miktarının %85 oranında düştüğünü görebiliyoruz. Burada dikkati çekmesi gereken nokta, bu grafiğin az önce 400 MiB hafıza kullanan uygulamanın Kubernetes tarafından 256 MiB limitinde tutulması grafiği değil, .Net Core çalışma zamanının üzerinde çalıştığı işletim sistemi olan Alpine Linux’dan aldığı limit bilgisi doğrultusunda verdiğimiz hafıza sınırının henüz çeyreğine bile gelmeden GC çalıştırarak uygulamanın kullandığı hafızayı kontrol altına almasıdır.

Tabi bu limitler belirlenirken gerçekçi bir yük altında ölçümler yapmak ve uygulamaya da fazlaca hafıza ve CPU baskısı yaratmayacak şekilde planlama yapmakta yarar var. Ancak burada da gördüğümüz gibi uygulamaları hiç bir limit vermeden Kubernetes ortamında çalıştırmak sağlıklı bir sonuç oluşturmuyor.

--

--