Docker Desktop on Windows 挂载问题根因分析
本文档记录在 Windows + Docker Desktop 环境下使用 bind-mount 时,运行 Symfony/Pimcore 应用遇到的 rmdir(...): Directory not empty 错误的根因分析与解决方案。
问题现象
运行 Symfony 的原子文件操作(如缓存重建、assets:install)时,反复报:
Failed to remove directory "/var/www/html/var/cache": rmdir(/var/www/html/var/cache): Directory not empty
或:
Failed to remove directory "/var/www/html/public/bundles/openadmin": rmdir(/var/www/html/public/bundles/.!!oU+): Directory not empty
chmod -R 777 无效,rm -rf 也无效,即使目录里看起来已经没有内容。
根因分析
1. 文件系统桥接机制
Windows 主机是 NTFS,Docker 容器是 Linux(ext4)。bind-mount 把 Windows 路径(如 d:\php_code\open-skeleton)挂载到容器内(/var/www/html),中间需要桥接:
Windows NTFS ──9p / virtiofs 协议── WSL2 VM ext4 ──mount── 容器
- 9p:9P2000.L 网络文件系统协议
- virtiofs:基于 virtio 的 FUSE 实现
两者都是 虚拟化/网络协议,不是内核原生文件系统。所有文件操作(unlink、mkdir、rmdir、rename)都要在 Windows 主机和 WSL2 VM 之间通过 RPC 传递。
2. Symfony Filesystem 的原子 rename 模式
Symfony 的 Filesystem::rename() 在目标目录已存在时,走 Linux 经典的原子替换流程:
1. 创建临时目录 var/.!!XXXX (随机后缀防冲突)
2. 写入新内容 var/.!!XXXX/...
3. 备份当前目录 var/cache → var/cach~
4. 重命名就位 var/.!!XXXX → var/cache (Linux 原生支持 rename-over)
5. 清理备份 递归 unlink var/cach~ 下所有文件
6. 删除空目录 rmdir(var/cach~)
在 Linux 原生 ext4 上,这个流程是可靠的------步骤 5 的 unlink 立即生效,步骤 6 的 rmdir 看到的就是空目录。
3. 9p 协议下的状态同步延迟
在 9p/virtiofs 上,Symfony 的递归 remove() 实现大致是:
php
foreach (scandir($dir) as $file) {
unlink($file); // ← 步骤 5:删除子文件
}
rmdir($dir); // ← 步骤 6:立即删除空目录
问题出在步骤 5 和 6 之间:
unlink()通过 9p 把删除请求发给 Windows 主机- Windows 主机删除文件,回复 "OK"
- 但 9p 协议 不保证 Windows 主机侧的状态变更立即对容器可见
- 容器调用
rmdir()时,9p 可能还报告"目录非空" - 返回
ENOTEMPTY,PHP 翻译为rmdir(...): Directory not empty
这不是 race condition (并发竞争),是 协议同步延迟------即使在单线程顺序执行的情况下也会发生,因为 9p 的 readdir 缓存可能比实际状态滞后几毫秒到几百毫秒。
4. 为什么 chmod -R 777 完全无效
bash
chmod -R 777 html/ # 改的是 mode bits (rwxrwxrwx)
报错是 ENOTEMPTY,不是 EACCES:
| 错误码 | 含义 | 触发场景 | chmod 能否解决 |
|---|---|---|---|
EACCES |
Permission denied | 没有读写权限 | ✅ 能 |
ENOTEMPTY |
Directory not empty | 目录里有内容(POSIX 硬性规定) | ❌ 不能 |
rmdir() 是 POSIX 系统调用,只能删除空目录 ,这是设计如此,与权限位无关。即使目录的 mode 是 777,只要里面还有文件(或 9p 报告有文件),rmdir 就会失败。
5. 为什么 rm -rf 也可能无效
rm -rf 内部同样是 unlink() + rmdir() 的递归组合,只是加了 -f 忽略错误继续执行。但最终在 rmdir 父目录时,还是会踩同样的 9p 同步延迟问题:
rm: cannot remove 'var/cache': Directory not empty
6. 命名卷为什么能解决
Docker 命名卷(open-var、open-bundles)的存储路径在 WSL2 VM 内部:
\\wsl$\docker-desktop-data\version-pack-data\community.docker\volume\...
↓ 挂载到容器
/var/www/html/var (真实 ext4,不走 9p)
命名卷的数据在 WSL2 的 ext4 文件系统上,容器的 rmdir 直接走 Linux 内核 VFS,没有协议中间层,目录状态实时同步,unlink 后立即 rmdir 就能成功。
解决方案
方案 A:使用 Docker 命名卷(推荐)
给需要原子写入的路径用命名卷,绕开 9p 协议。在 <docker-compose.yaml> 中:
yaml
php:
volumes:
- .:/var/www/html # 项目代码 bind-mount
- open-var:/var/www/html/var # 命名卷,跑在 ext4 上
- open-bundles:/var/www/html/public/bundles # 命名卷
supervisord:
volumes:
- .:/var/www/html
- open-var:/var/www/html/var # 必须和 php 共享同一卷
- open-bundles:/var/www/html/public/bundles
nginx:
volumes:
- .:/var/www/html:ro
- open-bundles:/var/www/html/public/bundles:ro # 只读,与 php 共享视图
volumes:
open-var:
open-bundles:
需要命名卷的典型路径:
| 路径 | 用途 | 触发原子操作的场景 |
|---|---|---|
var/ |
Symfony 缓存、日志、installer 临时目录 | 缓存重建、installer 临时目录替换 |
public/bundles/ |
Symfony bundle 静态资源 | assets:install 替换 bundle 目录 |
public/var/ |
Pimcore 上传资源(可选) | 大文件上传、缩略图生成 |
不需要命名卷的路径:
| 路径 | 原因 |
|---|---|
src/、config/、templates/ |
只读,不触发原子 rename |
public/(根) |
静态文件,通常不原子替换 |
vendor/ |
composer 安装时整体创建,不原子替换 |
方案 B:改名挪走(临时 workaround)
如果没加命名卷,遇到 rmdir 失败时,把残留目录改名挪走:
bash
# 把失败的目录改名(避开 rmdir)
docker exec open-skeleton-php-1 mv /var/www/html/var/cache /var/www/html/var/cache.broken
# 重新跑触发原子操作的命令
docker exec open-skeleton-php-1 php /var/www/html/bin/console cache:clear
mv 在同文件系统内是 rename(),即使目标存在也能成功(覆盖)。挪走后,新操作可以重新创建目录。
方案 C:循环重试(不推荐)
bash
# 循环 rm 直到成功
until docker exec open-skeleton-php-1 rm -rf /var/www/html/var/cache 2>/dev/null; do
sleep 0.5
done
靠 9p 最终同步,可能要等几秒到几十秒。不推荐生产用,只适合调试。
本次安装中触发的具体场景
| 路径 | 触发操作 | Symfony 走的流程 | 报错 |
|---|---|---|---|
var/cache/dev/ |
缓存重建 | 临时目录 → 备份 → rename → 清理备份 | rmdir(var/.!!06i): Directory not empty |
public/bundles/openadmin/ |
assets:install 重新安装 |
rmdir 旧 bundle 目录 → 复制新内容 | rmdir(public/bundles/.!!oU+): Directory not empty |
两者都依赖 rmdir 删除"刚被清空"的目录,在 9p 上都失败。改用命名卷后,这些操作在 ext4 上原生执行,问题消失。
一句话总结
Windows bind-mount 的 9p/virtiofs 协议在 unlink 和 rmdir 之间有状态同步延迟,Symfony 的原子 rename 流程踩中这个窗口。这不是权限问题,是文件系统协议问题,只能通过改用 ext4 命名卷绕开。