รองรับคำขอ 200 ล้านครั้งต่อวันด้วย CGI-bin
(simonwillison.net)- Jake Gold ทดสอบรันโปรแกรม CGI แบบ Go + SQLite บน AMD 3700X แบบ 16 เธรด เพื่อดูว่า CGI สไตล์ยุค 1990 จะรับมือได้แค่ไหนบนฮาร์ดแวร์สมัยใหม่
- ผลลัพธ์แสดงให้เห็นว่าแม้จะเป็นฮาร์ดแวร์ทั่วไป ก็ยังรองรับได้มากกว่า 2,400 คำขอต่อวินาที หรือมากกว่า 200 ล้านคำขอต่อวัน
- CGI ทำงานโดยเริ่มต้น รัน และจบโปรเซสใหม่ในทุกคำขอ ทำให้ในอดีตมีโอเวอร์เฮดสูง และจึงเกิดแนวทางอย่าง PHP และ FastCGI ขึ้นมาเพื่อลดต้นทุนส่วนนี้
- Simon Willison เล่าว่าในปี 2020 ตอนทำ datasette-ripgrep เขาใช้การเรียก Rust-based ripgrep CLI เพื่อประมวลผลการค้นหา และทำให้เปลี่ยนความเชื่อเดิมที่ว่าควรหลีกเลี่ยงการรันโปรเซสระหว่างเว็บรีเควสต์
- ด้วยภาษาอย่าง Go และ Rust ที่เริ่มต้นได้รวดเร็ว รวมถึงสภาพแวดล้อมแบบหลาย CPU การประมวลผลสไตล์ CGI อาจเป็นตัวเลือกที่ใช้งานได้จริงมากกว่าเดิม แต่ก็ไม่ใช่แนวทางที่แนะนำโดยทั่วไป
ประสิทธิภาพ CGI เมื่อมองใหม่บนฮาร์ดแวร์สมัยใหม่
- Serving 200 million requests per day with a cgi-bin เป็นบทความที่ Jake Gold ทดสอบประสิทธิภาพ CGI สไตล์ยุค 1990 ด้วย โปรแกรม CGI แบบ Go + SQLite
- สภาพแวดล้อมการทดสอบคือระบบที่ใช้ AMD 3700X แบบ 16 เธรด
- ผลลัพธ์สำคัญคือ แม้ใช้ CGI ก็ยังประมวลผลได้มากกว่า 2,400 คำขอต่อวินาที หรือมากกว่า 200 ล้านคำขอต่อวัน บนฮาร์ดแวร์ทั่วไป
- CGI เป็นวิธีที่เริ่มโปรเซสแยกสำหรับทุกคำขอ จากนั้นรันและปิดโปรเซสนั้น
- ชุมชนเว็บยุคแรกพยายามหลีกเลี่ยงโอเวอร์เฮดนี้ จึงสร้าง PHP และ FastCGI ที่ทำให้โค้ดค้างอยู่ในหน่วยความจำเพื่อลดต้นทุนเพิ่มเติม
เหตุผลที่ CGI อาจไม่ได้แย่เหมือนเมื่อก่อน
- หนึ่งในคอขวดของ CGI ในอดีตคือ การเขียนเว็บสคริปต์ด้วยภาษาอย่าง Perl, Python, Java ซึ่งไม่ได้ถูกออกแบบมาให้เริ่มต้นได้เร็วมาก
- ทุกวันนี้ หากใช้ Go และ Rust การประมวลผลคำขอแบบ CGI อาจทำงานได้มีประสิทธิภาพกว่ามาก
- ในปี 2020 Simon Willison สร้าง datasette-ripgrep โดยเรียกใช้เครื่องมือ CLI ripgrep ที่เขียนด้วย Rust ผ่านเชลล์เพื่อทำการค้นหา และได้ผลลัพธ์ที่ดี
- เนื่องจากโปรแกรม CGI รันเป็นโปรเซสแยก จึงอาจเข้ากันได้ดีกับสถาปัตยกรรมที่ใช้หลาย CPU
- เซิร์ฟเวอร์สมัยใหม่อาจมีได้ถึง 384 CPU threads
- VM ขนาดเล็กก็อาจมี 16 CPU ได้
- ประสิทธิภาพของ CPU และหน่วยความจำก็สูงขึ้นจากอดีตมาก
- วิธีเขียนเว็บแอปแบบปี 1998 เมื่อมาเจอกับ Go และ Rust ก็ยังเป็นการทดลองที่น่าสนใจ แต่ไม่ใช่ตัวเลือกพื้นฐานที่บริการส่วนใหญ่ควรยึดตาม
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ทุกวันนี้ CGI ค่อนข้างเร็ว แม้จะใช้ของอย่าง Python ก็ตาม
ถ้าการเริ่มสคริปต์ CGI ใช้ CPU 400ms และเซิร์ฟเวอร์มี 64 คอร์ ก็จะรองรับได้ 160 รีเควสต์ต่อวินาที หรือ 14 ล้านฮิตต่อวันต่อเซิร์ฟเวอร์หนึ่งเครื่อง
ถ้าเว็บเซอร์วิสของคุณรับคำขอไม่รวมไฟล์ static วันละหลายล้านครั้งแล้วยังลำบาก คอขวดก็ไม่ใช่เวลาเริ่มโปรเซส CGI
เมื่อก่อนคงพูดได้ว่า “เป็นเทคโนโลยีน่าเบื่อที่ไลบรารีมาตรฐานของ Python รองรับมานาน” แต่ผู้ดูแล Python ที่เหลืออยู่ดูเหมือนจะมองว่าความเสถียรและความเข้ากันได้ย้อนหลังเป็นโทษ และได้เอาโมดูลที่น่าเบื่อและเสถียรเกินไปออกจากไลบรารีมาตรฐานไปเรื่อยๆ
โมดูล cgi ถูกเอาออกใน Python 3.13
ผมใช้มันแทบทุกวันมาตลอด 25 ปีส่วนใหญ่ เลยยังติดนิสัยใช้ Python สำหรับทำ prototype อยู่ แต่ตอนนี้รู้สึกเสียดาย และกำลังลังเลระหว่าง JS กับ Lua
ที่น่าสนใจคือในนั้นลิงก์ไปยัง https://peps.python.org/pep-0206/ วันที่ 14 กรกฎาคม 2000 หรือเมื่อ 25 ปีก่อน ซึ่งตอนนั้นก็อธิบายแพ็กเกจ cgi ว่า “ออกแบบมาไม่ดี และตอนนี้แทบเป็นไปไม่ได้ที่จะแก้ไข” แล้ว
แพ็กเกจ https://github.com/jackrosenthal/legacy-cgi ดูเหมือนจะให้ ตัวทดแทนแบบ drop-in สำหรับโมดูลในไลบรารีมาตรฐาน
การรองรับนั้นยังอยู่ใน CGIHTTPRequestHandler ของโมดูล http.server
สิ่งที่อยู่ในโมดูล cgi มีแค่ฟังก์ชันบางตัวสำหรับพาร์สข้อมูลฟอร์ม HTML เท่านั้น
แต่หลังจากนั้นกลับพิจารณา JS ซึ่งไม่มีไลบรารีมาตรฐานในตัวเองเป็นทางเลือก ผมก็ไม่ค่อยเข้าใจ
Lua ก็ไม่มีโมดูล CGI ในไลบรารีมาตรฐานเหมือนกัน
ตอนนี้ใกล้เคียงกับการค้นหา ส่วนที่สิ้นเปลืองอย่างไม่น่าเชื่อ ซึ่งถูกใส่เข้ามาเพื่อออกของให้เร็ว แล้วหยุดมันมากกว่า
ถ้าลองทำให้ถูกต้อง แอปอาจแทบไม่เพิ่ม latency อะไรเลย นอกจากการเข้าถึง storage
ผมเรียน Python มาตั้งแต่ 1.6 แต่ส่วนใหญ่ใช้สำหรับสคริปต์ระบบปฏิบัติการ
ช่วงปี 1999–2003 ผมใช้ Tcl ในโมดูล Apache และ IIS และได้บทเรียนหนักๆ มากมายจากการต้องเขียนโมดูลใหม่เป็น C อยู่เรื่อยๆ
เมื่อเร็วๆ นี้ผมทดสอบโดยเอา ไบนารี Golang, RabbitMQ, Redis, MySQL ทั้งหมดไปรันบนเครื่องเดียวกันในมินิเซิร์ฟเวอร์ราคา 350 ดอลลาร์ แล้วมันรองรับได้ต่อเนื่อง 5,000 รีเควสต์ต่อวินาที
ถ้าคิดเป็น 24 ชั่วโมงต่อวันก็เท่ากับ 400 ล้านรีเควสต์
น่าทึ่งมากว่าเครื่องมือฟรียุคนี้ยอดเยี่ยมแค่ไหน และก็น่าทึ่งเหมือนกันที่เรายังจ่ายเงินให้ผู้ให้บริการคลาวด์กันมากขนาดนั้น
มันไม่ใช่การเปรียบเทียบที่เหมือนกันเป๊ะ แต่กระบวนการพัฒนาและจูนทุกอย่างบนอุปกรณ์ในห้องใต้ดินนั้นดีมากจริงๆ
มันแปลกที่เรายอมรับ overhead แบบนี้ต่อไปโดยไม่มีเหตุผลอื่นนอกจาก “Google ทำแบบนั้น”
ผมต้องเขียนบทความเกี่ยวกับ สถาปัตยกรรมโมโนลิทแบบโมดูลาร์ ที่เราใช้ได้ดีอยู่ให้ได้
เช่า เซิร์ฟเวอร์ dedicated จากบริษัทโฮสติ้งแล้วใช้ให้เต็มที่ก็ได้ เพียงแต่จะถูกจำกัดมากด้วยแบนด์วิดท์หรือปริมาณทราฟฟิก
เหตุที่คลาวด์ถูกใช้งานกันมากเป็นเพราะมีผลประโยชน์เกี่ยวข้องเยอะ เช่น VC และนักลงทุนก็ถือหุ้นบริษัทคลาวด์ด้วย และกลัวว่าการลงทุนจะพังเพราะทราฟฟิกพุ่งแบบจินตนาการที่จริงๆ แล้วจะไม่เกิดขึ้น
ฝ่ายขายคลาวด์เก่งในการจี้ความกังวลของนักลงทุน
ถ้าดิสก์เสียตอนกำลังเดินทางก็หมดทางแก้ และถ้าเผลอล็อกตัวเองออก ก็ไม่มี serial terminal สำรองไว้ fallback
จะตั้งค่าแบบคนใน r/homelab ก็ได้ แต่พอทำอย่างนั้น โดยเฉพาะเมื่อรวมเวลาของตัวเองเข้าไปด้วย ก็ไม่ชัดเจนแล้วว่าประหยัดเงินจริงไหม
สรุปแล้วผมมองว่า ผู้ให้บริการคลาวด์ เป็นดีลที่ค่อนข้างดีเพราะ economy of scale
แปลกที่ไม่สามารถหาอย่างใดอย่างหนึ่งแยกต่างหากได้
ลองจินตนาการดูว่าแค่มีไดรฟ์ 20TB สี่ตัวกับ CPU พอประมาณ จะรันธุรกิจหรือบริการใหญ่ได้ขนาดไหน
การได้คอนฟิกแบบนั้นจากผู้ให้บริการคลาวด์ไม่ใช่เรื่องง่าย
ประโยคที่ว่า “ชุมชนเว็บยุคแรกเรียนรู้เร็วว่านี่เป็นไอเดียที่แย่ และคิดค้นเทคโนโลยีอย่าง PHP ขึ้นมา” ถ้าพูดให้เคร่งจริงๆ เทคโนโลยีหลักคือ mod_php
ตัว PHP เองไม่ได้ต่างจาก Perl ในแง่วิธีการรัน แต่เพราะตัวเลือกด้านการออกแบบของ mod_php เมื่อเทียบกับ mod_perl สคริปต์ PHP จึงแค่โยนเข้าไปในเซิร์ฟเวอร์ก็รันได้เร็ว ส่วน mod_perl ต้องใช้ความคิดและเวทมนตร์เล็กน้อยถึงจะทำงานได้
มีการจัดการคอนเทนต์ อัปโหลดงาน ปฏิทินอีเวนต์ จัดการเกรด แชตเรียลไทม์ และฟอรั่ม ทั้งหมดเป็น C ล้วนบน CGI ซึ่งทำงานด้วยแล้วเหมือนตกนรก
วันที่ได้รู้จัก PHP ผมแทบร้องไห้ เพราะทุกอย่างที่เคยต้องอ่าน RFC หรือ reverse engineer HTTP แล้วเขียนอย่างลำบากตั้งแต่ศูนย์ ใน PHP กลายเป็นแค่การเรียกฟังก์ชันธรรมดา
ไม่ต้องดีบัก implementation ของ urlencode แบบลวกๆ หรือเสียทั้งวันเพราะ carriage return แปลกๆ ตัวเดียวใน HTTP header อีกต่อไป
ดูเหมือนว่าเวอร์ชันแรกของโมดูล Apache จะเป็นสำหรับ PHP/FI Version 2.0 ในปี 1996: https://www.php.net/manual/phpfi2.php#module
หาก cgi-bin จำเป็นต้องเข้าถึง DB ก็ต้องเปิดการเชื่อมต่อทุกครั้งที่โปรเซสเริ่มทำงาน
การปล่อยโค้ดค้างไว้ในหน่วยความจำแบบ FastCGI ไม่ใช่แค่เพื่อหลีกเลี่ยงต้นทุนเวลาเริ่มต้นเท่านั้น แต่ยังเพราะทำให้มี DB connection pool หรืออย่างน้อยก็การเชื่อมต่อแบบคงอยู่แยกตามเธรดได้ด้วย
เคยเป็นแบบนั้นตอนที่ทำทั้ง “Python เป็น single-thread งั้นรันหลาย ๆ ตัว” และ “Python ช้า งั้นรันหลาย ๆ ตัว” พร้อมกัน
เมื่อสเกลใหญ่ขึ้น สุดท้ายต้องใช้ shared connection pool นอก Python อย่าง pgbouncer และปรับจูนเยอะมากเพื่อรับโหลดโดยไม่ทำให้ DB ตาย
แน่นอนว่าหลังจากนั้น พอเขียนใหม่ด้วยภาษาที่เป็น multi-thread และมีประสิทธิภาพในระดับหนึ่ง ทุกอย่างก็กลับมาเรียบง่ายมากอีกครั้ง
โดยหลัก ๆ คือรัน daemon แยกต่างหากที่ทำหน้าที่เหมือนพร็อกซี
ถ้าใช้ Unix socket แทน TCP/IP ต้นทุนการเชื่อมต่อจะค่อนข้างต่ำลง
บนฮาร์ดแวร์นี้ แอป hello world ได้ 2,400 requests ต่อวินาที นี่ถือว่าไม่ค่อยแย่หรือเปล่า
ก็ไม่รู้เหมือนกันว่ายอมเสียประสิทธิภาพไปแล้วได้อะไรกลับมากันแน่ โค้ดก็ไม่ได้เรียบง่ายขึ้นด้วย
เว็บแบบนั้นมีแค่บางส่วนจากทั้งหมด
น่าจะรับ traffic bomb จาก HN ได้ด้วย
เมื่อวานก็มีการคุยกันแล้ว: https://news.ycombinator.com/item?id=44464272
ที่บริษัท เรายังให้บริการ ไดเรกทอรี cgi-bin สำหรับเว็บแอปภายในที่ทำขึ้นเร็ว ๆ เป็นครั้งคราว
ถ้ารักษาให้เรียบง่ายไว้ ก็ใช้งานได้ดี
การเป็น CGI ไม่ได้แปลว่าต้อง print HTTP/1.0 ไปที่ stdout โดยตรง
ตัวอย่างเช่น ถ้าใช้ wsgiref.handlers.CGIHandler ที่มากับ Python ก็สามารถรันแอป WSGI ใด ๆ เป็นสคริปต์ CGI ได้
import wsgiref.handlers, flask
app = flask.Flask(name)
wsgiref.handlers.CGIHandler().run(app)
เรารันสคริปต์ด้วย uwsgi และ CGI plugin[1] ของมัน
รู้สึกว่าเรียบง่ายและยืดหยุ่นกว่าการรัน Apache หรือ lighttpd เพราะ mod_cgi
เนื่องจาก uwsgi รันเป็น systemd unit จึงใช้ความสามารถด้าน hardening และ sandboxing ของ systemd ได้ทั้งหมดด้วย
จุดสะดวกของการจัดการ CGI ใน uwsgi ที่ mod_cgi ไม่มี คือสามารถกำหนด interpreter ที่จะใช้กับไฟล์บางรูปแบบได้
cgi = /cgi-bin=/webapps/cgi-bin/src
cgi-allowed-ext = .py
cgi-helper = .py=/webapps/cgi-bin/venv/bin/python3 # all dependencies go here
เวลาไปถึงไบต์แรกอยู่ที่ 250~350ms ซึ่งยอมรับได้สำหรับการใช้งานของเรา
[1]: https://uwsgi-docs.readthedocs.io/en/latest/CGI.html
ช่วงหลังผมใช้ Apache กับ side project หนึ่ง เหตุผลหนึ่งคือ ฟีเจอร์ .htaccess เลยได้คุยเรื่องคล้าย ๆ กัน
วางไฟล์ .htaccess ไว้ที่ไหนก็ได้ แล้ว Apache สามารถอ่านมันเข้ามาเป็นการตั้งค่าเซิร์ฟเวอร์เพิ่มเติมในทุกคำขอ: https://httpd.apache.org/docs/2.4/howto/htaccess.html
เหตุผลใหญ่ที่เคยเลี่ยงสิ่งนี้คือประสิทธิภาพ เพราะต้องเข้าถึงดิสก์เพิ่มในทุกคำขอ และถ้าเป็นไปได้ การใส่ไว้ในไฟล์ตั้งค่าหลักย่อมดีกว่าเสมอ
แต่ตอนนี้เซิร์ฟเวอร์ส่วนใหญ่มี SSD และ Linux ก็น่าจะมี RAM เหลือให้ใช้กับ file system cache
แน่นอนว่า Apache ยังต้อง parse การตั้งค่าทุกคำขอ ไม่ใช่แค่ครั้งเดียว ดังนั้นประสิทธิภาพก็ยังแย่ลงเล็กน้อย
แต่เมื่อดูว่า CPU ของเซิร์ฟเวอร์ส่วนใหญ่ทุกวันนี้แรงขึ้นมากแล้ว สำหรับ use case จำนวนมากก็น่าจะยอมรับได้
side project ยังเป็นเวอร์ชันแรกมาก ๆ แต่ก็ใช้งานอยู่แล้ว: https://github.com/StaticPatch/StaticPatch/tree/main
“ผมไม่ใช่โปรแกรมเมอร์จริง ๆ ผมแค่เอานั่นเอานี่มาต่อกันจนมันทำงาน แล้วก็ไปต่อ โปรแกรมเมอร์ตัวจริงคงบอกว่า ‘โอเค มันทำงานก็จริง แต่มี memory leak อยู่ตรงโน้นตรงนี้นะ ไม่ควรแก้หน่อยเหรอ?’ ผมก็แค่ restart Apache ทุก ๆ 10 requests ก็พอ”
PHP มาไกลมากแล้วหลังจากนั้น แต่ส่วนใหญ่คือกระบวนการแก้ไขความผิดพลาดในช่วงแรก ๆ
“เหตุผลที่ PHP 8 ดีกว่ามากคือมีโค้ดของผมอยู่ในนั้นน้อยลงมาก”
เพราะการเลือกแบบนี้ ทำให้ HTTP request 99.99% ช้าลงจากการอ่านดิสก์ที่ไม่จำเป็น
ผมลองคิดเรื่องนี้มากขึ้นในฐานะส่วนหนึ่งของเวิร์กโฟลว์การทำโปรโตไทป์อย่างรวดเร็ว
ภาษา JIT สมัยใหม่ ถ้าไม่ไปใช้โมเดล FastCGI เวลาเริ่มต้นก็น่าจะถูกครอบงำด้วยการ import เป็นส่วนใหญ่
ผมนึกเรื่องนี้ขึ้นมาตอนเริ่มใช้เว็บเซิร์ฟเวอร์ h2o กับสคริปต์โลคัล ซึ่งสามารถเขียนไฟล์ตั้งค่าด้วย mruby และ FastCGI handler ได้อย่างเรียบร้อยและรวดเร็ว แถมประสิทธิภาพก็เร็วมาก: https://h2o.examp1e.net/configure/fastcgi_directives.html
อีกกรณีที่มีประโยชน์คือเมื่อให้ลูกค้าขยายซอฟต์แวร์โลคัลด้วยโค้ดของตัวเอง
ตัวอย่างเช่น แทนที่จะให้ใช้ MCP เพื่อขยายเครื่องมือ AI ก็อาจให้ implement โครงสร้างคำขอบางแบบเป็น CGI ได้
เริ่มสงสัยว่าตัวบริการ MCP เองจะ implement ด้วย CGI ได้ไหม
เฟรมเวิร์ก MCP อาจเปิดเผยฟังก์ชันในรูปแบบโปรแกรมที่รองรับโหมดการรันทั้งสองแบบก็ได้
คงต้องไปขุดสเปกดูสักหน่อย
สำหรับผม
inetdก็คือ CGI นั่นแหละมันทำให้อินเทอร์เน็ตสนุกขึ้นมาก
ผมเคยโฮสต์เชลล์สคริปต์หลายตัวด้วย inetd เอง และยังมี HTTP server ที่เขียนด้วย Bash ล้วน ๆ ด้วย
VPS เครื่องนั้นหายไปนานแล้ว และตอนนั้นก็ไม่ได้ใช้ version control ด้วย
แล็ปท็อปที่ใช้เขียนมันก็หายไปแล้ว
แต่ก็สนุกจริง ๆ
การ deploy ง่ายพอ ๆ กับ Makefile + scp ส่วนการทดสอบก็เป็น Bash script อีกตัวที่ผสม
netcatหลายตัวกับ grepเป็นช่วงเวลาที่ดีจริง ๆ