- FLAME ห่อบางส่วนของโค้ดแอปพลิเคชันเดิมให้เป็นฟังก์ชัน แล้วรันบนสำเนาแอปชั่วคราว เพื่อให้ขยายแบบยืดหยุ่นในระดับละเอียดได้โดยไม่ต้องย้ายโค้ดไปยังรันไทม์แยก
- แนวทาง FaaS แบบเดิม แม้กับงานอย่างการสร้างภาพขนาดย่อจากวิดีโอ ก็อาจนำไปสู่โครงสร้างที่มีทั้ง HTTP, S3, SQS, API Gateway, encoder/decoder และ workflow orchestration เพิ่มเข้ามา
- flame library ของ Elixir ใช้
FLAME.callเพื่อมอบหมายการรันฟังก์ชันไปยัง remote runner และFLAME.FlyBackendของ Fly.io จะบูต Fly Machine ใหม่ด้วย Docker image เดียวกันแล้วเชื่อมต่อกลับมายังโหนดแม่ได้ภายในราว 3 วินาที - ในการพัฒนาและทดสอบ สามารถใช้
LocalBackendเพื่อรันในรันไทม์เดียวกัน และใน production ปรับ scale-to-zero กับการคงสถานะ hot ช่วงสั้น ๆ ได้ด้วยmin,max,max_concurrency,idle_shutdown_after - FLAME ไม่ได้มีเป้าหมายเพื่อแทนที่ work queue แต่แยก การรับประกันความคงทน ออกจากการรันแบบยืดหยุ่น โดยให้คิวรับผิดชอบ dispatch·commit·retry และให้งานที่ใช้ CPU สูงทำภายใน FLAME call
ปัญหาที่แพตเทิร์น FLAME ตั้งใจแก้
- การขยายอัตโนมัติแบบยืดหยุ่นช่วยลดภาระการดูแลเซิร์ฟเวอร์และสัญญาว่าจะคิดค่าใช้จ่ายตามการใช้งาน แต่เมื่อใช้ FaaS ก็มักมีคิว สตอเรจ glue code และความซับซ้อนของการพัฒนา·ทดสอบ·CI เพิ่มตามมาด้วย
- FLAME ย่อมาจาก Fleeting Lambda Application for Modular Execution เป็นแนวทางที่มองทั้งแอปพลิเคชันเหมือน lambda และนำเฉพาะงานบางโมดูลไปรันบนโครงสร้างพื้นฐานที่เปิดขึ้นมาชั่วคราว
- เป้าหมายสรุปได้ 3 ข้อ
- ลดภาระการดูแลเซิร์ฟเวอร์ด้วย flow การ deploy เดิมอย่าง
fly deploy,git push heroku,kubectl - ขยายแบบ on-demand อย่างละเอียด เฉพาะบางส่วนของโค้ดแอปพลิเคชัน
- ไม่ต้องเขียนแอปใหม่หรือย้ายโค้ดบางส่วนไปยังรันไทม์แบบผูกขาด
- ลดภาระการดูแลเซิร์ฟเวอร์ด้วย flow การ deploy เดิมอย่าง
ย้ายฟังก์ชันเดิมด้วย FLAME.call
- ตัวอย่างคือฟังก์ชัน
generate_thumbnailsในแอป Elixir ที่แปลงวิดีโอที่อัปโหลดเป็นภาพขนาดย่อด้วยffmpeg - ฟังก์ชันเดิมจะสร้างไดเรกทอรีชั่วคราว รัน
ffmpegจากนั้นบันทึกภาพขนาดย่อที่สร้างได้ลงใน durable storage และบันทึก URL ลงฐานข้อมูลด้วยRepo.insert_all - เนื่องจากงาน transcoding วิดีโอที่ใช้ CPU สูงอาจทำให้ทั้งบริการหยุดชะงักใน production จึงห่อเนื้อฟังก์ชันด้วย
FLAME.call(MyApp.FFMpegRunner, fn -> ... end)แทนที่จะย้ายโค้ดทั้งหมดไปเป็น FaaS หรือ microservice FLAME.callรับ ชื่อ runner pool และฟังก์ชัน จากนั้นค้นหาหรือบูตสำเนาใหม่ของทั้งแอปพลิเคชันแล้วรันเฉพาะฟังก์ชันนั้น- ตัวแปรอย่าง
%Video{}struct และintervalที่ฟังก์ชันปิดทับไว้จะถูกส่งไปด้วยโดยอัตโนมัติ - FLAME runner จะเชื่อมต่อกับโหนดแม่หลังบูต รับฟังก์ชันที่จะรัน แล้วส่งผลลัพธ์กลับไปยังผู้เรียก
- ตามการตั้งค่า runner อาจรอรับงานเพิ่ม แล้วค่อยปิดตัวเมื่อ idle หรือจบการทำงานทันที
- ตัวแปรอย่าง
- เพราะทั้งแอปพลิเคชันรวมถึงการเชื่อมต่อฐานข้อมูลถูกรันอยู่ด้วย จึงยังใช้
Repo.insert_allได้ตรง ๆ เหมือนโค้ดเดิม
ความซับซ้อนของ FaaS และความต่างของ FLAME
- FaaS ให้ชิ้นส่วนประกอบสำหรับแก้ปัญหา ขณะที่ FLAME ใกล้เคียงกับแนวทางที่ลดชั้นการสื่อสารแยกออกมาเอง
- ในตัวอย่างภาพขนาดย่อจากวิดีโอ แม้เริ่มจาก AWS Lambda Function URL แบบง่าย ๆ ความซับซ้อนก็เกิดขึ้นอย่างรวดเร็ว
- หากจะสตรีมภาพขนาดย่อกลับมายังแอปผ่าน HTTP ก็ต้องเขียน encoder และ decoder เองทั้งสองฝั่ง
- หากการ transcoding หรืออัปโหลดวิดีโอนานเกิน 15 นาที ก็ต้องแบ่งวิดีโอเป็นชังก์และใช้ Lambda เพิ่ม เพราะ Lambda มี hard timeout
- จึงต้องมีบริการเพิ่มอย่าง workflow orchestration, SQS, S3
- การติดตั้งแบบอิง FaaS มักสร้างจุดที่มีต้นทุนดังนี้
- ใช้ HTTP endpoint, S3, API Gateway เพื่อ trigger Lambda
- เขียน Lambda แบบปรับแต่งสำหรับ transcoding วิดีโอ
- เก็บผลลัพธ์ภาพขนาดย่อไว้ใน SQS
- เขียน SQS consumer ฝั่งแอป
- จัดการวิธีบันทึกลงฐานข้อมูล และส่งอีเวนต์ไปยัง subscriber ที่ยัง active และเชื่อมต่ออยู่กับอินสแตนซ์อื่น
- FLAME เปิดให้ใช้โค้ดภายในแอป ฐานข้อมูลเดิม PubSub และความสามารถของแพลตฟอร์มได้ตามเดิม จึงลดทั้งบริการแยกและสตอเรจสำหรับเก็บผลลัพธ์กลับมา
แบ็กเอนด์ Fly.io และการรันแบบโลคัล
- Elixir flame library คือ implementation ของแพตเทิร์น FLAME โดยมี
LocalBackendและFlyBackendมาให้เป็นค่าเริ่มต้น FLAME.FlyBackendจะบูตสำเนาแอปพลิเคชันบน Machine ใหม่ในโครงสร้างพื้นฐานของ Fly.io และเชื่อมกลับมายังโหนดแม่เพื่อรับงานได้ภายในราว 3 วินาที- Fly.io รันแอปพลิเคชันในรูป Docker image ที่แพ็กไว้แล้ว จึงเพียงร้องขอผ่าน Fly API ให้บูต Machine ใหม่ที่ใช้ image เดียวกับแอปปัจจุบัน
- บนโครงสร้างพื้นฐานของ Fly.io สามารถเริ่ม FLAME runner ใน region เดียวกับโหนดแม่ได้ ช่วยลด latency ระหว่างทั้งสองฝั่ง
FLAME.FlyBackendรวมเอกสารแล้วมีเพียง ไม่ถึง 200 LOC และมี dependency ของไลบรารีแค่ HTTP clientreqตัวเดียว- ในการพัฒนาและทดสอบ LocalBackend จะรันโค้ดดังกล่าวบนรันไทม์เดิมของโน้ตบุ๊กหรือเซิร์ฟเวอร์ CI
การส่งไฟล์และการทำให้การพัฒนา·ทดสอบง่ายขึ้น
- FLAME ช่วยให้ใช้ business logic, การตั้งค่าฐานข้อมูล, PubSub และความสามารถของแพลตฟอร์มซ้ำได้ โดยไม่ต้องเขียนโค้ดไว้นอกแอปพลิเคชัน
- ใน Elixir ด้วยความสามารถด้าน distributed ของ Erlang VM จึงสามารถส่ง file stream จากโหนดแม่ไปยังแอป FLAME ระยะไกลได้
- เปิด file stream ของพาธวิดีโอที่โหนดแม่
- เปิด file stream ของไฟล์ชั่วคราวใน FLAME child แล้วคัดลอก stream จากโหนดแม่
- หลังจากนั้น
ffmpegจะใช้ไฟล์ชั่วคราวใน remote runner เป็นอินพุตเพื่อสร้างภาพขนาดย่อ
- วิธีนี้ทำให้ส่งไฟล์ไปยังเซิร์ฟเวอร์ FLAME ได้โดยไม่ต้องตั้งค่า S3 หรือ HTTP interface แยกต่างหาก
- จึงลดทั้งการ deploy บริการแยก การดูแล endpoint การดึงผลลัพธ์กลับผ่าน S3/SQS และการตั้งค่า dependency สำหรับการพัฒนา·ทดสอบ·CI
FLAME นอกโลก Elixir
- Elixir เหมาะกับโมเดล FLAME เพราะมี process supervision และ distributed messaging แต่ภาษาที่มี concurrency primitive ที่เหมาะสมก็สามารถใช้แพตเทิร์นนี้ได้เช่นกัน
- ตัวอย่าง proof of concept ใน JavaScript คือการรันฟังก์ชันของแอปพลิเคชันบน Fly Machine อื่น: fly-run-this-function-on-another-machine
- flow ทั่วไปของการเรียก FLAME บน JavaScript คือย้ายส่วนที่รันโมดูลไปยังไฟล์ใหม่ แล้วไปรันใน runner pool
- หากอาร์กิวเมนต์ serialize เป็น JSON ได้ flow โดยรวมจะคล้ายตัวอย่าง Elixir คือโค้ดแอปจะไปรันบนอินสแตนซ์ที่เปิดขึ้นมาชั่วคราว
- ไลบรารี FLAME ที่สมบูรณ์ควรจัดการเรื่องต่อไปนี้
- logic สำหรับ scale-up และ scale-down ของ pool แบบยืดหยุ่น
- การจัดการ pool โดยคำนึงถึง hot startup และ cold startup
- การมอนิเตอร์ remote runner เพื่อป้องกัน orphaned resource
- วิธีรักษา deployment ให้เป็นเวอร์ชันล่าสุด
ความสัมพันธ์กับคิวงานเบื้องหลัง
- FLAME สามารถทำงานภายในตัวประมวลผลงานเบื้องหลังได้เช่นกัน แต่มีบางส่วนที่บทบาททับซ้อนกับ work queue
- โดยทั่วไป work queue ใช้เมื่อจำเป็นต้องมี การรับประกันความคงทน และสามารถปรับให้คิวประมวลผลงานได้มากขึ้นตามการเปลี่ยนแปลงของโหลด
- งานที่ต้องการความคงทนกับการรันแบบยืดหยุ่นเป็นคนละประเด็นกัน
- หากใช้คิวเพียงเพื่อ offload การรันงาน ก็ต้องมี glue code สำหรับใส่ข้อมูลลงงาน และส่งผลลัพธ์กลับไปยังผู้เรียกหรืออุปกรณ์ของผู้ใช้
- หากต้องรับประกันความสำเร็จของการสร้างภาพขนาดย่อหลังอัปโหลดวิดีโอ คิวก็อาจรับผิดชอบกลไก dispatch, commit, retry
- ส่วนการ transcoding จริงสามารถรันผ่าน FLAME call ภายในงาน เพื่อแยกความคงทนออกจากการรันแบบขยายตัวได้
- งานที่ไม่ต้องการความคงทน เช่น การพรีวิววิดีโอก่อนบันทึก หรือการรันโมเดล ML เมื่อผู้ใช้ออกจากแอปไปแล้ว อาจไม่เหมาะกับโครงสร้างที่ต้องเขียนงานลง durable storage
runner pool สำหรับการขยายแบบยืดหยุ่น
- implementation ของ FLAME ใน Elixir กำหนด elastic pool ของ runner เพื่อรองรับทั้ง scale-to-zero และการจำกัด concurrency
- ตัวอย่างการตั้งค่าจะเพิ่ม
FLAME.Poolเข้าไปในstart/2ของแอปพลิเคชันmin: 0เพื่ออนุญาต scale-to-zeromax: 10เพื่อบูต runner ได้สูงสุด 10 ตัวmax_concurrency: 5เพื่อรองรับงานffmpegได้ 5 งานต่อ runneridle_shutdown_after: 30_000เพื่อ idle down เมื่อไม่มีงานเรียกใช้นาน 30 วินาที
- เซิร์ฟเวอร์เว็บ Phoenix จะถูกเริ่มแบบมีเงื่อนไขตามการมีอยู่ของ FLAME parent
- บน FLAME runner ที่ไม่รับเว็บทราฟฟิก ไม่จำเป็นต้องเริ่มเว็บเซิร์ฟเวอร์
- ส่วน
MyApp.Repoเช่นฐานข้อมูลยังต้องคงไว้ เพราะใช้ภายใน FLAME runner
- หากกำหนด
min: 1ก็สามารถคงffmpegrunner อย่างน้อยหนึ่งตัวให้อยู่ในสถานะ hot ตั้งแต่เริ่มแอปได้
การวาง process ที่มีสถานะ
- ส่วนที่มีสถานะในแอป Elixir สร้างอยู่บน primitive ของ lightweight process ที่มี message mailbox
FLAME.callและFLAME.castเหมาะกับโค้ดที่ค่อนข้าง stateless ส่วนFLAME.place_childใช้เริ่ม process spec เดิมบน FLAME runner แทนโลคัลFLAME.place_childใช้ได้ในตำแหน่งที่เดิมเรียก interface อย่างTask.Supervisor.start_child,DynamicSupervisor.start_child- ในตัวอย่างการสร้างภาพขนาดย่อระหว่างอัปโหลด LiveView จะส่ง upload chunk ไปยัง process
ThumbnailGeneratorและ process นี้จะสื่อสารกับffmpeg- เมื่อพบ PNG delimiter ใน stdout ของ
ffmpegก็จะส่งข้อความรูปภาพไปยัง process ของ LiveView - LiveView จะรับข้อความใน
handle_infoแล้วเพิ่มรูปใหม่ใน UI
- เมื่อพบ PNG delimiter ใน stdout ของ
- หากเปลี่ยนการเรียก
DynamicSupervisor.start_child(@sup, spec)เดิมเป็นFLAME.place_child(Thumbs.FFMpegRunner, spec)processThumbnailGeneratorก็จะไปรันบน FLAME runner - เนื่องจาก process สามารถส่งข้อความถึงกันได้ไม่ว่าจะอยู่ที่ไหน เมื่อ process จบเพราะอัปโหลดเสร็จหรือแท็บเบราว์เซอร์ปิด เซิร์ฟเวอร์ FLAME ก็จะตรวจจับการสิ้นสุดและ idle down เมื่อไม่มีงานอื่น
การมอนิเตอร์ระยะไกลและการจัดการความล้มเหลว
- โครงสร้างพื้นฐานที่เปิดใช้งานชั่วคราวจำเป็นต้องมี failsafe เพื่อป้องกัน orphaned resource
- เมื่อโหนดแม่เปิด runner ขึ้นมา runner ต้อง idle down ได้เองเมื่อไม่มีงาน และต้องทำ failsafe shutdown หากติดต่อโหนดแม่ไม่ได้อีกต่อไป
- เมื่อมี deployment ใหม่มาแทนโหนดแม่ runner ก็ควรถูกปิดด้วย เพื่อคงการรับประกันว่าในคลัสเตอร์กำลังรันโค้ดชุดเดียวกัน
- ผู้เรียกที่กำลังรอผลลัพธ์จากงานของ runner ต้องคำนึงถึงกรณีที่ runner ปิดตัวลงไม่ว่าด้วยเหตุผลใด
- primitive ที่ Erlang VM มีให้ช่วยทำให้ implementation นี้เรียบง่ายขึ้น
- การมอนิเตอร์และ supervision ของ process ทั้งโลคัลและระยะไกล
- node monitoring เพื่อตรวจจับว่าโหนดขึ้นหรือลง
- flow การ shutdown ที่ควบคุมลำดับการเริ่ม·หยุดแอป เพื่อให้ runner ที่ยังทำงานอยู่มีเวลางานเสร็จระหว่าง deployment ใหม่
- รายละเอียดภายในจะมีบทความแยกตามมา และตอนนี้สามารถดูได้จาก source ของ flame
สถานะปัจจุบันและขั้นต่อไป
- ไลบรารี FLAME ของ Elixir ยังอยู่ในช่วงเริ่มต้น แต่สามารถลองใช้ได้แล้วตอนนี้
- ในอนาคตมีแผนทั้งเทคนิคการเติบโตของ pool ที่ซับซ้อนขึ้น และการวิเคราะห์เชิงลึกของ implementation ฝั่ง Elixir
- ยังสามารถพูดคุยต่อถึงการนำแพตเทิร์น FLAME ไป implement ในภาษาอื่นได้
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
จากการเจอความเจ็บปวดและความซับซ้อนของแอปที่ประกอบด้วย Lambda functions มากกว่า 100 ตัว ตลอด 4 ปีที่ผ่านมา ผมคิดว่าบทความนี้ชี้จุดอ่อนของสถาปัตยกรรม serverless แบบ FaaS ได้ตรงมาก
ตอนเริ่มต้น ข้อเสียพวกนี้มักไม่ค่อยเห็นชัด ตรงกันข้าม ถ้ามีการใช้งานน้อย มันแทบจะฟรีและแทบไม่ต้องบำรุงรักษาเลย ซึ่งเป็นข้อดีที่ชัดเจน
แต่ภายหลัง เมื่อ workflow ของ Lambda พันกันยุ่งและแข็งตัวขึ้นเรื่อย ๆ เพราะการพึ่งพากันไปมา ก็จะเริ่มเสียดายว่าน่าจะเลือกทำเป็น monolith แล้วจ่ายเพิ่มอีกไม่กี่ร้อยดอลลาร์เพื่อจัดการเองไปเลย ทุกวันนี้ถ้าใช้ที่อย่าง fly.io อาจเสียเงินน้อยกว่านั้นด้วยซ้ำ
สงสัยเหมือนกันว่าถ้าไม่ได้ใช้ Elixir จะเป็นอย่างไร
พอเริ่มแย่ ก็อาจถึงเวลาหางานใหม่พอดี โดยเฉพาะถ้าผ่านไปประมาณหนึ่งปีหลังเข้าทำงาน แล้วเริ่มเสียดาย process ที่ตัวเองนำเข้ามาเอง ผมเห็นแบบนี้หลายครั้งกับหัวหน้าแย่ ๆ, ‘unit test’ ห่วย ๆ, Scrum ฯลฯ
แต่ไม่รู้เหมือนกันว่าผู้คนตระหนักชัดเจนไหมว่าสาเหตุของความอึดอัดในที่ทำงานคืออะไร หรือแค่รับรู้แบบคลุมเครือว่า “ถึงเวลาต้องไปแล้ว” เวลาพยายามตั้งชื่อให้สิ่งที่ทำให้อึดอัดก็มักเจอแรงต้านเยอะมาก และหลังจากอ่าน Good to Great ผมก็สิ้นเปลืองอารมณ์กับเรื่องนี้น้อยลงมาก ไม่มีใครอยากพูดว่า “อ้อ นี่คือผลจากการกระทำของฉันเองสินะ”
คนที่สร้าง ระบบแบบรูบ โกลด์เบิร์ก ที่ผมต้องดูแลอยู่จริง ๆ เป็นกลุ่มแรก ๆ ที่ลาออกไป กัปตันของเรือลำนั้นเคยถามเพื่อนร่วมงานว่าเราควร open source engine ของเราดีไหม เพื่อนร่วมงานตอบว่าไม่มีใครอยากใช้ระบบที่ไปประดิษฐ์ล้อขึ้นมาใหม่ ทั้งที่ของเดิมมีอยู่แล้วและดีกว่า คนคนนั้นลาออกเองภายใน 3–4 เดือน
ถ้าเขียนโค้ดแบบ service-oriented ทั่วไปที่ Lambda เรียกกันเองได้ ก็ไม่จำเป็นต้องหั่นแอปพลิเคชันเป็นชิ้นเล็ก ๆ ที่รันสั้น ๆ หลายร้อยชิ้น แต่ถ้าต้องจ่ายเงินแม้แต่เวลาที่รออยู่ด้วย แบบนั้นก็เป็นไปไม่ได้ ถ้า Lambda สามารถหยุดพักการทำงานระหว่างรอ I/O ได้ ปัญหาก็จะคลี่คลาย ดังนั้นผมคิดว่า durable execution อาจเป็นคำตอบ
ช่วงหลายสัปดาห์ที่ผ่านมา ผมกำลังเขียนบทความเพื่อสาธิตเรื่องนี้: https://restate.dev/blog/suspendable-functions-make-lambda-t...
ความสะดวกในการใช้งานบางอย่างที่ได้มาฟรีใน Elixir เช่น function ที่ serialize ตัวแปรที่ capture ไว้ได้ อาจทำได้ไม่ครบทั้งหมด แต่ในภาษาอย่าง JavaScript ก็น่าจะไปได้ราว 90% ด้วยวิธีย้ายส่วน execution ของ module ไปไว้ในไฟล์ใหม่ แทนที่จะห่อด้วย closure
คนที่ implement ไลบรารี FLAME ยังต้องเขียนส่วน pooling, monitoring และ remote communication ด้วย Elixir ได้หลายอย่างมาฟรีในด้าน distributed messaging และ monitoring ส่วนฟีเจอร์ที่เกี่ยวกับการจัดวาง process ก็แทบจะเป็นเรื่องเฉพาะของ Elixir
สุดท้ายเลยย้ายไปเป็น fat Lambda monolith ที่ Lambda ตัวเดียวจัดการหลาย endpoint
พูดอีกอย่างคือ สามารถไปทาง monolith พร้อมกับลดค่า AWS ให้ต่ำที่สุดได้ ไม่ใช่ปัญหาที่ต้องเลือกอย่างใดอย่างหนึ่งเท่านั้น
ช่วงนี้ผมใช้ asp.net อยู่ และแม้แต่แอปขนาดค่อนข้างใหญ่ที่ deploy แบบ ready-to-run พร้อม EF model ที่ optimize แล้ว ก็เริ่มทำงานได้ค่อนข้างเร็ว
ผมเป็นผู้เขียนบทความนี้ ดีใจที่ได้เผยแพร่ในที่สุด และถ้ามีคำถามก็ยินดีตอบ หวังว่าบางคนจะได้รับแรงกระตุ้นมากพอที่จะ implement FLAME pattern ใน JavaScript, Go และภาษาอื่น ๆ
มันมีกลไกสำหรับ reactive scaling อยู่แล้วบน event bus รวมถึงการทำงานแบบ async ผ่าน Future และ coroutine อีกทั้งยังรองรับ data serialization และ distribution ทั่วทั้ง cluster ด้วย มี event bus client สำหรับหลายภาษายอดนิยมด้วย ดังนั้นน่าจะสร้างแอปพลิเคชันที่ผสมหลายภาษาได้
ถ้านำเสนอปัญหาว่าเป็น “ชะตากรรมที่เลวร้ายยิ่งกว่าความตาย” แล้วนำเสนอทางแก้ว่าแสนง่ายและไม่เจ็บปวดจนทำให้คนที่ไม่ทิ้งแนวทางอื่นดูเหมือนคนโง่ บทความแบบนี้ก็ถูกมองเป็น บทความขายของ ล้วน ๆ ได้ง่าย นั่นเป็นวิธีของพ่อค้ายาครอบจักรวาล
ช่วงต้นบทความชี้ปัญหาได้ดีอยู่แล้ว ถ้ามี การเปรียบเทียบอย่างเป็นกลาง ที่ใช้อารมณ์น้อยกว่านี้มาก คนที่สนใจแนวทางที่ดีกว่าน่าจะรู้สึกต่อต้านน้อยลง
เนื้อหาจริงของบทความน่าสนใจ แต่รูปแบบการนำเสนอทำให้รู้สึกถอยห่าง หวังว่าจะรับเป็น feedback เชิงสร้างสรรค์
แล้วก็รู้สึกผิดนิด ๆ ที่จอง ffmpeg.fly.dev ไปก่อน
ส่วนที่บอกว่า “ลองจินตนาการว่า แค่ห่อส่วนใดก็ได้ของโค้ดแอปเดิมด้วย function แล้วมันจะ scale อัตโนมัติ และ code block นั้นจะรันในสำเนาชั่วคราวของแอป” น่าสนใจมาก
ฟังดูเหมือนเอาสิ่งที่ fork ทำมาสร้างสำหรับ serverless งานยอดเยี่ยมมาก
เมื่อหลายปีก่อน ผมเคยใช้บริการที่ทำงานแบบนี้อยู่จริง ๆ PiCloud น่าเสียดายที่ถูก Dropbox ดูดรวมไปแล้ว แต่ก่อนหน้านั้นมันมีโมเดลแบบนี้เป๊ะ ๆ คือกระจายงานไปยัง worker อย่างโปร่งใส เป็นวิธีแพ็กโค้ดแล้วไปรันบน worker
ตัวอย่างอยู่ที่นี่ จะเห็นได้ว่าเป็นโมเดลเดียวกันพอดี: https://github.com/picloud/basic-examples/blob/master/exampl...
ผมไม่เคยใช้ Elixir แต่เคยใช้ Erlang เมื่อหลายสิบปีก่อน และดูเหมือนว่า BEAM โดยพื้นฐานไม่ได้เปลี่ยนไปมากนัก น่าจะเหมาะกับงานแบบนี้มากกว่าเยอะ เพราะเป็นส่วนหนึ่งของแกนหลักในการออกแบบ ถึงอย่างนั้นก็ไม่ใช่อาหารกลางวันฟรีเสียทีเดียว เพราะระหว่างรอ โปรเซสหลักอาจตายได้ ใช่ไหม
เห็นด้วยกับประเด็นหลักทั้งหมด เราเลือกแนวทางอีกแบบที่ https://www.windmill.dev โดยมองหน่วยของ abstraction เป็น ระดับซอร์สโค้ด ไม่ใช่ระดับคอนเทนเนอร์
เรา parse ฟังก์ชัน main กับ import เพื่อดึงอาร์กิวเมนต์และ dependency ออกมา แล้วรันโค้ดตามเดิมใน runtime ที่ต้องการ (TypeScript, Python, Go, Bash) เคล็ดลับสำคัญคือจัดการ cache ให้มีประสิทธิภาพ เพื่อให้ worker ร้อนพร้อมใช้งานอยู่เสมอ ไม่ว่าจะ import อะไรก็ตาม
วิธีนี้ไม่ได้ผสานเข้ากับ codebase แนบแน่นเท่า FLAME แต่กลุ่มผู้ใช้เป้าหมายต่างกัน ผู้ใช้ของเราสร้าง workflow ซับซ้อน งาน cron หรือสคริปต์ครั้งเดียวที่มี UI สร้างให้อัตโนมัติตั้งแต่ต้น
ใน FLAME ดูเหมือนว่า context ทั้งหมดจะถูก snapshot แล้ว restore กลับบน VM ปลายทาง อีกแนวทางคือเพิ่ม syntax สำหรับระบุว่า context ไหนจำเป็นและไม่จำเป็น เพื่อโหลดเฉพาะขั้นต่ำ ตอนนี้เรากำลังสำรวจอยู่เพื่อให้ Windmill ผสานกับ codebase เดิมได้ดีขึ้นและไม่ต้องพึ่งการเรียก HTTP
ในกระบวนการนั้น การส่งเฉพาะ context ที่จำเป็นจะถูกจัดการให้โดยปริยาย ในบทความก็อธิบายไว้แบบนี้: “FLAME.call รับชื่อ runner pool และฟังก์ชัน จากนั้นจะหา หรือบูต สำเนาใหม่ของทั้งแอปพลิเคชัน แล้วรันฟังก์ชันที่นั่น ตัวแปรที่ฟังก์ชันจับไว้เป็น closure เช่น struct %Video{} และ interval จะถูกส่งไปด้วยโดยอัตโนมัติ”
ชอบเป้าหมายของโปรเจกต์นี้มาก หวังจริง ๆ ว่า Windmill จะกลายเป็น ทางเลือกโอเพนซอร์สที่ดีกว่า Retool/Airtable
ชอบตรงที่ “ใน FLAME runner สำหรับการพัฒนาและทดสอบจะรันอยู่บน backend โลคัลเฉย ๆ” serverless ที่มี ประสบการณ์พัฒนาในเครื่อง ดี ๆ นี่เยี่ยมเลย
ถ้า “จากนั้นจะหา หรือบูต สำเนาใหม่ของทั้งแอปพลิเคชัน แล้วฟังก์ชันนั้นไปรันที่นั่น” แปลว่า
Flame.callแต่ละครั้งจะเริ่มโปรเซสของแอปทั้งหมดใหม่ แล้วคัดลอก execution context ใส่เข้าไปหรือเปล่า?ในแง่ scalability เป็นวิธีแก้ที่เรียบง่ายมาก แต่ก็น่าจะมีข้อเสียอยู่
ถ้าเวลาเริ่มแอปเพิ่มขึ้น 10ms ก็เท่ากับว่า
Flame.callทุกจุดในแอปพลิเคชันจะถูกบวกเพิ่ม 10ms และหน่วยความจำน่าจะเหมือนกันเวลาจะใช้ระบบนี้คงต้องคำนึงถึงข้อกังวลเหล่านี้ด้วย
ในสถานการณ์ที่มีโหลด pool จะร้อนพร้อมอยู่แล้ว จึงแทบไม่ต้องจ่ายค่า cold start ต่อไปยังมีการเพิ่มเทคนิคขยาย pool ที่ซับซ้อนขึ้นในไลบรารี Elixir ด้วย เพื่อหลีกเลี่ยงกรณีเจอ runner ที่เต็มแล้วต้อง cold start ตัวใหม่
สำหรับ runner ที่ร้อนอยู่ overhead มีแค่ latency ระหว่าง parent กับ child เท่านั้น ควรอยู่ใน datacenter เดียวกัน จึงน่าจะ 1ms หรือต่ำกว่า
ยอดเยี่ยม รู้สึกเหมือนเป็น เวอร์ชันเฉพาะ Elixir ที่เบามาก ของสิ่งที่เราสร้างไว้ที่ https://www.inngest.com/
ทั้งสองอย่างคล้ายกันตรงที่ห่อโค้ดเดิมด้วยอะไรบางอย่างเพื่อให้ใช้ใน serverless function ได้ และโดยแก่นแล้วเรียกได้ผ่าน RPC ระยะไกล
โค้ดแบบนี้มักรันเป็นลำดับของขั้นตอนเชิง imperative แต่ละขั้นตอนอาจรันแบบ serial หรือ parallel เป็น Lambda เพิ่มเติมได้ แต่ระหว่างขั้นตอนมี state โดยปริยายที่ถูกจับไว้ในตัวแปร ด้วยเหตุนี้ฟังก์ชันจึงกลายเป็น workflow ในโมเดลของ Inngest เราจับ state นี้ไว้แล้วฉีดกลับเข้าไปในฟังก์ชันเพื่อให้มี durability
ในแง่ durability โปรเซสแบบนี้ควรอิงกับ queue ข้อดีของโมเดลนี้คือ queue มีต้นทุนต่ำ ถ้าทำให้ queue ถูกพอ ๆ กับโค้ดหนึ่งบรรทัด ทุกอย่างก็ง่ายขึ้น นักพัฒนาคนไหนก็เขียนโค้ดที่เชื่อถือได้โดยไม่ต้องกังวลเรื่องโครงสร้างพื้นฐาน
monitoring และ observability ก็สำคัญเช่นกัน dead letter queue นั้นแย่มากจริง ๆ และควรจัดการและรันฟังก์ชันหรือขั้นตอนที่ล้มเหลวซ้ำได้
FLAME กับ Inngest ก็มีความต่างกัน Inngest อิง queue, ขับเคลื่อนด้วย event และสามารถให้บริการผ่าน HTTP ได้จากภาษาใดก็ได้ เพราะ Inngest เก็บ state ไว้ภายนอก ต่อให้เขียน workflow ด้วย Elixir แล้วเขียนใหม่เป็น TypeScript และ redeploy ฟังก์ชันที่กำลังรันอยู่ก็สามารถย้ายข้ามภาษา backend แบบเรียลไทม์คล้าย CRIU ได้
ถ้าเป็นแนว event-driven ก็ทำ flow control ได้ด้วย ทั้ง debounce, batch, throttling ไปจนถึง fan-out จัดการได้ใน runtime หรือภาษาใดก็ได้ ตัวอย่างเช่น แอป Elixir ตัวหนึ่งบน Fly ส่ง event เพื่อให้รันฟังก์ชันบน TypeScript + Lambda ได้
รอดูว่า FLAME จะไปทางไหน คิดว่ามีบางส่วนของเป้าหมายที่คล้ายกัน
ใน Elixir ถ้าต้องการ durability, retry และ workflow ปกติจะใช้ Oban และที่นี่ก็คงจะใช้แบบนั้นต่อไป งาน Oban จะเรียก FLAME เพื่อให้จัดการการรันแบบยืดหยุ่น
นี่เป็นหนึ่งในเหตุผลที่ผมไม่ชอบการใช้ตัวพิมพ์ใหญ่แบบชื่อเรื่องสไตล์อเมริกันที่ HN บังคับใช้จริง ๆ Serverless ดูเหมือนหมายถึงการคิดใหม่เกี่ยวกับบริษัท serverless.com ที่ขึ้นต้นด้วย S ตัวใหญ่ ไม่ใช่การคิดใหม่เกี่ยวกับหลักการที่เป็น serverless ตัว s เล็ก
อีกอย่างคือ อยากให้มีใครสักคนช่วยคิดใหม่เรื่อง Serverless จริง ๆ
ไม่แน่ใจว่าเคยดู https://sst.dev/ หรือยัง แต่ดูเหมือนพวกเขาทำสิ่งนั้นพอดี ตัวอย่างเช่น มี Live Lambda Development ทำให้การพัฒนาแบบ โลคัล ง่ายขึ้นมาก โดยลดวงจรป้อนกลับลงอย่างมาก ไม่ต้องอัปโหลดโค้ดขึ้นคลาวด์แล้วรอ deploy
ไอเดียค่อนข้างเจ๋ง และ API ก็ยอดเยี่ยม
ส่วนที่ว่า “งานที่ถูกจำกัดด้วย CPU อย่างการแปลงรหัสวิดีโอ อาจทำให้บริการทั้งระบบในโปรดักชันหยุดชะงักได้อย่างรวดเร็ว” นี่ แค่ทำ การขยายอัตโนมัติตาม CPU ไม่ได้เหรอ?
เพื่อจัดการงานเฉพาะบางงานที่ร้อนจัด คุณต้องขยายทั้งแอปพลิเคชันรวมถึงเว็บเซิร์ฟเวอร์ไปด้วย สิ่งที่เราต้องการ และเป็นเหตุผลที่คนมองหา FaaS คือ การขยายแบบยืดหยุ่น ในระดับละเอียด ไอเดียตรงนี้คือทำให้ขยายในระดับละเอียดแบบนั้นกับโค้ดแอปเดิมได้ แทนที่จะกดปุ่มขยายเว็บเซิร์ฟเวอร์หรือ worker รัว ๆ แล้วหวังว่ามันจะออกมาดี
หรืออาจต้องใช้ GPU ก็ได้
บริการหลักอาจใช้เซิร์ฟเวอร์ 1–2 เครื่องก็พอ แต่สำหรับงานที่เกิดขึ้นราววันละครั้ง อาจจำเป็นต้องขยายเป็นหลายสิบ หลายร้อย หรือหลายพันเครื่องเมื่อจำเป็น