Container mặc định chạy bằng root. Tôi kiểm tra sáu image phổ biến:

alpine:3.20      UID 0 | Config.User = (rong = root)
debian:12-slim   UID 0 | Config.User = (rong = root)
python:3.12-slim UID 0 | Config.User = (rong = root)
nginx:alpine     UID 0 | Config.User = (rong = root)
postgres:16      UID 0 | Config.User = (rong = root)
node:22-alpine   UID 0 | Config.User = (rong = root)

Sáu trên sáu. Bài này đo xem root đó thật sự nguy hiểm tới đâu, và cái giá của việc bỏ nó.

Root trong container làm được gì

Việc Kết quả
Đọc /etc/shadow của container ✅ được
Cài gói mới ✅ được
Sửa /etc/hosts ✅ được
Gắn hệ thống tệp (mount) permission denied (are you root?)
Đổi giờ hệ thống Operation not permitted

Root trong container không phải root của máy chủ. Docker bỏ bớt phần lớn quyền hạn:

container thuong : CapEff: 00000000a80425fb
voi --privileged : CapEff: 000001ffffffffff

Container thường chỉ giữ 14 trong số hơn 40 quyền hạn. Nên "root trong container" nằm đâu đó giữa người dùng thường và root thật.

Nhưng nó vẫn đủ để: cài công cụ tấn công, đọc mọi tệp trong image, ghi đè mọi tệp cấu hình, và — điều nguy hiểm nhất — ghi vào bất kỳ volume nào được gắn vào, kể cả volume dùng chung với dịch vụ khác.

Chuyển sang non-root: cái gì hỏng

UID 10001:
  ghi vao /       : Permission denied
  ghi vao /tmp    : DUOC
  cai goi (apk)   : ERROR: Unable to lock database: Permission denied

Đúng như mong đợi. Nhưng có một thứ không hỏng, và nó trái với luật Unix mà ai cũng thuộc:

chay bang UID 10001:
  cong 80   : MO DUOC
  cong 443  : MO DUOC
  cong 1023 : MO DUOC

Luật cổ điển nói cổng dưới 1024 chỉ root mới mở được. Trong Docker thì không, và lý do nằm ở một tham số hạt nhân:

trong container   : net.ipv4.ip_unprivileged_port_start = 0
tren may chu Linux: net.ipv4.ip_unprivileged_port_start = 1024

Docker cố ý hạ ngưỡng này xuống 0 cho mọi container. Đặt lại thì hành vi cổ điển quay về:

$ docker run --user 10001 --sysctl net.ipv4.ip_unprivileged_port_start=1024 ...
cong 80   : TU CHOI (Permission denied)
cong 1024 : MO DUOC

Nghĩa là "phải chạy root để nghe cổng 80" không còn là lý do hợp lệ để giữ root trong Docker. Nếu image của bạn đang chạy root chỉ vì lý do đó, bạn bỏ được ngay hôm nay.

Vấn đề thật: quyền trên volume

Đây là chỗ chuyển sang non-root thường thất bại.

Tạo một volume, chạy container root một lần, rồi chạy lại bằng UID 10001:

lan dau (root)  : chu so huu /dl = 0:0 drwxr-xr-x | ghi DUOC
lan sau (10001) : chu so huu /dl = 0:0           | doc DUOC
                                                 | ghi: Permission denied

Đọc được vì thư mục có quyền đọc cho mọi người; ghi thì không. Ứng dụng của bạn khởi động, đọc cấu hình bình thường, rồi hỏng ở lần ghi đầu tiên — có thể là vài phút sau khi triển khai.

Một cảnh báo về môi trường đo: trên macOS, bind mount từ thư mục máy Mac không tái hiện lỗi này. Docker Desktop ánh xạ lại quyền sở hữu theo UID bạn đang chạy:

UID trong container: 10001
chu so huu thu muc : 10001:0
ghi tep moi        : DUOC

Nên nếu bạn phát triển trên Mac và triển khai lên Linux, lỗi này sẽ không bao giờ xuất hiện trên máy bạn. Tôi phải dùng volume có tên — thứ nằm trong máy ảo Linux — mới đo được nó. Mô phỏng đúng tình huống Linux thì kết quả rõ ràng:

thu muc thuoc UID 501, container chay UID 10001
touch: Permission denied

Bốn cách vá, một cách không chạy

Cách 1 — chown trong Dockerfile.

RUN adduser -D -u 10001 ungdung && mkdir -p /dl && chown 10001:10001 /dl
USER ungdung
chu so huu: 10001:10001 | ghi: DUOC

Chạy được, và lý do thú vị: khi gắn một volume có tên còn rỗng vào một đường dẫn đã tồn tại trong image, Docker chép cả nội dung lẫn quyền sở hữu của thư mục đó vào volume.

Cách 2 — entrypoint chạy root rồi hạ quyền.

#!/bin/sh
chown -R 10001:10001 /dl
exec su-exec 10001:10001 "$@"
UID: 10001 | chu so huu: 10001:10001 | ghi: DUOC

Đây là cách các image chính thức như postgres dùng. Nó luôn hiệu quả vì chown chạy sau khi volume đã được gắn. Đổi lại, container khởi động bằng root — chấp nhận được vì nó hạ quyền ngay, nhưng một số môi trường cấm hẳn việc đó.

Chú ý dòng cuối phải có exec, đúng như phần 8 đã đo: thiếu nó thì mã thoát 137 và ứng dụng không bao giờ dừng tử tế.

Cách 3 — chạy lần đầu bằng --user đúng. KHÔNG chạy được.

docker run --user 10001 -v v3:/dl alpine ...
chu so huu: 0:0
touch: Permission denied

Đây là cách nhiều người thử đầu tiên và nó thất bại: Docker tạo thư mục gốc của volume bằng root, bất kể --user bạn truyền. Cờ --user chỉ đổi tiến trình trong container, không đổi cách volume được khởi tạo.

Cách 4 — chuẩn bị một lần bằng container root.

docker run --rm -v v4:/dl alpine chown 10001:10001 /dl
docker run --rm --user 10001 -v v4:/dl alpine ...   # ghi DUOC

Đơn giản, hiệu quả, nhưng là một bước thủ công dễ quên khi dựng môi trường mới.

Giới hạn quan trọng của cách 1

Cách 1 chỉ hiệu quả với volume còn rỗng. Với volume đã có dữ liệu:

volume da co du lieu, chu so huu: 0:0
dung image co chown san -> chu so huu 0:0 | touch: Permission denied

Docker chỉ chép quyền sở hữu vào volume lúc khởi tạo lần đầu. Volume đã có nội dung thì giữ nguyên quyền cũ mãi mãi.

Hệ quả thực tế: bạn sửa Dockerfile, thêm USER, thêm chown, build lại, triển khai — và nó vẫn hỏng trên môi trường thật vì volume ở đó đã tồn tại từ trước. Cách duy nhất là chạy cách 2 hoặc cách 4 lên volume cũ.

Nên làm gì

FROM alpine:3.20
RUN adduser -D -u 10001 ungdung
WORKDIR /app
COPY --chown=10001:10001 . .
USER 10001
CMD ["./ung-dung"]

Bốn điểm đáng chú ý:

  • COPY --chown= đặt quyền ngay lúc chép, rẻ hơn một lệnh RUN chown (vốn tạo thêm một layer nhân đôi dữ liệu — phần 2 đã đo rằng layer chỉ cộng thêm chứ không trừ đi).
  • Dùng số UID chứ không dùng tên trong USER. Số hoạt động cả khi /etc/passwd không có mục đó — cần thiết với distroless và scratch.
  • Chọn UID cao (10001 chẳng hạn) để không đụng UID có sẵn của hệ thống.
  • Ứng dụng phải ghi được ở đâu đó: /tmp luôn ghi được, hoặc gắn một volume đã chown đúng.

Và với những image bạn không kiểm soát, docker run --user là cách áp từ bên ngoài — miễn là bạn đã xử lý phần volume ở trên.

Thử ba mươi giây

Xem container nào trên máy bạn đang chạy root:

docker ps -q | while read c; do
  printf "%-28s UID=%s\n" \
    "$(docker inspect $c --format '{{.Name}}')" \
    "$(docker exec $c id -u 2>/dev/null || echo '?')"
done

Mọi dòng UID=0 là một container mà kẻ tấn công, nếu thoát ra được khỏi tiến trình ứng dụng, sẽ có toàn quyền trong container và ghi được vào mọi volume gắn vào nó.

Phần sau bàn về ARGENV: phạm vi, thời điểm, và vì sao ARG không phải chỗ để bí mật.