เราลดขนาด Git ของ JavaScript monorepo ลง 94% ได้อย่างไร
(jonathancreamer.com)- JavaScript monorepo ขนาดใหญ่ของ Microsoft อย่าง 1JS เติบโตจนมีผู้ใช้ที่ใช้งานต่อเดือน 1,000 คน แพ็กเกจราว 2,500 แพ็กเกจ และโค้ดราว 20 ล้านบรรทัด โดยล่าสุดขนาดตอน clone พุ่งไปถึง 178GB จนทำให้นักพัฒนาบางคน clone ได้ยากตั้งแต่ต้น
- คอขวดที่พบในช่วงแรกคือโครงสร้างที่ไฟล์เปลี่ยนแปลงของ Beachball สะสมอยู่ในโฟลเดอร์เดียวเป็นหลักหมื่นไฟล์ และเมื่อเพิ่มถึง 40,000 ไฟล์ ก็ทำให้ทุกครั้งที่เพิ่มไฟล์ใหม่จะเกิด tree object ขนาดใหญ่
- สาเหตุที่ใหญ่กว่าคือ Git packing code แบบเดิมเลือกเป้าหมายสำหรับ delta โดยดูแค่ 16 อักขระท้ายสุด ของชื่อไฟล์ ทำให้เกิดปัญหาเมื่อมีการอัปเดตซ้ำ ๆ กับชื่อไฟล์ยาวอย่าง
CHANGELOG.mdและCHANGELOG.json git repack -adf --window=250ช่วยได้ระดับหนึ่ง แต่เมื่อรันgit repack -adf --path-walkบน Microsoft Git fork2.47.0.vfs.0.2ซึ่งเป็นการแพ็กแบบอิงเส้นทาง ก็ทำให้ repository ในเครื่องลดจาก 178GB เหลือ 5GB- หากใน monorepo ขนาดใหญ่มีไฟล์ชื่อยาวที่เปลี่ยนบ่อย ก็ควรตรวจสอบ
pack.usePathWalk trueและคำสั่งใหม่git surveyโดยการเปลี่ยนแปลงที่เกี่ยวข้องมีแผนจะถูกรวมเข้า Git upstream ด้วย
ขนาดและคอขวดของ 1JS monorepo
- 1JS ซึ่งเป็น JavaScript monorepo ขนาดใหญ่ภายใน Microsoft มีทั้งขนาด repository ขนาดโค้ด และขนาดการมีส่วนร่วมที่ใหญ่มาก
- ผู้ใช้ที่ใช้งานต่อเดือนมากกว่า 1,000 คน
- ราว 2,500 แพ็กเกจ
- โค้ดราว 20 ล้านบรรทัด
- ขนาด repository ที่ clone มาใหม่ล่าสุดอยู่ที่ 178GB และนักพัฒนาบางคนในยุโรปไม่สามารถ clone repository ได้เพราะขนาดใหญ่เกินไป
- ตอน clone ครั้งแรกมีขนาดเพียงราว 1–2GB แต่ไม่กี่เดือนต่อมาก็โตเป็นประมาณ 4GB และยังเพิ่มขึ้นต่อเนื่องหลังจากนั้น
บทเรียน #1: อย่าสะสมไฟล์หลายพันไฟล์ไว้ในโฟลเดอร์เดียว
git-sizerช่วยให้เห็นปัญหา blob ขนาดใหญ่และไฟล์เปลี่ยนแปลงของ Beachball- blob ขนาดใหญ่มักเกิดจากการ commit ไฟล์ไบนารีเข้าไปโดยไม่ตั้งใจ และสามารถป้องกันได้ด้วยความสามารถอย่างการจำกัดขนาด check-in ของ Azure DevOps
- แต่ไฟล์ที่เข้าไปอยู่ใน history แล้วจะลบออกภายหลังได้ไม่ง่าย
- 1JS ใช้ Beachball change files เพื่อทำให้การเพิ่มช่วง semver ของแพ็กเกจเป็นแบบอัตโนมัติ
- จุดประสงค์การใช้งานคล้ายกับ Changesets
- เป้าหมายคือทำให้การเพิ่มเวอร์ชันแพ็กเกจเป็นอัตโนมัติเหมือน semantic-release
- เนื่องจากไม่มีการลบไฟล์เปลี่ยนแปลง ทำให้มีไฟล์สะสมอยู่ในโฟลเดอร์เดียวมากถึง 40,000 ไฟล์ และเมื่อเพิ่มไฟล์ใหม่ในสภาพนี้ก็จะสร้าง tree object ขนาดใหญ่
- แนวทางรับมือมี 2 ข้อ
- แก้ Beachball PR ให้เก็บหลายการเปลี่ยนแปลงไว้ในไฟล์เปลี่ยนแปลงเดียว แทนการมีหนึ่งไฟล์ต่อหนึ่งแพ็กเกจ
- เขียน pipeline สำหรับจัดระเบียบโฟลเดอร์ไฟล์เปลี่ยนแปลงแบบอัตโนมัติเป็นระยะ
บทเรียน #2: ปัญหา versioned branch และการแพ็ก changelog
- flow การจัดการเวอร์ชันของ 1JS จะคง branch
versionedที่เป็น mirror ของmainเอาไว้- เพื่อเก็บเวอร์ชันแพ็กเกจจริงและหลีกเลี่ยง Git conflict บน
main - เพื่อให้เห็นความสัมพันธ์ระหว่างเวอร์ชัน semver ที่ปล่อยเป็น NPM package กับ Git commit ได้อย่างแม่นยำ
- เพื่อเก็บเวอร์ชันแพ็กเกจจริงและหลีกเลี่ยง Git conflict บน
- branch
versionedมี commit ที่ส่วนใหญ่เป็นแค่การเติมเนื้อหาในไฟล์CHANGELOG.mdและCHANGELOG.jsonแต่กลับโตขึ้นจนการ clone ทำได้ยากขึ้นเรื่อย ๆ - แม้ repository จะโตขึ้นตามธรรมชาติจากการเพิ่มโค้ดหลายแสนบรรทัดต่อปีและนักพัฒนาหลายร้อยคนตั้งแต่ปี 2021 แต่เมื่ออัตราการเติบโตสูงกว่า monorepo ของ Office ภายใน Microsoft ก็เริ่มสงสัยว่ามีสาเหตุเฉพาะอื่นอยู่
- เมื่อนักพัฒนาที่สร้างฟีเจอร์สำหรับ monorepo ขนาดใหญ่ของ Git อย่าง
shallow checkoutและsparse indexเข้ามาตรวจสอบ ก็พบว่าเวลานำ branchversionedมาใช้งานมีการ fetch ข้อมูล Git เพิ่มอีก 125GB
วิธีที่ Git packing code สร้าง delta ผิดเป้าหมาย
- สาเหตุมาจาก Git packing code รุ่นเก่าที่เวลาบีบอัดไฟล์จะตรวจดูแค่ 16 อักขระท้ายสุด ของชื่อไฟล์เพื่อหาคู่เปรียบเทียบ
- โดยปกติ Git จะ push แค่ diff ของไฟล์ที่เปลี่ยนแปลง แต่ปัญหาการแพ็กนี้ทำให้เกิดการเปรียบเทียบกันระหว่างไฟล์
CHANGELOG.mdของคนละแพ็กเกจ - ตัวอย่างเช่นเมื่อแก้
repo/packages/foo/CHANGELOG.jsonGit อาจสร้าง diff กับrepo/packages/bar/CHANGELOG.jsonในขั้นตอนเตรียม push - ผลคือในหลายกรณีต้อง push ทั้งไฟล์เดิมซ้ำแล้วซ้ำอีก และบางไฟล์อาจมีขนาดหลายสิบ MB
- รายละเอียดเชิงลึกของปัญหาถูกรวบรวมไว้ใน โพสต์บน Git mailing list ของ Stolee
ผลลัพธ์ของ repack และ path-walk
- ขั้นแรกมีการใช้
git repack -adf --window=250เพื่อขยายหน้าต่างการเปรียบเทียบและปรับปรุงการบีบอัดของ pack file ซึ่งช่วยลดขนาด repository ลงได้มาก - จากนั้น git-for-windows PR #5171 ก็เพิ่ม วิธีแพ็กที่เดินตาม Git path แทนวิธีเดิมที่เดินตาม commit
- มีการ clone ใหม่แล้วรัน repack ด้วยวิธีใหม่บน Microsoft git
git version 2.47.0.vfs.0.2ซึ่งเป็น Git fork ของ Microsoft- คำสั่งที่ใช้:
git repack -adf --path-walk - ผลลัพธ์: ขนาด repository ลดจาก 178GB เหลือ 5GB
- คำสั่งที่ใช้:
- ตัวเลือกการตั้งค่าใหม่นี้ยังช่วยให้ตอน push สร้าง delta ได้ถูกต้องด้วย
git config --global pack.usePathWalk true
- นักพัฒนาที่ใช้
git version 2.47.0.vfs.0.2สามารถรัน repack หลัง clone ลงเครื่อง และใช้อัลกอริทึม push แบบ path-walk ใหม่เพื่อลดอัตราการโตของ repository ได้
ผลกระทบต่อ GitHub, Azure DevOps และ upstream Git
- GitHub มีการทำ repack และ Git garbage collection เป็นระยะ แต่ปัจจุบันวิธีแพ็กของ GitHub ยังไม่สามารถคำนวณ delta ของไฟล์อย่าง
CHANGELOG.mdและCHANGELOG.jsonได้อย่างถูกต้อง - กรณีชื่อไฟล์ยาวที่มีตัวอักษรท้ายเหมือนกันเกิน 16 ตัว และถูกแก้ไขบ่อยตามกาลเวลา ก็อาจได้รับผลกระทบเช่นกัน
- มีการยกตัวอย่างเป็นไฟล์สตริงขนาดใหญ่สำหรับ i18n
- Azure DevOps ที่ 1JS ใช้อยู่ยังไม่ได้ทำ repack แบบนี้ และกำลังดำเนินงานที่เกี่ยวข้องเพื่อช่วยลดขนาด repository ฝั่งเซิร์ฟเวอร์ด้วย
- การเปลี่ยนแปลงที่เกี่ยวข้องมีแผนจะถูกส่งกลับเข้า Git upstream
สิ่งที่ควรตรวจสอบใน monorepo ขนาดใหญ่
- หากใน monorepo ขนาดใหญ่มีการอัปเดต
CHANGELOG.mdหรือไฟล์ชื่อค่อนข้างยาวซ้ำ ๆ ก็ควรติดตามการเปลี่ยนแปลงที่เกี่ยวกับ path-walk - คำสั่งใหม่
git surveyให้ heuristic สำหรับประเมินสภาพ repository- Top Files By Disk Size
- Top Directories By Inflated Size
- Top Files By Inflated Size
- heuristic เหล่านี้ช่วยประเมินได้ว่างานด้าน path-walk จะส่งผลต่อขนาด repository นั้นหรือไม่
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ถ้าสงสัยว่าคำสั่งใหม่
git-surveyอยู่ที่ไหน คำตอบคือมันยังไม่ได้เข้า git.gitผู้เขียนใช้ Git fork ของ Microsoft และคำสั่งนี้ถูกเพิ่มเข้ามาเมื่อฤดูร้อนที่ผ่านมา: https://github.com/microsoft/git/pull/667
full-name-hashกับpath-walkก็ดูเหมือนจะมีแค่ใน fork หรือมีแค่ใน Git HEAD เท่านั้นหาไม่เจอทั้งในหน้า manual และใน บันทึกการเปลี่ยนแปลงของ 2.47
สงสัยว่าประโยคที่ว่า “คนในยุโรปโคลนรีโปไม่ได้เพราะขนาดรีโป” หมายความว่าอะไร
มันฟังดูแปลกที่รีโปขนาดใหญ่จะดาวน์โหลดในยุโรปไม่ได้ แต่ที่อื่นกลับได้ และก็ดูเหมือนว่าก่อนจะมีการเปลี่ยนฝั่งเซิร์ฟเวอร์ก็คงยังโคลนไม่ได้อยู่ดี
ประโยค “push ไฟล์ทั้งก้อนซ้ำไปเรื่อยๆ” เหมือนถูกตัดกลางคัน และระหว่างอ่านบทความก็รู้สึกว่า GIF รบกวนสายตามาก แถมยังโผล่มาแม้ในโหมดอ่าน
ถ้าจะเขียนให้ครบขึ้นก็น่าจะประมาณว่า “มีคนหนึ่งตั้งใจจะโคลนรีโปทั้งหมดลงโน้ตบุ๊กเพื่อใช้ระหว่างเดินทางในยุโรป แต่ตอนนั้นดิสก์เต็มเลยทำไม่ได้ หลังจากนั้นเคลียร์ดิสก์แล้วจึงโคลนได้”
ไม่น่าจะได้หมายถึงปัญหาใหญ่ระดับว่ายุโรปรับมือกับ ไฟล์ 180GB ไม่ได้
โดยเฉพาะถ้าคนคนนั้นใช้ VPN และยิ่งถ้าเป็นลูกเล่น “SSL VPN” ที่แย่มากด้วยก็ยิ่งใช่ ซอฟต์แวร์ที่จัดการ latency ได้ไม่ดีหรือเซิร์ฟเวอร์ที่ตั้งค่า window scaling พังๆ ก็ยังพบได้บ่อยอย่างน่าประหลาด
ถ้านักพัฒนาส่วนใหญ่อยู่ใกล้เซิร์ฟเวอร์และใช้การเชื่อมต่อที่แบนด์วิดท์ดี ปัญหาแบบนี้ก็มักไม่ค่อยโผล่มาให้เห็น
การโคลนจาก Azure DevOps ใช้แบนด์วิดท์ของการเชื่อมต่อแบบกิกะบิตผ่านสายได้ไม่เต็มเลย และเพราะใช้เวลานานเกินไป สุดท้ายไม่ฝั่งผมหรือฝั่ง Azure DevOps ก็จะหยุดไปเองจนต้องยอมแพ้
โชคดีที่แทบทุกอย่างทำได้ด้วย shallow clone ใน Codespaces เลยไม่ใช่ปัญหาใหญ่มาก และถ้ารีโปของเรามีปัญหาขนาดพองแบบเดียวกับรีโป 1JS ในบทความนี้ ก็หวังว่าจะมีการแก้เหมือนกัน
อัปเดต: เป็นความผิดพลาดงี่เง่ามาก และชื่อไฟล์ไม่ได้รวมพาธทั้งหมดไว้
ดูเหมือนคำอธิบายจะถูกกลบไปท่ามกลาง GIF แต่ 16 ตัวอักษรสุดท้ายตรงนี้ต่างกัน: เป็นตัวอย่างที่เปลี่ยน
repo/packages/foo/CHANGELOG.mdแต่ Git กลับสร้าง diff โดยเทียบกับrepo/packages/bar/CHANGELOG.mdระหว่างเตรียม pushcover letter ของ API
path-walkก็น่าอ่านเช่นกัน: https://lore.kernel.org/all/pull.1786.git.1725935335.gitgitg...ตัวอย่างในบล็อกอาจไม่ชัดมากนัก แต่จริง ๆ แล้ว Git ดึงทุกเวอร์ชันของทุกไฟล์ในรีโพมาใส่ 16 ไบต์สุดท้ายของพาธ ลงในแฮชเทเบิล แล้วจับกลุ่มสิ่งที่มันคาดว่าเป็นคนละเวอร์ชันของไฟล์เดียวกันไว้สำหรับ delta compression
foo/CHANGELOG.mdกับbar/CHANGELOG.mdในบทความยาวแค่ 13 ตัวอักษร จึงไม่ถูกต้องตามตัวอย่างเป๊ะ ๆ และต้องนึกถึงพาธที่มีคำต่อท้ายร่วมกันยาวกว่านี้ส่วนนี้ถูกแก้ด้วยออปชัน
--full-name-hashซึ่งตอนนี้จะเทียบพาธทั้งหมดแทนที่จะดูแค่ 16 ไบต์ส่วนการเพิ่มขนาดหน้าต่างที่พูดถึงถัดมานั้นใกล้เคียงกับการแฮ็กเพื่อหลบการจัดกลุ่มไฟล์ที่แย่มากกว่า ไม่ใช่การแก้จริง
มันยังคงป้อนอินพุตที่เลวร้ายให้ตัวบีบอัดอยู่ และแค่อ้อมปัญหาด้วยการใช้หน่วยความจำมหาศาล เลยทำให้งงที่ถูกนำเสนอเหมือนเป็นวิธีแก้
ส่วนที่น่าสนใจจริง ๆ คือ path-walk API และ
--full-name-hashตรงนี้สงสัยว่าชื่อไฟล์หมายถึง พาธ หรือ Git จะหยิบไฟล์อะไรก็ได้ที่ชื่อเหมือนกันมาสร้าง diff
แล้วก็อยากรู้ว่ามีรูปแบบอะไรไหมในการเลือกไฟล์อื่นนั้น
ดูได้จากข้อความ commit แรกของ PR
--full-name-hash: https://github.com/git-for-windows/git/pull/5157/commits/d5c...ยกข้อความมาคือ
"/CHANGELOG.json"ยาว 15 ตัวอักษร สร้างโดยเครื่องมือ beachball และมีเพียงอักษรตัวสุดท้ายของไดเรกทอรีแม่ที่ใช้แยกความต่างระหว่างเวอร์ชันต่าง ๆ ของไฟล์นี้ได้ แต่ถึงอย่างนั้นก็สะท้อนออกมาแค่ 2 บิตบนสุดถ้าอักษรนั้นเป็นตัวอักษรภาษาอังกฤษ มันก็จะชนกันเสมอ และพาธ
"/CHANGELOG.md"ก็มีปัญหาคล้ายกัน แต่พอมีพื้นที่ให้ต่างกันในไดเรกทอรีแม่มากกว่าเล็กน้อยอัลกอริทึมการจัดกลุ่มจะให้น้ำหนักอักขระน้อยลงเรื่อย ๆ ยิ่งอยู่ไกลจากด้านขวาของชื่อ:
hash = (hash >> 2) + (c << 24)แฮชมีขนาด 32 บิต และอักขระ 8 บิตแต่ละตัวของพาธทั้งหมดจะถูกใส่ต่อกันเข้าไปใน 8 บิตบนสุดของแฮช พร้อมกับเลื่อนบิตเดิมของแฮชไปทางขวา 2 บิต ดังนั้นแฮชสุดท้ายจึงได้รับอิทธิพลจากแค่ 16 ตัวอักษรสุดท้าย
ดูสิ่งที่เกิดขึ้นจริงได้ที่นี่: https://go.dev/play/p/JQpdUGXdQs7
มีการพอร์ตไปเป็น Go เพื่อเทียบค่าท้ายสุดของ
"aaa/CHANGELOG.md"กับ"zzz/CHANGELOG.md"ดังนั้นลองแทนค่าaaaและzzzแบบต่าง ๆ จะเห็นว่ามันส่งผลต่อค่าท้ายสุดน้อยมากแค่ไหน16 ตัวอักษรสุดท้ายของ
CHANGELOG.mdก็คือชื่อไฟล์เต็ม และถ้าแปลความแบบนั้นก็อธิบายได้ว่าทำไมวิธีแก้แบบfilepathwalkถึงช่วยแก้ปัญหาแต่ถ้ามันอิงแค่ 16 ตัวอักษรสุดท้ายของชื่อไฟล์จริง ๆ และไม่ใช่พาธทั้งหมด ปัญหานี้ก็น่าจะเกิดบ่อยกว่านี้มาก โดยเฉพาะในโมโนรีโพ
น่าจะสับสนเพราะ
fooกับbarเป็นตัวแปรสมมุติ และชื่อแพ็กเกจจริงอาจเป็นอะไรอย่างfirstPkg,secondPkgที่มีคำต่อท้ายร่วมกันแค่
"/CHANGELOG.md"อย่างเดียวก็ยาว 13 ตัวอักษรแล้ว ดังนั้นถ้ามีคำต่อท้ายร่วมกันอีก 3 ตัวอักษร ก็เกิด การชนกันของ 16 ตัวอักษร ได้แล้วเพิ่งลองกับ
nixpkgsทันที และถ้า clone จาก GitHub ตรง ๆ จะอยู่ที่ราว 5GBออปชันแรกที่บทความพูดถึงคือ
--window 250ลดขนาดลงเหลือ 1.7GB และออปชัน--path-walkใหม่ใน Microsoft Git fork ได้ผลน้อยกว่า ทำให้รวมแล้วเหลือ 1.9GBทั้งสองแบบเล็กกว่าครึ่งของขนาดตั้งต้น ดังนั้นก็คงดีถ้า GitHub มีวิธีรันงานแบบนี้ให้ และจะยิ่งดีถ้าเริ่มมีบริการโฮสต์ที่เปิดให้ผู้ใช้ควบคุมเรื่องแบบนี้ได้เอง
มี Derrick Stolee ปรากฏตัว คนที่ขุดลึกถึงการเปลี่ยนแปลงที่บทความต้องใช้และเป็นคนปล่อยออกมา
ถ้าสนใจโครงสร้างภายในของ Git การลดขนาด clone ของ Git ทั้งบนเครื่องโลคัลและใน CI Derrick เขียนบทความดี ๆ ไว้มากมายในบล็อก GitHub: https://github.blog/author/dstolee/
เว็บไซต์ส่วนตัวของเขาก็น่าอ่านเช่นกัน: https://stolee.dev/
ถ้าเผลอ commit ไบนารีขนาดใหญ่เข้าไป วิธีแก้ก็คือรัน
filter-branchเพื่อลบไบนารีต้นปัญหาออก จากนั้นสอนและตั้งค่าให้ทุกคนใช้ git-lfs สำหรับไฟล์ไบนารี แล้วค่อย force push และทำให้สภาพแวดล้อมการทำงานของแต่ละคนกลับมาอยู่ในสถานะปกติแม้จะไม่ใช่วิธีที่ดีที่สุด แต่ก็ยังดีกว่าปล่อยไฟล์ใหญ่ที่ไม่ได้ใช้งานค้างอยู่ใน Git ต่อไป
filter-branchน่ากลัว ก็ยังมี BFG: https://rtyley.github.io/bfg-repo-cleaner/อย่างที่มีคนอื่นชี้ไว้ ปัญหานี้เกี่ยวกับไฟล์ขนาดเล็กที่เปลี่ยนบ่อย ดังนั้นถ้าอยากประหยัดพื้นที่ก็แค่ลบเวอร์ชันเก่าออกจากประวัติ และใช้ LFS ต่อจากนี้ไป
การแฮ็ก Git ก็คงสนุกดี แต่ก็อดคิดไม่ได้ว่าแต่แรกมีวิธีไม่เอา แพ็กเกจ 2,500 ตัวไว้ในโมโนเรโป ไหม
ยิ่ง codebase ใหญ่ขึ้น ก็ยิ่งยากที่จะคาดหวังให้คนไม่สร้างล้อขึ้นมาใหม่ เพราะพวกเขาอาจไม่รู้ว่าโค้ดนั้นมีอยู่ หรือกลัวว่าถ้าปรับให้รองรับ use case ของตัวเองแล้วจะไปทำของเดิมพัง
จากประสบการณ์ส่วนตัว เวลาที่ใช้ในการเพิ่มจาก 100 โมดูลเป็น 200 โมดูล น้อยกว่าตอนเพิ่มจาก 50 เป็น 100 โมดูลเสียอีก
ดูเป็นปัญหาที่สร้างขึ้นมาเองล้วน ๆ
ช่วงหลังมานี้ฉันตามดูความคืบหน้าเรื่องโมโนเรโปและการสเกลจากงานเปิดตัว GitMerge ที่ GitButler เพิ่งโพสต์ไว้ ซึ่งมีของดีออกมาจาก Microsoft, GitHub และ GitLab เยอะมาก
อยากเข้าใจให้มากขึ้นว่าปัญหาการตรวจ 16 อักขระสุดท้ายกับการตรวจพาธทั้งหมดนั้นทำงานร่วมกับ delta compression, pack index และ multi-pack index อย่างไร
ดีที่เห็น Microsoft ใช้ Azure DevOps เองโดยตรง
ช่วงหลังรู้สึกว่าบริการ Azure ยิ่งนานยิ่งมี native connector ให้แค่ GitHub เลยคิดว่า Azure DevOps กำลังถูกปล่อยทิ้งไว้ข้างหลัง
การมีคนที่เข้าใจโครงสร้างภายในของ Git ลึกขนาดนั้นอยู่ใกล้มือและช่วยได้ คงเป็นสิทธิพิเศษที่ดีมากสำหรับการทำโปรเจกต์แบบนี้ในบริษัทขนาดนี้