Một người có thể là "số 1" ở nhà (con cả) và đồng thời là "nhân viên số 4017" ở công ty — cùng một con người, hai con số, không hề mâu thuẫn, vì mỗi con số chỉ có nghĩa trong hệ quy chiếu của nó. Tôi đã quên mất điều hiển nhiên đó khi lần đầu thấy một tiến trình Linux hiện ra vừa là PID 1 vừa là PID 17. Ở 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: 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.

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ó.

Hai con số PID nghe mâu thuẫn, cho tới khi tôi bỏ chữ "tuyệt đối"

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. Điều rút ra: 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Và cái ý "một định danh chỉ có nghĩa trong hệ quy chiếu của nó" này vọng ra khắp nghề lập trình, không riêng container. Trong JVM, identityHashCode hay địa chỉ object không phải hằng số tuyệt đối — GC nén bộ nhớ có thể dời object, nên đừng bao giờ tin một "địa chỉ" ổn định qua thời gian. Trong Go, một con trỏ trỏ vào stack có thể đổi khi goroutine bị dời stack; con số bạn in ra chỉ đúng ở thời điểm đó. Ngay cả CSDL: một ctid (vị trí vật lý của hàng) trong PostgreSQL đổi sau mỗi UPDATE vì MVCC ghi phiên bản mới ở chỗ khác — dùng nó như khóa bền vững là sai, phải dùng khóa chính. Mẫu số chung: trước khi tin một định danh là tuyệt đối, hỏi nó có nghĩa trong hệ quy chiếu nào. Gói lại bằng số: 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 4026532549 so với 4026532683 chứng minh là hai view tách biệt), 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.

Muốn tự thấy thì 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.