
干内网交付的人都清楚系统上线时最折腾的往往不是业务本身而是环境准备这一层机器不通外网Docker装不上镜像拉不了一天时间就耗在等包和找包上。制作离线Docker安装包就是提前在能联网的工作机上把引擎、依赖、镜像全部收齐做成一个自包含的目录拿到现场解压执行几分钟内让Docker跑起来。这篇文章会从方案选型、素材收集、实操打包、现场排障一直讲到多节点分发的进阶做法适合要往内网环境交付的运维和实施同学也适合第一次接触离线部署的人照着做出一份可用包。1. 动手之前先搞清楚离线安装包到底要装什么1.1 需求拆解离线包里除了引擎还有啥很多人一提离线Docker第一反应就是弄个安装文件。做离线包这事坑就坑在这里你以为只需要一个包实际上业务系统跑起来需要一整套东西。先拆一拆一套完整的离线Docker安装包至少包含四类内容。第一是Docker引擎本身包括docker CLI、dockerd守护进程、containerd、runc、docker-init这些组件。第二个是业务依赖的镜像比如常见的Nginx、Redis、PostgreSQL或者内部业务镜像这些镜像要以tar文件的形式随包携带。第三个是辅助工具和配置比如Docker Compose插件、daemon.json、systemd服务描述文件这些决定了引擎起得来、配置对不对。第四个是安装脚本和校验文件现场执行的时候不用临时敲命令跑一个脚本就能完成安装和镜像导入。我见过最典型的翻车现场某次上线前实施同学只拷了docker二进制rpm包过去到现场装完Docker发现镜像全没有业务镜像还要从某个私有仓库拉而现场网络根本不通。最后折腾了一下午在网络空窗期搞了个临时通路才把镜像传进去。本质问题就是需求拆解不完整离线包没有做到“自包含”。所以做包之前先在纸上列清单引擎、镜像、配置、脚本缺一样都不算完整的离线包。1.2 方式选择系统包管理器还是静态二进制收集Docker引擎有两种主流方式一种是下载发行版对应的RPM/DEB安装包另一种是下载静态二进制压缩包。很多新手一上来就问哪个好我的结论是没有绝对好坏取决于目标环境是否统一。如果你要交付的几十台机器都是同一个发行版的同样版本比如都跑某RHEL系发行版的7.x那么用RPM包最合适。因为包管理器会把依赖关系一起梳理清楚现场只要执行本地安装命令连续装完rpm包就能用。收集方式也很直接在联网机器上用yumdownloader或dnf download把docker-ce、docker-ce-cli、containerd.io、docker-compose-plugin这些包及依赖全部拉下来再拷贝到内网。这种方式前提是发行版一致不然依赖会出问题。如果目标机器五花八门有某个RHEL系发行版、有Debian系、甚至有定制的精简系统那静态二进制包是更好的选择。Docker官方发布的静态二进制包解压后就是一堆可执行文件拷到/usr/local/bin下就能用不依赖发行版的包管理系统。但代价是systemd服务文件、daemon.json这些外围配置需要自己准备安装脚本里要手工初始化。我的习惯是多数项目直接采用静态二进制方案因为它的适用范围广脚本写好后在异构环境里复用性极强。2. 有网环境下的素材收集与版本清单2.1 镜像清单先行版本必须锁死做离线包最容易踩的雷就是镜像版本用latest。之前有个项目开发同学在生产环境里直接写的nginx:latest结果我打包的时候拉下来的Nginx版本和业务代码要求的不一致现场跑起来后接口行为不对排查了很久才发现是镜像小版本漂移了。从此我养成了一个习惯任何离线镜像清单必须锁到具体版本禁止出现latest。锁版本的具体做法很简单。先在一个正常运行的业务环境里用docker images --format {{.Repository}}:{{.Tag}}把当前用到的镜像全列出来然后把它们整理成一个纯文本清单一行一个镜像。列完之后跟开发确认清楚每个镜像的版本约束全部改为精确版本号。比如nginx:1.25.3 redis:7.2.4 postgres:16.2这个清单文件在后续打包脚本里会被直接读取所以格式要保持简单。我还习惯在清单旁边加一列“用途说明”比如哪个是网关、哪个是缓存、哪个是数据库。镜像一多没有说明很容易迷失。清单是离线包的“索引”后面的拉取、导出、导入都以它为准。2.2 引擎二进制与系统依赖一起收齐镜像之外引擎本身的收集也要讲究。用静态二进制包的话建议选择Docker官方发布的stable目录里的版本下载后先解压看看里面的文件结构。正常情况下里面会包含docker、dockerd、containerd、containerd-shim-runc-v2、ctr、runc、docker-init这些程序。下载完成后别急着走先用./docker --version和./dockerd --version检查一下可执行文件是否正常同时记录版本号备用。这里有个非常容易被忽略的点静态二进制包不等于没有任何系统依赖。Docker的runc在启动容器时依赖系统的libseccomp库如果目标机器的系统太老、libseccomp版本过低安装脚本执行顺利但真正跑容器时会报OCI runtime相关的错误。所以在收集引擎的同时把libseccomp的离线安装包也一起放进包里现场如果发现版本不够直接本地安装。如果走RPM包路线收集工作更依赖命令。在联网机器上执行类似dnf download docker-ce docker-ce-cli containerd.io docker-compose-plugin --resolve的命令把主包和依赖包全部下载到一个目录。执行完以后务必检查目录里是否生成了多个rpm文件数量通常在十几个甚至更多不要漏。2.3 一台干净的工作机顶半个团队用于制作离线包的机器建议单独准备不要和日常开发环境混在一起。原因是镜像tar包导出后内容取决于你在哪台机器上拉取的架构和版本带环境越干净越不容易出错。工作机最好是Linux x86_64系统并且确定目标机器的CPU架构后再动手。这一点在后面的避坑部分会重点展开如果你在苹果M系列芯片的机器上拉取镜像默认拉下来的是arm64架构导出后拷贝到x86_64服务器上load能成功但运行会报exec format error。所以工作机上要么是x86_64要么拉取时显式指定目标平台。工作机上还要提前装好Docker并确保磁盘空间充足。镜像导出前最好先docker system df -v看一眼空间占用因为几个大镜像的tar加起来可能几十GB临时目录放不下会白白浪费下载时间。3. 核心实操把离线安装包做出来3.1 目录结构先规划再动手做离线包最好的经验是先规划目录再放文件。不要把所有东西堆在一个文件夹里现场人员要能一眼看出包里有什么、先执行哪个脚本。下面是我常用的结构offline-docker/ ├── install.sh ├── uninstall.sh ├── README.md ├── sha256sum.txt ├── bin/ │ └── docker/ │ ├── docker │ ├── dockerd │ ├── containerd │ ├── runc │ └── ... ├── images/ │ ├── nginx-1.25.3.tar │ ├── redis-7.2.4.tar │ └── postgres-16.2.tar ├── deps/ │ └── libseccomp.rpm └── conf/ ├── daemon.json ├── docker.service └── docker.socketbin目录放引擎二进制images目录放镜像导出文件deps目录放系统依赖conf目录放配置文件脚本和校验文件放最外层。这样做的核心原因是脚本的路径引用会非常清晰install.sh会去固定的几个子目录里找素材现场排查问题也不用满包乱翻。README.md很多人不爱写但对离线交付来说价值很大。我在README里会写明包对应的Docker版本、镜像列表、目标系统要求、执行步骤、常见报错处理办法。几年后再回看项目这份README就是最省心的文档。3.2 拉取并导出镜像三步以内搞定镜像导出是整个打包过程最耗时但也是最有规律的一步。核心流程就三步读取清单、拉取镜像、导出tar。通常我会写一个循环脚本避免手动重复操作。#!/usr/bin/env bash set -euo pipefail mkdir -p images while read -r image; do [ -z $image ] continue echo pulling $image docker pull $image # 将镜像名中的斜杠和冒号替换为下划线形成稳定的文件名 filename$(echo $image | tr /: __) echo saving $image to images/${filename}.tar docker save $image -o images/${filename}.tar done images.list这段脚本对标准镜像名没问题但对带私有仓库地址的长镜像名文件名会变长且难读。我的做法是在images.list里给镜像打上标签名注释文件名用简单别名同时另存一份完整的tag映射表保证可追溯。导出完成后一定要检查tar文件大小。可以ls -lh images/看一遍每个文件不应该是一个小几十KB的文件镜像tar一般从几MB到数GB不等。常见的问题是镜像里有悬空层或历史残留导致导出的包比预期大很多。这种不算致命但对交付体积有要求的环境建议导出前先docker image prune清理无tag镜像再重新拉取导出。3.3 一键安装脚本把现场操作变成跑脚本一颗好离线包的核心不是镜像而是install.sh脚本。它要帮现场人员把引擎装好、服务启动、镜像导入每一步都有输出和校验。下面这个脚本是我的常用模板已在上百台机器上验证过#!/usr/bin/env bash set -euo pipefail ROOT_DIR$(cd $(dirname $0) pwd) BIN_DIR${ROOT_DIR}/bin/docker IMG_DIR${ROOT_DIR}/images CONF_DIR${ROOT_DIR}/conf if [ ! -f ${BIN_DIR}/dockerd ]; then echo ERROR: docker binary not found in ${BIN_DIR} exit 1 fi echo installing docker binaries cp -r ${BIN_DIR}/* /usr/local/bin/ chmod x /usr/local/bin/docker /usr/local/bin/dockerd /usr/local/bin/containerd /usr/local/bin/runc echo preparing daemon.json if [ -f ${CONF_DIR}/daemon.json ]; then cp ${CONF_DIR}/daemon.json /etc/docker/daemon.json fi echo registering systemd services if [ -f ${CONF_DIR}/docker.service ]; then cp ${CONF_DIR}/docker.service /usr/lib/systemd/system/ fi if [ -f ${CONF_DIR}/docker.socket ]; then cp ${CONF_DIR}/docker.socket /usr/lib/systemd/system/ fi systemctl daemon-reload systemctl enable --now docker echo loading images for tar_file in ${IMG_DIR}/*.tar; do echo loading ${tar_file} docker load -i ${tar_file} done echo done docker version --format Docker version: {{.Server.Version}} docker images脚本看起来不长但每个细节都有讲究。set -euo pipefail保证中途出错立即退出不会在出错后继续跑后半段造成现场更混乱。先拷贝二进制再放配置最后启动服务并load镜像顺序不能反如果先load镜像Docker还没起来或者配置不对只会白等半天。systemd服务文件建议在打包前就准备好。可以从官方仓库里找docker.service和docker.socket模板按需修改。一种常见问题是机器没有systemd那脚本里要做兼容检测到没有systemd时直接以后台进程方式启动dockerd。但说实话现代系统基本上都有systemd这个兼容分支很少触发。3.4 校验与试运行交付前的最后一道闸离线包做完不是直接交付必须先自检。这一步相当于快递发货前的QA省得包发出去在客户现场出洋相。自检分三层。第一层是校验文件完整性在包根目录执行find . -type f -exec sha256sum {} \; sha256sum.txt生成校验清单。然后把整个目录复制到一台完全断网的干净虚拟机里执行sha256sum -c sha256sum.txt确认每个文件拷贝过程没损坏。第二层是模拟现场执行。断网环境下直接运行bash install.sh观察安装脚本是否能顺利完成。重点看三个输出docker version是否正常显示服务端版本、docker images是否能列出全部镜像、随手docker run --rm nginx:1.25.3 nginx -v能否正常启动容器。第三层是卸载回滚。把uninstall.sh也跑一遍确认它能清理干净避免现场操作人员回滚时把系统搞坏。一个能装也能卸的离线包才是合格的交付物。4. 现场部署时的高频坑与排查清单4.1 镜像导入了却用不了先查这3个地方离线交付最常见的槽点就是在现场执行完docker load后发现docker images里没有期望的镜像名字或者出现了好多none:none的记录。我遇到这种情况第一反应就是看导出命令的文件名。很多人在save时没有带全镜像的tag比如只写了docker save nginxdocker会把所有tag的nginx镜像全导进去load之后自然出现大量悬空镜像。正确做法是save时精确到nginx:1.25.3文件名也对应写上。第二查脚本里的通配符。如果images目录下还有其他类型的文件docker load -i ${IMG_DIR}/*.tar可能会把一个非tar文件喂给load报invalid tar header。所以打包时严格控制images目录只放tar文件。第三个坑比较隐蔽load成功了镜像也存在但现场机器上已经有一个同名的旧镜像新load的镜像被命名成nginx:1.25.3而旧镜像还是latest业务脚本引用的latest指向旧版本。这就是为什么交付文档里一定要写明现场启动容器要使用镜像清单里的完整tag不要裸写latest。4.2 dockerd起不来多半卡在系统依赖Docker装完了systemctl start docker却失败看journal日志会发现一堆线索但实践中最常见的原因无非几类。一类是libseccomp版本过老。日志里会追到runc或containerd调用seccomp相关的符号失败报错往往带着operation not permitted或者unknown syscall。处理办法是提前在deps目录放好对应系统的libseccomp安装包现场先本地安装再重启docker。另一类是iptables版本或内核模块不对。有的精简系统默认没有加载iptable相关内核模块dockerd启动时直接报错退出。检查一下lsmod | grep iptable缺什么就通过modprobe补齐。有些环境不允许修改内核模块那就只能退一步在daemon.json里设置iptables: false不过要清楚这会牺牲容器网络隔离适用场景有限。还有一个很现实的问题是老系统跑新Docker。比如某RHEL系发行版7.x的内核是3.10习惯上不推荐跑太新的Docker版本实测中Docker 24.x在这类老环境里容易遇到兼容性问题。遇到这种情况我的建议是直接选用20.10系列版本做离线包稳定压倒一切。4.3 平台架构错乱别把arm包带到x86现场exec format error是镜像平台错乱的最典型现象。当你在某台苹果M系列或其他ARM设备上拉取了镜像导出的tar包到x86服务器上load时一切正常一旦运行就报这个错。当时某个项目就翻过这个车开发同事在自己笔记本上制作离线包笔记本是ARM芯片拉取的镜像都默认带arm64平台结果现场服务器全是x86_64架构所有容器都跑不起来。最后只能在服务器环境重新拉一遍镜像再打包白白浪费了一个工作日。解决办法有两个层面。第一制作离线包的工作机尽量和目标架构一致这样不用额外操心平台问题。第二如果工作机架构不一致拉取时必须显式指定平台例如在x86目标场景下执行docker pull --platform linux/amd64 nginx:1.25.3。导出后还能用docker image inspect 镜像名 --format {{.Architecture}}确认架构字段确保导出的包确实是目标平台。4.4 搬运过程损坏sha256校验救你一命离线包的搬运介质一般是U盘、移动硬盘偶尔是网络拷贝。看起来都是成熟方案但文件损坏这事真不少见尤其是镜像tar动辄几个GB的时候。现场docker load报invalid tar header或者加载到一半中断大概率就是文件损坏了。对这种问题的唯一可靠预防手段就是在交付前生成sha256校验文件到现场后第一步就执行校验。考虑到现场机器可能没有sha256sum命令可以在install.sh里内置一个检验逻辑脚本启动时自动对images目录的tar文件做哈希比对比对不通过直接拒绝执行。压缩会加剧问题。有些同学喜欢把tar再压成tar.gz省空间但压缩文件对损坏更敏感一个比特的损坏都可能导致整个tar.gz无法解压。我的建议是能不压缩就不压缩除非交付介质空间非常紧张。真要压缩现场load之前务必要先解压再导入不要图省事直接读过。4.5 现场pull不到镜像离线包必须自包含有一种情况最让人头疼现场Docker已经装好业务镜像也已经load进去但业务脚本里有个步骤是去某个私有仓库拉一个附加镜像。这个镜像既不在离线包里也没办法在离线环境访问到整个部署流程就卡在最后一步。原因不难理解开发人员在写部署脚本时默认前提是“能联网”。所以做离线包的人在需求阶段就要跟开发和实施对齐所有需要运行的镜像必须提前在离线包里备齐绝不抱着“到时候再拉”的想法。我在交付前的检查清单里专门有一条“断网演练”现场执行完整部署流程从install.sh开始到业务系统启动全程不允许访问外网不允许访问私有仓库。演练能通过这个离线包才算真正自包含。5. 镜像很多、节点很多时上本地Registry更省事5.1 什么时候该换方案多镜像多节点tar包方式最直接但在两个场景下会变得难用。一是镜像数量多比如一个平台涉及几十个镜像每个版本更新一次就要重新save一遍光导出和拷贝就很费劲。二是目标节点多比如有几十台机器需要部署每台机器都要拷贝几十GB的tar包时间成本和介质成本都不可接受。这时候更好的方案是搭建一个内网Docker Registry。它的本质就是一台提供镜像拉取服务的机器节点只要配置好仓库地址像正常环境一样docker pull就能拿到镜像。对多节点交付来说效率比tar包高一个数量级。5.2 内网Registry搭建十分钟跑起来内网Registry的搭建步骤并不复杂关键是把包带进去。先在联网机上拉取registry:2镜像导出成tar再把registry.tar和离线包一起拷进内网。在离线环境中load这个镜像然后启动容器。docker load -i registry-2.tar docker run -d \ --name registry \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ --restart unless-stopped \ registry:2启动后把已有的镜像改名成“内网Registry地址/镜像名:标签”的格式再推送到Registry里docker tag nginx:1.25.3 192.168.1.100:5000/nginx:1.25.3 docker push 192.168.1.100:5000/nginx:1.25.3其他节点要使用Registry时修改每台机器的/etc/docker/daemon.json加入insecure-registries配置重启Docker后就可以直接pull{ insecure-registries: [192.168.1.100:5000] }这里用到的其实是HTTP明文访问。内网环境没有证书Registry默认要求HTTPS所以必须通过insecure-registries让Docker放行。注意配置是JSON格式改完要systemctl restart docker不然Docker不会重新读取配置。5.3 使用Registry时最容易踩的两个坑第一个坑是容器没挂数据卷。有人图省事直接docker run -d -p 5000:5000 registry:2镜像也顺利推上去了。可一旦容器重建或服务器重启后容器被清理Registry里的所有镜像全部消失又要重新导入一遍。挂载/data/registry到宿主机是做Registry的第一个必改项。第二个坑是改了daemon.json导致Docker起不来。insecure-registries的值写错成非数组结构或者JSON末尾多了逗号都会导致配置文件解析失败Docker重启直接失败。现场一旦这样很多人会慌了手脚。处理办法是先把daemon.json移走重启Docker恢复服务再仔细改配置。这也是为什么我建议daemon.json这类配置文件在打包前就写好并经过测试不到了现场再临时编辑。另外用Registry方案时镜像版本管理要更严格。同一镜像名可能有多个tagpush之前要确认要分发的是哪个版本。我习惯在Registry上固定保留一个“当前生产版本”的tag例如nginx:prod节点统一pull这个tag避免各处拉到的版本不一致。结尾如果你问我做了这么多年离线交付最大的体会是什么我会说离线Docker安装包的价值不在“装Docker”而在于把环境不确定性提前消解掉。清单锁版本、脚本自动化、离线自包含、sha256校验这四件事看着基础但每一条都是从现场翻车经历里磨出来的。尤其是现场网络不可用的时候一份准备充分的离线包可以让你在几分钟内把整套环境跑起来而不是满头大汗地翻文档找依赖。最后再分享一个习惯我每做一个离线包都会在一个临时虚拟机里全新安装一遍系统然后只用这个包完成部署。这个过程不需要太长时间但能帮我提前发现各种依赖缺失和脚本问题。把坑留在打包阶段都好解决把坑带进场就真的是给自己添堵。希望这篇内容能帮你少踩几个我踩过的坑。