ปัญหา
โค้ดบางตัวถูกเปลี่ยนเพราะมันเก่า และความเก่านี่แหละที่ทำให้การเปลี่ยนมันอันตราย pricing engine, billing run, การคำนวณภาษีหรือเงินเดือน, credit check: fix, ข้อยกเว้น และกรณีพิเศษที่สะสมมาหลายปีอยู่ในนั้น และไม่มีเอกสารไหนจดไว้ครบ โค้ดเก่าคือคำอธิบายกฎที่ครบถ้วนเพียงชิ้นเดียว
โค้ดแบบนี้ที่เขียนใหม่อาจผ่านทุกเทสต์แต่ยังผิดอยู่ก็ได้ เพราะเทสต์ครอบคลุมแค่กรณีที่มีคนนึกถึง Jesse Toth ที่เขียนถึงการ rewrite โค้ดเรื่อง permission ของ GitHub เพิ่มเหตุผลข้อที่สอง: พอเวลาผ่านไปนานและปริมาณมากพอ ข้อมูลใน production ก็สะสมความแปลกของตัวเองขึ้นมา ที่ไม่มีเทสต์หรือ specification ไหนอธิบายไว้ และ user จริงก็ไปเจอมันเข้า
คำตอบที่ผิดแบบนี้ดูไม่ออกว่าเป็นความล้มเหลว request สำเร็จ latency ปกติ แต่ราคาผิดไปหนึ่งเซนต์ ถ้าเป็น canary release ที่ดู error rate และ response time ก็จะ promote build นั้นผ่านไป และคนแรกที่สังเกตเห็นก็จะเป็นลูกค้าหรือนักบัญชี feature flag หรือการสลับแบบ blue-green ให้ทางถอยที่เร็วก็จริง แต่ต้องรอให้มีคนสังเกตเห็นก่อน
สิ่งที่ขาดไปคือหลักฐานที่เก็บมาก่อนที่ลูกค้าคนไหนจะพึ่งโค้ดใหม่ ว่าโค้ดใหม่ให้คำตอบเหมือนโค้ดเก่า กับ input ที่ production เจอจริง ๆ
ทำงานยังไง
ส่งทุก input ให้ implementation ทั้งสองตัว ใช้คำตอบแค่หนึ่งในสอง แล้วเทียบกัน ณ เวลาใดก็ตาม จะมีฝั่งเดียวที่เป็น source of truth และจนกว่าการเทียบจะพิสูจน์ได้ว่า implementation ใหม่สมควรได้บทนั้น ฝั่งนั้นก็คือตัวเก่า Sam Newman อธิบาย pattern นี้ภายใต้ชื่อนี้ใน Monolith to Microservices แต่การทำแบบนี้มีมาก่อนชื่อ: องค์กรต่าง ๆ เปลี่ยนระบบกันมานานแล้วด้วยการรันระบบเงินเดือนหรือ ledger ตัวใหม่คู่กับตัวเก่าสักสองสามรอบ แล้ว reconcile ผลลัพธ์ก่อนจะปิดตัวเก่า
ไดอะแกรมแสดงการแทนที่ pricing engine โดย Checkout ขอราคา แล้ว fork ก็ส่ง request เดียวกันให้ v1 และ v2
- input เดียวกันไปที่ทั้งคู่
2 × $12.50 + 8% taxถูกคิดราคาสองรอบ ตัว $27.00 จาก v1 กลับไปที่ Checkout ส่วน $27.00 จาก v2 ไปที่ comparator และไม่ไปไหนอีก ตัว v1 เขียนลง live store และเรียกออกไปข้างนอกจริง ๆ ส่วน v2 เขียนลง store ของตัวเองและเรียก stub เลยไม่มีอะไรโดนตัดเงินหรือส่งออกไปซ้ำ - เทียบ comparator ตรวจคู่ผลลัพธ์ของทุก input ถ้าตรงกันก็แค่นับไว้ ถ้าไม่ตรงก็ log ไว้พร้อมของที่ต้องใช้สร้างซ้ำ ก็คือ input และ output ทั้งสองฝั่ง: สำหรับ
3 × $16.67 + 8% taxตัว v1 ตอบ $54.00 แต่ v2 ตอบ $54.01 ส่วนจาก 1,000 input ล่าสุด ตรงกัน 970 ก็คือ 97% - อธิบาย mismatch ให้ได้ทุกตัว ความต่าง 30 ตัวที่ log ไว้กลายเป็น defect ในโค้ดใหม่ 18 ตัว, ความแปลกของโค้ดเก่าที่ธุรกิจพึ่งอยู่ 5 ตัว และ noise 7 กรณี เซนต์ที่ต่างไปนั้นเป็น defect: v1 ปัดเศษภาษีทีละชิ้น ($1.33 สามครั้งเป็น $3.99) แต่ v2 ปัดครั้งเดียวที่ subtotal $50.01 ($4.00) ความแปลกถูกตัดสินทีละตัว noise ถูกเอาออกจากการเทียบ และ v2.1 ก็ ship ออกไปพร้อม fix แล้วตอนนี้ input เดิมตรงกันแล้ว และอัตราก็ไต่ขึ้นไปถึง 100% แล้วอยู่ตรงนั้นไป 30 วันที่มีทั้งสิ้นเดือนและการคืนเงินรวมอยู่ด้วย
- Cut over สลับบทบาทกัน v2.1 กลายเป็น source of truth: คำตอบของมันกลับไปที่ Checkout และมันเขียนลง live store ส่วน v1 รันต่อไปอีกสักพักในฐานะ shadow โดยตอนนี้ถึงตาผลข้างเคียงของมันที่ต้องถูกแยกออกไป แล้วค่อยปลดระวาง
mismatch หมายถึงอะไรได้บ้าง
ทุก mismatch คือสิ่งที่ค้นพบ และการรันยังไม่จบตราบที่ยังมีตัวไหนอธิบายไม่ได้ มีอยู่สามแบบ
- Defect ในโค้ดใหม่ แก้มัน นี่คือแบบที่การรันมีไว้เพื่อหา
- ความแปลกของโค้ดเก่าที่ธุรกิจพึ่งอยู่ โค้ดเก่าทำบางอย่างที่ไม่มี specification ไหนพูดถึง (กฎการปัดเศษ, ส่วนลดที่อยู่นานกว่าในเอกสารไปหนึ่งวัน) และใบแจ้งหนี้ รายงาน และระบบของคนอื่นก็พึ่งมันมาหลายปีแล้ว ตัดสินใจให้รอบคอบ: จะทำแบบเดิมซ้ำในโค้ดใหม่ หรือแก้ให้ถูกแล้วบอกคนที่ได้รับผลกระทบ การแก้ให้ถูกทำให้สองฝั่งต่างกันโดยตั้งใจ เลยต้องลงทะเบียนไว้ในการเทียบว่าเป็นความต่างที่คาดไว้ พร้อมเหตุผลและเจ้าของ
- Noise timestamp, identifier ที่ generate ขึ้น, ลำดับใน list: คำตอบสองชุดหมายถึงสิ่งเดียวกันแต่ไม่ได้เหมือนกันเป๊ะ เอา noise ออกจากการเทียบ (ดูด้านล่าง) แทนที่จะชินกับ match rate ที่ต่ำกว่า 100%
บางทีโค้ดเก่าก็ผิดเฉย ๆ Toth ชี้ว่า feedback จากการเทียบทำให้เกิดการแก้ในระบบใหม่ และบางครั้งก็ในระบบเก่าด้วย ตอนที่ GitHub แทนที่โค้ดที่สร้าง merge commit การเทียบเปิดโปง bug ร้ายแรงสองตัวใน Git implementation ตัวเดิมที่ไม่มีใครเห็นมาหลายปี ควบคู่กับปัญหา performance ใหญ่ ๆ สามเรื่องในตัวใหม่ ถ้าคำตอบเก่าคือตัวที่ผิด ก็มีทางออกสามทาง: ทำผิดแบบเดิมไปก่อนแล้วค่อยแก้หลัง migration, แก้โค้ดเก่าด้วยให้สองฝั่งตรงกัน แล้วให้การแก้นั้นไปถึงลูกค้าเป็นการเปลี่ยนแปลงของมันเอง หรือแก้แค่โค้ดใหม่แล้วลงทะเบียนความต่างไว้ว่าเป็นสิ่งที่คาดไว้
ใครเป็นคนตัดสิน ไม่ใช่ developer ที่รันการเทียบ ความต่างไหนสำคัญหรือไม่เป็นคำถามทางธุรกิจ และคำตอบเป็นของคนที่เป็นเจ้าของกฎนั้น: ฝ่ายการเงินสำหรับการปัดเศษ ทีมภาษีสำหรับภาษี เก็บไว้หนึ่งบรรทัดต่อ mismatch แต่ละประเภท พร้อมสาเหตุ การตัดสินใจ และคนที่ตัดสิน รายการนั้นก็ยังเป็น specification ที่ซื่อตรงฉบับแรกที่ระบบเก่าเคยมีด้วย
fork และการเทียบอยู่ที่ไหน
- ในแอปพลิเคชัน หลัง abstraction ตัวเรียกใช้ interface และ implementation ตัวหนึ่งของ interface นั้นเรียกโค้ดเก่า เรียกโค้ดใหม่ เทียบ แล้วคืนผลของตัวเก่า branch by abstraction มี seam และ switch อยู่ถูกที่อยู่แล้ว และ verify branch by abstraction ของ Steve Smith ก็คือการจัดแบบนี้ในรูปแบบเข้มงวด: implementation สำหรับตรวจสอบเรียกทั้งสองตัวด้วย input เดียวกัน แล้ว fail fast เมื่อ output ไม่ตรงกัน Scientist ของ GitHub เป็น library ที่ดังที่สุดสำหรับรูปแบบที่ใช้ใน production พฤติกรรมเดิมใส่ไว้ใน block
use(control) และพฤติกรรมใหม่ใส่ใน blocktry(candidate) ตัว experiment จะคืนค่าของ control เสมอ เบื้องหลังมันตัดสินว่าจะรัน candidate เลยหรือไม่ รันสองตัวในลำดับสุ่ม จับเวลาทั้งคู่ เทียบค่า บันทึก exception จาก candidate แทนที่จะโยนออกไป และ publish ผลไปที่อะไรก็ได้ที่คุณเสียบไว้ ส่วน README ของมันแสดงรายการ port สำหรับ .NET, Java, Python, Go, Node.js และ platform อื่น ๆ - ใน proxy ที่อยู่หน้า deployment สองชุด proxy ส่งแต่ละ request ไปที่ instance เก่ากับ instance ใหม่ แล้วเทียบ response Diffy ที่เริ่มมาจาก Twitter ทำงานแบบนี้ ตอนนี้ Twitter archive repository ที่ใช้ license Apache ของมันไปแล้ว ส่วนผู้เขียนคนเดิมดูแลเครื่องมือนี้ต่อในชื่อ Opendiffy ภายใต้ license Creative Commons ที่อนุญาตเฉพาะการใช้งานที่ไม่ใช่เชิงพาณิชย์ เพราะฉะนั้นอ่านเงื่อนไขก่อนจะเอาไปต่อยอด ส่วนถ้าทั้ง service กำลังถูกแทนที่ ตัว facade ของ strangler fig ก็คือที่ตามธรรมชาติของ fork
- หลัง response แบบ out of band ระบบเก่าตอบตัวเรียกก่อน แล้วค่อยส่ง request พร้อม response ของตัวเองไปให้ตัวตรวจ ที่จะเรียกระบบใหม่และเทียบอยู่เบื้องหลัง ทีม Returns ของ Zalando ทำแบบนี้ ตอนย้าย logic เรื่องการคืนสินค้าออกจาก monolith ตัว monolith post แต่ละ request พร้อม response ไปที่ endpoint สำหรับ consistency check ของ service ใหม่ ที่รับไว้ทันที แล้วค่อยเล่น request นั้นซ้ำกับตัวเองทีหลัง และสร้าง metric กับ log เรื่องความต่างออกมา
- แบบ batch รันทั้งสอง pipeline กับ input ชุดเดียวกันในช่วงเวลาเดียวกัน แล้ว reconcile output: join กันด้วย business key แล้วแสดงรายการว่าอะไรหาย อะไรเกิน อะไรต่าง สำหรับใบแจ้งหนี้ statement และรอบจ่ายเงินเดือน นี่คือรูปแบบตามธรรมชาติ
ถ้าสองระบบ model domain ต่างกัน ให้เทียบในภาษาที่ตัวเรียกเห็น การแปลที่ anti-corruption layer ทำให้ตัวเรียก ก็ใช้กับ comparator ได้ด้วย
Side effect และ state
การเทียบคำตอบสองชุดของคำถามหนึ่งข้อไม่มีพิษภัย แต่การรันคำสั่งสองรอบไม่ใช่: บัตรโดนตัดเงินสองรอบ อีเมลถูกส่งสองฉบับ ตัวนับขยับสองครั้ง read path เลยเป็นกรณีที่ง่าย และเครื่องมือก็ขีดเส้นไว้ตรงนั้น README ของ Scientist บอกว่า library นี้ปลอดภัยเฉพาะกับโค้ดที่ไม่เปลี่ยนข้อมูล เพราะไม่มีอะไรรับประกันว่า candidate จะรันทุกครั้ง ส่วน Diffy ตัวดั้งเดิมของ Twitter ที่ archive ไปแล้ว ข้าม request แบบ POST, PUT และ DELETE เว้นแต่จะสั่งไว้เป็นอย่างอื่น และทีมของ Zalando ก็ระบุ endpoint ที่ไม่ idempotent ไว้ในข้อจำกัดของวิธีที่ใช้
สำหรับโค้ดที่เขียนข้อมูล มีสามวิธีทำให้ candidate ไม่เป็นอันตราย และไดอะแกรมใช้สองวิธีแรก
- ให้ candidate มี store ของตัวเอง มันเขียนลงสำเนาที่แยกไว้ และไม่มีอะไรอื่นอ่านสำเนานั้น
- Stub การเรียกออกไปข้างนอกของมัน payment provider, อีเมล, message ไปหาระบบอื่น: candidate คุยกับตัวแทนที่จดไว้ว่าถูกสั่งให้ทำอะไร
- เทียบสิ่งที่มันจะทำ แทนการให้มันทำจริง ให้ทั้งสองฝั่งสร้างคำสั่งออกมา (SQL statement, message, payment request) แล้วเทียบคำสั่ง บ่อยครั้งนี่คือการเทียบที่มีค่าที่สุด เพราะ side effect คือพฤติกรรมที่สำคัญจริง ๆ
state คือส่วนที่ยาก candidate คำนวณบนข้อมูลของตัวเอง และถ้าข้อมูลนั้นค่อย ๆ ห่างจากข้อมูลจริง แล้ว mismatch ทุกตัวหลังจากนั้นก็จะบอกอะไรเกี่ยวกับ drift ไม่ได้บอกอะไรเกี่ยวกับโค้ด เติม store ที่แยกไว้จาก snapshot แล้วทำให้มันตามทันด้วยการ apply การเขียนชุดเดียวกันลงทั้งสองที่: dual write ในแอปพลิเคชัน หรือ change data capture จาก live store ส่วน README ของ Scientist อธิบายการแบ่งงานแบบเดียวกัน: เปลี่ยนทั้งสองระบบตรงทุกจุดที่มีการเขียน ตรวจตอนอ่าน และเก็บ script สำหรับ reconcile ข้อมูลที่อยู่นิ่งไว้ Stripe ใช้ Scientist แบบนั้น ตอนย้าย subscription ไปไว้ใน table ของตัวเอง: เขียนลงทั้งสอง table อ่านจากทั้งสอง table และความต่างระหว่างการอ่านสองครั้งก็ยิง alert ไปหา engineer ทั้งหมดนี้ก่อนที่จะสลับการอ่านใด ๆ
หลัง cut-over กฎเดียวกันก็ใช้แบบกลับด้าน ถ้า implementation เก่ายังรันต่อในฐานะ shadow การเขียนและการเรียกของมันก็คือสิ่งที่ต้องแยกออกไป
เอา noise ออกจากการเทียบ
implementation ที่ถูกต้องทั้งสองตัวก็ยังให้ byte ต่างกันได้
- เวลา แต่ละฝั่งอ่านนาฬิกาเอง timestamp หรือ "valid until" เลยต่างกัน ส่งเวลาจุดเดียวกันเข้าไปให้ทั้งคู่ หรือไม่ก็เอา field พวกนี้ออกจากการเทียบ
- ค่าที่ generate ขึ้น identifier, token, random sample ให้ฉีดเข้าไปหรือไม่ก็ตัดออก
- ลำดับ list ที่ความหมายจริงคือ set หรือ key ของ map ให้เรียงก่อนเทียบ
- การแทนค่าตัวเลข binary floating point ให้ตัวเลขหลักท้าย ๆ ต่างกันถ้าลำดับการคำนวณต่างกัน เทียบเงินเป็นทศนิยมแบบแม่นยำหรือเป็นจำนวนเต็มของหน่วยย่อย และเก็บค่าความคลาดเคลื่อนที่ยอมรับได้ไว้ใช้กับค่าที่เป็นค่าประมาณจริง ๆ
- ข้อมูลที่ขยับระหว่างการเรียกสองครั้ง Scientist รันสอง block ต่อกัน และ README ของมันชี้ว่าข้อมูลที่สอง block พึ่งอยู่อาจเปลี่ยนไปในระหว่างนั้น
- ความต่างของ envelope ลำดับ header, whitespace, ค่า default ที่เขียนออกมาหรือละไว้ การเทียบของ Zalando ข้าม header ที่ไม่มีผลต่อผลลัพธ์
ทำการ normalise ไว้ที่เดียว ใช้กับทั้งสองฝั่ง และทำรายการ field ที่ข้ามไว้ให้สั้นและผ่านการ review: กฎ ignore ทุกข้อคือจุดที่ความต่างจริง ๆ ซ่อนอยู่ได้ Scientist มี compare สำหรับการเทียบแบบ custom, clean สำหรับลดค่าลงเหลือแค่สิ่งที่คุ้มจะเก็บ และ ignore สำหรับความต่างที่รู้อยู่แล้ว README ของมันเตือนไว้ด้วย: ตราบที่ยังมีกฎ ignore อยู่ ก็แปลว่ารู้อยู่แล้วว่า candidate ทำงานต่างออกไป
การวัดว่ามี noise มากแค่ไหนก่อนจะโทษโค้ดใหม่ก็ช่วยได้ Diffy ส่งทุก request ไปที่สาม instance: candidate และสำเนาสองตัวของเวอร์ชันล่าสุดที่รู้ว่าดี เรียกว่า primary กับ secondary อะไรที่ต่างกันระหว่าง primary กับ secondary ไม่มีทางมาจากโค้ดใหม่ Diffy เลยเทียบว่าสองตัวนั้นเห็นไม่ตรงกันบ่อยแค่ไหน กับ primary และ candidate เห็นไม่ตรงกันบ่อยแค่ไหน และถือว่าความต่างที่ไม่ได้เกิดบ่อยกว่า noise ก็คือ noise ส่วน README ของ Scientist แนะนำการ calibrate แบบเดียวกันในโค้ด: เริ่มด้วย experiment ที่ทั้งสอง block เรียก method เก่า
เทียบอะไรได้อีก
ผลลัพธ์มาก่อน และการรันรอบเดียวกันก็ตอบคำถามอื่นได้อีกฟรี ๆ
- Latency Scientist บันทึก wall-clock time และ CPU time ของทั้งสอง block ส่วน GitHub ไม่ถือว่า merge experiment ของตัวเองเสร็จ จนกว่าจะไม่เหลือกรณีที่ช้าด้วย
- Error exception หรือ timeout ใน candidate ตรงที่โค้ดเก่าตอบได้ ก็คือ mismatch เหมือนตัวอื่น ๆ ส่วนความล้มเหลวที่เหมือนกันสองตัวนับว่าตรงกันใน Scientist
- การใช้ resource จำนวน query ต่อ request, memory, การเรียก service อื่น: candidate ที่ได้คำตอบถูกแต่ใช้ query มากกว่าสิบเท่ายังไม่พร้อม
นานแค่ไหน และแพงแค่ไหน
การรันนานพอเมื่อมันเห็น input ที่สำคัญครบแล้ว และ input ที่เกิดไม่บ่อยก็มาตามปฏิทิน: สิ้นเดือน สิ้นไตรมาส รอบคำนวณภาษีประจำปี การคืนเงินและการกลับรายการ โปรโมชัน วันที่ 29 กุมภาพันธ์ ชั่วโมงที่เปลี่ยนเวลา สำหรับ logic ด้านการเงิน นั่นแปลว่าต้องครบรอบธุรกิจเต็ม ๆ งานที่สม่ำเสมอและปริมาณสูงไปถึงจุดนั้นเร็วกว่า อย่าง GitHub เริ่ม merge experiment กับ 1% ของ request ใช้เวลาสี่วันแก้สิ่งที่รายการ mismatch แสดงออกมา แล้วรันกับทุก request เป็นเวลา 24 ชั่วโมงโดยไม่มี mismatch หรือกรณีที่ช้าเลยแม้แต่ตัวเดียว ถึงตอนนั้น experiment ได้ตรวจ merge ไปหลายสิบล้านครั้งแล้ว และ GitHub ก็สลับ ตั้งเกณฑ์ไว้ล่วงหน้า อย่าง Zalando ก็ตั้งระดับ consistency ที่คาดไว้ให้ทุก endpoint
ระหว่างนั้นทุกอย่างถูกทำสองรอบ และบทความของ Zalando เตือนว่าโหลดของ component ที่เกี่ยวข้องอาจเพิ่มเป็นสองเท่า มีสองอย่างที่ช่วยลดค่าใช้จ่าย
- รัน candidate นอกเส้นทางของตัวเรียก ตอบก่อน แล้วค่อยเทียบ: บน background worker, จาก queue หรือจาก log ของ request ที่เอามาเล่นซ้ำทีหลัง วิธีนี้ยังกัน candidate ที่ช้าหรือพังไม่ให้ไปถึงตัวเรียกด้วย ส่วน Scientist รันทั้งสอง block ภายใน request และอย่างที่ README ของมันบอก มันไม่ได้ป้องกัน candidate ที่ timeout
- สุ่มตัวอย่าง เทียบแค่เปอร์เซ็นต์หนึ่งของ input แล้วค่อยเพิ่มขึ้นตามความมั่นใจ Scientist ปล่อยเรื่องนี้ให้ method
enabled?ที่คุณเขียนเอง การสุ่มตัวอย่างเจอความต่างที่เป็นระบบได้เร็ว แต่เจอ input ที่เกิดไม่บ่อยได้ช้า เพราะฉะนั้นเพิ่มไปให้ถึง 100% ก่อนจะเชื่อตัวเลขศูนย์
คำแนะนำของ Toth คือรัน experiment ให้นานเท่าที่ต้องใช้เพื่อให้มั่นใจ แล้วไม่นานกว่านั้น
Cut over และหลังจากนั้น
- สลับบทบาทด้วย switch feature flag ตัดสินว่าจะคืนคำตอบของฝั่งไหน การสลับเลยไม่ต้อง deploy และย้อนกลับได้ในไม่กี่วินาที มันขยับให้ traffic ส่วนหนึ่งก่อนได้ และ ณ จุดนั้นมันก็คือ canary release ของ implementation ที่รู้อยู่แล้วว่าให้คำตอบเหมือนกัน
- เทียบต่อไปอีกสักพัก พอให้โค้ดเก่าเป็น shadow การเทียบก็รันต่อหลังสลับ และจับสิ่งที่ช่วงก่อนหน้าไม่มีได้
- ย้ายข้อมูลแยกต่างหาก ถ้า implementation ใหม่เก็บ state ต่างออกไป การ migrate นั้นก็มีขั้นที่ compatible ของมันเอง: expand and contract
- เปิดทางถอยไว้ การถอยกลับทำได้แค่ตราบที่ implementation เก่ายังอ่าน state ปัจจุบันได้ ส่วน README ของ Scientist แนะนำให้คงการเขียนซ้ำสองที่ไว้จนกว่าพฤติกรรมใหม่จะอยู่ใน production มานานพอสมควร
- แล้วค่อยลบทุกอย่าง implementation เก่า และ fork, comparator, stub, store ที่แยกไว้ และ flag ด้วย ส่วน parallel run ที่ไม่มีวันจบก็คือระบบสองระบบที่ต้องดูแล
มันไม่ใช่อะไร
| ใครได้คำตอบของเวอร์ชันใหม่ | บอกอะไรคุณ | |
|---|---|---|
| Parallel run | ไม่มีใคร จนกว่าจะ cut over | ตัวเก่ากับตัวใหม่เห็นตรงกันไหม ทีละ input |
| Canary release | user จริงในสัดส่วนที่ค่อย ๆ เพิ่มขึ้น | build ใหม่แข็งแรงไหมภายใต้ traffic จริง: error, latency, saturation |
| Feature flags | user หรือสัดส่วนที่กฎเลือกไว้ | ตัวมันเองไม่บอกอะไร: มันคือ switch และในที่นี้ก็คือตัวที่สลับบทบาท |
| Blue-green deployment | ทุกคน จากวินาทีหนึ่งไปอีกวินาทีหนึ่ง | environment ใหม่ทั้งชุดรับช่วงต่อได้เรียบร้อยไหม โดยเก็บตัวเก่าไว้เป็นทางถอย |
Shadow traffic เป็นอีก pattern หนึ่งใน catalog นี้ ที่ mirror request จริงไปที่เวอร์ชันใหม่ที่ network layer โดยส่วนใหญ่ก็เพื่อดูว่ามันรับมือกับโหลดและ input ของ production ได้ยังไง proxy ทิ้ง response ที่ mirror ไปแล้ว (Istio อธิบาย mirrored request ว่าเป็น fire and forget) การจะเทียบมันเลยต้องมีขั้น diff ซ้อนเพิ่มเข้าไป และขั้นนั้นก็คือ parallel run แบบ proxy ส่วน dark launching ของ Martin Fowler คือไอเดียที่กว้างกว่า ว่าด้วยการรันพฤติกรรม back-end ใหม่ใน production โดยไม่ให้ user เห็น ปกติเพื่อวัดโหลดและ performance ของมันก่อนจะเปิด feature เขาชี้ว่ามันยังทำให้รัน feature ที่ implement ใหม่แบบ parallel ได้ด้วย โดยเรียกทั้งสองเวอร์ชันแล้วตรวจผลของทั้งคู่
ใช้ตอนไหนดี
- คำตอบที่ผิดแพงและมองเห็นยาก: pricing, billing, ภาษี, เงินเดือน, ดอกเบี้ย, การตัดสินใจด้าน risk และ credit, entitlement และ permission
- ไม่มีใครรู้กฎครบ ระบบ legacy ที่มีกรณีพิเศษไม่มีเอกสารสะสมมาหลายปี จนโค้ดเก่ากลายเป็น specification
- การคำนวณหรือ query path กำลังถูกแทนที่ ไม่ได้ถูกออกแบบใหม่: engine ใหม่, data store ใหม่ที่อยู่หลังการอ่านชุดเดิม, query ที่เขียนใหม่ ส่วน GitHub รายงานว่าใช้ Scientist กับโค้ดเรื่อง permission, กับการย้ายไป code search cluster ใหม่ และกับ query ที่ optimise แล้ว
- เป็นขั้นตรวจสอบของ migration ที่ใหญ่กว่า: ก่อนที่ strangler fig จะย้าย route หรือก่อนที่ branch by abstraction จะขยับ switch
ตอนไหนไม่ควรใช้
- เทียบ output กันไม่ได้ คำตอบเป็นแบบสุ่มหรือจัดอันดับโดยตั้งใจ หรือระบบใหม่ตั้งใจให้ทำงานต่างออกไป การเทียบต้องมีผลลัพธ์ที่คาดไว้ และในกรณีนี้ระบบเก่าให้ผลแบบนั้นไม่ได้
- แยก side effect ออกไม่ได้ interface ของ partner ที่ไม่มี sandbox, อุปกรณ์, ledger ที่คัดลอกไม่ได้: ถ้า candidate รันไม่ได้โดยไม่มีผลตามมา ก็รันแบบ parallel ไม่ได้
- ให้ state เดียวกันกับทั้งสองตัวไม่ได้ แบบนั้น mismatch จะวัดข้อมูล ไม่ได้วัดโค้ด
- การเปลี่ยนเล็กพอจะเทสต์แบบปกติได้ ข้อสรุปของ Newman เองในหนังสือคือ pattern นี้ต้องลงแรงจริง และเหมาะกับการเปลี่ยนที่ถือว่าเสี่ยงสูง ที่แรงที่ลงไปคุ้มกับสิ่งที่ได้ การเปลี่ยนที่ unit test และ contract test ครอบคลุมได้ดีไม่ต้องใช้มัน
ได้อะไร เสียอะไร
- ทุกอย่างถูกทำสองรอบ ทั้ง compute, query ที่ยิงเข้า database ที่ใช้ร่วมกัน, การเรียก dependency ที่ใช้ร่วมกัน และ latency ที่เพิ่มขึ้นถ้า candidate รันภายใน request
- Harness ก็เป็น software เหมือนกัน fork, การแยก, comparator, กฎ normalise และ dashboard ต้องถูกสร้าง เทสต์ และลบทิ้งทีหลัง ความผิดพลาดในส่วนนี้อันตรายในทิศทางเดียว: comparator ที่ normalise มากเกินไปจะรายงาน 100% ให้สองระบบที่เห็นไม่ตรงกัน
- มันพิสูจน์ว่าเหมือน ไม่ได้พิสูจน์ว่าถูก การเห็นตรงกับระบบเก่ารวมถึงการเห็นตรงกับ bug ของมันด้วย การเปลี่ยนพฤติกรรมที่ตั้งใจไว้ จะโผล่มาเป็น mismatch และต้องใช้เทสต์แบบอื่น
- มันครอบคลุมแค่ input ที่เกิดขึ้นจริง กรณีที่ไม่เกิดระหว่างการรันก็ไม่ถูกเทียบ การเล่น input ที่บันทึกไว้จากช่วงก่อนหน้าซ้ำช่วยขยายความครอบคลุมได้
- สอง implementation ต้องเดินไปพร้อมกัน กฎที่เปลี่ยนระหว่างการรันต้องเปลี่ยนทั้งสองที่ ไม่อย่างนั้นการรันจะวัดการเปลี่ยนนั้นแทน การรันที่ยาวนานเลยชวนให้ต้อง freeze โค้ดเก่า หรือไม่ก็ทำงานเป็นสองเท่า
- Log เต็มไปด้วยข้อมูล production input และ output ถูกบันทึกไว้เต็ม ๆ เพื่อให้สร้าง mismatch ซ้ำได้ และมีทั้งข้อมูลส่วนบุคคลและข้อมูลการเงินอยู่ด้วย ทีมของ Zalando ชี้ถึงความขัดแย้งนี้: field ที่อ่อนไหวไม่ควรถูกเก็บ หรือควรถูกล้างทีหลัง แต่แบบนั้น mismatch ที่เกิดตรง field พวกนั้นพอดีก็จะวิเคราะห์ยาก
- ประโยชน์มาช้า ระบบใหม่ไม่ได้รับ traffic ที่มีผลจริงเลยจนกว่าจะ cut over การรันเลยทำให้สิ่งที่การ rewrite ตั้งใจจะได้ต้องช้าออกไป นั่นคือราคาของหลักฐาน
ข้อควรรู้ตอนลงมือทำ
ปกป้องคำตอบ ไม่ว่า candidate จะทำอะไร ต้องไม่ไปเปลี่ยน ทำให้ช้า หรือทำพังสิ่งที่ตัวเรียกได้รับ catch ทุกอย่างที่มันโยนออกมา จำกัดเวลาของมัน และให้ thread กับ connection ของตัวเองกับมัน (bulkhead) หรือรันมันหลังจากส่ง response ออกไปแล้ว
ถ้าใช้ Scientist fork ก็คือโค้ดไม่กี่บรรทัดในตัวเรียก ส่วน experiment class ของคุณเองเป็นตัวให้
enabled?(candidate รันบ่อยแค่ไหน) และpublish(ผลไปที่ไหน)class Checkout include Scientist def total(order) science "pricing-v2" do |e| e.context order_id: order.id # published together with the result e.use { PricingV1.total(order) } # control: this value is returned e.try { PricingV2.total(order) } # candidate: compared and recorded, never returned e.clean { |amount| amount.to_s } # what is kept of each value for the mismatch log end end endบันทึกให้พอจะสร้างซ้ำได้ สำหรับทุก mismatch ให้เก็บ input, output ทั้งสองฝั่ง, build ของทั้งสองฝั่ง, เวลา และ correlation ID ปิดบังส่วนที่ไม่ต้องอ่านออก จำกัดการเข้าถึง และกำหนดระยะเวลาเก็บ
Normalise ใน function เดียวที่มีเทสต์ ที่แปลงผลลัพธ์ให้อยู่ในรูปมาตรฐาน (เรียงแล้ว ตัด field ที่เปลี่ยนไปมาออก จำนวนเงินเป็นทศนิยมแบบแม่นยำ) และใช้กับทั้งสองฝั่ง ให้กฎ ignore ทุกข้อมีเหตุผล เจ้าของ และวันหมดอายุ
เทียบเงินแบบตรงเป๊ะ ค่าคลาดเคลื่อนที่ยอมให้หนึ่งเซนต์จะซ่อน defect เรื่องการปัดเศษที่การรันมีไว้หาพอดี
เฝ้าดูการรันเหมือนเป็น service ตัวหนึ่ง match rate แยกตาม operation และตามประเภทของ input เพราะ 100% โดยรวมอาจซ่อนประเภท input ที่เกิดไม่บ่อยแต่ fail ทุกครั้งไว้ได้ ดู mismatch แยกตามประเภท, error rate ของ candidate และ latency ของทั้งคู่ พอตัวเลขเป็นศูนย์แล้ว ให้ยิง alert เมื่อมี mismatch ใหม่ตัวไหนก็ตาม
เล่นซ้ำเพื่อไปถึงเร็วขึ้น input จาก production ที่บันทึกไว้ หรือไฟล์สิ้นเดือนของปีที่แล้ว เอามารันผ่านทั้งสองเวอร์ชันแบบ offline ได้ วิธีนี้ครอบคลุมกรณีที่เกิดไม่บ่อยโดยไม่ต้องรอปฏิทิน ส่วน mismatch ที่ log ไว้ก็เอาไปทำเป็น regression test ได้ดี
เขียนเกณฑ์การจบไว้ก่อน: ไม่มี mismatch ที่อธิบายไม่ได้ตลอดจำนวนรอบที่กำหนดไว้ที่รวมกรณีที่เกิดไม่บ่อยไว้แล้ว, latency และ error rate ของ candidate อยู่ในขอบเขตที่กำหนด, และมีวันที่ที่จะลบโค้ดเก่ากับ harness ทิ้ง