ข้ามไปยังเนื้อหา

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
สแต็กของ VM เทียบกับสแต็กของ container

ชั้น hypervisor และ guest OS หายไปในโมเดล container container หลายตัวบน host เดียวกันแชร์ kernel เดียว ทำให้ขนาดต่อ 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 itself
Darwin 25.6.0
$ docker run --rm alpine:3.24 uname -sr # inside a container
Linux 6.12.76-linuxkit
$ docker info --format '{{.KernelVersion}}' # the engine
6.12.76-linuxkit

container รายงาน 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:

scripts/namespaces.sh
#!/bin/sh
docker run -d --name docker-demo-a alpine:3.24 sleep 300
docker run -d --name docker-demo-b alpine:3.24 sleep 300
for 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'
done
docker rm -f docker-demo-a docker-demo-b
# container a # container b
pid:[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 ps

namespace 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'
67108864
50000 100000

67108864 คือ 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 % PIDS
docker-b1-a 548KiB / 64MiB 0.84% 1
docker-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)

  • Microservices — แต่ละบริการส่งมอบเป็น image ของตัวเอง ปรับขนาดได้อิสระ และรีสตาร์ทโดยไม่กระทบ sibling อื่น
  • CI/CD pipelines — container แบบชั่วคราวให้ทุก build มี environment ที่สะอาดและทำซ้ำได้ ซึ่งจะหายไปเมื่อเสร็จสิ้น
  • environment นักพัฒนาที่สอดคล้องกัน — image เดียวกันทำงานบนแล็ปท็อปของนักพัฒนา ใน CI และในโปรดักชัน กำจัดบั๊ก “works on my machine”
  • การขยายขนาดแนวนอนที่รวดเร็ว — container instance ใหม่เริ่มต้นในมิลลิวินาที ทำให้ auto-scaling ตอบสนองได้ดี
  • การแยกความปลอดภัยที่แข็งแกร่ง — ทุก 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 คือ 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
layer ของ image เทียบกับ layer ของ container

การลบ container จะลบเฉพาะ writable layer ของตัวเอง image พื้นฐานยังคงอยู่และสามารถนำมาใช้สร้าง container ใหม่ได้ทันที

ตัวเลือกBenefitCost
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

โมดูล cache สำหรับ Node service ที่ AI เขียนและอ้างว่า “ปรับตัวตาม container ที่รัน”:

🐛 หา bug ในโค้ดนี้

cache.js
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 page
console.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 limit
cpus 11 availableParallelism 1
constrainedMemory 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.js
budget MiB 2232
$ echo $?
137
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' docker-demo-oom
OOMKilled=true exit=137

ให้อ่าน limit ของ cgroup (Node เปิดเป็น process.constrainedMemory() ซึ่งคืนเลขมหาศาล ไม่ใช่ 0 เมื่อไม่มี limit จึงต้องเอาค่าต่ำสุด):

cache.js
const os = require('os');
// Respect the cgroup limit when there is one
const 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 เดียวกัน)

VM จำลอง ฮาร์ดแวร์: guest kernel เชื่อว่าตัวเองเป็นเจ้าของคอมพิวเตอร์เครื่องเล็ก container จำลอง มุมมองของ kernel ตัวเดียว: process ธรรมดาที่ถูกห่อด้วย namespace (มองเห็นอะไร) และ cgroup (ใช้ได้เท่าไร) อะไรก็ตามที่ kernel ไม่ได้จำลอง — /proc/meminfo, นาฬิกา, เวอร์ชัน kernel, bug ของ kernel — จะถูกแชร์และมองเห็นได้

  • “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 และความหนาแน่น
container แชร์อะไรกับ host ที่ VM ไม่แชร์?
ข้อใดอธิบายได้ดีที่สุดว่าทำไม container เริ่มต้นเร็วกว่า VM?
คุณต้องการรัน Windows workload บน host Linux ควรใช้อะไร?
ความสัมพันธ์ระหว่าง Docker image กับ Docker container คืออะไร?
คุณ start container ด้วย --memory 64m แล้ว cat /sys/fs/cgroup/memory.max ข้างในจะพิมพ์อะไร?
Node service ตั้งขนาด cache จาก os.totalmem() * 0.25 และรันด้วย --memory 64m จะเกิดอะไร?