Image của một site Laravel đã có đủ code, theme, plugin và asset. Nhưng khi gắn volume mount vào đúng thư mục chứa chúng để giữ file upload, pod lên rồi mà trang trắng, trả 403, hoặc giao diện vỡ hết CSS. Nguyên nhân không nằm ở ứng dụng. Kubernetes (và Docker) gắn volume đè lên thư mục đích, nội dung image tại đó bị che đi cho tới khi pod bị xoá. Bài này tổng hợp cách nhận ra tình huống qua ba triệu chứng, kiểm chứng bằng vài lệnh, rồi dựng lại thư mục trên PVC bằng hai cách và một cách bố trí volume gọn.

Cách khai báo volume và PVC đã có trong Volume, PersistentVolume, PVC và StorageClass trên Kubernetes. Bài này chỉ bàn về hệ quả của volume mount lên thư mục đã có nội dung.

Vì sao volume mount làm mất file trong image

Hãy nhìn volume mount ở mức hệ thống file. Image gồm các layer chỉ đọc, container thêm một lớp ghi ở trên cùng. Mỗi volume mount được khai báo trong volumeMounts. Với mountPath: /var/www/html/public, kernel gắn hệ thống file của volume vào đường dẫn đó. Mọi thứ image đặt sẵn ở public vẫn nằm trong layer nhưng không còn nhìn thấy từ trong container. Với một PVC mới, ổ vừa được định dạng ext4: khi mount cả volume, thư mục chỉ có lost+found do ext4 tạo ra; khi dùng subPath, thư mục con do kubelet tạo sẽ rỗng và thuộc root.

Docker có thể sao chép nội dung image vào named volume còn rỗng ở lần mount đầu; bind mount và PVC trên Kubernetes thì không, bạn phải tự đưa nội dung vào.

Ba triệu chứng của volume mount che image

Triệu chứngVì saoDấu hiệu trong log
403 hoặc 404 khi mở trangWebroot bị che nên không còn index.phpnginx: directory index of ... is forbidden
Upload ảnh hoặc kích hoạt theme báo không tạo được thư mụcThư mục mount thuộc root, tiến trình PHP chạy bằng user khác; hoặc thư mục cần ghi bị cheImpossible to create the root directory, mkdir(): Permission denied
Trang mở được nhưng vỡ CSS, thiếu iconThư mục asset đã publish bị che, file tĩnh không cònnginx: open() ... failed (2: No such file or directory)

Cả ba đều do cùng một volume mount, chỉ khác thư mục bị che. Ví dụ ở dòng hai: một chức năng kích hoạt theme ghi file ngôn ngữ vào resources/lang. Nếu volume mount chỉ có public và themes mà quên resources, hoặc có mount nhưng thuộc root, thao tác ghi sẽ thất bại. Chức năng nào ghi vào thư mục nào thì thư mục đó phải ghi được, và phải chứa nội dung cần thiết.

Kiểm chứng volume mount bằng ba lệnh

Trước khi đổ lỗi cho ứng dụng, hãy xác nhận volume mount có che thư mục không.

# 1. Thư mục của volume mount có gì, thuộc ai
kubectl -n site-a exec deploy/app -- ls -la /var/www/html/public

# 2. Pod có mount nào, lấy từ subPath nào
kubectl -n site-a describe pod <ten-pod> | grep -A8 "Mounts:"

# 3. Lỗi file tĩnh của nginx (nếu error_log trỏ ra stderr)
kubectl -n site-a logs deploy/app -c <ten-container> | grep "open()"
# nếu không: kubectl exec ... -- tail /var/log/nginx/error.log

Nếu lệnh 1 chỉ trả lost+found hoặc thư mục rỗng, bạn đã tìm ra nguyên nhân. Ở lệnh 2, dòng Mounts có dạng /var/www/html/public from data (rw,path="public") cho biết thư mục đó lấy từ subPath nào. Lệnh 3 chỉ lấy log của một pod bất kỳ trong Deployment và chỉ thấy lỗi nginx khi error_log đã trỏ ra stderr. Nó chỉ dùng để đọc lỗi: nhiều dòng open() failed cho asset nói rằng file tĩnh thiếu, nhưng không tự nó chứng minh nguyên nhân của một lỗi 503 nếu có.

Cách 1: bản dự phòng trong image rồi copy khi khởi động

Cách này bù lại nội dung bị che bằng một bản dự phòng. Ý tưởng: lúc build, copy thư mục cần mount sang một chỗ không bị mount, ví dụ /var/code. Khi pod chạy, sao chép từ đó sang thư mục đã mount.

# Dockerfile
RUN mkdir -p /var/code \
 && cp -R public themes resources /var/code/
# Deployment, container chính
lifecycle:
  postStart:
    exec:
      command: ["/bin/sh", "-c", "cp -Rn /var/code/. /var/www/html/ || true; chown -R 101:101 /var/www/html"]

Ba lỗi hay gặp khi viết đoạn này: quên mkdir -p nên thư mục đích chưa tồn tại; dùng cp không có -R nên thư mục không được sao chép; và dùng cp -rf làm ghi đè theme hay plugin mà người dùng đã chỉnh sửa trên PVC ở mỗi lần pod khởi động. Điểm cuối này là suy luận từ hành vi của -f, không phải kết quả đo. Dùng -n (không ghi đè file đã có) hoặc chỉ copy khi thư mục đích còn trống. Hãy kiểm tra mã thoát của cp -n trên image của bạn: một số bản coreutils mới có thể trả mã khác 0 khi bỏ qua file đã có (chưa kiểm chứng), và nếu postStart thất bại thì kubelet kill container. Vì vậy ví dụ thêm || true.

Giá phải trả: image to gấp đôi ở phần được nhân bản, thời gian khởi động tăng theo số file cần copy, và cần xử lý owner sau khi copy. Chưa có số đo thời gian cp hay chown -R nên đừng ước lượng; hãy tự đo trên PVC của bạn.

Cách 2: publish asset sau khi volume mount

Nhiều CMS và package Laravel không cần copy sẵn file tĩnh: chúng có lệnh publish để sinh asset từ package vào public. Khi giao diện vỡ do volume mount che thư mục asset, chạy lại các lệnh đó sau khi volume đã gắn thường là đủ:

php artisan vendor:publish --tag=<tag-cua-package> --force
php artisan <lenh-publish-asset-theme-cua-cms>
php artisan <lenh-publish-asset-plugin-cua-cms>

Tên lệnh theo từng CMS; hãy xem php artisan list trong image. Đặt các lệnh này vào bước khởi động để mỗi lần pod mới lên thì asset được sinh lại. Ưu điểm là không cần nhân bản code trong image; nhược điểm là cần biết đủ danh sách package có asset và lệnh phải idempotent.

Một volume mount, nhiều subPath trên một PVC

Một ý hiểu sai thường gặp là "một pod chỉ được gắn một PVC". Điều này không đúng: một pod có thể khai báo nhiều volume, mỗi volume trỏ tới một PVC. Giới hạn thật nằm ở chỗ khác: PVC ReadWriteOnce chỉ gắn được vào một node tại một thời điểm, và mỗi PVC thêm một volume Block Storage được tính phí riêng. Muốn gom ba volume mount (public, themes, resources) thì cách gọn là một volume, nhiều volume mount với subPath:

volumeMounts:
- name: data
  mountPath: /var/www/html/public
  subPath: public
- name: data
  mountPath: /var/www/html/themes
  subPath: themes
- name: data
  mountPath: /var/www/html/resources
  subPath: resources
volumes:
- name: data
  persistentVolumeClaim:
    claimName: site-a-data

Một thử nghiệm khác khai báo ba mục volumes riêng nhưng cùng một claimName, và gặp FailedMount kèm "Unable to find device path". Nguyên nhân chưa được xác nhận; chỉ biết cấu hình một volume với nhiều subPath chạy được. Hãy dùng cấu hình này làm chuẩn.

Sau khi áp dụng, hãy kiểm chứng dữ liệu thực sự nằm trên PVC: ghi một file vào thư mục upload, xoá pod, chờ pod mới lên rồi ls lại. File còn là thành công.

Race của postStart: chuyển việc copy sang initContainer

Cách 1 đặt việc copy và chown trong postStart. Theo tài liệu Kubernetes, hook này chạy cùng lúc với entrypoint của container và không đảm bảo xong trước nó. Nginx hoặc PHP-FPM có thể nhận request khi copy chưa xong. Đây là điều đọc từ tài liệu, chưa đo trong tình huống gốc.

Sửa gọn hơn là chuyển sang initContainer, dùng chung image và chung volume, chạy xong mới tới container chính. Chỗ này cũng là nơi hợp lý để sửa owner:

initContainers:
- name: seed
  image: registry.example.com/site-a:1.0
  securityContext:
    runAsUser: 0
  command: ["/bin/sh", "-c"]
  args:
  - |
    set -e
    mkdir -p /seed/public /seed/themes /seed/resources
    cp -Rn /var/code/public/. /seed/public/ || true
    cp -Rn /var/code/themes/. /seed/themes/ || true
    cp -Rn /var/code/resources/. /seed/resources/ || true
    chown -R 101:101 /seed
  volumeMounts:
  - name: data
    mountPath: /seed

Script nhiều dòng chỉ trả mã thoát của lệnh cuối, nên set -e giúp lỗi ở mkdir hay chown không bị che; chỉ các lệnh cp -n được bỏ qua lỗi. Lệnh chown -R chạy mỗi lần pod khởi động và có thể chậm trên thư mục upload lớn; hãy chỉ chown khi owner sai, hoặc dùng fsGroup nếu driver hỗ trợ. Con số 101 chỉ là ví dụ; hãy thay bằng uid và gid của user chạy nginx và PHP-FPM trong image của bạn. Volume ext4 trống thuộc root nên owner không "tự đổi lại" khi mount; nó chưa từng được đặt. Chi tiết về fsGroup và owner có trong Permission denied trên volume: vì sao chown trong Dockerfile không đủ.

Checklist trước khi thêm volume mount vào image

  • Liệt kê thư mục sắp mount: có chứa code hay asset do image cung cấp không?
  • Chỉ đặt volume mount cho thư mục thực sự cần dữ liệu bền (upload, storage), tránh mount cả webroot.
  • Nếu bắt buộc volume mount lên thư mục chứa code: có bản dự phòng trong image hoặc lệnh publish chạy sau khi mount.
  • Dùng một volume với nhiều volume mount subPath; kiểm tra bằng describe pod dòng path=.
  • Việc copy và chown chạy trong initContainer, dùng cp -Rn thay vì ghi đè.
  • Thư mục mà ứng dụng ghi vào (upload, theme, lang) đều ghi được bằng user chạy tiến trình.
  • Xoá pod rồi kiểm tra dữ liệu còn trên PVC.

Về quy tắc subPath và hành vi volume mount, xem phần subPath trong tài liệu Volumes của Kubernetes, và tài liệu Init Containers. Một khuôn manifest hoàn chỉnh để đối chiếu nằm ở Manifest Kubernetes đầu tiên: namespace, Deployment và MySQL StatefulSet.

Bài tiếp theo

pm.max_children cho pod PHP-FPM: chọn request, limit và vì sao stress test ngắn đánh lừa. Khi volume mount đã đúng và pod chạy ổn, câu hỏi tiếp theo là cấp bao nhiêu tài nguyên và đặt PHP-FPM thế nào để không bị 503.