- หากสภาพแวดล้อมของคุณใช้ Docker image เป็นหน่วยสำหรับการดีพลอยอยู่แล้ว Nix ก็เป็นจุดเริ่มต้นให้ทดลองใช้ deterministic build และการตรึงเวอร์ชัน dependency ได้ โดยแทบไม่ต้องเปลี่ยนเวิร์กโฟลว์มากนัก
docker build ทั่วไปมักพึ่งพา สถานะของอินเทอร์เน็ตสาธารณะ เช่น Ubuntu repository หรือการดาวน์โหลดจากภายนอก จึงทำให้สร้าง image เดิมซ้ำได้ยากเมื่อเวลาผ่านไป
- Nix รู้ dependency ที่ต้องใช้ในระดับแพ็กเกจ จึงสามารถใช้
dockerTools.buildLayeredImage เพื่ออัปโหลดใหม่เฉพาะ layer ที่เปลี่ยนไป ได้
- ตัวอย่าง
douglas-adams-quotes ที่พัฒนาด้วย Go แสดงให้เห็นว่าสามารถสร้างไบนารีด้วย pkgs.buildGoModule แล้วใช้ nix build.#docker, docker load <./result เพื่อโหลดและดีพลอยเหมือน Docker image ปกติได้
- หากหลายบริการใน monorepo ใช้ layer และ Nix cache ร่วมกัน ก็จะช่วยลดเวลาและต้นทุนการบิลด์ทั้งในการสร้าง image จาก commit เก่าและการดีพลอยซ้ำ
เหตุผลที่ควรนำ Nix มาใช้กับการบิลด์ Docker image
- Nix มีคุณลักษณะรวมกัน 3 อย่างคือ package manager, ภาษา, และ ระบบปฏิบัติการ
- ใช้ภาษา Nix เขียนคำสั่งสำหรับบิลด์แพ็กเกจ
- จากคำสั่งเหล่านั้น Nix package manager สามารถสร้างซอฟต์แวร์ เครื่องมือ NixOS image และ container image ได้
- การนำ Nix เข้าไปใช้ใน CI/CD pipeline เดิมนั้นไม่ง่าย
- Nix แตกต่างจากแนวทางที่นักพัฒนาจำนวนมากคุ้นเคย
- หากไม่ใช่สภาพแวดล้อมที่เริ่มใหม่ได้ทั้งหมด เช่น startup ใหม่หรือ homelab อุปสรรคในการนำมาใช้จะสูง
- องค์กรที่ใช้ Docker อยู่แล้วสามารถเริ่มทดลองใช้ Nix จากส่วน การบิลด์ container image ได้ โดยไม่ต้องรบกวนโฟลว์การดีพลอยเดิมมากนัก
จุดที่การบิลด์ Docker มักสั่นคลอน
- Docker และการทำ containerization ถูกใช้อย่างแพร่หลาย จน Docker image ถูกใช้ราวกับเป็น ฟอร์แมตแพ็กเกจอเนกประสงค์มาตรฐานโดยพฤตินัย ของอินเทอร์เน็ต
- แพลตฟอร์มอย่าง Fly.io, Railway และ Render ใช้ Docker image แทน Linux program แบบ VM image หรือ tarball ใด ๆ
- การบิลด์ Docker ไม่ได้เป็น deterministic เสมอไป
- Dockerfile ที่พบทั่วไปบนอินเทอร์เน็ตส่วนใหญ่มักบิลด์ผ่าน แต่สัดส่วนเล็ก ๆ ที่ล้มเหลวก็อาจกลายเป็นปัญหาในการปฏิบัติการได้
- หนึ่งในจุดเปราะบางที่สุดคือการที่ Docker build เข้าถึง อินเทอร์เน็ตสาธารณะ
- จำเป็นต่อการดาวน์โหลดแพ็กเกจจาก Ubuntu repository
- เมื่อจำเป็นต้องสร้าง image เดิมขึ้นใหม่ในภายหลัง ก็ยากที่จะย้อนสถานะของ Ubuntu repository ให้ตรงกับตอนนั้นได้
- Ubuntu 18.04 กำลังใกล้สิ้นสุดการสนับสนุนในปีนั้น ทำให้บางครั้งอาจเพิ่งรู้ว่ามีอะไรพึ่งพาเวอร์ชันนี้อยู่ก็ตอนระบบล่มแล้ว
- การเพิ่มแพ็กเกจเข้า Docker image แบบง่าย ๆ อาจทำให้เกิด พื้นที่สูญเปล่า
- หากรัน
apt-get upgrade ตั้งแต่ต้นกระบวนการบิลด์ ไฟล์ใน container image อาจถูกแทนที่
- ไฟล์ก่อนถูกแทนอาจยังค้างอยู่ใน layer และสะสมเหมือนสำเนาเงา
วิธีที่ Nix จัดการ Docker layer
- Nix รู้ dependency ที่ต้องใช้ล่วงหน้า และสามารถแยกสิ่งเหล่านี้ออกเป็น Docker layer ให้น้อยที่สุดได้
- แค่เปลี่ยนโค้ดหนึ่งบรรทัด ก็ไม่จำเป็นต้องให้
apt หรือ npm ติดตั้ง dependency ใหม่
- สามารถสะท้อนเฉพาะการเปลี่ยนแปลงขั้นต่ำจริง ๆ ในการอัปเดต image ได้
dockerTools คือชุดเครื่องมือสำหรับใส่ Nix package และ dependency ของมันลงใน Docker image
- Docker image ที่สร้างด้วย Nix แบ่งกว้าง ๆ ได้สองแบบ
- image แบบไม่แยก layer: นำโปรแกรม dependency และรายการเพิ่มเติมอย่าง TLS root certificate ใส่รวมไว้ในโฟลเดอร์เดียว แล้วเผยออกมาเป็น image แบบ single layer
- image แบบแยก layer: วางแต่ละ dependency ไว้ใน image layer แยกกัน เพื่อให้อัปโหลดเฉพาะส่วนที่เปลี่ยนจริงได้
- Docker เองก็มี content-addressed store ภายใน แต่การใช้แค่
docker build อย่างเดียวทำให้ใช้ประโยชน์จากสิ่งนี้ได้ไม่เต็มที่
- image แบบ layered ของ Nix ใช้ layer ระดับแพ็กเกจ ดังนั้น dependency อย่าง
glibc จึงอัปโหลดเพียงครั้งเดียวได้
- อย่างไรก็ตาม หากต้องแก้ไขไลบรารีนั้น layer ดังกล่าวก็ต้องถูกอัปโหลดใหม่
ตัวอย่างบริการ Go: Douglas Adams Quotes
- บริการตัวอย่างคือโปรแกรม Go ที่ให้บริการคำคมของ Douglas Adams
- โปรเจกต์ Go module นิยาม Nix package ด้วย
pkgs.buildGoModule
bin = pkgs.buildGoModule {
pname = "douglas-adams-quotes";
inherit version;
src = ./.;
vendorHash = null;
};
- นิยามนี้ใช้เทมเพลต Go module เพื่อตั้งค่า Go compiler, C compiler สำหรับ CGo และการดาวน์โหลด dependency ภายนอก
pname คือชื่อแพ็กเกจ
version ถูกสร้างอัตโนมัติจาก Git commit ของบริการ
src = ./.; ใช้ซอร์สโค้ดจากไดเรกทอรีทำงานปัจจุบัน
- หากไม่มี dependency นอก standard library สามารถใช้
vendorHash = null ได้
- หากมี dependency ภายนอก ต้องระบุแฮชของ dependency ทั้งหมด หรือใช้
gomod2nix
- สามารถบิลด์แพ็กเกจได้ด้วยคำสั่งต่อไปนี้
nix build .#bin
- Docker image แบบ layered สามารถสร้างได้ด้วย
dockerTools.buildLayeredImage
docker = pkgs.dockerTools.buildLayeredImage {
name = "registry.fly.io/douglas-adams-quotes";
tag = "latest";
config.Cmd = "${bin}/bin/douglas-adams-quotes";
};
- เมื่อระบุเซิร์ฟเวอร์ไบนารีที่บิลด์แล้วใน
config.Cmd รายการที่จำเป็นจะถูกคัดลอกไปด้วย
- รวมถึง
glibc และสิ่งอื่นที่ต้องใช้ในการรัน
- แพ็กเกจเพิ่มเติมอย่าง CA root certificate สามารถใส่ไว้ใน
contents ได้
docker = pkgs.dockerTools.buildLayeredImage {
name = "registry.fly.io/douglas-adams-quotes";
tag = "latest";
contents = with pkgs; [ cacert ];
config.Cmd = "${bin}/bin/douglas-adams-quotes";
};
- สร้าง image และโหลดเข้า Docker daemon ได้ด้วยคำสั่งต่อไปนี้
nix build .#docker
docker load < ./result
- เมื่อลองเปิดดูด้วย
dive จะเห็นว่าแต่ละ layer เพิ่มแพ็กเกจต่าง ๆ จาก nixpkgs และท้ายสุดรายการเหล่านั้นจะถูกทำ symbolic link ไปยัง root ของ image
ผลของการแชร์ layer ใน monorepo
- หากมีหลายบริการอยู่ใน repository เดียวกัน Docker image ที่สร้างด้วย Nix จะสามารถ แชร์ layer กันได้
- แชร์ได้โดยไม่ต้องตั้งค่าเพิ่มเติม
- หากทำด้วย Docker อย่างเดียว มักต้องสร้าง base image กลางหลายตัว และอาจมีเครื่องมือหรือสิ่งที่ไม่จำเป็นสำหรับบางบริการปะปนอยู่
- ตัวอย่างเช่น repository
Xe/x เป็น monorepo ที่รวม side project, งานทดลอง และเครื่องมือสะสมมานาน 10 ปี
- หลายโปรเจกต์ถูกดีพลอยไปยังราวสามแพลตฟอร์ม
- มีการย้ายสิ่งเหล่านี้มาใส่ใน Docker image เพื่อให้คอนเวอร์จไปยังพื้นฐานการดีพลอยร่วมกัน
- เมื่อ push การอัปเดตของบริการหนึ่ง ก็จะมีบางส่วนของการอัปเดตที่แชร์กับบริการอื่นส่วนใหญ่ถูก push ไปด้วย
- แนวทางนี้ช่วยประหยัดเวลาและค่าใช้จ่ายในหลายโปรเจกต์
สิ่งที่ยากจะตามให้ทันด้วย Docker cache อย่างเดียว
- ในทางทฤษฎี หากใช้ Docker caching ก็อาจบิลด์ image ได้มีประสิทธิภาพพอ ๆ กับ Nix
- แต่หากต้องการผลลัพธ์แบบเดียวกันด้วยวิธีของ Docker ขั้นตอนบิลด์อาจดูแลรักษายากขึ้น
- ต้องติดตั้ง shared library ไว้ใน layer แยกต่างหาก
- ต้องเปิด network stack ระหว่างบิลด์อีกครั้ง ทำให้ reproducibility ลดลง
- ต้องปรับ search path, compiler flag และการตั้งค่าที่เกี่ยวกับ CGo ใหม่
dockerTools.buildLayeredImage ของ Nix จัดการรายละเอียดการใส่แพ็กเกจลงใน container แทนให้ จึงทำให้การตั้งค่าง่ายขึ้น
การทำซ้ำสภาพในอดีตและ Nix cache
- ข้อดีสำคัญอย่างหนึ่งของ Nix คือสามารถ บิลด์ซอฟต์แวร์ของช่วงเวลาในอดีตกลับมาได้อย่างแม่นยำ
- หากภายหลังต้องการจำลองบั๊กในสภาพแวดล้อมของลูกค้ารายใด ก็สามารถสร้าง Docker image เดิมขึ้นมาใหม่ได้
- การบิลด์แพ็กเกจทำหน้าที่ตรึงสถานะทั้งหมดของซอฟต์แวร์และ dependency ไว้ ณ ช่วงเวลาหนึ่ง
XeDN เป็นโปรเจกต์ที่ทำต่อเนื่องมาหลายปี และเวอร์ชันเมื่อ 14 เดือนก่อนสามารถบิลด์ได้ด้วยคำสั่งต่อไปนี้
nix build github:Xe/x/567fdc2#xedn-docker
- คำสั่งนี้บิลด์เป้าหมาย
xedn-docker จาก commit ที่กำหนดของ GitHub repo Xe/x
- เมื่อนำไปโหลดเข้า Docker daemon ก็จะได้ image ที่มีไบต์เหมือนกับในเวลานั้น
- ตัวอย่างนี้ยังรวม Go 1.19 ด้วย
- หากจะให้ได้ระดับการทำซ้ำแบบเดียวกันด้วย Docker build ปกติอย่างเดียว อาจต้องใช้ต้นทุนด้านพื้นที่จัดเก็บสูงมาก
- Nix cache เก็บเอาต์พุตของคำสั่ง Nix ไว้เพื่อไม่ต้องบิลด์ใหม่ภายหลัง
- แต่ละโน้ตบุ๊กของนักพัฒนาไม่จำเป็นต้องบิลด์แพ็กเกจอย่าง
nokogiri ใหม่ทุกครั้ง
- สามารถรับผลลัพธ์ที่บิลด์ไว้แล้วจากคลาวด์ได้
- Garnix ถูกใช้เป็น CI สำหรับทุก flakes project และรายงานสถานะการบิลด์ในแต่ละ commit
- แม้แต่การตั้งค่าเครื่อง homelab ก็ถูกบิลด์ด้วย Garnix ทำให้ตอนอัปเดตทุกเย็นสามารถดึงการตั้งค่าล่าสุดจาก Garnix cache แทนการบิลด์เองได้
บทสรุปในฐานะผลลัพธ์สำหรับการดีพลอย
- Nix อาจถือได้ว่าเป็น Docker image builder ที่ดีกว่า image builder ของ Docker เอง
- Nix บังคับให้ระบุผลลัพธ์ที่ต้องการ แทนการไล่ระบุขั้นตอนย่อยทั้งหมดเพื่อไปให้ถึงผลลัพธ์นั้น
- ในสภาพแวดล้อมที่ใช้ Docker อยู่แล้ว การเริ่มนำ Nix มาใช้จากการบิลด์ Docker image คือเส้นทางนำไปใช้ที่สมจริง
- Docker image ที่สร้างด้วย Nix สามารถแชร์ layer ระหว่างหลายส่วนของ monorepo ได้
- ด้วย binary cache จึงไม่จำเป็นต้องบิลด์โค้ดที่เคยบิลด์ไปแล้วในอดีตซ้ำอีก
- ผลลัพธ์สุดท้ายคือ container image ทั่วไปที่สามารถนำไปดีพลอยบนแพลตฟอร์มอย่าง AWS, Google Cloud และ Fly.io ได้
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ฉันพยายามจะชอบ Nix มาหลายครั้งแล้ว แต่ตอนนี้เริ่มรู้สึกว่าคงต้องยอมแพ้
ฉันมีระบบที่รัน Nix อยู่ 2 เครื่องและกลัวจนไม่กล้าแตะมัน อีกทั้งครั้งหนึ่งทั้งสองเครื่องก็พังจนต้องติดตั้งใหม่ตั้งแต่ต้นด้วย ในทางทฤษฎี Nix เป็นแบบ idempotent และ deterministic แต่ในทางปฏิบัติคุณต้องเข้าใจก่อนว่า มัน deterministic ต่ออะไร และถ้าไม่รู้พฤติกรรมของทุกส่วนที่มันพึ่งพาอย่างลึกซึ้ง คุณก็จะเจอผลลัพธ์ประหลาด ๆ ข้อผิดพลาดพิกล ๆ ที่ไม่ช่วยอะไร หรือไม่ก็ไม่มี feedback เลย
เอกสารแม้จะครบและถูกต้องในเชิงเทคนิค แต่ก็ เข้าใจยากอย่างสาหัส และบทสอนก็มักพาไปได้แค่ประมาณ 80% ของทางเท่านั้น พอเลยจากนั้นไปคุณต้องปะติดปะต่อเอง ซึ่งต้องใช้ทั้งความรู้จากประสบการณ์หลายปีและความหงุดหงิดจนฉันไม่อยากรับมืออีกแล้ว จุดแข็งของ Docker กลับเป็นความโกลาหลของมันเอง เพราะแค่พอรู้เรื่อง shell กับ package manager ของดิสโทรแบบคร่าว ๆ ก็สร้างอะไรได้แทบทุกอย่าง และถ้ามีปัญหาก็วิเคราะห์กับแก้ได้ง่ายกว่ามากด้วยเครื่องมือที่สั่งสมมาหลายสิบปี
Nix ก็เหมือน Emacs คือถ้ามีความอดทนกับความรู้เชิงลึกระดับมีวิสัยทัศน์ คุณจะทำอะไรก็ได้ แต่ดูเหมือนว่าจะมีแค่สองทางคือทุ่มตัวลงไปสุดทาง หรือไม่ก็ยืนดูห่าง ๆ
ถ้าบิลด์สองครั้งติดกันบนเครื่องเดียวกันก็จะได้ผลลัพธ์เหมือนกัน แต่พอเวลาผ่านไป package manager กับแท็กของ base image ถูกอัปเดต หนึ่งเดือนให้หลังผลการ rebuild ก็อาจต่างไปอย่างสิ้นเชิง พอทีมต้องดูแลคอนเทนเนอร์สัก 40 ตัว งานซ่อมคอนเทนเนอร์ก็กลายเป็นส่วนใหญ่ของงานไป
เพราะงั้นในทางทฤษฎี Nix ควรเป็นคำตอบที่สมบูรณ์แบบ แต่ความต่างของมันมากเกินไปจนเครื่องมือจาก vendor ใช้งานบน Nix ไม่ได้ และพอเกิดข้อผิดพลาดก็หาวิธีแก้เร็ว ๆ จากบนเว็บได้ยาก ความอึดอัดนี้ทำให้ฉันสร้าง https://www.stablebuild.com ซึ่งให้ deterministic build แบบอิง Docker บนคอนเทนเนอร์ Ubuntu, Debian และ Alpine โดยประกอบด้วย immutable Docker Hub pull-through cache, สำเนาเต็มรายวันของคลังแพ็กเกจ Ubuntu/Debian/Alpine, สำเนารายวันของ PPA ยอดนิยมและดัชนี PyPI รวมถึง immutable cache สำหรับไฟล์หรือ URL ใด ๆ
ในงานจริงมันใช้ง่าย ดีบักง่าย และรองรับซอฟต์แวร์ได้กว้าง ดังนั้นหลังย้ายคอนเทนเนอร์มาใช้ StableBuild ก็มีปัญหาที่เกิดจากความไม่ deterministic เป็นศูนย์
ฉันเคยเสียเวลาไปกับการพยายามเข้าใจว่าทำไม
nix developถึงไม่ทำงานตามเอกสาร และจะทำให้มันทำงานแบบนั้นได้อย่างไร เอกสารบอกว่าโดยพื้นฐานแล้วมันจะเข้าไปในสภาพแวดล้อมตอน build แต่ค่าปริยายจริงกลับไม่ sealed และชื่อ option บน command line ก็ชวนสับสนจนต้องไปขุดความรู้จากซอร์สโค้ดเองฉันยังอยากให้มีการอธิบาย edge case ที่ทำลาย reproducibility ให้ชัดเจนกว่านี้ด้วย โค้ด floating-point ไวต่อการเรียงลำดับการคำนวณ และสถานะก็อาจรั่วออกมาจากการ preempt ของระบบปฏิบัติการได้ ต่อให้เป็นเรื่องที่ดู obvious ถ้าไม่เขียนให้ชัด คนก็พร้อมจะพลาดกันเอง
ถ้าใช้การตั้งค่า “Erase your darlings” ก็จะกำจัดสถานะที่ทำซ้ำไม่ได้ส่วนใหญ่นอกบัญชีผู้ใช้ได้ มันยุ่งยากอยู่บ้าง แต่บน NixOS จะมีอะไรที่ไม่ยุ่งยากนักล่ะ
https://grahamc.com/blog/erase-your-darlings/
ส่วนในบัญชีผู้ใช้ฉันไม่ค่อยสนใจ และก็ไม่ชอบ Home Manager
Docker ก็โอเค แต่บน Mac ประสิทธิภาพไม่ค่อยดี ส่วน Nix นั้นดีตรงที่ทำให้การติดตั้งและทำงานให้เหมือนกันบนหลายเครื่องเป็นเรื่องเล็กน้อย เอกสารของ Nix แย่มากก็จริง แต่ ChatGPT-4 ช่วยแก้ปัญหา Nix ได้ยอดเยี่ยม
รู้สึกว่าปัญหา Nix 90% เกิดจากการพยายามทำสิ่งที่ไม่ใช่ “วิธีแบบ Nix”
ฉันเคยใช้ package manager ของแทบทุกดิสโทรมาแล้ว และต่อให้ไม่ได้ทำอะไรแปลกเป็นพิเศษ ก็ทำให้มันพังได้ไม่ทางใดก็ทางหนึ่งเสมอ Fedora Kinoite ของฉันทนได้ดีมากทั้งการเพิ่ม/ลบเลเยอร์ อัปเดตรายวัน และแม้แต่ rebase จาก Silverblue ส่วนตัวคิดว่า rpm-ostree น่าจะมาแทน Nix ได้
ผมมองว่า Nix และ NixOS อยู่ในสถานะคล้ายกับ git ก่อนยุค GitHub
แนวคิดพื้นฐานอิงอยู่กับวิทยาการคอมพิวเตอร์ที่จริงจังกว่าวิธีเดิมอย่าง SVN หรือ Docker มาก ขณะที่ระบบภายในยังมีปัญหาอยู่บ้างแต่ก็ไม่ได้แย่กว่าเดิม เพียงแต่เครื่องมือและเอกสารที่ผู้ใช้ต้องเจอโดยตรงยังไม่อยู่ในระดับ การยอมรับกระแสหลัก
การเปิดตัว flox อาจเปลี่ยนเรื่องนี้ไปแล้ว: https://flox.dev. ประสบการณ์แทบจะลื่นไหล และก็ไม่น่าแปลกใจนักเพราะมาจากทีม DE Shaw
Nix แทบไม่มีความหมายหากไม่มี flakes และ
nix-commandแต่ทั้งคู่ก็ถูกระบุในเอกสารว่ายังเป็นฟีเจอร์ทดลองและยังปิดไว้เป็นค่าเริ่มต้น เอกสารดีขึ้นเรื่อย ๆ แต่ก็ยังไม่พอ และแม้ nixlang จะเจ๋งมากถ้าเรียนจนคล่อง แต่ก็คงยอมรับไม่ได้ในฐานะกำแพงการเข้าสู่กระแสหลักnix-env -iA fooในทางปฏิบัติมักไม่ใช่พฤติกรรมที่คุณต้องการ ดังนั้น Nix จึงใกล้เคียงกับอาวุธลับสำหรับบริษัทที่รับภาระความเชี่ยวชาญภายในองค์กรได้ มากกว่าจะเป็นตัวจัดการแพ็กเกจแบบที่มันโฆษณาตัวเองflox ช่วยลดกำแพงแบบ “ลองครั้งเดียวแล้วได้ประสบการณ์ที่ดีกว่าทันที” ลงได้ Nix/NixOS หรือสิ่งที่คล้ายกันในท้ายที่สุดจะผลัก Docker ให้กลายเป็นแบบเดียวกับ Subversion แต่คงไม่เกิดขึ้นจนกว่าจะถึงโมเมนต์แบบ GitHub แล้วจากนั้นมันจะเกิดขึ้นรวดเดียว
ข้อบ่นเรื่อง Nix ส่วนใหญ่ในเธรดนี้ แม้ในเชิงเทคนิคจะไม่ถูกต้อง แต่ก็เข้าใจได้ทั้งหมด และที่สำคัญกว่านั้นคือ ไม่ใช่ความผิดของผู้ใช้ ผมรู้ว่าคนรุ่นที่ยังจำโลกก่อน git/GitHub ได้กำลังเหลือน้อยลงเรื่อย ๆ แต่ก็น่าไปฟัง Linus อธิบายต่อหน้าคนที่ผ่านเกณฑ์รับเข้าทำงานยุคแรกของ Google ว่าทำไมพวกเขาควรสนใจเครื่องมือที่รู้สึกว่าซับซ้อน
https://youtu.be/MjIPv8a0hU8?si=QC0UnHXRdMpp2tI4
เราสร้างสิ่งนี้กันมาสักพักแล้ว และถ้ามีอะไรที่ควรเอาไว้เป็นลำดับความสำคัญเพื่อทำให้มันดีขึ้น ผมก็อยากฟัง
git เป็นแบบนั้น แต่ Nix ยังไม่ใช่ ดังนั้นผมเลยไม่แน่ใจว่าโมเมนต์แบบ GitHub จะช่วยได้หรือเปล่า
บล็อกโพสต์นี้ไม่ได้อธิบายว่าทำไม เลเยอร์ Docker ที่ใช้ร่วมกัน ถึงมีประโยชน์
คำตอบคือเรื่องแคช และยิ่งมีอิมเมจจำนวนมากแชร์เลเยอร์เดียวกันได้มากเท่าไร ก็ยิ่งแคชได้มากขึ้น ทำให้เริ่มคอนเทนเนอร์ได้เร็วขึ้น
จุดที่ Docker อ่อนในเรื่องนี้คือ ถ้าคุณจะได้ประโยชน์จากแคช ตอนสร้างอิมเมจก็ต้องได้เลเยอร์ที่เหมือนกับเลเยอร์เดิมให้มากที่สุด ตัวอย่างเช่น ถ้าวันนี้คุณรัน
apt-get install python3แล้วไม่มีอัปเดตใหม่ ก็ควรได้เลเยอร์ที่เหมือนกับเมื่อวานทุกประการ แต่เลเยอร์ของ Docker แคชด้วยแฮชของไฟล์ ดังนั้นไฟล์ทุกไฟล์ต้องเหมือนกันเป๊ะ รวมถึงเมทาดาทาอย่างเวลาสร้างด้วยNix เก็บ dependency เป็นแฮชอยู่แล้ว ดังนั้นถ้าเป็นเวอร์ชันเดียวกันและการตั้งค่าเดียวกัน เลเยอร์ก็จะเหมือนเดิมเสมอ
โดยปกติ dependency มักเป็นกราฟ ไม่ใช่ต้นไม้ เลยทำให้มันน่ารำคาญได้อย่างรวดเร็ว เครื่องมือทางเลือกอย่าง Nix หรืออาจรวมถึง Bazel ไม่มีข้อจำกัดแบบนี้ และสามารถแมป dependency graph ไปเป็นเลเยอร์ Docker เพื่อให้ได้การแคชอย่างละเอียดได้ ซึ่งเป็นสิ่งที่ Dockerfile แสดงออกมาไม่ได้
apt-getจะไม่ถูกทำให้ใช้ไม่ได้โดยอัตโนมัติมันจะเปลี่ยนก็ต่อเมื่อใช้
--no-cacheหรือเมื่อมีการเปลี่ยนแปลงในเลเยอร์ด้านบนเท่านั้นช่วง 2~3 วันที่ผ่านมา ฉันหัวหมุนกับการพยายาม build Docker image บน Darwin แล้วบทความนี้ก็ให้ความรู้สึกเหมือนจักรวาลกำลังแกล้งฉันอยู่
Nix เป็นเครื่องมือที่ดีที่สุดสำหรับเป้าหมายที่ต้องการอย่างไม่ต้องสงสัย แต่ก็มี มุมมืดรกร้าง ที่คอยดูดวิญญาณอยู่เหมือนกัน รักมันนะ แต่บางทีก็รู้สึกเหมือนเป็น Morty ที่ถูกลากเข้าไปในการผจญภัยแดนคอมไพเลอร์ของ Rick
Docker โดยเนื้อแท้แล้วแทบจะเป็นคุกที่เอาไว้ขัง Linux binary ไว้เท่านั้น บน Linux แค่ build binary แล้วใส่ลงใน container ก็จบ และโค้ด Nix ก็เรียบง่าย ถ้าคุณ build โค้ดได้ การสร้าง container ก็เป็นแค่อีกหนึ่งขั้นตอนเท่านั้น
แต่ Docker ต้องการ Linux binary และบน Mac นั้น Docker Desktop จะยก Linux VM ขึ้นมาแล้วทำทุกอย่างอยู่ข้างในก่อนจะซ่อนข้อเท็จจริงนั้นไว้ ส่วน Nix ไม่ได้ทำแบบนั้น จึงเหลือทางเลือกอยู่สองแบบ
แบบแรกคือ cross-compile แต่ต้อง cross-compile ได้ถึงระดับ glibc และแม้ dependency จากชุมชนส่วนใหญ่จะรองรับ แต่บางแพ็กเกจก็อาจเขียนโดยผู้พัฒนาที่ไม่ได้คำนึงถึงการ cross-compile เลย ยิ่งไปกว่านั้น Hydra ที่คอยเติม standard Nix cache ก็ไม่ได้ build งานแบบ cross-compile ดังนั้นคุณอาจ build อยู่นานมากก่อนจะล้มเหลว
แบบที่สองคือผูก Linux builder เข้ากับ Mac แล้วส่งงาน build
x86_64-linuxไปให้ฝั่งนั้นทำ จะเป็นเครื่องจริง, VM, หรือแม้แต่ NixOS Docker container ก็ได้ข้อ 1 ดูเหมือนจะเป็นวิธีที่ถูกต้อง แต่ข้อ 2 ใช้งานได้จริงมากกว่า ปัญหาที่เจอน่าจะเกิดจากการพยายามทำข้อ 1 และสิ่งนี้ไม่ใช่แค่ต้องเข้าใจ Nix มาก ๆ แต่ยังต้องมีประสบการณ์ cross-compile พอสมควรด้วย คงจะดีถ้า Hydra สามารถ build งาน cross-compile จาก Darwin ไป Linux เพื่อทำ cache และช่วยกันไม่ให้ของพังได้ แต่ต้นทุนก็คงสูงขึ้นด้วย ดูแล้วน่าลองวิธีข้อ 2 มากกว่า
เหมือนจะมีวิธีแก้อย่างเป็นทางการอยู่: https://ryantm.github.io/nixpkgs/builders/special/darwin-bui...
บางสแต็กยังทำ cross-compile ได้ไม่แข็งแรงมากนัก เลยใช้ Docker เพื่อ cross-compile
{aarch64,amd64} x {linux,darwin}ตอนนี้กำลังรัน Docker หลายตัวบน Darwin aarch64 เพื่อคอมไพล์ทั้งหมดอยู่ และประสบการณ์ก็ดีมากhttps://github.com/gytis-ivaskevicius/high-quality-nix-conte...
มีบั๊กอยู่หนึ่งสองตัว แต่ดูเหมือนจะเกี่ยวกับ volume เป็นหลัก ส่วนการ build Docker image ก็จัดการได้โอเค จุดที่หยาบกว่าคือเรื่องความเร็ว เพราะทั้งฮาร์ดแวร์และความจริงที่ว่า Docker ต้อง emulation Linux VM ทำงานซ้อนกันจนโดนไปเต็ม ๆ
ประสบการณ์ในการสร้าง Docker image สำหรับแอปพลิเคชัน Java ด้วย Nix ไม่ค่อยดีนัก
หลังจาก
gradle2nixถูกยกเลิกไป ก็ดูเหมือนไม่มีทางเลือกที่ชัดเจนสำหรับแอปพลิเคชัน Java ที่ใช้ Gradle ก่อนหน้านี้เคยชวนเพื่อนลองทำ Docker image ของ Spring Boot แอปแบบง่าย ๆ ให้มีขนาดเล็กที่สุดเท่าที่จะทำได้ แต่ผลลัพธ์ที่ทำด้วย Nix กลับมีขนาดใหญ่เป็นสองเท่าของ image ที่ทำโดยไม่ใช้ Nixดูโค้ดได้ที่นี่: https://github.com/jossephus/Docker_challenge/blob/main/flak...
ทั้ง zulu และ JDK ที่ gradle ใส่มาพร้อมพารามิเตอร์
jdkถูกบรรจุเข้าไปด้วย ดูgradleGenใน nixpkgs แล้วจะเข้าใจว่าเป็นอย่างไร ส่วนgradle2nixนั้นขออภัยด้วย กำลังทำการปรับปรุงที่ดูแฮ็กน้อยลงอยู่โดยคร่าว ๆ เป็นแบบนี้: https://gist.github.com/takeda/17b6b645ad4758d5aaf472b84447b...
ลิงก์ทุกอย่างกับ musl, คอมไพล์ Python โดยปิดแพ็กเกจทั้งหมดที่แอปไม่ได้ใช้ และตัดส่วนที่ไม่ได้ใช้ใน boto3/botocore ออก เฉพาะ boto3/botocore ก็เกิน 100MB แล้ว
โดยพื้นฐานแล้วแพ็กเกจของ Nix ถูกทำมาเพื่อระบบปฏิบัติการ NixOS จึงอยากให้ทุกฟีเจอร์เปิดใช้งานไว้ในสถานการณ์ทั่วไปที่มีพื้นที่ดิสก์เพียงพอ เลยมี dependencies ที่ไม่จำเป็นติดมาค่อนข้างมาก ในทางกลับกัน Alpine image ถูกออกแบบมาสำหรับ Docker ดังนั้นเป้าหมายคือปิดฟีเจอร์เสริมในแพ็กเกจ ทำให้ผลลัพธ์เล็กกว่า
ถ้าจะทำ image ให้เล็ก ต้องใช้
overrideเพื่อปิดสิ่งที่ไม่จำเป็น ตัวอย่างเช่น zulu มี alsa, fontconfig, freetype, xorg, cups, gtk, cairo, ffmpeg อยู่ด้วย เพื่อนคนนั้นดึงเฉพาะไฟล์ที่จำเป็นอย่างระมัดระวังแล้วใส่ลงคอนเทนเนอร์ แต่ฝั่ง Nix เท่ากับใส่ทั้งแพ็กเกจ zulu และ dependencies ทั้งหมดแบบ bundle ลงไปควรเริ่มจากแก้ให้มี JDK แค่ชุดเดียวก่อน แล้วค่อยลดขนาด JDK เพิ่มด้วยวิธีด้านบน การใช้
openjdk_headlessอาจง่ายกว่าhttps://github.com/NixOS/nixpkgs/blob/master/pkgs/developmen...
https://github.com/GoogleContainerTools/jib/tree/master/jib-...
openjdk_headlessจะข้าม dependencies ของ GTK และ X ที่ Spring ไม่ต้องใช้เปลี่ยนจาก Zulu มาใช้ headless OpenJDK build ของ Nixpkgs เพื่อตัด dependencies ของไลบรารี GUI ออก และใช้
pkgs.jre_minimalกับjlinkเพื่อสร้าง JRE แบบกำหนดเองที่เล็กที่สุดขนาด image อยู่ที่ 161MB ซึ่งใหญ่กว่า image
demo_jlinkเล็กน้อย เพราะ JRE มีขนาดราว 90MB เนื่องจากรวมทุกโมดูลที่จำเป็นต่อการรันแอปจริง ๆ ไว้ครบ การเรียกjdepsในDockerfile_jlinkตรวจจับโมดูลได้ไม่ครบ จึงสร้าง JRE โดยมีแค่java.baseเท่านั้น แม้แต่ JRE แบบเล็กที่สุดของผม ถ้าใส่แค่java.baseขนาด JRE จะอยู่ราว 50MB และ container image ที่ใช้งานไม่ได้จะมีขนาด 117MB ตาม Podmanยังได้ลบ
copyToRootที่ไม่ถูกต้องออกจากการเรียกdockerTools.buildImageด้วย เพราะแค่ string context ของconfig.Cmdก็เพียงพอให้แอปเข้าไปอยู่ใน image แล้ว แต่โค้ดเดิมคัดลอกแอปเข้าไปซ้ำอีกครั้ง นอกจากนี้ยังเปลี่ยนไปใช้dockerTools.buildLayeredImageเพื่อใส่แต่ละ store path เป็น image layer แยกกัน ซึ่งดีต่อการขยายด้านพื้นที่เมื่อแชร์ dependencies ระหว่าง container image หลายตัว แต่ไม่มีผลกับการทดลอง image เดี่ยวสิ่งที่เหลือส่วนใหญ่คือการปรับขนาด JRE ให้เหมาะสมที่สุด
glibcเป็น dependency ที่ใหญ่รองลงมา อยู่ที่ประมาณ 30MB ซึ่งน่าจะเป็นเพราะ Nixpkgs build glibc ให้รองรับ locale และ character encoding จำนวนมาก ไม่แน่ใจว่าจะแยกสิ่งนี้ออกเป็น derivation หรือ output ต่างหากเพื่อให้เลือกใช้ได้หรือไม่ และจะคุ้มค่าในทางปฏิบัติหรือเปล่า ถ้าสร้างหลาย image ด้วยdockerTools.buildLayeredImageก็จะสามารถแชร์ทั้ง glibc และ dependencies อื่น ๆ ได้ทั้งหมด ตราบใดที่ใช้ Nixpkgs commit เดียวกันhttps://github.com/max-privatevoid/hackernews-docker-challen...
ถ้าเริ่มใช้ Nix อยู่แล้วก็ยอดเยี่ยม และก็น่าจะดีถ้า การจัดการแพ็กเกจแบบประกาศเจตนา อย่าง Nix หรือ Guix แพร่หลายมากขึ้น
ถ้าใช้งาน Docker อยู่แล้วแต่อยากค่อย ๆ นำ Nix เข้ามา วิธีแบบในงานบรรยายนี้ก็เป็นอีกแนวทางหนึ่ง: https://youtu.be/l17oRkhgqHE
แทนที่จะย้ายทั้งการตั้งค่าและการ build คอนเทนเนอร์ไปเป็น Nix ทั้งหมดในทันที ก็สามารถคง Dockerfile ไว้แล้วให้มัน build คอนฟิกของ Nix ได้ ข้อเสียใหญ่สุดคือใช้เลเยอร์ไม่ได้เลย ส่วนข้อดีคือสามารถค่อย ๆ ปรับ Dockerfile ไปทีละน้อย พร้อมกับนำโครงสร้างพื้นฐานและระบบอัตโนมัติของ Docker เดิมกลับมาใช้ต่อได้
[1] https://mitchellh.com/writing/nix-with-dockerfiles
แต่ก็สงสัยว่าส่วนใหญ่ของความไม่สามารถทำซ้ำได้นั้นไม่ใช่เพราะไม่มีอะไรรับประกันว่าเลเยอร์ Docker บางชั้นจะยังใช้งานได้ตลอดไปหรอกหรือ ถ้าอย่างนั้นก็อยากรู้เหมือนกันว่า Nix รับประกันได้หรือไม่ว่าเวอร์ชันของแพ็กเกจจะคงอยู่ใน repository ตลอดไป
นอกเรื่องนิดหน่อย แต่ผู้วาดภาพประกอบในสไลด์นี้ Annie Ruygt ก็เป็นคนทำโลโก้และงานแบรนด์อาร์ตให้กับสตาร์ตอัป YC สองแห่งคือ RethinkDB(rethinkdb.com) และ Pachyderm(pachyderm.io) ด้วย
เคยทำงานที่ Pachyderm มาก่อน และ Pachyderm ก็เป็นบริษัทที่ก่อตั้งโดยอดีตวิศวกรของ RethinkDB งานของเธอดีมากจริง ๆ
ไม่นานมานี้ใช้เวลาราวครึ่งวันตามคำแนะนำของทีมอินฟราเพื่อจะ build CI base image ด้วย Nix แต่ภาพที่ได้ใหญ่มาก และบางส่วนก็ใช้ไม่ได้เพราะปัญหาเรื่องลิงก์
สิ่งที่น่าหงุดหงิดมากเป็นพิเศษคือเวลาสร้าง อิมเมจหลายสถาปัตยกรรม มันพยายามจะรันบางอย่างของอีกสถาปัตยกรรมจริง ๆ และรองรับเพียง qemu แบบ hardware virtualization เท่านั้น เครื่อง build กับ workstation ของผมเป็น VM เลยไม่มีสิ่งนั้น แต่มี
binfmt-miscอยู่ ถ้ามัน fork/execmkdirของ arm64 เพื่อสร้าง/tmpก็คงพอเข้าใจได้ แต่ Docker layer เป็นแค่ไฟล์ tar ดังนั้นสร้างไดเรกทอรีแบบข้างล่างนี้ก็ได้echo "tmp uid=0 gid=0 time=0 mode=0755 type=dir" | bsdtar -cf - @-ยังมีโอกาสสูงด้วยว่าเลเยอร์แบบนี้มีอยู่ที่ไหนสักแห่งแล้ว จนผู้ใช้ไม่จำเป็นต้องดาวน์โหลดด้วยซ้ำ
ทุกครั้งที่ลองใช้ Nix จะรู้สึกว่าถ้าอีกไม่กี่เดือนก็น่าจะพร้อมสำหรับการใช้งานประจำได้แล้ว ใน nixpkgs แทบมีทุกแพ็กเกจที่ต้องการ และติดตั้งบน workstation ได้ดี แต่ความต้องการอย่าง “ต้องมี bash, python, build-essential, Bazel” ดูไม่ใช่เป้าหมายของตัวสร้าง Docker image ถ้าจะแค่เอา Go binary หนึ่งไฟล์ใส่ลงใน Docker image ก็ไม่จำเป็นต้องใช้ Nix เลย แค่เอา distroless มาแล้วใส่แอปพลิเคชันลงในไฟล์ tar ก็กลายเป็นคอนเทนเนอร์ได้แล้ว ส่วนตัวผมใช้
rules_ociของ Bazel ซึ่งข้างในก็ทำงานประมาณนั้น และมีความฉลาดเพิ่มอีกนิดตรงที่ build binary สำหรับหลายสถาปัตยกรรม สร้าง image index YAML แล้ว push ขึ้น registryแน่นอนว่าไฟล์ build ของแพ็กเกจต้องรองรับเรื่องนี้ด้วย และที่บอกว่า qemu รองรับแต่ hardware virtualization ก็ดูจะไม่ถูกนัก เพราะ qemu ใช้กับสถาปัตยกรรมที่มีได้แค่ software emulation ก็ได้
ตัวอย่างขั้นต่ำอยู่ที่นี่: https://discourse.nixos.org/t/how-do-i-get-a-shell-nix-with-...
ไม่ถึงกับอยากบอกว่ามันง่ายแค่ใช้
pkgCrossแต่ก็สงสัยว่าในขั้นตอนปกติไปเจอปัญหาเฉพาะอะไรมาhttps://nix.dev/tutorials/cross-compilation.html
ใช้มาหลายปีในบางโปรเจ็กต์แล้ว แต่ถ้าขนาดเป็นปัญหา ก็สามารถสร้างอิมเมจที่เล็กมากและมีเฉพาะสิ่งที่ระบุไว้ได้ด้วยวิธีที่กล่าวถึงในบทความ
[1] https://hub.docker.com/r/nixos/nix/tags
ใช้ musl ด้วย และมัน build อิมเมจที่เล็กที่สุดใน CI ได้อย่างรวดเร็วและสม่ำเสมอ พร้อมทั้งแคชก็ทำงานได้ดีเมื่อเทียบกับทุกเครื่องมือที่เคยใช้มา จึงไม่ค่อยเข้าใจว่าที่บอกว่ามีบางอย่างถูก execute หมายถึงอะไร
buildFHSEnvเพื่อรองรับ third-party binary แปลก ๆ หรือไม่ดูเหมือนว่า Nix ยังต้องการบทความที่อธิบายวิธีค่อย ๆ เปลี่ยนผ่านจากระบบเดิมแบบนุ่มนวลทีละส่วนมากกว่านี้
ในฐานะวิศวกรแพลตฟอร์มก็อยากจะชอบ Nix อยู่หรอก แต่สำหรับคนอื่น ๆ มันไม่ได้ง่ายขนาดนั้น
ผมยังมองว่าประสบการณ์นักพัฒนายังค่อนข้างแย่ ตัวอย่างเช่น ผมชอบประสบการณ์นักพัฒนาของ devbox มากกว่า เพราะสามารถเพิ่มแพ็กเกจได้แบบ
devbox add python@3.11พอดู
flake.nixยาว 120 บรรทัด ก็ยากจะบอกได้เต็มปากว่ามัน “ง่ายกว่า” จริงhttps://github.com/Xe/douglas-adams-quotes/blob/main/flake.n...
flake ที่ลิงก์ไว้นั้นนิยามวิธี build Go binary, นิยาม Docker image ที่ใช้ binary นั้นเป็น entrypoint และยังนิยาม NixOS module ที่สร้างบริการ systemd สำหรับรัน Go binary ด้วย นอกจากนี้ยังรวม NixOS test ที่ตรวจสอบว่า NixOS module นั้นสร้างบริการ systemd ได้ตามที่คาดไว้
เฟรมเวิร์กทดสอบของ NixOS ค่อนข้างน่าประทับใจ และการทดสอบจะรันอยู่ใน QEMU VM ที่รัน NixOS อยู่ด้วย สิ่งที่เกี่ยวข้องกับบทความที่ลิงก์มาคือการนิยาม Go binary กับ Docker image และ boilerplate รอบข้างบางส่วนเท่านั้น
อีกอย่าง หลายบรรทัดก็สอดคล้องแบบ 1:1 กับคำอธิบายบริการ systemd แบบ inline ดังนั้นไม่ว่าจะใช้ระบบไหนก็ไม่ได้หายไปไหน และยังมีวิธีเจ๋ง ๆ ในการประกาศหลายระบบด้วย override อยู่ในนั้น
ตัวอย่างนี้ใกล้เคียงกับ “ลองทำเรื่องเล็ก ๆ แบบที่ทำโปรเจกต์จริงจังขนาดใหญ่” มากกว่า และถ้าทำเป็นงานครั้งเดียวก็น่าจะลดเหลือราว 40 บรรทัดได้
binary กับไฟล์ตั้งค่านั้นเป็นรูปแบบที่ใกล้เคียงกับ “flat pack” ที่สุดเท่าที่จะหาได้บน Linux และตัวอย่างนี้ก็แสดงให้เห็นชัดว่าเรากำลังพลาดป่าทั้งผืนเพราะมัวแต่มองต้นไม้หรือไม่
ใน Guix ก็มี ตัวเลือก Docker ที่เรียบง่ายและใช้ได้ดีอย่าง
guix pack -f dockerGuix ยังมีข้อดีตรงที่ใช้ Guile/Scheme ซึ่งเป็นภาษาที่มีใช้อยู่แล้ว แทนที่จะใช้ภาษาเฉพาะทางของตัวเอง
[1] https://guix.gnu.org/manual/en/html_node/Invoking-guix-pack....