การเดินทางสู่ CI (Continuous Integration)
ผมเคยคิดว่าการทำ CI เป็นเรื่องง่ายๆ และเป็นเบสิคของทุกทีมที่ต้องปฏิบัติกัน แต่ในความเป็นจริง น้อยทีมมากที่จะทำออกมาได้ดี เพราะมันมีรายละเอียดยุ่บยั่บที่มากกว่าแค่เรื่องทางเทคนิค
ในบันทึกฉบับนี้ เราจะมาดูรายละเอียดยุ่บยั่บเหล่านี้กัน โดยผมจะนำเสนอด้วยวิธีการเล่าเรื่อง ตั้งแต่ทีมเริ่มต้นทำ CI ง่ายๆ ไปจนถึงจุดทีเป็น CI ที่สมบูรณ์มากขึ้น
โดยเราสมมติว่ามีทีมทีมหนึ่ง เริ่มต้นจากที่ไม่มีอะไรนอกจากเลยนอกจาก Version Control แล้วลองผิดลองถูก ทำการปรับปรุงกระบวนการ เรื่อยๆ จนมาถึงจุดที่เป็น CI ที่สมบูรณ์มากขึ้น
ภาค 1: เริ่มต้นจากบั้ก
เคยเจอสถานการณ์นี้ไหมครับ? แก้บั๊กหนึ่งตัว แต่วันถัดไปมีบั้กโผล่ขึ้นมา 3 ตัว
พอนั่งดีบั้กไปเรื่อยๆ ก็ค้นพบว่า ไอ้บั้ก 3 ตัวนี้เกิดขึ้นจากการแก้บั๊กของเราเมื่อวาน เพราะโค้ดที่เราแก้ดันไปส่งผลกระทบต่อโค้ดส่วนอื่น
เราเรียกบั๊กพวกนี้ว่า Regression bug
สถานการณ์นี้เกิดขึ้นบ่อย เวลาที่เราไม่มี Automated Testing ที่ครอบคลุมโค้ดของเราได้อย่างเพียงพอ ทุกๆครั้งที่เราแก้โค้ด เรามีความเสี่ยงที่จะสร้างบั๊กได้โดยไม่รู้ตัว เพราะระหว่างที่เราพัฒนาฟีเจอร์หรือแก้บั๊ก เราโฟกัสอยู่ที่ฟีเจอร์หรือบั๊กนั้นๆ โดยไม่ได้คิดว่าผลกระทบต่อส่วนอื่นในโปรแกรมจะเป็นอย่างไร
เพื่อแก้ปัญหานี้ ทีมจึงต้องมี Automated Testing เพื่อที่จะสามารถรันเทสต์ได้บ่อยๆโดยไม่ต้องรอผลจากเทสเตอร์อีกวันสองวัน (ซึ่งถึงจุดนั้น อาจจะแก้โค้ดไปเยอะแล้ว ทำให้หาบั๊กยากขึ้น)
หากเรามี Automated Testing ที่ครอบคลุมฟีเจอร์ที่เคยทำเสร็จไปแล้ว (หรือบั้กที่แก้ไปแล้ว) การทดสอบนี้จะช่วยเตือนเราทันที หากโค้ดที่เราแก้ไปมีผลกระทบตรงจุดอื่น
คนในทีมเลยตกลงกันว่าก่อนจะ Commit/Push โค้ด เราจะต้องการรัน Build + Automated Testing ให้ผ่านก่อน
ภาค 2: เหยียบเท้ากันเอง
สืบเนื่องจากกรณีที่แล้ว เราจึงตกลงว่าจะมี Automated Testing นาย A กับนาย B ก็พัฒนาโปรแกรมแยกกันไป ก่อน Commit/Push เข้า Repository กลางก็ทำการรันเทสต์ผ่านเรียบร้อย
วันถัดมา นาย C เอาโค้ดล่าสุดมารัน Automated Testing ปรากฏว่าไม่ผ่าน พังไปเรียบร้อยแล้ว
ปัญหาเกิดขึ้นเนื่องจากเวอร์ชั่นที่นาย A กับ นาย B อาจจะมีโค้ดที่ขัดแย้งกัน พอเอามาทำงานร่วมกันก็เลยพัง เหมือนเหยียบเท้ากันเอง เนื่องจากเราให้นาย A กับ นาย B รันเทสต์กันเอง ระหว่างที่รันอยู่ ต่างฝ่ายต่างก็มีแค่โค้ดที่เพิ่มเข้ามาของตัวเอง พอรวมเสร็จ ก็ไม่ได้มีการรันเทสต์อีกครั้ง
ปัญหานี้สามารถแก้ได้โดยมีส่วนกลางที่ทำหน้ารัน Automated Testing โดยทีมสามารถจัดเซอร์เวอร์กลาง ที่ทุกครั้งที่มีการ Push code เข้าไปที่ Central repository เจ้าเซอร์เวอร์ตัวนี้ก็จะทำการดึงโค้ดออกมา ทำการรัน Build + Automated Testing ให้เรียบร้อย ถ้าผ่านก็โอเค ไม่ผ่านก็ส่งสัญญาณเตือนทุกคนในทีม ว่าพังแล้วจ้า แก้ด่วน
เจ้าเซอร์เวอร์ที่ว่านี้ ผมจะขอเรียกมันว่า CI Server
ในปัจจุบัน มีซอฟท์แวร์ที่ใช้ทำหน้าที่นี้เยอะมาก ตัวที่เก่าแต่คลาสสิคสุดก็คงจะเป็น Jenkins หรือใครที่มาสาย Microsoft ก็จะมี Team Foundation Server สาย Open source ก็จะมี Travis หรือฝั่งคลาวด์เอง AWS ก็จะมี CodePipeline
เพื่อไม่ให้เป็นการเชียร์ฝั่งไหนอย่างออกหน้าออกตา ผมขอเรียกมันว่า CI Server เฉยๆ ส่วนไส้ใน ใครจะใช้อะไรก็แล้วแต่นะครับ เพราะคอนเซ็บยังเหมือนกัน
พอมี CI Server ทำหน้าที่กลาง ในกรณีที่แล้ว ถ้านาย A Push โค้ดก่อนก็จะรันเทสต์ผ่าน ส่วนนาย B ที่ตามมาทีหลังก็จะถูกรันบนเวอร์ชั่นที่รวมกันเรียบร้อยแล้ว พอนาย B Push เข้าไปใน Git ก็จะพังทันที นาย B ต้อง revert โค้ดตัวเองก่อนแล้วกลับไปแก้ให้เรียบร้อย (ไม่ก็เรียกนาย A มานั่งคุยปรับความเข้าใจกัน)
ภาค 3: “It works on my machine !!”
หลังจากมี CI Server แล้ว ปัญหาที่มักจะเจอกันบ่อยๆคือ Automated Testing ผ่านบนเครื่องตัวเอง แต่ดันไปพังบน CI
หรือที่แย่กว่าคือผ่านบนเครื่องตัวเอง ผ่านบน CI Server แต่ดันไปพังในระบบจริง (Production)
สถานการณ์นี้เกิดขึ้นบ่อย จนกลายเป็นที่มาของประโยคสุดคลาสสิคในว่า “It works on my machine”
ณ จุดนี้ทีมต้องเริ่มพิจารณาตัวเองแล้วว่ามีการจัดการเซ็ตอัพระบบยังไง ปัญหาอยู่ที่ส่วนไหน ซึ่งหลักๆก็จะมี
- Libraries/Dependencies หากโปรแกรมเมอร์ต้องมานั่งลง Library หรือ Dependency บนเครื่องตัวเองเอง อันนี้การันตีได้ว่าจะเร็วจะช้าก็ต้องเจอปัญหานี้ เพราะ Library ที่ใช้ในแต่ละเครื่องอาจจะมีเวอร์ชั่นไม่ตรงกัน อันนี้ก็ต้องมีการพึ่งนำ Dependency Management Tool เข้ามา ซึ่งเครื่องมือพวกนี้สามารถกำหนด Dependency/Library ในเวอร์ชั่นที่ต้องการลงใน Text file แล้ว Commit เข้าไปใน Repository ด้วย
- Operating System กรณีนี้อาจจะต้องพึ่ง Docker หรือ VirtualMachine เพิ่อให้ทุกทีมสามารันเทสต์ในระบบที่ใกล้เคียงกับระบบจริงมากที่สุด
- Configurations ค่าคอนฟิกต่างๆ หรือ Environment Variable ที่ใช้ในแต่ละระบบ (Dev/Testing/Production) ไม่ตรงกัน
- External Components เช่น ตอนรันบนเทสต์อาจจะรันบนบนอีก DB นึง แต่พอรันในระบบจริงอาจจะใช้ DB อีกตัว หรืออาจจะใช้ Load Balancer คนละชนิดเพื่อประหยัดค่าใช้จ่ายใน Testing environment
ภาค 4: Build + Automated Testing พังตลอด เหนื่อยใจจะแก้
ถึงจุดนี้ เราอาจจะเรียกได้ว่าทีมทำ CI ได้สำเร็จแล้ว
จากประสบการณ์ แทบจะทุกทีม จะต้องมีช่วงเวลาถัดมา ช่วงเวลาที่เหมือนจะทำ CI แต่ความจริงไม่ได้ทำ เพราะ Build กับ Automated Testing พังตลอด
สาเหตุหลักๆมักจะมาจากการขาดวินัยภายในทีม
จริงๆแล้วการทำ CI ไม่ได้สำคัญแค่ที่ Tool อย่างเดียวครับ ทุกคนต้องมีวินัยด้วย
วินัยอย่างแรกเลย คือถ้าโค้ดพังอยู่ ห้ามส่งโค้ดใหม่เข้าไป เรื่องนี้สำคัญมาก เพราะทำให้การแก้บั้ก หรือทำให้กลับไปอยู่ในสภาพที่โค้ดยังทำงานได้ จะทำได้ยากขึ้นเมื่อมีจำนวนโค้ดทับเข้าไปมากขึ้นเรื่อยๆ จากเดิมที่มีเทสต์พังแค่สองสามอัน อาจจะเพิ่มขึ้นเป็นสี่ห้าอัน
ถ้าเราปฏิบัติตามนี้ เวลาโค้ดพังอยู่ ทีมจะทำอะไรไม่ได้ ต้องรออย่างเดียว ซึ่งเป็นเรื่องที่ถูกต้องแล้วนะครับ เหมือนเวลาเข้าส้วมแล้วเจอคนอื่นลืมกดชักโครก อย่าขี้ทับขี้ กดชักโครกให้สะอาดก่อน
เพราะเขียนเพิ่มไปก็เอาไปขึ้นระบบจริง (Production) ไม่ได้ แถมทำให้เละเทะกว่าเดิม
ดังนั้น ในทีมต้องมีการตกลงกันว่าหากโค้ดที่ตัวเอง Push ไปทำให้ Build พัง ถ้าแก้ไม่ได้ในห้านาที ควรจะเอาออกให้กลับไปสู่สถานะเดิมทันที (Revert)
วินัยถัดมาคือ หากใครรู้ตัวว่ากำลังจะกลับบ้านหรือไปทานข้าว ก็ยังไม่ควร Push Code เพราะหากพังแล้ว คนอื่นจะทำงานต่อไม่ได้ ต้องรอคน Push กลับมาแก้
ถ้าเกิดกลับบ้านแล้วป่วยขึ้นมา สัปดาห์หน้าไม่มาทำงาน คนอื่นก็ต้องตามมากดขี้ให้อีก
สังเกตว่านี่ไม่ใช่เรื่องทางเทคนิคเลย เป็นเรื่องของวินัยเพียงอย่างเดียว ซึ่งบางครั้งการแก้นิสัยคนนี่มันยากกว่าการแก้บั้กเยอะ ผมเห็นทีมจำนวนมากที่มี CI แต่บิ้ลด์พังแทบจะตลอดเวลา
ภาค 5: เพิ่มคุณภาพของ Automated Testing
ใครที่เขียนเทสต์ คงเคยได้ยินถึงคำว่า Coverage กันมาบ้าง ซึ่งส่วนใหญ่ที่ใช้กันจะเป็น Line Coverage หรือ Branch Coverage
Coverage เป็นตัวชี้วัดว่าเทสต์ที่เรามีอยู่ ครอบคลุมโค้ดของเรามากแค่ไหน โดยตัวเลขนี้จะนับเป็นเปอร์เซ็นต์ ถ้าค่า Line Coverage เป็น 60% แปลว่าถ้ามีโค้ดอยู่100 บรรทัด เทสต์ได้รันผ่าน 60 บรรทัด
หรืออีกนัยหนึ่งคือ ถ้าหากมีบั้กอยู่ใน 40 บรรทัดที่ไม่ได้รันผ่าน บั้กพวกนี้จะหลุดผ่านขั้นตอนการ CI ขึ้นไปถึงระบบจริงได้ (หรืออาจจะไปเจอตอน User Acceptance Testing)
ซึ่งระบบ CI Server ใหม่ๆ เราสามารถตั้งค่าได้ว่าหาก Coverage ต่ำกว่าที่กำหนดไว้ หรือค่า Coverage ลดลงจากเดิม ก็ให้ทำการพังบิ้ลด์ทันที เพื่อรักษาคุณภาพของการทดสอบ
อย่างไรก็ตาม ไอ้ค่า Coverage นี่ก็อย่าไปเชื่อมันมากนะครับ ผมเคยทำงานอยู่ทีมนึง โค้ดบางส่วนให้เวนเดอร์ (Vendor) เขียน โดยมีข้อตกลงกันว่าโค้ดจะต้องมี Coverage ไม่ต่ำกว่า 80%
วันดีคืนดี หัวหน้าเรียกให้ผมเข้าไปดูโค้ดที่ Vendor เขียน
ดูๆไป ผมไม่แน่ใจว่าจะขำหรือโมโหดี เพราะโค้ดที่เขียนมีการเรียกใช้ทุกฟังก์ชั่น ทำให้ Coverage ออกมาค่าเกิน 80% แต่ไม่มีการทำ Assertion (คือ การเช็คว่าผลลัพธ์ที่ออกมาถูกรึเปล่า) ไปเสียเกินครึ่ง ดังนั้น ต่อให้โค้ดทำงานผิดพลาด เทสต์ก็จะผ่าน (เพราะไม่มีการเช็ค) Coverage ที่ได้ออกมาก็มีค่าสูง
ในทีมเลยมีบางคนพยายามจะเพิ่ม Mutation Testing ซึ่งเป็นอีกทดสอบนึงที่ใช้ทดสอบคุณภาพของ Test Coverage
Mutation Testing คือการแก้โค้ดในขณะที่ทำงานอยู่ให้ส่งค่าผิดๆ แล้วดูว่าเทสต์จะพังรึเปล่า ถ้าพัง แปลว่าเทสต์โค้ดนั้นใช้ได้ แต่ถ้าไม่พัง แปลว่าเทสต์โค้ดห่วย (เพราะถึงค่าจะผิดเทสต์ก็ยังผ่านอยู่ดี)
(โดยส่วนตัว ผมว่าน่าจะเปลี่ยนเวนเดอร์มากกว่า แต่เอาเถอะ)
กล่าวโดยสรุป ตอนนี้ทีมได้มาถึงจุดที่พึ่ง CI เยอะมาก และต้องการให้ Automated Testing สามารถดักบั้กให้เยอะในระดับนึง เพื่อเพิ่มคุณภาพของโค้ด
นอกเหนือจาก Testing ตามปกติ บางทีมอาจจะเพิ่ม Smoke testing, Performance testing, Load testing, End2End Testing, Code quality check, Code security scan/Fuzz Testing เข้าไปด้วย ซึ่งก็แล้วแต่ทีมไป เนื้อหาอันนี้เอาไปเขียนเป็นหนังสือได้เลย ผมขอยกยอดไปเขียนในบันทึกอื่นๆนะครับ
ภาค 6: Build นานเป็นชาติ พังทีไม่รู้ว่ามาจาก Commit ไหน
เมื่อเราเริ่มเพิ่มปริมาณของ Automated Testing (หรือทำอย่างอื่นนอกเหนือจากแค่เทสต์) ช่วงเวลาที่ CI Server ต้องใช้ในการรันทุกอย่าง ก่อนจะแสดงผลว่าผ่านหรือไม่ผ่านก็จะนานไปด้วย
ในบางทีม ช่วงเวลานี้อาจจะแค่ครึ่งช.ม. หรือบางทีม อาจจะต้องใช้เวลาเป็นวัน
เมื่อทีมมาถึงจุดนี้ ปัญหาที่ตามมาคือกว่าบิ้ลด์จะแสดงผล มีคน Push Commit เข้าไปหลายอันแล้ว พอพัง ในทีมก็ไม่แน่ใจว่ามันเกิดจาก Commit ไหน อาจจะเกิดอาการเกี่ยงกัน (โค้ดผมไม่ผิดหรอก คุณนั่นแหละเช็คโค้ดคุณก่อนสิ) เสียเวลาให้รันเทสต์แยกที Commit บนเครื่องตัวเอง
พอจะ Revert ทีก็ยุ่งยากขึ้นอีก เพราะต้องไปเช็คว่ามันพังตั้งแต่รวม Commit ไหนเข้าไป
ถ้าทีมมีคนเยอะขึ้น ปัญหาก็จะยิ่งเกิดขึ้นถี่
ปัญหานี้นอกจากจะเสียเวลาแล้ว ยังทำให้คนในทีมหลีกเลี่ยงการ Push Commit บ่อยๆ เพราะต้องรอนาน Commit จะมีขนาดใหญ่ขึ้น บางคนรอหลายวันกว่าจะ Push ที หากเกิดปัญหาก็ทำให้แก้ยากขึ้นไปอีก
เป็นวงจรอุบาทว์ไปไม่สิ้น…
โดยหลักการแล้ว CI ควรจะแสดงผลลัพธ์ในการ Push ให้ได้ในระยะเวลาไม่เกิน 10 นาที หรือยิ่งเร็วยิ่งดี ถ้าใช้เวลานานกว่านั้น โอกาสที่จะเกิดปัญหาเบื้องต้นก็จะมากยิ่งขึ้น
เมื่อมาถึงจุดนี้แล้ว ทีมจะต้องเริ่มหาวิธีการทำให้ CI Server แสดงผลให้เร็วขึ้น ซึ่งวิธีหลักๆที่ผมเคยเจอมี ดังนี้
- แยกรันบนหลายเครื่อง (Parallel) เพื่อลดระยะเวลาการทำงาน
- แยกขั้นในการทดสอบออกเป็นหลายเฟส โดยเฟสแรกควรจะครอบคลุมให้ได้มากที่สุด แต่ใช้เวลาไม่เกิน 10 นาที (อาจจะเป็นแค่ Unit testing กับ Smoke testing) ส่วนพวกการทดสอบที่ใช้เวลานาน (End2End testing, Code security scan,ฯลฯ) ให้ทำในเฟสถัดไป ซึ่งถ้ามีการแยกเฟสแรกออกมาดี 80% ของการพังทั้งหมดควรจะถูกตรวจจับได้ตั้งแต่เฟสแรก ซึ่งใช้เวลาแค่ 10 นาทีก็รู้ผล
- ทำการพังเทสต์ทันทีถ้าหากรันนานเกิน เพื่อป้องกันพวกเทสต์ที่ไม่มีคุณภาพ ใช้เวลานาน
ช่วงนี้เป็นช่วงที่ยากลำบากครับ ทีมต้องยอมรับว่าทุกอย่างในโลกไม่มีอะไรที่สมบูรณ์แบบครับ ถ้าไม่แลกด้วยเม็ดเงินและแรงงาน (ใส่เครื่องเพิ่มเพื่อทำรันแบบ Parallel) ก็ต้องยอมรับว่าเราอาจจะทดสอบในเฟสแรกให้ละเอียดน้อยลง และยอมรับความเสี่ยงที่จะตรวจจับบั้คได้ช้าลง ซึ่งยังดีกว่าต้องรอเป็นเวลานาน กว่าจะได้ผลลัพธ์
ภาค 7: แก้โค้ดของทีมเรา แต่ดันทำทีมอื่นพัง
สมมติว่าธุรกิจไปได้สวย ระบบต้องการขยายตัวอย่างรวดเร็ว ทีมโปรแกรมเมอร์มีจำนวนเพิ่มขึ้น ถึงจุดนึง เราจะต้องเริ่มทำการแตกทีม โดยแยกให้แต่ละทีมดูแลโค้ดแต่ละส่วนแยกกัน (เป็น Component หรือ Service)
เมื่อถึงจุดนี้ แต่ละทีมอาจจะเลือกทำ CI แยกกัน (ไม่งั้นเวลารันเทสต์จะนานมาก) แล้วนำโค้ดมารวมกันทีหลัง
แต่เนื่องจากแต่ละส่วนต้องทำงานร่วมกัน เราจะเริ่มเห็นอาการที่แก้โค้ดทีมนึงแล้วอีกทีมพัง ซึ่งสาเหตุอาจจะมาจากเรื่องของการขาด Backward Compatibility หรือเรื่องของ Dependency Management ที่ไม่ดีพอ
ดังนั้นใน CI ควรจะมีขั้นตอนที่เอาระบบจริงมาทดสอบกันหมดด้วย (End2End testing) หรือบางครั้ง หากเรารู้ว่าโค้ดส่วนของเรามีใครเรียกใช้บ้าง เราก็สามารถเอา End2End testing ของทีมนั้นๆมาใส่ไว้ใน CI ของเรา
สรุป: การทำ CI (Continuous integration) เป็น CI (Continuous improvement)
การพัฒนาควรเป็นไปทีละขั้นอย่างต่อเนื่อง ไม่ใช่ทำตู้มเดียวจบ ที่ผมเล่ามาทั้งหมดนี้ ไม่ได้จำเป็นว่าทีมคุณจะต้องผ่านเหตุการณ์เดียวกัน เพราะแต่ละทีมก็มีเงื่อนไขไม่เหมือนกัน ขนาดของทีมก็ไม่เท่ากัน
การเพิ่มคุณภาพของ CI หมายถึงเวลาที่ต้องลงทุนกับมันมากขึ้น และเวลาที่ใช้ในการดูแลมันก็จะเพิ่มขึ้นเป็นเงาตามตัว ท้ายที่สุดแล้ว เราทำ CI เพื่อให้เราพัฒนาได้เร็วขึ้น(ในระยะยาว) ไม่ใช่ช้าลง
ขอให้สนุกกับการเดินทางไปกับ CI ครับ