Container Chạy Bằng Gì: Namespaces, Cgroups và Union Filesystem
Ở Bài 1 ta thấy runc là thứ thực sự tạo container, bằng cách dùng các tính năng của nhân Linux. Bài này mổ xẻ chính những tính năng đó. Đây là phần "ma thuật" của container, và tin tốt là không có ma thuật nào cả — chỉ là ba cơ chế có sẵn trong nhân Linux ghép lại.
Một câu cần nhớ trước khi đi tiếp: container không phải một loại máy ảo tí hon. Nó chỉ là một tiến trình Linux bình thường, nhưng được nhân Linux cho một góc nhìn bị giới hạn và cô lập. Ba thứ tạo nên sự cô lập đó:
- Namespaces — quyết định container nhìn thấy gì (cô lập).
- Cgroups — quyết định container dùng được bao nhiêu tài nguyên (giới hạn).
- Union filesystem — quyết định container thấy hệ thống file nào (layer).
Một tiến trình thường ──────────► Một "container"
được khoác thêm:
┌───────────────────────────────────────────────┐
│ namespaces → thấy gì (process, mạng, mount) │
│ cgroups → dùng được bao nhiêu (CPU, RAM) │
│ union FS → thấy filesystem nào (layers) │
└───────────────────────────────────────────────┘
Ta đi từng cái một.
Namespaces: cô lập tầm nhìn
Tài liệu Docker gọi namespaces là "hình thức cô lập đầu tiên và trực tiếp nhất". Ý tưởng: nhân Linux có thể cho mỗi tiến trình một "namespace" riêng cho từng loại tài nguyên, và tiến trình chỉ thấy được những gì trong namespace của nó.
Theo tài liệu, "các tiến trình chạy trong một container không thể thấy, càng không thể tác động tới các tiến trình chạy trong container khác", và "mỗi container cũng có ngăn xếp mạng (network stack) riêng".
Có nhiều loại namespace, mỗi loại cô lập một thứ:
- PID — cô lập cây tiến trình. Tiến trình đầu trong container thấy mình là PID 1, và không thấy tiến trình ngoài container.
- NET — cô lập mạng: card mạng ảo, IP, bảng định tuyến riêng.
- MNT — cô lập điểm mount (hệ thống file mà tiến trình thấy).
- UTS — cô lập hostname.
- IPC — cô lập giao tiếp liên tiến trình (shared memory...).
- USER — ánh xạ user/group, cho phép root trong container không phải root ngoài host.
Tự kiểm chứng. Mỗi tiến trình có một thư mục /proc/<pid>/ns/ liệt kê namespace của nó. Chạy:
docker run --rm alpine ls -l /proc/self/ns/
Kết quả (rút gọn):
lrwxrwxrwx cgroup -> cgroup:[4026532965]
lrwxrwxrwx ipc -> ipc:[4026532963]
lrwxrwxrwx mnt -> mnt:[4026532961]
lrwxrwxrwx net -> net:[4026532966]
lrwxrwxrwx pid -> pid:[...]
lrwxrwxrwx uts -> uts:[...]
Mỗi dòng là một namespace, kèm một số (inode) định danh nó. Container có bộ namespace riêng nên các số này khác với tiến trình trên host.
Cô lập PID thấy rõ nhất qua lệnh ps. Bên trong container:
docker run --rm alpine ps aux
PID USER TIME COMMAND
1 root 0:00 ps aux
Chỉ có một tiến trình, và nó là PID 1. Trên máy host có hàng trăm tiến trình, nhưng container không thấy cái nào — vì nó ở trong PID namespace riêng. Đây chính là "cô lập": cùng một nhân Linux, nhưng mỗi container tưởng như mình có cả hệ thống riêng.
Và cũng vì dùng chung nhân với host, container mới nhẹ và khởi động nhanh — không phải boot một hệ điều hành như máy ảo, chỉ là tạo vài namespace cho một tiến trình.
Cgroups: giới hạn tài nguyên
Namespaces lo phần thấy gì. Nhưng nếu một container ngốn hết CPU hay RAM của máy thì các container khác chết theo. Đó là việc của control groups (cgroups).
Tài liệu Docker: cgroups "hiện thực việc tính toán và giới hạn tài nguyên", giúp "mỗi container nhận phần chia công bằng về bộ nhớ, CPU, disk I/O", và ngăn một container làm cạn tài nguyên hệ thống.
Thử giới hạn một container ở 50 MB RAM rồi để nó tự đọc giới hạn của mình:
docker run --rm --memory=50m alpine cat /sys/fs/cgroup/memory.max
52428800
52428800 byte đúng bằng 50 × 1024 × 1024 = 50 MiB. Cờ --memory=50m được dịch thành một cgroup giới hạn bộ nhớ, và bên trong container, file /sys/fs/cgroup/memory.max phản ánh đúng con số đó. Nếu tiến trình trong container cố dùng quá 50 MB, nhân sẽ chặn (và có thể bị OOM-kill).
Tương tự có --cpus, --cpu-shares để giới hạn CPU. Điểm cốt lõi: giới hạn được áp ở mức nhân, container không "lách" được.
Ghi chú: đường dẫn
/sys/fs/cgroup/memory.maxlà của cgroup v2 (phổ biến trên các hệ mới). Hệ cũ dùng cgroup v1 với đường dẫn khác (/sys/fs/cgroup/memory/memory.limit_in_bytes). Cơ chế thì như nhau.
Union filesystem: hệ thống file xếp lớp
Mảnh thứ ba trả lời: container lấy hệ thống file (thư mục, file, lệnh) ở đâu ra? Câu trả lời là union filesystem — cơ chế xếp chồng nhiều lớp file thành một.
Theo tài liệu Docker, "một image được dựng từ một chuỗi các layer. Mỗi layer ứng với một lệnh trong Dockerfile của image". Các layer này chỉ-đọc. Khi chạy container, "Docker thêm một writable layer lên trên cùng" để chứa mọi thay đổi.
Container đang chạy
┌─────────────────────────────────────────┐
│ Writable layer (của riêng container) │ ← ghi vào đây
├─────────────────────────────────────────┤
│ Layer 4 (chỉ-đọc) CMD/ENV... │ ┐
│ Layer 3 (chỉ-đọc) copy code │ │ các layer
│ Layer 2 (chỉ-đọc) cài thư viện │ │ của image
│ Layer 1 (chỉ-đọc) base OS (alpine...) │ ┘ (dùng chung)
└─────────────────────────────────────────┘
union mount → tiến trình thấy 1 cây file liền mạch
Tự xem các layer của một image:
docker pull nginx:alpine
docker image inspect nginx:alpine --format '{{range .RootFS.Layers}}{{println .}}{{end}}'
Mỗi dòng sha256:... là một layer chỉ-đọc. Để biết lệnh nào tạo ra layer nào và nó nặng bao nhiêu:
docker history nginx:alpine
CREATED BY SIZE
RUN /bin/sh -c set -x && apkArch="$(cat … 48.3MB
ENV ACME_VERSION=0.4.1 0B
CMD ["nginx" "-g" "daemon off;"] 0B
EXPOSE map[80/tcp:{}] 0B
Để ý: lệnh RUN cài phần mềm tạo ra layer nặng (48.3MB), còn ENV, CMD, EXPOSE chỉ là metadata nên 0B. Hiểu điều này rất quan trọng cho Bài 5 (build cache) và Bài 9 (tối ưu image).
Vì sao cách này tiết kiệm
Các layer chỉ-đọc được dùng chung giữa nhiều container và nhiều image. Nếu bạn chạy 10 container từ cùng một image, chúng dùng chung các layer chỉ-đọc đó; mỗi container chỉ tốn thêm một writable layer mỏng cho riêng nó. Đó là lý do container khởi động nhanh và nhẹ đĩa — không phải copy cả image cho mỗi container.
Copy-on-write: khi container ghi file
Layer image là chỉ-đọc, vậy container sửa file trong đó kiểu gì? Bằng copy-on-write (CoW). Theo tài liệu, khi container cần sửa một file đang nằm ở layer chỉ-đọc bên dưới, storage driver "tìm file trong các layer image" rồi "thực hiện thao tác copy_up, sao chép file lên writable layer của container". Từ đó container sửa trên bản sao ở writable layer; layer gốc không đổi.
Ghi vào /etc/nginx/nginx.conf:
┌────────────────────────────┐
│ Writable layer [bản sao] ◄┼── sửa ở đây
├────────────────────────────┤
│ Layer image [bản gốc] │ (giữ nguyên, chỉ-đọc)
└────────────────────────────┘
Hệ quả quan trọng: mọi thay đổi trong container nằm ở writable layer, và writable layer biến mất khi container bị xóa. Dữ liệu bạn muốn giữ lại (database, file upload) không thể để trong container — đó là lý do có volume, chủ đề Bài 6.
Ghép lại: container là gì
Gộp ba mảnh, định nghĩa "container" trở nên cụ thể: đó là một (hoặc vài) tiến trình Linux được nhân cho namespaces riêng (nên nó tưởng mình có hệ thống riêng), bị cgroups giới hạn tài nguyên (nên không phá máy), và chạy trên một union filesystem xếp từ các layer image chỉ-đọc cộng một writable layer (nên nhẹ và nhanh).
Không có hệ điều hành khách, không có ảo hóa phần cứng. Chỉ là nhân Linux của host, được dùng khéo. Đó là toàn bộ "bí mật" của container.
Tổng kết
- Namespaces cô lập tầm nhìn (PID, NET, MNT, UTS, IPC, USER) — kiểm chứng bằng
/proc/self/ns/vàpstrong container. - Cgroups giới hạn tài nguyên — kiểm chứng bằng
--memoryvà/sys/fs/cgroup/memory.max. - Union filesystem xếp các layer chỉ-đọc + một writable layer, dùng copy-on-write — xem bằng
docker historyvàdocker image inspect.
Hai bài đầu này cho bạn nền để hiểu mọi thứ về sau. Từ Bài 3, ta xắn tay thực hành: cài Docker (nếu chưa) và chạy, quản lý vòng đời container đầu tiên một cách bài bản.