2 poin oleh GN⁺ 2023-11-09 | 1 komentar | Bagikan ke WhatsApp
  • Meskipun batas CPU diterapkan pada kontainer, runtime Go pada dasarnya tidak menyadarinya, sehingga dapat membuat thread berdasarkan seluruh inti host dan meningkatkan latensi
  • GC Go sebagian besar berjalan secara bersamaan dengan aplikasi, tetapi memerlukan bagian stop-the-world(STW) yang menghentikan semua goroutine pada Sweep Termination dan Mark Termination
  • Linux CFS membagi dan mengalokasikan waktu CPU per detik berdasarkan jumlah inti, dan --cpus=4 berarti memberi kontainer jatah waktu CPU setara 4 detik setiap detik
  • Jika kontainer dibatasi 4 core di host 16 core, Go dapat menempatkan goroutine pada 16 thread OS, sehingga setelah kuota CPU habis STW bisa menjadi lebih panjang
  • Jika GOMAXPROCS disesuaikan dengan batas CPU kontainer, pada contoh ini siklus GC turun dari di bawah 2.5ms menjadi di bawah 1ms, dan STW berkurang hingga sekitar 26μs

Ketidaksesuaian antara batas CPU kontainer dan runtime Go

  • Saat menjalankan aplikasi Go di dalam kontainer, batas CPU berfungsi mencegah konsumsi seluruh CPU host
  • Masalahnya, runtime Go secara default tidak mengenali batas CPU milik kontainer
  • Karena ketidaksesuaian ini, runtime menganggap ia bisa memakai CPU lebih banyak daripada quota sebenarnya, yang berujung pada latensi tinggi

Titik terjadinya STW pada GC Go

  • Garbage collector Go berjalan secara bersamaan dengan aplikasi hampir sepanjang waktu
  • Namun, ada dua bagian dalam proses GC yang mengharuskan semua goroutine dihentikan
    • Tahap penghentian sebelum Mark Phase untuk menerapkan write barrier disebut Sweep Termination
    • Tahap penghentian setelah Mark Phase untuk menghapus write barrier disebut Mark Termination
  • Bagian STW biasanya berada di kisaran beberapa puluh mikrodetik
  • Aplikasi contoh adalah aplikasi web sederhana yang banyak melakukan alokasi memori, dan source code tersedia di go-cfs-blog
  • Kontainer dijalankan dengan batas 4 CPU
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
  • Trace dapat dikumpulkan dengan paket runtime/trace dan dianalisis dengan go tool trace
  • Pada eksekusi ini, siklus GC kurang dari 2.5ms, tetapi hampir 10% di antaranya adalah bagian STW
  • Untuk aplikasi yang sensitif terhadap latensi, proporsi sebesar ini pun bisa menjadi masalah

Cara kerja batas CPU Docker dan Linux CFS

  • Batas CPU --cpus di Docker adalah hard limit
  • --cpu-shares juga bisa diatur, tetapi hanya dipaksakan saat host mengalami keterbatasan CPU
    • Jika host masih longgar, kontainer bisa memakai lebih banyak daripada inti CPU yang dialokasikan
    • Saat host masuk kondisi terbatas, aplikasi akan benar-benar dibatasi
  • Linux Completely Fair Scheduler(CFS) diperkenalkan pada Linux 2.6.23 dan menjadi scheduler default hingga sebelum Linux 6.6
  • CFS adalah proportional share scheduler, yang menetapkan weight proses secara proporsional terhadap jumlah inti CPU yang dapat digunakan
    • Weight proses yang dapat memakai 4 inti CPU adalah 4
    • Weight proses yang dapat memakai 2 inti CPU adalah 2
  • CFS membagi dan mengalokasikan waktu CPU
    • Sistem 4 core dapat mengalokasikan waktu CPU setara 4 detik setiap detik
    • Mengalokasikan sejumlah inti CPU ke kontainer pada dasarnya sama dengan meminta waktu sebanyak n CPU kepada scheduler Linux
    • --cpus=4 berarti kontainer menerima waktu CPU setara 4 detik setiap detik

Mengapa STW menjadi lebih panjang

  • Runtime Go membuat satu thread OS untuk setiap inti CPU saat startup
  • Pada mesin 16 core, ia dapat membuat 16 thread OS terlepas dari batas CPU CGroup
  • Runtime menjadwalkan goroutine di atas thread-thread OS tersebut
  • Meskipun batas CPU kontainer hanya 4 core, Go tetap dapat menempatkan goroutine pada seluruh 16 thread OS
  • Dalam kondisi ini, runtime akan berharap bisa menggunakan waktu CPU setara 16 detik setiap detik
  • Waktu STW yang panjang terjadi karena semua goroutine harus dihentikan, termasuk yang berada di thread yang sedang menunggu dijalankan kembali oleh scheduler Linux
  • Setelah kontainer sudah menghabiskan quota CPU-nya, thread-thread tersebut tidak akan dijadwalkan

Menyesuaikan GOMAXPROCS dengan quota CPU

  • Go dapat membatasi jumlah thread CPU yang digunakan runtime melalui variabel lingkungan GOMAXPROCS
  • Pada kontainer dengan quota CPU 4, tetapkan juga GOMAXPROCS=4
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
  • Pada aplikasi dan beban yang sama, setelah GOMAXPROCS disamakan dengan quota CPU, waktu GC menjadi lebih singkat
  • Dalam trace, siklus GC turun menjadi di bawah 1ms, dan bagian STW menjadi 26μs
  • Dibandingkan waktu STW saat tidak ada batas GOMAXPROCS, ini sekitar 1/10-nya
  • GOMAXPROCS harus diatur sesuai jumlah inti CPU yang bisa dipakai kontainer
    • Jika mengalokasikan fractional CPU, bulatkan ke bawah
    • Jika mengalokasikan kurang dari 1 CPU, bulatkan ke atas
    • Rumusnya adalah GOMAXPROCS=max(1, floor(CPUs))
  • automaxprocs dari Uber adalah library open source yang menghitung nilai ini secara otomatis dari cgroups kontainer
  • Sebuah GitHub Issue telah dibuka untuk menambahkan dukungan default di runtime Go

Hal yang perlu dicek pada layanan Go yang dikontainerkan

  • Menetapkan batas CPU saja tidak cukup; GOMAXPROCS juga harus disesuaikan agar runtime Go mencerminkan batas tersebut
  • Jika sulit menghitungnya secara manual, nilai berbasis cgroups bisa diatur otomatis dengan library seperti automaxprocs
  • Layanan Go yang sensitif terhadap latensi perlu memeriksa waktu STW di GC trace untuk memastikan quota CPU dan konfigurasi runtime tidak saling bertentangan

1 komentar

 
GN⁺ 2023-11-09
Komentar Hacker News
  • Masalah yang umum terlihat di berbagai bahasa adalah aplikasi mendeteksi jumlah core mesin dengan melihat /proc/cpuinfo
    Namun di dalam kontainer Docker atau teknologi kontainer lain, file ini terlihat sama persis seperti host kontainer, dan mencantumkan semua core, berapa pun jumlah yang sebenarnya dialokasikan ke kontainer
    Untuk beberapa waktu saya sempat berpikir Docker mungkin bisa membuat /proc/cpuinfo palsu yang hanya mencantumkan “Docker CPU” yang dialokasikan ke tugas, tetapi setelah dipikir lagi sepertinya itu tidak akan berhasil karena beberapa alasan

    • Saat memakai batas berbasis quota, kontainer dapat menggunakan semua core CPU host
      Yang dibatasi adalah berapa lama kontainer bisa menggunakan core-core tersebut
      Ada juga pengecualian, dan dokumentasinya ada di sini: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
    • Saya hanya memakai nproc, dan pernah melihatnya dipakai di kontainer lain seperti bundle install -j $(nproc)
      Ini menghormati alokasi CPU, jadi menyediakan fungsi yang dicari
      Saya tidak tahu apakah aplikasi sembarang memakai nproc ketika memungkinkan
      “Mencetak jumlah unit pemrosesan yang tersedia untuk proses saat ini, yang bisa lebih sedikit daripada jumlah prosesor online. Jika informasi ini tidak dapat diperoleh, mencetak jumlah prosesor yang terpasang”
      https://www.gnu.org/software/coreutils/manual/html_node/npro...
      https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
    • Go tidak melakukan itu
      Go melihat jumlah CPU mask pada saat startup, lalu tidak melihatnya lagi setelah itu
      Di Kubernetes, ini menjadi masalah karena CPU yang terlihat saat proses berjalan bisa berubah
    • /proc/cpuinfo palsu sudah ada: https://github.com/lxc/lxcfs
      lxcfs adalah filesystem FUSE yang meniru /proc dengan menyimpulkan nilai cgroup, sehingga aplikasi dan library tidak perlu peduli apakah mereka berjalan di dalam kontainer
      Misalnya, /proc/uptime seharusnya mencerminkan waktu aktif kontainer, bukan host, dan /proc/cpuinfo mencerminkan batas yang lebih rendah di antara kombinasi cpu.max dan cpuset.cpus sebagai jumlah CPU
      Inferensi jumlah CPU juga bisa dilakukan dengan system call sched_getaffinity, dan cara ini tidak bergantung pada /proc/cpuinfo
      Jadi, bergantung pada library yang dipakai, Anda bisa berada dalam situasi yang menyulitkan
    • Melihat ini, kesimpulannya kontainer adalah abstraksi yang rapuh, dan VMware telah melewatkan peluang
  • Penjelasan ini agak keliru secara halus
    Dari sudut pandang Docker, ekstensi CFS cgroup memiliki beberapa kenop yang bisa disetel: cfs_quota_us, cfs_period_us(nilai default umum adalah 100ms, bukan 1 detik), dan shares
    Jika shares disetel, penjadwalan proporsional berbasis bobot diterapkan, tetapi itu hanya berarti saat ada kontensi
    Dua nilai pertama memberlakukan quota yang ketat
    Daripada memakai flag --cpu Docker, lebih baik memakai --cpu-shares untuk menghindari pemaksaan quota yang umumnya tidak berguna
    Menurut dokumentasi Linux, cpu.shares adalah bobot tiap grup pada hierarki yang sama, dan cpu.cfs_period_us adalah periode scheduler untuk menentukan bandwidth, dengan default 100000us atau 100ms
    cpu.cfs_quota_us adalah waktu maksimum grup saat ini dapat berjalan selama setiap cfs_period_us, dan nilai ini adalah waktu akumulatif di seluruh CPU sistem, jadi agar bisa memakai 2 CPU sepenuhnya, nilainya harus disetel menjadi dua kali cfs_period_us

    • Ungkapan “jangan memakai flag --cpu Docker, sebagai gantinya…” terlalu kuat tanpa petunjuk tambahan
      Sama sekali tidak bisa dianggap “umumnya tidak berguna”
      shares dan quota ditujukan untuk use case yang berbeda, jadi pahami use case Anda sendiri dan pilih yang sesuai
    • Satu hal yang perlu diperhatikan adalah jika memakai --cpu, aplikasi dapat mendeteksinya
      Mungkin karena memakai cpuset
      Jika memakai quota, aplikasi tidak bisa mendeteksinya, sehingga besar kemungkinan akan membuat thread jauh lebih banyak daripada yang diperlukan
    • Saya penulis blognya, terima kasih atas feedback-nya
      Saya akan coba memperjelas bagian ini
      Saya pikir gejalanya muncul seperti ini, tetapi saya perlu membuat ungkapannya lebih jelas
    • Orang yang memakai Kubernetes tidak menyetel atau mengubah pengaturan seperti ini secara langsung
      Aplikasi harus berperilaku dengan benar
  • Jika memakai CPU reservations alih-alih CPU limits, penyesuaian seperti ini tidak diperlukan: https://home.robusta.dev/blog/stop-using-cpu-limits
    CPU reservations juga pada dasarnya adalah limits, tetapi dideklarasikan sebagai batas implisit sekaligus jaminan
    Jadi biarkan runtime Go memakai semua CPU yang tersedia, dan jika terjadi perebutan CPU, biarkan scheduler Linux membatasinya sesuai reservations yang dideklarasikan

    • Alasan menetapkan limits bukan karena takut sebuah pod memengaruhi pod lain
      Melainkan karena tidak ingin terbiasa dengan kondisi bisa memakai CPU berlebih yang tidak dijamin
      Saat node semakin terisi pod lain, pod yang baru saja berjalan baik bisa tiba-tiba melambat
      Dengan limits, kita bisa menyimulasikan perilaku yang sama dan bersiap dengan perencanaan kapasitas yang tepat
      Itu bukan satu-satunya cara, tetapi cara yang paling sederhana
    • Pada konfigurasi 128 core, kami menjalankan beberapa hal, dan CPU limits ditetapkan jauh lebih tinggi daripada request, tetapi tetap dipasang agar sesuatu tidak lepas kendali
      Saya ingin tahu lebih banyak soal diskusi ini, tetapi artikel yang ditautkan sepertinya hanya membahas anggapan orang bahwa limit diperlukan untuk menjamin CPU bagi semua pod
    • Di komunitas Kubernetes rasanya diskusi ini muncul dua minggu sekali
      Artikel ini sendiri tidak salah dan secara umum lebih dekat ke content marketing, tetapi kesimpulannya terlalu luas dan mengabaikan beberapa alasan bagus untuk menetapkan limits
      Ada juga tulisan dari tempat yang sama yang memang keliru: https://home.robusta.dev/blog/containers-dont-use-chroot
      Ada workload yang menghabiskan semua kapasitas burst hanya demi keuntungan kecil, dan ada kalanya kapasitas burst HTTP server harus diprioritaskan dibanding cronjob yang akan selesai dalam waktu tertentu
      Pernah juga ada kasus developer tidak memperbarui requests meski kebutuhan aplikasi meningkat, lalu terjadi insiden ketika waktu CPU luang tiba-tiba berkurang
    • Reservations bukan limits, melainkan batasan penggunaan CPU minimum yang dijamin
      Secara teori itu adalah resource minimum yang dijamin, tetapi jika container-container yang sibuk berjalan bersama di host yang sama, tail latency dan latency rata-rata bisa meningkat secara tidak wajar
      Latency pada instans EC2 4 core saat utilisasi CPU 50% dan 90% cukup berbeda
      Pada reservations pun mirip: meski tiap container mendapatkan reservation-nya, proses sibuk lain di host yang sama membuat utilisasi CPU relatif menjadi sangat tinggi
    • Menarik, tetapi bukankah ini tidak berlaku untuk memori?
      OOMKiller bisa mengambilnya
      Jika CPU dan memory limits sama-sama tidak ada, kita tidak bisa mendapatkan kelas Guaranteed QoS, jadi pada titik tertentu pod bisa saja dieviction
  • Saya beberapa kali kena masalah CFS scheduler saat memakai container dan cgroup
    Penasaran scheduler baru itu apa
    Ada yang di sini sudah memakainya di cluster produksi?
    Sudah hampir 20 tahun kita membuang-buang core: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf

  • Selain GOMAXPROCS, rilis Go terbaru juga punya GOMEMLIMIT
    Dengan https://github.com/KimMachineGun/automemlimit, batas ini bisa disetel otomatis mirip seperti https://github.com/uber-go/automaxprocs

  • Tahun lalu di tempat kerja sebelumnya, saat menjadi platform engineer dan mengelola cluster Kubernetes on-premises serta infrastruktur pipeline CI/CD, saya menemukan ini
    Saya melihat ketidaksesuaian antara CPU fisik dan CPU yang dialokasikan menimbulkan masalah, terutama CPU throttling, tetapi sulit menemukan solusi yang bisa diskalakan dan berdampak pada semua deployment Go di cluster
    Meminta semua developer dari ratusan proyek menambahkan dependency autoprocs bukan pilihan
    Alternatif seperti menyamakan semua CPU request/limit ke bilangan bulat lalu memasukkan nilai itu sebagai environment variable GOMAXPROCS di manifest Kubernetes juga merepotkan dan tidak realistis
    Pada akhirnya kami menerapkan variabel GOMAXPROCS hanya pada sebagian aplikasi yang banyak memakai multi-threading dan mendapatkan perbaikan, tetapi kami masih belum menemukan solusi yang bisa diterapkan ke semua deployment dalam arsitektur microservices, ketika kebutuhan CPU tiap proyek sangat bervariasi

    • Tidak ada satu jawaban yang benar di sini
      Membatasi GOMAXPROCS bisa menimbulkan masalah latency serius ketika traffic menumpuk pada proses dan queueing-nya sederhana
      Terlepas dari gagasan tentang berapa banyak waktu yang rata-rata akan dipakai proses, dalam praktiknya yang terbaik adalah menyetel GOMAXPROCS ke nilai yang disediakan hardware
    • Anda bisa mendefinisikan mutating webhook yang menyuntikkan GOMAXPROCS ke semua container pod
  • Dari sudut pandang orang yang tidak akrab dengan Docker atau Go, saya penasaran apakah perilaku ini memang disengaja
    Bisakah tim Go membuatnya mengenali CGroups limit?
    Apakah runtime lain juga berperilaku serupa?

    • Saya cukup yakin .NET juga harus menangani masalah ini, dan seingat saya Java pernah atau masih punya masalah yang sama
      Atau yang dimaksud runtime seperti containerd?
    • Saya mengalami masalah yang sama di JVM
      Waktu itu di Scala
  • Ada juga teknik GC yang membuat jeda menjadi lebih singkat
    Misalnya, dengan menjalankan pekerjaan yang seharusnya dilakukan saat jeda secara bersamaan, lalu mengulanginya lagi di titik aman
    Harapannya, berkat pekerjaan konkuren itu, pekerjaan di titik aman berubah menjadi pengecekan sederhana bahwa “tidak ada yang perlu dikerjakan”
    Jika pekerjaan digandakan, throughput GC bisa memburuk

  • Tulisan ini membahas container, tetapi masalahnya tampaknya muncul kapan pun Go hanya bisa mengakses waktu CPU yang lebih sedikit daripada yang diharapkan
    Bukankah hal yang sama terjadi jika menjalankan Go di sistem yang juga memiliki proses lain yang memakai CPU?
    Bahkan mungkin cukup dengan menjalankan dua program Go secara bersamaan?