Phần 30 bật --read-only lên bốn image quen thuộc. Phần này đi tiếp hướng "cắt bớt sức mạnh mặc định của container", bằng hai cơ chế khác hẳn: capabilities (bớt quyền theo từng thao tác — chown, mount, bind cổng...) và seccomp (bớt quyền theo từng lời gọi hệ thống). Hai cái độc lập với nhau, và cách chúng độc lập chính là thứ hay bị hiểu sai nhất.
Tất cả lệnh dưới đây chạy thật trên alpine:3.20, container mặc định (không --privileged, không tuỳ chỉnh gì khác ngoài cờ được ghi rõ).
Mặc định có 14 capability, và --cap-drop=ALL phá được gì
$ docker run --rm alpine:3.20 sh -c 'apk add -q libcap; capsh --print' | grep Current
Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,
cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,
cap_mknod,cap_audit_write,cap_setfcap=ep
Đúng 14 trên hơn 40 capability của kernel — Docker đã tự bớt phần lớn từ trước khi bạn động vào gì cả. Drop hết rồi thử một thao tác cần CAP_CHOWN:
$ docker run --rm --cap-drop=ALL alpine:3.20 sh -c 'touch /tmp/f; chown 1000:1000 /tmp/f'
chown: /tmp/f: Operation not permitted
Đúng như tài liệu nói. Nhưng thử tiếp ping — thứ mà mọi bài viết về capability đều dẫn ra làm ví dụ kinh điển cho CAP_NET_RAW:
$ docker run --rm --cap-drop=ALL alpine:3.20 sh -c 'apk add -q iputils; ping -c1 127.0.0.1'
64 bytes from 127.0.0.1: seq=0 ttl=64 time=0.026 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss
Chạy được. Không phải vì CAP_NET_RAW bằng cách nào đó vẫn còn — capsh --print xác nhận nó đã mất. Lý do nằm ở một sysctl khác, chẳng liên quan gì tới capability:
$ docker run --rm alpine:3.20 cat /proc/sys/net/ipv4/ping_group_range
0 2147483647
ping_group_range cho phép mọi group ID dùng socket ICMP kiểu SOCK_DGRAM — cách ping hiện đại tạo gói tin mà không cần raw socket, nên không cần CAP_NET_RAW. Trên máy chạy Docker này, khoảng cho phép là "mọi group", nên ping sống sót qua --cap-drop=ALL. Đây là cấu hình của máy tôi đang đo, không phải luật chung — nếu ping_group_range trên máy bạn hẹp hơn (nhiều bản phân phối Linux đặt 1 0, tức là tắt hẳn), kết quả sẽ khác. Muốn biết chắc thì đo trên đúng máy sẽ chạy, đừng tin lại con số này.
--cap-add gần như vô dụng nếu container chạy non-root
Đây là phần khiến tôi mất nhiều thời gian nhất, vì kết quả ngược hẳn với những gì tôi tưởng. Ép sysctl port về giá trị cổ điển (1024) rồi thử bind cổng 80 bằng một tiến trình non-root có đủ capability cần thiết:
$ docker run --rm --user 1000 --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--sysctl net.ipv4.ip_unprivileged_port_start=1024 captest \
python3 -c 'import socket; s=socket.socket(); s.bind(("0.0.0.0",80)); print("OK")'
PermissionError: [Errno 13] Permission denied
--cap-add=NET_BIND_SERVICE có mặt, nhưng bind vẫn bị từ chối. Kiểm tra capability thật sự có hiệu lực trên tiến trình đó:
$ docker run --rm --user 1000 --cap-drop=ALL --cap-add=NET_BIND_SERVICE captest \
sh -c 'capsh --print' | grep -E 'Current|Ambient'
Current: =
Ambient set =
Rỗng cả hai. --cap-add chỉ nâng bounding set — trần quyền tối đa container được phép có — chứ không tự động cấp nó cho tiến trình. Với tiến trình chạy bằng UID 0, chuyện đó không thành vấn đề vì root luôn có toàn bộ capability trong bounding set ở effective set. Nhưng khi execve chạy với UID khác 0, kernel chỉ giữ lại capability nằm trong ambient set — và Docker không tự đưa capability vừa --cap-add vào đó cho một user tuỳ ý. Cách duy nhất để một tiến trình non-root thật sự dùng được capability là gắn nó vào chính file binary bằng setcap:
$ docker build -t captest-setcap - <<'EOF'
FROM alpine:3.20
RUN apk add -q --no-cache python3 libcap
RUN setcap cap_net_bind_service=+ep $(readlink -f $(which python3))
EOF
$ docker run --rm --user 1000 --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--sysctl net.ipv4.ip_unprivileged_port_start=1024 captest-setcap \
python3 -c 'import socket; s=socket.socket(); s.bind(("0.0.0.0",80)); print("OK")'
OK
Kết luận thực dụng: nếu image của bạn đã hạ xuống non-root và bạn thêm --cap-add cho nó vì tưởng thế là đủ, hãy kiểm tra capsh --print bên trong container thật — rất có thể ambient set vẫn rỗng và cờ đó chẳng có tác dụng gì.
seccomp: cổng lọc thứ hai, không quan tâm bạn có capability gì
Sơ đồ ở đầu bài vẽ đúng thứ tự: seccomp chặn ở điểm vào syscall, trước khi kernel kịp xử lý gì. Một ví dụ mà capability hoàn toàn không liên quan — tạo user namespace mới không cần capability nào cả, đó vốn là mục đích thiết kế của user namespace (để tiến trình không đặc quyền vẫn dùng được):
$ docker run --rm alpine:3.20 sh -c 'apk add -q util-linux; unshare --user --map-root-user id'
unshare: unshare failed: Operation not permitted
$ docker run --rm --security-opt seccomp=unconfined alpine:3.20 \
sh -c 'apk add -q util-linux; unshare --user --map-root-user id'
uid=0(root) gid=0(root) groups=0(root),65534(nobody)
Không thêm capability nào ở lệnh thứ hai — chỉ tắt seccomp. Hồ sơ mặc định của Docker chặn unshare/clone với cờ CLONE_NEWUSER để giảm đường thoát qua nested user namespace, và việc đó không đi qua capability check nào hết.
Tự dựng một hồ sơ seccomp tối thiểu — và vì sao tôi dừng lại
Ý tưởng: strace một ứng dụng thật (Python http.server, phục vụ đúng một request), lấy danh sách syscall nó thật sự gọi, rồi viết hồ sơ seccomp chỉ cho phép đúng chừng đó.
$ strace -f -o trace.log python3 -m http.server 8000 & sleep 1
$ wget -qO- http://127.0.0.1:8000/ >/dev/null
$ grep -oE '^[0-9]+ +[a-z_0-9]+\(' trace.log | sed -E 's/^[0-9]+ +//; s/\(//' | sort -u | wc -l
43
43 syscall duy nhất — accept4, bind, epoll_create1, openat, read, write... đủ để phục vụ một request tĩnh. Viết hồ sơ defaultAction: SCMP_ACT_ERRNO chỉ allow đúng 43 cái đó, chạy thử:
$ docker run --rm --security-opt seccomp=profile-43.json alpine:3.20 echo hi
docker: Error response from daemon: ... error closing exec fds:
get handle to /proc/thread-self/fd: fstatfs fsmount:fscontext:proc: operation not permitted
Container còn chưa kịp chạy echo — runc tự nó cần vài syscall để dựng xong tiến trình init (đóng file descriptor thừa, đọc /proc/thread-self...) trước khi đến lượt ứng dụng của bạn. strace trên ứng dụng không bao giờ thấy được phần này, vì nó chạy trước execve vào tiến trình bạn theo dõi. Tôi thêm dần theo từng lỗi mới hiện ra: trước là fstatfs, statx; rồi tới capget, capset, prctl, setgroups, setgid, setuid, chdir; rồi một loạt liên quan tới file descriptor và thoát tiến trình. Sau bốn vòng, danh sách lên 71 syscall — và container vẫn không khởi động nổi, mỗi lần một lỗi mới. Tôi dừng ở đây.
Bài học không phải "seccomp khó", mà là: strace ứng dụng chỉ cho bạn syscall của ứng dụng, không cho syscall của runtime dựng ra nó. Cách đúng để thu hẹp hồ sơ seccomp là lấy hồ sơ mặc định của Docker rồi bớt đi từng syscall một và đo lại — không phải dựng từ tay trắng. Muốn tự động thì có oci-seccomp-bpf-hook, công cụ theo dõi toàn bộ vòng đời container (kể cả phần khởi tạo của runc) rồi tự sinh hồ sơ — đúng việc tôi vừa làm thủ công và thất bại.
Tổng kết: cái gì còn chạy được
| Cấu hình | ping | chown | bind cổng thấp (non-root) | unshare --user |
|---|---|---|---|---|
| Mặc định | ✅ | ✅ | tuỳ sysctl + ambient set | ❌ (seccomp chặn) |
--cap-drop=ALL |
✅ (tuỳ ping_group_range) |
❌ | ❌ | ❌ |
--cap-drop=ALL --cap-add=X, root |
✅ nếu X đúng | tuỳ X | ✅ nếu X đúng | ❌ |
--cap-drop=ALL --cap-add=X, non-root |
✅ nếu X đúng | tuỳ X | ❌ trừ khi setcap trên binary |
❌ |
--security-opt seccomp=unconfined |
✅ | tuỳ capability | tuỳ capability | ✅ |
Hai cột cuối bảng là chỗ hay bị nhầm nhất: --privileged mở cả hai cổng cùng lúc (cấp đủ capability và tắt seccomp), nên nếu bạn thêm cờ đó "cho chạy được" mà không xác định lỗi gốc từ đâu, bạn sẽ không bao giờ biết container thật sự cần quyền gì trong hai loại.
Thử ba mươi giây
Xem capability thật sự có hiệu lực trên một container đang chạy trên máy bạn — không phải cấu hình bạn nghĩ nó có:
docker exec <container> sh -c \
'grep CapEff /proc/1/status; grep CapBnd /proc/1/status' | \
awk '{print $1, $2}'
Hai giá trị hex khác nhau (CapEff so với CapBnd) nghĩa là bounding set rộng hơn effective set thật — đúng tình huống --cap-add "có mặt nhưng không có tác dụng" đã đo ở trên.