จากการอ่านการสรุปเรื่อง สิ่งที่น่าสนใจจากเรื่อง Goodbye Microservices จาก 100+ เหลือ 1 service
มีหลายสิ่งอย่างที่น่าสนใจเกี่ยวกับการตัดสินใจ
ในการเลือกวิธีการแก้ไขปัญหา
ว่าเข้าใชเหตุผลอะไรในการตัดสินใจ
ซึ่งแต่ที่ละการตัดสินใจ มันมีปัจจัยเยอะมาก ๆ (needs and constraints)
ทำให้วิธีการเดียวกัน เอาไปใช้แบบ copy-and-paste ไม่ได้
กับอีกปัญหาหนึ่งของอีกทีมหรืออีกบริษัท
ดังนั้นถ้าเราเจอปัญหาลักษณะนี้ เราจะตัดสินใจอย่างไรกันบ้าง ?
ตรงนี้สำคัญกว่ามาก ๆ
เริ่มต้นของทุกระบบคือ Simple architecture
ในการจะ integrate กับระบบต่าง ๆ
คงไม่อยากให้ระบบของเราผูกมัดกับระบบข้างนอก หรือ 3-party มากนัก (loose coupling)
จึงมักจะมีการนำ queue/worker มาใช้งานเสมอ
โดยมีทั้งฝั่งขาเข้า และ ขาออก ด้วย
แสดงขาออกดังรูป

ซึ่งช่วยลดปัญหาเรื่องของการผูกมัดลงไป
แต่ปัญหาต่อมาคือ การรวมศูนย์ของการทำงานของ Destination worker
ต้องส่งข้อมูลไปยังปลายทางหลายที่ แถมเรียงลำดับกันอีก !!
โดยแต่ละที่มีปัญหาที่แตกต่างกันไป ทำให้ส่งไม่ได้
จึงต้องแก้ไขด้วยการ retry ของ message หรือ event นั้นใหม่ (retry events)
แถมจากข้อมูลพบว่า การ retry message/event มากกว่า ข้อมูลที่ส่งได้อีก !!
ในการโครงสร้างแบบนี้
ถ้ามีการทำงานที่ช้าใน event/message หนึ่ง ๆ แล้วจะส่งผลแบบลูกโซ่นั่นเอง
ถึงแม้ว่าจะ scale worker แล้ว
ก็ไม่ได้ช่วยให้รองรับได้มากเท่าไร และไม่ตอบโจทย์
มาถึงตรงนี้น่าสนใจมาก ๆ ถ้าเป็นเราเอง จะเลือกแนวทางการแก้ไขปัญหาอย่างไร ?
เป็น Architectural Kata ที่น่าสนใจ => How to set up and run your own Architectural Katas
ว่าเราจะคิด หรือ เลือกวิธีการใดบ้าง มาแก้ไขปัญหานี้
เช่น
- เปลี่ยนการทำงานของ worker ไป จาก sequeucial ไปเป็น parallel
- แยก worker ตาม target หรือ destination ไปเลย
- แยก queue ตามแต่ละ target หรือ destination
- แยก process ของการ retry ไปอีก queue หรือ อีก worker
จะสังเกตได้ว่า เราสามารถคิดได้หลายแนวทางมาก ๆ
แต่นี่เป็นเพียงของคนภายนอก
ที่ไม่ได้มีความรู้และเข้าใจในราายละเอียด ในข้อจำกัด และ สภาพของทีม
รวมทั้งความรู้และประสบการณ์ ทั้งดีและไม่ดี
ซึ่งมีส่วนต่อการตัดสินใจทั้งหมด
จากบทความต้นทาง ทีมพัฒนาเลือกตาม business condition
คือต้องส่งข้อมูลไปยังปลายทางให้เร็วที่สุด หรือ near-realtime กันเลย
ดังนั้นการรอ หรือ ช้า เป็นสิ่งที่ยอมรับไม่ได้
ดังนั้นแนวทางที่เลือกสำหรับการแก้ไขปัญหาคอขวดคือ
- แยก queue ตาม target หรือ destination
- แยก worker ตาม queue
- สร้าง router มาเพิ่ม เพื่อแยกว่าจะส่ง request ไปยัง queue อะไรบ้าง (1-to-many)
แสดงดังรูป

ทำการเพิ่มมาอีก 1 process หรือ อีก 1 hop ก็ว่ากันไป
ผลที่ได้คือ แยกการส่งของแต่ละ target หรือ destination
ทำให้ปัญหาที่เกิดขึ้นจาก target หรือ destination หนึ่ง จะไม่กระทบส่วนอื่น ๆ แล้ว
มาถึงตรงนี้น่าจะคุ้น ๆ ว่ามันคือ Pub/Sub pattern นั่นเอง
ปัญหาต่อมาที่เจอคือ Destination นั้นมีรูปแบบของ request ที่ส่งไปแตกต่างกันมาก
เนื่องจาก destination เริ่มมากขึ้น จาก 3 ไปเป็น 50 !!
ความหลากกลายของการ integrate หรือติดต่อสื่อสารมากขึ้น
ดังนั้นจาก code เดิมคือ worker ที่มี single repository
สำหรับทำการแปลง request ไปยังรูปแบบของแต่ละ Destination
ส่งผลให้เมื่อทำการแก้ไขแล้ว กระทบส่วนอื่น ๆ อีก (Side-effect from change)
ยิ่งกว่านั้น test ตรงหนึ่งพัง พังกันไปหมดเลย !!
เปลี่ยนเรื่องหนึ่ง ๆ ไม่น่าจะกระทบที่อื่นหรือไม่ ?
เป็นปัญหาลูกโซ่อีกแล้ว
มาถึงตรงนี้น่าสนใจอีกแล้ว ถ้าเป็นเราเอง จะเลือกแนวทางการแก้ไขปัญหาอย่างไร ?
ปัญหาที่เกิดจากการเปลี่ยนแปลงทั้งการทำงานหลัก และ การทดสอบ
เราจะเลือกวิธีการแก้ไขอย่างไรบ้าง ? เช่น
- ทำการ test เพิ่ม ทั้ง manual และ automated เพื่อสร้างความมั่นใจให้กับระบบ
- จัดการ code ให้เป็น monular ที่ดี ไม่ให้เรียกใช้งานแบบจ้ามไปข้ามมา เพื่อลดผลกระทบ
- แยก repo ของแต่ละ Destination ไปเลย
- แยก repo และ service กันไปเลย
จากบทความต้นทาง ทีมพัฒนาเลือกแยก repo ตาม Destination (1 repo == 1 service)
ช่วยแก้ไขปัญหาผลกระทบการแก้ไข code ของแต่ละ Destination
ช่วยให้ทีมพัฒนาและทดสอบได้ง่ายและสะดวกขึ้นมาก
แต่ผลที่ตามมาคือ จำนวน repo มากขึ้น
จาก 1 repo มาเป็น 50 repo
ซึ่งแน่นอนว่ามี code ที่ให้งานร่วมกันแน่นอน
จึงสร้าง shared library ขึ้นมา เพื่อให้ทุก repo นำไปใช้งาน (re-use)
และทำให้สร้าง destination ใหม่ ง่ายและเร็วขึ้นอย่างมาก (improve development productivity)
จาก shared library นี่เอง ก่อนให้เกิดปัญหา คือ
ถ้าทำการเปลี่ยนแปลงแล้ว
จะกระทบกับทุก ๆ service หรือ repo ทันที !!
ซึ่งใช้เวลาและคนเยอะมาก ๆ ในการแก้ไขกระทบ
ทั้ง code และ test
รวมทั้งต้อง re-deploy ทุก ๆ service หรือส่วนที่เกี่ยวข้องใหม่ทั้งหมด
หนักกว่านั้น มีการกำหนด version ของ shared library ที่ใช้งานด้วย
พบว่าแต่ละ service/repo ใช้ต่าง version กันอีก
ทั้ง ๆ ที่ใช้ destination เดียวกัน !!
ยังไม่พอ ในการ scaling service ก็มีปัญหา
เนื่องจากแต่ละ service มีรูปแบบของ load ที่ต่างกัน
ต้องจัดการอย่างเหมาะสม ตาม CPU และ Memory usage
โดยมีส่วนของ Auto-scaling มาดูแลส่วนนี้
เมื่อเวลาผ่านไป พบว่าเมื่อมี destination เพิ่มเข้ามาเรื่อย ๆ อย่างต่อเนื่อง ปัญหาก็มากขึ้น
ทั้งจำนวน repo
ทั้งจำนวน service
ทั้งจำนวน queue
ทั้ง operation ต่าง ๆ
จึงเป็นเหตุผลว่า กลับมารวมกันเป็น Single repo หรือ Monolith กันดีกว่านั่นเอง
แต่ไม่ได้รวม code ตรง ๆ นะ ใช้ Monorepo
และได้ตกลงกันว่า shared library หรือ dependency ที่มีอยู่
จะใช้ version เดียวกันในทุก ๆ destination
ในส่วนของการทดสอบก็ปรับปรุงเช่นกัน
แยกออกไปตาม destination ไม่ให้กระทบกัน
มีการ record request ที่ส่งไปยัง destination เอาไว้
เพื่อใช้ในการทดสอบ ช่วยลดเวลาการทดสอบไปได้เยอะมาก
เครื่องมือที่ใช้คือ yakbak
และ request-response เหล่านี้จะถูกบันทึก และ checkin ไว้ใน repo เสมอ
ในส่วนของ Queue ก็ไม่ได้เอาทิ้งไป
แต่ได้สร้างเป็น single service สำหรับจัดการเรื่องของ queue แทน
ช่วยลดปัญหาต่าง ๆ ลงไป

อ่านมาถึงตรงนี้ก็แปลกใจว่า ทำไมเพิ่งมาตกลงและปรับปรุงกัน !!
มันคือประสบการณ์ล้วน ๆ
แต่อย่างที่บอก เราไม่มีรายละเอียดอะไรเลย
อ่านและศึกษา เรียนรู้จากความผิดพลาด
ลองดูว่า ถ้าเราอยู่ในสถานการณ์ หรือ ปัญหารูปแบบนี้
แล้วใช้เงื่อนไข หรือ ข้อจำกัดขององค์กรของเรา
เราจะตัดสินใจกันอย่างไร ?
สุดท้าย ทุก ๆ การตัดสินใจ อย่าลืมเขียน Architectual Decision Record ไว้ด้วยเสมอ
Reference Websites