Container the Hard Way: Mổ xẻ container từ namespace tới runtime
May 22, 2026

Container không phải là magic — chỉ là Linux thôi
Lần đầu chạy docker run -it ubuntu bash, bạn thấy mình đang trong một shell của Ubuntu, với hệ thống file riêng, network riêng, PID riêng — tất cả khởi động trong dưới một giây. Cảm giác như một phép màu: máy ảo nhẹ tới mức không tin được. Nhưng nếu hỏi "container thực ra là gì?", câu trả lời chuẩn lại làm nhiều người ngạc nhiên: container không phải là một thực thể. Nó là một process Linux bình thường — chỉ là kernel đã được yêu cầu nhìn nó qua một số "kính lọc" đặc biệt.
Container không có file /proc/containers. Không có syscall create_container(). Khi bạn docker ps, Docker chỉ đang nhìn vào danh sách process mà nó tự lưu trong database của mình. Bản thân kernel không biết "container" là gì — nó chỉ biết về namespace, cgroup, mount point, capability, seccomp filter — và khi bạn kết hợp đủ những thứ đó lại quanh một process, kết quả gọi là container.
Đây là điều quan trọng nhất bạn cần ghi nhớ trước khi đọc tiếp:
Container: Một (hoặc nhiều) process Linux được cô lập bằng tổ hợp các tính năng của kernel — chủ yếu là namespace (cô lập view) và cgroup (giới hạn resource), kết hợp với một filesystem riêng và các lớp bảo mật phụ trợ. Không có syscall "container", chỉ có sự lắp ráp khéo léo của các nguyên thuỷ có sẵn từ trước.
Nói cách khác, container là một abstraction do userspace tự định nghĩa, được lắp ráp từ các thành phần kernel rời rạc. Docker, containerd, runc, Podman — họ chỉ là những người lắp ráp khác nhau, dùng cùng một bộ Lego.
Tại sao "the hard way"?
Bài này không phải để bạn quên mất Docker. Mục đích là lột từng lớp ra cho tới khi không còn gì để lột, để khi sau này gặp một câu hỏi như "tại sao container bị OOMKill?", "tại sao chạy ps trong container chỉ thấy 1 process?", "tại sao Kata Container nói là more secure than runc?" — bạn không phải đoán, mà có thể chỉ thẳng vào layer chịu trách nhiệm.
Lộ trình bài:
What we cover:
[Foundations]
1. VM vs Container — hai paradigm cô lập khác nhau
[The 5 pillars of container isolation]
2. Namespaces — cô lập view (8 loại)
3. Cgroups — cô lập tài nguyên (v1 vs v2)
4. Filesystem — chroot, pivot_root, OverlayFS, OCI image
5. Security — capabilities, seccomp, AppArmor/SELinux
6. Networking — veth, bridge, CNI
[The stack above the kernel]
7. Runtime stack — runc, containerd, CRI, shim
[Hands-on]
8. Build a container from scratch — chỉ unshare + cgroup + chroot
[Beyond runc]
9. Alternative isolation — gVisor, Kata, Firecracker, WASM
[Wrap-up]
10. Khi nào chọn cái gì
Nếu bạn đã quen Docker và muốn nhảy thẳng vào phần "nội tạng", có thể bỏ qua section 2 và đi thẳng tới Namespaces — đó là lớp nền móng của toàn bộ phần còn lại. Còn nếu bạn muốn thật sự cảm được container, hãy đọc tới section 9 và làm theo: 30 dòng shell, không Docker, một container chạy được. Sau đó bạn sẽ không bao giờ nhìn docker run như trước nữa.
Một lưu ý nhỏ về lịch sử
Container không phải Docker phát minh. Năm 2000, FreeBSD đã có jail(8). Năm 2004, Solaris có zones. Năm 2006, Google nội bộ đã chạy cgroups (lúc đó tên là process containers) để chia tài nguyên cho các service trong Borg. Linux namespace bắt đầu xuất hiện từ kernel 2.4.19 (mount namespace, năm 2002) và hoàn thiện dần qua hơn một thập kỷ.
Docker (2013) không phát minh container — nó đóng gói trải nghiệm: image format, registry, một CLI dễ dùng, một daemon đứng giữa. Phần khó của công nghệ đã có sẵn trong kernel. Phần khó của sản phẩm là làm cho nó tiếp cận được với nửa tỷ developer. Đây là chi tiết quan trọng — vì khi đào sâu, bạn sẽ thấy hầu hết "phép màu của Docker" đều là gói lại của những thứ Linux đã làm được từ 2002.
Giờ, hãy bắt đầu từ câu hỏi cơ bản nhất: container khác máy ảo ở chỗ nào, và tại sao điều đó lại quan trọng?
VM vs Container — hai cách "ảo hoá" khác nhau về bản chất
Để hiểu container, phải hiểu nó không phải là gì trước đã. Người ta hay nói "container là VM nhẹ", nhưng đó là phép so sánh nguy hiểm — vì nó che mất một sự thật quan trọng: VM và container ảo hoá ở hai tầng hoàn toàn khác nhau, dẫn đến hai mô hình bảo mật và performance khác nhau.
Virtual Machine — ảo hoá phần cứng
Một máy ảo (VM) ảo hoá ở tầng hardware. Một hypervisor (KVM, VMware ESXi, Hyper-V, Xen) giả lập một bộ CPU, RAM, disk, NIC ảo — và mỗi VM cài một kernel riêng, một hệ điều hành đầy đủ trên đó. Hai VM trên cùng host hoàn toàn không biết tới nhau ở tầng OS.
Hypervisor: Phần mềm tạo và quản lý máy ảo, đứng giữa phần cứng vật lý và các OS khách. Type-1 (bare-metal: ESXi, Xen, KVM) chạy trực tiếp trên phần cứng; Type-2 (hosted: VirtualBox, VMware Workstation) chạy như một ứng dụng trên một OS chủ.
Guest OS: Hệ điều hành chạy bên trong máy ảo. Mỗi VM có kernel riêng — một host Linux có thể chạy đồng thời các guest Windows, Linux, FreeBSD mà không xung đột.
Container — ảo hoá hệ điều hành (OS-level)
Container ảo hoá ở tầng OS. Tất cả container trên cùng host đều chia sẻ chung một kernel — kernel của host. Cái gọi là "ảo hoá" thật ra chỉ là kernel tự nó trình bày một view khác cho process: khi container hỏi "có những process nào đang chạy?", kernel chỉ trả về các process trong PID namespace của nó.
Không có CPU ảo. Không có RAM ảo. Không có disk ảo. Process trong container chạy trực tiếp trên CPU của host, gọi syscall trực tiếp vào kernel của host. Kernel chỉ làm hai việc thêm: (1) lọc view (namespace) và (2) áp hạn mức (cgroup).
VM model:
+-----------+ +-----------+ +-----------+
| App A | | App B | | App C |
| libs | | libs | | libs |
| Guest OS | | Guest OS | | Guest OS |
| + kernel | | + kernel | | + kernel |
+-----------+ +-----------+ +-----------+
| | |
v v v
+---------------------------------------+
| Hypervisor (KVM/ESXi) |
+---------------------------------------+
| Host hardware |
+---------------------------------------+
Container model:
+-----------+ +-----------+ +-----------+
| App A | | App B | | App C |
| libs | | libs | | libs |
+-----------+ +-----------+ +-----------+
| | |
v v v
+---------------------------------------+
| ONE shared Linux kernel |
| (namespaces + cgroups + LSM filter) |
+---------------------------------------+
| Host hardware |
+---------------------------------------+
Chú ý sự khác biệt then chốt: phía VM, mỗi guest có một kernel riêng. Phía container, chỉ có duy nhất một kernel — của host. Tất cả container đều "ngồi chung" trên kernel đó.
So sánh trực tiếp
| Tiêu chí | Virtual Machine | Container |
|---|---|---|
| Tầng ảo hoá | Phần cứng (hypervisor) | OS / kernel features |
| Kernel | Mỗi VM có 1 kernel riêng | Tất cả container dùng chung kernel host |
| Khởi động | Giây tới phút (boot OS) | Mili-giây tới giây |
| Image size | GB (chứa cả OS) | MB (chỉ app + libs) |
| Overhead | Cao (RAM, CPU cho mỗi guest OS) | Gần như zero so với process thường |
| Isolation strength | Mạnh — vi phạm phải xuyên qua hypervisor | Yếu hơn — chung kernel, vi phạm syscall = thoát container |
| Multi-OS | Có thể chạy Windows trên host Linux | Container Linux chỉ chạy trên kernel Linux (Windows có Windows container riêng) |
| Use case điển hình | Multi-tenant cloud, legacy app, OS khác | Microservice, CI/CD, dev environment, scale-out |
Tại sao container ra đời?
Nếu VM mạnh hơn về isolation và đã có sẵn từ những năm 2000, tại sao container vẫn xuất hiện và chiếm lĩnh? Vì trade-off của VM quá đắt cho workload mới:
- Tốc độ deploy. Microservice cần scale lên xuống trong vài giây. Boot một VM mất 30-60 giây — đủ lâu để mất user khi traffic spike. Container start trong dưới 1 giây.
- Mật độ. Một server có thể chạy 5-10 VM cùng lúc trước khi RAM cạn (mỗi guest OS tự ngốn 256MB-1GB). Cùng server đó chạy được hàng trăm container.
- Image portability. VM image hàng chục GB, copy giữa môi trường khó. Container image vài chục MB, push lên registry, pull về ở đâu cũng được — và nhờ layered filesystem (sẽ nói ở section 5), chỉ tải về phần chênh lệch.
- Dev/prod parity. "Works on my machine" giảm hẳn khi cả dev và prod chạy cùng một container image.
Đổi lại, container yếu hơn về isolation. Một kernel exploit (CVE-2022-0185 trong filesystem, hay CVE-2022-0492 trong cgroup release_agent) có thể cho phép process bên trong container thoát ra root của host. VM thì không — bạn phải vượt qua cả guest kernel và hypervisor.
Hybrid: container nhưng có VM bao quanh
Vì trade-off bảo mật ấy, có một dòng giải pháp lai: micro-VM hoặc sandboxed container. Container vẫn được orchestrate bằng Kubernetes/Docker, nhưng dưới đáy là một micro-VM (Firecracker, Cloud Hypervisor) hoặc một userspace kernel (gVisor). Section 10 sẽ đào sâu phần này.
Hiểu xong nền tảng — container là process + namespace + cgroup. Phần còn lại của bài chia ra từng thành phần, mổ xẻ. Bắt đầu từ "trái tim" của isolation: namespaces.
Namespaces — kernel "đeo kính" cho process
Nếu phải chọn duy nhất một tính năng kernel để giải thích container, đó là namespace. Cgroup giới hạn bao nhiêu tài nguyên process được dùng, nhưng namespace mới là cái quyết định process thấy thế giới như thế nào. Hai khái niệm khác hẳn nhau, và nhiều người gộp chung — đó là nguồn gốc của vô số hiểu lầm.
Namespace (Linux namespace): Một tính năng kernel cho phép các process khác nhau thấy các "view" khác nhau của cùng một tài nguyên hệ thống. Mỗi loại namespace cô lập một loại tài nguyên (PID, network, mount, hostname, ...). Hai process trong hai namespace khác nhau có thể có PID giống hệt nhau mà không xung đột, có cùng một số IP mà không loạn — vì kernel duy trì riêng các bảng cho từng namespace.
Hình dung đơn giản: namespace là một cặp kính mà kernel đeo cho process. Hai process đeo hai cặp kính khác nhau sẽ thấy hai thế giới khác nhau, dù vẫn đang chạy trên cùng một kernel, cùng một phần cứng.
Tám loại namespace hiện có
Tính tới kernel hiện đại (6.x), Linux có 8 loại namespace, mỗi loại cô lập một mảng cụ thể:
| Namespace | Cờ syscall | Cô lập cái gì |
|---|---|---|
Mount (mnt) | CLONE_NEWNS | Cây mount, file system view |
| UTS | CLONE_NEWUTS | Hostname, domain name |
| IPC | CLONE_NEWIPC | System V IPC, POSIX message queues |
| PID | CLONE_NEWPID | Cây tiến trình (PID space) |
| Network | CLONE_NEWNET | Interface, route, iptables, socket |
| User | CLONE_NEWUSER | UID/GID mapping |
| Cgroup | CLONE_NEWCGROUP | View của cgroup hierarchy |
| Time | CLONE_NEWTIME | CLOCK_MONOTONIC và CLOCK_BOOTTIME |
Một container "đầy đủ" thường tạo cả 7 cái đầu cùng lúc (Time namespace tương đối mới, kernel 5.6+, chưa phổ biến). Đó là lý do unshare(2) cho phép truyền nhiều cờ trong một lần gọi.
Mount namespace — "tôi có hệ thống file riêng"
Mount namespace là namespace ra đời sớm nhất (kernel 2.4.19, 2002). Mỗi mount namespace có cây mount riêng: bạn có thể mount một filesystem ở /data trong namespace A, mà namespace B không nhìn thấy.
Đây là nền tảng để container có "rootfs" riêng. Container sẽ:
- Tạo mount namespace mới (clone với
CLONE_NEWNS). - Mount rootfs của image (thường qua OverlayFS — sẽ nói ở section 5) lên một thư mục tạm.
- Dùng
pivot_root(2)để đảo cái thư mục tạm đó thành "/" của process.
Sau bước 3, process trong container không có cách nào "trèo" về hệ thống file của host nữa — vì cây mount mà nó nhìn thấy đã bị cô lập.
PID namespace — "tôi là PID 1"
PID namespace là cái khiến ps trong container trả về danh sách ngắn ngủn. Bên trong PID namespace mới, process đầu tiên được kernel đánh số là PID 1 (init), và các process con của nó là 2, 3, 4...
Host kernel view: Container view:
PID COMMAND PID COMMAND
1 systemd 1 nginx <- master
2 kthreadd 7 nginx <- worker
... 8 nginx <- worker
1234 dockerd
5678 containerd-shim
5679 nginx (master) <-- same process,
5685 nginx (worker) different PID in
5686 nginx (worker) container ns
Cùng một process — đó là nginx (master). Host kernel thấy nó là PID 5679, container thấy nó là PID 1. Không có gì kỳ diệu — bảng task_struct trong kernel có nhiều entry cho cùng một process, một entry ứng với mỗi PID namespace mà nó "thấy được" (host namespace + container namespace).
PID 1 có vai trò đặc biệt: nó là init của container. Nếu PID 1 chết, kernel sẽ kill toàn bộ process trong PID namespace đó — đây là cơ chế cleanup. Đó cũng là lý do best practice nói "dùng một init thật sự (tini, dumb-init) thay vì chạy bash làm PID 1" — vì bash không reap zombie process tốt và không xử lý signal đúng kiểu.
Network namespace — "tôi có stack mạng riêng"
Network namespace cô lập toàn bộ network stack: interface, routing table, iptables, ARP table, socket. Mỗi netns có một interface lo riêng, không liên quan tới lo của host.
Khi bạn docker run, container có IP của riêng nó (kiểu 172.17.0.2) — IP đó không tồn tại trên host. Cách hoạt động: Docker tạo một netns mới, đặt một đầu của veth pair vào netns đó rồi rename thành eth0 (chính là interface mà container thấy), đầu kia ở netns host (interface vethXXXX) được attach vào bridge docker0. (Phần này sẽ chi tiết hơn ở section 8.)
veth pair (Virtual Ethernet pair): Một cặp interface ảo nối liền nhau — gói tin gửi vào đầu này hiện ra ở đầu kia. Đây là "ống dây" cho phép một netns nói chuyện với netns khác (thường là netns container nói với netns host).
UTS namespace — hostname riêng
UTS (UNIX Time-Sharing) namespace cô lập hostname và domain name. Đây là cái khiến bạn hostname trong container thấy a1b2c3d4 (hash của container ID) thay vì hostname host. Đơn giản, ít drama.
IPC namespace — IPC riêng
System V IPC (semaphore, shared memory, message queue) và POSIX message queue được cô lập. Hai container không thể chia sẻ shared memory với nhau qua System V IPC trừ khi cùng IPC namespace. Đa số dev không bao giờ chạm tới namespace này — nhưng nó quan trọng cho các app legacy dùng IPC kiểu cũ (Oracle DB, PostgreSQL trước phiên bản dùng POSIX shm).
User namespace — "UID 0 trong container, UID 100000 ngoài host"
User namespace là namespace phức tạp và quyền lực nhất. Nó cho phép UID/GID mapping: process bên trong namespace thấy mình là UID 0 (root), nhưng từ host nhìn vào, nó thực sự là một UID không có đặc quyền (ví dụ 100000).
Container view: Mapping: Host view:
UID 0 (root) <--> 100000 UID 100000 (unprivileged)
UID 1 <--> 100001 UID 100001
... <--> ... ...
UID 65535 <--> 165535 UID 165535
Đây là lá chắn bảo mật quan trọng nhất của container hiện đại. Nếu container "tin tưởng" rằng nó là root, nó có thể tạo file owner=root, chạy syscall yêu cầu quyền cao — nhưng do mapping, mọi thao tác đó ở host chỉ thực hiện với quyền của UID 100000, không gây hại gì.
Đây là lý do rootless container (Podman, rootless Docker) tồn tại được. Không có user namespace, bạn không thể chạy container mà không có quyền root trên host. Có user namespace, một user thường có thể tạo container với "root" giả lập bên trong.
Trade-off: user namespace gây phức tạp khi mount filesystem (UID không khớp), khi share volume với host (file owner sai), và đôi khi mâu thuẫn với một số tool (ví dụ Kubernetes mặc định không bật user namespace cho pod).
Cgroup namespace — view của cgroup hierarchy
Đây là namespace tinh tế. Process có thể đọc /proc/self/cgroup để biết nó đang ở cgroup nào. Cgroup namespace virtualize path đó: bên trong namespace, container không thấy path đầy đủ trên host (ví dụ /sys/fs/cgroup/system.slice/docker-xxxx.scope/...), mà chỉ thấy / của cgroup tree con của riêng nó.
Đây là tính năng quality-of-life — chống information leak (container không biết tên cgroup của nó trên host) và giúp các tool đo metric bên trong container không bị nhầm lẫn.
Time namespace — đồng hồ riêng (một phần)
Time namespace (kernel 5.6, 2020) cho phép cô lập CLOCK_MONOTONIC và CLOCK_BOOTTIME — tức là offset của hai đồng hồ này có thể khác nhau giữa các namespace. Nó không virtualize CLOCK_REALTIME (giờ thực tế) — vì điều đó sẽ tạo ra nhiều rắc rối hơn lợi ích.
Mục đích chính: cho phép checkpoint/restore (CRIU) một container ở host A và restore ở host B mà uptime bên trong container vẫn nhất quán. Ít người dùng đến, nhưng tốt cần biết.
Tạo namespace như thế nào
Có ba syscall liên quan:
clone(2)— tạo process mới đồng thời tạo namespace mới (truyền cờCLONE_NEW*).unshare(2)— tách process hiện tại ra namespace mới.setns(2)— gắn process vào namespace có sẵn (thường đểnsentervào container đang chạy).
Một ví dụ thực tế với command-line unshare:
# Tạo một shell trong PID + mount + network + UTS namespace mới
sudo unshare --pid --mount --net --uts --fork --mount-proc bash
# Bên trong shell mới:
hostname container1
ps aux # chỉ thấy bash và ps
ip a # chỉ thấy lo (chưa có interface khác)
Đây là Docker version 0.0.1 — không layered image, không security, không networking, nhưng đã có "container". Section 9 sẽ mở rộng cái này thành demo đầy đủ.
Namespace nhìn trong /proc
Mỗi process có một thư mục /proc/<pid>/ns/ chứa các symlink tới namespace mà nó thuộc về:
$ ls -l /proc/self/ns/
cgroup -> 'cgroup:[4026531835]'
ipc -> 'ipc:[4026531839]'
mnt -> 'mnt:[4026531840]'
net -> 'net:[4026531969]'
pid -> 'pid:[4026531836]'
user -> 'user:[4026531837]'
uts -> 'uts:[4026531838]'
Số trong ngoặc là inode của namespace. Hai process có cùng inode = cùng namespace. Đây là cách hữu hiệu nhất để check: "process X và Y có chung network namespace không?" — chỉ cần so sánh /proc/X/ns/net và /proc/Y/ns/net.
Hiểu xong namespace, bạn đã hiểu một nửa container. Nửa còn lại là cgroup — câu chuyện về việc giới hạn process được dùng bao nhiêu tài nguyên.
Cgroups — giới hạn process được "ăn" bao nhiêu
Namespace cho process một thế giới riêng để nhìn. Nhưng nếu không có gì giới hạn, process trong container vẫn có thể ngốn hết RAM của host, kéo CPU lên 100%, làm DoS các container khác. Đó là vai trò của cgroups (control groups): giới hạn và đo đếm tài nguyên.
cgroup (control group): Tính năng kernel cho phép gom một hoặc nhiều process vào một nhóm và áp dụng giới hạn/quota tài nguyên cho nhóm đó. Tài nguyên gồm CPU, memory, block I/O, network bandwidth, PID count, hugetlb, perf_event, ... Cgroup có hierarchy (cây) — child kế thừa hạn mức từ parent.
Cgroup do Google đề xuất năm 2006 (tên gốc process containers), merge vào kernel mainline 2.6.24 (2008). Nó là nửa còn lại của thiết kế container.
cgroup v1 vs v2 — đáng để phân biệt
Có hai phiên bản cgroup tồn tại song song. Hiểu được sự khác biệt sẽ tiết kiệm rất nhiều thời gian gỡ rối:
| Tiêu chí | cgroup v1 | cgroup v2 |
|---|---|---|
| Ra mắt | 2008 | 2016 (kernel 4.5) |
| Hierarchy | Nhiều cây song song, mỗi controller có cây riêng | Một cây thống nhất |
| Mount point | /sys/fs/cgroup/<controller>/... | /sys/fs/cgroup/... |
| Memory + swap | Hai controller riêng | Gộp vào memory controller |
| Trạng thái | Vẫn còn nhưng deprecated | Mặc định trên distro mới (Fedora 31+, Ubuntu 22.04+, RHEL 9+) |
| Tool support | Đầy đủ | Một số tool cũ chưa support |
Sự khác biệt lớn nhất: v1 cho phép một process nằm trong cgroup cpu này nhưng cgroup memory khác — dẫn tới hierarchy phức tạp và bug. v2 nói "một process nằm trong một cgroup duy nhất, và cgroup đó controlle tất cả các resource cùng lúc." Đơn giản hơn nhiều, nhưng phải migrate.
cgroup v1 (multiple hierarchies):
/sys/fs/cgroup/cpu/ <- one tree
+-- docker/
+-- abc123/
+-- (tasks)
/sys/fs/cgroup/memory/ <- another tree, SAME process can be elsewhere
+-- system.slice/
+-- (tasks)
cgroup v2 (one unified tree):
/sys/fs/cgroup/
+-- system.slice/
| +-- (tasks here)
+-- user.slice/
+-- user-1000.slice/
+-- session-1.scope/
+-- (tasks here, all controllers apply)
Phần còn lại của section này dùng v2 làm chuẩn (vì là default trên distro hiện đại).
Các controller phổ biến
Dưới đây là các controller bạn sẽ chạm tới nhiều nhất trong container:
CPU controller
Giới hạn CPU theo 2 cách:
- cpu.max (v2) — quota / period. Ví dụ
50000 100000= "trong mỗi 100ms, được dùng tối đa 50ms CPU time" = nửa CPU. - cpu.weight — share, từ 1 đến 10000. Khi có cạnh tranh, weight cao được ưu tiên hơn.
# Giới hạn cgroup chỉ dùng 0.5 CPU
echo "50000 100000" > /sys/fs/cgroup/mygroup/cpu.max
# Trong Docker, tương đương:
docker run --cpus=0.5 ubuntu
Đây cũng là nguồn của một bẫy nổi tiếng trong Java/Go: trước đây JVM đọc Runtime.getRuntime().availableProcessors() từ host CPU count, không biết về cgroup limit, dẫn tới việc tạo quá nhiều thread. Java 10+ đã fix (-XX:+UseContainerSupport mặc định). Go đã từng có vấn đề tương tự với GOMAXPROCS (phải set thủ công hoặc dùng uber-go/automaxprocs).
Memory controller
- memory.max — hard limit. Vượt qua = process bị OOMKill.
- memory.high — soft limit. Vượt qua = kernel throttle process, ép swap/reclaim.
- memory.low — best-effort guarantee. Kernel cố không reclaim memory dưới mức này.
echo "536870912" > /sys/fs/cgroup/mygroup/memory.max # 512 MB hard limit
Khi container hit memory limit, kernel kích hoạt OOM killer chỉ trong cgroup đó — không ảnh hưởng host. Đây là cơ chế đằng sau status OOMKilled của Kubernetes/Docker.
OOM killer (Out-Of-Memory killer): Cơ chế kernel chọn và kill process khi hệ thống (hoặc cgroup) hết memory. Việc kill dựa trên score được tính từ kích thước memory, nice value, và một số heuristic khác. Trong cgroup v2, OOM killer được áp dụng trong phạm vi cgroup, nên host vẫn an toàn.
IO controller
Giới hạn băng thông đĩa per-block-device:
# Limit 10 MB/s read, 5 MB/s write on device 8:0 (sda)
echo "8:0 rbps=10485760 wbps=5242880" > /sys/fs/cgroup/mygroup/io.max
Tương đương Docker --device-read-bps và --device-write-bps.
PIDs controller
Giới hạn số PID trong cgroup. Phòng fork bomb.
echo "100" > /sys/fs/cgroup/mygroup/pids.max
Các controller khác
cpuset— pin process vào tập CPU/NUMA node cụ thể.hugetlb— giới hạn HugePage usage.rdma,misc— cho phần cứng đặc biệt.
Tạo và dùng cgroup thủ công
Với cgroup v2, tạo một cgroup đơn giản đến mức ngạc nhiên — chỉ là mkdir:
# 1. Tạo cgroup
sudo mkdir /sys/fs/cgroup/mygroup
# 2. Bật controller (subtree_control của parent)
echo "+cpu +memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# 3. Đặt limit
echo "50000 100000" | sudo tee /sys/fs/cgroup/mygroup/cpu.max
echo "536870912" | sudo tee /sys/fs/cgroup/mygroup/memory.max
# 4. Thêm process vào cgroup
echo $$ | sudo tee /sys/fs/cgroup/mygroup/cgroup.procs
# Giờ shell hiện tại bị giới hạn 0.5 CPU + 512MB
Trong v1 sẽ phải làm với từng controller hierarchy riêng, nhưng tư tưởng tương tự.
Cách Docker dùng cgroup
Docker không tự phát minh ra cgroup tree — nó delegate qua systemd hoặc tự quản lý. Trên distro hiện đại với cgroup v2 + systemd:
/sys/fs/cgroup/
+-- system.slice/
+-- docker.service/
| (cgroup của docker daemon)
+-- docker-<container_id>.scope/
+-- cgroup.procs <- chứa PID của container
+-- cpu.max
+-- memory.max
+-- ...
Khi bạn docker run --memory 256m --cpus 1.5 nginx, Docker chỉ làm hai việc:
- Tạo container (clone + namespace).
- Viết các giá trị vào
cpu.max,memory.maxcủa cgroup tương ứng.
Đó là tất cả. Phần nặng đã được kernel cgroup nguyên thuỷ làm sẵn.
Bẫy phổ biến: container thấy memory host
Câu hỏi phỏng vấn cổ điển: Tại sao chạy free trong container thấy memory của host chứ không phải limit?
Vì free đọc /proc/meminfo, mà /proc/meminfo không virtualize theo cgroup. Cgroup giới hạn được nhưng không che được. Hậu quả:
- App đọc
/proc/meminfođể quyết định cache size sẽ nhầm. - JVM trước Java 10 đọc về và set heap quá lớn.
- Tool như
top,htophiển thị memory host, không phải container.
Giải pháp:
- App tự đọc
/sys/fs/cgroup/memory.max(nếu chạy trong container). - Hoặc mount
lxcfsđể fake/proc/meminfotheo cgroup limit (như LXC làm). - Hoặc dùng các thư viện như
uber-go/automaxprocscho Go,-XX:+UseContainerSupportcho Java.
Đây là một ví dụ điển hình về leaky abstraction: container không phải là VM, nên nó không thể hoàn toàn ảo hoá mọi thứ. Phải hiểu giới hạn này để code đúng.
Xong namespace + cgroup, đã có "kernel skeleton" của container. Giờ cần một filesystem riêng để chạy app — section tiếp theo.
Filesystem & Image — từ chroot tới OverlayFS
Container có namespace và cgroup rồi, nhưng còn một câu hỏi: cây thư mục của container đến từ đâu? Khi bạn docker run ubuntu bash, bên trong bạn thấy /bin, /etc, /usr, đầy đủ Ubuntu — dù host có thể là Arch Linux hay Amazon Linux. Cách image này lắp ráp lại trên đĩa và xuất hiện trong container là một câu chuyện thú vị, đi từ chroot của 1979 tới OverlayFS hiện đại.
chroot — tổ tiên của container filesystem
chroot: Syscall và lệnh giúp thay đổi root directory của một process — process sau khi chroot sẽ thấy thư mục đó như là "/" của mình, không truy cập được lên trên. Có từ kernel Unix V7 (1979).
mkdir -p /tmp/jail/{bin,lib,lib64}
cp /bin/bash /tmp/jail/bin/
# copy các so deps...
sudo chroot /tmp/jail /bin/bash
# Giờ bạn ở trong "container" — / chính là /tmp/jail
Chroot không phải là security boundary. Một process có quyền CAP_SYS_CHROOT có thể chroot() ra ngoài bằng kỹ thuật cũ. Nó cũng không cô lập PID, network, ... Nhưng ý tưởng "thay root directory" là viên gạch đầu tiên của container.
Container hiện đại không dùng chroot() mà dùng pivot_root(2) — an toàn hơn, vì nó di chuyển root cũ ra ngoài và cho phép umount sau đó, ngăn process thoát qua các tham chiếu cũ.
Vấn đề: image trùng lặp
Giả sử bạn có 50 container đều dựa trên Ubuntu base image (~70MB). Nếu mỗi container có một bản copy đầy đủ, sẽ tốn 3.5GB chỉ cho base. Cộng với image của mỗi app, chục GB là chuyện thường.
Giải pháp: layered filesystem + union mount.
Union filesystem (union mount): Một filesystem ảo gộp (overlay) nhiều thư mục lại thành một view duy nhất. Khi đọc, nó tìm tuần tự từ layer trên xuống. Khi ghi, nó ghi vào layer trên cùng (writable layer). Các layer dưới là read-only và shared giữa các container.
Linux có nhiều union filesystem qua các năm: AUFS (Docker đời đầu), btrfs subvolume, ZFS, OverlayFS (chuẩn hiện nay, kernel 3.18+).
OverlayFS — cách hoạt động
OverlayFS có 4 thư mục:
- lowerdir — read-only, có thể có nhiều (nhiều layer chồng lên nhau).
- upperdir — writable, mọi thay đổi được ghi vào đây.
- workdir — thư mục tạm cho OverlayFS làm việc nội bộ.
- merged — view kết hợp mà process thấy.
+-------------------------+
| merged (what app sees) |
+-------------------------+
^
| overlay
+-------------------------+
| upperdir (writable) |
+-------------------------+
| lowerdir layer N |
+-------------------------+
| lowerdir layer N-1 |
+-------------------------+
| ... |
+-------------------------+
| lowerdir layer 0 |
+-------------------------+
- merged: view mà process trong container nhìn thấy như là "/".
- upperdir: layer ghi của container, chứa mọi thay đổi.
- lowerdir layer N…0: các layer read-only, share giữa nhiều container. Layer 0 thường là base image (ví dụ Ubuntu rootfs).
Khi container ghi file:
- File chưa tồn tại → tạo trong upperdir.
- File tồn tại trong lowerdir, container muốn sửa → copy-up: kernel copy file từ lowerdir lên upperdir trước, rồi mới ghi.
- Xoá file ở lowerdir → tạo một "whiteout" trong upperdir (file đặc biệt báo "file này đã bị xoá").
Lệnh mount thực tế:
mount -t overlay overlay \
-o lowerdir=/base/ubuntu:/base/python:/base/app,upperdir=/data/container1/upper,workdir=/data/container1/work \
/data/container1/merged
Khi container dừng, có thể xoá upperdir mà không ảnh hưởng tới các container khác đang dùng cùng lowerdir.
OCI Image — chuẩn hoá định dạng image
Trước đây mỗi runtime có format image riêng. Năm 2015, OCI (Open Container Initiative) ra đời để chuẩn hoá. OCI Image Specification định nghĩa image là gì.
OCI Image: Một tarball có cấu trúc gồm: (1) manifest JSON liệt kê các layer, (2) config JSON chứa metadata (entrypoint, env, cmd), (3) các layer là tar archive nén gzip. Mỗi layer có một content-addressable ID — là SHA-256 của nội dung tar.
Cấu trúc của một image khi bạn docker pull ubuntu:
ubuntu:22.04
+-- manifest.json
| {
| "schemaVersion": 2,
| "config": "sha256:abc...",
| "layers": [
| {"digest": "sha256:1a2b...", "size": 28000000},
| {"digest": "sha256:3c4d...", "size": 1200000},
| ...
| ]
| }
|
+-- config: sha256:abc...
| {
| "architecture": "amd64",
| "os": "linux",
| "config": {
| "Cmd": ["/bin/bash"],
| "Env": ["PATH=/usr/local/sbin:..."],
| },
| "rootfs": {
| "type": "layers",
| "diff_ids": ["sha256:...", "sha256:..."]
| }
| }
|
+-- layers/
+-- sha256:1a2b... .tar.gz (base rootfs)
+-- sha256:3c4d... .tar.gz (additional layer)
Vì layer được nội dung-định-danh (SHA-256), hai image dùng chung base layer chỉ tải về một bản — registry pull về theo digest, không phải theo path. Đây là lý do docker pull lần thứ hai một image base nhanh hơn hẳn lần đầu.
Dockerfile và cách layer được tạo
Mỗi instruction (gần như) trong Dockerfile tạo một layer mới:
FROM ubuntu:22.04 # layer 0: base
RUN apt-get update # layer 1: apt cache + ...
RUN apt-get install -y nginx # layer 2: nginx binary
COPY index.html /var/www/ # layer 3: copy file
CMD ["nginx", "-g", "daemon off;"] # config, không layer
Mỗi RUN chạy trong một container tạm, sau đó OverlayFS upperdir của container đó được commit thành tar và trở thành layer mới. Đó là lý do best practice nói: gộp nhiều RUN lại bằng && để giảm số layer, hoặc clean cache trong cùng layer (apt-get clean && rm -rf /var/lib/apt/lists/*).
Registry — nơi image sống
Container registry: Server lưu trữ và phân phối OCI image qua HTTP, theo chuẩn OCI Distribution Specification. Docker Hub, GitHub Container Registry, AWS ECR, Google GCR, Harbor là các implementation phổ biến.
Khi docker pull nginx:latest:
- Client hỏi registry: cho tôi manifest của
nginx:latest. - Registry trả về JSON liệt kê các layer digest.
- Client check cache local — layer nào đã có thì bỏ qua.
- Tải về các layer còn lại song song.
- Verify SHA-256 từng layer.
- Extract vào
/var/lib/docker/overlay2/...(hoặc tương đương).
docker push đảo ngược quá trình. Vì layer là content-addressable, registry tự dedupe — bạn push một image dựa trên ubuntu:22.04, registry chỉ nhận layer mới của bạn, các layer base đã có sẵn.
Snapshot vs Overlay — containerd góc nhìn khác
containerd (runtime modern hơn Docker) gọi layer storage là snapshot, và có nhiều snapshotter plugin: overlayfs (mặc định trên Linux), native (copy bằng tay, không dùng union FS), btrfs, zfs, stargz (lazy-pull). Mỗi snapshotter là một cách hiện thực hoá khác nhau cho cùng abstraction: "tạo một writable layer trên tập layer read-only".
copy-on-write — vì sao container start nhanh
Khi docker run, OverlayFS chỉ tạo upperdir rỗng và merged mount. Không có file nào bị copy. Container thấy "hệ thống file đầy đủ" mà thực ra hệ thống đó vẫn nằm yên trong các tar layer trên đĩa, được mount qua overlay.
Đây là lý do container start trong dưới 1 giây kể cả với image vài GB — start không liên quan tới kích thước image, mà chỉ liên quan tới việc setup namespace + cgroup + mount. Image size chỉ ảnh hưởng tới docker pull (lần đầu).
Filesystem xong. Container đã có namespace, cgroup, rootfs. Nhưng một process root bên trong namespace vẫn rất nguy hiểm — section tiếp theo nói về các lớp security làm cho việc đó an toàn hơn.
Security layers — vì sao container không phải là root toàn quyền
Một process gọi clone(CLONE_NEWPID | CLONE_NEWNS | ...) rồi chroot vào rootfs riêng — về kỹ thuật đã là "container". Nhưng nếu chỉ dừng ở đó, container vẫn cực kỳ nguy hiểm: process bên trong vẫn là UID 0 (root) trên kernel host, vẫn có thể gọi hàng trăm syscall, một số trong đó (như mount, reboot, kexec_load) có thể phá hủy host hoặc thoát container.
Để container thực sự dùng được trong production, kernel áp nhiều lớp giảm đặc quyền chồng lên nhau: capabilities, seccomp, LSM (AppArmor/SELinux), no_new_privs. Đây là phần ít được nhắc tới nhưng quan trọng nhất để hiểu "tại sao container vẫn an toàn dù chạy như root".
Capabilities — chẻ nhỏ quyền root
Trong Unix truyền thống, có hai trạng thái: UID 0 (root, làm gì cũng được) và non-zero (chỉ làm gì được cho phép). Quá nhị phân. Linux 2.2 (1999) chia quyền root thành ~40 capability rời rạc.
Linux capability: Một đặc quyền cụ thể của process. Thay vì "root có tất cả", capability cho phép gán một số quyền riêng (ví dụ
CAP_NET_ADMINđể config mạng,CAP_SYS_TIMEđể chỉnh giờ) mà không trao toàn bộ quyền root. Process có thể bị thu hồi capability không cần thiết để giảm thiệt hại nếu bị tấn công.
Một số capability quan trọng:
| Capability | Cho phép |
|---|---|
CAP_NET_ADMIN | Cấu hình mạng, iptables, route |
CAP_NET_BIND_SERVICE | Bind port < 1024 |
CAP_SYS_ADMIN | Một "túi đồ" khổng lồ — mount, swap, set hostname, ... |
CAP_SYS_PTRACE | Trace process khác |
CAP_SYS_MODULE | Load kernel module — tuyệt đối không cho container |
CAP_SYS_TIME | Đổi giờ hệ thống |
CAP_DAC_OVERRIDE | Bỏ qua discretionary access control (đọc file mặc dù không có quyền) |
CAP_CHOWN | Đổi owner của file |
CAP_KILL | Gửi signal cho process khác user |
Docker mặc định drop tất cả capability, rồi thêm lại một danh sách an toàn (gọi là bounding set). Mặc định Docker giữ lại 14 capability:
chown, dac_override, fowner, fsetid, kill, setgid, setuid,
setpcap, net_bind_service, net_raw, sys_chroot, mknod,
audit_write, setfcap
Chú ý là CAP_NET_ADMIN, CAP_SYS_ADMIN, CAP_SYS_MODULE đều bị bỏ. Đó là lý do bạn apt update trong container vẫn được (CAP_DAC_OVERRIDE đủ), nhưng iptables -L báo "permission denied" — vì cần CAP_NET_ADMIN. Để thêm: docker run --cap-add NET_ADMIN.
# Xem capability hiện tại của container
docker run --rm alpine sh -c "apk add libcap-utils && getpcaps 1"
# Drop tất cả, chỉ giữ NET_BIND_SERVICE để bind port 80
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx
Seccomp — lọc syscall
Capability lọc quyền; seccomp lọc syscall. Linux có ~350 syscall, container thường chỉ cần ~50-100. Số còn lại không ai dùng, nhưng từng có lỗ hổng (Dirty COW, Dirty Pipe...) — chặn trước những syscall không cần thiết là phòng thủ sâu.
seccomp (secure computing mode): Cơ chế kernel giới hạn các syscall mà process được phép gọi.
seccomp-bpf(mode mới) cho phép viết filter BPF: với mỗi syscall, filter trả vềALLOW,DENY,KILL, hoặcTRAP. Một khi cài, không thể tự gỡ — kể cả root trong container.
Docker đi kèm một default seccomp profile chặn ~44 syscall nguy hiểm: kexec_load, reboot, swapon, unshare (nếu không có user namespace), bpf, ... Bạn có thể viết profile riêng bằng JSON:
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": ["read", "write", "open", "close", "fstat",
"mmap", "exit_group"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Profile trên deny by default, chỉ allow vài syscall — quá khắt khe cho hầu hết app, nhưng tốt cho workload đặc biệt (function-as-a-service, sandbox).
Apply trong runc/Docker:
docker run --security-opt seccomp=my-profile.json myapp
Đây cũng là cơ chế đằng sau gVisor (sẽ nói ở section 10) — nó dùng seccomp ở mức cực đoan, chỉ allow một số syscall và intercept phần còn lại trong userspace.
LSM — AppArmor và SELinux
LSM (Linux Security Module): Framework trong kernel cho phép gắn các module bảo mật để kiểm soát "có cho phép thao tác X không" ở các điểm kiểm tra (hook) trong kernel. Hai LSM phổ biến là AppArmor (path-based, Ubuntu/SUSE) và SELinux (label-based, RHEL/Fedora).
Trong khi capability và seccomp can thiệp ở mức syscall, LSM can thiệp ở mức "object" — file nào, socket nào, process nào, được phép làm gì. Sâu hơn và linh hoạt hơn, nhưng cấu hình phức tạp hơn.
AppArmor trên Ubuntu mặc định gắn một profile docker-default cho mọi container, chặn:
- Ghi vào
/proc/*ngoại trừ các file an toàn. - Truy cập
/sys/fs/cgroup/*(read-only). - Mount filesystem ngoài rootfs container.
docker run --security-opt apparmor=docker-default ...
# Hoặc disable (không khuyến khích):
docker run --security-opt apparmor=unconfined ...
SELinux trên RHEL/Fedora dùng label. Mỗi process và file có một label (system_u:system_r:container_t:s0:c123,c456). Policy quyết định container với label nào được truy cập file với label nào. Chặt chẽ hơn AppArmor, nhưng debug khó hơn rất nhiều — setenforce 0 (permissive) là phản xạ đầu tiên của nhiều dev khi gặp lỗi quyền lạ.
no_new_privs — không leo quyền giữa chừng
Một process có thể leo quyền qua setuid binary (ví dụ /usr/bin/sudo, /usr/bin/passwd). Trong container không nên có chuyện đó. Cờ PR_SET_NO_NEW_PRIVS (kernel prctl) khoá process: kể cả exec một setuid binary, kernel không cho thêm quyền.
Docker bật cờ này mặc định khi bạn dùng --security-opt no-new-privileges. Kubernetes pod có securityContext.allowPrivilegeEscalation: false để bật.
Rootless container — không chạm tới root
Tất cả các lớp trên là giảm thiểu thiệt hại khi container chạy như root. Cách triệt để hơn: không cần root ngay từ đầu.
Rootless container (Podman, Buildah, rootless Docker) dùng user namespace (đã nói ở section 3) để map UID 0 trong container thành một UID không có đặc quyền trên host. Nếu container thoát ra, exploit cũng không có quyền gì.
Trade-off:
- Cần kernel support user namespace + một số tweak (
/etc/subuid,/etc/subgidđể khai báo UID range). - Một số tính năng giới hạn: bind port < 1024 cần
slirp4netns, mount filesystem hạn chế, một số filesystem (NFS) không hoạt động trong user namespace.
Tóm tắt phân lớp:
+------------------------------------------+
| Process in container |
+------------------------------------------+
| Layer 5: User namespace (UID remap) |
+------------------------------------------+
| Layer 4: LSM (AppArmor/SELinux profile) |
+------------------------------------------+
| Layer 3: Seccomp (filter syscall) |
+------------------------------------------+
| Layer 2: Capabilities (bounding set) |
+------------------------------------------+
| Layer 1: Namespace (isolated view) |
+------------------------------------------+
| Linux kernel |
+------------------------------------------+
Mỗi lớp là một defense in depth. Một lỗ hổng kernel có thể vượt qua lớp 1-2, nhưng đụng phải seccomp hoặc LSM thì khó hơn nhiều. Hiểu chồng lớp này, bạn sẽ hiểu vì sao các CVE container kiểu CVE-2019-5736 (runc thoát container) lại tạo ra cú sốc lớn — nó vượt qua nhiều lớp cùng lúc.
Container đã an toàn (đủ). Giờ ai là người gọi clone(CLONE_NEW*), set cgroup, áp seccomp, mount overlay? Đó là container runtime — section tiếp.
Container runtime stack — runc, containerd, CRI, và Docker
Ở những phần trước, "ai đó" gọi syscall, mount overlay, áp seccomp. Người đó là container runtime. Nhưng "runtime" là từ bị overload — có ít nhất hai lớp khác nhau cùng được gọi là runtime, và rất nhiều dev nhầm. Section này gỡ rối stack đó.
Hai loại runtime: low-level vs high-level
Low-level container runtime (OCI runtime): Chương trình gọi trực tiếp syscall kernel (clone, mount, setns, ...) để tạo container từ một bundle gồm rootfs +
config.json. Không quan tâm tới image, registry, network. Ví dụ:runc,crun,youki,runsc(gVisor),kata-runtime.
High-level container runtime (container manager): Chương trình quản lý vòng đời container: pull image từ registry, lưu trữ snapshot, gọi low-level runtime để start, expose API cho client (CLI, Kubernetes). Ví dụ:
containerd,CRI-O,Podman(cả low-level và high-level), Docker (về phần engine).
Cụ thể:
docker CLI / kubectl / ctr / crictl
|
v
+--------------------------------------+ <- HIGH-LEVEL
| Docker daemon / containerd / CRI-O |
| - manage images |
| - manage snapshots |
| - expose gRPC API |
+--------------------------------------+
|
v
+--------------------------------------+ <- LOW-LEVEL (OCI runtime)
| runc |
| - clone() namespaces |
| - set up cgroup |
| - mount overlay |
| - apply seccomp |
| - exec entrypoint |
+--------------------------------------+
|
v
Linux kernel (namespace + cgroup + ...)
OCI Runtime Specification — chuẩn để low-level runtime tuân theo
OCI Runtime Spec định nghĩa bundle: một thư mục chứa config.json (mô tả container) + rootfs/ (thư mục root). Và định nghĩa interface CLI mà runtime phải support:
runc create <container-id> # tạo container, chưa start process
runc start <container-id> # chạy entrypoint
runc list # liệt kê container
runc kill <container-id> # gửi signal
runc delete <container-id> # cleanup
config.json mô tả mọi thứ container cần — entrypoint, env, namespace nào tạo, cgroup limit gì, seccomp profile nào áp:
{
"ociVersion": "1.0.2",
"process": {
"user": {"uid": 0, "gid": 0},
"args": ["sh"],
"env": ["PATH=/usr/bin:/bin"],
"cwd": "/"
},
"root": {"path": "rootfs", "readonly": false},
"linux": {
"namespaces": [
{"type": "pid"},
{"type": "network"},
{"type": "mount"},
{"type": "ipc"},
{"type": "uts"}
],
"resources": {
"memory": {"limit": 536870912},
"cpu": {"quota": 50000, "period": 100000}
},
"seccomp": { "...": "..." }
}
}
Vì spec này được chuẩn hoá, bạn có thể swap runc ra crun (rewrite bằng C, nhanh hơn) hoặc runsc (gVisor) mà không đổi gì ở tầng trên. Đây là cú lớn về tính linh hoạt — và là lý do gVisor, Kata Containers chạy được trong Kubernetes mà không sửa Kubernetes.
runc — implementation tham chiếu
runc là implementation gốc của OCI Runtime Spec, viết bằng Go. Là spin-off từ Docker năm 2015 khi OCI ra đời. Code base nhỏ, ~30k dòng Go. Mỗi lần start container, runc fork một process con, gọi clone với cờ namespace, set cgroup, mount, áp seccomp, rồi exec entrypoint của user.
crun (RedHat, C) và youki (Rust) là hai implementation thay thế phổ biến. crun nhanh hơn runc đáng kể cho workload start nhiều container (như Knative scale-from-zero).
containerd — high-level runtime hiện đại
Trước 2017, Docker là một daemon monolithic: CLI, daemon, image management, container management, network — tất cả trong một binary dockerd. Khó embed vào Kubernetes. Docker tách lõi ra thành containerd, donate cho CNCF.
containerd có kiến trúc plugin:
+------------------------------------------+
| containerd daemon |
| |
| +-------------------+ |
| | gRPC API server | |
| +-------------------+ |
| | |
| v |
| +-------------------+ +---------------+ |
| | Content store | | Image manager | |
| | (layers, blobs) | | (pull/push) | |
| +-------------------+ +---------------+ |
| | | |
| v v |
| +-------------------+ +---------------+ |
| | Snapshotter | | Tasks runtime | |
| | (overlayfs/btrfs) | | (calls runc) | |
| +-------------------+ +---------------+ |
| | |
| v |
| +-------------+ |
| | containerd- | |
| | shim | |
| +-------------+ |
| | |
| v |
| [runc] |
+------------------------------------------+
CLI client là ctr (low-level, dev tool) và nerdctl (Docker-CLI-compatible).
Shim — vì sao containerd không trực tiếp giữ container
Khi containerd start container, nó không trực tiếp parent của process container. Thay vào đó nó spawn một shim (containerd-shim-runc-v2), shim đó mới gọi runc start và trở thành parent của container process.
Tại sao? Vì nếu containerd daemon restart (upgrade, crash, ...), parent chết → container chết. Shim ngăn điều đó: shim độc lập với containerd, container sống tiếp dù containerd down.
Shim cũng giữ pipe của stdout/stderr container và xử lý reattach khi client (CLI) ngắt kết nối — đó là lý do bạn có thể Ctrl+C ra khỏi docker logs -f mà container không chết.
CRI — interface giữa Kubernetes và runtime
Kubernetes ban đầu tích hợp trực tiếp Docker. Khi nhiều runtime xuất hiện (containerd, CRI-O), team Kubernetes định nghĩa CRI (Container Runtime Interface) — một gRPC API mà mọi runtime muốn dùng với Kubernetes phải implement.
CRI (Container Runtime Interface): API gRPC mà kubelet dùng để giao tiếp với container runtime trên node. Định nghĩa các method như
RunPodSandbox,CreateContainer,StartContainer,PullImage,ListPodSandbox. Cho phép Kubernetes làm việc với bất kỳ runtime nào support CRI.
Hệ quả: Kubernetes không "chạy Docker" nữa. Nó chạy containerd hoặc CRI-O, gọi qua CRI:
kubectl
|
v
kube-apiserver
|
v
kubelet (on node)
|
v gRPC (CRI)
containerd / CRI-O
|
v
runc
|
v
namespace + cgroup
Kubernetes 1.24 (2022) chính thức bỏ dockershim — không còn hỗ trợ Docker engine. Docker vẫn dùng được để build image (vì image là chuẩn OCI), nhưng cluster Kubernetes mặc định chạy containerd.
Docker — vẫn là gì hôm nay?
Docker, Inc. (the company) vẫn duy trì:
- Docker Desktop — gói cho dev (Mac/Win/Linux).
- Docker Engine — daemon (
dockerd) gọi xuốngcontainerd(project đã chuyển cho CNCF từ 2017). - Docker CLI — UX dễ dùng nhất trong tất cả các tool container.
- Docker Hub — registry phổ biến.
- BuildKit — tool build image hiện đại.
Khi bạn docker run, thật ra chuỗi gọi là:
docker CLI -> dockerd -> containerd -> containerd-shim -> runc -> kernel
5 process tham gia để start 1 container. Đây là lý do nerdctl (gọi thẳng containerd) hoặc podman (không daemon, gọi thẳng runc) thường nhanh hơn docker.
Podman — daemonless
Podman cùng API với Docker (alias docker=podman chạy được nhiều use case), nhưng kiến trúc khác hẳn: không có daemon. Mỗi podman run là một process fork ra, gọi runc, rồi thoát; container chạy độc lập (managed bởi systemd nếu cần).
Ưu điểm:
- Không có single point of failure.
- Rootless tự nhiên (mỗi user tự chạy container của mình).
- Tích hợp systemd tốt (
podman generate systemdtạo unit file).
Nhược điểm:
- Không có daemon = không có background reconciliation. Nếu container chết, không ai restart trừ khi bạn dùng systemd.
- Một số tính năng "swarm-style" thiếu.
Tóm lại: nếu bạn hiểu stack runc -> containerd-shim -> containerd -> CRI/Docker, bạn đã hiểu 90% câu hỏi "tại sao kubelet/containerd/Docker behave thế này thế kia". Section tiếp theo lấp lỗ hổng cuối cùng: làm sao container nói chuyện với mạng.
Container networking — veth, bridge, CNI
Container có network namespace riêng. Theo mặc định, netns mới chỉ có một interface lo (loopback). Không có eth0, không có route ra ngoài. Vậy mà container "tự nhiên" có IP và ra Internet được — magic xảy ra ở chỗ nào? Section này lột tầng tầng lớp networking, từ veth tới CNI plugin.
Có một bài riêng đào sâu phần này — Container Networking from Scratch — section này điểm lại các thành phần cốt lõi và đặt chúng vào bức tranh tổng thể của container stack.
veth pair — đường ống giữa hai netns
veth pair (Virtual Ethernet pair): Một cặp interface ảo nối liền nhau — gói tin nhận ở đầu này phát ra ở đầu kia. Một đầu nằm trong netns container, đầu kia nằm trong netns host (hoặc trong một netns khác). Đây là "wire" kết nối các netns.
container netns host netns
+------------------------------+ +------------------------------+
| | | |
| eth0 (10.0.0.2) | | veth1234abcd (no IP) |
| ^ | | ^ |
| | | | | |
| +--- veth pair ----------------------------+ |
| | | |
+------------------------------+ +------------------------------+
Tạo bằng tay:
# 1. Tạo netns mới
ip netns add c1
# 2. Tạo veth pair
ip link add veth0 type veth peer name veth1
# 3. Đặt đầu veth1 vào netns c1
ip link set veth1 netns c1
# 4. Đặt IP và up cả hai
ip addr add 10.0.0.1/24 dev veth0
ip link set veth0 up
ip netns exec c1 ip addr add 10.0.0.2/24 dev veth1
ip netns exec c1 ip link set veth1 up
ip netns exec c1 ip link set lo up
# 5. Test
ping 10.0.0.2 # host -> container
ip netns exec c1 ping 10.0.0.1 # container -> host
Trên cái nền veth pair này là toàn bộ thế giới container networking.
Bridge — kết nối nhiều container
Một veth pair nối được 2 endpoint. Để nhiều container nói chuyện với nhau, dùng Linux bridge: một switch L2 phần mềm.
Linux bridge: Software switch trong kernel hoạt động như switch L2. Các interface (vật lý hoặc ảo) "attached" vào bridge có thể trao đổi frame Ethernet với nhau như cùng đấu vào một switch vật lý.
Docker mặc định tạo bridge tên docker0 với subnet 172.17.0.0/16. Mỗi container có một veth, một đầu trong netns container (eth0), đầu kia attached vào docker0:
docker0 bridge (172.17.0.1/16)
/ | \
vethA1B2 vethC3D4 vethE5F6
| | |
container1 container2 container3
eth0 eth0 eth0
172.17.0.2 172.17.0.3 172.17.0.4
Container1 ping container2: gói tin đi qua veth, vào bridge, bridge forward sang veth khác, đến container2. Hoàn toàn ở L2, không cần routing.
Ra Internet — NAT
Bridge cho phép container nói chuyện với nhau và với host (qua docker0 interface trên host). Để container ra Internet, cần NAT: gói tin từ 172.17.0.2 phải được rewrite source IP thành IP host trước khi gửi đi. Đây là iptables MASQUERADE:
# Rule Docker tạo (tự động):
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
Đọc: "gói tin có source 172.17.0.0/16, đi ra interface không phải docker0 → rewrite source thành IP của interface đi ra".
Phía vào (publish port -p 8080:80):
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to 172.17.0.2:80
Đọc: "gói tin TCP tới port 8080 → rewrite destination thành container."
Network mode khác — host, none, container
Ngoài bridge mặc định, Docker có vài network mode khác — mỗi cái tương ứng với một cách khác nhau dùng network namespace:
| Mode | Cách hoạt động | Use case |
|---|---|---|
bridge (default) | Tạo netns mới, veth, attach vào docker0, NAT | App thường |
host | Không tạo netns — container dùng chung netns host | Performance critical (skip NAT), monitoring agent |
none | Tạo netns mới nhưng không attach gì | Job offline, không cần network |
container:<name> | Dùng chung netns với container khác | Sidecar pattern (Kubernetes pod chính là cái này) |
container: mode rất quan trọng vì nó là cơ chế đằng sau Kubernetes pod: tất cả container trong cùng pod share netns. Cụ thể, kubelet start một container "pause" (rất nhỏ) trước, các container khác join netns của pause container. Pause chỉ ngồi pause() cả đời để giữ netns sống.
CNI — chuẩn cho Kubernetes networking
Bridge + NAT của Docker hoạt động tốt cho single host. Nhưng Kubernetes có hàng trăm node — container ở node A phải nói chuyện với container ở node B mà không cần NAT (Kubernetes networking model yêu cầu "every pod gets unique IP, all pods can talk directly"). Cần một abstraction.
CNI (Container Network Interface): Đặc tả tối giản về cách container runtime gọi plugin để cấu hình network cho container. Khi container được tạo, runtime gọi
cni-plugin ADD <netns> <args>— plugin tự setup interface, IP, route, NAT, ... Cùng pattern khi xoá container:cni-plugin DEL ....
CNI plugin là binary đơn lẻ trên disk, thường ở /opt/cni/bin/. Cấu hình CNI nằm ở /etc/cni/net.d/. Một plugin chỉ làm một việc: setup network cho container vừa tạo.
Có hai cấp:
- Reference plugins — đơn giản:
bridge,host-local(IP allocation),loopback,portmap. - CNI implementations — phức tạp, làm overlay/routing cross-node: Flannel, Calico, Cilium, Weave Net, Antrea.
Mỗi implementation chọn một kỹ thuật khác:
| Plugin | Cách hoạt động | Đặc điểm |
|---|---|---|
| Flannel (VXLAN) | Overlay L2 qua VXLAN tunnel giữa các node | Đơn giản, default cho nhiều cluster |
| Calico | BGP routing thuần L3, không overlay | Performance cao, scale tốt, NetworkPolicy mạnh |
| Cilium | eBPF, replace iptables bằng BPF program | Modern, observability tốt, performance đỉnh |
| Weave Net | Overlay UDP/VXLAN tự design | Dễ setup, mesh networking |
kube-proxy — service và NAT trong Kubernetes
Container có IP riêng, nhưng pod có thể chết và tạo lại với IP khác. Client cần một địa chỉ ổn định. Đó là Service: một VIP (Virtual IP) cluster-wide, kube-proxy lo việc route VIP → các pod IP đang chạy.
kube-proxy: Component chạy trên mỗi Kubernetes node, watch các Service và Endpoint, rồi config iptables/IPVS (hoặc Cilium eBPF) để các gói tin tới VIP của service được DNAT về một trong các pod backend. Khi pod chết hoặc thêm, kube-proxy update rule.
Mode phổ biến:
iptablesmode (default) — viết một loạt rule iptables. Đơn giản, đủ tốt tới ~1000 service.ipvsmode — dùng IPVS (Linux kernel L4 load balancer). Scale tốt hơn nhiều, cần kernel module.nftablesmode — GA từ Kubernetes 1.33, thay iptables bằng nftables.- Hoặc bỏ kube-proxy và để Cilium eBPF làm việc đó hoàn toàn trong BPF.
Tóm lại stack networking
Tổng kết các tầng:
+---------------------------------------+
| Application (HTTP, gRPC, ...) |
+---------------------------------------+
| TCP/UDP socket in container netns |
+---------------------------------------+
| eth0 (veth in container) |
+---------------------------------------+
| veth peer in host netns |
+---------------------------------------+
| Bridge (docker0/cni0) OR routing |
+---------------------------------------+
| iptables/IPVS/eBPF (NAT, service) |
+---------------------------------------+
| Physical NIC of host |
+---------------------------------------+
| Network |
+---------------------------------------+
Mỗi tầng có thể swap: veth bằng macvlan/ipvlan, bridge bằng routing thuần, iptables bằng eBPF. Container networking là một trong các phần linh hoạt nhất của Linux kernel — bạn ráp các nguyên thuỷ thế nào tuỳ workload.
Đã có đủ kiến thức — section tiếp theo sẽ ráp tất cả lại tay không.
Hands-on — dựng một container bằng tay không Docker
Bây giờ ráp tất cả lại: namespace + cgroup + rootfs + bridge + veth. Mục tiêu là chạy một shell trong môi trường cô lập gần đủ để gọi là "container", chỉ dùng shell và các tool cơ bản (unshare, ip, mount). Không Docker. Không containerd. Không runc.
Demo này chạy được trên Linux host hiện đại (Ubuntu 22.04+, kernel 5.10+) với quyền root. Ở mỗi bước, mình giải thích syscall hoặc tool đứng đằng sau, để bạn thấy "container" thực sự là cái gì.
Bước 1 — Chuẩn bị rootfs
Cần một filesystem để container nhìn thấy như "/". Cách dễ nhất là export một image Alpine sẵn có:
mkdir -p /tmp/myctn/rootfs
docker export $(docker create alpine) | tar -C /tmp/myctn/rootfs -xf -
ls /tmp/myctn/rootfs
# bin dev etc home lib media ...
Nếu không có Docker, có thể tải tarball Alpine miniroot từ alpinelinux.org và extract — không sao cả.
Bước 2 — Setup cgroup giới hạn tài nguyên
Tạo cgroup v2 với limit 100MB RAM và 0.5 CPU:
# Bật controller ở root
echo "+cpu +memory +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# Tạo cgroup riêng
sudo mkdir /sys/fs/cgroup/myctn
echo "104857600" | sudo tee /sys/fs/cgroup/myctn/memory.max # 100 MB
echo "50000 100000" | sudo tee /sys/fs/cgroup/myctn/cpu.max # 0.5 CPU
echo "100" | sudo tee /sys/fs/cgroup/myctn/pids.max # 100 PID max
Bước 3 — Tạo network namespace và veth pair
# Tạo netns
sudo ip netns add myctn
# Tạo bridge (nếu chưa có)
sudo ip link add name br0 type bridge 2>/dev/null || true
sudo ip addr add 10.99.0.1/24 dev br0 2>/dev/null || true
sudo ip link set br0 up
# Tạo veth pair
sudo ip link add veth-host type veth peer name veth-ctn
# Đầu host attach vào bridge
sudo ip link set veth-host master br0
sudo ip link set veth-host up
# Đầu container vào netns
sudo ip link set veth-ctn netns myctn
sudo ip netns exec myctn ip link set lo up
sudo ip netns exec myctn ip link set veth-ctn name eth0
sudo ip netns exec myctn ip addr add 10.99.0.10/24 dev eth0
sudo ip netns exec myctn ip link set eth0 up
sudo ip netns exec myctn ip route add default via 10.99.0.1
# Bật forwarding và NAT để container ra Internet
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -s 10.99.0.0/24 ! -o br0 -j MASQUERADE
Bước 4 — Tạo các namespace còn lại và chroot vào rootfs
unshare tạo namespace mới và chạy command. Combine với việc đặt process vào cgroup và netns đã có:
# Vào cgroup trước khi clone
echo $$ | sudo tee /sys/fs/cgroup/myctn/cgroup.procs
# Vào netns đã tạo
sudo nsenter --net=/var/run/netns/myctn \
unshare --pid --mount --uts --ipc --fork --mount-proc \
chroot /tmp/myctn/rootfs /bin/sh
Trong shell này:
- Cô lập PID (
ps auxchỉ thấyshvàps) - Cô lập mount (mount riêng,
/procđã được mount lại bởi--mount-proc) - Cô lập network (chỉ có
eth010.99.0.10 vàlo) - Cô lập UTS (
hostname container1không ảnh hưởng host) - Cô lập IPC
- Filesystem là Alpine rootfs
- Bị cgroup giới hạn 100MB / 0.5 CPU / 100 PID
Test thử bên trong:
hostname container1
ps aux
ip addr
ping -c 2 10.99.0.1
ping -c 2 8.8.8.8 # nếu NAT ok
Cái còn thiếu so với Docker
Cái shell vừa tạo là một "container" theo bản chất kỹ thuật. Nhưng so với Docker thì còn thiếu các lớp sau:
- User namespace — chưa map UID. Nếu thoát thông qua bug kernel, vẫn là root host.
- Capabilities — vẫn full root capability set. Cần
capsh --drop=cap_sys_admin,cap_net_admin,... - Seccomp — chưa filter syscall. Cần
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...). - AppArmor/SELinux — không có profile.
- OverlayFS — đang dùng rootfs đầy đủ. Nếu nhiều container, lãng phí.
- pivot_root —
chrootkhông an toàn bằngpivot_root.
Mỗi cái là một patch lên trên cái base. Tổng cộng, Docker (qua containerd qua runc) tự động làm hết — chính là lý do tồn tại của container runtime.
Một script gói gọn
Đoạn này gói cả demo thành script reusable, để bạn copy về thử:
#!/usr/bin/env bash
set -e
CTN_NAME="${1:-myctn}"
CTN_IP="${2:-10.99.0.10/24}"
CTN_GW="10.99.0.1"
ROOTFS="/tmp/${CTN_NAME}/rootfs"
# 1. Prepare rootfs (assumes Alpine tarball already extracted)
if [[ ! -d "$ROOTFS/bin" ]]; then
mkdir -p "$ROOTFS"
docker export "$(docker create alpine)" | tar -C "$ROOTFS" -xf -
fi
# 2. cgroup v2
mkdir -p /sys/fs/cgroup/${CTN_NAME}
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
echo "104857600" > /sys/fs/cgroup/${CTN_NAME}/memory.max
echo "50000 100000" > /sys/fs/cgroup/${CTN_NAME}/cpu.max
echo "100" > /sys/fs/cgroup/${CTN_NAME}/pids.max
# 3. Network
ip link add name br0 type bridge 2>/dev/null || true
ip addr add 10.99.0.1/24 dev br0 2>/dev/null || true
ip link set br0 up
ip netns add ${CTN_NAME} 2>/dev/null || true
ip link add veth-${CTN_NAME} type veth peer name eth0-${CTN_NAME} 2>/dev/null || true
ip link set veth-${CTN_NAME} master br0
ip link set veth-${CTN_NAME} up
ip link set eth0-${CTN_NAME} netns ${CTN_NAME}
ip netns exec ${CTN_NAME} ip link set lo up
ip netns exec ${CTN_NAME} ip link set eth0-${CTN_NAME} name eth0
ip netns exec ${CTN_NAME} ip addr add ${CTN_IP} dev eth0
ip netns exec ${CTN_NAME} ip link set eth0 up
ip netns exec ${CTN_NAME} ip route add default via ${CTN_GW}
sysctl -w net.ipv4.ip_forward=1 > /dev/null
iptables -t nat -C POSTROUTING -s 10.99.0.0/24 ! -o br0 -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -s 10.99.0.0/24 ! -o br0 -j MASQUERADE
# 4. Run
echo $$ > /sys/fs/cgroup/${CTN_NAME}/cgroup.procs
exec nsenter --net=/var/run/netns/${CTN_NAME} \
unshare --pid --mount --uts --ipc --fork --mount-proc \
chroot "$ROOTFS" /bin/sh
Chạy sudo ./mini-ctn.sh myctn. Bạn vừa có một container. Khoảng 30 dòng shell.
Cleanup
sudo ip netns del myctn
sudo ip link del veth-host 2>/dev/null || true
sudo rmdir /sys/fs/cgroup/myctn
sudo rm -rf /tmp/myctn
Bài học rút ra
Sau khi làm điều này, ba điều thường khiến người ta giật mình:
- Không có gì kỳ diệu trong container. Tất cả đều là syscall và config có sẵn từ kernel hàng chục năm nay.
- Docker chỉ là một packaging. Image, registry, daemon, CLI — đó là cái khó về sản phẩm, không phải về công nghệ container.
- Tự build được container không có nghĩa nên dùng nó cho production. Cleanup, error handling, security hardening, networking phức tạp — đó là chỗ runc/containerd có giá trị.
Khi bạn debug container trong Kubernetes lần tới, hãy nhớ: đằng sau kubectl exec là chuỗi kubelet -> CRI -> containerd -> shim -> runc -> nsenter -> setns -> exec. Mỗi mắt xích có thể fail riêng. Hiểu được chuỗi này khiến debug nhanh hơn rất nhiều.
Phần cuối cùng: nếu container không đủ an toàn cho workload của bạn, có những lựa chọn nào khác?
Beyond runc — gVisor, Kata, Firecracker, WASM
Container "runc-style" (chia sẻ kernel host, dùng namespace + cgroup) là chuẩn 95% workload. Nhưng có những use case mà sự đánh đổi không chấp nhận được:
- Multi-tenant cloud — chạy code của khách hàng không tin tưởng. Một kernel exploit = thoát container = tấn công khách hàng khác.
- Function-as-a-service — cần start trong vài chục ms với hàng triệu function ngắn ngày.
- WebAssembly — model thực thi mới, isolation ở tầng VM thay vì OS.
Mỗi vấn đề tạo ra một dòng giải pháp khác nhau. Section này điểm qua bốn cách phổ biến.
gVisor — userspace kernel
gVisor: Một "application kernel" của Google, viết bằng Go. Container chạy trên runtime
runsckhông gọi syscall trực tiếp tới kernel host — chúng gọi tới Sentry, một mini-kernel chạy trong userspace, Sentry mới gọi (một tập rất nhỏ) syscall thực sự xuống kernel. Mục tiêu: thêm một lớp giảm thiểu attack surface giữa container và kernel.
+----------------------+
| Container app |
| (issues syscalls) |
+----------------------+
|
v
+----------------------+
| gVisor Sentry |
| (impl. syscalls) |
+----------------------+
|
v
+----------------------+
| Host kernel |
+----------------------+
Sentry là userspace "kernel" của gVisor, viết bằng Go — nó intercept syscall từ container qua ptrace (mặc định cũ) hoặc KVM platform (nhanh hơn, khuyến nghị cho production). Sentry tự xử lý syscall và chỉ gọi xuống host kernel khoảng ~50 syscall an toàn nhất.
Sentry tự implement filesystem, network stack, scheduling, ... Khi container gọi open("/etc/passwd"), Sentry xử lý (kiểm tra permission, lookup theo mount table của nó), rồi mới gọi xuống host nếu cần.
Trade-off:
- Pro: attack surface giảm khoảng 10 lần. Một CVE kernel thường không vượt qua được Sentry.
- Pro: tương thích OCI — drop-in replacement của
runctrong Kubernetes (--runtime=runsc). - Con: performance overhead ~10-30%, một số syscall (đặc biệt là syscall sâu vào kernel) chậm hẳn.
- Con: không 100% Linux ABI compatible — vài binary đặc biệt không chạy.
Use case: Google Cloud Run, một số function platform, học máy sandbox.
Kata Containers — micro-VM
Kata Containers: Mỗi container chạy trong một micro-VM riêng (KVM + một guest kernel nhỏ). Vẫn dùng OCI bundle, vẫn
kata-runtimethayrunc. Cô lập ở tầng hardware-virtualization như VM, nhưng performance và start time gần với container.
+----------------- pod -----------------+
| +------- micro-VM --------+ |
| | Guest kernel (minimal) | |
| | +-- container A | |
| | +-- container B | |
| +-------------------------+ |
+---------------------------------------+
|
v (via KVM)
+---------------------------------------+
| Host kernel + KVM |
+---------------------------------------+
Mỗi pod (hoặc mỗi container, configurable) được wrap trong một VM riêng. Guest kernel rất nhỏ (~5MB), boot trong dưới 200ms. Bên trong, container chạy như bình thường, nhưng nếu một CVE kernel cho phép thoát container — chỉ thoát vào guest kernel, vẫn phải vượt KVM mới đụng được host.
Trade-off:
- Pro: isolation mạnh tương đương VM truyền thống.
- Pro: tương thích OCI, drop-in cho Kubernetes.
- Con: overhead RAM ~50-100MB per pod (vì có guest kernel).
- Con: hỗ trợ host nested-virtualization (KVM khả dụng).
Use case: multi-tenant Kubernetes (Confidential Containers, một số managed Kubernetes của cloud).
Firecracker — micro-VM cho FaaS
Firecracker: Hypervisor của AWS, viết bằng Rust, được tối ưu cho mô hình "tạo + tiêu hủy nhanh". Boot một micro-VM (kernel guest cực kỳ trimmed) trong dưới 125ms, RAM overhead ~5MB. Là engine đằng sau AWS Lambda và Fargate.
Firecracker không phải là container runtime — nó là một hypervisor (như KVM/QEMU). Nhưng nó được thiết kế cho workload kiểu container: stateless, ngắn ngày, đông đảo.
Khác Kata: Firecracker chỉ implement subset rất nhỏ của hardware ảo hoá (chỉ virtio devices cần thiết — net, block, vsock; không có PCI, USB, ...). Trade-off compatibility lấy speed và security.
Firecracker design philosophy:
- Minimal device model (chỉ virtio)
- REST API để control
- Rust => memory safety
- Designed for "millions of VMs / day"
Use case: serverless function platform, containerd-shim cho Kubernetes (qua project firecracker-containerd).
WebAssembly — model thực thi mới
WebAssembly (Wasm): Một bytecode portable, sandboxed, deterministic, ban đầu cho browser. Đang lan ra server-side qua WASI (WebAssembly System Interface) — một chuẩn syscall mới được thiết kế lại từ đầu cho isolation.
WASM khác container về bản chất: nó không phải là Linux container — nó là một VM runtime (như JVM, CLR). Code biên dịch ra .wasm chạy trên một runtime (Wasmtime, WasmEdge, wasmer, ...). Runtime tự sandbox: code WASM không có syscall trực tiếp, chỉ có WASI functions được host cấp.
+----------------------+
| .wasm bytecode |
+----------------------+
|
v
+----------------------+
| Wasm runtime |
| - sandbox memory |
| - capability-based |
| - WASI imports |
+----------------------+
|
v
+----------------------+
| Host OS |
+----------------------+
Đặc điểm:
- Tiny: image .wasm thường vài MB.
- Fast start: dưới 10ms cold start.
- Capability-based: code WASM không thấy file system trừ khi được explicit cấp.
- Portable: cùng
.wasmchạy trên Linux, Mac, Windows, ARM, RISC-V, browser.
Trade-off:
- Con: phải biên dịch lại ứng dụng ra WASM (Rust, Go, C/C++ ok; nhưng Python/Java đang chậm dần ngấm vào WASM).
- Con: hệ sinh thái còn non — không có "Linux ecosystem" full.
Kubernetes có shim cho WASM (runwasi) — bạn có thể chạy .wasm workload trong pod như container thường.
So sánh tổng quan
| Tiêu chí | runc (container) | gVisor | Kata | Firecracker | Wasm |
|---|---|---|---|---|---|
| Cô lập | Namespace + cgroup | + userspace kernel | + KVM | + KVM | Sandbox runtime |
| Kernel | Host | Userspace (Sentry) | Guest (mini) | Guest (mini) | None |
| Start time | < 1s | ~1s | ~1s | ~125ms | < 10ms |
| Memory overhead | ~MB | ~10MB | ~50MB | ~5MB | ~MB |
| Compatibility | Full Linux | High (some quirks) | Full | Full | Cần build WASM |
| Attack surface | Wide | Narrow | Very narrow | Very narrow | Tiniest |
| Use case | General | Untrusted code | Multi-tenant | FaaS | Edge, plugins |
Khi nào chọn cái gì?
Vài rule of thumb:
- runc/Docker: 95% trường hợp. Default. Đủ tốt.
- gVisor: khi chạy code không tin tưởng (CI runner, sandbox cho người dùng, học máy training với code do user submit).
- Kata: multi-tenant cluster (mỗi khách hàng muốn isolation level VM, nhưng vẫn muốn UX container).
- Firecracker: FaaS, hàng trăm nghìn instance/day, mỗi instance ngắn ngày.
- WASM: edge computing, plugin system, polyglot UDF.
Đáng chú ý: các giải pháp này không loại trừ nhau. Một cluster Kubernetes có thể có node pool dùng runc, một pool khác dùng runsc, một pool nữa dùng Kata — tuỳ workload, RuntimeClass của Kubernetes cho phép pin pod vào runtime cụ thể. Đây là "polyglot isolation": chọn đúng công cụ cho đúng workload.
Kết luận — container là một ý tưởng đơn giản được lắp ráp khéo
Quay lại câu hỏi mở đầu: container là gì? Câu trả lời sau khi đi hết bài chắc đã rõ ràng hơn nhiều — không có "container" trong kernel Linux. Có namespace, có cgroup, có capabilities, có seccomp, có OverlayFS, có veth. Container là cái tên ta đặt cho sự lắp ráp khéo của chúng quanh một process. Docker, runc, containerd, Podman, Kubernetes — tất cả chỉ là các cách lắp ráp khác nhau cho cùng một bộ nguyên thuỷ.
Tóm tắt các tầng lại lần cuối:
+-----------------------------------------------+
| Orchestrator |
| (Kubernetes, Nomad, Swarm) |
+-----------------------------------------------+
| Container manager / CRI |
| (containerd, CRI-O, Docker engine) |
+-----------------------------------------------+
| OCI Runtime |
| (runc, crun, gVisor's runsc, Kata) |
+-----------------------------------------------+
| Kernel features |
| - Namespaces (PID, NET, MNT, UTS, IPC, USER) |
| - Cgroups v2 (CPU, memory, IO, PID) |
| - OverlayFS (union mount) |
| - Capabilities + Seccomp + LSM |
| - veth + bridge + netfilter |
+-----------------------------------------------+
| Linux kernel |
+-----------------------------------------------+
| Hardware |
+-----------------------------------------------+
Mỗi tầng có một abstraction rõ ràng. Tầng dưới không biết tầng trên — kernel không biết "container" là gì, runc không biết Kubernetes là gì. Đó là vẻ đẹp của thiết kế: interface bằng spec (OCI), implementation thay được.
Những điều đáng nhớ
Nếu sau bài này phải nhớ 5 điều, thì là:
- Container = process + namespace + cgroup + filesystem + security layers. Không có "container syscall". Chỉ là tổ hợp.
- Namespace cô lập view, cgroup giới hạn tài nguyên. Hai chuyện khác hẳn nhau — đừng gộp.
- Image là tập layer SHA-256-addressable + manifest. OverlayFS gộp chúng lại, registry phân phối chúng.
- OCI spec là cái khiến hệ sinh thái có thể swap được. runc -> runsc -> kata-runtime mà không đổi code orchestrator.
- Container không phải là silver bullet bảo mật. Một kernel exploit có thể thoát container. Nếu cần isolation level VM, dùng Kata/Firecracker/gVisor — không phải vì runc kém, mà vì cùng một kernel = cùng một attack surface.
Khi nào dùng cái gì?
- Học/dev local: Docker hoặc Podman. UX tốt nhất.
- Production Kubernetes: containerd với runc. Battle-tested.
- CI/sandbox không tin cậy: gVisor hoặc Kata.
- Serverless quy mô siêu lớn: Firecracker hoặc tương đương.
- Edge/plugin: WebAssembly với WASI.
Và quan trọng nhất, hiểu các tầng đủ để debug từng lớp riêng. Khi container OOMKill — đó là cgroup. Khi ps chỉ thấy 1 process — đó là PID namespace. Khi iptables báo permission denied — đó là capabilities. Khi docker pull chậm — đó là registry và layer hierarchy. Khi kubectl exec báo lỗi — có thể là CRI, có thể là shim, có thể là namespace setup.
Đi đâu tiếp theo?
Nếu bài này thú vị, một số hướng đào sâu:
- Container networking deep dive: Container Networking from Scratch đi vào chi tiết veth/bridge/iptables level.
- CNI implementations: Kubernetes CNI Overview so sánh Calico, Cilium, Flannel.
- eBPF observability: eBPF Deep Dive — cách quan sát và bảo mật container ở tầng kernel mà không cần modify code.
- Đọc source runc: ~30k dòng Go, code base sạch, là cách tốt nhất để thấy spec OCI biến thành syscall thật như thế nào. Bắt đầu từ
libcontainer/factory_linux.go. - Đọc spec OCI: Runtime spec + Image spec + Distribution spec, mỗi cái ngắn (~50 trang), đầy đủ.
Khi bạn nhìn docker run nginx lần tới và biết được trong 1 giây đó có bao nhiêu syscall, bao nhiêu file mount, bao nhiêu rule iptables, bao nhiêu cgroup được setup — thế giới container đã không còn là một hộp đen nữa. Nó chỉ là Linux thôi, hết sức bình thường, và bạn vừa hiểu nó.
References
- Linux Programmer's Manual — namespaces(7)
- Linux Programmer's Manual — cgroups(7)
- Linux Programmer's Manual — capabilities(7)
- Linux Programmer's Manual — seccomp(2)
- Linux Kernel Documentation — Control Group v2
- OCI Runtime Specification
- OCI Image Specification
- OCI Distribution Specification
- runc — CLI tool for spawning OCI containers
- containerd — Industry-standard container runtime
- CRI-O — Lightweight container runtime for Kubernetes
- Container Networking Interface (CNI) Specification
- OverlayFS Documentation — Linux Kernel
- "Build Your Own Container Using Less than 100 Lines of Bash" — Jess Frazelle
- "Containers From Scratch" — Liz Rice (GopherCon 2017)
- gVisor — Application Kernel for Containers
- Kata Containers Documentation
- Firecracker MicroVM — AWS
- WasmEdge — WebAssembly Runtime
Related posts
- Container Networking from Scratch - Đi sâu vào phần networking layer của container
- Kubernetes CNI Overview - CNI plugins và cách Kubernetes orchestrate container networking
- eBPF Deep Dive - Quan sát và bảo vệ container ở tầng kernel bằng eBPF
- Clean Architecture in Go - Code architecture, một góc khác của software engineering
