Có hai cách giới hạn tốc độ một chiếc xe. Cách một: lắp bộ hạn tốc vào động cơ — đặt 64 thì máy không thể vượt qua. Cách hai: sơn một cái biển "64" bên đường — bạn viết con số lên đó, nhưng chẳng có gì cản chiếc xe, và bảng đồng hồ không hề báo rằng bộ hạn tốc đã tháo. Docker rootful là xe có bộ hạn tốc; Docker rootless (dựng thiếu cgroup) là chiếc xe chỉ có cái biển vẽ — bạn khai --memory 64m, lệnh chạy ngon lành, mà giới hạn bị vứt đi không một lời. Rootless cho phép chạy cả daemon bằng người dùng thường, và người ta thường nói nó chậm hơn — mạng đi qua không gian người dùng, lưu trữ phải dùng cơ chế khác. Tôi đo, và chênh lệch hiệu năng gần bằng 0; chỗ mất mát nằm ở chỗ khác hẳn.

Tôi dựng hai daemon song song để đo: docker:27-dind (rootful) và docker:27-dind-rootless, cùng phiên bản 27.5.1, cùng storage driver overlay2. Trong bản rootless, tiến trình dockerd chạy dưới người dùng rootless thông qua rootlesskit.

Hiệu năng: gần như không chênh

Khởi động container — 20 lần docker run --rm alpine true:

Thời gian Chi phí docker exec (đối chứng)
rootful 236 ms/lần 37 ms
rootless 219 ms/lần 47 ms

Rootless nhanh hơn một chút. Chênh lệch nằm trong dao động giữa các lần chạy — kết luận đúng là "không khác nhau", không phải "rootless thắng".

Build — cùng một Dockerfile không cần mạng (ghi 128 MB, tạo 2.000 tệp, tar, nén 32 MB ngẫu nhiên), --no-cache, dọn sạch cache builder trước mỗi lần:

Ba lần chạy Image ra
rootful 1,9 / 1,9 / 1,9 s 44,5 MB
rootless 1,9 / 1,8 / 1,9 s 44,5 MB

Thông lượng qua cổng được publish — kéo 512 MB qua -p 8099:8099, ba lần:

MB/s
rootful 320 / 328 / 326
rootless 318 / 337 / 339

Đây là phép đo tôi mong thấy chênh lệch nhất, vì rootless chuyển tiếp cổng bằng RootlessKit ở không gian người dùng thay vì bằng iptables của nhân. Kết quả: không đo được chênh lệch nào.

Một lưu ý về phạm vi: cả hai daemon đều chạy lồng trong container trên máy tôi, và bản rootless dùng driver mạng vpnkit. Trên máy chủ Linux thật, rootless thường dùng slirp4netns hoặc pasta, con số có thể khác. Điều tôi đo được là: với đường publish cổng thông thường, rootless không phải nút thắt.

Chỗ thật sự mất mát: cgroup

Cgroup Driver: none
Cgroup Version: 2

Rootless không tự cầm được cgroup. Không có systemd đứng ra uỷ quyền, daemon chạy không có cgroup nào cả. Hậu quả:

docker run --memory 64m --tmpfs /d alpine \
  dd if=/dev/zero of=/d/f bs=1M count=200
OOMKilled Kết quả
rootful true bị giết
rootless false 209715200 bytes copied, 0.085 s — ghi trọn 200 MB

Đọc thẳng giới hạn từ trong container:

/sys/fs/cgroup/memory.max
rootful 67108864 (đúng 64 MB)
rootless max

Và đây mới là phần tệ nhất: docker run không in ra cảnh báo nào. Tôi truyền --memory 64m, lệnh chạy bình thường, không có dòng nào nói rằng giới hạn đã bị vứt bỏ.

Cảnh báo có tồn tại, nhưng nằm trong log của daemon, ghi đúng một lần lúc khởi động:

WARNING: Running in rootless-mode without cgroups.
Systemd is required to enable cgroups in rootless-mode.

Nếu bạn dựng rootless bằng gói systemd chính thức (dockerd-rootless-setuptool.sh) và delegation được bật, cgroup hoạt động. Nếu bạn dựng bằng tay hoặc trong container như tôi, mọi cờ --memory, --cpus, --pids-limit thành lời nói suông.

Đây là kịch bản xấu điển hình: cùng một docker-compose.yml khai mem_limit, chạy trên máy rootful thì giới hạn có hiệu lực, chạy trên máy rootless thì không — và không có gì trong đầu ra cho bạn biết bạn đang ở trường hợp nào. Kiểm tra bằng một lệnh:

docker run --rm --memory 64m alpine cat /sys/fs/cgroup/memory.max

Ra max là giới hạn không hoạt động.

Bên trong container thì không khác gì

Điều này hay bị hiểu nhầm: rootless không làm container bên trong yếu đi.

CapEff của container
rootful 00000000a80425fb
rootless 00000000a80425fb

Giống hệt — vẫn đủ 14 capability như phần 31 đã đếm. ping chạy được, publish cổng 80 được, ở cả hai bên.

Cái rootless bảo vệ là máy chủ, không phải bên trong container. Nếu ai đó thoát ra khỏi container, ở bản rootful họ thành root của máy; ở bản rootless họ thành một người dùng thường không có quyền gì đặc biệt. Đó là toàn bộ giá trị của nó, và nó rất đáng.

Nó cũng khác hẳn với việc chạy tiến trình bằng người dùng không phải root bên trong container (phần 12) — hai lớp độc lập, nên làm cả hai.

Ba lần tôi đo sai trong bài này

Build rootless xong trong 0,2 giây. Nghe như tin vui, thật ra là mkdir /b bị từ chối vì uid 1000 không ghi được vào /, và build thất bại ngay ở bước đọc context. Quy tắc cũ: một con số tốt bất thường là dấu hiệu phép đo hỏng, không phải dấu hiệu thắng lợi. Tôi thêm bước kiểm tra đầu ra phải bắt đầu bằng sha256: mới tính là một lần chạy hợp lệ.

Build lần hai thất bại ở apk add. Lỗi là DNS lookup error. Suýt nữa tôi viết "rootless làm hỏng DNS lúc build" — nhưng đó là hạn chế của việc chạy daemon lồng trong daemon, không phải của rootless. Tôi bỏ mạng ra khỏi Dockerfile đo hiệu năng, và không kết luận gì về DNS.

Đo thông lượng ra 0 mili giây. date +%s%N của busybox không hỗ trợ nano giây, nó trả về chuỗi có chữ N và phép chia thành 0. Chuyển sang bấm giờ ở phía ngoài là xong.

Muốn tự kiểm trên máy Linux của mình, dựng rootless bằng công cụ chính thức rồi soi cgroup và giới hạn thật:

dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker info --format 'Cgroup Driver: {{.CgroupDriver}}'
docker run --rm --memory 64m alpine cat /sys/fs/cgroup/memory.max

Hai dòng cuối là bài kiểm tra quan trọng nhất. CgroupDriver ra none, hoặc memory.max ra max, nghĩa là mọi giới hạn tài nguyên bạn khai đều đang bị bỏ qua — sửa bằng cách bật cgroup delegation của systemd trước khi đưa vào dùng thật.

Mẫu số chung

Bài học đầu tiên, và là chỗ ai cũng đoán sai về rootless: một lựa chọn gia cố vẽ một đường ranh cụ thể — hãy biết chính xác nó bảo vệ cái gì, không bảo vệ cái gì, và cái giá thật rơi vào đâu. Người ta tưởng rootless làm container "chặt chẽ hơn bên trong" (sai — capability y hệt) hoặc "chậm hơn" (sai — chênh gần 0); ranh giới thật của nó là bảo vệ máy chủ (thoát ra thì chỉ thành người dùng thường, không phải root), còn cái mất là khả năng ép giới hạn tài nguyên. Cùng chuyện "biết đúng đường ranh của một lớp bảo vệ" ở khắp nơi: một sandbox cô lập tiến trình nhưng không cô lập mạng, một hệ tệp chỉ-đọc không chặn ghi bộ nhớ, TLS bảo vệ đường truyền chứ không bảo vệ lúc lưu. Nguyên tắc: với mỗi biện pháp an toàn, hỏi rõ nó vẽ ranh ở đâu — đừng cho rằng "an toàn hơn" là an toàn ở mọi mặt, và tìm xem cái giá của nó thật ra nằm ở mặt nào.

Điều thứ hai, chính là cái biển tốc độ vẽ: một giới hạn bạn khai chỉ có thật khi có thứ ép nó — thiếu bộ máy cưỡng chế thì cái cờ chỉ là lời đề nghị, và tệ nhất là nó hỏng trong im lặng và tuỳ môi trường. Cùng một mem_limit trong compose, chạy máy rootful thì có hiệu lực, máy rootless thì bị bỏ, không dòng đầu ra nào cho bạn biết mình đang ở phía nào. Cùng cái bẫy "khai mà không ai cưỡng chế" ở khắp nơi: ulimit không được áp, một ràng buộc CSDL khai sau khi bảng đã có dữ liệu vi phạm, một rate-limit cấu hình nhưng chưa nối vào đường xử lý, một quota chỉ mang tính khuyến cáo. Nguyên tắc: đừng tin rằng đặt một giới hạn là nó đang có hiệu lực — đọc lại trạng thái thật để xác nhận (memory.max ra max là biển vẽ, không phải bộ hạn tốc), nhất là khi cùng một cấu hình có thể được cưỡng chế ở môi trường này mà bị làm ngơ ở môi trường khác.

Phần sau bắt đầu nhóm bài về mạng, mở đầu bằng việc localhost trong container trỏ đi đâu.