Phần trước dừng ở chỗ restricted đòi runAsNonRoot: true. Bài này đo xem thêm dòng đó vào thì cái gì vỡ, và vỡ ở đâu.
Cùng một image, đổi mỗi uid
| Pod | Kết quả |
|---|---|
nginx:1.27-alpine, mặc định |
1/1 Running |
nginx:1.27-alpine + runAsUser: 1000 |
Error, khởi động lại 2 lần trong 23 giây |
nginxinc/nginx-unprivileged + runAsUser: 1000 |
1/1 Running |
Đến đây thì đúng như dự đoán. Câu hỏi là nó chết ở chỗ nào.
[warn] the "user" directive makes sense only if the master process
runs with super-user privileges, ignored
[emerg] mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)
Không phải lỗi mở cổng. Là lỗi ghi vào hệ tệp.
Image nginx chính thức tạo thư mục cache lúc khởi động và những thư mục đó thuộc về root. Nói cách khác, thứ chặn bạn không phải mô hình quyền của mạng mà là mô hình quyền của tệp — mà đó là thứ sửa được bằng chown trong Dockerfile, không cần đặc quyền gì.
Image nginx-unprivileged chính là bản đã làm sẵn việc đó, và nó nghe cổng 8080 thay vì 80.
Cổng 80 với uid 1000: đo riêng
Ai cũng nói: cổng dưới 1024 là cổng đặc quyền, muốn mở phải là root hoặc phải thêm NET_BIND_SERVICE. Tôi đo hai pod, cùng runAsUser: 1000, một pod có thêm capability đó và một pod không.
s = socket.socket()
s.bind(("0.0.0.0", 80))
| Pod | Cổng 80 |
|---|---|
| uid 1000, không thêm gì | mở được |
uid 1000 + NET_BIND_SERVICE |
mở được |
Cả hai đều mở được. Capability kia không làm gì cả.
Tôi không tin ngay. Đọc ngưỡng thật:
net.ipv4.ip_unprivileged_port_start
trong pod 0
trong node kind 0
trong máy thật 1024
Máy thật vẫn giữ 1024, nhưng mọi netns của container đều bắt đầu ở 0. Trình chạy container hạ ngưỡng đó xuống khi tạo namespace mạng mới, nên trong container không còn khái niệm cổng đặc quyền.
Tôi kiểm lại bằng một container Docker thường, không qua Kubernetes: cũng 0. Vậy nguồn gốc là ở trình chạy container chứ không phải ở Kubernetes.
Nhưng đừng suy ra rằng có thể bỏ NET_BIND_SERVICE ở mọi nơi. Đây là hành vi của trình chạy, không phải bảo đảm của Kubernetes — cụm dùng CRI-O, dùng gVisor, hoặc có ai đó đặt lại sysctl thì kết quả khác. Điều rút ra đúng là: hãy đo trên cụm của bạn thay vì chép công thức, vì cả hai phía đều đang lặp lại điều họ không kiểm.
Và dù đo được vậy, cho ứng dụng nghe cổng 8080 vẫn tốt hơn: nó bỏ hẳn một thứ phải phụ thuộc.
readOnlyRootFilesystem
ghi vào / -> sh: can't create /a.txt: Read-only file system
ghi vào /tmp -> được
/tmp ghi được vì tôi gắn một emptyDir vào đó. Đó chính là cách dùng đúng: khoá toàn bộ hệ tệp gốc, rồi mở lại đúng những đường dẫn cần ghi.
securityContext: {readOnlyRootFilesystem: true}
volumeMounts:
- {name: tmp, mountPath: /tmp}
- {name: cache, mountPath: /var/cache/nginx}
volumes:
- {name: tmp, emptyDir: {}}
- {name: cache, emptyDir: {}}
Cách tìm ra danh sách đường dẫn cần mở: bật cờ, chạy, đọc log, thêm một emptyDir, lặp lại. Nhàm nhưng chỉ làm một lần cho mỗi image.
Cờ này đáng giá hơn runAsNonRoot về mặt phòng thủ. Kẻ tấn công chạy được mã trong container mà không ghi được gì xuống đĩa thì không cài được công cụ, không sửa được nhị phân, và mất khả năng sống sót qua lần khởi động lại.
fsGroup: chỗ tôi không tái tạo được lỗi
Tôi định trình bày fsGroup bằng một pod ghi hỏng vào volume. Không dựng được: cả emptyDir lẫn volume do bộ cấp phát của kind tạo ra đều là drwxrwxrwx, nên uid 1000 ghi được ngay cả khi không khai fsGroup.
Ghi lại vì đó là một cái bẫy: thư mục 0777 làm mọi thử nghiệm về quyền trở nên vô nghĩa. Cái bạn kết luận là "không cần fsGroup" thực ra là "bộ cấp phát này rộng rãi".
Trên các trình điều khiển CSI tạo thư mục 0755 thuộc root — nhiều bộ cấp phát của nhà cung cấp đám mây làm vậy — pod non-root sẽ ghi hỏng, và fsGroup là thứ chữa: Kubernetes đổi group của volume rồi bật bit setgid. Dấu hiệu nhận ra là drwxrws--- chứ không phải drwxr-x---.
Muốn biết cụm mình thuộc loại nào thì đo, đừng đoán.
Thử ba mươi giây
Đếm xem bao nhiêu container trong cụm đang chạy root:
kubectl get pods -A -o json | python3 -c '
import sys,json
r=0; t=0
for p in json.load(sys.stdin)["items"]:
ps=p["spec"].get("securityContext",{})
for c in p["spec"]["containers"]:
t+=1
cs=c.get("securityContext",{})
nr = cs.get("runAsNonRoot", ps.get("runAsNonRoot"))
u = cs.get("runAsUser", ps.get("runAsUser"))
if not nr and u in (None,0):
r+=1
print(p["metadata"]["namespace"], p["metadata"]["name"], c["name"])
print(f"-> {r}/{t} container co the dang chay root")
'
Con số đó sẽ cao hơn bạn nghĩ. Nhưng đừng sửa hết một lượt — sửa image nào bạn tự dựng trước, vì đó là chỗ chỉ cần thêm một dòng USER là xong.
Phần sau: init container và sidecar, và vì sao một Job có sidecar kiểu cũ thì không bao giờ kết thúc.