Pod Laravel khởi động bình thường, nhưng ngay khi ghi log, cache hay upload ảnh thì xuất hiện lỗi permission denied: Failed to open stream: Permission denied với storage/framework/cache, hoặc Impossible to create the root directory ở thư mục uploads. Điều khó hiểu là Dockerfile đã có sẵn dòng chown -R nginx:nginx storage public. Bài này giải thích vì sao dòng đó không cứu được đường dẫn được mount, hai nguồn gốc khiến owner bị sai, cách kiểm tra nhanh, và bảng chọn cách đặt owner đúng theo từng loại volume.
Nếu bạn chưa nắm rõ PV, PVC và StorageClass thì nên đọc trước Volume, PersistentVolume, PVC và StorageClass trên Kubernetes.
Triệu chứng của lỗi permission denied
- Log PHP-FPM hoặc Laravel có lỗi permission denied dạng
file_put_contents(...): Failed to open stream: Permission denied, thường ở đường dẫnstorage/framework/cache/data/...hoặcstorage/logs. - Upload ảnh báo lỗi không tạo được thư mục gốc (cùng họ permission denied), dù thư mục
public/uploadscó vẻ tồn tại. - Không có lỗi permission denied khi chạy image bằng
docker runkhông mount volume.
Vì sao chown trong Dockerfile không chặn được permission denied
Lệnh RUN chown chỉ thay đổi owner của các file nằm trong lớp image. Khi pod chạy, Kubernetes mount volume lên đúng đường dẫn đó (ví dụ /var/www/html/public), và nội dung của volume che hoàn toàn thư mục trong image. Owner bạn đặt lúc build vẫn còn trong image, nhưng không ai nhìn thấy nó nữa. Volume mới tạo thì thư mục gốc thường thuộc root, hoặc thuộc UID do máy NFS quyết định.
Đừng kết luận "chown ở Dockerfile không có tác dụng": nó vẫn đúng cho file nằm trong image, và chỉ vô hiệu với đường dẫn bị volume che.
Hai nguồn gây permission denied: owner sai
Volume mới tạo
PVC block storage mới được định dạng và mount với thư mục gốc thuộc root. Với hostPath và NFS thì owner là owner của thư mục trên máy chủ. Container chạy user thường (như nginx) không ghi được vào đó và gặp permission denied.
File do artisan chạy bằng root tạo ra
Nguồn thứ hai tinh vi hơn. Khi bạn chạy kubectl exec -it pod -- php artisan ..., lệnh chạy với user mặc định của container, thường là root. Nếu lệnh đó tạo file cache hoặc log, file thuộc root, và tiến trình PHP-FPM chạy user thường sau đó không ghi đè được. Chạy chown lại thì hết permission denied, rồi một lần artisan khác lại tái phát. Cách tránh là chạy artisan bằng đúng user của ứng dụng, ví dụ kubectl exec -it deploy/site-a -- su -s /bin/sh nginx -c 'php artisan cache:clear' (hoặc su-exec, runuser tuỳ image). Với Job thì cần đặt securityContext.runAsUser, runAsGroup (và fsGroup nếu cần) trùng user của ứng dụng, vì Job dùng USER mặc định của image, thường là root. Cách dùng Job có trong bài Chạy artisan trên Kubernetes: migrate bằng Job hay initContainer, scheduler bằng CronJob.
Kiểm tra nhanh permission denied trong pod
Trước khi sửa, hãy xác nhận ai đang chạy và ai sở hữu thư mục:
kubectl exec deploy/site-a -- sh -c 'ps -o user,pid,comm | grep -E "php-fpm|nginx"; ls -lan storage public'
kubectl logs deploy/site-a | grep -i -E 'permission|denied'
kubectl exec deploy/site-a -- tail -n 50 /var/log/nginx/error.log
kubectl get pod -l app=site-a -o jsonpath='{.items[0].spec.securityContext}{"\n"}{.items[0].spec.containers[*].securityContext}{"\n"}'
kubectl describe pod -l app=site-a | grep -A8 -i 'Mounts'Dòng đầu cho biết worker PHP-FPM chạy user nào. Đừng dùng id để suy ra điều này: id trong kubectl exec chỉ cho biết user của phiên exec, thường là root, trừ khi container chạy non-root bằng securityContext. Dòng cuối cùng tách việc: kubectl get pod -o jsonpath mới in được securityContext (kubectl describe không in), còn describe chỉ dùng cho phần Mounts.
Dùng ls -ln để thấy UID/GID dạng số, vì permission denied thực chất là so sánh số: tên user có thể trùng mà số lại khác giữa image và máy chủ volume. Nếu worker php-fpm chạy UID 101 mà thư mục thuộc 0, bạn đã tìm ra lỗi. Với image nginx cộng PHP-FPM, lỗi PHP thường nằm ở error log của nginx (ví dụ /var/log/nginx/error.log, tuỳ cấu hình) dưới dạng FastCGI sent in stderr, nên cần xem file đó khi kubectl logs không thấy lỗi.
Lỗi permission denied có thể phát hiện sớm nếu health endpoint thử ghi vào thư mục cục bộ của pod. Cách làm có trong bài Health endpoint cho Laravel trên Kubernetes: đổi quyền storage thì readiness thất bại và pod không nhận traffic.
Bảng so sánh các cách xử lý permission denied
| Cách | Làm gì | Lưu ý |
|---|---|---|
| chown trong Dockerfile | Đặt owner trong lớp image | Bị volume che, vô tác dụng với đường mount |
postStart | Chạy chown sau khi container khởi động | Hook chạy song song với ENTRYPOINT, không đảm bảo xong trước khi ứng dụng bắt đầu; pod chỉ Ready sau khi hook kết thúc nên chown chậm sẽ kéo dài thời gian khởi động. Chỉ chown được nếu container chạy bằng root |
| initContainer chạy root | Chown trước khi container chính chạy | Hiệu quả và dễ hiểu, nhưng chạy mỗi lần pod khởi động |
runAsUser, runAsGroup | Container chạy đúng UID/GID mong muốn | Chỉ có ích khi thư mục đã thuộc UID đó |
fsGroup | Kubelet đổi group và quyền ghi cho volume | Đổi group và thêm quyền ghi cho nhóm, không đổi owner user. Tuỳ driver CSI và fsGroupPolicy; không áp dụng cho hostPath, thường không cho NFS |
| chown ở nguồn (máy NFS, pipeline) | Đặt owner trước khi pod mount | Cần quyền trên máy chủ, xem bài kế tiếp về NFS |
Chọn theo loại volume:
| Loại volume | Cách nên dùng |
|---|---|
| Block storage CSI | fsGroup cùng OnRootMismatch |
hostPath, NFS | Chown ở nguồn, hoặc initContainer chạy root |
| Image chạy root | postStart, chỉ cho thư mục nhỏ (nhược điểm: kéo dài thời gian khởi động) |
Một trường hợp đã xác nhận: image chạy master process bằng root, postStart chown xong thì upload thành công. Cách này chỉ đúng khi container chạy root.
Các phương án fsGroup, initContainer và fsGroupChangePolicy bên dưới là theo tài liệu Kubernetes, được trình bày như cấu hình tham khảo để bạn tự thử trước khi đưa lên production, không phải kết quả đã đo.
fsGroup cho block storage
# đặt trong spec.template.spec của Deployment
securityContext:
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
fsGroupChangePolicy: "OnRootMismatch"Với fsGroupChangePolicy: OnRootMismatch, kubelet chỉ đổi quyền đệ quy khi thư mục gốc của volume chưa đúng, tránh chạm vào mọi file ở những lần khởi động sau. Tham số mặc định Always thì duyệt toàn bộ volume mỗi lần. Lưu ý runAsUser non-root ở cấp pod cần image hỗ trợ (nghe cổng lớn hơn 1024, không cần root cho nginx); image nginx cộng PHP-FPM chạy master bằng root để bind cổng 80 có thể hỏng khi chạy non-root. Chi tiết trong tài liệu security context của Kubernetes.
initContainer chown với UID số
initContainers:
- name: fix-owner
image: busybox:1.36
command: ["sh", "-c", "chown -R 1001:1001 /data"]
securityContext:
runAsUser: 0
volumeMounts:
- name: data
mountPath: /dataHãy dùng UID/GID số, vì tên user nginx không tồn tại trong image busybox. Cũng cần nhớ initContainer phải chạy xong thì container chính mới bắt đầu, nên với volume lớn, thời gian chown -R cộng thẳng vào thời gian khởi động của mỗi pod.
Giá của chown đệ quy trên thư mục lớn
Lệnh chown -R và chmod -R phải chạm vào từng file. Với thư mục public vài GB gồm rất nhiều file nhỏ, mỗi lệnh có thể chạy lâu ở mọi loại volume; NFS thường chậm hơn vì mỗi thao tác metadata đi qua mạng. Nếu đặt chúng (cùng cp -rf thư mục public) trong postStart thì mỗi lần khởi động lại, xoá pod hoặc rolling update đều phải chờ lâu.
Dấu hiệu nhận biết: xoá pod thì pod mới ở ContainerCreating rất lâu. Đây là suy luận từ việc pod khởi tạo chậm khi postStart chạy các lệnh đệ quy, chưa đo tách riêng từng lệnh. Hướng xử lý là bỏ chown, chmod đệ quy khỏi deployment và đặt owner đúng một lần ở nguồn. Bài chưa có số đo trước và sau khi bỏ chown.
Để tránh permission denied mà không trả giá khởi động, nguyên tắc là chỉ chown thư mục gốc cần thiết, đặt ở nơi chạy một lần (pipeline tạo thư mục, hoặc OnRootMismatch), tránh đặt trong đường khởi động của từng pod.
Đừng nhầm với lỗi FailedMount
Có lỗi cũng liên quan đến volume nhưng là loại khác: FailedMount hoặc Unable to find device path là lỗi gắn volume vào node, xảy ra trước khi container chạy. Permission denied chỉ xuất hiện sau khi mount thành công, ở bên trong container. Chẩn đoán lỗi attach nằm ở bài Pod kẹt ContainerCreating trên LKE: Multi-Attach và Unable to find device path. Đừng gộp hai loại lỗi, vì cách sửa hoàn toàn khác nhau.
Điều chưa chốt
- Chưa có thử nghiệm nào xác nhận
fsGrouphay initContainer chạy thật trong tình huống này; bạn cần tự kiểm. - Một ghi nhận cho rằng
postStartchown trong image chạy user thường "đã sửa lỗi", dù về nguyên tắc user thường không chown được thư mục của root. Lý do chưa được giải thích, nên đừng coi đó là cách làm chuẩn. - Cơ chế riêng của NFS (UID, squash) nằm ở bài sau.
Checklist xử lý permission denied
- Khi gặp permission denied, dùng
ps -o user,pid,commđể biết user của worker php-fpm vàls -lnđể biết owner số của thư mục (idchỉ cho biết user của phiên exec). - Block storage CSI: dùng
fsGroupcùngOnRootMismatch, thử trên môi trường test. - NFS hoặc
hostPath: đặt owner ở nguồn, không trông chờ vàofsGroup. - Không đặt chown, chmod đệ quy trên thư mục lớn trong
postStart. - Chạy artisan bằng đúng user của ứng dụng (hoặc Job có
runAsUsertrùng), tránh tạo file thuộc root. - Dùng UID/GID số khi viết initContainer.
- Tự kiểm thử: xoá pod, đợi pod mới, thử upload và xem log; lỗi permission denied phải biến mất.
Cấu hình Helm để đặt các giá trị này nhất quán cho mọi site được nói ở Chart dùng chung: tái sử dụng Helm cho Laravel, MySQL và Redis.
Bài tiếp theo
NFS và pod không chạy root: UID/GID, root_squash và anonuid. Cơ chế NFS quyết định chủ sở hữu thư mục khi pod không chạy bằng root, và cấu hình exports đúng.



