WSL 里的 Docker:慢的不是容器,通常是挂载边界
原创 · 约 24 分钟阅读 · 阅读 --

WSL 里的 Docker:慢的不是容器,通常是挂载边界

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。

Windows 上跑 Docker,很多人会下意识觉得“它肯定不如 Linux 原生”。这个判断有时候对,有时候又错得很离谱。

我更常见到的情况是:Docker Desktop 的 WSL 后端本身没什么问题,真正拖慢开发体验的是挂载路径。代码在 Windows 分区,容器在 Linux 里,热更新和依赖安装每天都在跨边界搬小文件。最后大家把锅甩给 Docker,其实 Docker 只是替路径选择背锅。

Docker Desktop WSL 后端的开发边界

这篇用一个 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 它,pipuv 可能还要在里面写缓存。每一步都在跨 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 都怕大目录。不要让热更新盯着 .gitnode_modules.venvdist

Vite、Next、Uvicorn、WatchFiles 都有各自配置,但原则一样:源码目录尽量小,生成目录和依赖目录不要扫。

一个 FastAPI 项目可以这么启动:

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port 8000 \
  --reload \
  --reload-dir app

Node 项目则尽量让依赖目录在 volume,watcher 只看 srcpagesapp 这些真正会改的目录。

数据库 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 越像玄学。

参考资料