- Bir konteynere CPU sınırı koysanız bile Go runtime’ı varsayılan olarak bunu bilmez; host’un tüm çekirdeklerini baz alarak thread oluşturabilir ve gecikmeyi artırabilir
- Go GC çoğunlukla uygulamayla eşzamanlı çalışır, ancak Sweep Termination ve Mark Termination aşamalarında tüm goroutine’leri durduran stop-the-world(STW) aralıklarına ihtiyaç duyar
- Linux CFS, çekirdek sayısını saniye başına CPU süresi olarak bölüştürür;
--cpus=4, konteynere her saniye 4 saniyelik CPU süresi verilmesi anlamına gelir
- 16 çekirdekli bir host üzerinde 4 çekirdek sınırı olan bir konteyner çalıştırıldığında Go, goroutine’leri 16 OS thread’i üzerine koyabilir; CPU quota’sı tükendikten sonra STW uzayabilir
GOMAXPROCS konteynerin CPU sınırına uydurulduğunda, örnekte GC döngüsü 2,5 ms’nin altından 1 ms’nin altına indi ve STW yaklaşık 26 μs’ye kadar azaldı
Konteyner CPU sınırı ile Go runtime’ı arasındaki uyumsuzluk
- Konteynerde Go uygulaması çalıştırırken CPU sınırı, host CPU’sunun tamamının tüketilmesini engelleyen bir mekanizmadır
- Sorun, Go runtime’ının konteynerin CPU sınırını varsayılan olarak algılamamasıdır
- Bu uyumsuzluk nedeniyle runtime, gerçek quota’dan daha fazla CPU kullanabileceğini varsayar ve bu da yüksek gecikmeye yol açabilir
Go GC’de STW’nin gerçekleştiği noktalar
- Go garbage collector, zamanın büyük bölümünde uygulamayla eşzamanlı çalışır
- Ancak GC sürecinde tüm goroutine’lerin durdurulması gereken iki aralık vardır
- Mark Phase öncesinde write barrier’ı uygulamak için durulan aşama Sweep Termination’dır
- Mark Phase sonrasında write barrier’ı kaldırmak için tekrar durulan aşama Mark Termination’dır
- STW aralıkları genellikle onlarca mikrosaniye düzeyindedir
- Örnek uygulama, çok miktarda bellek ayıran basit bir web uygulamasıdır ve kaynak kodu go-cfs-blog üzerinde yer alır
- Konteyner 4 CPU sınırıyla çalıştırılır
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
runtime/trace paketiyle trace toplanabilir ve go tool trace ile analiz edilebilir
- Bu çalıştırmada GC döngüsü 2,5 ms’nin altındaydı, ancak bunun neredeyse %10’u STW aralığıydı
- Gecikmeye duyarlı uygulamalarda bu düzeyde bir oran bile sorun olabilir
Docker CPU sınırları ve Linux CFS’nin çalışma biçimi
- Docker’ın
--cpus CPU sınırı bir hard limit’tir
--cpu-shares da ayarlanabilir, ancak yalnızca host CPU kısıtı altındayken zorlanır
- Host’ta boş kapasite varsa konteyner, kendisine ayrılan CPU çekirdeğinden daha fazlasını kullanabilir
- Host kısıtlı duruma geldiğinde uygulama sınırlandırılır
- Linux Completely Fair Scheduler(CFS), Linux 2.6.23’te tanıtıldı ve Linux 6.6’ya kadar varsayılan zamanlayıcıydı
- CFS, bir proportional share scheduler olup bir sürecin weight’ini kullanabileceği CPU çekirdeği sayısıyla orantılı tutar
- 4 CPU çekirdeği kullanabilen bir sürecin weight’i 4’tür
- 2 CPU çekirdeği kullanabilen bir sürecin weight’i 2’dir
- CFS, CPU süresini parçalara bölerek dağıtır
- 4 çekirdekli bir sistem her saniye 4 saniyelik CPU süresi dağıtabilir
- Bir konteynere CPU çekirdeği sayısı atamak, Linux zamanlayıcısından
n CPU kadar süre istemekle aynıdır
--cpus=4, konteynerin her saniye 4 saniyelik CPU süresi alacağı anlamına gelir
STW neden uzar?
- Go runtime’ı başlarken her CPU çekirdeği için bir OS thread’i oluşturur
- 16 çekirdekli bir makinede, CGroup CPU sınırından bağımsız olarak 16 OS thread’i oluşturabilir
- Runtime, goroutine’leri bu OS thread’leri üzerinde zamanlar
- Konteynerin CPU sınırı 4 çekirdek olsa bile Go, goroutine’leri 16 OS thread’inin tamamına yerleştirebilir
- Bu durumda runtime, her saniye 16 saniyelik CPU süresi kullanabileceğini bekler
- Uzun STW süresi, Linux zamanlayıcısının yeniden çalıştırmasını bekleyen thread’ler üzerindeki goroutine’ler dahil hepsinin durdurulması gerektiği için ortaya çıkar
- Konteyner CPU quota’sını zaten kullandıktan sonra bu thread’ler zamanlanmaz
GOMAXPROCS’u CPU quota’sına uydurmak
- Go,
GOMAXPROCS ortam değişkeniyle runtime’ın kullanacağı CPU thread’i sayısını sınırlayabilir
- CPU quota’sı 4 olan bir konteynerde
GOMAXPROCS=4 de birlikte belirtilir
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
- Aynı uygulama ve aynı yük altında
GOMAXPROCS CPU quota’sıyla eşleştirildiğinde GC süresi kısaldı
- Trace’te GC döngüsü 1 ms’nin altına indi ve STW aralığı 26 μs oldu
- Bu,
GOMAXPROCS sınırı yokken görülen STW süresine kıyasla yaklaşık 1/10 düzeyindedir
GOMAXPROCS, konteynerin kullanabileceği CPU çekirdeği sayısına göre ayarlanmalıdır
- Fractional CPU atanırken aşağı yuvarlanır
- 1 CPU’dan az atanırken yukarı yuvarlanır
- Formül
GOMAXPROCS=max(1, floor(CPUs)) şeklindedir
- Uber’in automaxprocs projesi, bu değeri konteyner cgroups’larından otomatik hesaplayan açık kaynaklı bir kütüphanedir
- Go runtime’ında bunun varsayılan olarak desteklenmesi için bir GitHub Issue açılmıştır
Konteynerleştirilmiş Go servislerinde kontrol edilmesi gerekenler
- Yalnızca CPU sınırı ayarlamak yeterli değildir; Go runtime’ının bu sınırı yansıtması için GOMAXPROCS da uyarlanmalıdır
- Doğrudan hesaplamak zorsa automaxprocs gibi bir kütüphaneyle cgroups tabanlı değer otomatik ayarlanabilir
- Gecikmeye duyarlı Go servisleri, GC trace’inde STW süresini kontrol ederek CPU quota’sı ile runtime ayarlarının birbirinden sapmadığını doğrulamalıdır
1 yorum
Hacker News yorumları
Birçok dilde ortak görülen sorun, uygulamanın /proc/cpuinfo’ya bakarak makinedeki çekirdek sayısını tespit etmesi.
Ancak Docker container’ı veya başka container teknolojileri içinde bu dosya container host’u ile birebir aynı görünür ve container’a gerçekte sadece birkaç çekirdek ayrılmış olsa bile tüm çekirdekleri listeler.
Bir süre Docker’ın, iş için ayrılmış “Docker CPU”ları listeleyen sahte bir /proc/cpuinfo oluşturabileceğini düşündüm; ama tekrar düşününce bunun çeşitli nedenlerle pek işe yaramayacağını fark ettim.
Sınırlanan şey, o çekirdekleri ne kadar süre kullanabileceğidir.
İstisnalar da var; dokümantasyon burada: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
nprockullanıyorum; başka container’larda dabundle install -j $(nproc)gibi kullanıldığını gördüm.Bu, CPU tahsisini dikkate aldığı için aranan işlevi sağlıyor.
Rastgele bir uygulamanın mümkün olduğunda nproc kullanıp kullanmadığını bilmiyorum.
“Mevcut sürece kullanılabilir işlem birimi sayısını yazdırır; bu sayı çevrimiçi işlemci sayısından daha az olabilir. Bu bilgi alınamazsa kurulu işlemci sayısını yazdırır.”
https://www.gnu.org/software/coreutils/manual/html_node/npro...
https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
Go başlangıçta CPU maskesindeki sayıya bakıyor, sonrasında tekrar bakmıyor.
Kubernetes’te süreç çalışırken görünen CPU’lar değişebildiği için bu sorun oluyor.
lxcfs, cgroup değerlerini çıkarımlayarak /proc’u taklit eden bir FUSE dosya sistemi; böylece uygulama ve kütüphanelerin container içinde çalışıp çalışmadığını umursaması gerekmiyor.
Örneğin /proc/uptime host’un değil container’ın çalışma süresini yansıtmalı; /proc/cpuinfo ise cpu.max ile cpuset.cpus birleşiminden daha düşük olan limiti CPU sayısı olarak yansıtıyor.
CPU sayısı çıkarımı
sched_getaffinitysistem çağrısıyla da yapılabilir ve bu yöntem /proc/cpuinfo’ya bağlı değildir.Bu yüzden kullandığınız kütüphaneye bağlı olarak zor durumda kalabilirsiniz.
Bu açıklama ince bir noktada hatalı.
Docker açısından CFS cgroup uzantısında ayarlanabilecek birkaç düğme var:
cfs_quota_us,cfs_period_us(yaygın varsayılan değer 1 saniye değil 100 ms) ve shares.Shares ayarladığınızda ağırlık tabanlı oransal zamanlama uygulanır, ancak yalnızca çekişme olduğunda anlamlıdır.
İlk iki değer katı bir quota uygular.
Docker’ın
--cpubayrağı yerine--cpu-shareskullanıp çoğu zaman işe yaramayan quota zorlamasından kaçınmak daha iyidir.Linux dokümantasyonuna göre
cpu.shares, aynı hiyerarşideki her grubun ağırlığıdır;cpu.cfs_period_us, bant genişliği değerlendirmesi için scheduler periyodudur ve varsayılan değeri 100000us, yani 100ms’dir.cpu.cfs_quota_us, hercfs_period_ussüresince mevcut grubun çalışabileceği azami süredir; bu değer sistem genelindeki CPU’lar üzerinde toplanmış süre olduğu için 2 CPU’yu tamamen kullandırmak istiyorsanızcfs_period_usdeğerinin iki katına ayarlanmalıdır.--cpubayrağını kullanmayın, bunun yerine…” ifadesi, başka bir ipucu olmadan fazla güçlü.Kesinlikle “çoğu zaman işe yaramaz” denemez.
Shares ve quota farklı kullanım senaryoları içindir; bu yüzden kendi kullanım senaryonuzu anlayıp ona göre seçim yapmak gerekir.
--cpukullanıldığında uygulamanın bunu algılayabilmesidir.Muhtemelen cpuset kullandığı için böyle görünüyor.
Quota kullanıldığında bunu algılayamaz; bu da gerekenden fazla thread oluşmasına yol açabilir.
Bu kısmı daha net hâle getirmeye çalışacağım.
Belirtilerin bu şekilde ortaya çıktığını düşünüyorum, ama ifadeyi daha açık yapmam gerekiyor.
Uygulamanın doğru çalışması gerekir.
CPU limits yerine CPU reservations kullanırsanız bu tür ayarlamalar gerekmez: https://home.robusta.dev/blog/stop-using-cpu-limits
CPU reservations da aslında limits sayılır; örtük bir sınır ve garanti olarak ilan edilir
Bu yüzden Go runtime'ın kullanılabilir tüm CPU'ları kullanmasına izin verip, CPU çekişmesi oluştuğunda Linux scheduler'ın ilan edilen reservations'a göre sınırlamasına bırakmak yeterli
Garanti edilmemiş fazladan CPU kullanabilen bir duruma alışmak istememek
Node'a başka pod'lar giderek doldukça az önce sorunsuz çalışan bir pod birden yavaşlayabilir
limits kullanırsanız aynı davranışı simüle eder ve doğru kapasite planlamasıyla buna hazırlanabilirsiniz
Tek yöntem değil ama en basit yöntem
Bu tartışmayı daha çok merak ediyorum; ancak bağlantı verilen yazı, insanların CPU'yu tüm pod'lara garanti etmek için limit gerektiğini düşündüğü kısmı ele alıyor gibi görünüyor
Bu yazı kendi başına yanlış değil ve büyük ölçüde içerik pazarlamasına yakın, ancak genellemeleri geniş ve limits ayarlamak için iyi olan birçok nedeni görmezden geliyor
Aynı yerin bazı yazıları ise düpedüz yanlış: https://home.robusta.dev/blog/containers-dont-use-chroot
Çok küçük kazanç sağlayıp tüm burst kapasitesini tüketen workload'lar da var; belirli bir süre içinde bitmesi gereken bir cronjob yerine bir HTTP sunucusunun burst kapasitesine öncelik vermek gereken zamanlar da oluyor
Geliştiricilerin, uygulama gereksinimi büyüdüğü halde requests'i güncellemediği ve boş CPU zamanı aniden azalınca kesinti yaşandığı durumlar da oldu
Teoride asgari garanti edilen kaynaktır; ancak aynı host üzerinde yoğun konteynerler birlikte çalıştığında kuyruk gecikmesi ve ortalama gecikme anormal biçimde artabilir
4 çekirdekli bir EC2 instance'ında CPU kullanımı %50 iken gecikme ile %90 iken gecikme epey farklıdır
reservations için de benzer şekilde, her konteyner kendi reservation'ını garanti alsa bile aynı host'taki diğer yoğun süreçler yüzünden göreli CPU kullanımı çok yükselir
OOMKiller devreye girip süreci öldürebilir
Hem CPU hem bellek limits yoksa Guaranteed QoS sınıfını alamazsınız; bu yüzden bir noktada pod tahliye edilebilir
Konteynerler ve cgroup kullanırken CFS scheduler yüzünden birkaç kez sorun yaşadım
Yeni scheduler'ın ne olduğunu merak ediyorum
Burada bunu production cluster'da denemiş biri var mı?
Neredeyse 20 yıldır çekirdekleri boşa harcıyoruz: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
Konteyner kaynak sınırı koymuş, fakat konteyner içindeki süreç olan Go, kullanılabilir paralellik miktarını hesaplarken o sınır için kullanılan işletim sistemi özelliğini kontrol etmiyor; sorun bu
GOMAXPROCS dışında, son Go sürümlerinde GOMEMLIMIT de var
https://github.com/KimMachineGun/automemlimit kullanarak, https://github.com/uber-go/automaxprocs benzeri şekilde bu sınırı otomatik ayarlayabilirsiniz
Geçen yıl önceki işimde platform mühendisi olarak on-premises Kubernetes cluster'larını ve CI/CD pipeline altyapısını yönetirken bunu fark ettim
Gerçek CPU ile ayrılan CPU arasındaki uyumsuzluğun özellikle CPU throttling gibi sorunlara yol açtığını gördüm; ancak cluster'daki tüm Go deployment'larını etkileyen ölçeklenebilir bir çözüm bulmak zordu
Yüzlerce projedeki tüm geliştiricilere autoprocs bağımlılığını ekletmek bir seçenek değildi
Alternatif olarak tüm CPU request/limit değerlerini tam sayıya çekip Kubernetes manifest'lerinde bu değeri GOMAXPROCS ortam değişkenine koymak da zahmetli ve uygulanabilir değildi
Sonuçta çoklu thread'i yoğun kullanan bazı uygulamalara GOMAXPROCS değişkenini uygulayarak iyileşme elde ettik; ancak CPU gereksinimleri projeden projeye çok değişen mikroservis mimarisindeki tüm deployment'lara uygulanabilecek bir çözümü hâlâ bulamadık
GOMAXPROCS'u sınırlamak, bir sürece trafik yığıldığında ve kuyruklama basit olduğunda ciddi gecikme sorunlarına yol açabilir
Sürecin ortalamada ne kadar zaman kullanacağına dair düşünceden bağımsız olarak, GOMAXPROCS'u donanımın sunduğu değere ayarlamak pratikte en iyisidir
Docker veya Go'ya aşina olmayan biri olarak, bu davranışın kasıtlı olup olmadığını merak ediyorum
Go ekibi bunu CGroups limit'lerini tanıyacak hâle getirebilir mi?
Diğer runtime'lar da benzer mi davranıyor?
Yoksa containerd gibi runtime'lardan mı bahsediyordun?
Scala'daydı
Duraklamaları daha kısa hale getiren GC teknikleri de var
Örneğin, duraklama sırasında yapılacak işleri eşzamanlı olarak yürütüp güvenli noktada tekrar yineleme yöntemi
Beklenti, eşzamanlı çalışma sayesinde güvenli noktadaki işin “yapılacak bir şey yok” şeklinde basit bir kontrole dönüşmesi
İşi iki katına çıkarmak GC verimini kötüleştirebilir
Bu yazı konteynerlerden bahsediyor, ancak sorun Go beklenenden daha az CPU zamanına erişebildiğinde her zaman ortaya çıkıyor gibi görünüyor
CPU kullanan başka süreçlerin olduğu bir sistemde Go çalıştırıldığında da aynı şey yaşanmaz mı?
Hatta yalnızca iki Go programını aynı anda çalıştırmak bile buna yol açmaz mı?