Hầu hết lập trình viên backend chạy ứng dụng của mình trong container mỗi ngày mà không cần biết bên trong đó có gì, và điều đó phần lớn là ổn. Nhưng khi một sự cố xảy ra, một tiến trình bị giết không rõ lý do, một cổng không nối được, một giới hạn tài nguyên siết nhầm, thì cái "phần lớn là ổn" ấy sụp xuống, và câu hỏi đầu tiên luôn là: container thật ra là cái gì. Sê-ri này trả lời bằng cách đo, không bằng cách tin. Phần đầu tiên bắt đầu từ nền tảng: namespace, cơ chế Linux làm cho một tiến trình trong container nhìn thấy một thế giới khác với tiến trình ngoài nó, dù cả hai chạy trên cùng một kernel. Chúng ta sẽ dựng hai container thật và đo xem chúng bị cô lập ở đâu, chia sẻ cái gì, và vì sao điều đó quan trọng với bảo mật.

Namespace là gì và cách kiểm chứng

Ảnh chụp đoạn mã nền tối namespace vì sao tiến trình trong container thấy một thế giới khác với Docker 29 2 container tạm debian 12 đo bằng proc readlink proc PID ns docker inspect ipcs, namespace là gì cô lập cái mà tiến trình nhìn thấy container không phải máy ảo chung một kernel chung CPU chỉ là một nhóm tiến trình bị bịt mắt theo 6 trục, PID mỗi container có bảng PID riêng tiến trình chính là PID 1 không thấy PID của container khác, MNT cây thư mục riêng rootfs là overlay không thấy mount của host, NET card mạng bảng định tuyến cổng riêng phần 2, UTS hostname riêng IPC shared memory queue riêng USER ánh xạ uid Docker mặc định không bật, mỗi namespace là một inode trong kernel readlink proc PID ns cho ra ns số inode, cách đo dựng 2 container debian 12 chạy sleep hostname khác nhau app-container other-container, 1 PID ps trong container vs docker inspect State.Pid cùng tiến trình so hai con số, 2 proc ls proc đếm số tiến trình mỗi container nhìn thấy, 3 UTS hostname mỗi container, 4 inode ns readlink proc 1 ns pid net mnt uts ipc user khác nhau là cô lập giống là chung, 5 exec join docker exec tiến trình mới so inode ns với PID 1 của chính container, 6 IPC ipcmk tạo shared memory ở container 1 ipcs đếm ở cả hai, vì sao so inode namespace chứ không chỉ nói cô lập namespace là một đối tượng kernel có số inode hai tiến trình cùng inode là cùng namespace là thấy cùng thứ khác inode pid net mnt uts ipc là bị cô lập trục đó cùng inode user là không cô lập user Docker mặc định đây là cách kiểm chứng được không phải tin lời docker exec có chui vào đúng namespace của container không

Hình 1: Sáu trục namespace, cách đo bằng hai container, và lý do so inode namespace thay vì chỉ nói "cô lập".

Một 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 kernel riêng; container thì dùng chung đúng kernel của host, chung CPU, chung bộ nhớ vật lý, và chỉ là một nhóm tiến trình bị kernel "bịt mắt" theo một số trục. Cơ chế bịt mắt đó là namespace, và Linux có sáu loại chính. PID cho mỗi container một bảng số tiến trình riêng, nên tiến trình chính của container là PID 1 và nó không thấy tiến trình của container khác. MNT cho cây thư mục riêng, với rootfs là một lớp overlay chứ không phải đĩa. NET cho card mạng, bảng định tuyến và cổng riêng, chủ đề của phần 2. UTS cho hostname riêng. IPC cho shared memory và message queue riêng. USER ánh xạ uid, nhưng Docker mặc định không bật nó, một điểm sẽ trở lại ở cuối bài.

Điều làm namespace kiểm chứng được là mỗi namespace là một đối tượng kernel có số inode. Đọc liên kết /proc/PID/ns/<loại> cho ra một chuỗi dạng ns:[số inode], và quy tắc rất đơn giản: hai tiến trình có cùng số inode cho một loại namespace thì ở cùng namespace đó, tức thấy cùng một thứ; khác inode thì bị cô lập ở trục đó. Vì vậy thay vì nói chung chung "container được cô lập", ta đo được chính xác nó cô lập ở đâu. Cách đo: dựng hai container debian:12 chạy sleep với hostname khác nhau, app-container và other-container. Sau đó so PID của tiến trình nhìn từ trong container với PID nhìn từ host qua docker inspect; đếm số tiến trình mỗi container thấy trong /proc; đọc inode của sáu namespace ở cả hai container; kiểm docker exec có chui vào đúng namespace của container không; và tạo một shared memory ở container này để xem container kia có thấy không.

Đo thật: một tiến trình, hai PID, năm trục cô lập

Ảnh chụp output thật nền tối sleep là PID 1 bên trong 90064 bên ngoài với 2 container debian 12 cùng kernel PID host từ docker inspect inode ns từ readlink, 1 tới 3 PID proc và hostname cùng tiến trình sleep trong container PID 1 host VM PID 90064 một process hai con số, proc thấy gì ns-lab 3 tiến trình ns-lab2 4 không ai thấy tiến trình của container kia, hostname UTS ns-lab bằng app-container ns-lab2 bằng other-container mỗi container một tên, 4 tới 5 inode namespace cô lập 5 trục dùng chung USER loại ns-lab ns-lab2 giống khác, pid 4026533240 4026533376 khác bảng PID riêng, net 4026533242 4026533378 khác mạng riêng, mnt 4026533237 4026533373 khác cây thư mục riêng, uts 4026533238 4026533374 khác hostname riêng, ipc 4026533239 4026533375 khác shared memory riêng, user 4026531837 4026531837 giống chung user ns root trong container bằng uid 0 thật, docker exec vào ns-lab proc self ns pid bằng 4026533240 y hệt PID 1 exec chui vào namespace container, 6 tới 7 IPC cô lập 8 host nhìn tất cả là một bảng PID ns-lab tạo 1 shared memory segment ipcmk M ns-lab thấy 1 ns-lab2 thấy 0 IPC cô lập, host VM docker inspect State.Pid ns-lab 90064 ns-lab2 90109 cùng một bảng PID của kernel chung, rootfs mount gạch chéo là overlay lowerdir image cộng upperdir ghi không phải đĩa riêng là lớp phủ phần 6, nói thẳng container là tiến trình Linux bị bịt mắt 5 trục không phải máy ảo nhẹ vì chung kernel lưu ý bảo mật Docker mặc định không bật user namespace nên root trong container là uid 0 thật trên host phần 7

Hình 2: Cùng một tiến trình có hai PID, năm namespace cô lập và một dùng chung, IPC cô lập, và host nhìn mọi container trong một bảng PID.

phép đo kết quả
PID của sleep: trong container / trên host 1 / 90064, cùng một tiến trình
số tiến trình /proc mỗi container thấy 3 và 4, không thấy của nhau
hostname (UTS) app-container / other-container
inode pid, net, mnt, uts, ipc giữa hai container khác nhau cả năm
inode user namespace 4026531837 ở cả hai, dùng chung
docker exec pid-ns so với PID 1 cùng 4026533240, exec chui vào
shared memory tạo ở container 1 container 1 thấy 1, container 2 thấy 0

Cùng một tiến trình mang hai số PID, và đó là bản chất của PID namespace. Lệnh sleep chạy trong container là PID 1 khi nhìn từ bên trong, nhưng docker inspect cho biết nó là PID 90064 trong bảng PID của host, tức của máy ảo Linux mà Docker Desktop chạy. Không có hai tiến trình; chỉ có một, với hai cái tên số tuỳ theo ai nhìn. PID 1 bên trong là một con số đặc biệt với hệ quả riêng, chủ đề của phần 10 về zombie và tín hiệu. Và vì mỗi container có bảng PID riêng, /proc trong container chỉ liệt kê tiến trình của chính nó, 3 và 4 tiến trình trong hai container, không container nào thấy tiến trình của container kia; một chương trình giám sát chạy trong container không thể quét tiến trình của container khác hay của host qua /proc, vì /proc nó đọc cũng đã bị namespace lọc.

Năm namespace cô lập, đo bằng năm cặp inode khác nhau. Đọc /proc/1/ns/ ở hai container cho năm cặp số rõ ràng: pid 4026533240 so với 4026533376, net 4026533242 so với 4026533378, mnt, uts, ipc đều khác. Mỗi cặp khác nhau là một bằng chứng cô lập cụ thể: bảng PID riêng, ngăn mạng riêng, cây thư mục riêng, hostname riêng, vùng IPC riêng. Hostname xác nhận trục UTS một cách trực quan: một container tự gọi mình là app-container, container kia là other-container, dù đổi hostname trong một container không ảnh hưởng gì tới container kia hay host. Đây là lý do một container có thể nghĩ nó là web-01 trong khi host tên khác hẳn.

Nhưng user namespace dùng chung, và đó là điều quan trọng nhất về bảo mật. Trong khi năm namespace kia khác nhau, inode user namespace của hai container giống hệt: 4026531837, và đó cũng chính là user namespace của host. Nghĩa là Docker, với cấu hình mặc định, không bật user namespace. Hệ quả trực tiếp: uid 0, tức root, bên trong container là uid 0 thật trên host. Một tiến trình chạy root trong container, nếu thoát được ra ngoài qua một lỗ hổng kernel hay một mount sai, là root trên máy chủ, không phải một user bị giới hạn. Phần 7 về capabilities sẽ đo chính xác root trong container làm được gì và bị chặn gì; ở đây chỉ cần ghi nhận rằng "cô lập" của container không bao gồm cô lập danh tính user theo mặc định, và đây là khác biệt lớn so với trực giác "container như một máy riêng".

docker exec chui vào đúng namespace của container, kiểm chứng được bằng inode. Khi chạy docker exec để mở một shell trong container đang chạy, tiến trình mới đó có /proc/self/ns/pid bằng đúng 4026533240, y hệt inode của PID 1 trong container. Đây không phải điều hiển nhiên: docker exec là một tiến trình mới do daemon tạo, nhưng nó được đặt vào đúng các namespace của container đích, nên nó thấy đúng cây tiến trình, đúng mạng, đúng filesystem mà ứng dụng trong container thấy. Đó là lý do một lệnh gỡ lỗi chạy qua docker exec quan sát được đúng môi trường của ứng dụng, chứ không phải môi trường của host.

IPC cô lập, và host nhìn tất cả trong một bảng PID chung. Tạo một shared memory segment bằng ipcmk trong container thứ nhất: ipcs trong chính nó thấy một segment, còn container thứ hai thấy 0; vùng IPC của chúng tách biệt, nên hai ứng dụng dùng shared memory hay POSIX message queue trong hai container không đụng nhau. Ở chiều ngược lại, từ phía host, docker inspect cho thấy hai container là PID 90064 và 90109 trong cùng một bảng PID của kernel chung; host thấy mọi tiến trình của mọi container, vì chúng chỉ là tiến trình thường trên kernel đó. Và rootfs của container là một mount kiểu overlay, lớp image chỉ đọc chồng với lớp ghi riêng, không phải một đĩa độc lập, chi tiết mà phần 6 sẽ đo về dung lượng và chi phí ghi.

Đánh đổi cần cân nhắc

Nhẹ vì chung kernel, nhưng cô lập không bằng máy ảo. Lợi thế của container so với máy ảo đến thẳng từ việc không có kernel riêng: khởi động mili giây thay vì giây, chi phí bộ nhớ gần như chỉ là tiến trình, mật độ cao. Giá phải trả là ranh giới cô lập mỏng hơn: một lỗ hổng kernel ảnh hưởng mọi container trên host, và như đo được, danh tính user không cô lập mặc định. Với tải cần cô lập mạnh, ví dụ chạy mã không tin cậy của nhiều khách hàng, container thường được đặt thêm trong một lớp máy ảo nhẹ như Firecracker hay gVisor; cho dịch vụ nội bộ của cùng một tổ chức thì namespace là đủ.

Bật user namespace nếu chạy mã không tin cậy, nhưng cân nhắc chi phí vận hành. Docker hỗ trợ user namespace qua userns-remap, ánh xạ root trong container sang một uid không đặc quyền trên host, đóng phần lớn khoảng trống mà bài này đo. Lý do nó không bật mặc định là nó làm phức tạp việc chia sẻ volume và một số image giả định root thật; vì vậy đây là một quyết định cân nhắc, không phải bật vô điều kiện. Biết rằng mặc định là không bật đã là bước đầu để không ngạc nhiên.

Phạm vi đo. Các số PID và inode ở đây là của một máy ảo Linux trong Docker Desktop trên máy Mac; trên host Linux trực tiếp, "host" là chính máy thật và các con số sẽ khác, nhưng mọi quan hệ, một tiến trình hai PID, năm trục cô lập, user ns chung, giữ nguyên. Chưa đo network namespace chi tiết, phần 2; chưa đo cgroups, cơ chế khác hẳn namespace vì cgroups giới hạn tài nguyên tiến trình được dùng chứ không phải thứ nó thấy, chủ đề của phần 3 đến 5.

Ba ý mang về

  1. Container là tiến trình Linux bị bịt mắt theo trục, không phải máy ảo. Cùng một sleep là PID 1 bên trong và 90064 trên host; nhẹ vì chung kernel, nhưng cũng vì thế ranh giới cô lập mỏng hơn máy ảo.
  2. Cô lập đo được bằng inode namespace. Năm trục pid, net, mnt, uts, ipc có inode khác nhau giữa hai container là bằng chứng cô lập; docker exec chia sẻ inode với PID 1 là bằng chứng nó chui vào đúng namespace container.
  3. User namespace dùng chung theo mặc định. Root trong container là uid 0 thật trên host, nên "container như một máy riêng" là trực giác sai về bảo mật; bật userns-remap nếu chạy mã không tin cậy.

Nguồn

Phần sau đi sâu vào trục NET: network namespace cho container một ngăn mạng riêng với card ảo, bảng định tuyến và cổng tách biệt. Đo gói đi qua cặp veth và bridge docker0, so địa chỉ IP nhìn từ trong và ngoài, và dựng một network namespace bằng tay để thấy Docker thật ra làm gì.