
1. 为什么需要离线部署一个让我凌晨三点爬起来改方案的场景大概半年前接手了一个政府单位内部系统的迁移任务。网络环境属于典型的物理隔离内网跟外网完全不沾边的那种。机器倒是不少但所有服务器、工作站全部在一个封闭的网段里没有互联网出口连个代理都没有。拿到需求的第一反应是用Docker部署这套系统吧结果下一秒就意识到——这台机器上连Docker都没装而且根本没法用yum install或者apt-get install去装。当时试过的几个常规路径全部堵死docker pull拉镜像要联网、yum装 Docker 要联网、甚至想找个能上网的机器下载完再用U盘拷进去都得先解决从哪下载、下载哪些文件、拷进去之后怎么装这一连串问题。最要命的是单位对移动介质管理很严U盘拷贝都要审批网络隔离区域更是连USB口都做了管控。后来熬了两天才把整个思路理顺离线部署的标准姿势不是找一台能联网的机器把 Docker 装好再把文件copy过去而是把整个软件供应链都提前准备好一次性搬进去。这里说的供应链包括三样东西Docker引擎本身的安装包、目标系统要用的所有镜像文件、以及镜像仓库registry服务。这三样缺一不可缺任何一样到了现场都会卡壳。那篇博文我想了很久才决定写。因为Docker内网离线部署这个需求在等保、涉密、能源、金融、制造业这些行业里非常普遍但网上能找到的教程大多是有网环境下怎么玩Docker真正从零开始讲离线部署的、把坑都踩过一遍的太少。这篇就当作我这半年在隔离网环境里反复折腾的记录希望能让后来的人少走点弯路。2. 先把思路捋清楚离线部署的整体方案与选型逻辑2.1 离线的本质不是拷贝文件而是搬运一套供应链很多人一听到离线部署就觉得是把安装包复制过去装上就行。但在Docker场景下这个认知会害死人。因为Docker引擎装好只是第一步业务跑起来需要镜像而镜像本身就是层层依赖的堆叠——一个Java应用镜像可能基于某个OpenJDK基础镜像OpenJDK又可能基于某个Linux发行版的基础镜像。这一串依赖链每条都不能断。所以离线部署的核心思路应该是在一台可以联网的跳板机上把所需要的所有内容全部下载好然后通过离线介质搬运到目标内网环境再在内网完成安装、导入和启动。整个过程分三个阶段准备阶段在联网机器上完成下载Docker引擎离线包、获取所有业务镜像、准备registry镜像。搬运阶段通过移动硬盘、光盘或者审批后的U盘把上述文件拷贝到内网机器。部署阶段在目标内网服务器上完成安装Docker引擎、启动registry容器、将镜像推送到registry、在业务服务器上拉取镜像并启动。这个方案的好处在于只要准备阶段做扎实了现场环境即便千奇百怪也不会太被动。而且把registry作为中转站带进内网后续再新增镜像、升级版本也都有地方放不用每次都用U盘一个个拷。2.2 选型用哪个Docker版本、哪种镜像搬运方式最稳先说话版本选择。Docker引擎分两个大系列老牌的CECommunity Edition社区版和后来Moby项目改名后的版本。实际离线部署时我强烈建议选择带-ce.标识的稳定版本比如20.10.17或者24.0.x原因是这些版本在各类国产化操作系统如麒麟、统信UOS和CentOS 7.x上的兼容性已经被大量验证过。再说registry的选择。官方有 Docker 自家的registry:2镜像也有Harbor这种企业级镜像仓库。考虑到离线部署往往是在资源有限、不想引入太多组件的环境里registry:2就够用了。它轻量、启动简单、功能完全覆盖存镜像取镜像的核心场景。Harbor虽然带Web UI、权限管理、漏洞扫描但在一个纯内网、单机或几台机器的环境里这些功能大概率用不上反而引入了一个庞大的PHP后端和数据库依赖运维负担直线上升。关于镜像搬运常规做法有两种方式原理优点缺点直接保存镜像文件docker save打成tar包到目标机器docker load导入简单直接单机部署最快不支持多节点统一分发版本管理混乱通过registry中转把镜像docker push到内网registry业务机器docker pull支持多节点拉取便于后续版本管理符合真实生产需求需要多部署一个registry服务前期准备稍复杂我自己的习惯是单机或两三台机器用save/load就行一旦超过三台、或者后续明确要持续迭代版本直接上registry方案省得后面后悔。2.3 一个容易被忽略的前置准备目标系统的OS版本与架构匹配这是整个离线部署里最阴的坑。内网服务器五花八门有CentOS 7.9、有Ubuntu 18.04、有麒麟V10、甚至有老旧的CentOS 6。不同OS版本、不同CPU架构x86_64 vs ARM64对应的Docker二进制包完全不同。我就在这点上吃过亏。当时拿到一台鲲鹏920处理器的服务器也就是ARM架构我却在准备阶段下载了x86_64的Docker离线包。结果到了现场安装时报exec format error排查了大半天才反应过来是CPU架构不匹配。后来就长记性了准备之前先在目标环境执行uname -m确认架构x86_64对应AMD64包aarch64对应ARM64包千万别搞混。3. 准备阶段实操在联网机器上把粮食弹药备齐3.1 下载Docker引擎离线安装包这个环节看起来简单但去哪里下载和下哪个文件都有讲究。我推荐直接从Docker官方站点下载下载地址格式是https://download.docker.com/linux/static/stable/架构/比如x86_64架构就打开https://download.docker.com/linux/static/stable/x86_64/这里面能找到类似docker-20.10.17.tgz这样的文件。这个静态包的好处是免安装解压以后把二进制文件扔到/usr/bin/就能用非常符合离线场景。如果目标机器是CentOS且你希望用systemd管理Docker服务官方还提供了rpm包格式是https://download.docker.com/linux/centos/版本号/x86_64/stable/Packages/里面有docker-ce-20.10.17-3.el7.x86_64.rpm这类文件。但要注意rpm包方式需要同时下载一堆依赖包容器运行时、网络插件等离线环境下手动梳理依赖关系非常痛苦。相比之下tar.gz静态包加自编systemd服务是更可控的方案。我在实际操作中的选择下载tar.gz静态包然后用下面的脚本配置systemd服务。# 在联网机器上创建目录并下载 mkdir -p /data/offline/docker cd /data/offline/docker wget https://download.docker.com/linux/static/stable/x86_64/docker-20.10.17.tgz # 同时把docker-compose也准备好如果业务需要 wget https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-linux-x86_64下载完以后在联网机器上先解压看一下文件结构确认里面包含docker、dockerd、containerd这些关键二进制再拿去拷贝。3.2 导出业务镜像docker pull docker save 一条龙镜像准备是整个离线部署里内容最多的一部分。下面演示一个典型场景需要部署一套 Nginx MySQL Java 应用 的架构对应三个镜像。# 在联网机器上 docker pull nginx:1.24-alpine docker pull mysql:8.0.32 docker pull openjdk:17-jdk-slim # 打tar包 docker save -o /data/offline/images/nginx.tar nginx:1.24-alpine docker save -o /data/offline/images/mysql.tar mysql:8.0.32 docker save -o /data/offline/images/openjdk.tar openjdk:17-jdk-slim有几个细节必须强调镜像tag要具体。不要docker pull nginx或者docker pull mysql那样拉下来的是latest标签。一旦原仓库的latest变了你下载的和目标环境可能不一致且离线环境里无法校验。务必写上具体的版本标签这次部署用了mysql:8.0.32下次升级才知道自己在用什么。save命令可以打包多个镜像到一个文件比如docker save -o all.tar img1:tag1 img2:tag2 img3:tag3。但我不推荐这么做。原因很简单上线以后如果只发现某一个镜像需要单独替换你还得重新导出整个大包分成一个个单独tar包到时候哪个坏了替换哪个更灵活。如果镜像依赖私有仓库比如公司内部才有的镜像源这一步需要先在联网机器上登录私有仓库再pull。千万别到了内网环境才发现拉不到那时候就真哭了。docker save出来的tar包大小通常很大一个MySQL镜像400多MB很常见。如果在U盘拷贝时有大小限制可以先压缩gzip /data/offline/images/mysql.tar到目标环境解压再load能节省不少搬运时间。3.3 准备镜像仓库docker save registry:2 镜像既然决定采用registry中转方案那registry:2镜像本身也得通过docker save导出带走。这个没太多花活拉就完了docker pull registry:2.8.2 docker save -o /data/offline/images/registry.tar registry:2.8.2同时如果业务机器数量多、每台都要手动配docker pull的--insecure-registry参数建议在准备阶段就把目标服务器要用的daemon.json示例配置也写好到了现场直接分发给各节点省得一台台改。4. 目标环境安装Docker引擎的完整步骤与避坑细节4.1 systemd服务配置让Docker开机自启且稳定运行把解压后的docker二进制安装到系统路径之后最关键的步骤是配置systemd服务。很多教程到这里就一笔带过但实际生产中没有正确的systemd配置Docker容器跑着跑着会出各种诡异问题比如网络namespace残留、容器重启策略失效、socket文件权限错误等。完整动作如下# 解压docker静态包到/opt或直接/usr/bin mkdir -p /opt/docker tar xzf docker-20.10.17.tgz -C /opt/docker # 将二进制链接到系统PATH cp /opt/docker/docker/* /usr/bin/ # 创建systemd服务文件 cat /etc/systemd/system/docker.service EOF [Unit] DescriptionDocker Application Container Engine Documentationhttps://docs.docker.com Afternetwork-online.target firewalld.service Wantsnetwork-online.target [Service] Typenotify ExecStart/usr/bin/dockerd ExecReload/bin/kill -s HUP $MAINPID LimitNOFILEinfinity LimitNPROCinfinity LimitCOREinfinity TimeoutStartSec0 Delegateyes KillModeprocess Restarton-failure StartLimitBurst3 StartLimitInterval60s [Install] WantedBymulti-user.target EOF # 配置daemon.json这里先写基础配置registry相关稍后加 mkdir -p /etc/docker cat /etc/docker/daemon.json EOF { exec-opts: [native.cgroupdriversystemd], log-driver: json-file, log-opts: { max-size: 100m } } EOF # 重载并启动 systemctl daemon-reload systemctl enable docker systemctl start docker # 验证 docker version这里有三个点我觉得比操作本身更重要Typenotify不能丢。dockerd启动会通过socket通知systemd它已经准备好了。如果用默认的Typesimplesystemd可能会在dockerd还没完全初始化时就认为服务已启动导致立即执行后续命令时出现Cannot connect to the Docker daemon。KillModeprocess是为了防止systemd停止Docker时连容器一起kill掉。这个参数决定了停止docker服务时systemd只杀dockerd进程本身而不会把容器的主进程也一并杀掉。daemon.json里的cgroupdriver设置。如果这台机器之后要跑Kubernetes必须先把cgroupdriver配成systemd否则kubelet与容器运行时之间会出现cgroup驱动不一致的报错。即便是普通docker部署这个配置也能避免很多资源限制上的边界问题。4.2 内核参数与防火墙两个内网环境常见的隐性杀手装好Docker只是第一步。实际部署中最常见的两个装好了但用不了问题往往出在内核参数和防火墙策略上。内核参数方面最需要调的是cat /etc/sysctl.conf EOF net.bridge.bridge-nf-call-iptables 1 net.bridge.bridge-nf-call-ip6tables 1 net.ipv4.ip_forward 1 EOF sysctl -p第一个和第二个参数的作用是保证宿主机上iptables规则能正确作用于容器网络中的bridge流量。如果这台机器上装了firewalld或其他防火墙且内核参数没调经常会出现容器内能ping通网关但容器与容器之间、容器与外网之间不通的怪现象。防火墙策略方面内网环境常常有严格的主机白名单策略。Docker默认会修改宿主机的iptables来转发流量如果安全基线要求每个端口都明确放行就需要在启动容器时用--publish精确映射端口并在宿主机防火墙里放行对应端口。这里有一个容易被忽略的点firewalld的默认zone策略在某些版本下会阻塞docker的网桥流量建议在验证阶段直接用firewall-cmd放行docker网段或者干脆在测试时临时停掉firewalld确定问题出在哪再精细化配置。4.3 测试动作装完不验证等于没装每台机器装完Docker我都建议用同一个测试容器快速验证一遍docker run --rm -d -p 8080:80 --name test-nginx nginx:1.24-alpine curl http://127.0.0.1:8080/验证两件事第一容器能不能起来、端口映射是否生效第二重启后容器策略是否符合预期--restart参数。这个动作看似多此一举但在一批5台、10台机器挨个装的时候能帮你提前暴露这台机器内核版本太老那台机器glibc库缺失的问题。5. 把镜像搬进内网load push pull 完整链路5.1 第一步在目标机器上启动registry容器假设这台机器既是仓库服务器也是业务服务器IP是192.168.1.100我们在这台机器上把registry:2镜像load进来并启动# 导入registry镜像 docker load -i /opt/offline/images/registry.tar # 启动registry容器 docker run -d \ --name registry \ --restartalways \ -p 5000:5000 \ -v /opt/registry-data:/var/lib/registry \ registry:2.8.2这里有个细节数据目录用-v挂载到宿主机/opt/registry-data这样将来registry容器挂了重建镜像数据还在。如果只跑docker run -d -p 5000:5000 registry:2.8.2而不挂载数据卷容器一旦被误删整个仓库里的镜像灰飞烟灭。5.2 第二步把业务镜像load进来并push到registry回到仓库服务器上执行# 逐个导入镜像 docker load -i /opt/offline/images/nginx.tar docker load -i /opt/offline/images/mysql.tar docker load -i /opt/offline/images/openjdk.tar # 重新打tag并push docker tag nginx:1.24-alpine 192.168.1.100:5000/nginx:1.24-alpine docker push 192.168.1.100:5000/nginx:1.24-alpine docker tag mysql:8.0.32 192.168.1.100:5000/mysql:8.0.32 docker push 192.168.1.100:5000/mysql:8.0.32 docker tag openjdk:17-jdk-slim 192.168.1.100:5000/openjdk:17-jdk-slim docker push 192.168.1.100:5000/openjdk:17-jdk-slim执行后用docker image ls查看会发现镜像列表里既有原来的原始镜像名也有加了IP前缀的新tag。这两个tag占的是同一份镜像数据不会重复占磁盘空间。但你要注意后续业务机器上pull下来的镜像名字一定带192.168.1.100:5000/前缀。5.3 第三步业务服务器修改daemon.json并pull镜像其他业务服务器上如果要从192.168.1.100:5000这个私有仓库拉镜像必须先告诉Docker守护进程这是一个非TLS的私有仓库要信任它。cat /etc/docker/daemon.json EOF { insecure-registries: [192.168.1.100:5000] } EOF systemctl daemon-reload systemctl restart docker这一步如果不做直接执行docker pull 192.168.1.100:5000/nginx:1.24-alpine一定会报http: server gave HTTP response to HTTPS client这是Docker默认强制HTTPS访问registry所致。内网环境没配证书必须用insecure-registries显式声明告诉它这个地址裸HTTP访问也认。修改完daemon.json重启docker后再执行docker pull 192.168.1.100:5000/nginx:1.24-alpine docker pull 192.168.1.100:5000/mysql:8.0.32 docker pull 192.168.1.100:5000/openjdk:17-jdk-slim看到下载进度条走完离线部署的核心链路就全通了。6. 实战中的高发问题清单这些坑我全踩过一遍6.1 坑一docker pull 报 x509 / HTTPS 错误这是离线环境里最高频的报错。报错信息形如Error response from daemon: Get https://192.168.1.100:5000/v2/: http: server gave HTTP response to HTTPS client原因就是前面说的Docker默认用HTTPS去访问registry但内网registry只开了HTTP。解决办法三选一在daemon.json中配置insecure-registries最推荐给registry配SSL证书安全等级更高的环境需要但离线环境配置证书管理成本高启动registry容器时加环境变量REGISTRY_HTTP_TLS_CERTIFICATE和REGISTRY_HTTP_TLS_KEY但还是要先解决证书从哪来的问题。6.2 坑二exec format error报错形态standard_init_linux.go:228: exec user process caused exec format error九成原因是CPU架构不匹配。目标机器如果是ARM64架构你准备阶段拉了openjdk的amd64版镜像到现场跑容器就会这样报错。解决方式是准备阶段就用docker pull --platformarm64拉取ARM版镜像或者在联网机器上先查清目标架构再拉取。还有一种情况是操作系统太老、内核不支持某些镜像要求的系统调用但出现概率远低于架构问题。6.3 坑三firewalld 与 Docker 冲突导致的容器外网不通容器能启动但容器里ping不通外网或别的机器。这个现象在RHEL/CentOS系服务器上特别典型。核心问题在于firewalld启动时会清空Docker创建的iptables链或者拦截bridge转发流量。解决路径先停掉firewalld做对比验证systemctl stop firewalld docker restart test-nginx如果停了firewalld就通了说明是防火墙拦截。后续要么把docker网段加入firewalld信任区要么在安全策略允许的前提下保持firewalld关闭。对于一个物理隔离的内网环境真正的安全更多依赖网络层ACL而不是单机防火墙所以很多生产环境直接屏蔽firewalld服务。6.4 坑四容器重启后配置丢失很多新手会把配置文件直接写在容器里比如用docker exec去改Nginx的配置。一旦容器被删、镜像重新创建所有改动灰飞烟灭。离线部署场景下容器配置应当通过三种方式固化环境变量比如MySQL的MYSQL_ROOT_PASSWORD数据卷挂载宿主机目录挂载进容器自定义镜像把配置文件COPY进镜像并重新build。我个人最推荐数据卷挂载因为离线环境下重新build镜像需要准备Dockerfile和基础镜像链路更长而数据卷挂载只需要在docker run时指定-v /host/path:/container/path配合备份宿主机目录就能做到配置和数据的持久化。6.5 坑五docker load 时间过长或无响应大镜像tar包1GB以上在内网机器上load时偶尔会出现卡住的感觉。这里有两个可能原因一是磁盘IO慢二是镜像tar包本身是在Windows或macOS上打的文件权限标记异常导致解压过程变慢。解决手段拷贝到目标机器后先解压tar包用docker load -i加载gzip解压后的tar包加载前执行sync确保数据落盘。如果目标机器磁盘是机械硬盘耐心等这是物理性能问题。7. 进阶离线环境下compose编排与版本管理的实战经验7.1 把docker-compose也离线带进去前面提到的docker-compose-linux-x86_64文件用法是把可执行文件放到/usr/local/bin/docker-compose并加执行权限cp docker-compose-linux-x86_64 /usr/local/bin/docker-compose chmod x /usr/local/bin/docker-compose docker-compose version为什么离线部署一定要带compose因为真实业务不会是单容器孤军奋战。一个典型的应用至少包含前端Nginx 后端Java MySQL Redis四个容器的启动顺序、网络互通、环境变量引用如果全写在shell脚本里维护成本高到离谱。而docker-compose.yml把这些描述清楚一份文件复制到所有环境都能复现。7.2 一份可参考的compose编排以一套典型Web应用为例docker-compose.yml大致长这样version: 3.8 services: mysql: image: 192.168.1.100:5000/mysql:8.0.32 container_name: app-mysql restart: always environment: MYSQL_ROOT_PASSWORD: ChangeMe123 MYSQL_DATABASE: appdb volumes: - /opt/data/mysql:/var/lib/mysql ports: - 3306:3306 healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1] interval: 10s timeout: 5s retries: 5 backend: image: 192.168.1.100:5000/openjdk:17-jdk-slim container_name: app-backend restart: always depends_on: mysql: condition: service_healthy volumes: - /opt/app/jar:/app command: [java, -jar, /app/demo.jar] ports: - 8080:8080 nginx: image: 192.168.1.100:5000/nginx:1.24-alpine container_name: app-nginx restart: always depends_on: - backend volumes: - /opt/app/nginx/conf.d:/etc/nginx/conf.d:ro - /opt/app/nginx/html:/usr/share/nginx/html:ro ports: - 80:80 logging: driver: json-file options: max-size: 10m这里有几个我在生产环境里被毒打后的体会depends_on条件里用service_healthy而不是默认的service_started。因为MySQL容器启动和MySQL服务真正接受连接之间有很大差距没做健康检查直接启动后端大概率要报数据库连接失败然后整个依赖链崩掉。日志必须加max-size限制。内网机器磁盘通常不会特别大一个不限制日志的容器几天就能把宿主机磁盘写满。到时候容器全挂了排查原因半天发现是磁盘满那感觉太酸爽了。镜像名一律使用带registry前缀的完整地址。这样不管在哪台机器上执行docker-compose pull它都会从内网registry拉取而不是默认去Docker Hub找。7.3 版本升级与镜像迭代离线方案不能一锤子买卖离线环境最怕的就是我改了一版镜像怎么同步过去。我的做法是这样的在联网机器上构建好新镜像tag成带版本号的形式比如192.168.1.100:5000/backend:1.2.3docker save导出tar包拷入内网在内网仓库服务器docker loaddocker tagtag成不带版本或带新版本 docker push每台业务机器执行docker-compose pulldocker-compose up -d。注意docker-compose.yml 里的镜像tag变了才需要docker-compose pull如果tag没变但镜像内容变了相同tag被覆盖push那么业务机器上的镜像可能还是旧的需要在业务机器上手动删除旧镜像再拉取或者直接使用唯一版本号tag比如backend:1.2.3而不是backend:latest。这也是我坚持在标签里带具体版本号的原因——变相让自己能做真正的版本管理。8. 写在最后离线部署的心态准备与几点个人体会在隔离网里做Docker部署和在公网服务器上做Docker部署完全不是一个物种。公网环境下随便docker run拉不下来拉一下就行实在不行换源重试但在内网任何一个小的准备遗漏都可能让你重新走一遍审批流程、重新扛着硬盘进机房。我个人的几点体会第一准备阶段的检查清单永远比执行阶段的步骤重要。每次出发去现场之前我都会在联网机器上一项项核对Docker静态包架构对不对所有业务镜像的tag是不是精确版本registry.tar带没带docker-compose二进制带没带有没有先在一台测试机上完整地从零演练一遍第二务必在联网机器上做一次完整的模拟演练。准备阶段的机器和目标内网机器即使OS版本不同整个save/load/push/pull的链路应该在联网机器上自己跑一遍。别嫌麻烦离线现场翻车一次的成本足够你演练十次。第三给目标环境留有余量。无论磁盘空间、内存、还是镜像存储目录都要按未来六个月的增量来预留。离线环境增加存储比公网痛苦得多每次扩容都可能涉及硬件的采购和审批。第四文档和配置要固化。在联网机器上建一个目录专门存放部署清单、daemon.json模板、compose文件、镜像列表、版本号变化记录。这些文件不仅是为了事后复盘更是为了下一次需要扩容新机器时能直接照着做而不依赖某个人脑中的记忆。我始终觉得Docker离线部署考验的不是技术水平而是工程化思维——在资源受限、环境陌生、链路不可逆的情况下怎么把该准备的事情在出门前全部想清楚。这也是为什么我反复强调准备阶段和检查清单。如果这篇文章能帮你在下一次走进那间没有互联网的机房时少挠一次头我就觉得值了。