Mapping Source Code and Builds to Microservices (การจับคู่ Source Code และ Build เข้ากับ Microservices)

เรา ได้ดูหัวข้อหนึ่งที่จุดชนวนให้ฝ่ายต่างๆ ถกเถียงกันไปแล้ว นั่นคือ feature branching เทียบกับ trunk-based development แต่ปรากฏว่าความขัดแย้งยังไม่จบแค่นั้นสำหรับบทนี้ อีกหัวข้อหนึ่งที่มักจะทำให้เกิดความเห็นที่หลากหลายมากคือการจัดระเบียบโค้ดสำหรับ microservices ของเรา ผมมีความชอบส่วนตัวของตัวเอง แต่ก่อนที่เราจะไปถึงตรงนั้น ลองมาสำรวจตัวเลือกหลักๆ ในการจัดระเบียบโค้ดสำหรับ microservices ของเรากันก่อน

One Giant Repo, One Giant Build (Repo ยักษ์เดียว Build ยักษ์เดียว)

ถ้า เราเริ่มจากตัวเลือกที่ง่ายที่สุด เราก็สามารถโยนทุกอย่างรวมกันได้เลย เรามี repository ยักษ์เดียวที่เก็บโค้ดทั้งหมดของเรา และเรามี build เดียว ดังที่เราเห็นใน Figure 7-5 การ check in ใดๆ เข้า source code repository นี้จะทำให้ build ของเรา trigger ขึ้น ซึ่งเราจะรัน verification step ทั้งหมดที่เกี่ยวข้องกับ microservices ทั้งหมดของเรา และสร้าง artifact หลายตัว ซึ่งทั้งหมดผูกกลับไปที่ build เดียวกัน

Using a single source code repository and CI build for all microservice

Figure 7-5. การใช้ source code repository และ CI build เดียวสำหรับ microservices ทั้งหมด

เมื่อเทียบกับแนวทางอื่นๆ วิธีนี้ดูเรียบง่ายกว่ามากเมื่อมองผิวเผิน มี repository ให้กังวลน้อยลง และ build ก็เรียบง่ายในเชิงแนวคิดมากกว่า จากมุมมองของนักพัฒนา ทุกอย่างก็ตรงไปตรงมาเช่นกัน ผมแค่ check in โค้ดเข้าไป ถ้าผมต้องทำงานกับหลาย service พร้อมกัน ผมก็แค่ต้องกังวลเรื่อง commit เดียว

โมเดลนี้จะทำงานได้ดีเลยถ้าคุณเห็นด้วยกับแนวคิดของ lockstep release ที่คุณไม่รังเกียจการ deploy หลาย service พร้อมกัน โดยทั่วไปแล้วนี่เป็น pattern ที่ควรหลีกเลี่ยงอย่างแน่นอน แต่ในช่วงเริ่มต้นของโปรเจกต์มากๆ โดยเฉพาะถ้ามีเพียงทีมเดียวที่ทำงานกับทุกอย่าง โมเดลนี้อาจสมเหตุสมผลในช่วงเวลาสั้นๆ

ทีนี้ให้ผมอธิบายข้อเสียสำคัญบางอย่างของแนวทางนี้ ถ้าผมเปลี่ยนแปลงโค้ดเพียงบรรทัดเดียวใน service เดียว เช่น เปลี่ยนพฤติกรรมใน User service ใน Figure 7-5 service อื่น ทั้งหมด ก็จะถูกตรวจสอบและ build ไปด้วย ซึ่งอาจใช้เวลานานกว่าที่จำเป็น เพราะผมต้องรอสิ่งที่อาจไม่จำเป็นต้องทดสอบเลย สิ่งนี้ส่งผลกระทบต่อ cycle time ของเรา ซึ่งก็คือความเร็วที่เราสามารถนำการเปลี่ยนแปลงหนึ่งครั้งจากการพัฒนาไปสู่การใช้งานจริงได้ แต่สิ่งที่น่ากังวลกว่านั้นคือการรู้ว่า artifact ตัวไหนควรหรือไม่ควรถูก deploy ตอนนี้ผมต้อง deploy build service ทั้งหมดเพื่อผลักการเปลี่ยนแปลงเล็กๆ ของผมเข้า production หรือเปล่า มันบอกยากมาก การพยายามเดาว่า service ไหน เปลี่ยนแปลงจริงๆ แค่จากการอ่าน commit message นั้นเป็นเรื่องยาก องค์กรที่ใช้แนวทางนี้มักจะจบลงที่การ deploy ทุกอย่างพร้อมกัน ซึ่งเป็นสิ่งที่เราต้องการหลีกเลี่ยงจริงๆ

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

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

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

Pattern: One Repository per Microservice (aka Multirepo) (Pattern: หนึ่ง Repository ต่อหนึ่ง Microservice หรือ Multirepo)

ด้วย pattern แบบหนึ่ง repository ต่อหนึ่ง microservice (ซึ่งมักถูกเรียกว่า pattern แบบ multirepo เมื่อเทียบกับ pattern แบบ monorepo) โค้ดของแต่ละ microservice จะถูกเก็บไว้ใน source code repository ของตัวเอง ดังที่เราเห็นใน Figure 7-6 แนวทางนี้ทำให้การจับคู่ระหว่างการเปลี่ยนแปลง source code กับ CI build เป็นไปอย่างตรงไปตรงมา

The source code for each microservice is stored in a separate source code repository

Figure 7-6. source code ของแต่ละ microservice ถูกเก็บไว้ใน source code repository แยกต่างหาก

การเปลี่ยนแปลงใดๆ ใน User source code repository จะ trigger build ที่ตรงกัน และถ้ามันผ่าน ผมก็จะได้ User microservice เวอร์ชันใหม่ที่พร้อม deploy การมี repository แยกต่างหากสำหรับแต่ละ microservice ยังทำให้คุณสามารถเปลี่ยน ownership เป็นรายๆ repository ได้ ซึ่งสมเหตุสมผลถ้าคุณต้องการพิจารณาโมเดล ownership แบบเข้มข้นสำหรับ microservices ของคุณ (จะพูดถึงเรื่องนี้ต่อในไม่ช้า)

อย่างไรก็ตาม ความตรงไปตรงมาของ pattern นี้ก็สร้างความท้าทายบางอย่างเช่นกัน โดยเฉพาะอย่างยิ่ง นักพัฒนาอาจพบว่าตัวเองต้องทำงานกับหลาย repository พร้อมกัน ซึ่งเป็นเรื่องยากลำบากมากถ้าพวกเขาพยายามเปลี่ยนแปลงข้าม repository หลายตัวพร้อมกัน นอกจากนี้ การเปลี่ยนแปลงไม่สามารถทำแบบ atomic ข้าม repository ที่แยกกันได้ อย่างน้อยก็ไม่ใช่กับ Git

Reusing code across repositories (การใช้โค้ดซ้ำข้าม Repository)

เมื่อ ใช้ pattern นี้ ก็ไม่มีอะไรมาขวางไม่ให้ microservice หนึ่งพึ่งพาโค้ดอื่นที่ถูกจัดการอยู่ใน repository อื่นได้ กลไกง่ายๆ สำหรับการทำสิ่งนี้คือการนำโค้ดที่คุณต้องการใช้ซ้ำมาแพ็คเป็น library ซึ่งจะกลายเป็น dependency ที่ชัดเจนของ microservices ที่อยู่ปลายน้ำ เราจะเห็นตัวอย่างของสิ่งนี้ใน Figure 7-7 ที่ซึ่ง Invoice และ Payroll service ต่างก็ใช้ Connection library ร่วมกัน

bms2 0707

Figure 7-7. การใช้โค้ดซ้ำข้าม repository ที่แตกต่างกัน

ถ้าคุณต้องการ roll out การเปลี่ยนแปลงใน Connection library คุณจะต้องทำการเปลี่ยนแปลงใน source code repository ที่ตรงกัน แล้วรอให้ build ของมันเสร็จสมบูรณ์ ซึ่งจะให้ artifact เวอร์ชันใหม่แก่คุณ เพื่อที่จะ deploy Invoice หรือ Payroll service เวอร์ชันใหม่ที่ใช้ library เวอร์ชันใหม่นี้จริงๆ คุณจะต้องเปลี่ยนเวอร์ชันของ Connection library ที่พวกมันใช้อยู่ นี่อาจต้องใช้การเปลี่ยนแปลงแบบ manual (ถ้าคุณ depend เวอร์ชันเฉพาะเจาะจง) หรืออาจตั้งค่าให้เกิดขึ้นแบบไดนามิกก็ได้ ขึ้นอยู่กับลักษณะของ CI tooling ที่คุณใช้ แนวคิดเบื้องหลังเรื่องนี้ถูกอธิบายไว้อย่างละเอียดในหนังสือ Continuous Delivery โดย Jez Humble และ Dave Farley 4

สิ่งสำคัญที่ต้องจำไว้แน่นอนก็คือ ถ้าคุณต้องการ roll out Connection library เวอร์ชันใหม่ คุณก็ต้อง deploy Invoice และ Payroll service เวอร์ชันที่ build ใหม่ด้วย จำไว้ว่าข้อควรระวังทั้งหมดที่เราสำรวจใน "DRY and the Perils of Code Reuse in a Microservice World" เกี่ยวกับการใช้ซ้ำและ microservices ยังคงใช้ได้อยู่ ถ้าคุณเลือกที่จะใช้โค้ดซ้ำผ่าน library คุณก็ต้องยอมรับความจริงที่ว่าการเปลี่ยนแปลงเหล่านี้ไม่สามารถ roll out แบบ atomic ได้ ไม่งั้นเราก็จะบั่นทอนเป้าหมายของ independent deployability คุณยังต้องตระหนักด้วยว่ามันอาจยากขึ้นที่จะรู้ว่า microservices บางตัวกำลังใช้ library เวอร์ชันใดอยู่ ซึ่งอาจเป็นปัญหาถ้าคุณกำลังพยายาม deprecate การใช้ library เวอร์ชันเก่า

Working across multiple repositories (การทำงานข้ามหลาย Repository)

นอกจากการใช้โค้ดซ้ำผ่าน library แล้ว เรายังจะเปลี่ยนแปลงข้าม repository ได้ด้วยวิธีไหนอีก ลองมาดูอีกตัวอย่างหนึ่งกัน ใน Figure 7-8 ผมต้องการเปลี่ยน API ที่ Inventory service เปิดให้ใช้ และผมยังต้องอัปเดต Shipping service ให้ใช้การเปลี่ยนแปลงใหม่นี้ได้ด้วย ถ้าโค้ดของทั้ง Inventory และ Shipping อยู่ใน repository เดียวกัน ผมก็สามารถ commit โค้ดได้ในครั้งเดียว แต่แทนที่จะเป็นแบบนั้น ผมจะต้องแบ่งการเปลี่ยนแปลงเป็นสอง commit หนึ่งสำหรับ Inventory และอีกหนึ่งสำหรับ Shipping

Changes across repository boundaries require multiple commits

Figure 7-8. การเปลี่ยนแปลงข้ามขอบเขต repository ต้องใช้หลาย commit

การแบ่งการเปลี่ยนแปลงแบบนี้อาจก่อให้เกิดปัญหาได้ ถ้า commit หนึ่งล้มเหลวแต่อีกอันสำเร็จ ตัวอย่างเช่น ผมอาจต้องทำการเปลี่ยนแปลงสองครั้งเพื่อ rollback การเปลี่ยนแปลงนั้น และมันอาจซับซ้อนขึ้นถ้ามีคนอื่น check in เข้ามาในระหว่างนั้น ความจริงก็คือในสถานการณ์เฉพาะนี้ ผมคงอยากจะจัดลำดับ commit อยู่ดี ผมอยากมั่นใจว่า commit ที่เปลี่ยน Inventory service ทำงานได้ก่อนที่ผมจะเปลี่ยน client code ใดๆ ใน Shipping service ถ้าฟังก์ชันใหม่ใน API ยังไม่มีอยู่จริง ก็ไม่มีประโยชน์อะไรที่จะมี client code ที่ใช้มันอยู่

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

อันที่จริง ผมมองว่าความยากลำบากในการทำงานข้ามหลาย repo นั้นมีประโยชน์ในการช่วยบังคับให้ขอบเขตของ microservice ชัดเจนขึ้น เพราะมันบังคับให้คุณคิดอย่างรอบคอบว่าขอบเขตเหล่านี้อยู่ตรงไหน และธรรมชาติของการปฏิสัมพันธ์ระหว่างพวกมันเป็นอย่างไร

เคล็ดลับ

ถ้าคุณเปลี่ยนแปลงข้าม microservices หลายตัวอยู่ตลอดเวลา นั่นก็มีแนวโน้มว่าขอบเขตของ microservice คุณอยู่ผิดที่ อาจคุ้มค่าที่จะพิจารณารวม microservices กลับเข้าด้วยกัน ถ้าคุณสังเกตเห็นว่าสิ่งนี้กำลังเกิดขึ้น

จากนั้นก็ยังมีความยุ่งยากของการต้อง pull จากหลาย repo และ push ไปหลาย repo ในฐานะส่วนหนึ่งของ workflow ปกติของคุณ จากประสบการณ์ของผม เรื่องนี้สามารถทำให้ง่ายขึ้นได้ ไม่ว่าจะโดยการใช้ IDE ที่รองรับหลาย repository (ซึ่งเป็นสิ่งที่ IDE ทุกตัวที่ผมใช้มาตลอดห้าปีที่ผ่านมารองรับได้) หรือโดยการเขียน wrapper script ง่ายๆ เพื่อลดความยุ่งยากเมื่อทำงานผ่าน command line

Where to use this pattern (ควรใช้ Pattern นี้เมื่อไร)

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

Pattern: Monorepo (Pattern: Monorepo)

ด้วย แนวทาง monorepo โค้ดของ microservices หลายตัว (หรือโปรเจกต์ประเภทอื่นๆ) จะถูกเก็บไว้ใน source code repository เดียวกัน ผมเคยเห็นสถานการณ์ที่ monorepo ถูกใช้โดยทีมเดียวเพื่อจัดการ source control สำหรับ service ทั้งหมดของทีมนั้น แม้ว่าแนวคิดนี้จะถูกทำให้เป็นที่นิยมโดยบริษัทเทคโนโลยียักษ์ใหญ่บางแห่ง ที่หลายทีมและนักพัฒนาหลายร้อยหรือหลายพันคนสามารถทำงานบน source code repository เดียวกันได้

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

ถ้าเรายกตัวอย่างที่เพิ่งคุยกันไป ที่เราต้องการเปลี่ยนแปลง Inventory ให้เปิดเผยพฤติกรรมใหม่บางอย่าง และอัปเดต Shipping service ให้ใช้ฟังก์ชันใหม่ที่เราเปิดเผยนี้ด้วย การเปลี่ยนแปลงเหล่านี้ก็สามารถทำได้ใน commit เดียว ดังที่เราเห็นใน Figure 7-9

Using a single commit to make changes across two microservices using a monorepo

Figure 7-9. การใช้ commit เดียวเพื่อเปลี่ยนแปลงข้ามสอง microservices โดยใช้ monorepo

แน่นอนว่า เช่นเดียวกับ pattern แบบ multirepo ที่คุยกันไปก่อนหน้านี้ เรายังคงต้องจัดการกับด้านการ deploy อยู่ดี เราคงต้องพิจารณาลำดับการ deploy อย่างรอบคอบ ถ้าเราต้องการหลีกเลี่ยง lockstep deployment