2 puan yazan GN⁺ 2023-11-09 | 1 yorum | WhatsApp'ta paylaş
  • 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

 
GN⁺ 2023-11-09
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.

    • Quota tabanlı limit kullanıldığında container, host’un tüm CPU çekirdeklerini kullanabilir.
      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...
    • Yalnızca nproc kullanıyorum; başka container’larda da bundle 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 bunu yapmıyor.
      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.
    • Sahte /proc/cpuinfo zaten var: https://github.com/lxc/lxcfs
      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_getaffinity sistem ç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.
    • Buna bakınca container’ların gevşek bir soyutlama olduğu ve VMware’in bir fırsatı kaçırdığı sonucuna varıyorum.
  • 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 --cpu bayrağı yerine --cpu-shares kullanı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, her cfs_period_us sü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ız cfs_period_us değerinin iki katına ayarlanmalıdır.

    • “Docker’ın --cpu bayrağı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.
    • Dikkat edilmesi gereken bir nokta, --cpu kullanı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.
    • Blog yazarıyım, geri bildirim için teşekkürler.
      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.
    • Kubernetes kullananlar bu ayarları doğrudan ayarlamaz veya değiştirmez.
      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

    • limits ayarlamanın nedeni bir pod'un başka pod'ları etkilemesinden korkmak değil
      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
    • 128 çekirdekli bir yapılandırmada birkaç şey çalıştırıyoruz; CPU limits'i request'ten çok daha yüksek tutuyoruz ama bir şeyin kontrolden çıkmaması için yine de ayarlıyoruz
      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
    • Kubernetes topluluğunda bu tartışmayı iki haftada bir yapıyoruz gibi hissediyorum
      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
    • reservations, limits değil; asgari garanti CPU kullanımı kısıtıdır
      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
    • İlginç ama bu bellek için geçerli değil, değil mi?
      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

  • 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

    • Bunun tek bir doğru cevabı yok
      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
    • Tüm pod konteynerlerine GOMAXPROCS enjekte eden bir mutating webhook tanımlayabilirsiniz
  • 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?

    • .NET'in de bu sorunu ele almak zorunda kaldığından oldukça eminim; Java'da da sorun vardı ya da hâlâ var diye hatırlıyorum
      Yoksa containerd gibi runtime'lardan mı bahsediyordun?
    • JVM'de de aynı sorunu yaşadım
      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ı?