--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.