- แม้จะรัน
docker run ubuntu ก็ยังคง แชร์เคอร์เนล Linux ของโฮสต์ และ Ubuntu มีหน้าที่เพียง จัดเตรียมเครื่องมือใน user space เท่านั้น
- ผลลัพธ์ของ
uname -r จะแสดงเป็น เวอร์ชันเคอร์เนลของโฮสต์ และมีเพียง /etc/os-release ที่แสดงข้อมูล Ubuntu
- VM แต่ละตัวมีเคอร์เนลของตัวเองและใช้เวลาบูตหลายนาที ขณะที่คอนเทนเนอร์ เริ่มทำงานในระดับมิลลิวินาที และแชร์เคอร์เนลของโฮสต์ผ่าน การแยกระดับระบบปฏิบัติการโดยไม่ต้องทำ hardware virtualization จึงมี overhead ต่ำ
- ด้วย ความเสถียรของ Linux system call ABI คอนเทนเนอร์จากดิสทริบิวชันที่หลากหลายจึงทำงานบนเคอร์เนลเดียวกันได้
- ในสภาพแวดล้อม RAM 16GB ขีดจำกัดที่ใช้งานได้จริงคือคอนเทนเนอร์ขนาดเล็ก 50-100 ตัว, ขนาดกลาง 10-30 ตัว และ คอนเทนเนอร์ขนาดใหญ่ 5-10 ตัว
- เหตุผลที่ต้องเข้าใจสถาปัตยกรรมนี้คือช่องโหว่ของเคอร์เนลส่งผลกับทุกคอนเทนเนอร์ และ การเลือก base image มีผลโดยตรงต่อความเข้ากันได้และความปลอดภัย
ความหมายของการ “รัน Ubuntu”
- เมื่อรัน
docker run ubuntu:22.04 จะได้ bash prompt ที่ดูเหมือน Ubuntu และสามารถใช้ apt update กับติดตั้งแพ็กเกจได้
- แต่เมื่อรัน
uname -r ภายในคอนเทนเนอร์ จะเห็นเป็น เวอร์ชันเคอร์เนลของโฮสต์ (เช่น 6.5.0-44-generic)
- ไฟล์
/etc/os-release จะแสดงว่าเป็น Ubuntu 22.04 แต่ เคอร์เนลเป็นของเครื่องโฮสต์ และส่วนที่เป็น “Ubuntu” นั้นเป็นเพียง ไฟล์ซิสเต็มที่ประกอบเป็น user space เท่านั้น
คอนเทนเนอร์ vs เครื่องเสมือน: เปรียบเทียบสถาปัตยกรรม
- VM ทำ virtualization ของฮาร์ดแวร์ ส่วนคอนเทนเนอร์ทำ virtualization ของระบบปฏิบัติการ
- ความต่างหลัก:
- เคอร์เนล: VM แต่ละตัวมีเคอร์เนลของตนเอง ส่วนคอนเทนเนอร์แชร์เคอร์เนลของโฮสต์
- เวลาบูต: VM ใช้เวลาหลายนาที ส่วนคอนเทนเนอร์ใช้เวลาเพียง มิลลิวินาที
- memory overhead: VM อยู่ที่ 512MB-4GB ส่วนคอนเทนเนอร์อยู่ที่ 1-10MB
- การใช้ดิสก์: VM ใช้ 10-100GB ส่วนอิมเมจคอนเทนเนอร์ใช้ 10-500MB
- ระดับการแยก: VM อยู่ที่ระดับฮาร์ดแวร์ ส่วนคอนเทนเนอร์อยู่ที่ระดับ โปรเซส
- ประสิทธิภาพ: VM มี overhead ราว 5-10% ส่วนคอนเทนเนอร์ให้ ประสิทธิภาพใกล้เคียง native
องค์ประกอบจริงของ base image
- เนื้อหาของ tarball ที่ถูกดาวน์โหลดเมื่อ pull
ubuntu:22.04:
-
1. ไบนารีที่จำเป็น (/bin, /usr/bin)
/bin/bash (เชลล์), /bin/ls (แสดงรายการไฟล์), /bin/cat (แสดงเนื้อหาไฟล์)
/usr/bin/apt (ตัวจัดการแพ็กเกจ), /usr/bin/dpkg (เครื่องมือแพ็กเกจของ Debian)
-
2. shared library (/lib, /usr/lib)
- glibc และ shared library อื่น ๆ ที่โปรแกรมใช้ลิงก์
/lib/x86_64-linux-gnu/libc.so.6 (C library - พื้นฐานของทุกโปรแกรมภาษา C)
- ไลบรารีสำคัญอย่าง
libpthread.so.0, libm.so.6
-
3. ไฟล์คอนฟิก (/etc)
/etc/apt/sources.list (คลังแพ็กเกจ)
/etc/passwd (ฐานข้อมูลผู้ใช้)
/etc/resolv.conf (การตั้งค่า DNS ซึ่งโดยปกติมัก mount มาจากโฮสต์)
-
4. ฐานข้อมูลแพ็กเกจ
/var/lib/dpkg/status (แพ็กเกจที่ติดตั้งแล้ว)
/var/lib/apt/lists/ (แคชรายการแพ็กเกจที่ใช้งานได้)
- ไม่มี เคอร์เนล, bootloader หรือไดรเวอร์รวมอยู่ด้วย
เคอร์เนลเหมือนเดิม แต่ทุกอย่างรอบตัวเปลี่ยนไป
- ฟังก์ชันที่ Linux kernel จัดให้มี: การจัดตารางโปรเซส, การจัดการหน่วยความจำ, การทำงานของไฟล์ซิสเต็ม, network stack, device driver, system call
- เมื่อโปรเซสในคอนเทนเนอร์เรียก
open(), read(), fork() คำขอจะถูก ส่งตรงไปยังเคอร์เนลของโฮสต์
- เคอร์เนล ไม่รู้และไม่สนใจ ด้วยซ้ำว่าโปรเซสนั้นมาจาก “Ubuntu container” หรือ “Alpine container”
-
ความเสถียรของอินเทอร์เฟซ system call
- Linux syscall ABI มีความเสถียรสูงมาก
- เหตุผลที่ไบนารีที่คอมไพล์ด้วย glibc 2.31 (Ubuntu 20.04) ยังทำงานได้บนเคอร์เนลของ Ubuntu 24.04:
- เคอร์เนลรักษา backward compatibility
- ไม่มีการเปลี่ยนหมายเลข system call
- มีการเพิ่มฟีเจอร์ใหม่ แต่แทบไม่ลบฟีเจอร์เดิมออก
- นี่จึงเป็นเหตุผลที่รันคอนเทนเนอร์ Ubuntu 18.04 ได้บนโฮสต์ที่ใช้เคอร์เนล 6.5
ลองทำเอง: เคอร์เนลเดียวกัน แต่ user space ต่างกัน
- เมื่อรันคำสั่ง query เคอร์เนลเดียวกันบนหลาย base image จะเห็นได้ว่าทุกอิมเมจ แชร์เคอร์เนลของโฮสต์
ubuntu:22.04, debian:12, alpine:3.19, fedora:39, archlinux:latest ต่างก็ใช้เคอร์เนลเวอร์ชันเดียวกัน (6.5.0-44-generic)
- สิ่งที่ต่างกันในแต่ละคอนเทนเนอร์คือองค์ประกอบของ userland เช่นไบนารี
uname และ libc
เหตุผลที่คอนเทนเนอร์มีประสิทธิภาพมาก
-
1. ไม่มีการทำเคอร์เนลซ้ำซ้อน
- VM แต่ละตัวโหลดเคอร์เนลเต็มชุดขึ้นหน่วยความจำ (ประมาณ 100-500MB)
- VM 10 ตัวกินหน่วยความจำสำหรับ 10 เคอร์เนล แต่คอนเทนเนอร์ 10 ตัวใช้ เคอร์เนลเดียว
-
2. เริ่มทำงานได้ทันที
- ลำดับการบูตของ VM: BIOS → bootloader → kernel → init system → services
- คอนเทนเนอร์ใช้เพียงการเรียก
fork() และ exec() ก็มี โปรเซสพร้อมทำงานภายในมิลลิวินาที
- การบูต VM ทั่วไป: 30-60 วินาที / การเริ่มคอนเทนเนอร์: ประมาณ 0.347 วินาที
-
3. แชร์ image layer
- หากรันคอนเทนเนอร์ 100 ตัวจาก
ubuntu:22.04 base image layer จะมีอยู่ บนดิสก์เพียงครั้งเดียว
- แต่ละคอนเทนเนอร์จะได้เพียง copy-on-write layer แบบบาง สำหรับเก็บการเปลี่ยนแปลง
-
4. แชร์หน่วยความจำผ่านเคอร์เนล
- page cache ของเคอร์เนลถูกแชร์ร่วมกัน
- หาก 50 คอนเทนเนอร์อ่านไฟล์เดียวกัน เคอร์เนลจะ cache ไว้เพียงครั้งเดียว
- เมื่อใช้ shared library เดียวกัน ก็สามารถ แชร์ memory page ผ่าน copy-on-write ได้
คำนวณขีดจำกัดการรันคอนเทนเนอร์
-
วิเคราะห์หน่วยความจำ (อิงจาก VM ที่มี RAM 16GB)
- RAM รวม: 16,384 MB
- overhead ของโฮสต์ OS: -1,024 MB
- Docker daemon: -256 MB
- overhead ของ container runtime: -512 MB
- หน่วยความจำที่ใช้ได้สำหรับคอนเทนเนอร์: 14,592 MB
-
การใช้หน่วยความจำตามประเภทคอนเทนเนอร์
- ขั้นต่ำสุด (
sleep): ประมาณ 1MB
- Alpine + แอปขนาดเล็ก: ประมาณ 25MB
- Ubuntu + แอป Python: ประมาณ 120MB
- Ubuntu + แอป Java: ประมาณ 500MB
- บริการ Node.js: ประมาณ 200MB
-
ค่าสูงสุดในทางทฤษฎี
- คอนเทนเนอร์ขั้นต่ำสุด (1MB): 14,592 ตัว
- Alpine + แอปขนาดเล็ก (25MB): 583 ตัว
- Ubuntu + Python (120MB): 121 ตัว
- Java microservice (500MB): 29 ตัว
-
ขีดจำกัดในโลกจริง
- นอกเหนือจากหน่วยความจำ ยังมีปัจจัยอื่นที่ต้องคำนึงถึง:
- CPU scheduling: หากมีคอนเทนเนอร์แย่งกันมากเกินไป จะเกิด latency spike
- file descriptor: ค่าเริ่มต้นของ ulimit คือ 1024
- network port: ใช้ได้เพียง 65,535 พอร์ตสำหรับการ map พอร์ต
- PID: ถูกจำกัดด้วย
/proc/sys/kernel/pid_max (ค่าเริ่มต้น: 32,768)
- disk I/O: มี overhead จาก OverlayFS และต้องไล่ดูหลายเลเยอร์
- ใน VM 16GB เมื่อรัน workload จริง ขีดจำกัดที่ใช้งานได้คือ:
- คอนเทนเนอร์ขนาดเล็ก (API, worker): 50-100 ตัว
- คอนเทนเนอร์ขนาดกลาง (DB, cache): 10-30 ตัว
- คอนเทนเนอร์ขนาดใหญ่ (โมเดล ML, แอป JVM): 5-10 ตัว
ความเข้ากันได้ของ Linux distribution
-
คำมั่นเรื่อง kernel ABI
- Linux รักษา syscall interface ที่เสถียร
- ไบนารีที่คอมไพล์สำหรับเคอร์เนลเก่ายังทำงานได้บนเคอร์เนลใหม่
- ไบนารีของ Ubuntu 18.04 รันได้ปกติบนเคอร์เนล 6.5
-
กรณีที่ความเข้ากันได้พัง
- ความต้องการฟีเจอร์ของเคอร์เนล: หากคอนเทนเนอร์ต้องการฟีเจอร์ที่เคอร์เนลไม่มี (เช่น io_uring ต้องการเคอร์เนล 5.1+)
- การพึ่งพา kernel module: Wireguard ต้องใช้ wireguard kernel module, ส่วน NVIDIA container ต้องใช้ nvidia kernel driver
- ข้อจำกัดของ seccomp/capability: หากโฮสต์บล็อก syscall ที่คอนเทนเนอร์ต้องใช้ (เช่น การใช้ ptrace ต้องใส่
--cap-add SYS_PTRACE)
แนวทางเลือก base image
| Base image |
ขนาด |
ตัวจัดการแพ็กเกจ |
การใช้งาน |
scratch |
0 MB |
ไม่มี |
ไบนารี Go/Rust ที่ static compile แล้ว |
alpine |
7 MB |
apk |
คอนเทนเนอร์ขนาดเล็กมาก, ใช้ musl libc |
distroless |
20 MB |
ไม่มี |
เน้นความปลอดภัย, ไม่มีเชลล์และตัวจัดการแพ็กเกจ |
debian-slim |
80 MB |
apt |
สมดุลระหว่างขนาดและความเข้ากันได้ |
ubuntu |
78 MB |
apt |
เป็นมิตรกับงานพัฒนา |
fedora |
180 MB |
dnf |
แพ็กเกจใหม่, SELinux |
-
ควรใช้อิมเมจแต่ละแบบเมื่อไร
- scratch: สำหรับไบนารีที่ static compile แล้ว โดยมีเพียงไบนารีและไม่มี OS เลย
- alpine: อิมเมจขั้นต่ำที่ยังต้องการ shell access แต่ใช้ musl libc แทน glibc จึงอาจมีปัญหาความเข้ากันได้บางส่วน
- distroless: อิมเมจ production ที่เน้นความปลอดภัย ไม่มี shell และ package manager ทำให้ดีบักยากกว่าแต่ปลอดภัยกว่า
เส้นแบ่งระหว่าง user space กับเคอร์เนล
-
สิ่งที่มาจาก base image (user space)
- เชลล์ (
/bin/bash, /bin/sh)
- C library (glibc, musl)
- ตัวจัดการแพ็กเกจ (apt, apk, yum)
- ยูทิลิตีหลัก (ls, cat, grep)
- การตั้งค่า init system (โดยมากไม่ใช่ systemd เต็มตัว)
- ผู้ใช้และกลุ่มพื้นฐาน (
/etc/passwd)
- path ของไลบรารีและการตั้งค่าต่าง ๆ
-
สิ่งที่มาจากโฮสต์ (เคอร์เนล)
- การจัดตารางโปรเซสและการจัดการหน่วยความจำ
- network stack (TCP/IP, routing)
- การทำงานของไฟล์ซิสเต็ม (อ่าน, เขียน, mount)
- ฟีเจอร์ด้านความปลอดภัย (namespaces, cgroups, seccomp)
- device driver (GPU, เครือข่าย, สตอเรจ)
- การจัดการเวลาและนาฬิกา
- การเข้ารหัสและการสร้างเลขสุ่ม
-
ภาพลวงที่สร้างโดย namespace
- เคอร์เนลจัดเตรียม namespace เพื่อให้คอนเทนเนอร์รู้สึกเหมือนถูกแยกออกจากกัน
- โปรเซสที่เห็นเป็น PID 1 ภายในคอนเทนเนอร์ อาจมี PID ที่สูงกว่ามากบนโฮสต์ (เช่น 45678)
- เคอร์เนลคง mapping นี้ไว้: PID 1 ในคอนเทนเนอร์ → PID 45678 บนโฮสต์
- นี่คือวิธีที่ การแยกทำงานได้โดยไม่ต้องมี virtualization
ความหมายในสภาพแวดล้อม production
-
1. ช่องโหว่ของเคอร์เนลกระทบทุกคอนเทนเนอร์
- หากเคอร์เนลของโฮสต์มีช่องโหว่ คอนเทนเนอร์ทั้งหมดก็เสี่ยงไปด้วย
- จำเป็นต้องแพตช์โฮสต์อย่างสม่ำเสมอ
-
2. เคอร์เนลของโฮสต์เป็นตัวจำกัดความสามารถของคอนเทนเนอร์
- หากจะใช้ io_uring โฮสต์ต้องใช้เคอร์เนล 5.1+
- ฟีเจอร์ eBPF ต้องใช้เคอร์เนล 4.15+ ที่เปิดออปชันบางอย่างไว้
-
3. ความสำคัญของ glibc vs musl
- Alpine ใช้ musl libc
- ไบนารีบางตัวที่คอมไพล์มาสำหรับ glibc อาจรันไม่ได้
- ตัวอย่างเช่น บน Alpine อาจเกิดข้อผิดพลาดว่าไม่พบ
/lib/x86_64-linux-gnu/libc.so.6 เมื่อรัน glibc binary
-
4. “OS” ของคอนเทนเนอร์เป็นเพียงแนวคิดเชิงการจัดระเบียบ
- จากมุมมองของเคอร์เนล “Ubuntu container” กับ “Debian container” ไม่ต่างกันเลย
- ทั้งหมดเป็นเพียงโปรเซสที่สร้าง syscall เท่านั้น
ความเข้าใจผิดที่พบบ่อย
- ❌ “คอนเทนเนอร์คือ VM แบบเบา”: คอนเทนเนอร์คือโปรเซสที่มีการแยกระดับสูง ส่วน VM ทำ virtualization ของฮาร์ดแวร์และรันเคอร์เนลแยกต่างหาก
- ❌ “แต่ละคอนเทนเนอร์มีเคอร์เนลของตัวเอง”: คอนเทนเนอร์ทั้งหมดแชร์เคอร์เนลของโฮสต์ และ “OS” ของคอนเทนเนอร์มีเพียงไฟล์ใน user space
- ❌ “รัน Ubuntu container = รัน Ubuntu”: จริง ๆ คือการรันเครื่องมือของ Ubuntu บนเคอร์เนลของโฮสต์ หากโฮสต์เป็น Debian ก็เท่ากับกำลังรันบน Debian kernel
- ❌ “base image มีระบบปฏิบัติการครบชุด”: base image มีเพียงเครื่องมือขั้นต่ำใน user space ไม่มีเคอร์เนล, bootloader หรือไดรเวอร์
- ❌ “ยิ่งมีคอนเทนเนอร์มาก = ใช้หน่วยความจำมากขึ้นเสมอ”: ด้วยเลเยอร์ที่แชร์กันและ page cache ของเคอร์เนล คอนเทนเนอร์จึงมัก แชร์หน่วยความจำกันได้อย่างมีประสิทธิภาพ
สรุปประเด็นสำคัญ
- Docker base image คือ สแนปช็อตของไฟล์ซิสเต็มที่มีองค์ประกอบใน user space ของ Linux distribution
- ประกอบด้วยไบนารี, ไลบรารี และคอนฟิกที่ทำให้ Ubuntu ดูและใช้งานเหมือน Ubuntu
- แต่ระบบปฏิบัติการจริงอย่าง เคอร์เนลนั้นแชร์กับโฮสต์
- สถาปัตยกรรมนี้ทำให้เกิด:
- เวลาเริ่มต้นระดับมิลลิวินาที (ไม่มีการบูตเคอร์เนล)
- memory overhead ต่ำมาก (เคอร์เนลเดียว, แชร์ page ร่วมกัน)
- ความหนาแน่นสูงในระดับใหญ่ (หลายร้อยคอนเทนเนอร์ต่อโฮสต์)
- ประสิทธิภาพใกล้เคียง native (เรียก syscall ตรงสู่เคอร์เนล)
- trade-off คือการแยกที่อ่อนกว่า VM เพราะคอนเทนเนอร์แชร์เคอร์เนลเดียวกัน จึงทำให้ kernel exploit กระทบทุกคอนเทนเนอร์ได้
- สำหรับ workload ส่วนใหญ่ trade-off นี้คุ้มค่า
9 ความคิดเห็น
เคอร์เนล + เครื่องมือ = ดิสทริบิวชัน
ถ้าอย่างนั้น แบบนี้ก็นับว่าเป็น Ubuntu เหมือนกันไม่ใช่หรือ..
ก็เลยมีพวกบทสอนที่ให้ลองสร้าง Docker เองบน Linux โดยแยกไดเรกทอรีแล้วก็จัดการเรื่องผู้ใช้กับกลุ่มอะไรทำนองนั้นด้วยนะครับ
มีประโยชน์มาก
리눅스 네임스페이스, cgroups, 및 chroot를 사용하여 자체 Docker를 구축하세요.
ดังนั้นถ้าจะพูดแบบเวอร์ ๆ หน่อย ก็มองได้ประมาณว่า chroot + cgroup = docker
Actually แล้วมันค่อนข้างใกล้กับ
systemd-nspawnมากกว่านิดหน่อยนะ ☝️🤓จริงๆ แล้ว
ประชดประชัน / ถ่อมตัวแบบขำ ๆ
น่าสนใจมากจริงๆ
แม้ว่าเนื้อหาหลักน่าจะอธิบายโดยอิงกับ LINUX เป็นหลัก แต่
ถ้ารันบน Windows ก็คงเป็นการแชร์ virtual kernel ที่สร้างขึ้นด้วย WSL2 แบบที่บทความอธิบายไว้ใช่ไหมครับ?
แล้วถ้าเกิดมีช่องโหว่ใน Docker จนสามารถแตะต้องเคอร์เนลได้ขึ้นมา อยากรู้เหมือนกันว่าควรมองว่า Windows ที่มีการทำ virtualization ซ้อนอีกชั้นหนึ่งจะปลอดภัยกว่า Linux หรือเปล่า
ผมค่อนข้างตกใจกับปฏิกิริยาในคอมเมนต์ด้านบนเหมือนกันครับ
ผมนึกว่าทุกคนน่าจะรู้อยู่แล้วเลยใช้งานกันเสียอีก
ลินุกซ์เคอร์เนลใช้ของโฮสต์ ส่วนที่เหลือเป็นการนำเครื่องมือที่ใช้ในลินุกซ์ดิสทริบิวชันมาใช้งานครับ
เท่าที่ผมทราบ WSL2 รันแบบ virtualized บน Hyper-V ครับ
Windows - ลินุกซ์ใน virtual machine - แล้วค่อยมี Container อยู่ข้างในอีกที...
โดยพื้นฐานแล้ว root ภายใน Container ไม่ใช่ root ของทั้งระบบจริง ๆ เลยไม่สามารถไปจัดการเคอร์เนลได้ตามอำเภอใจครับ
แต่ถ้ามีช่องโหว่ขึ้นมาก็เป็นเรื่องใหญ่เหมือนกัน
ถ้ามองในแง่ประสิทธิภาพ ที่ Windows ช้ากว่านิดหน่อยก็เพราะต้องผ่าน virtualization เพิ่มอีกชั้นหนึ่งครับ