Managing Breaking Changes (การจัดการ Breaking Change)
ดังนั้น คุณได้พยายามอย่างเต็มที่แล้วเพื่อให้แน่ใจว่าการเปลี่ยนแปลงที่คุณทำกับ interface ของไมโครเซอร์วิสเป็นแบบ backward compatible แต่คุณตระหนักว่าคุณจำเป็นต้องทำการเปลี่ยนแปลงที่จะเป็น breaking change คุณจะทำอย่างไรในสถานการณ์แบบนี้? คุณมีตัวเลือกหลักสามอย่าง:
Lockstep deployment
กำหนดให้ไมโครเซอร์วิสที่เปิดเผย interface และผู้เรียกใช้ทั้งหมดของ interface นั้นถูกเปลี่ยนแปลงพร้อมกัน
Coexist incompatible microservice versions
รัน version เก่าและใหม่ของไมโครเซอร์วิสควบคู่กันไป
Emulate the old interface
ให้ไมโครเซอร์วิสของคุณเปิดเผย interface ใหม่และจำลอง interface เก่าด้วย
Lockstep Deployment
แน่นอนว่า lockstep deployment ขัดแย้งกับหลักการของ independent deployability ถ้าเราต้องการที่จะสามารถ deploy version ใหม่ของไมโครเซอร์วิส ของเราที่มี breaking change กับ interface ของมัน แต่ยังคงทำแบบนี้ในลักษณะที่เป็นอิสระ เราต้องให้เวลาผู้เรียกใช้ของเราในการ upgrade ไปยัง interface ใหม่ นั่นนำเราไปสู่สองตัวเลือกถัดไปที่ผมจะพิจารณา
Coexist Incompatible Microservice Versions
อีก ทางแก้ versioning หนึ่งที่มักถูกอ้างถึงคือการให้ version ที่แตกต่างกันของ service มีชีวิตอยู่พร้อมกัน และให้ผู้เรียกใช้เก่า route traffic ของพวกเขาไปยัง version เก่า ในขณะที่ผู้เรียกใช้ใหม่เห็น version ใหม่ ดังแสดงใน Figure 5-3 นี่คือแนวทางที่ Netflix ใช้อย่างประหยัดในสถานการณ์ที่ต้นทุนของการเปลี่ยนผู้เรียกใช้เก่าสูงเกินไป โดยเฉพาะในกรณีที่หายากที่อุปกรณ์ legacy ยังคงผูกติดกับ version เก่าของ API ส่วนตัวแล้วผมไม่ใช่แฟนของแนวคิดนี้ และเข้าใจว่าทำไม Netflix ถึงใช้มันน้อยครั้ง อย่างแรก ถ้าผมต้องแก้ bug ภายในใน service ของผม ตอนนี้ผมต้องแก้และ deploy service สองชุดที่แตกต่างกัน นี่อาจหมายความว่าผมต้อง branch codebase สำหรับ service ของผม ซึ่งมักจะมีปัญหาเสมอ อย่างที่สอง มันหมายความว่าผม ต้องมีความฉลาด ในการจัดการเรื่องนำผู้เรียกใช้ไปยังไมโครเซอร์วิสที่ถูกต้อง พฤติกรรมนี้ หลีกเลี่ยงไม่ได้ ที่จะจบลงด้วยการอยู่ใน middleware ที่ไหนสักแห่ง หรือใน script nginx กลุ่มหนึ่ง ทำให้ยากขึ้นที่จะทำความเข้าใจพฤติกรรมของระบบ สุดท้าย ลองพิจารณา persistent state ใดๆ ที่ service ของเราอาจจัดการอยู่ ลูกค้าที่ถูกสร้างโดย version ใดก็ตามของ service ต้องถูกจัดเก็บและทำให้มองเห็นได้สำหรับ service ทั้งหมด ไม่ว่า version ไหนจะถูกใช้ เพื่อสร้างข้อมูลตั้งแต่แรก นี่อาจเป็นแหล่งที่มาเพิ่มเติมของ ความซับซ้อน
Figure 5-3. Running multiple versions of the same service to support old endpoints (การรันหลาย version ของ service เดียวกัน เพื่อรองรับ endpoint เก่า)
การให้ service version ที่รันพร้อมกันอยู่ร่วมกันเป็นระยะเวลาสั้นๆ อาจสมเหตุสมผลอย่างยิ่ง โดยเฉพาะเมื่อคุณกำลังทำอะไรบางอย่าง เช่น canary release (เราจะพูดถึง pattern นี้เพิ่มเติมใน "On to Progressive Delivery" ) ในสถานการณ์เหล่านี้ เราอาจให้ version อยู่ร่วมกันแค่ไม่กี่นาทีหรืออาจจะไม่กี่ชั่วโมง และโดยปกติเราจะมี version ที่แตกต่างกัน ของ service เพียงสองตัวเท่านั้นที่มีอยู่ในเวลาเดียวกัน ยิ่งใช้เวลานานเท่าไหร่ในการให้ผู้เรียกใช้ upgrade ไปยัง version ใหม่และ release คุณควรมองหาที่จะให้ endpoint ที่แตกต่างกันในไมโครเซอร์วิสเดียวกันอยู่ร่วมกัน มากกว่าที่จะให้ version ที่แตกต่างกัน โดยสิ้นเชิงอยู่ร่วมกัน ผมยังไม่มั่นใจว่างานนี้คุ้มค่าสำหรับโปรเจกต์ทั่วไป
Emulate the Old Interface (จำลอง Interface เก่า)
ถ้าเราได้ทำทุกอย่างที่ทำได้เพื่อหลีกเลี่ยงการนำเสนอ breaking interface change งานถัดไปของเราคือการจำกัดผลกระทบ สิ่งที่เราต้องการ หลีกเลี่ยงคือการบังคับให้ผู้เรียกใช้ upgrade แบบ lockstep ไปกับเรา เพราะเราต้องการรักษาความสามารถในการ release ไมโครเซอร์วิส อย่างอิสระจากกันและกันเสมอ วิธีหนึ่งที่ผมเคยใช้ได้สำเร็จเพื่อจัดการเรื่องนี้คือการให้ interface เก่าและใหม่อยู่ร่วมกันใน service ที่กำลังรันอยู่เดียวกัน ดังนั้นถ้าเราต้องการ release breaking change เราจะ deploy version ใหม่ของ service ที่เปิดเผยทั้ง version เก่าและใหม่ของ endpoint
สิ่งนี้ทำให้เราสามารถปล่อยไมโครเซอร์วิสใหม่ออกมาได้เร็วที่สุดเท่าที่จะทำได้ พร้อมกับ interface ใหม่ ในขณะที่ให้เวลาผู้เรียกใช้ ย้ายมา เมื่อผู้เรียกใช้ทั้งหมดไม่ได้ใช้ endpoint เก่าอีกต่อไป คุณก็สามารถลบมันออกพร้อมกับ code ที่เกี่ยวข้องได้ ดังแสดงใน Figure 5-4
Figure 5-4. One microservice emulating the old endpoint and exposing the new backward-incompatible endpoint (ไมโครเซอร์วิสหนึ่งจำลอง endpoint เก่าและเปิดเผย endpoint ใหม่ที่ backward-incompatible)
ครั้งสุดท้ายที่ผมใช้วิธีนี้ เราทำให้ตัวเองตกอยู่ในความยุ่งเหยิงเล็กน้อยกับจำนวนผู้เรียกใช้ที่เรามีและจำนวน breaking change ที่เราทำ นี่หมายความว่าเราให้ endpoint version ที่แตกต่างกันสามตัวอยู่ร่วมกันจริงๆ นี่ไม่ใช่สิ่งที่ ผม จะแนะนำ! การเก็บ code ทั้งหมดไว้และ testing ที่เกี่ยวข้องที่จำเป็นเพื่อให้แน่ใจว่าพวกมันทั้งหมดทำงานได้เป็นภาระเพิ่มเติมอย่างแน่นอน เพื่อทำให้จัดการได้ง่ายขึ้น เราแปลง request ทั้งหมดไปยัง endpoint V1 เป็น request V2 ภายใน แล้วก็แปลง request V2 เป็น endpoint V3 นี่หมายความว่าเราสามารถระบุได้ชัดเจนว่า code ไหนจะถูกเลิกใช้เมื่อ endpoint เก่าตายไป
นี่คือตัวอย่างของ expand and contract pattern อย่างแท้จริง ซึ่งช่วยให้เราทยอยนำ breaking change มาใช้ได้ เรา ขยาย (expand) ความสามารถที่เราเสนอ รองรับทั้งวิธีเก่าและวิธีใหม่ในการทำสิ่งใดสิ่งหนึ่ง เมื่อผู้เรียกใช้เก่าทำสิ่งต่างๆ ด้วยวิธีใหม่แล้ว เรา หดตัว (contract) API ของเรา ลบ functionality เก่าออก
ถ้าคุณจะให้ endpoint อยู่ร่วมกัน คุณต้องการวิธีให้ผู้เรียก route request ของพวกเขาให้เหมาะสม สำหรับระบบที่ใช้ HTTP ผมเคยเห็นสิ่งนี้ ทำได้ทั้งด้วยหมายเลข version ใน request header และใน URI เอง—ตัวอย่างเช่น /v1/customer/ หรือ /v2/customer/ ผมค่อนข้างลังเลว่าแนวทางไหนสมเหตุสมผลที่สุด ในแง่หนึ่ง ผมชอบให้ URI เป็น opaque เพื่อกีดกัน client จาก hardcode URI template แต่ในอีกแง่หนึ่ง แนวทางนี้ทำให้สิ่งต่างๆ ชัดเจนมากและสามารถทำให้ request routing ง่ายขึ้นได้
สำหรับ RPC เรื่องนี้อาจซับซ้อนกว่าเล็กน้อย ผมเคยจัดการเรื่องนี้ด้วย protocol buffer โดยใส่ method ของผมไว้ใน namespace ที่แตกต่างกัน—ตัวอย่างเช่น v1.createCustomer และ v2.createCustomer —แต่เมื่อคุณพยายามรองรับ version ที่แตกต่างกันของ type เดียวกันที่ถูกส่งผ่านเครือข่าย แนวทางนี้อาจกลายเป็นเรื่องเจ็บปวดมากจริงๆ
Which Approach Do I Prefer? (ผมชอบวิธีไหน?)
สำหรับสถานการณ์ที่ทีมเดียวกันดูแลทั้งไมโครเซอร์วิสและผู้เรียกใช้ทั้งหมด ผมค่อนข้างสบายๆ กับการ release แบบ lockstep ในสถานการณ์ ที่จำกัด สมมติว่ามันเป็นสถานการณ์ที่เกิดขึ้นครั้งเดียวจริงๆ การทำแบบนี้เมื่อผลกระทบจำกัดอยู่แค่ทีมเดียวก็สามารถให้เหตุผลได้ อย่างไรก็ตาม ผมระมัดระวังเรื่องนี้มาก เพราะมีอันตรายที่กิจกรรมแบบครั้งเดียวจะกลายเป็นเรื่องปกติทางธุรกิจ และนั่นก็คือการสูญเสีย independent deployability ใช้ lockstep deployment บ่อยเกินไป และคุณจะจบลงด้วย distributed monolith ในไม่ช้า
การให้ version ที่แตกต่างกันของไมโครเซอร์วิสเดียวกันอยู่ร่วมกันอาจมีปัญหาได้ อย่างที่เราคุยกันไป ผมจะพิจารณาทำแบบนี้เฉพาะใน สถานการณ์ที่เราวางแผนจะรัน microservice version ควบคู่กันแค่ระยะเวลาสั้นๆ ความจริงคือเมื่อคุณต้องให้เวลาผู้เรียกใช้ upgrade คุณอาจต้องมองไปที่หลายสัปดาห์หรือมากกว่านั้น ในสถานการณ์อื่นๆ ที่คุณอาจให้ microservice version อยู่ร่วมกัน บางทีอาจเป็นส่วนหนึ่ง ของ blue-green deployment หรือ canary release ระยะเวลาที่เกี่ยวข้องจะสั้นกว่ามาก ซึ่งชดเชยข้อเสียของแนวทางนี้
โดยทั่วไปแล้ว ผมชอบใช้การจำลอง endpoint เก่าเมื่อไหร่ก็ตามที่ทำได้ ความท้าทายของการ implement การจำลองนั้น ในความเห็นของผม จัดการได้ง่ายกว่าความท้าทายของการให้ microservice version อยู่ร่วมกันมาก
The Social Contract (สัญญาทางสังคม)
แนวทาง ไหนที่คุณเลือกส่วนใหญ่จะขึ้นอยู่กับความคาดหวังที่ผู้เรียกใช้มีว่าการเปลี่ยนแปลงเหล่านี้จะถูกทำอย่างไร การเก็บ interface เก่าไว้ รอบๆ อาจมีต้นทุน และในอุดมคติคุณอยากปิดมันและลบ code กับ infrastructure ที่เกี่ยวข้องออกโดยเร็วที่สุดเท่าที่จะทำได้ ในอีกด้านหนึ่ง คุณอยากให้เวลาผู้เรียกใช้มากที่สุดเท่าที่จะทำได้ในการทำการเปลี่ยนแปลง และจำไว้ว่า ในหลายกรณี backward-incompatible change ที่คุณกำลังทำนั้นมักเป็นสิ่งที่ผู้เรียกใช้ร้องขอ และ/หรือจะให้ประโยชน์กับ พวกเขาในที่สุด แน่นอนว่ามีการรักษาสมดุลระหว่างความต้องการของผู้ดูแลไมโครเซอร์วิสและของผู้เรียกใช้ และเรื่องนี้จำเป็นต้องได้รับ การพูดคุย
ผมพบว่าในหลายสถานการณ์ วิธีที่การเปลี่ยนแปลงเหล่านี้จะถูกจัดการไม่เคยถูกพูดคุยมาก่อน นำไปสู่ความท้าทายทุกรูปแบบ เช่นเดียวกับ schema การมีความชัดเจนในระดับหนึ่งเกี่ยวกับวิธีที่ backward-incompatible change จะถูกทำ สามารถทำให้สิ่งต่างๆ ง่ายขึ้นได้มาก
คุณไม่จำเป็นต้องมีเอกสารกองโตหรือประชุมใหญ่โตเพื่อบรรลุข้อตกลงว่าการเปลี่ยนแปลงจะถูกจัดการอย่างไร แต่สมมติว่าคุณไม่ได้เดินไปตาม เส้นทางของ lockstep release ผมขอแนะนำว่าทั้งเจ้าของและผู้เรียกใช้ของไมโครเซอร์วิสจำเป็นต้องชัดเจนในสิ่งเหล่านี้สักสองสามข้อ:
-
คุณจะยกประเด็นว่า interface จำเป็นต้องเปลี่ยนแปลงขึ้นมาได้อย่างไร?
-
ผู้เรียกใช้และทีมไมโครเซอร์วิสจะร่วมมือกันตกลงว่าการเปลี่ยนแปลงจะมีหน้าตาอย่างไรได้อย่างไร?
-
ใครถูกคาดหวังให้ทำงานอัปเดตผู้เรียกใช้?
-
เมื่อการเปลี่ยนแปลงถูกตกลงกันแล้ว ผู้เรียกใช้จะมีเวลานานแค่ไหนในการย้ายไปยัง interface ใหม่ก่อนที่มันจะถูกลบ?
จำไว้ว่า หนึ่ง ในความลับของสถาปัตยกรรมไมโครเซอร์วิสที่มีประสิทธิภาพคือการยอมรับแนวทางที่ให้ผู้เรียกใช้มาก่อน ไมโครเซอร์วิสของคุณมีอยู่เพื่อ ถูกเรียกใช้โดยผู้เรียกใช้อื่นๆ ความต้องการของผู้เรียกใช้มีความสำคัญสูงสุด และถ้าคุณกำลังทำการเปลี่ยนแปลงไมโครเซอร์วิสที่จะทำให้ ผู้เรียกใช้ upstream มีปัญหา นี่จำเป็นต้องถูกนำมาพิจารณาด้วย
ในบางสถานการณ์ แน่นอนว่า อาจเป็นไปไม่ได้ที่จะเปลี่ยนผู้เรียกใช้ ผมเคยได้ยินจาก Netflix ว่าพวกเขาเคยมีปัญหา (อย่างน้อยในอดีต) กับกล่อง set-top box เก่าที่ใช้ Netflix API version เก่า กล่อง set-top box เหล่านี้ไม่สามารถ upgrade ได้ง่าย ดังนั้น endpoint เก่าจึงต้องคงพร้อมใช้งานอยู่ จนกว่าและตราบใดที่จำนวนกล่อง set-top box เก่าจะลดลงมาถึงระดับที่พวกมันสามารถหยุดการรองรับได้ การตัดสินใจหยุดผู้เรียกใช้เก่าจากการเข้าถึง endpoint ของคุณบางครั้งก็จบลงด้วยการเป็นการตัดสินใจทางการเงิน—มันมีต้นทุนเท่าไหร่ สำหรับคุณในการรองรับ interface เก่า เทียบกับเงินที่คุณได้จากผู้เรียกใช้เหล่านั้น
Tracking Usage (การติดตามการใช้งาน)
แม้ ถ้าคุณตกลงกันเรื่องเวลาที่ผู้เรียกใช้ควรหยุดใช้ interface เก่า คุณจะรู้ไหมว่าพวกเขาหยุดใช้มันจริงๆ แล้ว? การทำให้แน่ใจว่าคุณมี logging พร้อมใช้สำหรับแต่ละ endpoint ที่ไมโครเซอร์วิสของคุณเปิดเผยสามารถช่วยได้ เช่นเดียวกับ การทำให้แน่ใจ ว่าคุณมี client identifier บางอย่าง เพื่อที่คุณจะได้คุยกับทีมที่เกี่ยวข้องได้ถ้าคุณต้องทำงานร่วมกับพวกเขาเพื่อให้พวกเขาย้ายออกจาก interface เก่าของคุณ นี่อาจเป็นอะไรง่ายๆ อย่างการขอให้ผู้เรียกใช้ใส่ identifier ของพวกเขาใน header user-agent เมื่อทำ HTTP request หรือคุณอาจกำหนดให้ทุก call ต้องผ่าน API gateway บางประเภทที่ client ต้องใช้ key เพื่อระบุตัวตนของ ตัวเอง
Extreme Measures (มาตรการสุดโต่ง)
ดังนั้น สมมติว่าคุณรู้ว่าผู้เรียกใช้ยังคงใช้ interface เก่าที่คุณอยากลบอยู่ และพวกเขากำลังถ่วงเวลาในการย้ายไปยัง version ใหม่ คุณสามารถทำอะไรได้บ้าง? สิ่งแรกที่ต้องทำคือคุยกับพวกเขา บางทีคุณอาจช่วยเหลือพวกเขาให้ทำการเปลี่ยนแปลงเกิดขึ้นได้ ถ้าทุกอย่างล้มเหลว และพวกเขายังคงไม่ upgrade แม้จะตกลงที่จะทำแล้วก็ตาม มีเทคนิคสุดโต่งบางอย่างที่ผมเคยเห็นถูกใช้
ที่บริษัทเทคโนโลยีขนาดใหญ่แห่งหนึ่ง เราคุยกันว่าบริษัทจัดการกับปัญหานี้อย่างไร ภายในองค์กร บริษัทมีระยะเวลาที่ใจกว้างมากคือหนึ่งปี ก่อนที่ interface เก่าจะถูกเลิกใช้ ผมถามว่าบริษัทรู้ได้อย่างไรว่าผู้เรียกใช้ยังคงใช้ interface เก่าอยู่ และบริษัทตอบว่าจริงๆ แล้ว ไม่ได้เสียเวลาติดตามข้อมูลนั้น หลังจากหนึ่งปีมันก็แค่ปิด interface เก่าไปเลย ภายในองค์กรมีความเข้าใจร่วมกันว่าถ้าสิ่งนี้ทำให้ ผู้เรียกใช้พัง มันเป็นความผิดของทีมไมโครเซอร์วิสที่เรียกใช้—พวกเขามีเวลาหนึ่งปีในการทำการเปลี่ยนแปลงและไม่ได้ทำ แน่นอนว่าแนวทางนี้ จะไม่ได้ผลสำหรับหลายคน (ผมบอกแล้วว่ามันสุดโต่ง!) มันยังนำไปสู่ความไม่มีประสิทธิภาพในระดับสูงด้วย ด้วยการไม่รู้ว่า interface เก่า ถูกใช้อยู่หรือไม่ บริษัทก็ปฏิเสธโอกาสของตัวเองที่จะลบมันออกก่อนที่หนึ่งปีจะผ่านไป ส่วนตัวแล้ว แม้ผมจะแนะนำให้แค่ปิด endpoint หลังจากระยะเวลาหนึ่ง ผมก็ยังคงอยากมีการติดตามอย่างแน่นอนว่าใครจะได้รับผลกระทบ
มาตรการสุดโต่งอีกอย่างที่ผมเคยเห็นจริงๆ แล้วอยู่ในบริบทของการเลิกใช้ library แต่ในทางทฤษฎีมันก็สามารถใช้กับ microservice endpoint ได้เช่นกัน ตัวอย่างที่ให้มาคือ library เก่าที่คนพยายามเลิกใช้ภายในองค์กร เพื่อหันไปใช้ library ที่ใหม่กว่าและดีกว่า แม้จะมีงาน จำนวนมากในการย้าย code ไปใช้ library ใหม่ บางทีมก็ยังคงถ่วงเวลาอยู่ ทางแก้คือการแทรก sleep เข้าไปใน library เก่า เพื่อให้มันตอบสนอง ต่อการเรียกช้าลง (พร้อม logging เพื่อแสดงว่าเกิดอะไรขึ้น) เมื่อเวลาผ่านไป ทีมที่ผลักดันการเลิกใช้ก็เพิ่มระยะเวลาของ sleep ขึ้นเรื่อยๆ จนในที่สุดทีมอื่นๆ ก็เข้าใจ แน่นอนว่าคุณต้องมั่นใจอย่างยิ่งว่าคุณได้ใช้ความพยายามที่สมเหตุสมผลอื่นๆ จนหมดแล้วเพื่อให้ผู้เรียกใช้ upgrade ก่อนที่จะพิจารณาอะไรแบบนี้!