container กับ VM
ความแตกต่างพื้นฐาน
หัวข้อที่มีชื่อว่า “ความแตกต่างพื้นฐาน”ทั้ง container และ VM (VM) แก้ปัญหาเดียวกัน นั่นคือการรันซอฟต์แวร์ใน environment ที่แยกออกจากกัน แต่ทั้งสองบรรลุการแยกนี้ที่ชั้นต่างกันของสแต็ก
VM บูตระบบปฏิบัติการเกสต์แบบเต็มรูปแบบภายใน hypervisor VM แต่ละตัวมี kernel ไลบรารีระบบ และ init system ของตัวเอง ทำให้ VM มีน้ำหนักมาก: ใช้เวลาเป็นวินาที (บางครั้งเป็นนาที) ในการเริ่มต้น และใช้แรมหลายร้อยเมกะไบต์ก่อนที่ application ของคุณจะโหลดด้วยซ้ำ
container ข้ามขั้นตอน guest OS ทั้งหมด โดยแชร์ kernel ของ host และแยกเฉพาะ process namespace, filesystem และ network โดยใช้ฟีเจอร์ kernel Linux (namespaces และ cgroups) ทำให้ container มีน้ำหนักเบา: การเริ่มต้นวัดเป็นมิลลิวินาที และการใช้หน่วยความจำน้อยมาก
การเปรียบเทียบสแต็ก
หัวข้อที่มีชื่อว่า “การเปรียบเทียบสแต็ก”flowchart TB
subgraph VM["Virtual Machine stack"]
direction TB
vmApp["Application"] --> vmGuest["Guest OS (kernel + system libs)"]
vmGuest --> vmHyper["Hypervisor (VMware, KVM, Hyper-V)"]
vmHyper --> vmHost["Host OS Kernel"]
vmHost --> vmHw["Hardware"]
end
subgraph CT["Container stack"]
direction TB
ctApp["Application"] --> ctRt["Container Runtime (containerd / runc)"]
ctRt --> ctHost["Host OS Kernel"]
ctHost --> ctHw["Hardware"]
end ชั้น hypervisor และ guest OS หายไปในโมเดล container container หลายตัวบน host เดียวกันแชร์ kernel เดียว ทำให้ขนาดต่อ container เล็กลงอย่างมาก
พิสูจน์: container คืออะไรกันแน่
หัวข้อที่มีชื่อว่า “พิสูจน์: container คืออะไรกันแน่”คำสั่งสามชุดยุติข้อถกเถียงเรื่อง “แชร์ kernel” ลองรันเองได้ output ด้านล่างมาจาก Docker Desktop 4.81.0 บน macOS (Engine 29.6.1, linux/arm64)
1. Kernel เดียว kernel ที่ container รายงานคือของ engine host ไม่ใช่สิ่งที่อยู่ใน image:
$ uname -sr # on the Mac itselfDarwin 25.6.0$ docker run --rm alpine:3.24 uname -sr # inside a containerLinux 6.12.76-linuxkit$ docker info --format '{{.KernelVersion}}' # the engine6.12.76-linuxkitcontainer รายงาน kernel เดียวกับ docker info และ ไม่ใช่ kernel Darwin ของ Mac Docker Desktop รัน Linux VM (linuxkit) ตัวเล็กหนึ่งตัว และทุก container บนนั้นแชร์ kernel ของ VM นั้น บน Linux server ไม่มี VM: kernel ของ container ก็คือของ server เอง container ไม่ boot kernel แต่ยืมมาใช้
2. Namespace จำกัดสิ่งที่ process มองเห็น แต่ละ container ได้ namespace instance ของตัวเอง process เดียวกันใน container สองตัวจึงมี namespace ID ต่างกัน และ process table เริ่มที่ PID 1:
#!/bin/shdocker run -d --name docker-demo-a alpine:3.24 sleep 300docker run -d --name docker-demo-b alpine:3.24 sleep 300for c in docker-demo-a docker-demo-b; do docker exec "$c" sh -c 'for n in pid net mnt uts; do readlink /proc/self/ns/$n; done; ps'donedocker rm -f docker-demo-a docker-demo-b# container a # container bpid:[4026534874] pid:[4026535010]net:[4026534876] net:[4026535012]mnt:[4026534871] mnt:[4026535007]uts:[4026534872] uts:[4026535008]PID USER TIME COMMAND 1 root 0:00 sleep 300 # PID 1 inside the container 7 root 0:00 psnamespace ID ต่างกันทุกตัว และ sleep 300 เป็น PID 1 ใน process table ของมันเอง (ใน process table ของ host มันคือ process ธรรมดาที่มี PID อื่น) ถ้าเพิ่ม --pid host container จะแชร์ process table ของ engine host แทน: docker run --rm --pid host alpine:3.24 ps แสดง 457 process เริ่มด้วย /initd และ kthreadd (ของ VM) นี่คือความหมายของ “namespace” ในทางปฏิบัติ และเป็นเหตุผลที่ --pid host เป็นสิทธิ์ที่ไม่ควรแจก
3. cgroup จำกัดสิ่งที่ process ใช้ได้ --memory และ --cpus กลายเป็นไฟล์ใต้ /sys/fs/cgroup:
$ docker run --rm --memory 64m --cpus 0.5 alpine:3.24 sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max'6710886450000 10000067108864 คือ 64 MiB เป็นไบต์; 50000 100000 คือ “CPU 50 ms ต่อคาบ 100 ms” หรือครึ่ง CPU หากไม่ตั้ง limit memory.max จะเป็นคำว่า max นี่คืออินเทอร์เฟซ cgroup v2 (docker info รายงาน Cgroup Version: 2)
Footprint container sleep 300 ที่ idle วัดด้วย docker stats --no-stream:
NAME MEM USAGE / LIMIT MEM % PIDSdocker-b1-a 548KiB / 64MiB 0.84% 1docker-b1-b 548KiB / 8.721GiB 0.01% 1ราวครึ่ง mebibyte และหนึ่ง process สำหรับ Alpine container ที่ idle; container ตัวที่สองไม่มี limit “limit” ของมันจึงเป็น 8.7 GiB ของ VM image alpine เป็น layer เดียวขนาด 8.66 MB ที่ทั้งสองตัวแชร์กัน (docker ps -s แสดง 0B (virtual 8.66MB) ทั้งคู่) เราไม่อ้างตัวเลขของ VM เพราะไม่ได้วัดบนเครื่องนี้ สิ่งที่สำคัญคือความต่างเชิงโครงสร้าง (VM เพิ่ม guest kernel และ init)
เมื่อใดควรใช้ container
หัวข้อที่มีชื่อว่า “เมื่อใดควรใช้ container”- Microservices — แต่ละบริการส่งมอบเป็น image ของตัวเอง ปรับขนาดได้อิสระ และรีสตาร์ทโดยไม่กระทบ sibling อื่น
- CI/CD pipelines — container แบบชั่วคราวให้ทุก build มี environment ที่สะอาดและทำซ้ำได้ ซึ่งจะหายไปเมื่อเสร็จสิ้น
- environment นักพัฒนาที่สอดคล้องกัน — image เดียวกันทำงานบนแล็ปท็อปของนักพัฒนา ใน CI และในโปรดักชัน กำจัดบั๊ก “works on my machine”
- การขยายขนาดแนวนอนที่รวดเร็ว — container instance ใหม่เริ่มต้นในมิลลิวินาที ทำให้ auto-scaling ตอบสนองได้ดี
เมื่อใดควรใช้ VM
หัวข้อที่มีชื่อว่า “เมื่อใดควรใช้ VM”- การแยกความปลอดภัยที่แข็งแกร่ง — ทุก container แชร์ kernel เดียว bug ของ kernel หรือ runtime จึงอาจกลายเป็นทางหลุดได้ และเคยเกิดขึ้นจริง: CVE-2019-5736 ทำให้ process ใน container เขียนทับ binary
runcของ host และได้ root บน host (NVD; แก้ใน Docker 18.09.2) ขอบเขต hypervisor ของ VM มีพื้นผิวโจมตีที่เล็กกว่า - ต่างตระกูล OS — การรัน Windows workloads บน host Linux (หรือกลับกัน) ต้องใช้ VM เพราะ container แชร์ kernel host
- application เลกาซี — ซอฟต์แวร์ที่ต้องการเวอร์ชัน kernel เฉพาะ, kernel module หรือการกำหนดค่าระดับระบบที่ไม่สามารถแชร์กับ host ได้
image กับ container
หัวข้อที่มีชื่อว่า “image กับ container”คำสองคำนี้มักสับสนกัน ความแตกต่างมีความแม่นยำดังนี้:
- image คือ blueprint แบบ static อ่านอย่างเดียว — snapshot ของ layered filesystem ที่รวม application dependencies และ metadata ของคุณ image สร้างครั้งเดียวและแชร์ผ่าน registry
- container คือ instance ที่กำลังทำงาน (หรือหยุด) ของ image เมื่อ Docker เริ่ม container Docker จะเพิ่ม thin writable layer ทับบน read-only image layers คุณสามารถรัน container หลายตัวจาก image เดียวกันพร้อมกัน; ทั้งหมดแชร์ image layers เดียวกัน แต่แต่ละตัวได้รับ writable layer ของตัวเอง
flowchart TB
subgraph IMG["Image (read-only layers)"]
direction TB
i3["Layer 3: app binary"]
i2["Layer 2: python:3.14"]
i1["Layer 1: debian:bookworm-slim"]
i3 --> i2 --> i1
end
subgraph CON["Container = Image + writable layer"]
direction TB
cw["Writable layer (runtime changes)"]
c3["Layer 3: app binary (shared, read-only)"]
c2["Layer 2: python:3.14 (shared, read-only)"]
c1["Layer 1: debian:bookworm-slim (shared, read-only)"]
cw --> c3 --> c2 --> c1
end การลบ container จะลบเฉพาะ writable layer ของตัวเอง image พื้นฐานยังคงอยู่และสามารถนำมาใช้สร้าง container ใหม่ได้ทันที
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| container | เริ่มต้นเร็ว (มิลลิวินาที) ใช้ทรัพยากรน้อยกว่า VM มาก | แชร์ kernel กับ host จึงมีขอบเขตการแยกที่อ่อนกว่า |
| VM | แยกด้วย hypervisor แข็งแกร่งกว่ามาก รองรับ OS ต่างตระกูลได้ | ต้องมี guest OS เต็มรูปแบบ บูตช้า และใช้แรมเยอะ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- คิดว่า container ให้การแยกที่แข็งแกร่งเท่า VM ทั้งที่ container ยังแชร์ kernel เดียวกับ host
- พยายามรัน workload ที่ต้องใช้ OS ต่างตระกูล (เช่น Windows บน host Linux) ด้วย container ทั้งที่กรณีนี้ต้องใช้ VM
- สับสนระหว่าง image (blueprint แบบ static) กับ container (instance ที่กำลังทำงาน) เวลาวางแผน deployment
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”โมดูล cache สำหรับ Node service ที่ AI เขียนและอ้างว่า “ปรับตัวตาม container ที่รัน”:
🐛 หา bug ในโค้ดนี้
const os = require('os');// AI: "size the in-memory cache to 25% of RAM so it adapts to any environment"const budget = Math.floor(os.totalmem() * 0.25);console.log('budget MiB', Math.round(budget / 2 ** 20));const buf = Buffer.alloc(budget, 1); // touch every pageconsole.log('allocated', buf.length);เฉลย
container แชร์ kernel และ /proc/meminfo (ที่ os.totalmem() อ่าน) คือหน่วยความจำของ VM ไม่ใช่ limit ของ cgroup พิสูจน์ด้วย limit 64 MiB:
$ docker run --rm --memory 64m --cpus 0.5 node:24-alpine node -e "..."totalmem MiB 8930 # the Desktop VM, not the 64 MiB limitcpus 11 availableParallelism 1constrainedMemory MiB 64 # what the cgroup really allowsสคริปต์จึงตั้งงบ 2232 MiB ใน container 64 MiB และถูก kernel ฆ่า:
$ docker run --name docker-demo-oom --memory 64m --memory-swap 64m node:24-alpine node /app/cache.jsbudget MiB 2232$ echo $?137$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' docker-demo-oomOOMKilled=true exit=137ให้อ่าน limit ของ cgroup (Node เปิดเป็น process.constrainedMemory() ซึ่งคืนเลขมหาศาล ไม่ใช่ 0 เมื่อไม่มี limit จึงต้องเอาค่าต่ำสุด):
const os = require('os');// Respect the cgroup limit when there is oneconst limit = process.constrainedMemory() || os.totalmem();const budget = Math.floor(Math.min(limit, os.totalmem()) * 0.25);console.log('budget MiB', Math.round(budget / 2 ** 20));const buf = Buffer.alloc(budget, 1);console.log('allocated', buf.length);เมื่อใช้ --memory 64m จะพิมพ์ budget MiB 16 / allocated 16777216 และ exit 0 runtime อื่นมีการรับรู้ container ของตัวเอง (JVM อ่าน cgroup เป็นค่าเริ่มต้น) ให้ตรวจของคุณ อย่าเชื่อ /proc/meminfo หรือ nproc (nproc พิมพ์ 11 ใน container เดียวกัน)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”VM จำลอง ฮาร์ดแวร์: guest kernel เชื่อว่าตัวเองเป็นเจ้าของคอมพิวเตอร์เครื่องเล็ก container จำลอง มุมมองของ kernel ตัวเดียว: process ธรรมดาที่ถูกห่อด้วย namespace (มองเห็นอะไร) และ cgroup (ใช้ได้เท่าไร) อะไรก็ตามที่ kernel ไม่ได้จำลอง — /proc/meminfo, นาฬิกา, เวอร์ชัน kernel, bug ของ kernel — จะถูกแชร์และมองเห็นได้
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “container มี OS ของตัวเอง” มี userland ของตัวเอง (ไฟล์จาก image) แต่ไม่มี kernel
uname -rใน Alpine พิมพ์6.12.76-linuxkitของ Desktop VM - “บน Mac container รันบน macOS” มันรันใน Linux VM Darwin ไม่เห็นมันเลย จึงเป็นเหตุผลที่ IP ของ bridge และ path แบบ Linux ทำงานต่างจาก Linux server
- “
--memory 64mทำให้แอปเห็นแค่ 64 MB” มันแค่ทำให้ kernel ฆ่าแอปเมื่อเกิน 64 MB แอปยังอ่านได้ 8930 MiB จาก/proc/meminfoเว้นแต่ runtime จะอ่าน cgroup - “container เร็วกว่า VM เสมอ” container ตัด boot ของ guest kernel และความซ้ำซ้อน งานที่ใช้ CPU หนักรันเร็วเท่ากัน ข้อได้เปรียบคือเวลา start และความหนาแน่น