把 Docker 当虚拟机用,是踩坑的开始
一、先说结论
Docker 解决的核心问题不是"部署",是交付物的一致性。
它最大的价值,是把环境问题从"线上事故"提前成了"构建失败"——失败前移,代价从半夜被叫起来,变成流水线上一条红叉。想明白这一点,很多用法上的取舍就自然了。
但绝大多数人对 Docker 的误解,都始于同一句话:"这不就是个轻量级虚拟机吗?"
不是。容器没有 init 系统、没有自己的一整套服务、没有"状态"这个概念。它是一组被隔离的进程,加一层写时复制的文件系统。你一旦把它当虚拟机用,就会开始在里面装 sshd、装 cron、装 supervisor、装一堆本该属于宿主机的服务——然后你的镜像变成一个几千兆的、谁也不敢动的黑盒。
我判断自己有没有跑偏,就看一个问题:我是不是想 SSH 进去改东西?
如果想,那要么是镜像构建得不完整(缺的东西应该写进 Dockerfile),要么是我在做临时排障(那就该用 docker exec 进去看一眼,看完就走,绝不留下痕迹)。
二、"在我机器上能跑",是怎么被终结的
这句话的解法不是 Docker,是"把环境写死"。
三件事必须钉死:
第一,基础镜像的版本。 FROM node:latest 是给自己埋雷。今天 latest 是 20,半年后是 22,某天你重新构建,一切照旧却突然跑不起来了。写 node:20.11-slim,甚至写 digest,别写 latest。
第二,系统依赖。 以前我们靠一份《部署文档》,里面写着"记得装 libpq-dev 和 tzdata",然后新同事照着做,总漏一项。现在这些进 Dockerfile,文档会过期,Dockerfile 不会。
第三,构建产物不可变。 这一条最容易被忽略:生产环境永远不 build,只 pull。 而且 pull 的时候尽量按 digest 拉,而不是按 tag——tag 是可以被覆盖的,digest 不能。
做到这三点,"在我机器上能跑"这句话才真正消失。
三、镜像瘦身:我在 alpine 上栽过的跟头
刚学的时候听说 alpine 小,5MB,觉得很优雅,把什么都往 alpine 上搬。后来发现代价都藏在细节里:
- musl 不是 glibc。 Python 编译某些 C 扩展直接报错;某些预编译的二进制根本跑不起来。你会花一整天在"为什么 pip install 失败"上。
- 没有 tzdata。 容器里时间永远是 UTC,日志和别人对不上,排查问题时先差 8 小时。
- 没有 ICU / locale。 中文直接变问号。
- DNS 行为不一样。 musl 的解析器和 glibc 在
ndots、并发查询上的默认行为有差异,在 K8s 里表现为"偶发的域名解析超时",非常难查。
我的结论很实用主义:基础镜像默认选 -slim 的 Debian,除非我确定这个镜像里没有任何原生依赖。 体积从 1GB 降到 200MB,收益已经拿到了;为了再省 150MB 去和 musl 搏斗,通常不值。
真在意体积,正确姿势是多阶段构建,而不是换一个更小的底座:
还有一个几乎人人都踩过、但很少有人写进文章的坑:.dockerignore。
没写它的时候,docker build . 会把整个目录(包括 node_modules、.git、日志、数据库文件)打进构建上下文传给 daemon。我见过最夸张的一次,构建上下文 3.2GB,光传输就等了两分钟,而实际需要的源码只有 4MB。
四、构建缓存:为什么别人构建 20 秒,你要 3 分钟
Dockerfile 的每一行是一个层,某一层变了,它下面所有层全部失效。
所以指令顺序应该按"变更频率"倒序排:越不常变的东西越往前放。
在 CI 里还要多做一步——把缓存搬出机器:
BuildKit 还有个更省事的能力,挂载本地缓存目录,重装依赖时不用重新下载:
这一条用上之后,我在 CI 上的构建时间从 4 分多降到 50 秒左右。优化的第一步永远不是换机器,是看缓存命中率。
五、数据:容器可以死,数据不能
这是新手最容易出血的地方:往容器里写文件,docker rm 之后一切归零。
正确的理解是——容器的文件系统是一次性的,任何需要留存的东西都不该住在里面。
volume 和 bind mount 我现在的用法分得很清楚:
|
场景 |
选择 |
原因 |
|
数据库数据目录 |
named volume |
由 Docker 管理,跨平台行为一致,性能正常 |
|
配置文件、代码 |
bind mount |
需要直接在宿主机编辑 |
|
macOS/Windows 上的大文件读写 |
尽量不要挂 |
文件系统转换层会让 IO 慢一个数量级 |
最后一条值得单独说。在 Mac 上把数据库数据目录 bind mount 到 ~/ 下面,导入数据能比 volume 慢 5 到 10 倍。这不是 Docker 的问题,是跨文件系统语义转换的必然代价。开发环境图省事可以忍,生产环境别这么干。
另外两个细节:
- 备份不要用
cp拷容器里的文件。 正在写入的数据库文件拷出来是坏的。老老实实mysqldump/pg_dump/redis-cli SAVE。
- UID 不匹配。 容器里以 root 写入的文件,宿主机上你就得 sudo 才能删。反过来,宿主机 UID 1000 挂进容器而容器里没有这个用户,就是
Permission denied。我用过最省心的做法是在 Dockerfile 里创建和宿主机同 UID 的用户,构建时用--build-arg UID=$(id -u)传进去。
六、网络:默认 bridge 是个陷阱
第一次遇到"两个容器互相访问不到",我以为是防火墙问题,其实是:默认的 bridge 网络没有 DNS 服务发现。
容器之间只能靠 IP 通信,而 IP 每次重启都变。于是有人开始用 --link(早就废弃了),或者把 IP 写进配置(然后某天全部失效)。
正确做法只有一条:自己建一个网络。
在自定义网络里,容器名就是域名,db 直接能解析。用 Compose 的话更省事——它会自动给你建一个专属网络,服务名天然可解析。
顺带一个原则:端口只在真正需要对外的时候才 -p 暴露。 内部服务之间的通信走容器网络,压根不需要发布端口。我见过把数据库 5432 发布到 0.0.0.0 的公网机器,只能说勇气可嘉。
七、资源限制:不设限制,就是在赌运气
默认情况下,容器能吃掉宿主机所有的内存。一个内存泄漏的容器,可以把整台机器拖死,然后所有服务一起挂。
但设了限制之后有个反直觉的坑:应用看不见这个限制。
JVM 老版本按宿主机内存算堆大小,容器限了 512MB,它照样按 32GB 去规划,然后被 OOM Killer 直接杀掉,日志里连一句遗言都没有。所以要显式告诉运行时:
还有一点:CPU limit 是配额,不是绑核。 它保证你最多用这么多,但不会给你独占核心,别指望拿它做实时性保证。
顺带说 OOM:容器被 OOM Killer 干掉后,docker ps -a 只显示 Exited (137)。看到 137,先怀疑内存,别怀疑代码。
八、启动顺序:depends_on 不等于"准备好了"
这是我认为最容易误导人的一个配置项。
depends_on 默认只保证启动顺序,不保证服务可用。数据库容器"已启动"和"能接受连接"之间,可能差着十几秒。于是应用连不上数据库,直接退出,重启,再退出——restart 策略把它变成了一个完美的崩溃循环。
Compose 里的正解是配合健康检查:
但比这更可靠的,是应用自己会重试。编排工具的健康判断总有边界情况,而"连不上就退避重试 30 秒"这个逻辑写在代码里,永远管用。
别把可靠性寄托在编排上——这句话我对每个刚上手容器的人都说过。
九、那些没人提醒你、但一定会咬你一口的小事
日志会把磁盘撑爆。 json-file 驱动默认不限大小,一个话痨服务能在一个月内把 /var/lib/docker 写满,然后整个 Docker 罢工。全局配一下:
时区。 加 ENV TZ=Asia/Shanghai 还不够,镜像里必须有 tzdata,否则这个变量是空的。
中文乱码。 加 ENV LANG=C.UTF-8。
僵尸进程。 容器的 1 号进程要负责回收子进程,而你的应用通常不会干这活。加 --init 让 tini 来做,一行参数省掉一堆诡异问题。
磁盘去哪了。 docker system df 先看清楚是镜像、容器还是卷占的。清理时注意 docker system prune -a 会把所有没在运行的镜像都删掉,包括你明天还要用的那个。
十、什么时候我不用 Docker
这一点很少有人写,但我觉得是成熟度的一部分。
- 一次性的小脚本、临时工具:
docker run的启动开销和路径映射的麻烦,超过它带来的隔离收益,直接跑更快。
- macOS / Windows 上的重度 IO 场景:编译大项目、跑数据库,文件系统层的代价是真实存在的,能上 Linux 就上 Linux。
- 需要内核模块、eBPF、硬件直通:容器的隔离边界会一直和你打架,这种时候虚拟机是对的。
- 只有一个服务的个人项目:裸机 + systemd 更省心。Docker 的收益来自"多服务 + 环境一致性",单服务享受不到多少,却要多维护一层。
十一、最后
如果只留一句话:
容器是不可变的交付物,不是一台你可以随便进去折腾的机器。
判断自己有没有真正上手,看这几个下意识反应就够了:
- 拿到一个需求,先想"这个容器要不要状态、日志去哪、挂了谁拉起",而不是先
docker run;
- 出问题时第一反应是
docker logs和docker inspect,而不是想办法进去改;
- 生产环境的镜像只 pull 不 build,且能说出它的 digest;
- 会主动给容器设内存上限,并且知道应用自己看不看得见这个上限。
命令是一天学会的,这些习惯大概要一两年。共勉。
- 点赞
- 收藏
- 关注作者
评论(0)