前段时间接手了一个让人头疼的迁移任务:把一套运行着多个 Docker 容器的业务环境,从源服务器搬到一台完全离线的目标服务器上。所谓完全离线,指目标机器不接入公网,没有内网环境可以 pull 镜像,也没有现成的镜像仓库可以用来中转,所有迁移物料只能靠物理介质一点一点送进去。更麻烦的是,这不是单容器项目,而是十多个服务互相依赖、带自定义网络和数据卷的复杂Docker环境。标题里标着“待验证”,是因为这套方案在纸面上推演了很多轮,实际动手后仍然踩到了不少“看似能行、实则不行”的坑。这篇文章会把我在这个任务里的约束条件、盘点流程、镜像离线打包、目标端重建、验证与回滚策略,以及目前还没完全跑通的细节完整记录下来,给同样要做离线迁移的人一个可参照的路线图。
1. 先把约束条件摆上桌:到底哪些工具能用、哪些不能
这种迁移任务里,最危险的不是技术难度,而是对“可用条件”的误判。很多时候你以为能用的手段,在真正执行时根本行不通。如果把约束条件想得太乐观,后面的每一步都会跟着跑偏。
1.1 “无网络”和“无镜像仓库”分别意味着什么
先说无网络。这里不是指源端没有网络,而是指目标端和源端之间不能通过网络打通。最严格的情况下,源端和目标端就是两台互不相通的机器,中间隔着一道物理上无法逾越的隔离带。这意味着所有依赖网络的动作全部作废:docker pull、docker push、通过 HTTP 或 FTP 传输镜像 tar 包、scp 复制文件……统统不在可用名单里。
无镜像仓库又意味着什么呢?如果有一台中转的 registry,我们可以把镜像推到私有仓库,再到目标端 pull 下来,这是常规解法。但这次的环境里连这个中转站都没有,只能靠最原始的搬运方式——把镜像打包成文件,用 U 盘、移动硬盘或者其他物理载体带到目标机,再 docker load 回去。
这里有一个很容易被忽视的连锁影响:既然目标端无网络,那么目标端大概率也缺少很多常用工具。比如 gzip、tar 一般都自带,但某些精简版系统连 unzip 都得现场找安装包;更别提想临时装一个更高版本的 Docker 运行时来兼容镜像格式。所以做离线迁移前,第一件事就是确认目标端的操作系统版本、内核版本、Docker 版本,这直接决定了 load 进来的镜像能不能跑起来。我在这次任务里就吃过亏:源端用的 Docker 已经支持较新的镜像格式,目标端的 Docker 版本偏老,load 之后容器启动直接报错,最后只能想办法升级目标端的 Docker 运行时。
提示:核对 Docker 版本时,不光要看 docker --version,还要看内核模块的情况。如果镜像用到 overlay2 存储驱动,目标端内核需要加载 overlay 和 overlay2 相关模块;如果用到 iptables 来做端口映射,目标端还需要放行对应的内核模块。
1.2 “多应用复杂Docker环境”的复杂到底复杂在哪
单容器迁移在离线环境里其实不算难:docker save 一个镜像,搬过去 load,再 docker run 起来就完事。真正的复杂度来自多个应用之间的耦合关系。我在这次任务里把复杂度归纳为五个维度:
- 依赖顺序:某些服务必须在另一些之后启动,否则初始化脚本会失败。
- 服务发现:容器 A 要通过容器名访问容器 B,依赖 Docker 内置 DNS 和自定义网络。
- 持久化数据:每个应用可能挂载了不同的数据卷,数据库、日志、上传文件都散落在不同的卷里。
- 网络拓扑:多个容器共用同一个自定义 bridge 网络,部分容器还绑定了固定 IP。
- 宿主资源:有些容器需要限制内存 CPU,有些需要绑定宿主机端口,有些要读取宿主设备文件。
任何一个维度没盘清楚,迁移后都会出现“容器起来了但业务不通”的怪现象。所以下文中所有步骤,都建立在先把这五个维度彻底摸清的基础上。这也是为什么我建议你在迁移前先花费大量时间做静态盘点——现在多花一小时梳理,后面能节省一整天排查。
2. 迁移前的静态盘点:把整套环境的“变与不变”都写进一张表
在离线迁移里,源端环境就是唯一可靠的信息来源。迁移前我至少花了三个小时做静态盘点,把每个容器的镜像、挂载、网络、环境变量、启动参数全部记录到一张迁移清单里。这张清单不是可有可无的文档,而是后续生成启动脚本、做验证对照的直接依据。
2.1 镜像盘点:tag、digest 和悬空镜像都得单独考虑
第一步是拿到完整的镜像列表。docker image ls 能看到仓库名和 tag,但这是最表面的信息。我建议再用 docker images --digests 看一层 digest,因为在离线传递过程中,镜像名字可能被重复 tag,而 digest 才是唯一标识。同理,迁移回源校验时,比对 digest 比比对名字可靠得多。
有一个容易漏掉的地方是悬空镜像(dangling images),也就是 docker images -f dangling=true 列出的那些没有 tag 的镜像。如果某个容器的镜像 tag 已经被覆盖,但容器还在用旧的镜像 ID 运行,那么 docker save 时必须把这个 ID 也带上。我这次就遇到一个容器用的是“上周的镜像 ID”,而当前 tag 已经指向了新构建的版本,如果只按 tag 打包加载,目标端恢复的容器就会变成“错误的版本”。
所以打包阶段建议用镜像 ID 或者完整 tag 列表,而不是简单粗暴地 docker save $(docker images -q)。后者虽然能覆盖所有镜像,但会把大量无用的中间层和历史镜像也塞进去,包体体积膨胀明显,而且在 load 回来后不容易从文件名判断到底包含了哪些内容。
2.2 数据盘点:具名卷、匿名卷、bind mount 三种类型分开登记
Docker 的持久化数据在宿主机上主要有三种形式,映射关系必须分开处理:
| 类型 | 典型挂载形式 | 数据在源端的位置 | 迁移策略 |
|---|---|---|---|
| 具名卷 | 卷名:/var/lib/mysql | /var/lib/docker/volumes/卷名/_data | 打包卷内容,目标端用同名卷重建 |
| 匿名卷 | 随机hash:/data | /var/lib/docker/volumes/随机hash/_data | 找到对应容器启动命令里的隐式挂载,按具名卷处理 |
| bind mount | 宿主机路径:/app/config | 宿主机指定路径 | 直接复制目录,注意权限属主 |
这里有一个关键坑:docker inspect 一个容器时,Mounts 字段里会区分 Type 是 volume 还是 bind。具名卷和匿名卷都显示为 volume,但匿名卷没有名字,只显示一个 UUID。如果你只靠 docker volume ls 查不到匿名卷的对应关系,就需要 docker inspect 容器拿到 .Mounts[].Name 字段,再到 /var/lib/docker/volumes/ 下找到对应目录。
另外,bind mount 的宿主机路径也需要重点登记。因为在目标端重建容器时,如果宿主机目录不存在,Docker 会默认帮你创建一个空目录,容器可能启动成功但读到的是空配置。所以迁移清单里必须明确记录宿主机需要预先创建的目录列表及权限。
提示:对于数据库这类需要严格数据一致性的服务,不要在生产运行状态下直接打包数据文件。先让服务优雅停机,再执行备份,否则卷里的数据可能是“写一半”的状态。
2.3 网络盘点:自定义网络名称、子网、固定IP 和容器别名
多应用 Docker 环境通常定义了自定义 bridge 网络,容器之间通过容器名互相访问。离线迁移时,网络这块必须在目标端原样重建,否则容器互联会立刻失效。
盘点命令分三步:
- docker network ls 列出所有网络,区分 bridge、host、none 和自定义网络。
- docker network inspect <网络名> 查看子网、网关、已连接的容器及固定 IP。
- docker inspect <容器名> 再核对一遍每个容器实际连接的网络和 IP 地址。
自定义网络在 create 时如果指定了 --subnet,重建时最好沿用相同网段,这样那些锚定固定 IP 的容器才能原样恢复。容器别名(network aliases)同样要记录,因为很多应用在代码里用的是别名访问,而不是容器名。
2.4 配置盘点:环境变量、端口映射、重启策略和启动命令逐个提取
静态盘点的最后一块是容器的运行时配置。docker inspect 的输出字段很多,我只会挑几个迁移一定要复现的:
- Env:环境变量,特别是各种密码、密钥、数据库连接串。
- ExposedPorts 和 PortBindings:端口映射,注意是否绑定了指定 IP(比如 127.0.0.1:3306:3306)。
- RestartPolicy:重启策略,决定容器重启后会不会自动拉起。
- Entrypoint 和 Cmd:容器真正的启动命令,不能丢。
- HostConfig 里的 Memory、NanoCpus、OomScoreAdj:资源限制。
- Privileged、Devices:特权模式和宿主设备映射。
推荐的做法是把这些字段导出来,转成 JSON 存档,作为迁移清单的附件。后面写启动脚本时,直接按这张清单逐项还原,误差会小很多。
3. 镜像离线打包:save/load 链路里最容易出问题的三个环节
静态盘点完成之后,就可以进入镜像离线打包阶段。这套链路的核心只有两个命令:docker save 和 docker load。但实际执行时,有不少细节是文档没写的。离线环境里每一步都要谨慎,因为一旦包传输到一半发现有问题,重新搬运的成本非常高。
3.1 为什么一定用 docker save,不能用 docker export
很多初学者看到容器在运行,第一反应是 docker export 把容器导出成 tar,以为这样就能把“整个环境”搬家。这个做法在离线迁移场景里是大坑。docker export 导出的只是容器文件系统的快照,镜像原有的分层结构、标签、环境变量、Entrypoint、健康检查指令等全部丢失。也就是说,load 回来之后拿到的不是镜像,而是一坨裸文件系统,你得手动重新指定 Entrypoint 和 Cmd,稍有偏差应用就起不来。
docker save 则相反,它保存的是镜像的全部元数据和分层数据,load 之后得到的是一个和源端一致的镜像,可以直接 docker run。所以离线迁移镜像必须用 save,这是底线,不用讨论。
3.2 多镜像打包、压缩、分片和完整性校验
如果环境里有十几个镜像,我会先把要打包的镜像 tag 列表整理好,写成一个文件,然后用循环 save 到同一个 tar 包里:
# 从迁移清单提取镜像tag列表,逐行保存 while read IMG; do docker save "$IMG" -o /tmp/tmp_$(basename "$IMG").tar done < image_list.txt但更简洁的做法是直接一次 save 所有镜像:
docker save nginx:1.21 mysql:8.0 app-backend:v1.2 app-frontend:v1.0 -o app-images.tar拿到单文件 tar 之后,还可以进一步压缩,减少物理介质拷贝体积。压缩我推荐使用 gzip 或 zstd。gzip 兼容性最好,所有环境都有;zstd 压缩率高、速度快,但目标解压环节需要 zstd 工具,离线环境不一定有,所以我大多数情况下还是老老实实用 gzip。
压缩之后要注意分片。主要原因是 U 盘或者部分老旧的移动硬盘文件系统对大文件有限制,比如 FAT32 单文件不能超过 4GB。如果打包后的 tar 或 tar.gz 超过这个值,就需要用 split 分片:
split -b 3900m app-images.tar.gz app-images.tar.gz.part_目标端再 cat 拼接:
cat app-images.tar.gz.part_* > app-images.tar.gz gunzip app-images.tar.gz docker load -i app-images.tar3.3 完整性校验,不能依赖“能 load 就没问题”
离线传递最大的风险是介质损坏。U 盘拷贝中断、移动硬盘休眠导致文件截断,这些都是我实际遇到过的物理层故障。docker load 对损坏 tar 的容忍度很低,一旦包坏,load 到一半就会报错,后续所有镜像都无法导入。
所以在源端生成完 tar 包后,我会立刻执行:
sha256sum app-images.tar > app-images.tar.sha256然后把校验文件和包一起拷走。到目标端后,先跑:
sha256sum -c app-images.tar.sha256确认通过再 load。这一步永远不要省略。多应用环境里镜像数量多、包体大,每一次重新拷包的代价都极高,提前做一次校验比事后重新搬运划算得多。
4. 目标端重建:网络矩阵先行,镜像灌入随后,数据卷最后
到了目标端,整个迁移进入还原阶段。顺序能明显影响成功率:我建议先建网络、再导镜像、最后恢复数据卷。倒过来的话,可能出现镜像导入了但没有网络可用、或者卷恢复了但容器还没创建出来等一系列半成品状态,排查起来很麻烦。
4.1 第一步:重建自定义网络矩阵
打开迁移清单,把源端所有自定义网络在目标端原样创建一遍:
docker network create --driver bridge --subnet 172.28.0.0/16 --gateway 172.28.0.1 app_network需要注意的是,如果网络在源端启用了 IPv6 或附加了其他 option,比如 --ipv6 --subnet fd00::/64,这些配置也要一并还原。别以为 create 一个同名 network 就万事大吉,源端的固定 IP 分配、DNS 行为可能因为网段不一致而出现偏差。
4.2 第二步:docker load 导入镜像并核对 digest
将镜像包传到目标端后,执行:
docker load -i app-images.tarload 完成之后,输出会列出每个镜像的 ID 和 tag。这时先不要急着跑容器,先对照迁移清单检查两点:镜像数量是否齐全、tag 是否都能对上。如果某个镜像在源端有多个 tag,load 后可能只保留了其中一个,需要用 docker tag 把缺失的 tag 补回来。
docker tag <image-id> app-backend:v1.0这个坑在镜像是通过 ID save 时尤其容易踩,所以我在源端打包时就会显式列出 tag 列表,同时把 digest 写入校验文件,还原阶段可以直接比对。
4.3 第三步:数据卷恢复,三种手法按需选
数据卷的恢复手法取决于卷的类型和容器启动方式。讲一下我常用的三种:
第一种,直接复制 bind mount 目录。最简单,适合配置文件、静态资源这类不涉及复杂权限的场景。但要注意目录属主。如果源端目录属主是 1000:1000,目标端复制过去之后属主不变,但目标端的 UID 对应关系不一定一致,容器启动如果以 root 写入,后续调整比较麻烦。
第二种,用 docker run 临时容器做 tar 打包和释放。适合具名卷,能够绕过宿主机路径直接操作卷数据。
源端:
docker run --rm -v db_data_vol:/data -v /backup:/backup alpine tar czf /backup/db_data_vol.tgz -C /data .目标端:
docker run --rm -v db_data_vol:/data -v /restore:/restore alpine tar xzf /restore/db_data_vol.tgz -C /data这种做法的优势是无论卷的宿主机路径如何变化,只要卷名对得上,数据就落到正确位置。
第三种,使用数据库自带的逻辑备份工具(比如 mysqldump / pg_dump)导出逻辑备份。这种方式最接近“业务级”数据备份,迁移后数据一致性最容易保证,但要求容器里能临时执行备份命令,而且恢复时间更长。对于数据库这类强一致服务,如果没有条件做完整停机,我会优先考虑这种办法。
4.4 第四步:编排脚本化,避免手工 docker run 一百行参数
多应用环境里容器数量多,手工一条条 docker run 很容易写错参数。我在迁移前会先把清单转成一份可执行的编排脚本,每个容器对应一段启动逻辑。
如果是用 docker-compose 管理的、一开始就在 compose 文件里定义的环境,迁移时最省事,直接在目标端把 compose 文件拷过来,改好镜像 tag 和宿主机路径,docker compose up -d 就能还原。如果环境是历史遗留的散装 docker run,我有两个选择:
- 用 docker inspect 导出配置后,自己写一个转换脚本生成 docker run 命令。
- 把散装容器改写成 docker-compose.yml,再启动。
第二种更推荐,因为 compose 文件本身就是迁移文档,后续维护也方便。改写时重点核对:image、container_name、ports、volumes、networks、environment、restart、deploy.resources。我这次改造大概用了两个多小时,期间反复检查端口映射和卷路径,后面果然省了很多力气。
提示:改写 compose 文件时,尽量不要改容器名、网络名、卷名。这些名字之间可能存在隐式引用,比如别的容器在代码里硬编码了容器名来拼接访问地址。一旦改名,服务发现立刻断裂。
5. 迁移后的验证:不是容器起来了就叫成功
我见过太多团队做迁移,docker ps 看到容器状态是 Up 就宣布成功,然后业务一访问发现问题一大堆。离线迁移在验证上尤其要认真,因为一旦目标端上线,再想恢复到迁移前状态可能会非常麻烦。
5.1 分层验证清单:从最底层到最外层逐步确认
我设计了一套五层验证策略,每一层通过之后才进入下一层:
第一层,镜像完整性。对照迁移清单检查 docker images 的列表、tag、digest。这一步可以通过一个简单的 shell 循环比对。
第二层,容器存活。所有容器 Up,且日志里没有 ERROR、FATAL 级别报错。
第三层,数据落盘。关键数据要能做抽查比对:数据库行数、表的记录数、某个文件的 md5 值、卷目录的文件数量。
第四层,服务间发现。进入容器 A,尝试解析并访问容器 B 的容器名或别名。比如:
docker exec -it app-backend sh ping app-frontend curl http://app-frontend:8080/health这层最容易暴露网络重建问题。如果发现解析不到,多半是自定义网络没建对,或者容器没有连到同一个网络。
第五层,对外连通。模拟外部请求,验证端口映射是否生效,负载均衡后端的健康检查是否通过。
5.2 回滚预案:源端保留一个完整快照再动手
离线迁移一旦开始,目标端就是一个从零搭建的环境。为降低风险,我在正式开始迁移之前,先把源端的状态完整保留下来:镜像 tar 包、数据卷备份、迁移清单、启动脚本全部归档。这样即使目标端还原到一半发现大问题,也可以重新来,不至于需要回源端再补数据。
另外,迁移正式切换之前可以在目标端做一次“演练式”还原:先把环境完整起一遍,验证允许后,可以清理掉演练数据,再用正式数据迁移一次。如果你的目标端存储空间足够,也可以把演练环境和正式环境放在不同网络、不同卷里,避免互相污染。
5.3 哪些环节是“待验证”的:风险登记表
这篇文章标题叫“待验证”,我不是在玩文字游戏,而是这次迁移确实还有几个环节没有完全跑通。我把已知的风险都列出来:
| 环节 | 当前状态 | 主要风险 | 应对措施 |
|---|---|---|---|
| 镜像在线 save/load | 已验证 | 多 tag 镜像 load 后 tag 丢失 | 显式 tag 列表打包 + digest 校验 |
| 具名卷 tar 备份恢复 | 已验证 | 容器启动时权限不一致 | 备份后启动前调整属主 |
| 自定义网络重建 | 部分验证 | IP 冲突或网段不一致 | 沿用源端 --subnet |
| bind mount 目录属主 | 待验证 | 目标端 UID 与容器要求不匹配 | 启动后检查目录权限 |
| 数据库逻辑备份恢复 | 待验证 | 长时间停机或增量数据丢失 | 先停机后端对端备份 |
这张表会随迁移进度更新,每项验证完成就划掉。现在整体方案的大方向已经跑通,个别环节还在等目标端环境进一步确认。
6. 实操中踩过的坑:时间不同步、tag漂移、匿名卷清空
最后一部分,把这次离线迁移中真实踩过的坑集中复盘一下,每一个都很典型,涉及到的细节如果有人正好在做类似迁移,可以直接对照排查。
6.1 目标端系统时间不准,牵连到 TLS 证书校验失败
目标端如果长期离线,系统时钟很容易停在很久以前。迁移配置里如果有 HTTPS 健康检查或对其他服务发起 TLS 请求,系统时钟不准会直接导致证书验证失败,表现为“curl 突然报证书过期”“服务 A 请求服务 B 握手失败”。排查时第一反应是证书,实际上却是时间问题。
解决方案很简单,但容易遗漏:迁移前先同步目标端系统时间。可以物理介质带一份 ntpdate,或者直接让运维手工设置一个接近当前值的时间。无论如何,不能默认“离线机器时间也是准的”。
6.2 docker load 后镜像 tag 漂移,容器启动用了错误的版本
这个问题在上文提到过,但值得再单独强调一次。如果源端某个镜像出现过多次 tag 覆盖,而 save 时又用 docker images -q 导出了全部镜像 ID,load 回来的镜像可能只保留了其中最新 tag,旧 tag 对应关系丢失。容器启动时,写的是旧 tag,就会因为找不到镜像而报错。
我的对策是:打包时显式用 repo:tag 列表;还原后立刻 docker images 核对;如果确实丢了 tag,再 docker tag 修复。
6.3 匿名卷在容器重建时被清空
还有一个隐蔽的坑:如果容器启动命令里没有显式写 -v 挂载,但镜像 Dockerfile 里定义了 VOLUME,那么容器每次创建都会自动生成一个匿名卷。迁移时如果只备份了具名卷和 bind mount,匿名卷里的数据就会在容器重建时丢失。
处理方式是在盘点阶段就通过 docker inspect 找到所有匿名卷,并在迁移清单里标注“需要一起备份”。备份手法与具名卷完全一致,只是源卷目录在 /var/lib/docker/volumes/ /_data。
6.4 bind mount 目录不存在时 Docker 的“善意”坑
最后一个是 Docker 的默认行为坑。docker run 或 docker compose 创建容器时,如果指定的 bind mount 宿主机目录不存在,Docker 会自动以 root 权限创建一个空目录。这个“善意”导致容器启动成功,但应用读取的配置全是空目录内容,业务表现为“服务起来了但没有数据,没有配置”。
所以我在目标端启动容器之前,会先把迁移清单里所有宿主机目录批量创建好,并设置好属主和权限。这个动作建议放到编排脚本第一步,不要让 Docker 默认创建。