--read-only là một trong những cờ bảo mật rẻ nhất mà Docker có: hệ thống tệp của container thành chỉ đọc, kẻ tấn công vào được cũng không cài đặt thêm gì được, không sửa được nhị phân, không để lại backdoor sống sót qua lần khởi động lại.

Nó cũng làm hầu hết image chết ngay. Tôi bật cờ đó lên bốn image phổ biến, không sửa gì khác:

Image Kết quả Dòng lỗi đầu tiên
redis:7.4-alpine running không có dòng lỗi nào
nginx:1.27-alpine exited mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
postgres:16-alpine exited chmod: /var/run/postgresql: Read-only file system
python:3.12-alpine exited FileNotFoundError từ tempfile

Redis chạy được ngay vì nó không ghi gì trong cấu hình mặc định — không snapshot, không PID file, không thư mục tạm. Ba cái còn lại đều cần đúng một vài đường dẫn ghi được, không phải cả hệ thống tệp.

Tìm tập tmpfs nhỏ nhất cho nginx

Cách làm là thêm từng --tmpfs một, chạy lại, đọc lỗi mới:

Thêm gì Trạng thái HTTP
--tmpfs /tmp exited
+ --tmpfs /var/cache/nginx exited
+ --tmpfs /var/run running 200

Ba dòng, và nginx chạy đầy đủ trên hệ thống tệp chỉ đọc:

docker run -d --read-only \
  --tmpfs /tmp --tmpfs /var/cache/nginx --tmpfs /var/run \
  nginx:1.27-alpine

Với postgres, tôi thêm --tmpfs /var/run/postgresql --tmpfs /tmp và một volume cho thư mục dữ liệu. Nó khởi động, tạo bảng và ghi được:

trang thai = running
ghi va doc duoc: 2
touch: /etc/thu: Read-only file system

Ghi vào CSDL bình thường, ghi vào /etc bị chặn. Đó chính xác là điều ta muốn.

Cái bẫy: tmpfs che mất nội dung có sẵn

Tôi thử thêm --tmpfs /etc/nginx/conf.d "cho chắc". Container chạy, nhưng không phục vụ gì cả — kết nối bị từ chối.

khong tmpfs, /etc/nginx/conf.d co: default.conf
co tmpfs,    /etc/nginx/conf.d co: (rong)

tmpfs là một hệ thống tệp rỗng đè lên thư mục. Mọi thứ image đã đặt sẵn ở đó biến mất — không báo lỗi, không cảnh báo. nginx khởi động không có server block nào và ngồi im.

Quy tắc: chỉ --tmpfs lên thư mục mà bạn muốn nó rỗng lúc khởi động. Thư mục chứa cấu hình hoặc dữ liệu thì dùng volume, hoặc để yên.

tmpfs nằm trong RAM, và tính vào giới hạn bộ nhớ

Đây là chỗ dễ mất cả container. tmpfs không phải đĩa — nó là bộ nhớ. Và nó được tính vào --memory:

docker run --memory 100m --tmpfs /data alpine \
  dd if=/dev/zero of=/data/f bs=1M count=200
Killed
ma thoat=0  OOMKilled=true

Ghi 200 MB vào tmpfs với giới hạn 100 MB thì container bị giết. Ghi 50 MB thì không sao. Và để ý mã thoát vẫn là 0 — đúng cái bẫy mà phần 25 đã đo: chỉ có OOMKilled mới nói thật.

Mặc định thì tmpfs của Docker không có trần riêng. Trong máy tôi:

co /tmp mac dinh: 7.8G

Đúng một nửa RAM của máy. Nghĩa là một ứng dụng ghi log hoặc ghi tệp tạm vào /tmp có thể ăn tới 7,8 GB — hoặc chạm giới hạn --memory trước và bị giết. Nên đặt trần tường minh:

docker run --tmpfs /tmp:size=16m ...
co: 16.0M
16777216 bytes (16.0MB) copied

dd được lệnh ghi 32 MB, ghi được đúng 16 MB rồi hết chỗ. Trần có hiệu lực.

tmpfs mặc định là noexec

Docker gắn tmpfs với rw,nosuid,nodev,noexec. Chép một tệp nhị phân vào rồi chạy:

mac dinh (--tmpfs /tmp):      sh: /tmp/echo: Permission denied
co :exec (--tmpfs /tmp:exec): CHAY-DUOC
de so sanh, chay tu /bin:     CHAY-DUOC

Đây là một hàng rào bảo mật tốt — rất nhiều kỹ thuật tấn công là tải payload về /tmp rồi chạy.

Nhưng nó cũng làm gãy những ứng dụng hợp lệ hay giải nén thư viện native vào thư mục tạm rồi nạp: một số thư viện JNI của Java, sqlite-jdbc, vài gói Python có phần mở rộng biên dịch. Triệu chứng là Permission denied ở một chỗ nghe chẳng liên quan gì tới tệp tạm. Chữa bằng --tmpfs /tmp:exec, hoặc tốt hơn là trỏ ứng dụng sang một thư mục tạm khác.

Tôi đo sai chỗ này lần đầu

Lần đầu tôi chép /bin/echo vào /tmp rồi chạy, và nhận e: applet not found. Tôi suýt kết luận :exec không có tác dụng.

Thật ra /bin/echo trong Alpine là một liên kết tượng trưng tới busybox, mà busybox quyết định chạy chức năng nào dựa trên tên tệp — chép thành /tmp/e là nó không nhận ra. Tệp đã chạy hoàn hảo; chỉ là chương trình bên trong từ chối.

Cái làm lộ ra sai lầm là bắt lấy thông báo lỗi thật thay vì chỉ xem lệnh thành công hay thất bại. Lần đo lại cho Permission denied — hoàn toàn khác applet not found. Và tôi thêm một ca đối chứng chắc chắn phải chạy được (/bin/echo từ đúng chỗ của nó) để biết phép đo còn tỉnh táo.

--read-only KHÔNG khoá mọi thứ

Điều cuối cần biết trước khi coi đây là hàng rào bảo mật. Tôi thử ghi vào từng nơi:

Đường dẫn Với --read-only
/, /etc chỉ đọc
/proc, /sys/fs/cgroup chỉ đọc
/tmp (có tmpfs) ghi được
Volume gắn vào ghi được
/dev, /dev/shm ghi được

Volume và bind mount không bị ảnh hưởng bởi --read-only. Muốn volume chỉ đọc thì phải khai riêng: -v ten:/duong/dan:ro.

Vậy --read-only cho bạn gì? Nó chặn việc sửa chính image đang chạy — cài gói, thay nhị phân, ghi đè cấu hình. Kẻ tấn công vẫn ghi được vào những chỗ bạn cố tình mở, nên hãy mở ít nhất có thể. Kết hợp với chạy bằng người dùng không phải root (phần 12) thì hai lớp này bù cho nhau khá tốt.

Trong Compose:

services:
  web:
    image: nginx:1.27-alpine
    read_only: true
    tmpfs:
      - /tmp:size=16m
      - /var/cache/nginx:size=64m
      - /var/run

Thử ba mươi giây

Bật read-only lên chính image của bạn và để nó tự khai ra cần gì:

docker run --rm --read-only <anh-cua-ban> 2>&1 | grep -i 'read-only\|denied'

Rồi thêm --tmpfs <duong-dan> cho từng đường dẫn nó kêu, chạy lại, tới khi sạch. Ba lưu ý:

  • Đường dẫn nào cần giữ dữ liệu thì dùng volume, không dùng tmpfs.
  • Đường dẫn nào đã có nội dung trong image thì đừng phủ tmpfs lên.
  • Luôn đặt size= cho tmpfs, vì mặc định là một nửa RAM và nó tính vào --memory.

Phần sau bắt đầu nhóm bài về mạng: localhost trong container không phải localhost của bạn, và tôi sẽ đo xem gói tin thật sự đi đường nào.