1. 为什么“离线”这两个字才是这件事的真正难点很多人第一次听到“把容器从一台机器搬到另一台机器”第一反应是docker save加docker load两条命令搞定能有多难。我一开始也这么想直到真正在一个没有外网、没有私有镜像仓库、甚至连内网互通都受限的环境里干了一次才发现事情远没有那么简单。docker save和docker load只是整条链路里最不起眼的一环真正让人掉头发的是镜像从哪来、依赖怎么凑齐、传输介质怎么选、目标机器上缺什么、加载之后为什么跑不起来。这篇内容聊的就是离线 docker 容器打包转移这件事。它解决的核心问题是在一台能联网或者能拿到镜像的源宿主机上把一个已经跑起来的容器连同它的镜像、数据、配置完整地搬到另一台完全隔离的目标宿主机上并且保证搬过去之后能原样跑起来。适合的人群很明确——做内网部署的运维、给客户现场交付的技术支持、在隔离环境里做实验的开发者以及任何需要“把环境整体搬家”的人。哪怕你只是刚接触 docker 不久只要跟着思路走也能把这件事做扎实。我先说一个反直觉的结论离线转移的成败八成取决于打包之前的准备工作而不是打包命令本身。命令就那么几条谁都会敲但镜像里有没有藏着运行时才下载的东西、容器有没有挂载宿主机上的目录、目标机器的内核和存储驱动跟源机器差多少这些才是决定你搬过去能不能跑的关键。我见过太多次“镜像 load 成功了容器一启动就退出”的场面排查半天发现是容器依赖的一个配置文件在宿主机上根本没跟着镜像走。所以这篇不打算上来就甩命令而是按真实的操作顺序把每一步的意图、坑点和判断依据讲清楚。你会看到镜像和容器的区别到底在哪、save和export该怎么选、离线环境下依赖怎么凑、传输介质怎么挑、目标机器上要提前确认什么、加载后跑不起来怎么一步步排查。这些都是文档里不会写、但现场一定会遇到的东西。提示本文所有操作都基于常见的 Linux Docker 环境涉及的命令和参数以主流稳定版本为准。不同发行版、不同存储驱动的细节会有差异实操时以你自己环境的实际输出为准。2. 先搞清楚你要搬的到底是“镜像”还是“容器”这是整件事的第一个分水岭也是最容易含糊过去的地方。很多人嘴上说“搬容器”实际动手时却只搬了镜像结果容器里的数据、启动参数、挂载配置全丢了。要避免这个坑得先把 docker 里几个概念的关系理清楚。2.1 镜像、容器、数据卷三者的关系打个比方镜像就像一张系统安装光盘是只读的模板容器是用这张光盘装出来的一台机器它在镜像之上加了一层可写层你运行时的所有改动都落在这层里数据卷则是挂在机器外面的移动硬盘容器删了它还在。离线转移的时候你到底要搬哪几样取决于你的目标。如果你只是想把一个“干净的环境”搬到目标机器之后重新起一个新容器那搬镜像就够了。如果你要把“当前这个正在跑的容器连同它现在的状态”原封不动搬过去那就得把容器的可写层也带上这时候光有镜像不够。如果容器还挂了宿主机目录或者数据卷那这些数据得单独处理它们不在镜像里也不在容器可写层里。我整理了一张对照表帮你快速判断自己属于哪种情况你的目标需要搬的东西推荐手段目标机器上重新起一个同样的服务镜像docker save/docker load保留容器当前运行状态和数据容器可写层 镜像docker commit后再save或docker export容器挂了宿主机目录镜像 宿主机目录内容镜像走save目录单独打包容器用了命名数据卷镜像 数据卷内容镜像走save卷用临时容器打包多容器编排的一整套服务所有镜像 编排文件 卷逐个save 配置文件 卷打包这张表看着简单但每一行背后都有坑。比如“容器可写层”这一行docker commit会把当前容器的状态固化成新镜像听起来很美但它会把容器运行时的临时文件、日志、缓存全打进去镜像体积可能翻好几倍而且这些脏数据搬到新环境未必适用。所以我的经验是能用“镜像 外部数据”解决的就不要用commit把状态揉进镜像里除非你确实需要保留容器内的临时状态。2.2 save 和 export 的区别别用错这两个命令长得像作用完全不同用错了会丢东西。docker save针对的是镜像它把镜像的完整分层结构、元数据、标签全部打包成一个 tar 文件docker load回来之后镜像的分层和历史都还在可以正常docker history查看。docker export针对的是容器它把容器的文件系统导出成一个扁平的 tar所有分层被压成一层docker import回来之后是一个没有历史、没有元数据的新镜像启动命令、环境变量这些都得你自己重新指定。一句话记要保留镜像完整性和可复用性用save只想把某个容器的文件系统抠出来当快照用export。绝大多数离线转移场景正确答案是save。export更适合做容器文件系统的取证或者极简快照日常搬家不推荐。# 保存镜像推荐保留分层和元数据 docker save -o myapp_v1.tar myapp:v1 # 保存多个镜像到一个文件适合一次搬一整套 docker save -o bundle.tar myapp:v1 redis:7 mysql:8 # 加载 docker load -i myapp_v1.tar # export 的用法仅作对比日常少用 docker export -o container_fs.tar my_running_container docker import container_fs.tar myapp:snapshot还有一个细节docker save出来的 tar 用docker load加载时镜像名和标签是打包时写进去的加载后原样恢复。但如果你用docker exportdocker import导入时必须手动给镜像打标签否则就是个none:none的悬空镜像很容易搞混。2.3 容器启动参数不会跟着镜像走这是新手最容易栽的地方。你用docker run -e DB_HOSTxxx -p 8080:80 -v /data:/app/data起了一个容器然后commit成镜像搬到新机器docker run起来发现环境变量没了、端口没映射、数据目录是空的。为什么因为这些启动参数是docker run时传的不属于镜像也不属于容器文件系统commit和save都不会带上它们。所以离线转移时你必须自己把这些“运行配置”记下来。我的习惯是在源机器上先docker inspect把容器的完整配置导出来存成文件跟镜像一起搬过去到了目标机器照着重新写docker run命令。# 导出容器完整配置作为迁移参考 docker inspect my_container my_container_config.json # 只看关键部分环境变量、端口、挂载、启动命令 docker inspect -f {{json .Config.Env}} my_container docker inspect -f {{json .HostConfig.PortBindings}} my_container docker inspect -f {{json .Mounts}} my_container docker inspect -f {{json .Config.Cmd}} my_container这份配置就是你在目标机器上重建容器的“说明书”。别嫌麻烦现场排查时它能救命——尤其是当容器跑不起来、你又记不清当初传了哪些参数的时候。3. 打包前的准备把“隐形依赖”一个个揪出来准备工作做得好后面一路顺准备没做够搬过去就是一堆报错。这一节讲的是打包之前必须在源机器上确认的几件事每一件都对应一类常见的翻车现场。3.1 确认镜像里有没有“运行时才下载”的东西有些镜像看着能跑其实启动脚本里藏了apt-get install、pip install、npm install或者curl下载配置的动作。在源机器上因为有网这些动作悄悄成功了你以为镜像自给自足搬到离线机器上第一步就卡在下载失败容器直接退出。排查方法很直接看镜像的启动命令和入口脚本。# 看镜像的 Entrypoint 和 Cmd docker inspect -f {{json .Config.Entrypoint}} {{json .Config.Cmd}} myapp:v1 # 进容器看入口脚本内容如果是脚本启动 docker run --rm --entrypoint cat myapp:v1 /entrypoint.sh如果入口脚本里有网络请求你有两个选择一是在源机器上先把这些依赖装进镜像改 Dockerfile 重新构建或者进容器装好后commit二是把这些依赖文件单独准备好挂载进去。前者更干净后者更灵活看你的实际情况。我一般优先改镜像因为“镜像自包含”是离线部署的基本原则挂载太多外部文件迁移时容易漏。3.2 挂载目录和数据卷必须单独处理前面提过容器挂载的宿主机目录和命名数据卷内容都不在镜像里。打包镜像之前先把这些数据的位置和大小摸清楚。# 查看容器的所有挂载点 docker inspect -f {{range .Mounts}}{{.Type}} {{.Source}} - {{.Destination}}{{\n}}{{end}} my_container输出会告诉你哪些是bind宿主机目录、哪些是volumedocker 管理的卷。对于bind类型直接把宿主机目录打包就行对于volume类型得用一个临时容器把卷内容导出来。# 把命名数据卷的内容打包到当前目录 docker run --rm -v myvolume:/data -v $(pwd):/backup alpine \ tar czf /backup/myvolume.tar.gz -C /data . # 目标机器上恢复 docker run --rm -v myvolume:/data -v $(pwd):/backup alpine \ tar xzf /backup/myvolume.tar.gz -C /data这里有个容易忽略的点卷的权限和属主。用tar打包再解包如果目标机器上容器的运行用户 UID 跟源机器不一致解出来的文件属主可能不对容器读写就报权限错误。稳妥的做法是打包时保留权限tar czf默认保留解包后检查一下关键目录的属主必要时用chown修正。3.3 镜像体积和分层别把垃圾一起搬走离线传输的介质容量往往有限U 盘、移动硬盘、内网文件服务器空间都不是无限的。打包前看一眼镜像体积太大的话想想能不能瘦身。# 查看镜像及分层大小 docker images myapp:v1 docker history myapp:v1docker history能看出每一层多大如果某一层特别大且是临时文件比如编译产物、下载的安装包说明构建时没做清理。这种情况可以在源机器上基于原镜像重新构建一个精简版把不必要的层去掉。不过要注意离线场景下重新构建需要基础镜像也在本地如果基础镜像本身就得联网拉那就得先把基础镜像也save下来。另一个省空间的技巧是打包时用 gzip 压缩。docker save本身不压缩出来的 tar 可能好几个 G压缩后往往能小一半以上。# 边保存边压缩 docker save myapp:v1 | gzip myapp_v1.tar.gz # 加载压缩包 gunzip -c myapp_v1.tar.gz | docker load压缩会多花点 CPU 时间但传输时间省下来的更多尤其是走慢速介质的时候这笔账很划算。4. 传输介质与搬运方式的选择逻辑打包好了接下来是怎么把文件弄到目标机器。这一步看着是体力活其实选错了介质和方式前面省下的时间全搭进去。4.1 常见介质的特点和适用场景介质优点缺点适用场景U 盘 / 移动硬盘通用、无需网络容量有限、速度受接口影响、易丢失小体积镜像、临时应急内网文件共享速度快、容量大需要两端都能访问共享同一内网、有共享服务内网直连传输速度最快需要网络可达、配置稍复杂两台机器在同一网段光盘 / 一次性介质隔离性最好容量小、写入麻烦高隔离要求场景选介质的原则就一条在满足隔离要求的前提下选带宽最大、最省事的那个。如果两台机器在同一内网、只是不能上外网那直接内网传输最省事别折腾 U 盘。如果物理隔离那 U 盘或移动硬盘是现实选择注意容量和文件系统格式有些文件系统不支持超大单文件打包前确认一下。4.2 大文件传输的完整性校验不能省不管用什么介质传完之后一定要校验。离线环境里文件损坏是真实存在的风险U 盘拔插不当、传输中断、存储介质老化都可能导致 tar 包损坏而docker load遇到损坏的包报的错往往很含糊让你以为是镜像问题实际是文件坏了。# 源机器上生成校验值 sha256sum myapp_v1.tar.gz myapp_v1.tar.gz.sha256 # 目标机器上传完后校验 sha256sum -c myapp_v1.tar.gz.sha256校验通过再load能省掉大量“镜像明明没问题为什么加载失败”的困惑。这个习惯我从第一次踩坑之后就再没丢过——那次是一个几个 G 的包传到一半断了load报了个莫名其妙的错排查了两个小时才发现是文件不完整。4.3 传输过程中的命名规范多个镜像、多个数据包一起搬的时候文件名混乱是另一个隐形坑。我的做法是统一命名规则镜像名_版本_日期.tar.gz数据包用卷名_data_日期.tar.gz配置文件用容器名_config.json。到了目标机器一眼就能对上号不会出现“这个包是哪个服务的”这种尴尬。5. 目标机器上的落地加载只是开始文件到了目标机器docker load成功很多人到这就以为完事了。实际上真正的验证才刚开始加载成功不等于能跑起来。5.1 加载后先做三项检查docker load之后别急着docker run先确认三件事镜像在不在、标签对不对、架构匹配不匹配。# 确认镜像已加载 docker images | grep myapp # 确认镜像架构源机器和目标机器架构必须一致 docker inspect -f {{.Architecture}} myapp:v1 uname -m架构这一项特别容易被忽略。如果源机器是 x86_64目标机器是 ARM比如某些开发板、国产化平台镜像加载成功但一运行就报exec format error。这种情况不是镜像坏了是架构不兼容得在目标架构上重新构建或者找对应架构的镜像。离线环境下这个坑很致命因为没法临时拉一个对应架构的镜像救急只能提前确认。5.2 重建容器时把配置对齐拿着之前导出的docker inspect配置在目标机器上重建docker run命令。重点对齐这几项环境变量、端口映射、挂载路径、启动命令、网络模式、重启策略。挂载路径尤其要注意源机器上挂的是/data/app目标机器上如果这个目录不存在或者权限不对容器起来就报错。# 目标机器上先建好挂载目录并设好权限 mkdir -p /data/app chown 1000:1000 /data/app # UID 按容器内运行用户来 # 再起容器 docker run -d --name myapp \ -e DB_HOST192.168.1.10 \ -p 8080:80 \ -v /data/app:/app/data \ --restart unless-stopped \ myapp:v15.3 容器起不来的排查链路如果容器启动后立刻退出按这个顺序查基本能定位到问题看退出码和日志docker ps -a看状态docker logs myapp看输出。退出码 127 通常是命令找不到1 是通用错误137 是被 OOM 或强杀。看是不是缺依赖日志里如果有“command not found”“No such file”说明镜像里缺东西回到 3.1 节检查运行时下载的问题。看挂载和权限日志里有“Permission denied”检查挂载目录属主和容器运行用户。看网络容器起来了但服务访问不到检查端口映射、目标机器防火墙、容器内服务监听地址监听 127.0.0.1 的话外部访问不到得监听 0.0.0.0。看架构报exec format error回到 5.1 节确认架构。这个排查顺序是我踩了无数次坑之后总结的从“容器自身”到“外部环境”逐层往外查比东一榔头西一棒子高效得多。6. 几个让我印象深刻的现场教训讲几个真实场景里总结出来的经验都是文档里不会写、但现场一定会遇到的东西。第一个教训别在源机器上把容器删了再打包。有一次为了“干净”先把容器停了删了再save镜像结果发现容器可写层里的数据全没了。正确顺序是先commit如果需要保留状态或者先导出数据确认数据都备份好了再动容器。顺序错了数据就找不回来了。第二个教训镜像标签别用latest。离线环境里多个版本混在一起latest指向哪个根本说不清。我现在的习惯是每个搬过去的镜像都打明确的版本标签myapp:1.2.3-20240115这种日期和版本都带上出问题能精确回溯。第三个教训目标机器的 docker 版本要提前确认。高版本 docker 保存的镜像低版本 docker 可能加载不了报“unsupported manifest”之类的错。离线环境里没法升级 docker 的话就得在源机器上用兼容的方式保存或者提前把目标机器的 docker 升到位。这个确认动作花不了两分钟但能省掉几个小时的折腾。第四个教训一次搬一整套服务时镜像之间的依赖关系要理清。比如应用镜像依赖某个基础镜像的特定版本单独搬应用镜像过去基础镜像版本不对跑起来行为就不一样。稳妥的做法是把整套服务涉及的所有镜像列个清单逐个save到目标机器逐个load加载完用docker images对一遍清单确认一个不少。7. 把流程固化成可复用的清单做了几次之后我把整个离线转移流程固化成了一个清单每次照着走基本不会再漏东西。分享出来你可以根据自己的场景调整。源机器侧确认要搬的是镜像还是容器状态决定用save还是commitsave检查镜像入口脚本有没有运行时下载动作导出容器配置docker inspect打包所有挂载目录和命名数据卷镜像save并 gzip 压缩生成 sha256 校验文件按统一命名规则整理所有文件传输侧选合适介质确认容量够传完做 sha256 校验目标机器侧确认 docker 版本和 CPU 架构匹配docker load加载镜像恢复数据卷和挂载目录检查属主权限按导出的配置重建docker run命令启动后看日志、看端口、做功能验证确认无误后再清理临时文件这个清单不复杂但每一条都对应过一个坑。真正做过几次离线转移的人会明白难的从来不是那几条命令而是这些命令背后需要提前想到、提前确认的东西。把准备工作做足后面的操作就是顺水推舟准备工作偷懒后面就是无穷无尽的排查。最后分享一个小技巧如果条件允许在正式搬之前先在一台跟目标环境尽量接近的测试机上完整走一遍流程。很多问题架构、docker 版本、权限、依赖在测试机上就会暴露比到了客户现场才发现要好处理得多。离线环境最大的特点就是“出了问题没法临时补救”所以能提前验证的一定提前验证。