Docker镜像离线迁移:save/load与export/import选型 📅 发布时间:2026/9/19 1:00:38 👁 浏览次数: 客户现场断网、机房只有一条千兆线、跨部门交付要把镜像搬过去这类场景下没有私有仓库可用能依靠的就是 docker save、docker load、docker export、docker import 这四条命令加上 tar/tar.gz 这一层打包压缩。四个命令长得像、参数也像但操作的对象根本不是同一个东西混着用十次有九次会在现场翻车。我把这几年在不同项目里踩过的坑整理成一篇从数据结构的差异讲到压缩参数的取舍再到那几类反复出现的报错怎么定位。适合已经会跑 docker build、但对离线搬运还停留在复制粘贴命令阶段的同学也适合需要写交付脚本、给现场工程师出操作手册的人。1. save/load 和 export/import 到底动的是哪块数据1.1 四个命令操作的对象完全不同先把最容易搞混的一点定下来save 和 load 操作的是镜像export 和 import 操作的是容器。这个区别决定了后面所有的行为差异。镜像在磁盘上的形态是一摞只读层加一堆元数据。docker save做的事就是把这摞层原封不动地导出来同时在 tar 包里塞进manifest.json、repositories和每个层对应的配置 json。导出来的东西再docker load回去层的内容、层的顺序、镜像 ID、标签、构建历史、ENV、CMD、ENTRYPOINT 全都在因为它本质上就是把/var/lib/docker里那份镜像数据换了个存放位置。docker export走的是另一条路。它拿一个容器把这个容器当前可见的文件系统——镜像层加上容器可写层——合并成一个扁平的 rootfs压成一个 tar。合并这个过程意味着层与层之间的边界没了镜像的历史记录没了Dockerfile 里的 ENV、CMD、EXPOSE、WORKDIR、VOLUME 这些镜像的配置也全都没了。docker import收到这个扁平 tar 之后只能把它当成一个文件系统快照重新包成镜像所以你必须手动用-c把配置补回去否则这个镜像跑不起来或者跑起来行为不对。有一个很直观的验证方式docker load之后执行docker history历史层一条不少docker import之后执行docker history只有孤零零一条Imported from。1.2 一个真实的翻车现场export 之后 ENV 全丢了前几年做过一个 Java 服务的迁移。当时的思路很朴素老机器上容器跑得好好的那就docker export导出新机器docker import进去接着跑省得重新构建。结果新机器上容器一起来就报找不到 java。排查过程现在想想很典型进容器ls /usr/lib/jvm目录存在java 二进制也在java -version直接敲命令能跑。问题出在环境变量——老容器是靠 Dockerfile 里的ENV JAVA_HOME/usr/lib/jvm/xxx加上ENV PATH$JAVA_HOME/bin:$PATH才能直接调用java而 export 出来的 tar 里根本没有这些环境变量。启动脚本里写的是java -jar app.jarPATH 里没有 java自然就报找不到。这个坑的教训不是export 不好用而是用 export 就得接受它只搬文件系统、不搬配置要么用-c补全要么干脆放弃 export 这条路改用 save。后面我把这个项目全部换成了 save/load一次都没再出过同类问题。1.3 选型对照表什么时候该用哪条路把差异摆成一张表会清楚很多对比维度docker save / loaddocker export / import操作对象镜像 image容器 container产物内容所有层 manifest 配置 历史合并后的扁平 rootfs是否保留标签保留由包内 repositories 决定不保留import 时必须自己指定是否保留构建历史保留丢失是否保留 ENV/CMD/ENTRYPOINT保留丢失需-c补数据卷里的数据不在包内不在包内load 时能否改名字不能只能 load 完再 tag可以import 时直接命名体积层可去重多镜像打包更省每个容器一份基础层重复存储典型用途镜像离线交付、备份、CI 传递抢救一个跑挂的容器现场、做根文件系统快照选型逻辑其实一句话能概括要搬镜像就用 save/load要搬某个容器现在的样子才用 export/import。日常交付、离线部署、给客户送包99% 的场景应该是 save/load。export/import 真正的价值场景是容器里手工装了一堆东西、改了配置Dockerfile 已经对不上了想把这个现场冻结下来做取证或者做临时基线或者要把一个容器当根文件系统导出来喂给别的工具链。注意不管是 save 还是 export挂载进容器的数据卷volume 或者 -v 挂的宿主机目录内容都不会进包。这个后面单独讲。2. 镜像搬运实操从 docker save 到落地加载2.1 最小可用闭环与一个 Windows 上的隐藏雷最朴素的写法是这样docker save -o nginx-1.25.tar nginx:1.25 ls -lh nginx-1.25.tar docker load -i nginx-1.25.tardocker save nginx:1.25 nginx-1.25.tar这种 stdout 重定向写法在 Linux 上效果一样但我更推荐-o两个原因一是 stdout 写法出错时比如磁盘满shell 会留下一个半截文件而且退出码可能被重定向掩盖-o的报错更直接二是 Windows 上 stdout 重定向有个经典雷区。Windows PowerShell 5.1 默认的重定向输出编码是 UTF-16LE不是二进制流。docker save nginx:1.25 nginx.tar跑完文件大小会变成正常值的两倍左右开头多出一堆00字节拿到 Linux 上docker load -i必然报invalid tar header。这个坑我见过至少三次当事人第一反应都是文件传坏了其实是本地产物就是坏的。在 PowerShell 里要么用-o要么显式走cmd /c docker save ... file.tar或者用[IO.File]::WriteAllBytes。2.2 多镜像合并成一个包与标签保留逻辑docker save支持一次带多个镜像docker save -o bundle.tar app:v1.2 app:v1.3 base/ubuntu:22.04 docker load -i bundle.tar这种写法在交付场景里很划算因为同一个 tar 内多个镜像共享的层只会存一份。比如 app:v1.2 和 app:v1.3 有 90% 的层是相同的分开打两个包体积是 AB合起来打一个包体积接近 A 加上增量层能省下大量传输时间。我经手过一个交付包分开存是 4.6GB合并后 2.9GB压缩之后 1.1GB差别很实在。标签的处理逻辑要记清楚save 会把镜像当前所有的标签都写进包里的repositories文件load 的时候原样恢复。这意味着两件事load 的时候没法指定新名字。你想把nginx:1.25加载成myrepo/nginx:custom只能 load 完再docker tag然后docker rmi掉旧标签没有别的捷径。如果本地已经存在同名同标签的镜像load 会把它覆盖掉。这在生产机器上执行要格外小心尤其是多人共用的构建机——覆盖了别人正在用的镜像而对方可能正在跑构建。还有一个容易被忽略的情况如果 save 时镜像的标签是none比如是别人构建完docker rmi掉标签留下的悬空镜像包里的 repositories 就是空的load 回来还是一个none镜像只能靠镜像 ID 引用。所以交付前一定先确认docker images里每一条都有正常标签用docker tag补一下再打包。2.3 边压边传的管道写法与它的风险网络条件好的时候可以不做中间落盘直接开管道docker save app:v1.2 | gzip -1 | ssh usertarget gunzip | docker load这个写法的好处是省掉一次磁盘落地和一次读盘几百 MB 的镜像在千兆内网里跑得非常快。但有两个必须提前知道的风险点。第一是断线即归零。ssh 一断整条链路的进度全丢前面传了几分钟的流量白费。镜像超过 2GB 并且线路不太稳的情况下我宁愿先落盘再传配合rsync --partial --progress或者scp的续传能力虽然慢一点但可恢复。第二是管道的退出码会骗人。上面这条命令的退出码取决于最后一个进程ssh/gunzip中间的gzip因为管道被提前关闭通常也不会报错所以哪怕docker load因为磁盘满失败了echo $?也可能是 0。写脚本的时候一定要加set -o pipefail并且不要只看退出码而是去捕获输出里的Loaded image:关键字set -euo pipefail out$(docker save app:v1.2 | gzip -1 | ssh usertarget gunzip | docker load) echo $out | grep -q Loaded image: || { echo 加载失败: $out; exit 1; }第三点算是个提示管道方案下docker load是流式处理的tar 走到哪写到哪中途失败会在目标机的/var/lib/docker留下一堆垃圾层。失败之后建议docker system prune清一遍再用 ID 确认没有残留。3. 压缩格式怎么选gzip、pigz、zstd 的实测取舍3.1 压缩级别和耗时的关系docker save产出的 tar 默认是不压缩的体积就是镜像在docker images里看到的那个大小。不压缩的好处是加载时最快、CPU 占用最低坏处是取决于镜像内容的可压缩性体积往往能砍掉一半以上。我拿手上一个 1.8GB 的后端服务镜像做了一组对照机器是 8 核结果如下数值只代表这一组样本的量级你的镜像里如果已经是压缩过的 jar 包或者 PNG 图片居多压缩率会明显更差方案压缩后体积压缩耗时解压耗时说明不压缩1.8GB00内网直传首选gzip -1约 1.0GB约 40 秒约 12 秒性价比最高gzip -6默认约 940MB约 2 分 10 秒约 13 秒默认值往往不划算gzip -9约 920MB约 6 分 30 秒约 13 秒多花 4 分钟换 20MBpigz -p8 -6约 940MB约 25 秒约 13 秒多核碾压单核 gzipzstd -T0 -3约 1.05GB约 15 秒约 5 秒快得离谱zstd -T0 -19约 880MB约 4 分钟约 6 秒体积和速度的另一个平衡点看这张表能得出两个很反直觉的结论。一是gzip 从 -1 到 -9体积收益极小但时间翻了好几倍。镜像里的内容大部分是二进制和已经压缩过的资源高压缩级别能榨出来的空间很有限。所以我日常只用-1除非是在用带宽极其紧张的长途链路上做一次性交付。二是zstd 在这个场景里几乎是全面胜出压缩率接近 gzip -6速度快好几倍解压又快而且支持多线程。唯一的门槛是两端都得有 zstd 命令——很多精简的基础镜像和信创环境里没有需要提前确认或者干脆改成zstd -dc | docker load这种在宿主机上解压的方式容器里有没有就不重要了。3.2 pigz 和 zstd 的落地命令pigz 是 gzip 的多线程实现输出的仍然是标准 gzip 格式兼容性上没有任何牺牲唯一要求是压缩端装了 pigz解压端用普通 gunzip 就行# 压缩多核 docker save app:v1.2 | pigz -p $(nproc) -6 app-v1.2.tar.gz # 解压并加载 pigz -dc app-v1.2.tar.gz | docker loadzstd 的用法zstd -T0 -10 -o app-v1.2.tar.zst app-v1.2.tar zstd -dc app-v1.2.tar.zst | docker load这里我习惯把解压和 load 拆成管道而不是直接docker load -i xxx.tar.zst。原因是docker load对压缩格式的自动识别是分档的gzip、bzip2、xz 长期支持比较稳zstd 要看 Docker 版本和是不是启用了 containerd 镜像存储旧版本直接喂.zst会报invalid tar header。管道方式把解压交给独立的 zstd 进程docker load收到的永远是纯 tar跨版本最保险代价只是不能一条命令吃下文件。3.3 校验和分卷大文件传输的两个保命动作生成完之后一定要生成校验值这是我最坚持的一条规矩sha256sum app-v1.2.tar.gz | tee app-v1.2.tar.gz.sha256 # 目标机 sha256sum -c app-v1.2.tar.gz.sha256镜像包动辄几个 GB网络传输中出现静默损坏的概率没你想的那么低。少了这一步后面 load 报unexpected EOF的时候你根本分不清是包本身有问题还是传输过程中出的问题只能从头再传一遍。分卷是另一个老派但可靠的招数适合放在文件系统限制单文件大小或者传到移动介质上的场景split -b 2G -d app-v1.2.tar.gz part_ # 目标机 cat part_* app-v1.2.tar.gz sha256sum -c app-v1.2.tar.gz.sha256最后补一个诊断技巧如果拿不准一个文件到底是 tar 还是 tar.gz不要靠后缀判断用file命令最直接。file app-v1.2.tar.gz # gzip compressed data, ... 说明是 gz # POSIX tar archive 说明是裸 tar tar -tzf app-v1.2.tar.gz | head # 能列出 manifest.json、repositories、层目录说明包结构完整tar -tzf只读目录不落盘对大包来说是最快的一次结构体检。4. 容器文件系统迁移export/import 的适用边界4.1 export 出来的东西到底少了什么前面说过 export 丢元数据具体丢哪些值得列清楚因为排查时是按条目对号入座的构建历史docker history只剩一条任何依赖历史层做增量构建的流程都会失效。启动配置CMD、ENTRYPOINT、-c之外的默认参数、WORKDIR、USER 全部丢失。环境变量ENV 丢失这是最常见的翻车点。暴露端口和卷声明EXPOSE 和 VOLUME 丢失。VOLUME 丢失带来的连锁反应是import 出来的镜像启动时不会自动创建匿名卷。标签不保留。健康检查HEALTHCHECK 丢失依赖健康检查做滚动发布的编排会一直显示 unhealthy 或者直接不检查。挂载的数据卷内容不在包内需要单独处理。还有一个不太直观的点export 出来的镜像往往比想象中的大。因为层被合并成一层原来多个镜像共享的基础层在 import 之后变成各自独立的一份如果同一台机器上 import 十几个同源的容器快照磁盘占用会比用镜像时高不少。4.2 用 --change 把关键配置补回来docker import的-c--change参数可以指定 Dockerfile 里的指令把丢掉的配置补回去docker export web1 web1.tar docker import \ -c CMD [/usr/sbin/nginx,-g,daemon off;] \ -c ENV LANGC.UTF-8 \ -c ENV JAVA_HOME/usr/lib/jvm/temurin-17-jdk \ -c WORKDIR /opt/app \ -c EXPOSE 8080 \ -c USER 1000:1000 \ web1.tar mynginx:snap-20240601支持的指令包括 CMD、ENTRYPOINT、ENV、EXPOSE、VOLUME、USER、WORKDIR、LABEL、ONBUILD、STOPSIGNAL。写的时候有两点要注意CMD用 exec 数组形式比 shell 形式可靠因为 shell 形式会隐式包一层/bin/sh -c和环境里PATH不完整的情况叠加起来容易出怪问题ENV的引用关系不会被自动解析ENV PATH$JAVA_HOME/bin:$PATH这种写法在-c里要手动展开成完整路径。如果你自己也记不清原来有哪些环境变量可以在老容器上先看一眼docker inspect -f {{json .Config.Env}} web1 docker inspect -f {{.Config.Cmd}} {{.Config.Entrypoint}} web1把这几条 inspect 的输出留着import 的时候照抄就行。这也是我建议所有做容器快照的人养成的习惯——export 之前先把 inspect 结果存一份文本和 tar 包放在一起。4.3 数据卷和运行中容器的处理两个必须处理的现实问题。第一是数据卷内容得单独搬。export 不含卷数据如果容器里有持久化数据需要在目标机上单独恢复。做法有两种# 方式一临时挂载卷用 tar 打出来 docker run --rm -v webdata:/data -v $PWD:/backup busybox \ tar -czf /backup/webdata.tar.gz -C /data . # 方式二容器还在跑的话直接从宿主机目录抄 docker inspect -f {{range .Mounts}}{{.Source}} - {{.Destination}}{{\n}}{{end}} web1我一般用方式一因为它不依赖宿主机的目录结构包里的路径也是干净的相对路径恢复时不会带着宿主机上的绝对路径痕迹。第二是运行中的容器导出文件系统可能是撕裂的。容器进程正在写数据库文件、日志文件的时候 export拿到的可能是写了一半的状态。这个跟给运行中的虚拟机拍快照还不一样没有文件系统层的静默一致性保护。所以 export 之前尽量docker stop实在不能停就至少确认关键目录没有活跃写入导完在测试环境验证一遍再拿到生产上用。5. 报错逐个拆那几类反复出现的 tar 与 docker 报错5.1 no such file or directory 的三种截然不同的成因搜这个词的人特别多因为它在不同环节出现时含义完全不同得按上下文区分。成因一文件路径真的写错了。典型报错是tar: app.tar.gz: Cannot open: No such file or directory。这种最好办ls一下、pwd一下就行。常见的低级错误是在sudo环境下路径变了sudo tar -zxvf xxx时当前目录还是原目录但文件权限可能不够读或者 shell 补全补了个看起来很像的文件名。成因二目录权限不够读报错文案却是 No such file。这个特别坑。文件存在但你当前用户没权限读时某些 tar 版本给出的提示就是不存在的措辞。判断方法很简单sudo -u nobody ls -l 文件名试一下或者直接stat。修复就是加读权限或提升执行身份。成因三docker 自己的临时目录消失。报错长这样Error response from daemon: open /var/lib/docker/tmp/docker-import-1234567890/repositories: no such file or directory这类报错基本可以锁定两个方向磁盘满了导致写临时文件失败或者有清理脚本、监控 agent 在 load 过程中把/var/lib/docker/tmp下的内容当垃圾删了。前者查df -h /var/lib/docker后者查有没有定时任务在跑。我遇到过一次是运维同学配了个每天清理 /tmp 下超过 10 分钟的文件的脚本匹配规则写宽了顺手把 docker 的 tmp 也扫进去现象就是随机性地 load 失败很难复现。5.2 invalid tar header 和 not in gzip format这两个报错经常一起出现根因大多是同一个文件本身的格式和命令期望的不一致。invalid tar header出现在docker load阶段意味着 docker 读到的字节流不是合法的 tar。按可能性排序排查Windows 上用重定向生成的包UTF-16 编码前面讲过先看文件大小是不是正常值的两倍。传输中断导致截断。sha256sum -c一分钟能确认。把.tar.zst直接喂给旧版docker load。改成zstd -dc xxx.tar.zst | docker load。拿文本编辑器打开过 tar 包再保存。听着离谱但确实有人这么干过。not in gzip format出现在解压阶段通常是拿-z参数去解一个本来就是裸 tar 的文件gzip: stdin: not in gzip format tar: Child returned status 1这种最简单去掉-z用tar -xvf就行或者用file命令先确认格式。反过来拿裸 tar 传给tar -zxvf时GNU tar 其实会自动探测并正常工作反而是看起来没报错的那次最容易被忽略。5.3 no space left on device 与磁盘空间的账怎么算这条报错的原因很直白但需要多少空间这件事很多人算不明白导致明明腾出 10G 还是失败。以docker load -i app.tar.gz1.1GB为例加载过程中的空间需求是环节占用说明原始包1.1GB已经存在的部分解压出的临时 tar最多 1.8GBdocker 会边解压边流式读取峰值不一定到满写入镜像层约 1.9GB加上元数据比裸 tar 略大峰值约 3.5 - 4GB所以至少留 4GB 以上余量如果改成gunzip -c app.tar.gz | docker load这种宿主机先解压再喂管道的写法峰值会低一些因为中间 tar 不落盘。磁盘紧张的时候我优先用管道方案。清理的时候要小心几件事。docker image prune只清悬空镜像docker image prune -a会删掉所有没有容器引用的镜像在构建机上跑这个等于把缓存全清掉下次构建从头拉基础镜像。docker system prune -a还要更狠会连停止的容器、没用到的网络一起清。生产机器上我从来不直接敲第二条都是先docker system df看账再docker image ls --filter danglingtrue看清单确认了才动手。如果/var/lib/docker所在分区天生就小与其反复清理不如直接迁走。改/etc/docker/daemon.json{ data-root: /data/docker }改完systemctl stop docker、rsync -aP /var/lib/docker/ /data/docker/、启动确认镜像还在之后再删旧目录。这个操作我建议在业务低峰期做rsync那一步取决于数据量可能十几分钟到几小时。6. 离线交付与备份场景的完整套路6.1 一份可以直接抄的搬运脚本把前面所有要点串起来我实际交付时用的脚本大致长这样做了完整性校验和失败快速退出#!/usr/bin/env bash set -euo pipefail IMAGE${1:?用法: $0 镜像名:标签 [输出目录]} OUT_DIR${2:-.} SAFE_NAME$(echo $IMAGE | tr /: __) STAMP$(date %Y%m%d) BASE${OUT_DIR}/${SAFE_NAME}_${STAMP} mkdir -p $OUT_DIR echo [1/4] 校验镜像存在 docker image inspect $IMAGE /dev/null echo [2/4] 打包并压缩 docker save $IMAGE | pigz -p $(nproc) -1 ${BASE}.tar.gz echo [3/4] 生成校验值 sha256sum ${BASE}.tar.gz ${BASE}.sha256 echo [4/4] 输出清单 docker image inspect -f 镜像: {{.RepoTags}} ID: {{.Id}} $IMAGE ls -lh ${BASE}.tar.gz ${BASE}.sha256目标机上的恢复只需要三步校验、加载、确认。sha256sum -c app__v1.2_20240601.sha256 pigz -dc app__v1.2_20240601.tar.gz | docker load docker image ls | grep app脚本里我特意用镜像名生成文件名tr /: __就是为了避免交付包里出现十几个叫image.tar.gz的文件现场谁也说不清哪个是哪个。带上日期也是同理两周后回来做增量交付的时候一眼能看出新旧。6.2 Windows 与 Docker Desktop 侧的差异如果一端是 Windows 桌面环境除了前面提到的重定向编码问题还有几个点会拖慢进度或者直接失败。跨文件系统的 I/O 性能。在 WSL2 里操作/mnt/c/...下的文件大文件读写速度可能只有 ext4 分区里的三分之一甚至更低。几百 MB 的镜像包体感还能接受几 GB 的包就能明显感觉到卡。我的做法是压缩和加载全部在 WSL 的原生文件系统里做比如~/images/传出去的时候再拷到 Windows 盘上。磁盘只涨不缩。Docker Desktop 在 WSL2 后端下用的是一个动态扩展的虚拟磁盘文件删掉镜像之后宿主机上看到的文件大小不会自动回缩。清理的动作要在两处做Docker Desktop 的界面里 Clean / Purge data以及关闭 WSLwsl --shutdown之后对虚拟磁盘做压缩。很多人抱怨镜像删了 20GC 盘还是满的原因就在这里。换行符。在 Windows 上编辑过的 shell 脚本会变成 CRLF拿到 Linux 上执行报bad interpreter: /usr/bin/env: no such file or directory。这个报错长得跟文件不存在很像实际是文件头多了个\rdos2unix或者sed -i s/\r$//处理一下就行。路径写法。docker load -i的路径参数在 Docker Desktop 环境下-i C:\images\app.tar和-i /c/images/app.tar都可能不认稳妥的写法是用正斜杠的 Windows 路径-i C:/images/app.tar或者干脆在 PowerShell 里先cd到目录再用相对路径。6.3 几个反直觉但很关键的细节最后把几条零散但容易吃亏的经验集中说一下。跨架构的镜像搬过去是跑不起来的。在 x86 机器上 save 的镜像load 到 ARM 机器上容器启动会报exec format error。docker save只会导出本地平台对应的那个镜像变体多架构的 manifest 信息带不走。跨架构交付的正确做法是构建阶段就docker buildx build --platform出对应架构的镜像打包时按架构分开打文件名里带上amd64、arm64这样的标识。镜像 ID 一致不等于可以直接混用。load 回来的镜像 ID 和源端一致这是 save/load 的优点但如果你在源端重新构建了镜像又用了同一个标签两边的 ID 就对不上了而 tag 还是一样的。交付时最好在包里附一份docker image inspect的 ID 清单双方对一下 ID 比对比标签可靠得多。别把 tar 解开再重新打。有时候为了看清包里的内容会tar -xf出来看完再tar -cf打回去。这个操作会丢失文件权限、符号链接、硬链接的细节打回去的包docker load十有八九失败。要看内容就只用tar -tzf列清单不要解开。真正需要备份的往往不是镜像。镜像本身是可以从 Dockerfile 重新构建出来的只要代码和基础镜像还在。真正不可替代的是数据卷里的数据和那些没落进配置管理的手工改动。所以在设计备份策略的时候我一般把镜像的 save 当作离线缓存而不是备份——真正要保证的是数据卷的定期归档和 Dockerfile 的版本管理。大文件传输完之后立刻验证别等现场。我踩过最狼狈的一次是在客户机房传输花了四十分钟当场 load 报错然后发现 sha256 对不上还得重传而窗口时间只剩半小时。后来改成传输一结束立刻校验、校验不过立刻重传宁可多花十分钟也不把风险留到使用的那一刻。上面这些操作里我平时最常用的组合是docker save | pigz -1落盘、sha256sum校验、pigz -dc | docker load恢复再配一个顺手的小检查——加载完之后跑一次docker run --rm 镜像名 环境自检命令确认镜像不只是加载成功而是真的能起来。毕竟docker load报出Loaded image:只代表包解开了、层写进去了不代表镜像能在新环境里正常工作。多花三十秒跑一次冒烟验证能省下现场排查半小时。