入门Docker的时候大多数人第一节课学的是docker pull以为把一个镜像拉到本地就算“会用镜像”了。我也一样头几天就是反复pull、images、run觉得自己已经是容器玩家了。直到有一天我试图跑一个旧项目的镜像发现启动失败、镜像巨大、架构还不匹配这才意识到pull只是打开了门真正“用”镜像是另一套逻辑。这篇是镜像篇的下半部分。上一篇已经把镜像和容器的基本区别讲透了这一篇直接进入实际操作怎么拉镜像才不会出错、怎么管理本地镜像的体积、run的时候你其实在和镜像的哪一层打交道、以及当你需要一个自定义镜像时到底该走哪条路。适合那种已经跑通了hello-world、下一步想把镜像玩明白的人。1. 拉取镜像不只是docker pull命名规则、tag陷阱和平台差异1.1 镜像名其实是一串有结构的地址很多新手只会照抄文档里的nginx、redis、ubuntu真到了要拉私有镜像或者想拉某个组织发布的镜像时就直接懵了。我建议我们先拆一下镜像名的结构它其实由四部分组成[仓库地址/] 命名空间 / 仓库名 : 标签举个例子registry.cn-beijing.example.com/myteam/web-server:v1.0拆开看就是——仓库地址是registry.cn-beijing.example.com命名空间是myteam仓库名是web-server标签是v1.0。具体来说仓库地址默认情况下可以省略系统会去你配置的默认公共仓库找。如果没写地址就是使用默认仓库。命名空间可以简单理解成“谁发布的”。官方镜像通常省略这个层级比如写ubuntu:22.04它实际上相当于某个官方命名空间下的ubuntu仓库第三方组织的镜像一般都会带一个前缀比如bitnami/nginx这里的bitnami就是命名空间。仓库名这层才是真正意义上的软件名。标签表示具体版本下面单独说。所以当你看到一份文档让你docker pull someorg/someapp:2.3你应该立刻反应过来这是一个带命名空间的第三方镜像而不是官方镜像。熟悉了这个结构你以后不论是从默认公共仓库拉还是从公司内部仓库拉都不会把地址写错。1.2 别把latest当作版本号用标签这件事我得单独拎出来说。latest是新手最容易踩的坑——它不是一个具体的版本记录而是仓库上一个“当前默认”的浮动指针。什么意思nginx:latest今天指向镜像A半年后你再 pull它可能已经指向镜像B了因为维护者把新版本推上去并更新了latest标签。最典型的教训是团队里某个人在部署脚本里写死了nginx:latest线上跑了一年后重新拉镜像重启结果软件行为变了排查到天亮才发现是版本漂移。生产环境里我建议这样做尽量使用明确版本号的标签比如nginx:1.27.3。如果服务对版本极度敏感可以更进一步用镜像摘要digest锁定。方法是先docker pull nginx:1.27.3然后查docker images --digests拿到那个sha256:...字符串。后续拉取可以用nginxsha256:xxxx的形式即使哪天这个标签被维护者删除或覆盖你依然能拿到当时那块一模一样的镜像内容。这种做法其实是把“版本标签”和“行为一致性”彻底解绑属于我自己这些年一直坚持的稳妥方案。新项目可以嫌麻烦不写 digest但至少要养成“不用 latest 上生产”的习惯。1.3 平台架构为什么有人的镜像拉下来跑不动镜像里的程序不是凭空运行的它们有针对特定 CPU 架构编译过的内容。同一个软件通常有amd64、arm64、386等等多个平台的版本。默认情况下Docker 会做一次“自动适配”按照当前机器的架构去拉对应平台的镜像这个过程一般是正常的。真正的问题出现在两个场景里你在 Apple 芯片上跑 Linux 容器默认拉不到arm64版本时的表现还算正常但如果某个老旧镜像只发布了amd64版本Docker 并不会直接拒绝它会静默拉取amd64的镜像然后靠模拟层硬跑。这时候你会发现容器能起但 CPU 占用高、响应慢、偶尔还有奇怪的错误。反过来你在 x86 机器上拉了一个只提供arm64的镜像那它很可能直接提示exec format error。怎么解决在拉取时显式指定平台docker pull --platform linux/amd64 nginx:1.27.3 docker run --platform linux/amd64 nginx:1.27.3这里我只建议在明确知道要跨平台时使用。日常在通用服务器上做开发时这一节的内容大家心里有数就行不需要每次都加参数。2. 管理本地镜像库看懂列表、正确打tag、清理不用的镜像2.1 docker images输出里哪些信息值得看docker images大概是使用率最高的命令了但很多人只是看一眼有没有名字就完事。我习惯把它当成“本地镜像清单”来读核心字段就五个REPOSITORY、TAG、IMAGE ID、CREATED、SIZE。其中IMAGE ID值得多说一句它是镜像内容唯一标识符的前12位真正的sha256是全量的64位。同一份镜像内容不管被打多少个 tagIMAGE ID都不会变。如果你怀疑两个名字是不是同一个镜像直接比对IMAGE ID就知道了。再说SIZE。这里有个误区表格里的体积只是镜像压缩前的分层累计大小并不是容器运行后在磁盘上占用空间的总和。容器运行期间产生日志、临时文件、数据卷内容这些都不会同步到docker images的列表里。想要看真实的磁盘占用用docker system df它会按镜像、容器、数据卷、构建缓存分类显示这才是服务器容量告警时应该看的命令。2.2 打tag不等于复制镜像docker tag是个很容易被误解的命令。很多新人以为 tag 是“复制一份新镜像”其实它只是给同一个镜像多起了一个名字。镜像的分层是共享的磁盘上并不会多出一份数据。我在实际工作中经常会用 tag 做两件事一是给即将发布的版本补一个语义化标签。比如本地构建完myapp:commit-abc123后确认测试通过了再执行docker tag myapp:commit-abc123 myapp:v1.2.0 docker tag myapp:v1.2.0 myapp:latest这样 commit 标签保留了可追溯性v1.2.0是发布版本latest只是方便日常拉取。它们指向同一个镜像 ID互不冲突。二是给别人的镜像打上自己内部的命名空间方便推到私有仓库。比如docker tag nginx:1.27.3 registry.example.com/infra/nginx:1.27.3 docker push registry.example.com/infra/nginx:1.27.3明白这层原理之后你就知道删除 tag 并不会真正删除镜像数据只有当你把一个镜像的所有 tag 都删掉它才会变成“无主镜像”等待被清理。2.3 清理镜像的正确姿势镜像清理是个长期课题服务器磁盘被镜像占满的尴尬我经历不止一次。先说最安全的清理只删除不再使用的悬挂镜像。悬挂镜像dangling image指的是已经没有 tag 关联的镜像层数据。它们通常是重新构建镜像后产生的残留显示为none:none。清理命令是docker image prune它默认只删悬挂镜像不会动到你还在用的版本。想要更彻底一点可以加上-adocker image prune -a这会清理所有未被任何容器使用的镜像而不只是悬挂的。注意如果你某个镜像虽然没用但一会儿可能还要回滚那就别加-a否则重新拉取时会很麻烦。还有一个常见报错场景删除镜像时提示image is being used by container原因很简单有一个容器哪怕已经退出还在引用它。Docker 不允许删除被引用的镜像除非你先删除那个容器或者用docker rmi -f强制删。但-f我不建议乱用它容易把还能追溯的镜像关系搞乱。更推荐的做法是docker rm 容器ID docker image rm 镜像ID按顺序清理。养成“先容器后镜像”的习惯磁盘维护会省心很多。3. 从镜像到容器run命令背后的镜像运行机制3.1 docker run到底在和镜像做什么docker run是使用镜像最重要的一步因为它真正完成了“镜像 → 容器”的转变。从底层看Docker 会做三件事从镜像创建一层可写容器层、分配网络和目录资源、执行启动命令。写时复制copy-on-write这个机制我喜欢用很生活化的例子解释镜像是一张印刷好的图纸容器是你在图纸上铺的一张透明膜。你在膜上随意涂改图纸本体永远不变。用同一个镜像跑十个容器它们各自在透明膜上操作互不影响底层共享同一份图纸这就是容器比虚拟机轻量的核心原因之一。所以docker run nginx之后你在容器里改了/etc/nginx/nginx.conf然后删掉容器你的修改也就一起消失了。镜像还是原来那块干干净净。这个特性对日常使用很重要——它意味着镜像本身是不可变的你不用担心跑容器会搞脏基础镜像。3.2 为什么你run一个ubuntu镜像会秒退新手最常见的困惑是我拉了一个ubuntu:22.04执行docker run ubuntu:22.04结果什么都没发生就退出了是不是镜像有问题不是的。这跟容器生命周期有关。容器的生命周期取决于它的主进程PID 1主进程运行期间容器是活的主进程一退出容器就停止。ubuntu镜像默认执行的命令是bash而bash在没有交互输入的情况下会立刻读取到 EOF然后退出于是容器也就跟着退出了。这不是 bug是设计。想让这样的镜像“活”下来要么给它常驻任务比如跑一个 web 服务要么用交互模式把你连进去docker run -it --rm ubuntu:22.04 bash-i是保持标准输入打开-t是分配一个伪终端--rm表示退出后自动删除容器。这套参数组合对于调试基础镜像特别常用你进去之后可以装软件、查文件、跑命令退出即清理不会留下堆积的中间容器。3.3 端口映射、环境变量和数据卷与镜像的边界如果镜像内置了一个监听端口的服务比如nginx:1.27.3监听 80 端口那在容器外要怎么访问它我最初以为只要容器跑起来宿主机就能直接访问 80实际当然不行。容器有独立的网络命名空间宿主机要访问容器里的服务需要显式把宿主机的某个端口映射到容器的端口docker run -d --name web -p 8080:80 nginx:1.27.3这里的-p 8080:80意思是把宿主机的 8080 端口转发到容器的 80 端口。这个映射关系只存在于运行这个容器时不会修改镜像本身。环境变量同理。很多镜像通过环境变量控制行为比如docker run -d --name app -e TZAsia/Shanghai -e APP_ENVproduction 某镜像环境变量在镜像执行时注入镜像文件不变。数据卷-v也是如此把宿主机目录挂载进容器后容器内对这个目录的写入会落回宿主机。这三个参数一起理解之后你会发现镜像就像一套“只读模板”所有可变的东西都由运行参数告诉 Docker 引擎。掌握了这层认知后面理解 Dockerfile 的时候会非常顺畅。4. 自定义镜像的两条路commit的快捷方式和Dockerfile的正规方法4.1 docker commit能用但不该依赖最早是我刚接触 Docker 时很多教程教的流程是起一个基础镜像的容器进去手动apt install、改配置、装依赖最后docker commit把它固化成自己的镜像。这条路确实能走通而且在调试阶段很直观docker commit 容器ID myapp:v0.1但我后来在一个模拟项目里用它连续封装了十几次之后发现一个问题这个镜像是怎么构建出来的完全只能靠你自己当时的记忆。今天是手动改了/etc下的一个配置文件明天是顺手装了个调试工具一周之后镜像是怎么变成这个样子的没有任何记录。而且每次 commit 都会基于当前容器层追加一层手动操作越多层越臃肿最后镜像体积能比初始基础镜像大出一倍还多。我见过有人 commit 出来的某个中间件镜像有 2 个多 G里面一半都是早该删掉的临时文件。所以我的观点很明确docker commit适合临时保存“验证中的现场”比如你调试到一半怕容器崩了内容丢失快速 commit 一下做个快照。它不适合作为长期重复构建的方案。真正要沉淀成可复用的镜像下一步。4.2 Dockerfile镜像怎么被“制造”出来的说明书Dockerfile解决的核心问题就是可复现。它把构建步骤写成文本放进来就能复现出几乎一致的镜像。先看一个最小示例FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ CMD [python, app/main.py]逐行说FROM基于哪个镜像开始构建这一行决定了你的基础环境。WORKDIR设置容器内的工作目录没有这个目录会自动创建。COPY把构建上下文里的文件拷贝进镜像。RUN在构建过程中执行命令比如安装依赖。CMD容器运行时默认执行的命令。构建命令是docker build -t myapp:v1.0 .结尾这个点代表构建上下文路径Docker 会把这个路径下的文件打包发送给引擎处理。理解了这一点你就会发现COPY只能引用构建上下文里的文件放到上下文之外的程序文件它是复制不进来的。RUN和COPY每一行都会产生一个新的镜像层而层与层之间有缓存机制如果某一行之前已经构建过且内容没变Docker 会直接复用缓存不必重新执行。这就是为什么多阶段构建里常常把依赖安装写在前面——requirements.txt不变时后续每次改代码都能命中缓存构建速度快很多。4.3 别把宿主机的垃圾带进镜像里构建上下文的设计初衷是为了方便但也带来了一个经典问题很多人不写排除规则直接把整个项目目录作为上下文docker build .结果项目里塞了几个 G 的日志、数据库备份、node_modules 之类的东西全被打包上传给 Docker 守护进程。构建又慢又卡最后镜像还带着一堆不该出现的内容。解决办法是加一个.dockerignore文件语法类似.gitignore至少写上这些.git node_modules *.log .tmp .cache这个文件必须在构建上下文的根目录下。写完后docker build发送的数据量会显著下降。我见过一个真实场景同事加了.dockerignore之后构建发送的上下文从 800M 降到 12M整个镜像构建时长从十几分钟降到半分钟。这差距不是配置优化带来的就是没做好“上下文瘦身”。关于CMD和ENTRYPOINT的区别简单提一嘴ENTRYPOINT定义容器启动后不可替换的主进程CMD可以看成是传给ENTRYPOINT的默认参数也允许在docker run后面覆盖。想要写一个灵活而又稳定入口的镜像配置通常会组合使用。新手阶段先从模糊认知开始没有关系等到真正设计应用镜像时再仔细琢磨。5. 镜像的备份、迁移与溯源save/load、export/import与history的配合5.1 两对打包命令名字相似套路完全不同当你要把镜像从一台机器搬到另一台机器或者在没有外网的服务器上部署就绕不开镜像的保存与恢复。这地方有四个命令save、load、export、import。我见过无数人把它们混着用结果恢复出来的东西根本不是原来那个镜像。按照层级信息保留与否我把它们分成两组命令对对象保留历史分层典型用途docker save/docker load镜像保留完整备份迁移镜像推荐使用docker export/docker import容器不保留压平成单层把容器转成快照特殊场景才用常用做法是docker save nginx:1.27.3 -o nginx.tar docker load -i nginx.tarsave出来的 tar 包包含完整的镜像分层和元数据load进去之后还是一个正经镜像可以直接跑。而export导出的是容器当前文件系统的快照不包含镜像本身的构建历史导出的东西import回来后严格意义上不是一个“镜像”或者说层信息已丢失后续想追溯这个镜像怎么构建的基本不可能。我的经验是默认只使用save/load除非你有把握知道自己在干什么才去碰另一组。5.2 用history看穿镜像内部是怎么长出来的拿到一个陌生镜像又想知道它是怎么构成的不需要把它跑起来直接看构建历史docker history 某个镜像输出的每一行对应镜像里的一层可以看到每一层是由什么指令产生的、体积多大。这个命令有两个实用场景一是排查镜像为什么这么大通常你会看到某一个RUN指令产生的层特别大说明它安装了大量依赖却没有清理二是在接手同事的镜像时快速理解它的大致构建流程虽然不是完整的 Dockerfile但足以建立基本认知。配合docker inspect可以查看镜像的配置信息比如暴露的端口、环境变量、工作目录等。有次我拿到一个别人写好的镜像从inspect里看到它默认命令是启动某个服务端口是 8080环境变量缺一个就不启动现场五分钟就定位了问题省去了盲试的时间。5.3 不进容器把镜像里的文件抠出来有时候你不想把容器跑起来只是想看看镜像里的某个配置文件长什么样或者想拷一个文件出来参考。最快的方式是启动一个临时容器用--rm不让它留下痕迹docker run --rm -d --name tmp 目标镜像 sleep 3600 docker cp tmp:/etc/nginx/nginx.conf ./nginx.conf docker rm -f tmp如果是基于那些没有sleep命令的极简镜像或者你想直接从一个镜像里把文件拷贝出来也可以用构建的方式。在 Dockerfile 里用COPY --from镜像可以随意从另一个镜像取文件这也是多阶段构建里很常见的操作FROM 某个目标镜像 AS base FROM alpine:3.19 COPY --frombase /etc/app/config.yml /data/config.yml新手阶段不建议太早研究多阶段构建但docker cp配合临时容器这一套值得早点掌握很多日常排查和调试都会用到。最后说说我自己的镜像整理习惯这篇文章讲的是使用镜像的完整链路从拉取、管理、运行到构建和迁移。如果你全部读完了我建议再自行做一个小实践挑一个自己常用的小软件写一个最简单的 Dockerfile 把它构建成镜像再导出、导入、查看历史、跑一次。这套动作做完你对“镜像”二字的理解会和只敲docker pull完全不同。我自己后来在团队里立了几条不成文的规矩所有镜像执行任务时尽量加--rm避免容器堆积基础镜像写明确 tag重要服务锁定 digest定期执行docker image prune清理悬挂镜像手写的 Dockerfile 全部提交到版本仓库。这些规矩没什么高深的地方都是被踩过的坑逼出来的经验。镜像本身不难难的是你愿不愿意从一开始就按照“可复现、可追溯、可清理”这三个原则去使用它。做到这三点容器化的入门才算真正踏踏实实落地了。