Linux Docker离线安装全链路:依赖打包、镜像搬运与故障排查
内网机房交付这种活,最容易出现的一种情况是:机器已经上架、电源和网络指示灯都亮着,但整个网段没有外网出口,交付清单上却写着“安装容器运行时”。很多人第一反应是“下个安装包传上去双击”,结果传上去发现跑不起来——缺依赖、缺内核模块、缺镜像,一路卡到底。Linux 环境下的 Docker 离线安装看起来只是一个包的搬运工作,实际上牵扯到组件拆分、依赖闭包、内核能力、systemd 托管、存储驱动、网络转发、镜像供给这一整条链路,任何一环断了,docker run都只会给你一句看不懂的报错。
这篇内容就是把这套链路从头到尾拆开讲一遍:一台完全断网的 Linux 服务器,怎么把容器运行时装上去、让服务正常起来、把镜像搬进去、跑起第一个容器,以及后续升级回滚怎么留后路。适合手上正被这类任务卡住的一线运维、交付工程师看,也适合平时只用过在线一条命令安装、想搞清楚背后到底装了什么的人。下面的命令和参数都是可以直接照着敲的,遇到差异点我会说清楚为什么要这样选。
1. 把“装 Docker”这件事拆开:装的到底是什么
1.1 不是一个包,是五六个包加三四项内核能力
在线环境下敲一行安装命令,包管理器会把依赖关系全帮你拉齐,所以你感觉不到它有多复杂。断网之后,你得自己扮演包管理器的角色,第一步就是搞清楚“完整的一套”包含哪些东西。
从打包形态看,主流的两条线分别是 rpm 系和 deb 系,拆出来的包大体是这几块:
| 组件 | 职责 | 缺失后的典型现象 |
|---|---|---|
| docker-ce(服务端) | dockerd 主进程,负责镜像、容器、网络、存储管理 | 只有命令行,没有守护进程 |
| docker-ce-cli | docker 命令行客户端 | 敲 docker 提示命令不存在 |
| containerd.io | 底层容器运行时,管容器生命周期 | dockerd 起不来,报 runtime 连接失败 |
| docker-buildx-plugin | 构建扩展,多平台构建依赖它 | build 命令降级或报插件缺失 |
| docker-compose-plugin | compose v2 插件 | docker compose 子命令不可用 |
| container-selinux(rpm 系) | 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 表示 v2,tmpfs 表示 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-plugin
deb 系的做法稍微绕一点,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='*' --nogpgcheck=false /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 接口创建容器的工具ctr:containerd 自带的命令行客户端,调试时很有用docker-proxy:用户态端口转发进程,某些场景下 iptables 转发失效时兜底docker:命令行客户端dockerd-rootless.sh:无根模式启动脚本
这里的层次关系值得强调一句:docker只是客户端,它把请求通过 socket 发给dockerd;dockerd再把容器生命周期的活交给containerd;containerd调runc真正创建进程。所以排查问题时看清报错出在哪一层,比盲目重启服务有效得多。
3.2 落位、写 unit、配 daemon.json 的完整流程
装法本身不复杂,解压、拷贝、赋权三步:
tar xzvf docker-27.3.1.tgz cp docker/* /usr/bin/ chmod +x /usr/bin/docker*接下来是 systemd 单元。这一步是二进制安装和打包安装最大的差别——打包安装自带单元文件,二进制安装得自己写。下面这份是经过多轮验证的版本:
[Unit] Description=Docker Application Container Engine 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 TimeoutStartSec=0 Delegate=yes KillMode=process Restart=on-failure StartLimitBurst=3 StartLimitInterval=60s [Install] WantedBy=multi-user.target几个参数值得解释一下。Type=notify是让 systemd 等 dockerd 真正就绪后再认为启动成功,缺了它会出现“服务显示 active 但实际还在初始化”的假象。Delegate=yes是把 cgroup 的控制权下放给 dockerd,容器自己管理子 cgroup 需要这个。LimitNOFILE=infinity是因为容器场景下文件描述符消耗远超普通服务,默认值几千很容易被撑爆。
然后是daemon.json,这个文件在/etc/docker/下,没有就新建:
{ "exec-opts": ["native.cgroupdriver=systemd"], "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 的问题。
排查路径是这样的:先看宿主机用的是哪版 cgroup,stat -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且没有开ftype=1(xfs_info /看输出里ftype=1才行,为 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 --zone=trusted --add-interface=docker0 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 \ --restart=always \ -v /data/registry:/var/lib/registry \ registry:2有了这个仓库,后续的分发流程就变成 "有网机器 push 到内网仓库,其他机器从仓库 pull",不用再逐个拷文件。仓库的存储目录挂到独立数据盘上,容量规划按最大镜像体积乘以版本数来估。
这里有个容易踩的坑:docker push到内网仓库时,镜像名必须是仓库地址:端口/名字:标签的格式,否则会默认往公共仓库推。改名用docker tag:
docker 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 两份文件比凭记忆推测快太多。离线环境没有便捷的在线检索和临时拉包能力,所有判断都得靠现场信息,把这些信息留档,本质上是在给自己攒排查素材。