内网机房交付这种活最容易出现的一种情况是机器已经上架、电源和网络指示灯都亮着但整个网段没有外网出口交付清单上却写着“安装容器运行时”。很多人第一反应是“下个安装包传上去双击”结果传上去发现跑不起来——缺依赖、缺内核模块、缺镜像一路卡到底。Linux 环境下的 Docker 离线安装看起来只是一个包的搬运工作实际上牵扯到组件拆分、依赖闭包、内核能力、systemd 托管、存储驱动、网络转发、镜像供给这一整条链路任何一环断了docker run都只会给你一句看不懂的报错。这篇内容就是把这套链路从头到尾拆开讲一遍一台完全断网的 Linux 服务器怎么把容器运行时装上去、让服务正常起来、把镜像搬进去、跑起第一个容器以及后续升级回滚怎么留后路。适合手上正被这类任务卡住的一线运维、交付工程师看也适合平时只用过在线一条命令安装、想搞清楚背后到底装了什么的人。下面的命令和参数都是可以直接照着敲的遇到差异点我会说清楚为什么要这样选。1. 把“装 Docker”这件事拆开装的到底是什么1.1 不是一个包是五六个包加三四项内核能力在线环境下敲一行安装命令包管理器会把依赖关系全帮你拉齐所以你感觉不到它有多复杂。断网之后你得自己扮演包管理器的角色第一步就是搞清楚“完整的一套”包含哪些东西。从打包形态看主流的两条线分别是 rpm 系和 deb 系拆出来的包大体是这几块组件职责缺失后的典型现象docker-ce服务端dockerd 主进程负责镜像、容器、网络、存储管理只有命令行没有守护进程docker-ce-clidocker 命令行客户端敲 docker 提示命令不存在containerd.io底层容器运行时管容器生命周期dockerd 起不来报 runtime 连接失败docker-buildx-plugin构建扩展多平台构建依赖它build 命令降级或报插件缺失docker-compose-plugincompose v2 插件docker compose 子命令不可用container-selinuxrpm 系SELinux 策略装包时直接报依赖不满足除了包还有内核这一层的能力它不在包管理器管辖范围内但缺了照样跑不起来overlay文件系统模块联合挂载容器分层的基础、br_netfilter让网桥流量走 iptables 规则、nf_conntrack连接跟踪NAT 和端口映射的前提、ip_tables/nf_tables网络规则引擎。这几项在大多数现代内核里都是现成的但企业内网里跑着老内核的机器不少尤其是一些为了兼容老业务一直没升级的系统overlay模块可能压根没编译进去这种情况只能退回vfs存储驱动代价是磁盘占用成倍增长后面会细说。我的经验是动手前先用三行命令把家底摸清比装到一半再回头查要省事得多uname -r # 内核版本 lsmod | grep -E overlay|br_netfilter # 模块是否已加载 stat -fc %T /sys/fs/cgroup/ # cgroup2fs 表示 v2tmpfs 表示 v11.2 三条离线路线各有各的适用边界断网装容器运行时常见做法有三种选错了后面的返工量会非常大。第一种依赖打包法。在一台同版本、同架构、能上网的机器上用包管理器把目标包和它的全部依赖下载到本地目录再整体搬到离线机器上安装。优点是和在线安装的结果完全一致systemd 单元、SELinux 策略、默认配置全都是发行版调好的后续维护也顺着发行版的习惯走。缺点是对“同版本同架构”要求很严两台机器的系统小版本差一点依赖就可能对不上。第二种静态二进制包。拿官方发布的压缩包解压出来是一堆可执行文件手工放到/usr/bin自己写 systemd 单元。优点是对系统几乎没要求不依赖包管理器和发行版仓库跨发行版通用。缺点是所有配置都得自己维护SELinux 策略、日志轮转、升级流程都要手工处理。第三种整机克隆或者容器化宿主。有人图省事直接把装好的机器打成镜像批量部署。短期看最快长期看是灾难主机名、机器 ID、SSH 密钥、网卡配置全都一样批量上线后各种冲突而且后续单台升级没法做。这条路我不建议走除非是一次性的临时环境。三条路线的对比大致是这样维度依赖打包法静态二进制整机克隆系统一致性要求高同版本同架构低极高后续升级难度低中高SELinux/防火墙集成自动手工自动但会重复适合场景标准化内网集群异构老系统、临时环境一次性演示1.3 上机前的体检清单别省这十分钟真正开始搬包之前有几项参数必须先确认否则装完发现问题再改成本会翻好几倍。磁盘规划是头一条。默认数据目录在/var/lib/docker镜像和容器层全堆在这里系统盘一般给不了多少空间。建议单独挂一块盘装之前就用df -h看清楚剩余容量决定要不要在daemon.json里改>mkdir -p /tmp/docker-offline dnf download --resolve --alldeps \ --destdir /tmp/docker-offline \ docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugindeb 系的做法稍微绕一点apt-get download不会递归比较可靠的办法是借用缓存目录mkdir -p /tmp/deb-cache/archives/partial apt-get install --download-only -y \ -o Dir::Cache::archives/tmp/deb-cache \ docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin跑完之后ls /tmp/deb-cache/archives/*.deb应该能看到十几个甚至几十个文件包括libseccomp2、libcgroup、iptables这类基础依赖。文件数量偏少通常意味着依赖没递归到底这时候不要急着搬走先在能联网的机器上把dnf repoquery --requires --resolve或者apt-cache depends的结果跟下载目录对一遍。顺手把 GPG 公钥也导出来带上rpm 系需要用rpm --import导入否则安装时会卡在签名校验。签名这关不要图省事用--nogpgcheck绕过去离线环境恰恰是最需要校验来源的场景。2.2 离线机器上安装最关键的是“让它别去找网”包搬过去之后很多人习惯性敲dnf install ./docker-ce-xxx.rpm然后在离线机器上等了五分钟没反应——因为 dnf 在尝试刷新仓库元数据没有外网就一直超时重试。正确做法是先禁掉所有远端仓库只从本地文件装dnf install -y --disablerepo* --nogpgcheckfalse /tmp/docker-offline/*.rpm如果担心通配符顺序问题用rpm -Uvh一次性把所有包丢进去rpm 会自动做拓扑排序cd /tmp/docker-offline rpm -Uvh --replacepkgs --replacefiles *.rpmdeb 系更简单dpkg -i *.deb一次性给全部文件dpkg 会先把所有包解包、再统一执行配置大部分依赖顺序问题都能靠这个机制自动解决cd /tmp/docker-offline dpkg -i *.deb装完立刻验证一下别等到配置完才发现包没装全rpm -qa | grep -E docker|containerd # rpm 系 dpkg -l | grep -E docker|containerd # deb 系 ldd /usr/bin/dockerd | grep not found # 检查动态库有没有缺ldd那一行特别值得做它查的是动态链接库层面包管理器管不到这一层。老系统上libseccomp版本偏低是很常见的问题表现为 dockerd 启动直接报error while loading shared libraries而包管理器认为一切正常。2.3 版本锁定与“以后还要加包”的预案离线环境最怕的不是装不上而是半年后业务要加个功能发现少了个包又得重新走一遍搬运流程。所以在第一次打包时就该把预案做好。我的习惯是在离线目录里放三样东西完整的包集合、一个VERSION.txt记录每个包的精确版本号、一个SHA256SUMS校验文件。版本号写清楚的原因很实际——rpm 系升级时如果新老版本混装dnf可能会因为依赖版本冲突把整个事务回滚有版本清单就能快速定位是不是拿错了包。后续补包的时候最省事的做法是回到那台“打包机”保持它的仓库配置和当初一致用同样的命令增量下载到同一个目录。注意别在打包机上顺手升级了系统否则下载下来的依赖版本会跟着变和离线机器对不上。还有一个容易忽略的点docker-ce和containerd.io的版本是有关联的跨大版本混用虽然多数情况能跑但出问题时排查成本极高。固定一组经过验证的版本组合把所有离线机器都统一到这一组比追新要靠谱得多。3. 路线二静态二进制包环境再怪也能落地3.1 压缩包里那几个文件分别管什么下载下来的docker-xx.x.x.tgz解压后是一个docker/目录里面躺着七八个可执行文件很多人直接cp docker/* /usr/bin/就完事了其实值得搞清楚每个是干嘛的dockerd主守护进程容器、镜像、网络、存储的中枢containerd底层运行时负责容器的创建、启动、销毁containerd-shim-runc-v2垫在 containerd 和容器进程之间的中间层让容器进程脱离 containerd 的生命周期独立存在runc真正调用内核 namespace 和 cgroup 接口创建容器的工具ctrcontainerd 自带的命令行客户端调试时很有用docker-proxy用户态端口转发进程某些场景下 iptables 转发失效时兜底docker命令行客户端dockerd-rootless.sh无根模式启动脚本这里的层次关系值得强调一句docker只是客户端它把请求通过 socket 发给dockerddockerd再把容器生命周期的活交给containerdcontainerd调runc真正创建进程。所以排查问题时看清报错出在哪一层比盲目重启服务有效得多。3.2 落位、写 unit、配 daemon.json 的完整流程装法本身不复杂解压、拷贝、赋权三步tar xzvf docker-27.3.1.tgz cp docker/* /usr/bin/ chmod x /usr/bin/docker*接下来是 systemd 单元。这一步是二进制安装和打包安装最大的差别——打包安装自带单元文件二进制安装得自己写。下面这份是经过多轮验证的版本[Unit] DescriptionDocker Application Container Engine Afternetwork-online.target firewalld.service Wantsnetwork-online.target [Service] Typenotify ExecStart/usr/bin/dockerd ExecReload/bin/kill -s HUP $MAINPID LimitNOFILEinfinity LimitNPROCinfinity TimeoutStartSec0 Delegateyes KillModeprocess Restarton-failure StartLimitBurst3 StartLimitInterval60s [Install] WantedBymulti-user.target几个参数值得解释一下。Typenotify是让 systemd 等 dockerd 真正就绪后再认为启动成功缺了它会出现“服务显示 active 但实际还在初始化”的假象。Delegateyes是把 cgroup 的控制权下放给 dockerd容器自己管理子 cgroup 需要这个。LimitNOFILEinfinity是因为容器场景下文件描述符消耗远超普通服务默认值几千很容易被撑爆。然后是daemon.json这个文件在/etc/docker/下没有就新建{ exec-opts: [native.cgroupdriversystemd], log-driver: json-file, log-opts: { max-size: 100m, max-file: 3 }, data-root: /data/docker, storage-driver: overlay2, bip: 10.20.0.1/24 }log-opts那两行是必须加的。默认的 json-file 日志驱动不限制大小一个话多的服务跑上两个月能把磁盘写满而且这个问题在磁盘告警之前毫无征兆。>ExecStart/usr/bin/dockerd --containerd/run/containerd/containerd.sock这样改的好处是职责清晰containerd 挂了单独重启不影响 dockerd 的配置加载日志也分开。代价是要多维护一个单元文件而且containerd的默认配置在/etc/containerd/config.toml需要先用containerd config default /etc/containerd/config.toml生成一份。注意两种方式只能选一种。如果已经让 dockerd 托管 containerd又额外起了 containerd.service两者会争抢/run/containerd/containerd.sock表现为服务反复重启、日志里刷address already in use。切换方式时一定要先systemctl disable --now containerd再改配置。还有一个二进制安装特有的注意点SELinux 策略。打包安装会带container-selinux二进制方式没有SELinux 处于 enforcing 状态下容器挂载宿主机目录会直接被拒。稳妥的处理是给数据目录打上正确的上下文标签semanage fcontext -a -t container_file_t /data/docker(/.*)? restorecon -Rv /data/docker如果semanage命令不存在说明policycoreutils-python-utils这类工具包也没装需要提前一起带上。临时用setenforce 0关掉是能跑通但这属于把安全策略整体拆掉不建议在生产机器上这么干。4. 服务起来了但容器起不来四类高频故障的排查链路4.1 cgroup 驱动不一致报错信息藏在 shim 里systemctl status docker显示绿色docker info也能输出内容但docker run直接报failed to create shim task或者oci runtime error这类问题的第一嫌疑是 cgroup 驱动。宿主机上的 cgroup 有两个版本v1 和 v2而容器运行时侧的 cgroup 驱动也有systemd和cgroupfs两种。两边对不上的时候创建容器就会失败而且报错信息往往指向 shim 层很容易被误判成 runc 的问题。排查路径是这样的先看宿主机用的是哪版 cgroupstat -fc %T /sys/fs/cgroup/返回cgroup2fs就是 v2再看docker info | grep -i cgroup输出的驱动名。v2 环境下必须用systemd驱动这就是前面daemon.json里exec-opts配置的由来。改完配置记得重启服务而且要先systemctl daemon-reload否则 systemd 用的还是旧的单元定义配置改了跟没改一样。这个坑我踩过改了半天配置死活不生效最后发现是漏了daemon-reload。4.2 overlay2 退化成 vfs磁盘用量的差距是数量级的docker info里Storage Driver显示vfs的时候说明 overlay2 没能启用。vfs 是纯复制式存储每启动一个容器就把镜像层完整复制一遍镜像 2G 的话跑十个容器就是 20G磁盘很快见底。overlay2 起不来的原因有好几种按排查顺序排内核有没有overlay模块modprobe overlay试一下报错就是没有底层文件系统是不是xfs且没有开ftype1xfs_info /看输出里ftype1才行为 0 的话必须重建文件系统没法在线改数据目录所在的分区是不是用了不支持的格式。如果是老内核彻底不支持 overlay那就只能接受 vfs但要在规划上做补偿数据盘给足空间、镜像尽量精简、容器数量控制住同时把docker system prune的清理任务挂到定时里。4.3 端口映射规则看着生效但外面连不上docker ps显示0.0.0.0:8080-80/tcp宿主机上curl localhost:8080通从别的机器访问就是超时。这个现象最常见的根因是 IPv4 转发没开。sysctl net.ipv4.ip_forward # 输出 1 才是开的没开的话容器发出的包出不去外面进来的包也转不进来。写进/etc/sysctl.d/99-docker.conf持久化同时把网桥的过滤开关也一起配上cat /etc/sysctl.d/99-docker.conf EOF net.ipv4.ip_forward 1 net.bridge.bridge-nf-call-iptables 1 net.bridge.bridge-nf-call-ip6tables 1 EOF sysctl --system第二个根因是 iptables 的后端版本。新一点的系统默认把iptables命令指向iptables-nft而有些容器运行时版本在 nftables 后端上插入 NAT 规则会出现兼容问题表现就是规则写进去了但匹配不到。判断方法是iptables --version看输出里带的是nf_tables还是legacy如果怀疑是这里的问题切回 legacy 后端验证一下update-alternatives --set iptables /usr/sbin/iptables-legacy systemctl restart docker第三个根因是防火墙。宿主机上跑着 firewalld 的时候它会接管 iptables 规则容器运行时插入的规则可能被重载覆盖掉。稳妥的做法是把容器网桥加进受信任区域而不是直接关掉防火墙firewall-cmd --permanent --zonetrusted --add-interfacedocker0 firewall-cmd --reload4.4 内网网段和默认网桥撞车症状极其隐蔽前面提过默认网桥占用172.17.0.0/16如果内网正好也在用这段容器访问内网地址时内核会认为目标是本地链路而不是走默认网关表现是各种莫名其妙的超时而且ping和telnet的结论还可能不一致。排查方法很直接看宿主机的路由表ip route里有没有和172.16.0.0/12重叠的网段。有重叠就在daemon.json里改{ bip: 10.20.0.1/24, default-address-pools: [ {base: 10.21.0.0/16, size: 24} ] }bip管的是默认网桥本身的地址段default-address-pools管的是用docker network create创建自定义网络时分配的地址池。两个都要配只配一个的话自定义网络还是会从默认池里拿地址撞车照旧发生。改完重启服务之前的容器网络要重建历史容器记录建议顺手清一下避免残留的网络配置干扰判断。5. 镜像从哪来让容器真正跑起来的最后一公里5.1 save 和 load 的搬运细节比想象中讲究服务装好只是第一步离线环境下没有地方拉镜像docker run hello-world一样跑不起来。最直接的办法是在有网机器上docker pull下来然后打包搬运docker pull nginx:1.27 docker save -o nginx-1.27.tar nginx:1.27 gzip -9 nginx-1.27.targzip -9不是可有可无的镜像 tar 包本身是未压缩的层堆叠压缩比通常能到 3:1 以上几个 G 的包压完剩一两个 G拷贝时间差很多。docker load直接支持 gzip 包不用先解压docker load -i nginx-1.27.tar.gz docker images | grep nginx这里有个细节要注意docker save保存的是完整的镜像包括所有层和历史记录标签也会一起带上。如果保存的时候用的是镜像 ID 而不是名字:标签加载进来标签会是none还得手工docker tag补一下。所以 save 的时候一定要用带标签的名字。另外多架构镜像在 save 的时候只会保存当前平台的版本。如果离线环境里有不同 CPU 架构的机器必须在对应架构的机器上分别 pull 再 save否则搬过去会出现exec format error。5.2 内网起一个私有仓库比反复传 tar 划算得多容器数量上去之后一台台机器传 tar 包再 load 的做法很快就顶不住了。这时候在内网起一个私有仓库更合适。仓库本身也是容器所以要先在有网机器上把仓库镜像 save 下来搬进去docker save -o registry-2.tar registry:2 # 搬到离线机器后 docker load -i registry-2.tar docker run -d --name registry \ -p 5000:5000 \ --restartalways \ -v /data/registry:/var/lib/registry \ registry:2有了这个仓库后续的分发流程就变成 有网机器 push 到内网仓库其他机器从仓库 pull不用再逐个拷文件。仓库的存储目录挂到独立数据盘上容量规划按最大镜像体积乘以版本数来估。这里有个容易踩的坑docker push到内网仓库时镜像名必须是仓库地址:端口/名字:标签的格式否则会默认往公共仓库推。改名用docker tagdocker tag nginx:1.27 10.20.0.10:5000/base/nginx:1.27 docker push 10.20.0.10:5000/base/nginx:1.27默认情况下这个仓库走的是 HTTP 而不是 HTTPS客户端那边需要在daemon.json里把地址加进insecure-registries白名单否则 push 会直接报http: server gave HTTP response to HTTPS client。5.3 镜像清单和校验别在半年后抓瞎离线环境最痛苦的时候不是装的那一刻而是三个月后某台机器上的镜像版本和别的机器不一样却没人说得清当初为什么不一样。我的做法是维护一份文本清单记录镜像名、标签、对应的 tar 包文件名和 SHA256跟包目录放在一起sha256sum nginx-1.27.tar.gz SHA256SUMS docker images --format {{.Repository}}:{{.Tag}} {{.ID}} {{.Size}} IMAGE-LIST.txt搬运前后各校验一次能挡住传输损坏这种概率不高但后果很烦的问题。镜像 tar 包损坏的表现是docker load中途报unexpected EOF如果没校验很容易被误判成磁盘满或者服务异常。6. 没有外网的日子升级、回滚和日常维护6.1 升级窗口怎么排先备份哪三样东西离线环境的升级比在线环境麻烦因为没有回头路——出问题不能重新拉包只能靠事前备份。我一般要求三样东西必须先备份daemon.json配置文件、当前的包版本清单、docker info的完整输出。第三样很多人不理解为什么要备。原因是docker info里包含了存储驱动、cgroup 驱动、网络配置、运行时版本这些关键状态升级出问题时对比新旧两份输出能快速定位是哪一项变了。这比翻日志快得多。升级流程本身是停服务、换包、启服务三步但顺序上有个细节先停容器再停服务。systemctl stop docker会终止所有容器如果容器里有正在写数据的状态硬停可能损坏卷里的文件。稳妥做法是先docker stop逐个停业务容器确认docker ps空了再停服务。docker ps -q | xargs -r docker stop systemctl stop docker # 替换包或二进制文件 systemctl start docker docker info | head -306.2 回滚时最容易忽略的是配置差异包回滚本身不难把旧版本的包重新装回去就行。真正容易出事的是配置新版本可能引入了新的配置项或者某些字段的默认值变了回滚包但留着新配置服务可能起不来或者行为异常。所以回滚的正确顺序是停服务、还原旧配置、装回旧包、起服务。配置一定要先还原因为新版配置里如果有旧版本不认识的字段dockerd 启动时会直接报unknown field然后退出日志里就一行很容易看漏。二进制方式还有个额外注意点升级时containerd、runc、docker-proxy这些文件都要一起换只换dockerd和docker会导致版本不匹配。替换前先systemctl stop docker替换完systemctl start docker中途不要试图热替换正在运行的二进制文件会报Text file busy。最后再分享一个我在实际环境里养成的习惯每次动离线机器之前把docker version、docker info、ip route、sysctl -a | grep -E bridge|forward四条命令的输出存成一个带日期的文本文件放在固定目录里。这些输出加起来不到两百行但在排查“昨天还好好的今天就不行了”这类问题时直接 diff 两份文件比凭记忆推测快太多。离线环境没有便捷的在线检索和临时拉包能力所有判断都得靠现场信息把这些信息留档本质上是在给自己攒排查素材。