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引擎分两个大系列:老牌的CE(Community 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] Description=Docker Application Container Engine Documentation=https://docs.docker.com After=network-online.target firewalld.service Wants=network-online.target [Service] Type=notify ExecStart=/usr/bin/dockerd ExecReload=/bin/kill -s HUP $MAINPID LimitNOFILE=infinity LimitNPROC=infinity LimitCORE=infinity TimeoutStartSec=0 Delegate=yes KillMode=process Restart=on-failure StartLimitBurst=3 StartLimitInterval=60s [Install] WantedBy=multi-user.target EOF # 配置daemon.json(这里先写基础配置,registry相关稍后加) mkdir -p /etc/docker cat > /etc/docker/daemon.json <<EOF { "exec-opts": ["native.cgroupdriver=systemd"], "log-driver": "json-file", "log-opts": { "max-size": "100m" } } EOF # 重载并启动 systemctl daemon-reload systemctl enable docker systemctl start docker # 验证 docker version这里有三个点,我觉得比操作本身更重要:
Type=notify不能丢。dockerd启动会通过socket通知systemd它已经准备好了。如果用默认的Type=simple,systemd可能会在dockerd还没完全初始化时就认为服务已启动,导致立即执行后续命令时出现"Cannot connect to the Docker daemon"。KillMode=process是为了防止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 \ --restart=always \ -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 --platform=arm64拉取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: "ChangeMe@123" 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.3; docker save导出tar包,拷入内网;- 在内网仓库服务器
docker load+docker tag(tag成不带版本或带新版本) +docker push; - 每台业务机器执行
docker-compose pull+docker-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离线部署考验的不是技术水平,而是工程化思维——在资源受限、环境陌生、链路不可逆的情况下,怎么把该准备的事情在出门前全部想清楚。这也是为什么我反复强调"准备阶段"和"检查清单"。
如果这篇文章能帮你在下一次走进那间没有互联网的机房时少挠一次头,我就觉得值了。