bài về cgroup ta thấy cách nhân giới hạn tài nguyên của một container. Nhưng còn một nửa kia của câu chuyện container: làm sao một tiến trình bên trong lại thấy một hệ thống riêng — PID riêng, hostname riêng, mạng riêng — như thể nó ở trên một máy độc lập? Cơ chế đó là namespace. Tôi đo trong container, và một tiến trình duy nhất hiện ra với hai số PID khác nhau tùy góc nhìn.

Namespace cô lập

Namespace là một view riêng của một tài nguyên

Một namespace cô lập một loại tài nguyên hệ thống, cho các tiến trình bên trong một view riêng của tài nguyên đó. Linux có nhiều loại namespace, mỗi loại cô lập một thứ:

  • PID: số hiệu tiến trình riêng — tiến trình đầu tiên trong namespace là PID 1, như init trên một máy mới.
  • mount: cây hệ thống file riêng (đây là cách container có rootfs riêng).
  • net: card mạng, địa chỉ IP, cổng riêng.
  • UTS: hostname riêng.
  • user: ánh xạ uid/gid riêng (root trong container có thể là user thường ngoài host).
  • IPC, cgroup, time: các tài nguyên khác.

Docker và Kubernetes dựng một container bằng cách đặt một tiến trình vào một bộ namespace mới cho mỗi loại, cộng với một trần tài nguyên qua cgroup (bài trước). Tôi tạo một namespace PID và UTS mới bằng clone, rồi so những gì tiến trình thấy bên trong với những gì host thấy bên ngoài.

Đo: một tiến trình, hai PID

NGOÀI (tiến trình cha, trong container):
  getpid() = 16 | hostname = '0b5e1063...'
  inode pid ns = 4026532549

TRONG namespace mới (clone với CLONE_NEWPID|CLONE_NEWUTS):
  getpid() = 1        <- PID 1, như init trên máy mới!
  hostname = 'container-ao'
  inode pid ns = 4026532683   <- KHÁC inode -> namespace khác

NGOÀI, cha nhìn con: PID = 17   <- cùng tiến trình, số PID khác
NGOÀI, hostname cha: '0b5e1063...'   <- KHÔNG đổi (UTS cách ly)

Đây là điểm cốt lõi. Tiến trình con, nhìn từ bên trong namespace của nó, có getpid() trả về 1 — nó tưởng mình là tiến trình đầu tiên trên một máy mới. Nhưng nhìn từ bên ngoài, chính tiến trình đó được cha thấy là PID 17. Một tiến trình, hai số PID — không phải hai tiến trình, mà là một tiến trình được đánh số khác nhau tùy bạn đứng ở namespace nào. Và inode của PID namespace (một mã định danh của nhân) khác nhau — 4026532549 ở ngoài, 4026532683 ở trong — chứng minh hai view thật sự là hai namespace tách biệt. Con cũng đổi hostname thành 'container-ao', nhưng hostname của cha không đổi: UTS namespace đã cô lập nó.

Một lần tôi đo hớ: "PID 1" không phải init thật

Cái làm tôi khựng lại là hai con số PID cho một tiến trình. Ban đầu nó nghe mâu thuẫn: một tiến trình sao có thể vừa là PID 1 vừa là PID 17? Nếu tôi tin PID là một con số tuyệt đối, duy nhất trên máy (như tôi vẫn nghĩ), thì đây là điều vô lý.

Lời giải hóa ra là: PID không tuyệt đối, nó tương đối theo namespace. Cùng một tiến trình có một PID trong mỗi namespace mà nó thuộc về. getpid() trả về số PID trong namespace của người gọi. Nên "PID 1" mà bạn thấy khi ps bên trong một container không phải là init thật của máy — nó chỉ là số mà tiến trình đó mang trong view riêng của container. Từ host, cùng tiến trình đó mang một số PID khác (và init thật của máy vẫn là PID 1 ở namespace gốc). Biến ẩn khiến hai con số trông mâu thuẫn chính là "nhìn từ namespace nào".

Điều này lật đổ một hiểu lầm phổ biến: container không phải là máy ảo. Máy ảo giả lập cả phần cứng và chạy một nhân riêng. Container thì không — nó là một tiến trình host bình thường, dùng chung nhân với host, chỉ được đặt trong một bộ view (namespace) khiến nó thấy một hệ thống riêng, cộng một trần tài nguyên (cgroup). Đó là lý do ps trên host thấy được các tiến trình của container (dưới số PID host của chúng), còn bên trong container chúng thấy PID của riêng mình. Bài học đo lường: nhiều "sự thật" của hệ điều hành là tương đối theo góc nhìn — một PID, một hostname, một danh sách tiến trình đều phụ thuộc namespace bạn đang đứng.

Mount namespace: vì sao container có "hệ thống file riêng"

PID namespace giải thích số tiến trình, nhưng cái làm container cảm giác như một máy riêng nhất là mount namespace. Nó cho tiến trình một cây thư mục (bảng mount) riêng, nên container có thể thấy một / hoàn toàn khác host. Docker dựng cái / đó từ image bằng overlayfs — xếp chồng các lớp chỉ-đọc của image với một lớp ghi mỏng ở trên — rồi pivot_root để tiến trình chỉ thấy cây file này. Chính vì vậy, môi trường tôi đo suốt sê-ri này có /tmp là overlayfs và /m là fuseblk: đó là mount namespace của container quyết định cây file, không phải đĩa thật của host. Điểm đáng nhớ: hostname (UTS), tiến trình (PID), file (mount), mạng (net) — mỗi thứ là một view do một namespace riêng dựng nên, và một container chỉ là một tiến trình được bọc trong đủ các view đó để trông như một máy. Gỡ từng lớp namespace ra, cái còn lại chỉ là một tiến trình Linux bình thường.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: hiểu container là process, không phải máy, giúp gỡ rối đúng chỗ. Khi một tiến trình trong container "biến mất" hay treo, bạn có thể quan sát nó từ host bằng số PID host của nó — ps, strace, /proc/<host-pid>/ trên host đều thấy được, vì đó chỉ là một tiến trình thường. Và khi PID 1 của container chết, cả container dừng (như init thật chết thì máy sập) — nên tiến trình chính của container phải là thứ bạn muốn dùng làm "init", và nó cần xử lý tín hiệu, thu hồi tiến trình con mồ côi (zombie) đúng cách.

Hệ quả thứ hai: user namespace là nền của "container không cần root". Ánh xạ uid cho phép một tiến trình là root bên trong namespace (uid 0) nhưng là một user thường bên ngoài — nên nó có toàn quyền trong view của mình mà không có quyền gì nguy hiểm trên host. Đây là cách các runtime như Podman chạy container không đặc quyền, và cũng là cách tôi có thể (nếu dùng unshare -Ur) tạo namespace mà không cần --privileged — dù trong phép đo trên, tạo PID namespace trực tiếp vẫn cần CAP_SYS_ADMIN, nếu không clone trả EPERM.

Hệ quả thứ ba là bài học tư duy: cô lập trong Linux là cô lập cái nhìn thấy, không phải cô lập vật lý. Con số mang theo: namespace cho mỗi tiến trình một view riêng của một tài nguyên — cùng một tiến trình là PID 1 nhìn từ namespace của nó và PID 17 nhìn từ host (inode namespace khác nhau chứng minh), và đổi hostname trong UTS namespace không đụng tới host; container vì thế không phải máy ảo mà là tiến trình host với view bị giới hạn cộng trần cgroup. Trước khi tin một con số như PID hay hostname trong container, hỏi: nhìn từ namespace nào?

Thử ba mươi giây

Xem các namespace của một tiến trình: ls -l /proc/<pid>/ns/ — mỗi liên kết như pid:[4026532549] là một namespace, và con số trong ngoặc là inode định danh nó; hai tiến trình có cùng inode nghĩa là chung namespace đó. Rồi so PID trong với ngoài: nếu bạn có Docker, chạy docker run --rm --name t -d alpine sleep 300, rồi docker exec t ps (thấy sleep là PID 1 bên trong) so với ps aux | grep 'sleep 300' trên host (thấy nó mang một PID host lớn hơn nhiều). Cùng một tiến trình sleep, hai số PID — chính là cái tương-đối-theo-namespace mà bài này đo, đang chạy dưới mọi container trên máy bạn.