"unable to find image 'hello-world:latest' locally"——如果你刚接触 Docker,第一次敲docker run hello-world就被这条报错挂在终端前面,你会觉得 Docker 不但难用,还非常不友好。我当年也经历过这个阶段,更别提那时候对 Docker Image(Docker 镜像)的理解完全是跑偏的:我以为它跟系统 ISO 镜像一样,是某个"一整套东西"的大文件,拉下来解压就能跑。直到把镜像的分层机制、仓库体系、构建缓存这些知识点全部串起来以后,再回头看新手期的困惑,我发现几乎所有问题都出在一个根上:镜像到底是个什么玩意。
这篇文章我想用老玩家的口吻,把 Docker 镜像从原理到常用命令,再到自己构建和排障的完整闭环讲清楚。它适合刚装好 Docker 准备第一次拉镜像的人,也适合那些已经会上手docker run、但对镜像的存储结构、Tag、Registry 还没有系统认知的"半新手"。看完之后,你至少能自己回答三个问题:镜像和容器本质区别是什么?镜像命令怎么背最省脑子?写 Dockerfile 的时候为什么层数越少越好?
1. hello-world 都拉不下来?先把对镜像的想象力纠正过来
1.1 镜像不是一个"大压缩包",而是一份只读模板
很多人包括当年的我,会把镜像想象成 VMware 或者 VirtualBox 里的"系统镜像文件"——一个完整的、可启动的磁盘快照。Docker 镜像乍一看确实有点这个意思:它里面包含了程序运行所需的操作系统底层文件、运行时、依赖库和代码。但它的工作方式和虚拟机镜像完全不是一个物种。
虚拟机镜像是一份"完整拷贝",启动虚拟机时把这坨数据直接映射到虚拟磁盘上,你能看到 BIOS 自检、系统启动的全过程;而 Docker 镜像是一份"只读模板",它本身是不能直接运行的,必须由 Docker 引擎基于它创建出一个容器,容器里才跑程序。镜像之于容器,更像是"做蛋糕用的模具"之于"烤出来的蛋糕"——模具可以反复使用,每次用同一个模具做出来的蛋糕都是一个独立的成品。
这个区别带来的第一个实操含义是:镜像可以反复 instantiate 出无数个互不干扰的容器实例。同一个mysql:8.0镜像,你docker run两次,就会得到两个独立运行、数据各自隔离的 MySQL 容器。所以第一件事就是忘掉"镜像=压缩包"的直觉,把它记成"镜像=只读的模板包"。
1.2 docker run 的执行过程:hello-world 到底做了什么
Docker 执行docker run hello-world时,真正做的事情分四步:
- 检查本地是否已经存在
hello-world:latest这个镜像,如果存在就直接用本地的; - 本地不存在,就去配置好的镜像仓库(默认是 Docker Hub)拉取这个镜像到本地;
- 镜像到位后,Docker 引擎基于它创建一个容器;
- 容器启动,执行这个镜像里预设的命令,运行结束后容器退出。
hello-world 这个镜像实际是一个非常小的可执行程序,它的唯一工作就是打印一段欢迎文字然后结束。正因为够小、够简单,它基本不会因为程序自身逻辑报错,所以被官方用来当作"Docker 是否安装成功"的冒烟测试工具。
理解了底层流程,你就会明白,刚才那条unable to find image 'hello-world:latest' locally并不是真正的错误,而是一条"提示"。Docker 只是在告诉你:本地没找到这个镜像,我先去拉取一下。真正让你觉得"失败了"的往往是在拉取环节出现的网络超时或仓库连接失败。这一块我放到第 6 章专门讲排查链路,这里先把概念立住。
2. 镜像是千层饼:分层结构与写时复制
如果只记住一句话,我会让你记住这句:Docker 镜像是一块由多层只读文件系统叠成的"千层饼",容器启动时,Docker 在这块千层饼顶上再加一层临时可写层。
2.1 只读层 + 容器可写层
镜像内部不是平铺的一大坨文件,而是由很多个"层"(layer)从下往上堆叠而成。每一层都记录了相对于上一层的一些文件变更:新增了哪些文件、修改了哪些文件、删除了哪些文件。这些层在构建时一次性生成,之后永远是只读的。
当你用docker run创建容器时,Docker 会在这些只读层之上再叠加一个可写层。程序在运行过程中产生的文件写入、修改、删除,全部发生在最顶上的可写层,底下的镜像层不会被触碰。这也是为什么同一个镜像可以同时启动很多个容器而互不干扰——每个容器都有自己独立的可写层,底层千层饼是共享的。
这里有一个非常关键的机制叫"写时复制"(Copy-on-Write)。容器里改一个文件时,Docker 不会原地去改底层只读层里的那个文件,而是先把文件从只读层复制一份到可写层,然后在可写层里进行修改。这个机制带来的直接体验是:即使你让容器把/etc/nginx/nginx.conf改得面目全非,镜像里那份原始配置文件也完好无损。对使用者来说,你看到的就是一个正常的文件系统;对 Docker 来说,它用"复制再修改"换来了镜像层的全局共享。
2.2 为什么非要分层:共享、增量、缓存三位一体
从工程角度看,分层机制是 Docker 能火起来的基石之一。往大了说有三个收益。
第一,存储共享。你机器上如果同时存在ubuntu:20.04、ubuntu:22.04和基于它们构建的若干业务镜像,这些镜像之间如果共享底部的 apt 层、基础文件层,Docker 只需在一份物理副本上做引用计数,而不是每个镜像都单独存一份完整的根文件系统,磁盘占用能省下非常多。
第二,网络增量。拉取镜像时,如果本地已经有了某个中间层,Docker 只会下载缺失的那些层。比如你更新了业务代码并重新构建镜像,但基础的 JDK 层完全没变,那么部署到新机器上时只需要传输变化的代码层,而不是整个几百 MB 的 JDK 环境层。
第三,构建缓存。构建镜像时每一层都是独立缓存的,只要某一层的内容和构建指令没有变化,这一层及之前的所有层都可以直接复用,大幅缩短重复构建时间。这条在后面讲 Dockerfile 时会继续展开。
2.3 用 docker history 亲眼看看"层"长什么样
说一百遍不如自己看一眼。在终端里拉一个镜像,然后执行:
docker pull alpine:latest docker history alpine:latest你会看到类似下面的输出(具体 ID 每台机器不同):
IMAGE CREATED CREATED BY SIZE d7a6a0d5c4d1 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B <missing> 2 weeks ago /bin/sh -c #(nop) ADD file:... in / 7.05MB这里每一行对应的就是镜像的一层。alpine镜像已经很精简了,依然能看到至少两层:一层负责把基础文件系统内容 ADD 进镜像,一层用来设置默认启动命令。等后面你亲手构建一个多步骤的自定义镜像,docker history会清楚地展示出每一层产生的过程和大小,这比任何文档都直观。
3. 镜像命令实操:入门阶段真正高频的一套组合拳
镜像相关的 Docker 命令看起来很多,但入门阶段真正每天都用的不超过十来个。我给你按"拉取查看、删除清理、打标签、离线搬运、检视内部"五个场景拆开讲。
3.1 拉取与查看:pull、images
docker pull nginx:1.24-alpine拉镜像时可以不带 tag,默认就是 latest;但我建议从第一天起就养成"指定 tag"的习惯,后面专门讲 Tag 管理时你会明白为什么。拉完之后用docker images查看本机镜像列表,重点看REPOSITORY、TAG、IMAGE ID、SIZE四列。IMAGE ID 是镜像的摘要标识,实际使用中你经常只需要写它的前几位,Docker 能自动识别,比如docker rmi d7a6a0d5c4d1。
3.2 删除与清理:rmi、prune
删除镜像用docker rmi <镜像名或ID>。两个细节值得注意:
- 如果有人用这个镜像创建了尚未删除的容器,
docker rmi会拒绝删除并报错提示容器还在。你需要先删容器,或者用docker rmi -f强制删除镜像记录,但容器里的可写层并不会因此被清理干净,所以我一般不推荐随意加-f。 - 系统运行久了会攒下一堆"悬空镜像"(dangling image),也就是
<none>:<none>的中间层残留。一条命令清理:
docker image prune -f想清理得更彻底,把所有未被容器使用的镜像一起清掉,用docker image prune -a。但这条命令下手比较狠,刚拉下来还没用过的镜像也可能被清掉,我建议新手先只清悬空的。
3.3 打标签与离线搬运:tag、save、load
打标签就是给镜像 ID 起一个易读的别名:
docker tag nginx:1.24-alpine myregistry.example.com/ops/nginx:1.24-alpinedocker tag不会复制镜像数据,它只是在镜像 ID 上新增了一个引用名,成本忽略不计。
离线环境搬运镜像靠 save 和 load 这对组合:
docker save -o nginx-1.24-alpine.tar nginx:1.24-alpine docker load -i nginx-1.24-alpine.tar注意docker save保存的是镜像的完整结构(包括所有层),不是容器;如果你想把"容器当前的文件系统状态"导出来,那是docker export的活,导出的只是一个扁平文件系统,不含镜像分层信息,也带不了历史。新手经常在这里混,区分一句话:save 镜像、export 容器。
3.4 用 inspect 读懂一个镜像的"户口本"
docker inspect nginx:1.24-alpine会输出一大段 JSON,里面包含镜像的完整元信息。新手看这个输出容易被吓到,其实只用关注几个字段:
Architecture和Os:镜像的平台,比如amd64/linux;RootFS.Layers:这个镜像包含哪些层的 ID,列表长度就是层数;Config.Cmd和Config.Entrypoint:容器启动时默认执行的命令;GraphDriver:当前使用的存储驱动。
如果两个镜像RootFS.Layers有重叠,说明它们共享了一部分底层,这也是判断"能不能复用镜像层节省磁盘"的最直接入口。很多老玩家排查镜像兼容性问题、或者确认构建缓存是否命中时,都是从inspect的输出里找线索的。
4. 镜像、容器、Registry 与 Tag:把关系链理清才算真入门
镜像本身不是孤立的概念,它连着容器的运行时,也连着仓库的发布与分发。我建议你把下面这条关系链一次性记住,它会帮你避免日后大量混淆。
4.1 镜像与容器:类是模板,实例是运行态
类比到面向对象编程:镜像就是"类",定义了模板和默认行为;容器就是根据这个类 new 出来的"实例",有自己独立的运行时状态。类本身不占进程资源,只有实例化之后才真正在系统里跑起来;镜像也一样,只是静静地躺在磁盘上,必须docker run之后才变成活着的容器。
从这个类比可以推导出两个结论。第一,一个镜像可以被实例化多次,而每次实例化之间完全隔离。第二,容器删除后,它的可写层也随之消失,容器内产生的文件改动全部没了——但镜像毫发无损。这也是"容器适合跑无状态服务"这件事在原理层面的由来。
4.2 Registry 与 Repository:仓库里的仓库
镜像的完整名称实际上由三部分组成:[Registry地址]/[Repository名称]:[Tag]。默认的 Registry 是 Docker Hub,地址docker.io可以省略不写。所以nginx:1.24-alpine的完整形态其实是docker.io/library/nginx:1.24-alpine。
这里的关键区分是 Registry 和 Repository:Registry 是一个"存放镜像的服务",比如 Docker Hub,或者你们公司内网搭的 Harbor;Repository 是某个具体镜像在 Registry 里的"项目页",比如docker.io下的library/nginx、library/mysql。一个 Registry 下面可以有很多 Repository,一个 Repository 下面通过不同 Tag 挂载同一镜像的多个版本。官方叫法里library表示"官方镜像"这个命名空间。
理解这个链条后,拉取私有仓库镜像时你就不会再拼错地址了:
docker pull harbor.example.com/team-qa/order-service:2024.11.02-rc1就是从harbor.example.com这个 Registry 的team-qa/order-service仓库里,拉取 Tag 为2024.11.02-rc1的镜像版本。
4.3 Tag 应该怎么管理:别让 latest 变成"薛定谔版本"
Tag 是镜像的版本标签,但它本质上是一个"可移动的指针"。同一个镜像 ID 可以被打好几个 Tag,也可以把某个 Tag 重新指向新的镜像 ID。正因为 Tag 可变动,latest这个概念就很微妙:它只是一个默认标签,不代表"最新版本"本身是稳定的。
我自己在项目里吃过亏:CI 流水线构建完直接推latest,测试环境拉下来第二天就跑挂了,因为中间隔了几个小时,latest 已经被同事的新构建覆盖。后来团队定的规矩非常简单:每次构建必须打上不可变的版本号 Tag,比如2025.04.01-12-30-commit_sha或者语义化版本v2.3.1;latest只用来指向当前推荐部署的稳定版,并且由发布负责人手动更新。
对个人学习阶段来说,你的 Tag 管理只要做到一条就够:拉镜像时明确写版本号,别依赖默认的 latest。这样同样的命令,在你在的机器和别的机器上执行结果才能保持一致。
5. 自己制作镜像:Dockerfile 是最短的学习路径
读再多镜像,不如亲手做一个。入门阶段你不需要一上来就搞多阶段构建那套高级技巧,先把一个最小可用的 Django 或 Node 服务装进镜像,感受"从代码到镜像"的完整流程,后面进阶就顺理成章。
5.1 一份能跑通的最小 Dockerfile
假设你有一个最简单的 Flask 应用,目录里是app.py和requirements.txt。新建一个空白文件Dockerfile,写入:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 5000 CMD ["python", "app.py"]然后执行:
docker build -t my-flask-demo:0.1 . docker run -p 5000:5000 my-flask-demo:0.1浏览器打开http://localhost:5000能看到页面,你就完成了第一次手工构建。这里每条指令解决一个明确问题:
FROM:指定基础镜像,所有自定义镜像都是站在某个基础镜像的肩膀上搭起来的;WORKDIR:设置后续命令的工作目录,相当于cd,但如果目录不存在,它也会自动创建;COPY:把宿主机上的文件复制进镜像;RUN:在镜像构建过程中执行命令,通常是安装依赖、编译项目、调整权限这类动作;EXPOSE:仅仅是一个声明,告诉使用者这个容器预计监听哪个端口,它并不真正发布端口;CMD:定义容器启动时默认执行的命令,一个 Dockerfile 通常只保留最后一个 CMD。
5.2 每条指令生成一层:分层机制如何反过来指导 Dockerfile 写法
前面说过,镜像是由一层层堆起来的。在 Dockerfile 里,绝大多数指令都会产生一个新层:RUN 执行的结果、COPY 复制进来的文件,都会形成独立图层。
这意味着什么?Dockerfile 的每一行"姿势"都会直接影响最终镜像的体积、磁盘占用和构建速度。最容易踩的坑是"一条 RUN 干一堆散事",Docker 会把这条 RUN 结束后留下的完整文件系统状态记录成一个层,如果中途产生的临时文件没有在同一层里删掉,这些垃圾就被焊接在镜像层里成为永久包袱。
所以两个习惯建议你从第一天就养成:
- 把强相关的命令用
&&串进同一条 RUN 里执行,最后顺手清理缓存。比如安装系统依赖时不要写三条 RUN 分开装,写成一条RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*; - 构建镜像时让"变更频率低的内容"放在 Dockerfile 前面、"变更频率高的内容"放在后面。上面示例里先 COPY
requirements.txt安装依赖、再 COPY 业务代码,就是刻意为之——依赖文件一天难得改一次,而代码每次提交都变。这样改代码时只需重建最后两层,依赖安装的缓存能直接命中。
5.3 构建缓存与 .dockerignore:一个提效,一个防污染
Docker 在构建时会复用本地已有的、未参与变化的中间层作为缓存。判断"是否变化"主要看指令本身和涉及的上下文文件。所以你的 COPY 指令写的是COPY . /app,那每次构建 Docker 都要扫描整个项目目录,连node_modules、.git、临时日志这些都没法逃过,既拖慢构建,也可能把敏感文件带进镜像。
解决方式和其他生态的做法类似:在项目根目录建一个.dockerignore文件,规则和.gitignore非常像:
node_modules .git __pycache__ *.log .env dist构建时这些目录会被排除在构建上下文之外,你不会再看到"上下文中包含几十万文件"的警告,镜像里的垃圾也随之大幅减少。
6. 入门期躲不开的几个坑:从报错倒推原因
最后分享一些我经常在初学者身上看到的、以及自己亲手踩过的坑。这些坑基本都能从原理层面倒推出答案。
6.1 "unable to find image 'hello-world:latest' locally" 的完整排查链路
回到开头那条报错。完整的报错通常长这样:
Unable to find image 'hello-world:latest' locally docker: Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers).前半句是提示,后半句才是真正的原因:Docker 尝试连接 Docker Hub 时超时。排查顺序建议是:
- 先确认网络环境能否正常访问外网。在宿主机上
curl -I https://registry-1.docker.io/v2/看看返回什么。如果直接超时,说明链路走不通,不是 Docker 的问题。 - 再看 Docker 的仓库配置。运行
docker info,找到Registry Mirrors这一项。如果它是空的,说明你没配任何镜像加速服务,Docker 会直连 Docker Hub,慢或者失败都很正常。 - 配置可用的镜像加速服务,然后重启 Docker 再拉取。
这里务必区分一个概念:加速服务只是把 Docker Hub 的镜像做了一层合法的缓存转发,不改动镜像内容。具体配置方法见下一条。
6.2 Docker Desktop 启动失败:先查虚拟化,再查组件
Windows 上打开 Docker Desktop 最常见的一个报错是:
Docker Desktop failed to start because virtualisation support wasn't detected.不少同事第一次遇到就慌了,其实是触发了 Docker Desktop 的启动前置检查:它需要 Windows 的虚拟化能力支持,才能在后台跑 Linux 虚拟机来承载容器。排查建议按三步走:
- 在 BIOS/UEFI 设置里确认 CPU 虚拟化技术(Intel VT-x 或 AMD SVM)已经开启;
- 在"启用或关闭 Windows 功能"里勾选"虚拟机平台"和"适用于 Linux 的 Windows 子系统"(WSL2 依赖这两项);
- 确认当前 Windows 版本满足 Docker Desktop 的最低要求。
如果你是老版本 Windows 用户,Docker Desktop 早期还会依赖 Hyper-V,这时候还需要把 Hyper-V 相关功能一并打开。在虚拟机里再装 Docker Desktop 的同学,还需要额外开启"嵌套虚拟化"选项,否则上面的检查照样过不去。
6.3 拉镜像慢:给 Docker 配一个能连通的镜像加速服务
Docker 官方仓库的服务器在海外,国内直连时网络延迟高、下载慢是常态。我不建议用任何看起来"神奇"的办法,正路就是给 Docker 配置镜像加速服务。这里以比较常见的做法为例。
国内多家云厂商都提供容器镜像加速服务,注册后可以拿到专属加速地址。以阿里云为例,登录容器镜像服务控制台,在"镜像加速器"页面会生成一个形如https://xxxx.mirror.aliyuncs.com的地址。然后编辑 Docker 的守护进程配置文件(通常是/etc/docker/daemon.json,macOS/Windows 在 Docker Desktop 的 Docker Engine 设置里也可以编辑):
{ "registry-mirrors": ["https://xxxx.mirror.aliyuncs.com"] }保存后重启 Docker(Linux 是sudo systemctl restart docker),再用docker info确认Registry Mirrors里已经出现你的加速地址,重新拉镜像就会快很多。值得提醒的是,加速服务只是中转,Tag 行为不会变,你该怎么用还是怎么用。
6.4 Windows 下构建镜像被换行符坑了一次
这是一个特别隐蔽的坑。Windows 下用 Git 克隆项目时,Git 默认可能把文本文件的换行符从 LF(Unix)自动转成 CRLF(Windows)。而 Dockerfile 里如果混入了 CRLF,Docker 在解析RUN指令时,命令末尾会被带一个看不见的\r,于是构建时经常出现类似/bin/sh: 1: apt-get: not found这种诡异报错。
排查办法是打开 Dockerfile 文件,用编辑器显示所有符号,看行尾是 LF 还是 CRLF。根治方式是在 Git 配置里显式声明换行符策略:
git config --global core.autocrlf input然后重新把文件转回 LF。这个坑我至少见过三次,一旦知道原因,以后遇到任何"命令明明没错但构建报错"的情况,你都会先怀疑换行符。
6.5 镜像越攒越多,磁盘告警了怎么清理
Docker 用久了最头疼的就是磁盘占用。先别慌着删镜像,先看数据分布:
docker system df它会把镜像、容器、数据卷、构建缓存四类占用分别列出来。镜像本身容易清理,重点是构建缓存:反复构建镜像后,/var/lib/docker下的 Build Cache 有时候比镜像本体还大。清理命令建议按温和程度分两档:
- 只清理悬空数据:
docker system prune; - 想连未使用的镜像和构建缓存一起清:
docker system prune -a --volumes。
第二次清理会删除所有没被容器使用的镜像,包括你刚下载还没用的,所以我通常先跑温和版,如果空间还是不够,再针对性删旧镜像。定期跑docker system df看看哪部分是主要占用,比盲目删除安全得多。
最后说一点我自己的体会。Docker 命令再怎么多,真正决定你用得顺不顺手的,其实是脑子里的那套模型:镜像是多层只读模板、容器是加了一层可写状态的实例、Tag 是可移动的指针、Registry 是分发的中枢。一旦这四个概念串成一条线,绝大多数命令和报错你都能当场推导出大概方向,而不需要翻文档。初学阶段别急着把所有命令都背下来,先把常用的几个跑熟,再跟着 Dockerfile 构建几个自己的镜像,这个节奏是最舒服的。