- แม้จะตั้งข้อจำกัด CPU ให้คอนเทนเนอร์ แต่โดยค่าเริ่มต้น Go runtime จะไม่รู้เรื่องนี้ จึงอาจสร้างเธรดตามจำนวนคอร์ทั้งหมดของโฮสต์และทำให้ latency สูงขึ้นได้
- Go GC ส่วนใหญ่ทำงานพร้อมกับแอปพลิเคชัน แต่ในช่วง Sweep Termination และ Mark Termination จำเป็นต้องมีช่วง stop-the-world (STW) ที่หยุด goroutine ทั้งหมด
- Linux CFS แบ่งสรรจำนวนคอร์ออกเป็นเวลา CPU ต่อวินาที โดย
--cpus=4 หมายถึงให้เวลา CPU แก่คอนเทนเนอร์เทียบเท่า 4 วินาทีในทุก ๆ 1 วินาที
- หากรันคอนเทนเนอร์ที่จำกัด 4 คอร์บนโฮสต์ 16 คอร์ Go อาจวาง goroutine บน OS thread ได้ 16 ตัว ทำให้ STW ยาวขึ้นหลังใช้ CPU quota หมด
- เมื่อตั้ง
GOMAXPROCS ให้ตรงกับข้อจำกัด CPU ของคอนเทนเนอร์ ในตัวอย่าง GC cycle ลดจากต่ำกว่า 2.5ms เหลือต่ำกว่า 1ms และ STW ลดลงเหลือประมาณ 26μs
ความไม่สอดคล้องกันระหว่างข้อจำกัด CPU ของคอนเทนเนอร์กับ Go runtime
- เมื่อรันแอปพลิเคชัน Go ในคอนเทนเนอร์ ข้อจำกัด CPU เป็นกลไกที่ป้องกันไม่ให้ใช้ CPU ทั้งหมดของโฮสต์
- ปัญหาคือโดยค่าเริ่มต้น Go runtime ไม่รับรู้ ข้อจำกัด CPU ของคอนเทนเนอร์
- ความไม่สอดคล้องนี้ทำให้ runtime มองว่าสามารถใช้ CPU ได้มากกว่า quota จริง และอาจนำไปสู่ latency ที่สูงได้
จุดที่เกิด STW ใน Go GC
- Garbage collector ของ Go ทำงาน พร้อมกัน กับแอปพลิเคชันเป็นส่วนใหญ่
- อย่างไรก็ตาม ในกระบวนการ GC มีสองช่วงที่ต้องหยุด goroutine ทั้งหมด
- ขั้นตอนที่หยุดก่อน Mark Phase เพื่อใช้ write barrier คือ Sweep Termination
- ขั้นตอนที่หยุดอีกครั้งหลัง Mark Phase เพื่อเอา write barrier ออกคือ Mark Termination
- ช่วง STW โดยทั่วไปอยู่ในระดับหลายสิบไมโครวินาที
- แอปพลิเคชันตัวอย่างเป็นเว็บแอปพลิเคชันแบบง่ายที่จัดสรรหน่วยความจำจำนวนมาก และซอร์สโค้ดอยู่ที่ go-cfs-blog
- คอนเทนเนอร์รันโดยจำกัดไว้ที่ 4 CPU
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
- สามารถเก็บ trace ด้วยแพ็กเกจ
runtime/trace และวิเคราะห์ด้วย go tool trace
- ในการรันครั้งนี้ GC cycle ต่ำกว่า 2.5ms แต่เกือบ 10% ของเวลานั้นเป็นช่วง STW
- สำหรับแอปพลิเคชันที่ไวต่อ latency สัดส่วนระดับนี้ก็อาจเป็นปัญหาได้
ข้อจำกัด CPU ของ Docker และการทำงานของ Linux CFS
- ข้อจำกัด CPU
--cpus ของ Docker เป็น hard limit
- สามารถตั้ง
--cpu-shares ได้เช่นกัน แต่จะถูกบังคับใช้เฉพาะเมื่อโฮสต์ถูกจำกัดด้าน CPU เท่านั้น
- หากโฮสต์ยังมีทรัพยากรเหลือ คอนเทนเนอร์สามารถใช้ได้มากกว่าจำนวน CPU core ที่จัดสรรไว้
- เมื่อโฮสต์อยู่ในภาวะถูกจำกัด แอปพลิเคชันจึงจะถูกจำกัด
- Linux Completely Fair Scheduler(CFS) ถูกนำมาใช้ใน Linux 2.6.23 และเป็น scheduler เริ่มต้นจนก่อน Linux 6.6
- CFS เป็น proportional share scheduler โดยกำหนด weight ของโปรเซสตามสัดส่วนกับจำนวน CPU core ที่สามารถใช้ได้
- โปรเซสที่ใช้ CPU core ได้ 4 คอร์จะมี weight เป็น 4
- โปรเซสที่ใช้ CPU core ได้ 2 คอร์จะมี weight เป็น 2
- CFS แบ่งเวลา CPU ออกมาแจกจ่าย
- ระบบ 4 คอร์สามารถแจกจ่ายเวลา CPU ได้เทียบเท่า 4 วินาทีในทุก ๆ 1 วินาที
- การจัดสรรจำนวน CPU core ให้คอนเทนเนอร์ เท่ากับการขอเวลาเทียบเท่า CPU
n ตัวจาก Linux scheduler
--cpus=4 หมายความว่าคอนเทนเนอร์ได้รับเวลา CPU เทียบเท่า 4 วินาทีในทุก ๆ 1 วินาที
ทำไม STW จึงยาวขึ้น
- Go runtime จะสร้าง OS thread หนึ่งตัวต่อ CPU core ตอนเริ่มต้น
- บนเครื่อง 16 คอร์ อาจสร้าง OS thread 16 ตัว ได้โดยไม่ขึ้นกับข้อจำกัด CPU ของ CGroup
- runtime จะ schedule goroutine ลงบน OS thread เหล่านี้
- แม้ข้อจำกัด CPU ของคอนเทนเนอร์จะเป็น 4 คอร์ Go ก็ยังสามารถวาง goroutine บน OS thread ทั้ง 16 ตัวได้
- ในสภาพนี้ runtime จะคาดหวังว่าสามารถใช้เวลา CPU ได้เทียบเท่า 16 วินาทีในทุก ๆ 1 วินาที
- เวลา STW ที่ยาวเกิดจากต้องหยุด goroutine ทั้งหมด รวมถึง goroutine ที่อยู่บนเธรดซึ่งกำลังรอให้ Linux scheduler กลับมารันอีกครั้ง
- หลังจากคอนเทนเนอร์ใช้ CPU quota ไปหมดแล้ว เธรดเหล่านั้นจะไม่ได้รับการ schedule
ตั้ง GOMAXPROCS ให้ตรงกับ CPU quota
- Go สามารถจำกัดจำนวน CPU thread ที่ runtime จะใช้ได้ด้วย environment variable
GOMAXPROCS
- ในคอนเทนเนอร์ที่มี CPU quota เป็น 4 ให้ระบุ
GOMAXPROCS=4 ควบคู่กัน
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
- เมื่อปรับ
GOMAXPROCS ให้ตรงกับ CPU quota ภายใต้แอปพลิเคชันและโหลดเดียวกัน เวลา GC ก็สั้นลง
- ใน trace พบว่า GC cycle ลดลงเหลือต่ำกว่า 1ms และช่วง STW อยู่ที่ 26μs
- ซึ่งคิดเป็นประมาณ 1/10 เมื่อเทียบกับเวลา STW ตอนที่ไม่ได้จำกัด
GOMAXPROCS
- ควรตั้ง
GOMAXPROCS เป็นจำนวน CPU core ที่คอนเทนเนอร์สามารถใช้ได้
- เมื่อจัดสรร CPU แบบเศษส่วนให้ปัดลง
- เมื่อจัดสรรน้อยกว่า 1 CPU ให้ปัดขึ้น
- สูตรคำนวณคือ
GOMAXPROCS=max(1, floor(CPUs))
- automaxprocs ของ Uber เป็นไลบรารีโอเพนซอร์สที่คำนวณค่านี้จาก cgroups ของคอนเทนเนอร์โดยอัตโนมัติ
- มีการเปิด GitHub Issue เพื่อให้ Go runtime รองรับสิ่งนี้เป็นค่าเริ่มต้น
สิ่งที่ควรตรวจสอบในบริการ Go ที่ทำงานในคอนเทนเนอร์
- การตั้งข้อจำกัด CPU เพียงอย่างเดียวไม่เพียงพอ ต้องปรับ GOMAXPROCS เพื่อให้ Go runtime สะท้อนข้อจำกัดนั้นด้วย
- หากคำนวณเองได้ยาก สามารถใช้ไลบรารีอย่าง automaxprocs เพื่อตั้งค่าอัตโนมัติจาก cgroups ได้
- บริการ Go ที่ไวต่อ latency ควรตรวจสอบเวลา STW ใน GC trace เพื่อดูว่า CPU quota กับการตั้งค่า runtime ไม่คลาดเคลื่อนกัน
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ปัญหาที่พบได้ร่วมกันในหลายภาษาคือแอปพลิเคชันดู /proc/cpuinfo เพื่อหาจำนวนคอร์ของเครื่อง
แต่ภายใน Docker container หรือเทคโนโลยี container อื่น ๆ ไฟล์นี้จะดูเหมือนกับบน host ของ container ทุกอย่าง และจะแสดงรายการคอร์ทั้งหมด ไม่ว่าจะจัดสรรให้ container จริง ๆ แค่กี่คอร์ก็ตาม
ช่วงหนึ่งเคยคิดว่า Docker อาจสร้าง /proc/cpuinfo ปลอมที่แสดงเฉพาะ “Docker CPU” ที่จัดสรรให้งานได้หรือไม่ แต่พอลองคิดดูอีกที น่าจะทำได้ไม่ดีด้วยหลายเหตุผล
สิ่งที่ถูกจำกัดคือ ใช้งานคอร์เหล่านั้นได้นานแค่ไหน
มีข้อยกเว้นด้วย และเอกสารอยู่ที่นี่: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
nprocและเคยเห็นใช้ใน container อื่น ๆ ด้วย เช่นbundle install -j $(nproc)สิ่งนี้เคารพ การจัดสรร CPU จึงให้ฟังก์ชันที่ต้องการได้
ไม่แน่ใจว่าแอปพลิเคชันทั่วไปจะใช้ nproc เมื่อทำได้หรือไม่
“แสดงจำนวนหน่วยประมวลผลที่พร้อมใช้งานสำหรับโปรเซสปัจจุบัน ซึ่งอาจน้อยกว่าจำนวนโปรเซสเซอร์ที่ออนไลน์อยู่ หากไม่สามารถดึงข้อมูลนี้ได้ จะแสดงจำนวนโปรเซสเซอร์ที่ติดตั้งอยู่”
https://www.gnu.org/software/coreutils/manual/html_node/npro...
https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
ตอนเริ่มต้น Go จะดูจำนวนใน CPU mask แล้วหลังจากนั้นจะไม่ดูอีก
ใน Kubernetes CPU ที่มองเห็นได้ระหว่างที่โปรเซสทำงานอยู่อาจเปลี่ยนไปได้ จึงกลายเป็นปัญหา
lxcfs คือระบบไฟล์ FUSE ที่อนุมานค่าจาก cgroup แล้วจำลอง /proc เพื่อให้แอปพลิเคชันและไลบรารีไม่ต้องสนใจว่ากำลังรันอยู่ใน container หรือไม่
ตัวอย่างเช่น /proc/uptime ควรสะท้อน uptime ของ container ไม่ใช่ของ host และ /proc/cpuinfo จะสะท้อนขีดจำกัดที่ต่ำกว่าระหว่างการผสมกันของ cpu.max กับ cpuset.cpus เป็นจำนวน CPU
การอนุมานจำนวน CPU ทำได้ด้วย system call
sched_getaffinityเช่นกัน และวิธีนี้ไม่พึ่งพา /proc/cpuinfoดังนั้นจึงอาจลำบากได้ ขึ้นอยู่กับไลบรารีที่ใช้
คำอธิบายนี้ผิดในรายละเอียดเล็กน้อย
จากมุมมองของ Docker ส่วนขยาย CFS cgroup มีปุ่มปรับได้หลายตัว:
cfs_quota_us,cfs_period_us(ค่า default ทั่วไปคือ 100ms ไม่ใช่ 1 วินาที) และ sharesถ้าตั้งค่า shares จะใช้การ scheduling แบบสัดส่วนตามน้ำหนัก แต่จะมีความหมายก็ต่อเมื่อมีการแย่งทรัพยากรกัน
สองค่าข้างหน้าจะบังคับ quota อย่างเข้มงวด
แทนที่จะใช้ flag
--cpuของ Docker โดยทั่วไปควรใช้--cpu-sharesเพื่อเลี่ยงการบังคับ quota ที่แทบไม่มีประโยชน์ตามเอกสาร Linux
cpu.sharesคือค่าน้ำหนักของแต่ละกลุ่มในลำดับชั้นเดียวกัน,cpu.cfs_period_usคือช่วงเวลา scheduler สำหรับการพิจารณา bandwidth และค่า default คือ 100000us หรือ 100mscpu.cfs_quota_usคือเวลาสูงสุดที่กลุ่มปัจจุบันสามารถรันได้ในแต่ละcfs_period_usและค่านี้เป็นเวลารวมข้าม CPU ทั้งระบบ ดังนั้นถ้าต้องการให้ใช้ CPU 2 ตัวได้เต็มที่ ต้องตั้งเป็นสองเท่าของcfs_period_us--cpuของ Docker แต่ให้…” นั้นแรงเกินไปหากไม่มีเงื่อนไขประกอบมันไม่ควรถูกมองว่า “แทบไม่มีประโยชน์” อย่างเด็ดขาด
shares กับ quota มีไว้สำหรับกรณีใช้งานที่ต่างกัน ดังนั้นควรเข้าใจกรณีใช้งานของตัวเองแล้วเลือกให้เหมาะ
--cpuแอปพลิเคชันจะตรวจจับสิ่งนี้ได้น่าจะเป็นเพราะใช้ cpuset
ถ้าใช้ quota แอปพลิเคชันจะตรวจจับไม่ได้ จึงมีโอกาสสูงที่จะสร้าง thread มากเกินจำเป็น
จะลองทำให้ส่วนนี้ชัดเจนขึ้น
คิดว่าอาการจะปรากฏออกมาในลักษณะนี้ แต่ควรใช้ถ้อยคำให้ชัดกว่านี้
แอปพลิเคชันต้องทำงานอย่างถูกต้อง
ถ้าใช้ CPU reservations แทน CPU limits ก็ไม่จำเป็นต้องปรับแต่งแบบนี้: https://home.robusta.dev/blog/stop-using-cpu-limits
จริง ๆ แล้ว CPU reservations ก็เป็น limits โดยปริยาย เพียงแต่ประกาศเป็นขีดจำกัดและการรับประกันแบบแฝง
ดังนั้นปล่อยให้ Go runtime ใช้ CPU ทั้งหมดที่มีได้ และถ้าเกิดการแย่งใช้ CPU ก็ให้ Linux scheduler จำกัดตาม reservations ที่ประกาศไว้ก็พอ
แต่เพราะไม่อยากชินกับสภาพที่ใช้ CPU ส่วนเกิน ที่ไม่ได้รับการรับประกันได้
เมื่อมี pod อื่น ๆ เข้ามาเติมเต็ม node มากขึ้นเรื่อย ๆ pod ที่เพิ่งรันได้ดีเมื่อครู่อาจช้าลงอย่างกะทันหัน
การใช้ limits จะจำลองพฤติกรรมเดียวกันนี้ และช่วยให้เตรียมรับมือด้วยการวางแผน capacity ที่ถูกต้องได้
ไม่ใช่วิธีเดียว แต่เป็นวิธีที่ง่ายที่สุด
อยากรู้ประเด็นนี้เพิ่มเติม แต่บทความที่ลิงก์ดูเหมือนจะพูดแค่ว่าคนคิดว่าต้องมี limit เพื่อรับประกัน CPU ให้ทุก pod
บทความนี้ไม่ได้ผิดในตัวมันเอง และโดยรวมค่อนข้างเป็น content marketing แต่ข้อสรุปกว้างเกินไปและมองข้ามเหตุผลดี ๆ หลายข้อในการตั้ง limits
บทความอื่นจากที่เดียวกันบางอันก็ผิดไปเลย: https://home.robusta.dev/blog/containers-dont-use-chroot
มี workload ที่ใช้ burst capacity จนหมดทั้งที่ได้ประโยชน์เพียงเล็กน้อย และบางครั้งก็ควรให้ความสำคัญกับ burst capacity ของ HTTP server มากกว่า cronjob ที่ต้องจบภายในเวลาที่กำหนด
เคยมีกรณีที่นักพัฒนาไม่อัปเดต requests ทั้งที่ความต้องการของแอปเพิ่มขึ้น แล้วพอเวลา CPU ว่างลดลงกะทันหันก็เกิด incident
ตามทฤษฎีคือทรัพยากรขั้นต่ำที่รับประกัน แต่ถ้ามีคอนเทนเนอร์ที่งานหนักหลายตัวรันอยู่บนโฮสต์เดียวกัน tail latency และ latency เฉลี่ยอาจเพิ่มขึ้นผิดปกติได้
latency ตอน CPU utilization 50% กับ 90% บน EC2 instance 4 คอร์นั้นต่างกันพอสมควร
ในกรณี reservations ก็คล้ายกัน แม้แต่ละคอนเทนเนอร์จะได้รับ reservation ของตัวเอง แต่เพราะมี process อื่นที่งานหนักบนโฮสต์เดียวกัน ทำให้การใช้ CPU ในเชิงสัมพัทธ์สูงมาก
OOMKiller อาจมาเก็บไปได้
ถ้าไม่มีทั้ง CPU และ memory limits ก็จะไม่ได้ Guaranteed QoS class ดังนั้นในบางจุด pod อาจถูกขับออกได้
ใช้คอนเทนเนอร์กับ cgroup แล้วโดน CFS scheduler เล่นงานมาหลายครั้ง
อยากรู้ว่า scheduler ตัวใหม่คืออะไร
มีใครที่นี่เคยใช้ใน production cluster ไหม?
ตอนนี้เสียคอร์ทิ้งมาเกือบ 20 ปีแล้ว: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
ปัญหาคือคอนเทนเนอร์ตั้ง resource limit ไว้ แต่ Go ซึ่งเป็น process ภายในคอนเทนเนอร์ ไม่ตรวจสอบฟีเจอร์ของระบบปฏิบัติการที่ใช้ตั้ง limit นั้นตอนคำนวณปริมาณ parallelism ที่ใช้งานได้
นอกจาก GOMAXPROCS แล้ว Go รุ่นล่าสุดยังมี GOMEMLIMIT ด้วย
ใช้ https://github.com/KimMachineGun/automemlimit เพื่อกำหนด limit นี้อัตโนมัติได้ คล้ายกับ https://github.com/uber-go/automaxprocs
ปีก่อนตอนทำงานเก่าผมเป็น platform engineer ดูแล on-premises Kubernetes cluster และโครงสร้างพื้นฐาน CI/CD pipeline แล้วพบเรื่องนี้
เคยเห็นว่าความไม่ตรงกันระหว่าง CPU จริงกับ CPU ที่จัดสรรทำให้เกิดปัญหา โดยเฉพาะ CPU throttling แต่หาวิธีแก้ที่ขยายผลได้และมีผลกับ deployment ของ Go ทั้งหมดใน cluster ได้ยาก
การให้ developer ทุกคนในหลายร้อยโปรเจกต์ใส่ dependency autoprocs ไม่ใช่ทางเลือก
อีกทางคือปรับ CPU request/limit ทั้งหมดให้เป็นจำนวนเต็ม แล้วใส่ค่านั้นเป็น environment variable GOMAXPROCS ใน Kubernetes manifest ซึ่งก็ยุ่งยากและทำจริงไม่ได้
สุดท้ายจึงใส่ตัวแปร GOMAXPROCS ให้เฉพาะบางแอปพลิเคชันที่ใช้หลายเธรดหนัก ๆ และได้ผลดีขึ้น แต่ยังไม่เจอวิธีแก้ที่ใช้ได้กับ deployment ทั้งหมดในสถาปัตยกรรม microservices ที่แต่ละโปรเจกต์มีความต้องการ CPU ผันผวนมาก
ถ้าจำกัด GOMAXPROCS อาจเกิด ปัญหา latency รุนแรง เมื่อ traffic ไหลเข้า process จำนวนมากและการ queuing เรียบง่าย
ไม่ว่าคุณจะคิดอย่างไรเกี่ยวกับเวลาที่ process จะใช้โดยเฉลี่ย จริง ๆ แล้วการตั้ง GOMAXPROCS ตามค่าที่ hardware ให้มานั้นดีที่สุด
ในฐานะคนที่ไม่คุ้นกับ Docker หรือ Go อยากรู้ว่าพฤติกรรมนี้ตั้งใจให้เป็นแบบนี้หรือไม่
ทำให้ทีม Go รับรู้ CGroups limit ได้ไหม?
runtime อื่น ๆ ก็ทำงานคล้ายกันหรือเปล่า?
หรือหมายถึง runtime อย่าง containerd?
ตอนนั้นเป็น Scala
ยังมี เทคนิค GC ที่ทำให้การหยุดชั่วคราวสั้นลงได้ด้วย
เช่น ทำงานที่ต้องทำระหว่างหยุดชั่วคราวไปพร้อมกันก่อน แล้วค่อยทำซ้ำอีกครั้งที่ safe point
ความคาดหวังคือด้วยการทำงานแบบพร้อมกัน งานที่ safe point จะกลายเป็นแค่การตรวจสอบง่าย ๆ ว่า “ไม่มีอะไรต้องทำ”
หากทำงานซ้ำเป็นสองเท่า throughput ของ GC ก็อาจแย่ลงได้
บทความนี้พูดถึงคอนเทนเนอร์ แต่ดูเหมือนว่าปัญหานี้จะเกิดขึ้นเสมอเมื่อ Go เข้าถึง เวลา CPU ได้น้อยกว่าที่คาดไว้
หากรัน Go บนระบบที่มีโปรเซสอื่นใช้ CPU อยู่ เรื่องเดียวกันก็น่าจะเกิดขึ้นไม่ใช่หรือ?
แม้แต่แค่รันโปรแกรม Go สองตัวพร้อมกัน ก็น่าจะเป็นแบบนั้นไม่ใช่หรือ?