Hình dung một phòng lưu trữ mà mọi tài liệu đều đặt sau kính: không ai sửa được bản gốc. Nhưng người làm việc trong đó vẫn cần vài tờ giấy nháp để ghi tạm, nên bạn đặt đúng vài tập nháp ở đúng chỗ cần, không hơn. --read-only chính là cái tủ kính đó — một trong những cờ bảo mật rẻ nhất Docker có: hệ thống tệp thành chỉ đọc, kẻ tấn công vào được cũng không cài thêm gì, không sửa nhị phân, không để lại backdoor sống qua lần khởi động lại. Rắc rối là hầu hết image cần ghi ở vài chỗ để chạy, nên đóng kính hết là chúng chết — và cả bài này là chuyện tìm đúng mấy tập giấy nháp tối thiểu ấy, cùng ba cái bẫy khi đặt chúng.
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
Muốn tự tìm tập tmpfs tối thiểu cho image của mình, bật read-only lên rồi để chính nó 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.
Mẫu số chung
Bài học đầu tiên, chính là cách tìm ra tập tmpfs: đóng hết rồi mở đúng cái tối thiểu — và đừng đoán danh sách cho phép, hãy rút nó ra từ chính những lần bị chặn. Tôi không ngồi liệt kê trước nginx cần ghi đâu; tôi bật --read-only, chạy, đọc dòng lỗi, thêm đúng một --tmpfs, lặp lại — ba vòng là xong, và tập cuối cùng đúng là nhỏ nhất chứ không phải một phỏng đoán rộng tay. Cùng phương pháp "khởi đầu đóng, mở theo cái thật sự gãy" ở khắp nơi: dựng allowlist tường lửa bằng cách xem cái gì bị chặn, viết CSP từ các vi phạm quan sát được, sinh seccomp profile từ một bản trace, tối thiểu hoá quyền IAM bằng cách đọc log access-denied. Nguyên tắc: quyền tối thiểu không đến từ suy đoán mà từ quan sát — bắt đầu ở trạng thái từ chối, rồi chỉ mở đúng chỗ thực sự hỏng, vì mọi thứ bạn mở "cho chắc" đều là bề mặt bạn tự tay để hở.
Điều thứ hai, đọc từ cái bẫy tmpfs làm biến mất default.conf: đặt một thứ đè lên một thứ đang có thể che khuất nó hoàn toàn, trong im lặng — không phải lúc nào cũng là thêm vào. tmpfs là một hệ tệp rỗng phủ lên thư mục, nên mọi nội dung image đặt sẵn ở đó bốc hơi, nginx khởi động không có server block và ngồi im không báo lỗi. Cùng cái bẫy "che khuất chứ không hợp nhất" ở khắp nơi: mount đè lên thư mục không rỗng, một biến cục bộ che biến ngoài (shadowing), một import trùng tên built-in, một route sau che route trước, thứ tự trong PATH. Nguyên tắc: khi đặt gì đó lên trên một cái đang tồn tại, hỏi rõ cơ chế đó hợp nhất hay thay thế — vì "phủ lên" có thể xoá tầm nhìn tới cái bên dưới mà không một tín hiệu nào cho bạn biết.
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.