1. 镜像、容器、仓库这三个词,理解对了就赢了一半
但凡接触过一点 Docker,基本都听过"镜像、容器、仓库"三件套。但我在实际带项目的时候发现,很多人是"背住了名词,没建立模型"——最典型的表现就是把 Docker 当成一台轻量虚拟机来用:在里面装全套环境,跑一个"常驻系统",容器一停就等于关机。这个认知偏差不纠正,后面学再多命令也是空中楼阁。
1.1 镜像不是安装包,是一层一层叠出来的只读模板
第一次用 Docker,往往是从docker pull ubuntu开始的。拉下来一看,体积好几百兆,很多人就以为是"一个完整的 Ubuntu 系统搬进来了"。这是最大的误解。
镜像是分层(layer)叠加的结果。构建一个镜像时,每一条会改变文件系统的指令都会产生一个新层,例如RUN apt-get install、COPY,而每一层只记录"相对上一层的差异"。比如你在 Ubuntu 基础镜像上装了一个 Nginx,新增的只有 Nginx 相关的文件和包管理缓存,并不会把整个根文件系统复制一份。多个容器共享同一个基础镜像时,这些公共层在磁盘上只有一份,这就是为什么能用一条docker run ubuntu秒起一个容器,而不是像虚拟机一样经历完整的系统引导。
理解分层还有一个实际用处:排查镜像为什么这么大。用docker history 镜像名可以看到每一层的大小和创建指令;用docker image inspect 镜像名可以查看层列表。我在优化镜像体积时,第一步永远是看 history,定位是哪一层引入了体积暴涨的文件,然后针对性地做清理。
提示:
docker pull下载的是多个层的压缩包,本地保存的是解压后的层目录。所以"拉下来的镜像体积"和"磁盘上实际占用"不一定完全一致,要看docker system df才有准确数字。
1.2 容器是镜像的一次运行实例,但它仍然只是进程
镜像本身是个静态文件。真正让它跑起来,是docker run创建了一个容器:Docker 会在镜像最上面加一层可写层(container layer),然后启动一个用户态进程,把这个进程隔离在一个独立的命名空间里。
这句话值得再读一遍:容器里跑的是进程,不是操作系统。
它有自己的 PID 空间(容器里的 PID 1 不等于宿主机的 PID 1)、独立的网络栈和挂载点,但它和宿主机共享同一个内核。所以你在容器里执行uname -r看到的还是宿主机内核版本,这也是容器比虚拟机轻的根本原因。
这个模型解释了许多"翻车"现象:
- 容器启动后立刻退出,往往是因为镜像里配置的启动命令是一个前台进程,而它执行完就结束了,容器也随之退出;
- 容器里
kill 1可以干掉整个容器,因为 PID 1 就是这个容器的主进程; - 修改容器里的文件,容器删除后修改就丢了,除非把改动提交成新镜像或挂载数据卷。
1.3 仓库与人分享,tag 和 digest 才是精确定位
镜像靠仓库分发。默认使用的就是 Docker Hub,一条docker pull nginx:1.25里,nginx是仓库名,1.25是 tag,省略前缀时默认从官方仓库拉取。
维护生产环境的人,我建议多盯一眼digest。同一个nginx:1.25tag,镜像内容可能被仓库维护者重新推送过,导致不同时间拉下来的镜像并不完全一样。真正不可变的是nginx:1.25@sha256:xxxx这种按 digest 引用。如果你们的发布流程对一致性要求极高,可以构建时记录 digest,回滚时按 digest 拉取,而不是对着一个 tag 赌运气。
2. 跑通第一个容器之前,先搞懂命令背后的执行路径与守护进程
很多人学 Docker 是"记命令",docker run -d -p 8080:80 nginx跑通了,就觉得自己会了。可一旦端口不通、容器没起来、日志打不出来,就全抓瞎。我建议先搞清楚这条命令从输入到回车,后台到底发生了什么。
2.1 从安装到守护进程,别跳过 daemon 配置
Linux 上主流做法是通过发行版软件源安装 docker-ce 和 containerd,然后把docker服务设为开机自启。装完之后不要急着拉镜像,先检查一个文件:/etc/docker/daemon.json。
这是 Docker daemon 的配置文件,很多"疑难杂症"都在这里埋着。常用配置项包括:
| 配置项 | 作用 | 建议 |
|---|---|---|
>docker run -d \ --name web \ -p 8080:80 \ --memory=512m \ --cpus=1 \ --restart=unless-stopped \ nginx
3. Dockerfile 是一份可追溯的构建清单,优化点决定交付质量用 3.1 基础镜像不是越全越好选基础镜像的原则很简单:够用就好。同样的应用需要,直接写 同时,避免使用 3.2 指令顺序与层缓存机制:把变动频率低的放在前面Docker 构建时会逐层检查缓存:如果当前指令和上一层内容都没变,就会复用层缓存,而不是重新执行。利用这个机制,我把依赖安装放在源码拷贝之前: 如果反着写,每条 3.3 多阶段构建:把编译环境和运行环境分开多阶段构建的核心价值是:构建过程中需要的工具链(编译器、依赖包)很大,但运行期根本用不上。与其费劲在一个镜像里删删减减,不如把"构建"和"运行"分成两个阶段。 以 Go 服务为例: 最终镜像里只有编译好的二进制和运行时依赖,没有一丁点编译源码或中间产物。很多人觉得多阶段构建复杂,实际操作一遍,看到体积对比和启动体验后,就再也不想用单阶段方案了。 3.4 ENTRYPOINT 和 CMD 的关系,以及 PID 1 的信号问题这两者的关系是:命令行参数会覆盖 CMD,但不会覆盖 ENTRYPOINT。最稳妥的用法是ENTRYPOINT 写固定的主程序,CMD 写默认参数,比如: 用户通过 容器里的 PID 1 还有一个经典问题:信号转发。默认情况下,发往容器的 SIGTERM 信号不会自动转发给 PID 1,如果主进程没有处理信号, 4. 数据与网络:容器重启不可怕,数据丢了才可怕容器是"一次性"的,这句话的潜台词是:删除容器时,容器可写层里的数据会一起消失。对无状态服务来说,删了重建是好事;但对数据库、应用上传文件、日志这种有状态数据,必须主动管理。 4.1 三种挂载方式:匿名卷、命名卷、bind mount
命名卷放在 还有一种 bind mount,把宿主机目录直接映射进容器,比如开发时热加载源码、把宿主机日志目录挂给容器。好处是看得见、改得方便,坏处是宿主机目录的权限问题容易引发容器内读取异常。我的原则是:开发调试用 bind mount,生产数据用命名卷。 4.2 卷权限问题:postgres 起不来的常见元凶一个非常典型的问题: 4.3 网络模型不止 bridge:容器之间怎么通信默认的 bridge 网络里,容器之间通过 IP 可以互通,但 IP 会变化。想让容器通过服务名稳定访问,最好的办法是创建自定义网络: 在自定义网络里, host 模式适合对网络性能极度敏感的场景(比如部分网络转发服务),但缺点很明显:没有隔离网络栈,不能用 4.4 网络排查三板斧遇到容器之间访问不通,我一般按这个顺序排查:
很多"网络不通"最后都栽在第 1 步:容器明明在同一宿主机,但一个在自定义网络、一个在默认 bridge,IP 互通规则完全不同。先查网络归属,比盲改防火墙靠谱。 5. Compose 把"一堆 docker run"收拢成一份声明式配置,但细节决定成败当服务数量超过五六个,逐个敲 5.1 文件骨架与常用指令一个最简骨架: 注意我在这里加了一个 5.2 depends_on 不等于"服务已经就绪",健康检查才是
解决办法是加健康检查: 这一条配置能救很多人的命。 5.3 环境变量与配置文件分离Compose 写死环境变量是安全反模式。正确做法是用
6. 生产环境里最容易翻车的五个场景,我把排查链路写出来了标题里那句"拿捏的死死的",最终要落到生产环境中能冷静处理问题。下面五个翻车场景,是按出镜频率排的,每一个我都真实见过或者踩过。 6.1 容器启动后立即退出现象: 排查链路:
这个场景八成的根因是"没有前台运行"。 6.2 端口冲突与诡异访问不通现象: 排查链路:
6.3 日志文件撑爆磁盘现象:磁盘告警,看 根因:默认 解决: 改完 daemon.json 重启后,对存量容器需要重建才会生效。更严重的设计问题是:大量应用把日志打到 stdout,没有结构化日志链路;我建议在 daemon 层面做好轮转兜底,应用层面再按规范输出到指定文件或直接送日志中心,两层都不能省。 6.4 时区与编码不对现象:容器内 排查链路:
6.5 docker system prune 不能无脑跑
我的建议:生产环境的清理操作必须单独写命令。只清理悬空镜像用 7. 从"会用命令"到"拿捏原理":namespace 和 cgroup 的关键认知到这一步,Docker 的命令、文件、网络、Compose 都通了,再往前走一步就是理解它"凭什么能隔离"。这部分不用深入内核源码,只需要建立两个关键认知。 7.1 namespace:让进程以为自己是"唯一"Linux namespace 是容器隔离的基础。进程创建时,Docker 会为它分配新的 namespace:
类比一下:镜像分层是"文件系统视图的叠加",namespace 是"进程视图的隔离"。容器不是另一个机器,而是同一台 Linux 里一个自成体系的进程组。 7.2 cgroup:限制和统计资源光隔离还不够,还要防一个容器把整台机器吃垮,这就是 cgroup 的职责。 理解 cgroup 对排查有很大帮助:容器内存超限被 OOM kill 时, 7.3 安全边界:容器不等于安全堡垒共享内核意味着容器逃逸一旦发生,危害范围就是整个宿主机。所以生产环境我会建议做几件事:
这些点不是危言耸听,而是在真实环境里反复验证过的底线。Docker 的便利性容易让人忽略它仍然是共享内核的进程隔离,把权限收得越紧,出事的概率就越低。 这篇文章写到这里,基本把 Docker 从镜像模型、命令执行、Dockerfile 构建、数据网络、Compose 编排到生产排错和底层原理都过了一遍。我在实际使用中一个很深的体会是:学 Docker 不需要把每个命令都背下来,真正要建立的是三个模型——镜像分层的存储模型、容器即进程的隔离模型、声明式配置的编排模型。这三个模型在脑子里立住了,遇到没见过的问题也能顺着日志和原理推出答案。 最后分享一个小技巧:日常调试时可以设置全局的环境别名,比如把 |