我们组新人入职时,我会先看一眼他怎么用 Docker。这不是什么考核,纯粹是因为这个东西的误用率实在太高了,而且误用之后的痛苦是慢性的——不会立刻炸,只会让你每天多浪费二十分钟。
最常见的误用只有一种:把容器当虚拟机使。
那个「什么都在容器里」的方案
典型症状是这样的:一个巨大的开发镜像,里面装了运行时、数据库客户端、vim、git、zsh、甚至 oh-my-zsh 主题。开发者 docker exec -it dev bash 进去,在里面写代码、跑测试、调试。
我自己这么干过大半年,后来受不了了。问题在于:
- 编辑器的语言服务在宿主机上,看不到容器里的依赖,跳转和补全全废,或者要配一堆远程开发的东西;
- 容器一重建,你在里面装的、改的所有东西全没了。于是大家开始往 Dockerfile 里加自己的个人配置,镜像越来越臃肿,我们那个镜像最后到了 3.8GB;
- 调试器要跨容器边界连接,端口映射、路径映射一堆配置,出问题极难排查。
更根本的是,这套做法搞错了 Docker 在开发环境里的定位。
我现在的分工
非常简单一条线:依赖服务跑容器,应用代码跑宿主机。
数据库、缓存、消息队列、对象存储模拟器——这些东西装在本机上又麻烦又容易和其他项目冲突,用容器再合适不过。启动一条命令,删除一条命令,版本随便切。
而你自己的应用代码,直接在宿主机跑。编辑器体验完整、调试器直连、热重载正常、日志直接在终端里。没有任何中间层。
我们的 compose 文件因此变得很短,只有三个服务,不到 40 行。启动时间从原来的 90 秒降到 8 秒。
几个具体的实践
镜像版本必须写死。 image: mysql:8.0 看着挺具体,其实 8.0 是个滚动标签,今天和三个月后拉到的可能不是同一个。我们被这个坑过一次:某天所有新人的环境都起不来,老员工的没事,因为他们本地缓存的是旧镜像。现在我们写完整版本号,比如 mysql:8.0.36。
用具名卷,不用匿名卷。 匿名卷在 docker compose down 之后就找不回来了。具名卷可以复用,也可以在需要时精确删除某一个:
volumes:
pgdata:
services:
db:
volumes:
- pgdata:/var/lib/postgresql/data端口别用默认的。 我把 MySQL 映射到 13306 而不是 3306,本机装的 MySQL 和其他项目就都不会打架。多敲几个字符,省掉无数次「端口已被占用」。
初始化脚本挂进去。 多数数据库镜像支持把 SQL 文件挂到某个目录,首次启动时自动执行。我们把建表语句和一小份种子数据放那儿,新人克隆仓库后一条命令就有能用的环境,入职第一天的搭建时间从半天变成十分钟。
关于 Mac 上的文件挂载性能
如果你确实需要把代码挂进容器(比如运行环境实在没法在宿主机装),在 macOS 上一定要注意 bind mount 的性能。
原因是虚拟化层的文件系统同步开销。我们测过一个有 4 万个文件的项目,在宿主机上跑测试 12 秒,挂载进容器跑 3 分 40 秒。差了将近 20 倍。
缓解办法:把依赖目录(node_modules、vendor 之类)放进具名卷而不是从宿主机挂载;挂载时加 :cached 标记。但最好的办法还是不挂载——回到上面那条分工原则。
Dockerfile 的分层
虽然开发环境用得少,但构建镜像时这条依然重要:变化频率低的放前面,变化频率高的放后面。
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .先复制依赖清单、装依赖,最后才复制代码。这样只要依赖没变,前面的层就命中缓存。我们调整了一次顺序,CI 的构建时间从 6 分钟降到 50 秒。
配套的是 .dockerignore。没有它,COPY . . 会把 .git、本地日志、缓存、虚拟环境全塞进镜像。加上之后我们的构建上下文从 900MB 降到 12MB。
最后一个观点
Docker 在开发环境里的核心价值,是消除「在我机器上是好的」这类问题中,属于外部依赖的那一部分。它保证你和同事用的是同一个版本的数据库、同样的初始数据、同样的配置。
它不擅长承担「统一每个人的编辑器和 shell」这种职责,那部分该留给宿主机。分清这条界线,Docker 就是个省心的工具;分不清,它就是每天早上跟你打一架的对手。