WSL 里的 Docker:慢的不是容器,通常是挂载边界
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。
Windows 上跑 Docker,很多人会下意识觉得“它肯定不如 Linux 原生”。这个判断有时候对,有时候又错得很离谱。
我更常见到的情况是:Docker Desktop 的 WSL 后端本身没什么问题,真正拖慢开发体验的是挂载路径。代码在 Windows 分区,容器在 Linux 里,热更新和依赖安装每天都在跨边界搬小文件。最后大家把锅甩给 Docker,其实 Docker 只是替路径选择背锅。

这篇用一个 API 服务做主线。项目很普通:FastAPI 后端、PostgreSQL、Redis、一个前端 dev server。目标不是讲 Docker 入门,而是把 WSL 里几个最容易混在一起的问题拆开:谁在管理 engine,代码应该挂哪里,依赖要不要放 volume,磁盘为什么越来越大,出了问题先看哪些命令。
先搞清楚:你在 WSL 里敲 docker,不等于 Docker 装在 Ubuntu 里
Docker Desktop 开启 WSL2 backend 后,常见的使用方式是:人在 Ubuntu 里敲 docker compose up,实际 engine 由 Docker Desktop 管理。Docker 文档也把这个模式说得很清楚:WSL2 提供 Linux 内核、文件系统共享、冷启动和动态资源分配能力,Docker Desktop 则把 Linux 容器体验接到 Windows 桌面。
所以不要把它想成“我在 Ubuntu 里完整装了一套 Docker daemon”。更准确的图是:
你日常所在的 Ubuntu 发行版负责 shell、源码、编辑器远程环境。Docker Desktop 负责 engine、镜像、容器生命周期、UI 和部分网络集成。还有一些内部发行版,比如 docker-desktop,你一般不需要进去折腾。
先确认基础状态:
docker version
docker context ls
docker info | sed -n '1,80p'
正常情况下,docker version 能看到 client 和 server;docker context ls 里当前 context 通常是 desktop-linux 或类似 Docker Desktop 管理的上下文。
如果 Ubuntu 里提示找不到 docker,先去 Docker Desktop 设置里确认 WSL Integration 是否打开,并选中了你的发行版。不要一上来就在 Ubuntu 里 apt install docker.io。两套 Docker 混在一起,后面排障会很痛苦。
最容易犯错的 compose 文件
我见过很多这样的 docker-compose.yml:
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --reload
volumes:
- /mnt/d/work/my-api:/app
ports:
- "8000:8000"
这能跑,但不是好习惯。
如果代码在 /mnt/d/work/my-api,容器里 /app 看到的文件来自 Windows 分区。API 热更新要监听它,Python 要 import 它,uvicorn --reload 要反复 stat 它,pip 或 uv 可能还要在里面写缓存。每一步都在跨 Windows/WSL 文件系统边界。
更好的版本很简单:项目先放到 WSL 文件系统,例如 ~/work/my-api,然后在这个目录里跑 compose:
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --reload
working_dir: /app
volumes:
- .:/app
ports:
- "8000:8000"
关键不是 .:/app 这个写法有多神奇,而是当前目录本身在 WSL 的 Linux 文件系统里。
cd ~/work/my-api
docker compose up --build
这时 bind mount 的源头是 Linux 文件系统,容器也是 Linux,热更新链路短很多。Docker Desktop 官方文档和 VS Code Remote Containers 的性能建议里,都反复强调把源码放在 WSL2 文件系统里,而不是从 Windows 文件系统远程挂进去。
三种挂载方式,不要混着用
本地开发最常见的三种方案:
第一种是从 /mnt/c 或 /mnt/d bind mount。它方便 Windows 工具直接处理文件,但热更新、依赖安装、小文件扫描都会慢。除非这个服务几乎不读写源码,或者 Windows 工具才是主力,否则不要选。
第二种是从 ~/work/project bind mount。开发 Web 服务、Python API、Node 服务,通常选这个。源码在 WSL,容器看源码也快,Windows 还能通过 explorer.exe . 偶尔访问。
第三种是 named volume。它适合依赖目录、数据库数据、构建缓存。比如 Node 项目可以让源码 bind mount,但把 node_modules 放进 volume:
services:
web:
image: node:22
working_dir: /app
command: npm run dev
volumes:
- .:/app
- web_node_modules:/app/node_modules
ports:
- "5173:5173"
volumes:
web_node_modules:
这样做有一个好处:Windows/WSL 主机不需要直接管理容器里的 node_modules。依赖目录大量小文件留在 Docker volume 里,源码留在 WSL。缺点是你要接受“主机目录里看不到 node_modules”,并且重建依赖时要知道怎么清 volume:
docker compose down -v
docker compose up --build
Python 项目也类似。开发时我更喜欢把虚拟环境放在容器内或 volume,而不是 bind mount 主机 .venv。主机 .venv 和容器 .venv 混着用,路径、平台 wheel、解释器版本很容易打架。
热更新慢,先看路径,不要先改框架
假设一个 FastAPI 服务热更新很慢,保存文件后 5 秒才 reload。不要马上怀疑 uvicorn,先在容器里看 /app 来自哪里。
docker compose exec api sh
pwd
mount | grep ' /app '
再回到 WSL 宿主:
pwd
df -T .
如果路径在 /mnt/d,先搬项目。搬完以后再测热更新。很多时候不需要任何框架参数。
如果项目已经在 ~/work,再看监听器限制和忽略目录。Node 和 Python 的 watcher 都怕大目录。不要让热更新盯着 .git、node_modules、.venv、dist。
Vite、Next、Uvicorn、WatchFiles 都有各自配置,但原则一样:源码目录尽量小,生成目录和依赖目录不要扫。
一个 FastAPI 项目可以这么启动:
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--reload \
--reload-dir app
Node 项目则尽量让依赖目录在 volume,watcher 只看 src、pages、app 这些真正会改的目录。
数据库 volume 不要挂到 Windows 分区
开发环境里经常有人这么写:
services:
postgres:
image: postgres:16
volumes:
- /mnt/d/docker-data/postgres:/var/lib/postgresql/data
我不建议这样做。
数据库数据目录不是普通源码目录。它关心权限、锁、fsync、重命名语义和大量小文件写入。即使开发环境“看起来能跑”,也很容易在异常断电、容器重启、权限变化后出奇怪问题。
直接用 named volume:
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
POSTGRES_DB: app
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
需要备份时用数据库自己的工具:
docker compose exec postgres pg_dump -U postgres app > backup.sql
需要恢复:
cat backup.sql | docker compose exec -T postgres psql -U postgres app
不要为了“在资源管理器里能看到 data 文件夹”,把数据库内部文件摊到 Windows 分区。看得到不等于可维护。
磁盘越来越大,是两层问题
Docker Desktop 用 WSL 后端时,镜像、容器、volume 会占用 Docker 自己管理的存储。再加上 WSL 发行版自己的 ext4.vhdx,你会看到 C 盘或者某个存储位置越来越大。
先看 Docker 层:
docker system df
docker image ls
docker volume ls
再清理明确不要的东西:
docker container prune
docker image prune
docker builder prune
如果确认所有未使用资源都可以删:
docker system prune
带 -a 和 --volumes 要谨慎:
docker system prune -a --volumes
这会删掉未使用镜像和未使用 volume。开发数据库、对象存储、本地消息队列的数据可能都在 volume 里。删之前先看:
docker volume ls
docker volume inspect postgres_data
Docker 资源清了,不代表 Windows 立刻拿回磁盘空间。WSL2 的 VHDX 会增长,但回收空间往往还需要 compact。这个话题我会在后面的“WSL 磁盘为什么越用越大”里单独讲,因为它不只是 Docker 的问题。
在 Docker 这篇里记住一个顺序就够了:先从 Docker 内部清理无用镜像、容器、builder cache、volume;确认 guest 文件系统已经释放空间以后,再考虑 VHDX 层面的压缩。
日志和排障:别只看 Docker Desktop UI
Docker Desktop UI 很适合看概况,但真正排障还是命令快。
服务起不来:
docker compose ps
docker compose logs api --tail=200
docker compose config
镜像构建慢:
DOCKER_BUILDKIT=1 docker build --progress=plain .
docker builder du
端口不通:
docker compose port api 8000
curl -v http://localhost:8000/health
容器里 DNS 异常:
docker compose exec api getent hosts github.com
docker compose exec api cat /etc/resolv.conf
路径挂载不符合预期:
docker compose exec api mount
docker inspect <container_id> --format '{{json .Mounts}}' | jq
这些命令最好写进项目的 docs/dev.md。不要让每个人都在 Docker Desktop UI 里点来点去猜同一个问题。
我现在推荐的本地 compose 结构
一个比较稳的本地开发结构是这样:
~/work/my-product/
api/
Dockerfile
docker-compose.yml
app/
pyproject.toml
web/
package.json
src/
后端:
services:
api:
build: .
working_dir: /app
command: uvicorn app.main:app --host 0.0.0.0 --reload --reload-dir app
volumes:
- .:/app
- api_venv:/app/.venv
ports:
- "8000:8000"
depends_on:
- postgres
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
POSTGRES_DB: app
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
api_venv:
postgres_data:
前端:
services:
web:
image: node:22
working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
volumes:
- .:/app
- web_node_modules:/app/node_modules
ports:
- "5173:5173"
volumes:
web_node_modules:
如果你觉得每次启动都 npm install 太慢,可以改成手工执行一次依赖安装,或者做一个 dev image。关键是不要把依赖目录从 Windows 分区 bind 进容器。
什么时候不用 Docker Desktop
也有一些场景,我会考虑直接在 WSL 里装 Docker Engine,或者用别的容器运行时。
比如 CI 环境要尽量贴近 Linux 服务器;比如你不需要 Docker Desktop UI,也不想被它的设置和更新影响;比如公司许可政策不允许使用 Docker Desktop。这个时候可以在 WSL 发行版里启用 systemd,再安装 Docker Engine。
但这不是本文默认推荐,因为它会把更多维护责任交给你:daemon、权限、启动、镜像存储、网络、升级都要自己管。对多数桌面开发者,Docker Desktop + WSL Integration 足够好,前提是路径和 volume 策略正确。
我一般先把这几个问题问清楚:
代码是不是在 ~/work?
依赖目录是不是 volume 或容器内目录?
数据库数据是不是 named volume?
热更新有没有排除大目录?
Docker 磁盘清理有没有固定流程?
这些做对以后,才值得讨论要不要换运行时。
最后留一条经验
WSL 里的 Docker 不神秘。它慢的时候,先别急着怪虚拟化,也别急着换框架。
先看路径。
再看 mount。
再看 watcher。
再看 volume。
最后才看资源限制和运行时。
本地开发最怕“为了方便看见文件,把所有东西摊在 Windows 分区”。真正省心的方案通常相反:源码、依赖、容器和数据尽量留在 Linux 一侧闭环,Windows 只负责桌面、浏览器和少数交互入口。
边界越少,Docker 越像 Linux。边界越乱,Docker 越像玄学。