- Railway ออกแบบบิลเดอร์สำหรับสร้างคอนเทนเนอร์อิมเมจจากโค้ดผู้ใช้ใหม่ โดยย้ายประสบการณ์จาก Nixpacks ที่เคยใช้สร้างแอปมาแล้ว มากกว่า 14 ล้านแอป มาสู่ Railpack
- Nixpacks เพียงพอสำหรับผู้ใช้ 80% แต่ ผู้ใช้ Railway อีก 200,000 ราย ที่เหลืออาจเจอข้อจำกัดด้านการจัดการเวอร์ชัน ขนาดอิมเมจ และการแคช
- Railpack ยกระดับ ความสามารถในการทำซ้ำของการบิลด์ ด้วยเวอร์ชันแบบ
major.minor.patch, การล็อก dependency และ flow การติดตั้งที่อิง Mise แทนการจัดการเวอร์ชันแบบ commit-based ของ Nix - ด้วยการสร้าง BuildKit LLB และ Frontend ขึ้นมาโดยตรง ทำให้อิมเมจ Node พื้นฐานเล็กลง 38% และอิมเมจ Python พื้นฐานเล็กลง 77% พร้อมรองรับแคชที่แชร์ข้ามสภาพแวดล้อมได้
- Railpack เปิดให้ใช้งานเป็น Beta แล้วในหน้าตั้งค่าบริการ และ Railway กำลังให้ความสำคัญกับความสมบูรณ์ของภาษาที่มีการใช้งานบ่อยก่อนการรองรับภาษาจำนวนมาก
เบื้องหลังที่ Railway สร้างบิลเดอร์ตัวใหม่
- Railway เปิดตัว Railpack ในฐานะก้าวถัดไปของ Railway builder
- Railpack ถูกพัฒนาขึ้นใหม่ตั้งแต่ต้น โดยอิงจากประสบการณ์ที่ได้จากการใช้ Nixpacks บิลด์แอปมาแล้ว มากกว่า 14 ล้านแอป
- หลังเปิดตัวเมื่อราว 3 ปีก่อน Nixpacks ก็กลายเป็นวิธีหลักของ Railway ในการบิลด์อิมเมจจากโค้ดผู้ใช้
- มันทำงานได้ดีสำหรับผู้ใช้ทั้งหมด 80% แต่ ผู้ใช้ Railway อีก 200,000 ราย ที่เหลืออาจพบข้อจำกัด
- Railway มองว่าหากต้องการขยายฐานผู้ใช้จาก 1 ล้านคนเป็น 100 ล้านคน จำเป็นต้องอัปเกรดบิลเดอร์ครั้งใหญ่
ข้อจำกัดที่ Nixpacks เจอเมื่อใช้ Nix
- ปัญหาใหญ่ที่สุดคือ การจัดการเวอร์ชันแพ็กเกจแบบอิง commit ของ Nix
- แต่ละแพ็กเกจให้มาเฉพาะเมเจอร์เวอร์ชันล่าสุด
- เวอร์ชันจะผูกกับ commit เฉพาะใน nixpkgs repo
- ความพยายามที่จะรองรับทุก patch version กลายเป็นโครงสร้างที่ต้องแมปสตริงเวอร์ชันกับ commit SHA โดยตรง ซึ่งไม่ชัดเจนและดูแลรักษายากสำหรับผู้ร่วมพัฒนาที่ไม่คุ้นกับการจัดการเวอร์ชันของ Nix
- ภาษาต่าง ๆ อย่าง Node และ Python จึงลงเอยด้วยการรองรับเพียง เมเจอร์เวอร์ชันล่าสุด
- เมื่ออัปเดต commit SHA เพื่อรองรับแพ็กเกจเวอร์ชันใหม่กว่า เวอร์ชันของแพ็กเกจอื่นก็อาจเปลี่ยนตามไปด้วย
- หากค่าเริ่มต้นเปลี่ยน บิลด์ของผู้ใช้ที่เคยทำงานได้อาจล้มเหลวด้วยข้อผิดพลาดที่คาดไม่ถึง
- Railway มองว่าสถานการณ์ที่บิลด์ซึ่งเคยสำเร็จกลับพังแบบกะทันหัน แย่กว่าการที่ผู้ใช้ยังเข้าถึงแพ็กเกจล่าสุดไม่ได้
ปัญหาเรื่องขนาดอิมเมจและการแคช
- วิธีที่ Nixpacks ใช้ Nix เพื่อนำ dependency เข้ามา มักทำให้เกิดอิมเมจขนาดใหญ่
- ทั้ง Nix และแพ็กเกจกับไลบรารีที่เกี่ยวข้องซึ่งจำเป็นต่อการบิลด์และรันไทม์ จะถูกใส่ไว้ในเลเยอร์
/nix/storeเดียว - เนื่องจากไม่สามารถแยก Nix dependency ออกเป็นเลเยอร์ย่อยได้ จึงมีข้อจำกัดในการลดขนาดอิมเมจสุดท้าย
- Railway มองว่านี่ไม่ใช่ปัญหาของ Nix เอง แต่เป็นปัญหาของวิธีที่ Nixpacks ใช้ Nix
- ในด้านการแคช ก็ยากที่จะควบคุมว่า layer cache จะถูกทำให้ใช้ไม่ได้เมื่อใด
- Railway จะ inject environment variable ของ deployment ID เข้าไปในทุกการบิลด์
- ใน Dockerfile เลเยอร์ที่รันหลังจากเพิ่มตัวแปรนี้จะถูกทำให้ใช้ไม่ได้เสมอและไม่สามารถแคชได้
- แนวทางที่พยายามซ่อนองค์ประกอบหลักของ Nix จากผู้ใช้ก็ไม่ค่อยลงตัว
- Railway ไม่ต้องการให้ผู้ใช้จำเป็นต้องเข้าใจว่า derivation คืออะไร หรือจำเป็นต้องรู้ว่าเหตุใด Node 22.14.0 จึงอยู่ใน archive version ใดของ unstable channel
การเปลี่ยนแปลงเชิงโครงสร้างของ Railpack
- Railway สร้าง Railpack ขึ้นมาเพื่อแก้ปัญหาที่พบใน Nixpacks
- เมื่อเลิกใช้ Nix ชื่อก็เปลี่ยนจาก Nixpacks เป็น Railpack
- โค้ดเบสเปลี่ยนจาก Rust เป็น Go เพราะไลบรารีของ BuildKit
- Railpack ควบคุมวิธีสร้างอิมเมจสุดท้ายได้โดยตรงมากขึ้น
- มันสร้าง BuildKit LLB และ Frontend ขึ้นมาโดยตรง
- เมื่อเทียบกับ Nixpacks อิมเมจ Node พื้นฐานเล็กลง 38% และอิมเมจ Python พื้นฐานเล็กลง 77%
- ใช้ Mise สำหรับการตีความเวอร์ชันและการติดตั้งแพ็กเกจส่วนใหญ่
- และยังเปิดทางให้รองรับแหล่ง executable อื่น ๆ ได้ในอนาคต
- สามารถ ล็อก dependency ที่ใช้ในบิลด์ที่สำเร็จได้
- ทำให้แม้ Node เวอร์ชันเริ่มต้นจะเปลี่ยนจาก 22 เป็น 24 ก็ยังไม่ทำให้บิลด์พัง
- ใช้ BuildKit secrets เพื่อไม่ให้ secret environment variables ปรากฏในล็อกการบิลด์หรืออิมเมจสุดท้าย
วิธีการทำงานของ Railpack build
- กระบวนการของ Railpack แบ่งเป็น 3 ขั้นตอน
- Analyze: ดูโค้ดแล้วตัดสินใจว่าจะติดตั้งแพ็กเกจใด รันคำสั่งใด และใช้คำสั่งเริ่มต้นอะไร
- Plan: สร้าง build plan ที่ serialize เป็น JSON ได้และประกอบด้วยหลายขั้นตอน โดยแต่ละขั้นตอนรับอินพุตจากขั้นตอนอื่นหรือจากทั้งอิมเมจ
- Generate: สร้าง BuildKit build graph ตามอินพุตและเอาต์พุตของแผน
- Dockerfile มีลักษณะเป็นเส้นตรง แต่ BuildKit graph สามารถจัดแบบขนานได้มากกว่าอย่างมาก
- แต่ละคำสั่งจะรันในสเตจของตัวเองภายใน multi-stage build เพื่อให้ควบคุม input layer และวิธีประกอบไฟล์ซิสเต็มสุดท้ายได้อย่างละเอียด
- Railpack จะสร้าง build plan ที่รวมทุกขั้นตอนการบิลด์ที่จำเป็นไว้
- แต่ละขั้นตอนจะระบุอย่างชัดเจนว่าต้องใช้ขั้นตอนก่อนหน้าหรืออิมเมจใด
- รูปแบบนี้อยู่ในระดับล่างกว่าวิธีที่ใช้ใน Nixpacks
- จากนั้นแผนจะถูกแปลงเป็นกราฟในรูปแบบ LLB เพื่อให้ตีความได้
- BuildKit เริ่มจากปลายทางแล้วไล่ย้อนกลับ โดยดึงจากแคชเมื่อทำได้ และรันคำสั่งเฉพาะเมื่อจำเป็นเพื่อ resolve เลเยอร์ที่ร้องขอ
- เพื่อทำให้เลเยอร์ใช้ไม่ได้เมื่อ environment variable บางตัวเปลี่ยน Railpack จะ hash ค่าของตัวแปรที่ถูกใช้ แล้ว mount ไฟล์ที่มีแฮชนั้นเข้าไปใน input file system
- หากทั้งโค้ดและตัวแปรที่ใช้ไม่เปลี่ยน layer cache ก็จะยังใช้งานได้
- Railpack สามารถนิยามวิธีสร้างอิมเมจได้ทั้งหมดอย่างสมบูรณ์
สิ่งที่ Railpack ทำให้เป็นไปได้
- สามารถบิลด์และ deploy เว็บไซต์สแตติกจาก Vite, Astro, CRA และ Angular ได้แบบ ไม่ต้องตั้งค่า
- การเชื่อมต่อระหว่างขั้นตอนบิลด์กับ Railway UI แนบแน่นยิ่งขึ้น
- รองรับเวอร์ชันล่าสุดของภาษาได้โดยไม่ต้องรอ Railpack release
- ใช้ layer caching ที่ปรับให้เหมาะสม ข้ามหลายสภาพแวดล้อมของโปรเจกต์ได้
วิธีใช้งานและขอบเขตการรองรับในปัจจุบัน
- ขณะนี้ Railpack เปิดให้ใช้งานในสถานะ Beta และสามารถเปิดใช้ได้จากหน้าตั้งค่าบริการ
- ตอนนี้ถูกใช้งานแล้วกับการบิลด์ของ railway.com และ central station
- ปัจจุบันรองรับสิ่งต่อไปนี้
- Node
- Python
- Go
- PHP
- การ deploy Static HTML
- รองรับเว็บไซต์สแตติกจาก Vite, Astro, CRA และ Angular โดยตรง
- Railway ตั้งเป้าสร้างสภาพแวดล้อมที่ทำให้ deploy ได้ง่ายทั้งฝั่งฟรอนต์เอนด์และแบ็กเอนด์
- การรองรับเฟรมเวิร์กและภาษาต่าง ๆ ยังคงถูกเพิ่มอย่างต่อเนื่อง
- สามารถส่งคำขอได้ที่ Help Station
- จนกว่าจะได้ข้อสรุปเรื่อง core API และ abstraction ทาง Railway จะให้ความสำคัญกับความลึกของภาษาที่มีการใช้งานมากก่อนการรองรับที่กว้าง
- Railpack เป็นโอเพนซอร์ส และมีเอกสารให้ที่ railpack.com
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
แม้จะชอบ Nix แต่ก็ไม่ได้จะวิจารณ์ที่ Railway ถอยออกจาก Nix เพียงแค่รู้สึกว่ามีข้อบ่นบางอย่างที่ควรอธิบายเพิ่ม
Nixpkgs นั้นยอดเยี่ยม แต่ไม่ใช่สิ่งเดียวกับ Nix และแค่ไม่เหมาะนักในกรณีที่ต้องการดึง toolchain เวอร์ชันตามใจมาใช้ เครื่องมือ Nix สำหรับดึง Rust เวอร์ชันใดก็ได้ตอนนี้ดีมากอยู่แล้ว และเครื่องมือพัฒนาอื่น ๆ ที่อิงกับ Nix ก็แสดงให้เห็นว่าสามารถจัดการเรื่องนี้ได้ดี
ประโยคที่ว่า “ไม่มีวิธีแยก Nix dependency ออกเป็นเลเยอร์แยกต่างหาก” ก็ฟังไม่ค่อยเข้าใจ เพราะจริง ๆ จะแยกแบบไหนก็ได้ตามต้องการ และเครื่องมือ Docker ที่มีอยู่ใน Nixpkgs ก็รองรับเรื่องนี้อยู่บ้าง
การย้ายจาก Rust ไป Go ไม่ได้เกี่ยวกับ Nix โดยตรง แต่ก็น่าสนใจ และยังทำให้ฟังเหมือน Railpacks กับ Nixpacks ถูกสร้างโดยคนละทีมกันด้วย ผมเคยเห็นหลายครั้งว่าพอคนที่ไม่คุ้นกับ Nix ต้องมารับช่วงโซลูชัน Nix ที่ยังไม่สมบูรณ์ภายในองค์กร ผลลัพธ์มักออกมาแย่พอสมควร เพราะงั้นในที่ทำงานผมก็มักไม่ใช้ Nix เพราะกลัวจะเกิดสถานการณ์แบบนี้
ประโยคว่า “ก็แยกเลเยอร์ได้ตามที่ต้องการ” เองก็ต้องดูด้วยว่าวิธีนั้นชัดเจน ง่าย และเป็นพฤติกรรมพื้นฐานหรือไม่
เหตุผลที่คนไม่พอใจกับ Nix ไม่ใช่เพราะมันไม่ Turing-complete แต่เพราะมันไม่มี first-class API ที่เรียบง่ายและเชื่อมกับแนวปฏิบัติตามธรรมเนียมของโปรเจกต์ใน ecosystem นั้นได้ทันที จนสุดท้ายสร้างปัญหามากกว่าที่จะแก้
ถ้าทุกโปรเจกต์ที่อยากใช้ Nix สุดท้ายต้องไหลไปสู่การเขียนโมดูลเองเพื่อซ่อมปัญหาของ Nix เหตุผลที่จะใช้ Nix แทนเครื่องมือกระแสหลักที่มีเอกสารดีกว่าก็จะยิ่งน้อยลง ซึ่งกรณีนี้ก็ดูเหมือนเป็นแบบนั้นพอดี และคนส่วนใหญ่น่าจะเลือก Docker ไปเลย
น่าหงุดหงิดที่ผลิตภัณฑ์สำหรับนักพัฒนากลับยึดติดกับ flakes ที่บริสุทธิ์ทางอุดมการณ์ มากกว่าจะไปแก้ปัญหา developer experience เชิงปฏิบัติด้วยความเร็วที่เร็วกว่าระดับยุคธรณีกาล รู้ว่าเป็นงานจากผู้มีส่วนร่วมโดยสมัครใจ แต่ก็ยังน่าเสียดายมากที่มีแรงงานทางเทคนิคมหาศาลถูกใช้ไปกับสิ่งที่ในทางปฏิบัติกลับใช้งานยากเพราะประสบการณ์ผู้ใช้ที่แย่
วิธีที่ Nix ทำงานร่วมกับโครงสร้างของ Nixpkgs หมายความว่าการตรึงเวอร์ชันแพ็กเกจใดแพ็กเกจหนึ่ง ก็คือการตรึง commit ของต้นไม้ nixpkgs ทั้งก้อนด้วย และการ build แพ็กเกจ node/python/ruby ยังพึ่งพาสถานะของ tree นอกไดเรกทอรีแพ็กเกจเองด้วย จึงต้องมีการแมประหว่างเวอร์ชันกับ commit
abstraction นี้รั่วออกมา ทำให้ Railway ต้องเปิดเผยเรื่องนี้กับผู้ใช้ และผู้ใช้อาจเจอสถานการณ์ที่จริง ๆ แค่อยากรัน
yarn add new-fancy-nodejs-package-with-linked–native-depsแต่กลับต้องมาคอยจัดสถานะหลายแบบของรีโพซิทอรี nixpkgs ให้เข้ากันถ้าเป็นกรณีใช้งานที่ขอบเขตแคบ การใช้ Nix โดยไม่มี Nixpkgs ก็อาจโอเค แต่สำหรับแพลตฟอร์มอย่าง Railway มันดูยากจะหาเหตุผลมารองรับ
pkgโดยมากก็ทำงานได้ดี แต่วันหนึ่งพอลองคอมไพล์ vim จาก ports พร้อม USE flags แบบกำหนดเอง ก็ต้องดึง dependency มากกว่า 20 ตัว แล้วmake menuconfigก็ถามออปชันทุกครั้ง สุดท้ายพอมาถึงแพ็กเกจที่ 16 จาก 23 ก็เจอ error ทำนองว่า “ตัวนี้ต้องใช้ตัวนั้น และตัวนั้นต้องใช้ Fubar3.32.1 แต่ Fubar3 ถูก deprecated ไปเป็น Fubar4 แล้ว” จนยอมแพ้เข้าใจได้ว่านักพัฒนา Core OS ไม่อาจรองรับแพ็กเกจกว่าหมื่นตัวทั้งหมดได้ แต่ก็ควรบอกให้ชัดด้วยว่า ถ้าจะเปิดฟีเจอร์แบบกำหนดเองมาใช้จริง โอกาสล้มเหลวนั้นสูง หรือไม่ก็ควรกำหนดเกณฑ์ว่ามาตรฐาน build ที่สร้างอย่างอิสระต้องสำเร็จก่อนถึงจะเข้า ports ได้ และถ้าคอมไพล์ไม่ผ่านก็ควรถอดออกจากรายการ ports ไปเลย
จะวิจารณ์ภาษา Nix เป็นชั่วโมงก็ยังได้ แต่มันก็เก่าแล้ว และอาจเป็นทางเลือกที่ดีที่สุดในยุคนั้น อีกทั้งตอนนี้ก็อาจไม่คุ้มจะเปลี่ยน ระบบ build ของ Nix ให้ความรู้สึกค่อนข้างดิบ และมัก rebuild ของที่ดูเหมือนไม่จำเป็นต้อง rebuild บ่อย ๆ ตัวอย่างเช่น ส่วนสำคัญของการ build NixOS installation ISO ดันขึ้นกับ command line
console=ttyS2,1500000n8ที่ส่งให้ kernel ดังนั้นแค่เปลี่ยนความเร็ว serial port ก็ต้อง build ใหม่ราว 3 นาที ฟังดูขำแต่ก็ไม่ถึงกับทำให้เลิกใช้ Nix เพียงแค่มันเป็นสิ่งที่ผมจะไม่ยอมให้เกิดใน build ของตัวเองสำหรับ Docker image นั้น ส่วนตัวมองว่าเป็นพื้นที่ที่ Nix ทำได้แย่ที่สุด นานมาแล้วตอนทำซอฟต์แวร์ Go ผมต้องเพิ่มไบนารี
pg_dumpของ Postgres เข้าไปใน container image ตามข้อเสนอของทีม infra เลยลองใช้ Nix ปรากฏว่า image ที่ควรมีแค่ Go binary แบบบีบอัดขนาด 50MB กลับพองเป็น 1.5GB แบบไม่รู้ที่มา ทั้งที่pg_dumpมีแค่ 464KB สุดท้ายเลยหันไปใช้ Bazel กับrules_debianเพื่อติดตั้งแพ็กเกจ apt แล้วผลลัพธ์บน distroless ก็สะอาดและเล็กกว่ามาก จากประสบการณ์จริงกับ Nix ทำให้รู้สึกว่าระบบ Nix มักจะลงเอยที่ 1.4GB เสมอ ISO ติดตั้งก็ 1.4GB เครื่องที่เพิ่งติดตั้งเสร็จก็ 1.4GBกรณีที่อยาก build โปรเจกต์ C++ ขนาดใหญ่เป็นเส้นทางที่มีคนปูไว้ดีอยู่แล้ว และต่อให้เปลี่ยนจาก C++ เป็น Rust แก่นของปัญหาก็ไม่ได้ต่างกัน มี build system ที่ทำให้สถานการณ์ด้านไลบรารีเจ็บปวดน้อยลง และแม้มันจะซับซ้อนพอ ๆ กับ Nix แต่ก็เหมาะกับงานลักษณะนี้มากกว่า Nix พยายามจะเป็น build system สำหรับสร้างทั้งซอฟต์แวร์ของคนอื่นและ nixpkgs เลยไปอยู่ฝั่งที่เป็น general มาก ระบบ build ที่ออกแบบมาเพื่อสร้างซอฟต์แวร์ของตัวเองโดยตรงมักทำเรื่องนี้ได้ดีกว่า ส่วนตัวผมพอใจกับ Bazel และนอกจาก
go buildสำหรับโปรเจกต์ Go ล้วน ๆ แล้วก็คงแทบไม่ใช้อย่างอื่น ยังมีตัวเลือกอีกมาก ใน 99% ของกรณี ผมจะใช้พวกนั้นแทน Nix แล้วค่อยเขียน flake เพื่อให้คนติดตั้งเวอร์ชันล่าสุดผ่าน home-manager ได้ส่วนของการเลือกเวอร์ชันฟังดูแปลก ๆ เวอร์ชันของ nixpkgs นั้นสมเหตุสมผลเวลารันระบบหรือใช้ build แต่ถ้าจะให้ runtime หรือ compiler ในฐานะแพลตฟอร์ม ก็จำเป็นต้องใช้วิธีแบบ devenv ที่ จัดหาเวอร์ชันโดยตรง
ถ้าต้อง build ระบบเก่าเพื่อจะให้บริการ nodejs รุ่นเก่า ก็จะพลาด security patch ของ dependency ไปด้วย Devenv จัดการเรื่องนี้ เช่น ผ่าน https://github.com/cachix/nixpkgs-python ในลักษณะ “ทำให้ Python ทุกเวอร์ชันบน Nix อัปเดตล่าสุดทุกชั่วโมง”
การที่ Railway inject environment variable ของ deployment ID เข้าไปในทุก build ก็ทำได้ในเลเยอร์ถัดจากขั้นติดตั้งเช่นกัน จะแยกแพ็กเกจเป็นหลายเลเยอร์ก็ได้ และก็มีระบบอัตโนมัติระดับ bundle เพื่อลดจำนวนเลเยอร์ด้วย
คำพูดที่ว่า “ตัว Nix เองไม่มีปัญหา ปัญหาอยู่ที่วิธีที่เราใช้มัน” เป็นตัวอย่างที่ดีของหลัก ใช้เครื่องมือให้เหมาะกับงาน Nix ยอดเยี่ยมมากสำหรับบางกรณี และแย่มากสำหรับอีกบางกรณี
ปัญหาคือ learning curve ของ Nix สูงเกินไป พอเข้าใจมันมากพอจะตัดสินได้ ก็มักจะลงทุนเวลาไปเยอะจนรู้สึกเสียดายที่จะถอย แล้วสุดท้ายก็พยายามฝืนให้มันเข้ากับความต้องการเดิม
ด้วยกระบวนทัศน์นี้ AI จึงสร้าง
shell.nixหรือconfiguration.nixที่ตรงตามสเปกได้ง่ายมาก เช่น ใส่ Python package, Linux package, environment variable, รายการ path ฯลฯ ได้ฉันใช้สิ่งนี้บ่อยเพื่อให้ repository มี environment ที่รองรับแพ็กเกจนั้นอย่างครบถ้วน ถ้าใช้ flakes ก็น่าจะ reproducible กว่า แต่ตอนนี้ฉันยังเข้าใจว่า
flake.nixคืออะไรประมาณshell.nixที่มีการ pin เวอร์ชัน และยังอยู่ระหว่างเรียนรู้มันดูเหมือนพยายามยัดเรื่องเวอร์ชันเข้าไปในที่ที่เดิมไม่ได้มีแนวคิดนี้ เหมือนเอาลูกบาศก์สี่เหลี่ยมยัดเข้ารูวงกลม
เรื่องที่ว่า “เวอร์ชันพื้นฐาน” ทำให้สิ่งที่พึ่งพามันพัง ฉันไม่เข้าใจว่าหมายถึงอะไร มันเหมือนใช้แท็ก
:latestของ Docker แล้วพอมีเซิร์ฟเวอร์ใหม่ขึ้นมาและได้เวอร์ชันต่างจาก image “พื้นฐาน” ก่อนหน้า ก็กลับแปลกใจว่าทำไมมันพังคำอธิบายในบล็อกโพสต์นี้ฉันไม่เข้าใจเลยสักนิด ดูเหมือนคนที่ไม่รู้ด้วยซ้ำว่า “เวอร์ชัน” ของซอฟต์แวร์คืออะไร
ส่วนที่บอกว่า “ไม่มีวิธีแยก Nix dependency ออกเป็นเลเยอร์ต่างหาก” ก็ไม่เข้าใจเหมือนกันว่าทำไม แน่นอนว่าคุณแบ่ง
/nix/storeออกเป็นกี่เลเยอร์ก็ได้ตามต้องการ จึงอดสงสัยไม่ได้ว่ารู้จริงหรือเปล่าว่าแต่แรกคอนเทนเนอร์กับ Nix ควรถูกใช้อย่างไรพอเห็นความไม่ชำนาญแบบชัดเจนเช่นนี้ ก็ไม่น่าแปลกใจที่วิธีแก้ที่เสนอจะมีกลิ่นไม่น่าไว้ใจแบบปลาเน่า นี่คือ อาการ NIH syndrome ตามตำรา และมีโอกาสสูงที่ปัญหาเดิมซึ่งแก้ด้วย Nix ไม่ได้ จะกระจายตามไปอยู่ใน “วิธีแก้” ใหม่ด้วย
อย่างที่คนอื่นพูดไว้ nix2container และ flakes น่าจะแก้ทุกปัญหาที่พวกเขามีได้
ในแง่การจัดการเวอร์ชัน flakes ที่ฉันเขียนไว้เมื่อ 3 ปีก่อน ทุกวันนี้ก็ยัง build ได้ด้วยเวอร์ชันเดิมและ output เดิมแบบเป๊ะเหมือนตอนเขียนครั้งแรก
ฟังดูเหมือนอยากออกสู่ตลาดในฐานะแพลตฟอร์มเพื่อไปหาเงินลงทุน
แก้ไข: เมื่อกี้ฉันเพิ่งไปดู GitHub ของ nixpacks แล้ว สิ่งที่สะดุดตาทันทีคือมันใช้
rustPlatformจาก nixpkgs แทนที่จะใช้ rust-overlay ของ oxalica[0] ซึ่งแค่ค้นหาปัญหา Rust แบบผ่าน ๆ ก็น่าจะเจอแล้วrust-overlayเป็นหนึ่งใน overlay ที่มีประโยชน์และทรงพลังที่สุดเท่าที่ฉันเคยใช้[0] https://github.com/oxalica/rust-overlay
nix2container[1] สามารถแยก dependency ออกเป็นเลเยอร์ต่างหากได้จริง คุณสร้างเลเยอร์ที่ใส่เฉพาะ dependency บางส่วนที่ image ต้องใช้ได้แบบ explicit และมีตัวอย่างอยู่ในส่วนนี้: https://github.com/nlewo/nix2container?tab=readme-ov-file#is...ตัวอย่างเช่น ถ้า image ต่าง ๆ ใช้ bash ก็สร้างเลเยอร์ที่มี bash closure ไว้โดยเฉพาะได้ เลเยอร์นี้จะถูกใช้ซ้ำในทุก image และจะถูก build กับ push ใหม่ก็ต่อเมื่อ bash closure นั้นเปลี่ยนเท่านั้น
ข้อบ่นว่า image ใหญ่เพราะมีเลเยอร์
/nix/storeเดียว ใช้ได้กับฟังก์ชันnixpkgs.dockerTools.buildImageแบบพื้นฐาน แต่ใช้ไม่ได้กับ nix2container หรือnixpkgs.dockerTools.streamLayeredImageเครื่องมือเหล่านี้ไม่ได้เขียนเลเยอร์ลงใน Nix store แต่สร้างสคริปต์ที่ push image จริงโดยอาศัย store path ที่มีอยู่แล้ว ฝั่ง implementation ของ nix2container จะสร้างไฟล์ JSON ที่อธิบาย Nix store path ของทุกเลเยอร์ แล้วให้ Skopeo ใช้ JSON นี้เพื่อ push image ไปยัง Docker daemon, registry, podman ฯลฯเพื่ออ้างอิงไว้ ฉันเป็นผู้เขียน nix2container เอง
[1] https://github.com/nlewo/nix2container
ปัญหาหลักตรงนี้คือการยึดติดกับแนวคิด ซุปเวอร์ชันแบบคัสตอม ที่ package manager ของแต่ละภาษาเป็นคนผลักดัน วิธีนี้ไม่ยั่งยืนโดยสิ้นเชิง
ส่วน Mise ซึ่งเป็นทางเลือก ดูเหมือนไม่มีความสามารถในการเข้าใจข้อจำกัดเรื่องเวอร์ชันระหว่างแพ็กเกจต่าง ๆ และก็ดูเหมือนไม่ได้รันการทดสอบว่าทุกแพ็กเกจที่ติดตั้งแล้วทำงานร่วมกับเวอร์ชันรอบข้างได้ดีหรือไม่ ถ้าเป็นแบบนั้น มันก็ไม่ใช่สิ่งเดียวกันเลย
แม้แนวทาง "ซุปเวอร์ชันแบบปรับแต่งเอง" จะไม่ยั่งยืน แต่เหตุผลหนึ่งที่คนยังใช้กันอยู่ก็เพราะโดยมากมันใช้งานได้ดีจริง ๆ ส่วนหนึ่งที่มันทำงานได้ดีก็เพราะไลบรารีระดับระบบปฏิบัติการมาจากอีกโลกหนึ่งที่อนุรักษ์นิยมกว่ามาก และพยายามอย่างที่สุดที่จะไม่ทำลายความเข้ากันได้ย้อนหลัง
ดังนั้นจึงสามารถใช้ระบบปฏิบัติการที่เสถียรและดูแลอย่างดีเป็นฐาน แล้วค่อยซ้อนซุปเวอร์ชันแบบปรับแต่งเองของเครื่องมือและ language runtime ด้วยเครื่องมืออย่าง mise หรือ asdf ก่อนรันแอปได้ มันแทบไม่พังเลย พอพังก็แค่ลองขยับเวอร์ชันกับแก้จุดเล็ก ๆ ให้กลับมาทำงานได้แล้วก็ไปต่อ การที่มันพังนั้นน่าหงุดหงิดก็จริง แต่ไม่ใช่เรื่องสำคัญ การเพิ่มแรงเสียดทาน บังคับให้ต้องเรียนรู้ หรือเพิ่มงานให้มากขึ้นคือการเสียเวลา
ในทางกลับกันก็มีคนที่พยายามหาวิธีแก้ไม่ให้มันพังอีกเลย สำหรับคนกลุ่มนี้ ปัญหานี้สำคัญ ดังนั้นแม้วิธีแก้จะเพิ่มแรงเสียดทาน การเรียนรู้ และงานเพิ่มขึ้นก็ยอมรับได้ คนกลุ่มนี้ต้องการ Nix
แต่คนส่วนใหญ่อยู่ในกลุ่มแรก ดังนั้นบริษัทอย่าง Railway ที่ต้องการเติบโต สุดท้ายก็ต้องเลือกวิธีแก้ที่เหมาะกับกลุ่มนั้น
Cargo.lockเป็นเรื่องง่ายมาก nixpkgs อาจเดินคนละทางกับซุปเวอร์ชันแบบปรับแต่งเอง แต่ ตัว Nix เอง ก็รองรับแนวทางนั้นได้ดีเหมือนกันจากประสบการณ์ที่เคยทำงานด้าน DevOps/SRE ถ้ามีใครพยายามสร้างระบบมาจัดการ dependency อะไรทำนองนี้ โดยมากจะลงเอยที่หนึ่งในสองทางนี้ ยก Python เป็นตัวอย่างได้
ทางเลือก 1: “ใช้ shared monorepo ขนาดใหญ่ตัวเดียวไปเลย” ข้อดีคือทุกอย่างอยู่ที่เดียว มีของที่ต้องใช้ครบ และทุกคนใช้ของชุดเดียวกัน ทำให้แก้ปัญหาอย่างช่องโหว่ได้ง่าย ข้อเสียคือจะมีคนที่อยากได้เวอร์ชันพิเศษอยู่เสมอ การ rollout แบบค่อยเป็นค่อยไปทำได้ยากจนการเปลี่ยนแปลงมักกลายเป็นบิ๊กแบง และสุดท้ายก็จะมีคำถามว่า “แล้วจะทำ Docker เวอร์ชันเล็ก ๆ ยังไง?”
ทางเลือก 2: “ให้ทุกคนมี conda/venv ของตัวเอง” ข้อดีคือแต่ละคนได้สิ่งที่ต้องการแบบตรงเป๊ะ ไม่ต้องใช้แพ็กเกจที่ไม่จำเป็น และอัปเกรดทีละขั้นได้ง่าย ข้อเสียคือจะกลายเป็น “นี่มี conda environment ทั้งหมดกี่อันกันแน่?” ไลบรารีของคนละกลุ่มอาจไม่เคยถูกทดสอบร่วมกันบนชุด Python library เดียวกัน และการจัดการช่องโหว่ก็กลายเป็นฝันร้ายเพราะไม่รู้ด้วยซ้ำว่า conda environment ต่าง ๆ อยู่ที่ไหน
เพราะงั้นผมจึงมักสงสัยทุกครั้งที่มีคนบอกว่า “วิธีใหม่นี้แก้ได้หมด” ยิ่งทำงานมานานเท่าไร ประโยคที่ว่า “ไม่มีทางแก้ มีแต่ trade-off” ก็ยิ่งจริงมากขึ้นเท่านั้น
ต่อให้มีประสบการณ์กับ Nix แค่นิดหน่อย ผมก็มองว่าประเด็นหลายอย่างตรงนี้ไม่ค่อยถูกนัก
มีการบอกว่า “มันไม่ชัดเจนหรือดูแลรักษาได้ยากสำหรับผู้ร่วมพัฒนาที่ไม่คุ้นกับการจัดการเวอร์ชันของ Nix” และ “Node กับ Python เหลือรองรับแค่ major version ล่าสุด” แต่ผมไม่เข้าใจว่าทำไมสิ่งนี้ถึงทำให้ดูแลรักษาไม่ได้ ถ้าปัญหาคือต้องทำรายการเวอร์ชันที่ใช้ได้ ก็สงสัยว่าทำไมถึง automate ไม่ได้
ยิ่งไปกว่านั้น ผมก็ไม่เข้าใจว่าทำไม Railway ต้องเป็นคนกำหนดวิธีที่ผู้ใช้ใช้ Nix ด้วย หนึ่งในแก่นของ Nix ไม่ใช่การที่สามารถตั้งค่าเครื่องเปล่าให้มีแพ็กเกจเวอร์ชันที่ต้องการอย่างแม่นยำหรอกหรือ ทำไม Railway ต้องเข้ามาคั่นกลางระหว่างผู้ใช้กับเวอร์ชันเหล่านั้นแล้วคอยจำกัดอีก
ถ้าเป็นโครงสร้างที่ผู้ใช้มองไม่เห็น Nix โดยตรง ก็ยังคงเหลือคำถามเดิมอยู่ดีว่า รายการเวอร์ชันของแพ็กเกจ มัน automate ไม่ได้จริงหรือ?
พูดตรง ๆ เหตุผลที่ยกมาดูไม่ค่อยแน่นนัก เป็นไปได้ว่าอาจเป็นเพราะคนที่ผลักดัน Nix เข้ามาออกจากทีมไปแล้ว และคนที่เหลือก็ไม่ได้ชอบมันเท่าไร ภาษานี้เองก็ไม่ได้ยอดเยี่ยมนัก และเอกสารสมัยก่อนก็ไม่ได้ดีมากด้วย
ถึงอย่างนั้น ผมก็ยังสงสัยว่าแม้จะไม่ได้รู้จักสแตกที่พวกเขาเลือกดีพอ แต่มันให้ determinism ได้ใกล้เคียง Nix หรือไม่ ถ้าไม่ได้ ก็อาจย้อนกลับมาสร้างปัญหาหรือทำให้การดำเนินงานยากขึ้นทีหลัง
/nix/storeเดียว และมีแพ็กเกจและไลบรารีที่เกี่ยวกับ Nix ทั้งหมดซึ่งจำเป็นทั้งตอน build และ runtime อยู่ในนั้น”ฟังดูคล้ายกับการบอกว่า “เราทิ้งรถยนต์เพราะทำให้มันวิ่งไปข้างหน้าไม่ได้” ทั้งที่นี่เป็นหนึ่งในสิ่งที่ Nix ทำได้เสถียรที่สุดอยู่แล้ว มันตรวจจับ runtime dependency ที่ถูกอ้างอิงจริงจากไบนารีผลลัพธ์โดยอัตโนมัติ ด้วยการจับคู่สตริงแฮชของ
/nix/storeถ้าทำสิ่งนั้นไม่ได้ ก็แปลว่าใช้งานมันแปลกมากหรือพลาดหนักมาก จนผมนึกแทบไม่ออกเลยว่าจะทำอย่างไรให้ Nix แก้ปัญหานี้ให้อัตโนมัติไม่ได้
ดังนั้นผมคงไม่รับเอาประสบการณ์ Nix ของพวกเขามาเชื่อแบบเต็มที่นัก เรื่องการจัดการเวอร์ชันเป็นปัญหาทั่วไปมากที่ทุกคนเจออยู่แล้ว ดังนั้นถ้าพยายามแก้มันจริง ๆ ก็น่าจะน่าสนใจกว่านี้
Nix ไม่ได้ให้การรับประกันเรื่องเวอร์ชันแบบตามใจ แต่ให้ การรับประกันตาม commit ถ้าเจอกรณีขอบ คุณก็น่าจะต้องปวดหัวกับการเปลี่ยนแปลงของ glibc หรือ shared library ที่ชนกัน
อาจจะสายไปหน่อย แต่ผมยินดีให้คำปรึกษาเพื่อทำให้มันทำงานตามแนวทางแบบ Nix idiomatic ได้ ตัวผลิตภัณฑ์ดูดีมาก
และไม่ใช่แค่นั้น แต่ยังลามไปถึง dependency ของ dependency แล้วก็ dependency ของอันนั้นต่อ ๆ กันไป จนการ rebuild ครั้งใหญ่เกิดขึ้นบ่อยมาก
มันช่วยหลีกเลี่ยงการชนกันของ shared library ได้ก็จริง แต่ทางแก้นี้สิ้นเปลืองมาก และอาจทำให้การพัฒนาเจ็บปวดได้ ดูจากกระบวนการ staging ของ nixpkgs ก็ได้
ถึงอย่างนั้น มันก็น่าจะยัง “ถูกแพ็กมาให้มีโอกาสทำงานได้สูง” มากกว่าซอฟต์แวร์ 95% บนโลกนี้อยู่ดี
ผมไม่เข้าใจว่าทำไมถึงไม่สร้าง derivation ของตัวเอง แทนที่จะไปพึ่งแฮชของ nixpkgs