Stability Patterns (Pattern เพื่อความมั่นคงของระบบ)

มี pattern ไม่กี่แบบที่เราสามารถใช้เพื่อให้แน่ใจว่าถ้ามีอะไรผิดพลาดขึ้นจริง มันจะไม่ก่อให้เกิดผลกระทบลูกโซ่ที่เลวร้าย สิ่งสำคัญคือคุณต้องเข้าใจแนวคิดเหล่านี้ และควรพิจารณาอย่างจริงจังในการนำมาใช้ในระบบของคุณ เพื่อให้แน่ใจว่าพลเมืองที่ไม่ดีตัวเดียวจะไม่ทำให้ทั้งโลกพังทลายลงมารอบตัวคุณ อีกสักครู่เราจะดูมาตรการความปลอดภัยสำคัญๆ ที่คุณควรพิจารณา แต่ก่อนอื่น ผมอยาก จะเล่าเรื่องสั้นๆ เพื่อแสดงให้เห็นว่าสิ่งแบบไหนที่อาจผิดพลาดได้

หลายปีก่อน ผมเป็น technical lead ให้กับโปรเจกต์ของ AdvertCorp (ชื่อบริษัทและรายละเอียดถูกเปลี่ยนไปเพื่อปกป้องผู้บริสุทธิ์!) ซึ่งให้บริการโฆษณาย่อยออนไลน์ผ่านเว็บไซต์ที่ได้รับความนิยมมาก ตัวเว็บไซต์เองรองรับปริมาณการใช้งานค่อนข้างสูง และสร้างรายได้ก้อนโตให้กับธุรกิจ โปรเจกต์ที่ผมทำอยู่มีหน้าที่รวมบริการเดิมหลายตัวที่ใช้ส่งมอบฟังก์ชันการทำงานคล้ายกันสำหรับโฆษณาประเภทต่างๆ เข้าด้วยกัน ฟังก์ชันการทำงานเดิมสำหรับโฆษณาประเภทต่างๆ กำลังค่อยๆ ถูกย้ายเข้าสู่ระบบใหม่ที่เรากำลังสร้าง โดยโฆษณาบางประเภทยังคงถูกให้บริการจากระบบเก่าอยู่ เพื่อให้การเปลี่ยนผ่านนี้โปร่งใสต่อลูกค้าปลายทาง เราสกัดกั้นการเรียกทั้งหมดไปยังโฆษณาประเภทต่างๆ ในระบบใหม่ของเรา และเบี่ยงเบนไปยังระบบเก่าเมื่อจำเป็น ตามที่แสดงใน Figure 12-1 นี่ จริงๆ แล้วเป็นตัวอย่างของ strangler fig pattern ซึ่งเราพูดถึงสั้นๆ ใน "Useful Decompositional Patterns"

A stranger fig pattern being used to direct calls to older legacy systems

Figure 12-1. การใช้ strangler fig pattern เพื่อส่งการเรียกไปยังระบบ legacy เดิม

เราเพิ่งย้ายผลิตภัณฑ์ที่มีปริมาณการใช้งานสูงสุดและทำรายได้มากที่สุดไปยังระบบใหม่ แต่โฆษณาส่วนใหญ่ที่เหลือยังคงถูกให้บริการโดยแอปพลิเคชันเก่าจำนวนหนึ่ง ทั้งในแง่จำนวนการค้นหาและเงินที่แอปพลิเคชันเหล่านี้ทำได้ มี long tail ที่ยาวมาก—แอปพลิเคชันเก่าหลายตัวได้รับปริมาณการใช้งานน้อยและสร้างรายได้น้อยมาก ระบบใหม่ทำงานมาระยะหนึ่งแล้วและทำงานได้ดีมาก รับมือกับโหลดที่ไม่น้อยเลย ตอนนั้นเราน่าจะรับมืออยู่ที่ประมาณ 6,000–7,000 request ต่อวินาทีในช่วง peak และแม้ว่าส่วนใหญ่จะถูก cache ไว้อย่างหนักด้วย reverse proxy ที่อยู่หน้าเซิร์ฟเวอร์แอปพลิเคชันของเรา แต่การค้นหาสินค้า (ส่วนที่สำคัญที่สุดของไซต์) ส่วนใหญ่ไม่ได้ถูก cache และต้อง round trip ไปยังเซิร์ฟเวอร์เต็มรูปแบบ

เช้าวันหนึ่ง ก่อนที่เราจะเข้าสู่ช่วง peak ตอนกลางวันตามปกติ ระบบเริ่มทำงานช้าลง แล้วก็เริ่มล่ม เรามีการ monitoring ในระดับหนึ่งกับแอปพลิเคชันหลักตัวใหม่ของเรา เพียงพอที่จะบอกเราว่าแต่ละ node ของแอปพลิเคชันเรากำลังพุ่งไปที่ CPU 100% ซึ่งสูงกว่าระดับปกติแม้ในช่วง peak มาก ในเวลาไม่นาน ทั้งไซต์ก็ล่ม

เราสามารถตามหาต้นตอของปัญหาและนำไซต์กลับมาใช้งานได้ ปรากฏว่าเป็นระบบโฆษณา downstream ตัวหนึ่ง ซึ่งเพื่อประโยชน์ของ case study ที่ไม่เปิดเผยตัวตนนี้เราจะเรียกว่ารับผิดชอบโฆษณาที่เกี่ยวกับผักกาดหัวเทอร์นิป (turnip) ระบบโฆษณา turnip ซึ่งเป็นหนึ่งในบริการที่เก่าแก่ที่สุดและได้รับการดูแลรักษาน้อยที่สุด เริ่มตอบสนองช้ามาก การตอบสนองช้ามากเป็นหนึ่งใน failure mode ที่แย่ที่สุดที่คุณจะเจอได้ ถ้าระบบไม่ตอบสนองเลย คุณจะรู้ตัวได้เร็ว แต่เมื่อมันแค่ ช้า คุณต้องรออยู่พักหนึ่งก่อนจะยอมแพ้—กระบวนการรอนั้นสามารถทำให้ทั้งระบบช้าลง ก่อให้เกิด resource contention และตามที่เกิดขึ้นในกรณีของเรา ทำให้เกิด cascading failure ไม่ว่าสาเหตุของความล้มเหลวจะเป็นอะไรก็ตาม เราได้สร้างระบบที่เปราะบางต่อปัญหา downstream ที่ลุกลามจนทำให้ทั้งระบบล่ม บริการ downstream ที่เราแทบไม่มีอำนาจควบคุมสามารถทำให้ทั้งระบบของเราล่มได้

ขณะที่ทีมหนึ่งดูปัญหาของระบบ turnip พวกเราที่เหลือเริ่มดูว่าเกิดอะไรผิดพลาดขึ้นในแอปพลิเคชันของเรา เราพบปัญหาไม่กี่อย่าง ซึ่งแสดงไว้ใน Figure 12-2 เราใช้ HTTP connection pool เพื่อจัดการการเชื่อมต่อ downstream ของเรา thread ใน pool มีการตั้งค่า time-out สำหรับระยะเวลาที่จะรอตอนเรียก downstream HTTP ซึ่งเป็นเรื่องดี ปัญหาคือ worker ทุกตัวใช้เวลานานกว่าจะ time out เพราะบริการ downstream ที่ช้า ขณะที่พวกมันกำลังรอ request เพิ่มเติมก็เข้ามาขอ worker thread จาก pool เมื่อไม่มี worker ว่าง request เหล่านี้ก็ค้างเอง ปรากฏว่า connection pool library ที่เราใช้มี time-out สำหรับการรอ worker อยู่จริง แต่มันถูก ปิดไว้เป็นค่าเริ่มต้น ! สิ่งนี้ทำให้เกิดการสะสมของ thread ที่ถูกบล็อกจำนวนมาก แอปพลิเคชันของเราปกติมีการเชื่อมต่อพร้อมกัน 40 การเชื่อมต่อ ในเวลาห้านาที สถานการณ์นี้ทำให้เราพุ่งไปที่ประมาณ 800 การเชื่อมต่อ จนทำให้ระบบล่ม

An outline of the issues that caused the outage

Figure 12-2. ภาพรวมของปัญหาที่ทำให้เกิด outage

ที่แย่ไปกว่านั้นคือบริการ downstream ที่เรากำลังเรียกใช้นั้นเป็นตัวแทนของฟังก์ชันการทำงานที่มีลูกค้าของเราใช้งานน้อยกว่า 5% และสร้างรายได้น้อยกว่านั้นอีก เมื่อคิดให้ลึกลงไป เราค้นพบด้วยวิธีที่ยากลำบากว่าระบบที่แค่ทำงานช้านั้น ยาก กว่ามากในการรับมือ เมื่อเทียบกับระบบที่ล้มเหลวอย่างรวดเร็ว ในระบบกระจาย latency นั้นฆ่าคุณได้

แม้ว่า เรา จะตั้งค่า time-out บน pool ไว้อย่างถูกต้อง เราก็ยังใช้ HTTP connection pool ตัวเดียวร่วมกันสำหรับ outbound request ทั้งหมด ซึ่งหมายความว่าบริการ downstream ที่ช้าเพียงตัวเดียวสามารถใช้ worker ที่มีอยู่จนหมดได้ด้วยตัวเอง แม้ว่าทุกอย่างอื่นจะปกติดีก็ตาม สุดท้าย เป็นที่ชัดเจนจากการ time-out และ error ที่เกิดขึ้นบ่อยครั้งว่าบริการ downstream ที่ว่านี้ไม่ปกติ แต่ถึงอย่างนั้นเราก็ยังคงส่ง traffic ไปทางนั้นต่อไป ในสถานการณ์ของเรา นี่หมายความว่าเรากำลังทำให้สถานการณ์ที่แย่อยู่แล้วแย่ลงไปอีก เพราะบริการ downstream ไม่มีโอกาสฟื้นตัวเลย สุดท้ายเราได้แก้ไขสามอย่างเพื่อป้องกันไม่ให้เกิดเหตุการณ์แบบนี้อีก: ตั้งค่า time-out ให้ถูกต้อง ใช้ bulkhead เพื่อแยก connection pool ต่างๆ ออกจากกัน และใช้ circuit breaker เพื่อหลีกเลี่ยงการส่ง call ไปยังระบบที่ไม่ปกติตั้งแต่แรก

Time-Outs

Time-outs เป็นสิ่งที่ถูกมองข้ามได้ง่าย แต่ในระบบกระจายการตั้งค่าให้ถูกต้องเป็นเรื่องสำคัญ ผมควรรอนานแค่ไหนก่อนที่จะยอมแพ้กับการเรียก downstream? ถ้าคุณรอนานเกินไปก่อนตัดสินใจว่า call นั้นล้มเหลว คุณสามารถทำให้ทั้งระบบช้าลงได้ Time out เร็วเกินไป คุณก็จะถือว่า call ที่อาจจะสำเร็จนั้นล้มเหลว ถ้าไม่มี time-out เลย บริการ downstream ที่ล่มก็อาจทำให้ทั้งระบบของคุณค้างได้

ในกรณีของ AdvertCorp เรามีปัญหาเกี่ยวกับ time-out สองอย่าง อย่างแรกคือเราไม่มี time-out บน HTTP request pool หมายความว่าเมื่อขอ worker เพื่อทำ downstream HTTP request thread ของ request จะบล็อกตลอดไปจนกว่าจะมี worker ว่าง อย่างที่สองคือ เมื่อในที่สุดเรามี HTTP worker ว่างสำหรับเรียกระบบโฆษณา turnip เรากลับรอนานเกินไปก่อนที่จะยอมแพ้กับ call นั้น ดังนั้นตามที่ Figure 12-3 แสดง เราจำเป็นต้องเพิ่ม time-out ใหม่หนึ่งตัวและเปลี่ยนตัวที่มีอยู่แล้ว

Changing timeouts on AdvertCorp

Figure 12-3. การเปลี่ยน time-out ในระบบ AdvertCorp

Time-out สำหรับ downstream HTTP request เดิมถูกตั้งไว้ที่ 30 วินาที—ดังนั้นเราจะรอ response จากระบบ turnip 30 วินาทีก่อนจะยอมแพ้ ปัญหาคือในบริบทที่กว้างขึ้นที่ call นี้ถูกเรียก การรอนานขนาดนั้นไม่สมเหตุสมผลเลย โฆษณาที่เกี่ยวกับ turnip ถูกเรียกขึ้นเพราะผู้ใช้ของเรากำลังดูเว็บไซต์ผ่านเบราว์เซอร์ แม้แต่ในตอนที่เหตุการณ์นี้เกิดขึ้น ก็ไม่มีใครรอ 30 วินาทีให้หน้าเว็บโหลด ลองคิดดูว่าจะเกิดอะไรขึ้นถ้าหน้าเว็บไม่โหลดหลังจากผ่านไป 5, 10 หรือ 15 วินาที คุณจะทำอย่างไร? คุณก็รีเฟรชหน้าเว็บ ดังนั้นเรากำลังรอระบบโฆษณา turnip ตอบกลับ 30 วินาที แต่ก่อนหน้านั้นมาก request เดิมก็ไม่ valid แล้วเพราะผู้ใช้เพิ่งรีเฟรช ทำให้เกิด inbound request ใหม่เพิ่มขึ้น ซึ่งก็ทำให้เกิด request ใหม่อีกครั้งไปยังระบบโฆษณา วนเป็นเช่นนี้เรื่อยไป

เมื่อดูพฤติกรรมปกติของระบบโฆษณา turnip เราพบว่าปกติแล้วเราคาดว่าจะได้ response ในเวลาน้อยกว่าหนึ่งวินาทีมาก ดังนั้นการรอ 30 วินาทีจึงมากเกินไป ยิ่งไปกว่านั้น เรามีเป้าหมายที่จะ render หน้าเว็บให้ผู้ใช้ภายใน 4–6 วินาที จากข้อมูลนี้ เราจึงตั้งค่า time-out ให้เข้มงวดขึ้นมาก โดยตั้งเป็น 1 วินาที เรายังตั้ง time-out 1 วินาทีสำหรับการรอ HTTP worker ให้ ว่าง ด้วย ซึ่งหมายความว่าในกรณีเลวร้ายที่สุด เราจะรอข้อมูลจากระบบ turnip ประมาณ 2 วินาที

Tip

Time-out นั้นมีประโยชน์อย่างมาก ตั้ง time-out ให้กับทุก call ที่ออกนอก process และเลือก time-out เริ่มต้นที่เหมาะสมสำหรับทุกอย่าง บันทึก log เมื่อเกิด time-out ดูว่าเกิดอะไรขึ้น และปรับเปลี่ยนตามความเหมาะสม ดูเวลา response ปกติที่ "healthy" ของบริการ downstream ของคุณ และใช้ข้อมูลนั้นเป็นแนวทางในการตั้งค่า threshold ของ time-out

การตั้ง time-out สำหรับการเรียกบริการเดียวอาจไม่เพียงพอ จะเกิดอะไรขึ้นถ้า time-out นี้เกิดขึ้นในฐานะส่วนหนึ่งของชุดปฏิบัติการที่กว้างขึ้น ที่คุณอาจต้องการยอมแพ้ก่อนที่ time-out จะเกิดขึ้นด้วยซ้ำ? ในกรณีของ AdvertCorp เช่น ไม่มีประโยชน์เลยที่จะรอราคา turnip ล่าสุด ถ้ามีโอกาสสูงที่ผู้ใช้ได้ยอมแพ้ไปแล้ว ในสถานการณ์เช่นนี้ การมี time-out สำหรับปฏิบัติการโดยรวม และยอมแพ้ถ้า time-out นี้เกินก็สมเหตุสมผล เพื่อให้สิ่งนี้ทำงานได้ เวลาที่เหลืออยู่ของปฏิบัติการนั้นๆ จะต้องถูกส่งต่อไปยัง downstream ด้วย ตัวอย่างเช่น ถ้าปฏิบัติการโดยรวมในการ render หน้าเว็บต้องเสร็จภายใน 1,000 มิลลิวินาที และเมื่อเราเรียก downstream turnip ad service ผ่านไปแล้ว 300 มิลลิวินาที เราก็ต้องมั่นใจว่าจะรอไม่เกิน 700 มิลลิวินาทีสำหรับ call ที่เหลือให้เสร็จสิ้น

Warning

อย่าคิดแค่ time-out สำหรับการเรียกบริการเดียวเท่านั้น จงคิดถึง time-out สำหรับปฏิบัติการโดยรวมด้วย และยกเลิกปฏิบัติการถ้า time-out budget โดยรวมนี้เกิน

Retries

ปัญหาบางอย่างกับ downstream call เป็นเพียงชั่วคราว packet อาจหายไป หรือ gateway อาจมีโหลดพุ่งขึ้นแปลกๆ ทำให้เกิด time-out บ่อยครั้ง การ retry call นั้นสมเหตุสมผลมาก กลับมาที่เรื่องที่เราเพิ่งพูดถึง คุณเคยรีเฟรชหน้าเว็บที่ไม่โหลดกี่ครั้งแล้ว แล้วพบว่าครั้งที่สองใช้งานได้ปกติ? นั่นคือ retry ในทางปฏิบัติ

การพิจารณาว่าความล้มเหลวของ downstream call แบบไหนควร retry นั้นมีประโยชน์ ถ้าใช้ protocol อย่าง HTTP เช่น คุณอาจได้ข้อมูลที่เป็นประโยชน์กลับมาใน response code ที่ช่วยให้คุณตัดสินใจได้ว่าควร retry หรือไม่ ถ้าคุณได้ 404 Not Found กลับมา การ retry มักไม่ใช่ไอเดียที่มีประโยชน์ ในทางกลับกัน 503 Service Unavailable หรือ 504 Gateway Time-out อาจถือเป็น error ชั่วคราวและสมควรที่จะ retry

คุณมักจะต้องมี delay ก่อนที่จะ retry ถ้า time-out หรือ error เดิมเกิดจากการที่ microservice downstream รับโหลดมากเกินไป การส่ง request เพิ่มเข้าไปอีกอาจไม่ใช่ไอเดียที่ดี

ถ้าคุณจะ retry คุณต้องนำสิ่งนี้มาพิจารณาเมื่อตั้ง threshold ของ time-out ถ้า time-out threshold สำหรับ downstream call ถูกตั้งไว้ที่ 500 มิลลิวินาที แต่คุณอนุญาตให้ retry ได้สูงสุดสามครั้งโดยเว้นหนึ่งวินาทีระหว่างแต่ละครั้ง คุณอาจต้องรอนานถึง 3.5 วินาทีก่อนจะยอมแพ้ ตามที่กล่าวไว้ก่อนหน้า การมี budget สำหรับระยะเวลาที่ปฏิบัติการหนึ่งๆ ได้รับอนุญาตให้ใช้เป็นไอเดียที่มีประโยชน์—คุณอาจไม่ตัดสินใจทำ retry ครั้งที่สาม (หรือแม้แต่ครั้งที่สอง) ถ้าคุณเกิน time-out budget โดยรวมไปแล้ว ในทางกลับกัน ถ้านี่เป็นส่วนหนึ่งของปฏิบัติการที่ไม่ได้เผชิญหน้าผู้ใช้โดยตรง การรอนานขึ้นเพื่อให้งานเสร็จอาจยอมรับได้ทั้งหมด

Bulkheads

Release It! , 4 Michael Nygard แนะนำแนวคิดของ bulkhead เป็นวิธีแยกตัวเองออกจากความล้มเหลว ในการต่อเรือ bulkhead เป็นส่วนของเรือที่สามารถปิดกั้นเพื่อปกป้องส่วนที่เหลือของเรือ ดังนั้นถ้าเรือรั่ว คุณสามารถปิดประตู bulkhead ได้ คุณเสียส่วนหนึ่งของเรือไป แต่ส่วนที่เหลือยังคงสมบูรณ์

ในแง่สถาปัตยกรรมซอฟต์แวร์ มี bulkhead หลากหลายรูปแบบที่เราสามารถพิจารณาได้ กลับมาที่ประสบการณ์ของผมเองกับ AdvertCorp เราพลาดโอกาสในการใช้ bulkhead กับ downstream call จริงๆ เราควรใช้ connection pool ที่แยกกันสำหรับแต่ละ downstream connection แบบนั้นแล้ว ถ้า connection pool หนึ่งถูกใช้จนหมด connection อื่นๆ จะไม่ได้รับผลกระทบ ตามที่เราเห็นใน Figure 12-4

bms2 1204

Figure 12-4. การใช้ connection pool แยกสำหรับแต่ละ downstream service เพื่อสร้าง bulkhead

การแยก concern ออกจากกัน (separation of concerns) ก็เป็นอีกวิธีหนึ่งในการสร้าง bulkhead ได้เช่นกัน ด้วยการแยกฟังก์ชันการทำงานออกเป็น microservice ต่างๆ เราลดโอกาสที่ outage ในพื้นที่หนึ่งจะส่งผลต่ออีกพื้นที่หนึ่ง

ลองดูทุกแง่มุมของระบบคุณที่อาจผิดพลาดได้ ทั้งภายใน microservice และระหว่างกัน คุณมี bulkhead อยู่หรือเปล่า? ผมขอ แนะนำให้เริ่มจาก connection pool แยกกันสำหรับแต่ละ downstream connection อย่างน้อยที่สุด คุณอาจต้องการไปไกลกว่านั้น และพิจารณาใช้ circuit breaker ด้วย ซึ่งเราจะดูกันในอีกสักครู่

หลายๆ ทาง bulkhead เป็น pattern ที่สำคัญที่สุดในบรรดา pattern ที่เราดูมาจนถึงตอนนี้ time-out และ circuit breaker ช่วยให้คุณปลดปล่อยทรัพยากรเมื่อมันเริ่มถูกจำกัด แต่ bulkhead สามารถทำให้มันไม่ถูกจำกัดตั้งแต่แรกได้ มันยังให้ความสามารถในการปฏิเสธ request ในเงื่อนไขบางอย่างได้ด้วย เพื่อให้แน่ใจว่าทรัพยากรจะไม่ถูกใช้จนอิ่มตัวมากขึ้นไปอีก สิ่งนี้เรียกว่า load shedding บางครั้งการปฏิเสธ request เป็นวิธีที่ดีที่สุดในการหยุดไม่ให้ระบบสำคัญถูกครอบงำจนกลายเป็น bottleneck สำหรับ upstream service หลายตัว

Circuit Breakers

ในบ้านของคุณเอง circuit breaker มีไว้เพื่อปกป้องอุปกรณ์ไฟฟ้าจากไฟกระชาก ถ้าเกิดไฟกระชาก circuit breaker จะตัดไฟ ปกป้องเครื่องใช้ไฟฟ้าราคาแพงของคุณ คุณยังสามารถปิด circuit breaker ด้วยตัวเองเพื่อตัดไฟส่วนหนึ่งของบ้าน ทำให้คุณทำงานกับระบบไฟฟ้าได้อย่างปลอดภัย ใน pattern อีกตัวจาก Release It! Nygard แสดงให้เห็นว่าไอเดียเดียวกันนี้สามารถทำงานได้ดีอย่างน่าทึ่งในฐานะกลไกป้องกันสำหรับซอฟต์แวร์ของเรา

เราสามารถมองว่า circuit breaker เป็นกลไกอัตโนมัติในการปิดผนึก bulkhead ไม่ใช่แค่เพื่อปกป้อง consumer จากปัญหา downstream แต่ยังเพื่อปกป้องบริการ downstream จากการเรียกที่อาจส่งผลเสียเพิ่มเติมด้วย เมื่อพิจารณาถึงอันตรายของ cascading failure ผมขอ แนะนำให้บังคับใช้ circuit breaker กับทุก downstream call แบบ synchronous ของคุณ คุณไม่จำเป็นต้องเขียนเองด้วยซ้ำ—นับตั้งแต่ที่ผมเขียนหนังสือเล่มแรก การ implement circuit breaker ก็มีให้ใช้อย่างแพร่หลายมากขึ้น

กลับมาที่ AdvertCorp ลองพิจารณาปัญหาที่เรามีกับระบบ turnip ที่ตอบสนองช้ามากก่อนที่จะคืน error ในที่สุด แม้ว่า เรา จะตั้งค่า time-out ถูกต้อง เรา ก็จะยังต้องรอนานก่อนจะได้ error กลับมา แล้ว เรา ก็จะลองใหม่อีกครั้งเมื่อมี request เข้ามาครั้งถัดไป แล้วก็รออีก มันแย่พออยู่แล้วที่บริการ downstream ทำงานผิดปกติ แต่มันยังทำให้ทั้งระบบช้าลงด้วย

ด้วย circuit breaker หลังจากที่ request จำนวนหนึ่งไปยังทรัพยากร downstream ล้มเหลว (ไม่ว่าจะจาก error หรือ time-out) circuit breaker จะถูกตัด request ที่ตามมาทั้งหมดที่ผ่าน circuit breaker นั้นจะล้มเหลวอย่างรวดเร็วขณะที่ breaker อยู่ในสถานะตัด (open) 5 ตามที่คุณเห็นใน Figure 12-5 หลังจากผ่านไประยะเวลาหนึ่ง client จะส่ง request ไม่กี่ครั้งเพื่อดูว่าบริการ downstream ฟื้นตัวแล้วหรือยัง และถ้าได้รับ response ที่ปกติเพียงพอ ก็จะรีเซ็ต circuit breaker

An overview of circuit breakers

Figure 12-5. ภาพรวมของ circuit breaker

วิธีที่คุณ implement circuit breaker ขึ้นอยู่กับว่า request ที่ "ล้มเหลว" หมายถึงอะไร แต่เมื่อผม implement มันสำหรับการเชื่อมต่อ HTTP ผมมักจะถือว่าความล้มเหลวหมายถึง time-out หรือ 5XX HTTP return code บางส่วน ด้วยวิธีนี้ เมื่อทรัพยากร downstream กำลัง time out หรือคืน error หลังจากถึง threshold หนึ่งๆ เราจะหยุดส่ง traffic โดยอัตโนมัติและเริ่มล้มเหลวอย่างรวดเร็ว และเราสามารถเริ่มใหม่โดยอัตโนมัติเมื่อทุกอย่างกลับมาปกติ

การตั้งค่าให้ถูกต้องอาจยุ่งยากเล็กน้อย คุณไม่อยากตัด circuit breaker ง่ายเกินไป และก็ไม่อยากใช้เวลานานเกินไปในการตัด เช่นเดียวกัน คุณต้องแน่ใจจริงๆ ว่าบริการ downstream กลับมาปกติแล้วก่อนที่จะส่ง traffic ไป เช่นเดียวกับ time-out ผมขอ เลือกค่าเริ่มต้นที่สมเหตุสมผลและยึดใช้ทุกที่ แล้วค่อยเปลี่ยนสำหรับกรณีเฉพาะ

ขณะที่ circuit breaker ถูกตัด คุณมีตัวเลือกบางอย่าง หนึ่งคือ queue request ไว้และ retry ในภายหลัง สำหรับ use case บางอย่าง วิธีนี้อาจเหมาะสม โดยเฉพาะถ้าคุณกำลังทำงานบางอย่างเป็นส่วนหนึ่งของ asynchronous job ถ้า call นี้ถูกเรียกเป็นส่วนหนึ่งของ synchronous call chain แต่ การล้มเหลวอย่างรวดเร็วน่าจะดีกว่า ซึ่งอาจหมายถึงการส่ง error ขึ้นไปตาม call chain หรือการลดระดับฟังก์ชันการทำงานที่ละเอียดอ่อนกว่านั้น

ในกรณีของ AdvertCorp เราห่อ downstream call ไปยังระบบ legacy ด้วย circuit breaker ตามที่ Figure 12-6 แสดง เมื่อ circuit breaker เหล่านี้ถูกตัด เรา update เว็บไซต์โดยอัตโนมัติเพื่อแสดงว่าเราไม่สามารถแสดงโฆษณา turnip ได้ในตอนนี้ เรายังคงให้เว็บไซต์ส่วนที่เหลือทำงานต่อไป และสื่อสารกับลูกค้าอย่างชัดเจนว่ามีปัญหาที่จำกัดอยู่แค่ส่วนหนึ่งของผลิตภัณฑ์ ทั้งหมดนี้ทำโดยอัตโนมัติ

Adding circuit breakers to AdvertCorp

Figure 12-6. การเพิ่ม circuit breaker ให้กับ AdvertCorp

เราสามารถกำหนดขอบเขตของ circuit breaker ของเราให้มีหนึ่งตัวสำหรับแต่ละระบบ legacy downstream—ซึ่งสอดคล้องกับความจริงที่ว่าเราได้ตัดสินใจมี request worker pool แยกกันสำหรับแต่ละ downstream service

ถ้าเรามีกลไกนี้อยู่ (เช่นเดียวกับ circuit breaker ในบ้านของเรา) เราสามารถใช้มันด้วยตัวเองเพื่อทำงานได้อย่างปลอดภัยขึ้น ตัวอย่างเช่น ถ้าเราต้องการนำ microservice ตัวหนึ่งลงเพื่อบำรุงรักษาตามปกติ เราสามารถเปิด circuit breaker ทั้งหมดของ upstream consumer ด้วยตัวเอง เพื่อให้พวกมันล้มเหลวอย่างรวดเร็วขณะที่ microservice ออฟไลน์ เมื่อมันกลับมา เราสามารถปิด circuit breaker และทุกอย่างควรกลับสู่ปกติ การเขียนสคริปต์เพื่อเปิดและปิด circuit breaker ด้วยตัวเองในฐานะส่วนหนึ่งของกระบวนการ deploy อัตโนมัติอาจเป็นก้าวถัดไปที่สมเหตุสมผล

Circuit breaker ช่วยให้แอปพลิเคชันของเราล้มเหลวอย่างรวดเร็ว—และการล้มเหลวอย่างรวดเร็วก็ดีกว่าการล้มเหลวอย่างช้าๆ เสมอ circuit breaker ช่วยให้เราล้มเหลวก่อนที่จะเสียเวลาอันมีค่า (และทรัพยากร) ไปกับการรอ microservice downstream ที่ไม่ปกติตอบสนอง แทนที่จะรอจนกว่าเราจะพยายามใช้ microservice downstream แล้วจึงล้มเหลว เราสามารถตรวจสอบสถานะของ circuit breaker ของเราได้ล่วงหน้า ถ้า microservice ที่เราจะพึ่งพาเป็นส่วนหนึ่งของปฏิบัติการหนึ่งๆ ใช้งานไม่ได้อยู่ในขณะนี้ เราสามารถยกเลิกปฏิบัติการก่อนที่จะเริ่มด้วยซ้ำ

Isolation (การแยกตัว)

ยิ่ง microservice ตัวหนึ่งพึ่งพาความพร้อมใช้งานของ microservice อีกตัวมากเท่าไร สุขภาพของตัวหนึ่งก็ยิ่งส่งผลต่อความสามารถของอีกตัวในการทำงานมากเท่านั้น ถ้าเราสามารถใช้เทคโนโลยีที่อนุญาตให้ server downstream ออฟไลน์ได้ เช่น ผ่านการใช้ middleware หรือระบบ call buffering ประเภทอื่น microservice ต้นทางมีโอกาสน้อยลงที่จะได้รับผลกระทบจาก outage ไม่ว่าจะวางแผนไว้หรือไม่ ของ microservice downstream

การเพิ่ม isolation ระหว่างบริการยังมีประโยชน์อีกอย่างหนึ่ง เมื่อบริการถูกแยกออกจากกัน จำเป็นต้องมีการประสานงานน้อยลงมากระหว่างเจ้าของบริการ ยิ่งทีมต้องประสานงานกันน้อยลง ทีมเหล่านั้นก็ยิ่งมีความเป็นอิสระมากขึ้น เพราะสามารถดำเนินการและพัฒนาบริการของตัวเองได้อย่างอิสระมากขึ้น

Isolation ยังใช้ได้ในแง่ของการที่เราย้ายจากระดับ logical ไปสู่ physical ด้วย ลอง considers microservice สองตัวที่ดูเหมือนจะแยกออกจากกันโดยสิ้นเชิง พวกมันไม่สื่อสารกันในทางใดเลย ปัญหาของตัวหนึ่งไม่ควรส่งผลต่ออีกตัว ใช่ไหม? แต่จะเกิดอะไรขึ้นถ้าทั้งสอง microservice รันอยู่บน host เดียวกัน และตัวหนึ่งเริ่มใช้ CPU จนหมด ทำให้ host นั้นมีปัญหา?

ลองดูอีกตัวอย่างหนึ่ง microservice สองตัวต่างมีฐานข้อมูลของตัวเองที่แยกกันในระดับ logical แต่ทั้งสองฐานข้อมูลถูก deploy บน database infrastructure เดียวกัน ความล้มเหลวใน database infrastructure นั้นจะส่งผลกระทบต่อ microservice ทั้งสองตัว

เมื่อเราพิจารณาว่าจะ deploy microservice ของเราอย่างไร เราก็อยากมุ่งมั่นให้เกิด failure isolation ในระดับหนึ่งเพื่อหลีกเลี่ยงปัญหาแบบนี้ ตัวอย่างเช่น การทำให้แน่ใจว่า microservice รันอยู่บน host อิสระ พร้อม operating system และทรัพยากร computing ที่ล้อมรอบตัวเองเป็นก้าวที่สมเหตุสมผล—นี่คือสิ่งที่เราบรรลุได้เมื่อเรารัน instance ของ microservice ใน virtual machine หรือ container ของตัวเอง isolation แบบนี้ อย่างไรก็ตาม อาจมีต้นทุน

เราสามารถแยก microservice ของเราออกจากกันได้อย่างมีประสิทธิภาพมากขึ้นโดยรันมันบนเครื่องที่แตกต่างกัน นี่หมายความว่าเราต้องการ infrastructure มากขึ้น และเครื่องมือในการจัดการ infrastructure นั้น ซึ่งมีต้นทุนโดยตรง และยังสามารถเพิ่มความซับซ้อนให้กับระบบของเรา เปิดช่องทางใหม่ๆ ที่อาจเกิดความล้มเหลว microservice แต่ละตัวอาจมี database infrastructure ที่อุทิศให้โดยเฉพาะทั้งหมด แต่นั่นก็คือ infrastructure ที่ต้องจัดการมากขึ้น เราสามารถใช้ middleware เพื่อสร้าง temporal decoupling ระหว่าง microservice สองตัว แต่ตอนนี้เราก็มี broker ที่ต้องกังวลด้วย

Isolation เช่นเดียวกับเทคนิคอื่นๆ ที่เราดูมา สามารถช่วยพัฒนาความทนทานของแอปพลิเคชันของเราได้ แต่ก็ไม่ค่อยได้มาฟรีๆ การตัดสินใจเรื่อง trade-off ที่ยอมรับได้ระหว่าง isolation กับต้นทุนและความซับซ้อนที่เพิ่มขึ้น เช่นเดียวกับหลายๆ เรื่อง อาจเป็นสิ่งสำคัญมาก

Redundancy (ความซ้ำซ้อน)

การมีมากกว่าหนึ่งของบางสิ่งเป็นวิธีที่ดีในการพัฒนาความทนทานของ component หนึ่งๆ การมีคนมากกว่าหนึ่งคนที่รู้วิธีการทำงานของฐานข้อมูล production ดูสมเหตุสมผล ในกรณีที่มีใครบางคนลาออกจากบริษัทหรือลาพัก การมี microservice instance มากกว่าหนึ่งตัวก็สมเหตุสมผลเช่นกัน เพราะช่วยให้คุณทนต่อความล้มเหลวของ instance หนึ่งได้ และยังมีโอกาสส่งมอบ ฟังก์ชันการทำงาน ที่ต้องการได้

การหาว่าคุณต้องการ redundancy มากแค่ไหน และที่ไหน ขึ้นอยู่กับว่าคุณเข้าใจ failure mode ที่อาจเกิดขึ้นของแต่ละ component ดีแค่ไหน ผลกระทบของฟังก์ชันการทำงานที่ใช้งานไม่ได้ และต้นทุนของการเพิ่ม redundancy

บน AWS เช่น คุณไม่ได้รับ SLA สำหรับ uptime ของ EC2 (virtual machine) instance เดี่ยว คุณต้องทำงานบนสมมติฐานว่ามันสามารถและจะตายบนตัวคุณได้ ดังนั้นการมีมากกว่าหนึ่งจึงสมเหตุสมผล แต่ไปไกลกว่านั้น EC2 instance ถูก deploy เข้าไปใน availability zone (data center เสมือน) และคุณก็ไม่ได้รับการรับประกันใดๆ เกี่ยวกับความพร้อมใช้งานของ availability zone เดี่ยว หมายความว่าคุณอยากให้ instance ที่สองอยู่ใน availability zone ที่ แตกต่างกัน เพื่อกระจายความเสี่ยง

การมีสำเนามากขึ้นของบางสิ่งช่วยได้เมื่อพูดถึงการ implement redundancy แต่ก็อาจเป็นประโยชน์เมื่อพูดถึงการ scale แอปพลิเคชันของเราเพื่อรับมือกับโหลดที่เพิ่มขึ้นด้วย ในบทถัดไปเราจะดูตัวอย่างของการ scale ระบบ และดูว่าการ scale เพื่อ redundancy กับการ scale เพื่อโหลดแตกต่างกันอย่างไร

Middleware

"Message Brokers" เราดูบทบาทของ middleware ในรูปแบบของ message broker เพื่อช่วยในการ implement ทั้ง interaction แบบ request-response และแบบ event-based คุณสมบัติที่มีประโยชน์อย่างหนึ่งของ message broker ส่วนใหญ่คือความสามารถในการรับประกันการส่งมอบ (guaranteed delivery) คุณส่งข้อความไปยังฝ่าย downstream และ broker รับประกันว่าจะส่งมันให้ถึง โดยมีข้อแม้บางอย่างที่เราสำรวจไปก่อนหน้านี้ ภายใน เพื่อให้การรับประกันนี้เป็นจริง ซอฟต์แวร์ message broker จะต้อง implement สิ่งต่างๆ เช่น retry และ time-out แทนคุณ—การดำเนินการแบบเดียวกับที่คุณต้องทำเองกำลังถูกดำเนินการ แต่ทำในซอฟต์แวร์ที่เขียนโดยผู้เชี่ยวชาญที่โฟกัสลึกในเรื่องนี้ การให้คนเก่งๆ ทำงานให้คุณมักเป็นไอเดียที่ดี

ทีนี้ ในกรณีตัวอย่างเฉพาะของเรากับ AdvertCorp การใช้ middleware เพื่อจัดการการสื่อสารแบบ request-response กับระบบ turnip downstream อาจไม่ได้ช่วยอะไรมากนัก เราก็ยังคงไม่ได้รับ response กลับไปให้ลูกค้าของเราอยู่ดี ประโยชน์ที่เป็นไปได้อย่างหนึ่งคือ เราจะลด resource contention บนระบบของเราเอง แต่นี่ก็จะแค่ย้ายไปเป็นจำนวน request ที่ค้างอยู่มากขึ้นใน broker เท่านั้น ที่แย่ไปกว่านั้น request จำนวนมากที่ขอราคา turnip ล่าสุดอาจเกี่ยวข้องกับ request ของผู้ใช้ที่ไม่ valid อีกต่อไปแล้ว

อีกทางเลือกหนึ่งคือการกลับด้าน interaction แทน และใช้ middleware ให้ระบบ turnip broadcast โฆษณา turnip ล่าสุด แล้วเราค่อยไป consume มัน แต่ถ้าระบบ turnip downstream มีปัญหา เราก็จะยังไม่สามารถช่วยลูกค้าที่กำลังมองหาราคา turnip ที่ดีที่สุดได้อยู่ดี

ดังนั้นการใช้ middleware อย่าง message broker เพื่อช่วยขนถ่ายภาระเรื่อง robustness บางส่วนอาจมีประโยชน์ แต่ไม่ใช่ในทุกสถานการณ์

Idempotency

ในการดำเนินการที่เป็น idempotent ผลลัพธ์จะไม่เปลี่ยนแปลงหลังจากถูกใช้งานครั้งแรก แม้ว่าจะมีการใช้งานซ้ำหลายครั้งในภายหลัง ถ้าการดำเนินการเป็น idempotent เราสามารถเรียกซ้ำได้หลายครั้งโดยไม่ส่งผลเสีย นี่มีประโยชน์มากเมื่อเราต้องการ replay ข้อความที่เราไม่แน่ใจว่าถูกประมวลผลไปแล้วหรือยัง ซึ่งเป็นวิธีทั่วไปในการฟื้นตัวจาก error

ลองพิจารณาการเรียกง่ายๆ เพื่อเพิ่มคะแนนอันเป็นผลจากลูกค้าคนหนึ่งของเราสั่งซื้อสินค้า เราอาจเรียกด้วย payload แบบที่แสดงใน Example 12-1

Example 12-1. การเพิ่มคะแนนให้บัญชี
<credit>
  <amount>100</amount>
  <forAccount>1234</account>
</credit>

ถ้า call นี้ถูกรับหลายครั้ง เราก็จะเพิ่ม 100 คะแนนหลายครั้ง ดังนั้น call นี้จึงไม่ใช่ idempotent ในตอนนี้ ด้วยข้อมูลเพิ่มเติมอีกเล็กน้อย เราสามารถทำให้ points bank สามารถทำ call นี้เป็น idempotent ได้ ตามที่แสดงใน Example 12-2

Example 12-2. การเพิ่มข้อมูลให้กับการเพิ่มคะแนนเพื่อทำให้เป็น idempotent
<credit>
  <amount>100</amount>
  <forAccount>1234</account>
  <reason>
    <forPurchase>4567</forPurchase>
  </reason>
</credit>

เรารู้ว่า credit นี้เกี่ยวข้องกับคำสั่งซื้อที่ระบุ 4567 โดยสมมติว่าเราสามารถได้รับ credit เพียงครั้งเดียวสำหรับคำสั่งซื้อหนึ่ง เราสามารถใช้ credit นี้ซ้ำได้อีกโดยไม่เพิ่มจำนวนคะแนนโดยรวม

กลไกนี้ทำงานได้ดีพอๆ กันกับ event-based collaboration และอาจมีประโยชน์อย่างยิ่งถ้าคุณมี instance หลายตัวของบริการประเภทเดียวกันที่ subscribe เหตุการณ์ (event) แม้ว่าเราจะเก็บว่า event ไหนถูกประมวลผลไปแล้ว แต่ในการส่งข้อความแบบ asynchronous บางรูปแบบ อาจมีช่วงเวลาสั้นๆ ที่ worker สองตัวเห็นข้อความเดียวกัน ด้วยการประมวลผล event แบบ idempotent เราจะมั่นใจได้ว่าสิ่งนี้จะไม่ก่อให้เกิดปัญหาใดๆ

บางคนติดอยู่กับแนวคิดนี้มากเกินไปและคิดว่ามันหมายความว่า call ที่ตามมาด้วย parameter เดียวกันจะไม่มีผลกระทบใดๆ เลย ซึ่งทำให้เราอยู่ในสถานะที่น่าสนใจ เราก็ยังอยากบันทึกความจริงที่ว่ามี call เข้ามาไว้ใน log ของเรา ตัวอย่างเช่น เราอยากบันทึก response time ของ call และเก็บข้อมูลนี้ไว้สำหรับ monitoring ประเด็นสำคัญตรงนี้คือสิ่งที่เราถือว่าเป็น idempotent คือ business operation ที่อยู่ภายใต้มัน ไม่ใช่สถานะทั้งหมดของระบบ

HTTP verb บางตัว เช่น GET และ PUT ถูกกำหนดไว้ใน HTTP specification ว่าเป็น idempotent แต่เพื่อให้เป็นเช่นนั้นจริง บริการของคุณต้องจัดการ call เหล่านี้ในลักษณะ idempotent ด้วย ถ้าคุณเริ่มทำให้ verb เหล่านี้ไม่ idempotent แต่ผู้เรียกคิดว่าพวกเขาสามารถเรียกซ้ำได้อย่างปลอดภัย คุณอาจเจอปัญหายุ่งยาก จำไว้ว่า แค่คุณใช้ HTTP เป็น protocol พื้นฐานไม่ได้แปลว่าคุณจะได้ทุกอย่างมาฟรีๆ!