概述
从 Docker Hub 拉一个 Redis 镜像,导出成 tar,删掉本地镜像,再从 tar 灌回来。命令本身没难度,坑都藏在细节里------怎么命名归档文件、save 和重定向有什么区别、删镜像为什么会被容器拦住。
纲要
- 练习题目的六步目标与拆解
- 每步的命令、预期输出与设计原因
- 镜像命令速查表
docker save/load与docker export/import的边界- 四个高频陷阱:
-o与>、.tar.gz后缀、load 混用 import、删镜像前先删容器 - 一条可复制的完整练习脚本
练习要解决什么
讲义给的题目是:
需求:去 Docker Hub 搜索并拉取一个 Redis 镜像
目标是六步,顺序不能乱:
| 序号 | 目标 | 对应命令 |
|---|---|---|
| 1 | 去 Docker Hub 搜索 Redis 镜像 | 浏览器搜索 / docker search redis |
| 2 | 查看 Redis 镜像的名称和版本 | Docker Hub 的 Tags 页 |
| 3 | 拉取镜像 | docker pull redis |
| 4 | 打包成 tar 归档 | docker save -o redis.tar redis:latest |
| 5 | 删除本地镜像 | docker rmi redis:latest |
| 6 | 从 tar 重新加载 | docker load -i redis.tar |
练习题里没有第 7 步,但少了它整道题就没有验收标准------加载回来之后必须再 docker images 看一眼,确认 REPOSITORY、TAG、IMAGE ID 都对得上。题目的真正考点不是"记得住命令",而是理解 save/load 这条链路里到底搬运了什么。
课前资料目录长这样,练习用到的归档文件以后会和其他素材放在一起:
tree
day03-Docker/
├── 讲义/
│ ├── Docker实用篇.md # 2.1.5 就是本文的练习题
│ ├── Docker实用篇.pptx
│ └── assets/
└── 资料/
├── Centos7安装Docker.md
├── Dockerfile
├── docker-compose
├── docker-demo.jar
├── hmy.cnf
├── jdk8.tar.gz
├── mysql.tar
├── nginx.conf
├── redis.tar.gz # 注意:只有 22 字节,不是 save 的产物
├── assets/
└── cloud-demo/
后面讲命名陷阱时会回到 redis.tar.gz 这个文件上。
确认镜像名称
镜像名是 [repository]:[tag] 两段式,不写 tag 时默认就是 latest。Redis 的官方镜像在 Docker Hub 上叫 redis,完整身份是 docker.io/library/redis:latest,library 是官方镜像的命名空间,日常拉取时可以全省掉。
不加 latest 而写具体版本是有意义的。docker pull redis:6.2.1 拉的是写死的版本,docker pull redis 拉的是当下最新的。生产环境的镜像必须钉版本,否则今天构建的和下个月重建的可能不是同一个东西。
命令行也能搜,但结果只是 Docker Hub 上前若干个仓库的简要列表,看不出 tags,正式确认版本还是去 Docker Hub 的 Tags 页面看。
bash
docker search redis
text
NAME DESCRIPTION STARS OFFICIAL
redis Redis is an open source key-value store that... 12000 [OK]
bitnami/redis Bitnami Redis Docker Image ...
OFFICIAL 那列是 [OK] 的才是官方镜像,library/redis 排在第一行。
拉取镜像
bash
docker pull redis
等价于 docker pull redis:latest,看到的输出是分层下载的过程:
text
Using default tag: latest
latest: Pulling from library/redis
a2abf6c4d29d: Pull complete
c7a4e4382001: Pull complete
4044b9ba67c9: Pull complete
Digest: sha256:b3f4a3db5e1c1a0d0f...
Status: Downloaded newer image for redis:latest
docker.io/library/redis:latest
Pulling from 后面那一串就是镜像层(layer)。Docker 按层下载并做内容去重,所以第二次拉同一个镜像会直接提示 Image is up to date for redis:latest,不会重新下载------层已经在本地缓存里。这也是后面 docker save 导出的东西一定会比单个 tar 包大的原因,它把每一层都装进去了。
查看本地镜像
bash
docker images
当前 Docker 版本的列头是固定的五列:
text
REPOSITORY TAG IMAGE ID CREATED SIZE
redis latest 7614ae9453d1 2 weeks ago 113MB
nginx latest 605c77e624dd 3 weeks ago 141MB
想只看某个镜像,把名字当过滤参数传进去:
bash
docker images redis
IMAGE ID 是这一列的短 ID。这条命令不只是"看看有没有",它还是第 6 步验收时的比对基准:回灌之后 IMAGE ID 应该和删除前完全一致,如果变了,说明你灌进来的不是同一个镜像。
导出为 tar 归档
bash
docker save -o redis.tar redis:latest
讲义原文这里就是 redis.tar,不带 .gz。命令跑完没有回显,靠 ll 或 ls -lh 确认:
bash
ls -lh redis.tar
text
-rw-r--r-- 1 root root 138M Feb 28 10:12 redis.tar
-o 是 --output,意思是让 Docker 自己把归档写到指定文件。不带 -o 时 save 会把 tar 流打到标准输出------这个默认行为后面会和 shell 重定向放在一起讲。
删除本地镜像
bash
docker rmi redis:latest
text
Untagged: redis:latest
Untagged: redis@sha256:b3f4a3db5e1c1a0d0f...
Deleted: sha256:7614ae9453d1...
Deleted: sha256:c7a4e4382001...
...
注意两件事。第一,删镜像用 rmi 不是 rm,docker rm 删的是容器,名字差一个字母,结果天差地别。第二,输出是先把 Untagged 把 tag 摘掉,再逐层 Deleted,顺序和拉取时正好相反。
删完复查,列表里就没有它了:
bash
docker images redis
text
REPOSITORY TAG IMAGE ID CREATED SIZE
从归档回灌
bash
docker load -i redis.tar
text
Loaded image: redis:latest
-i 是 --input,从文件读。这一步之所以能"还原",是因为 save 出来的 tar 里有完整的 manifest.json 和全部层目录,tag、镜像配置(CMD、ENV、暴露的端口)都在里面。load 只是把档案解回本地镜像库,不涉及任何网络请求,所以离线机器上同样能跑通。
如果只需要确认归档里有什么,不用加载,直接看 tar 的目录结构:
bash
tar -tf redis.tar | head -n 8
text
7614ae9453d1e1...
7614ae9453d1e1.../VERSION
7614ae9453d1e1.../json
7614ae9453d1e1.../layer.tar
c7a4e4382001...
...
manifest.json
repositories
manifest.json 就是元数据,一堆十六进制目录名就是各层。
复查与比对
bash
docker images
docker images -q redis
回灌后 redis:latest 应该原样出现,IMAGE ID 与删除前一致。整个往返过程可以画成这样:
#mermaid-svg-VoLSt8DGrCGVrxZD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VoLSt8DGrCGVrxZD .error-icon{fill:#552222;}#mermaid-svg-VoLSt8DGrCGVrxZD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VoLSt8DGrCGVrxZD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VoLSt8DGrCGVrxZD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VoLSt8DGrCGVrxZD .marker.cross{stroke:#333333;}#mermaid-svg-VoLSt8DGrCGVrxZD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VoLSt8DGrCGVrxZD p{margin:0;}#mermaid-svg-VoLSt8DGrCGVrxZD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD .cluster-label text{fill:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD .cluster-label span{color:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD .cluster-label span p{background-color:transparent;}#mermaid-svg-VoLSt8DGrCGVrxZD .label text,#mermaid-svg-VoLSt8DGrCGVrxZD span{fill:#333;color:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD .node rect,#mermaid-svg-VoLSt8DGrCGVrxZD .node circle,#mermaid-svg-VoLSt8DGrCGVrxZD .node ellipse,#mermaid-svg-VoLSt8DGrCGVrxZD .node polygon,#mermaid-svg-VoLSt8DGrCGVrxZD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VoLSt8DGrCGVrxZD .rough-node .label text,#mermaid-svg-VoLSt8DGrCGVrxZD .node .label text,#mermaid-svg-VoLSt8DGrCGVrxZD .image-shape .label,#mermaid-svg-VoLSt8DGrCGVrxZD .icon-shape .label{text-anchor:middle;}#mermaid-svg-VoLSt8DGrCGVrxZD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VoLSt8DGrCGVrxZD .rough-node .label,#mermaid-svg-VoLSt8DGrCGVrxZD .node .label,#mermaid-svg-VoLSt8DGrCGVrxZD .image-shape .label,#mermaid-svg-VoLSt8DGrCGVrxZD .icon-shape .label{text-align:center;}#mermaid-svg-VoLSt8DGrCGVrxZD .node.clickable{cursor:pointer;}#mermaid-svg-VoLSt8DGrCGVrxZD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VoLSt8DGrCGVrxZD .arrowheadPath{fill:#333333;}#mermaid-svg-VoLSt8DGrCGVrxZD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VoLSt8DGrCGVrxZD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VoLSt8DGrCGVrxZD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VoLSt8DGrCGVrxZD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VoLSt8DGrCGVrxZD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VoLSt8DGrCGVrxZD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VoLSt8DGrCGVrxZD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VoLSt8DGrCGVrxZD .cluster text{fill:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD .cluster span{color:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VoLSt8DGrCGVrxZD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VoLSt8DGrCGVrxZD rect.text{fill:none;stroke-width:0;}#mermaid-svg-VoLSt8DGrCGVrxZD .icon-shape,#mermaid-svg-VoLSt8DGrCGVrxZD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VoLSt8DGrCGVrxZD .icon-shape p,#mermaid-svg-VoLSt8DGrCGVrxZD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VoLSt8DGrCGVrxZD .icon-shape .label rect,#mermaid-svg-VoLSt8DGrCGVrxZD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VoLSt8DGrCGVrxZD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VoLSt8DGrCGVrxZD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VoLSt8DGrCGVrxZD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} docker pull redis
docker save -o redis.tar
docker rmi redis:latest
docker load -i redis.tar
确认恢复
Docker Hub
library/redis:latest
本地镜像
redis:latest
磁盘归档 redis.tar
未压缩 tar,含全部分层 + manifest.json
本地已无该镜像
docker images 复查
IMAGE ID 与删除前一致
图中 save 与 load 是两条方向相反的边,中间夹着一次删除。删掉镜像不会动磁盘上的 tar,所以这条链路本质上是"把镜像从 Docker 的存储目录里挪到普通文件系统上,再挪回去"。
命令速查表
| 阶段 | 命令 | 作用 | 常用参数 |
|---|---|---|---|
| 搜索 | docker search redis |
在 Docker Hub 上按关键字找仓库 | --limit n、--filter is-official=true |
| 拉取 | docker pull redis:6.2.1 |
从仓库拉镜像到本地 | 不写 tag 即 latest |
| 查看 | docker images |
列出本地镜像 | -q 只输出 ID、-a 含中间层、--filter 过滤 |
| 导出 | docker save -o redis.tar redis:latest |
把镜像(含全部分层与元数据)存成 tar | -o/--output,可一次跟多个镜像 |
| 加载 | docker load -i redis.tar |
从 tar 恢复镜像,保留 tag | -i/--input、-q 静默 |
| 打标签 | docker tag redis:latest myredis:1.0 |
给镜像加一个新引用 | 源镜像、目标名 |
| 删除 | docker rmi redis:latest |
删除本地镜像 | -f 强制、可传 ID 或 名称:tag |
| 清理 | docker image prune -a |
清理未被容器使用的镜像 | -a 连无 tag 的一起清 |
| 看帮助 | docker save --help |
查某个子命令的语法 | 所有子命令通用 |
docker save --help 的输出值得存一份,本机实际是这样的:
text
Usage: docker save [OPTIONS] IMAGE [IMAGE...]
Save one or more images to a tar archive (streamed to STDOUT by default)
Options:
-o, --output string Write to a file, instead of STDOUT
--platform string Save only the given platform variant. Formatted
as "os[/arch[/variant]]" (e.g., "linux/amd64")
streamed to STDOUT by default 这句是后面所有命名问题的根源。
save/load 与 export/import 的边界
这两组命令长得像,作用对象完全不同。save/load 面向镜像,export/import 面向容器:
| 维度 | docker save / docker load |
docker export / docker import |
|---|---|---|
| 操作对象 | 镜像 | 容器 |
| 归档命令 | docker save -o a.tar IMAGE |
docker export -o a.tar CONTAINER |
| 归档内容 | 全部分层 + manifest.json 等元数据 |
容器根文件系统的合并快照,只有一层 |
| 是否保留 tag | 保留,load 后 tag 原样恢复 |
丢失,import 时必须自己指定 名称:tag |
| 是否保留历史与配置 | 保留(CMD、ENTRYPOINT、ENV、构建历史) | 全部丢失 |
| 回灌命令 | docker load -i a.tar |
docker import a.tar myapp:1.0 |
| 归档体积 | 大,等于所有层之和 | 小,只有一份合并后的文件系统 |
| 典型用途 | 离线机器之间搬运镜像 | 导出容器现场做 rootfs、做瘦身基镜像 |
一句话记:save 存的是"镜像本身",export 存的是"容器里的文件长什么样"。练习里用的是 save 路径,所以回灌之后 redis:latest 这个 tag 才会自动回来。
两条链路不能交叉:
- 把
export出来的快照丢给docker load,会直接报归档格式错误。 - 把
save出来的 tar 丢给docker import,要么报 tar 解析错误,要么"成功"导入出一个没有入口命令的垃圾镜像,docker run起来立刻退出。
几个容易踩的坑
docker save -o 和重定向 >
两种写法看起来等价,实际不是:
bash
docker save -o redis.tar redis:latest # Docker 自己打开文件写入
docker save redis:latest > redis.tar # shell 先创建文件,再让 Docker 往标准输出写
单纯的二选一都能用,真正的坑是混着写:
bash
docker save -o a.tar redis:latest > b.tar
-o a.tar 把归档写进了 a.tar,标准输出没有任何内容,于是 shell 创建并清空的 b.tar 永远是 0 字节。这种命令不会报错,等到别人拿着 b.tar 去 docker load 才炸。
重定向还有第二个副作用:shell 是先 创建、截断目标文件,再 执行命令。如果镜像名敲错了,docker save 失败退出,但 redis.tar 已经被清空了------你以为有个归档,其实是个 0 字节文件,而 -o 写法在镜像不存在时根本不会去动那个文件。所以凡是"生成产物"的场景,优先用命令自带的 -o/-i,不要用重定向。
.tar.gz 后缀不等于真压缩
docker save 的输出永远是未压缩 tar。把文件命名成 redis.tar.gz 不会让它小一个字节:
bash
docker save -o redis.tar.gz redis:latest # 内容是 tar,名字骗人
ls -lh redis.tar.gz # 大小和 redis.tar 一模一样
要真压缩,得自己过一遍 gzip:
bash
docker save redis:latest | gzip > redis.tar.gz
docker load 能自动识别 gzip 归档,所以上面这条压缩版 load -i redis.tar.gz 也能正常加载。问题是"文件叫 .tar.gz 但内容是裸 tar"这种情况,用 tar -xzf 解压会直接报 gzip: stdin: not in gzip format,排查时容易被名字带偏。
回到 资料/ 目录里那个 redis.tar.gz------它只有 22 字节,显然不是任何一次 save 的产物,就是个占位文件。判断一个归档是不是真货,第一件事是 ls -lh 看体积,Redis 镜像导出来是百兆级别,不可能 22 字节。
load 和 import 不能混用
load 只认 save 生成的归档,import 只认文件系统快照。名字里都带个"载入"的意思,参数格式却完全不同:load 用 -i 指定归档,import 直接把 tar 当位置参数,还要额外给个 名称:tag。记法是:save 配 load,export 配 import。
删镜像前必须先删依赖它的容器
练习里 docker rmi 一次就成功了,是因为从头到尾只 pull 了镜像,没 run 过容器。一旦镜像被容器引用过(哪怕容器已经停止),删除就会失败:
text
Error response from daemon: conflict: unable to delete 7614ae9453d1 (must be forced)
- image is being used by stopped container 3f2b1c9d8e70
Docker 拦这一下是故意的:容器只是停止,没有删除,它的可写层还挂在原镜像上,直接删镜像会让这个容器再也起不来。处理顺序是先清容器再清镜像:
bash
docker rm -f 3f2b1c9d8e70 # 先删容器
docker rmi redis:latest # 再删镜像
要批量清场:
bash
docker rm -f $(docker ps -aq) # 删除所有容器(含运行中的)
docker rmi redis:latest
用 docker rmi -f 硬删也能过,但那是强制解引用,容器会变成悬空状态,除了掩盖问题没有别的好处。
完整练习脚本
把六步串成一个可以整段复制的脚本,带 IMAGE ID 比对做自动验收:
bash
#!/usr/bin/env bash
# Docker 镜像命令练习:Redis 镜像的拉取 / 导出 / 删除 / 回灌
# 需要有 docker 权限,非 root 用户确认已在 docker 组里(否则每条命令前面加 sudo)
set -euo pipefail
IMAGE="redis:latest"
TAR="redis.tar"
echo "===== 0. 环境自检 ====="
docker version --format '{{.Server.Version}}'
echo "===== 1. 拉取镜像(不写 tag 等价于 ${IMAGE})====="
docker pull redis
echo "===== 2. 查看本地镜像 ====="
docker images
docker images redis
echo "===== 3. 导出为 tar 归档 ====="
rm -f "${TAR}"
docker save -o "${TAR}" "${IMAGE}"
ls -lh "${TAR}"
# 确认归档里确实有 manifest.json 和各层目录
tar -tf "${TAR}" | head -n 8
echo "===== 4. 记录删除前的 IMAGE ID ====="
OLD_ID="$(docker images -q "${IMAGE}")"
echo "before: ${OLD_ID}"
echo "===== 5. 删除本地镜像 ====="
# 先确认没有容器在引用这个镜像
echo "引用该镜像的容器(应为空):"
docker ps -a --filter "ancestor=${IMAGE}" --format '{{.ID}} {{.Names}}'
docker rmi "${IMAGE}"
# 复查:查不到任何记录即删除成功
docker images "${IMAGE}"
echo "===== 6. 从 tar 重新加载 ====="
docker load -i "${TAR}"
echo "===== 7. 复查并比对 IMAGE ID ====="
NEW_ID="$(docker images -q "${IMAGE}")"
echo "after : ${NEW_ID}"
if [ -n "${NEW_ID}" ] && [ "${OLD_ID}" = "${NEW_ID}" ]; then
echo "通过:IMAGE ID 与删除前一致,镜像完整恢复"
else
echo "失败:镜像未恢复或 ID 不一致" >&2
exit 1
fi
脚本里有两处是练习题原文没写、但实际必须做的:记录删除前的 IMAGE ID、回灌后再比一次。少了这两步,"恢复成功"只能靠肉眼感觉。
官方文档
- docker save:
-o与--platform的用法 - docker load:支持的归档格式与压缩类型
- docker export
- docker import
- Redis 官方镜像:查看全部可用 tag
- 讲义原文:
day03-Docker/讲义/Docker实用篇.md第 2.1.5 节
总结
- 练习的六步是一条完整闭环:
pull拿到镜像 →save落成 tar →rmi删本地 →load灌回来。归档文件放在普通文件系统上,镜像放在 Docker 的存储目录里,两边互不影响,删了镜像不会动 tar。 save搬运的是镜像本身,带着全部分层和manifest.json,所以load之后 tag 和 IMAGE ID 都能原样回来;export搬运的是容器文件系统的合并快照,元数据全丢,必须配import且要自己补名字。- 归档一律用
-o/-i,别用重定向。-o a.tar > b.tar这种写法会静默产出一个 0 字节的b.tar,而且重定向会在命令执行前就先清空目标文件。 save的输出是未压缩 tar,叫.tar.gz不会变小。想真压一遍就docker save redis:latest | gzip > redis.tar.gz。判断归档真伪先看体积,别信后缀。docker rmi失败多半是被容器占着,先docker rm -f清容器再删镜像,不要习惯性加-f硬删。