ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内网离线安装deb包:APT本地仓库构建与依赖排错

内网离线安装deb包:APT本地仓库构建与依赖排错

一台内网服务器,没有外网出口,运维只给了一个U盘和一句"把监控agent装上"。U盘里躺着三个deb包,插上机器执行dpkg -i,终端立刻刷出七八行红色报错——dependency problems prevent configuration、libssl1.1 is not installed、Package xxx is not configured yet。这就是离线安装deb包最典型的第一现场:包本身没坏,坏的是它背后那张依赖网,在线时由APT自动织好,离线时得你自己一针一线补回去。

我这些年经手的隔离环境不算少,银行类客户的封闭内网、工厂车间的工控机、没有外网链路的教学机房,都踩过同一类坑。核心矛盾只有一个:在线环境下apt install一句话搞定的事,在离线环境下需要你手工重建APT的元数据体系,也就是搭一个APT本地仓库。这件事听着唬人,拆开看无非三块——把deb和它依赖的deb都收集齐、生成APT能读的索引文件、把索引挂到sources.list里。但每一块都有细节能把人卡住半天,比如索引文件的路径为什么必须写到./、trusted=yes到底该不该加、不同发行版版本之间混装会出什么怪事。

这篇内容面向的是需要在内网、隔离网、无外网链路上部署Debian系软件包的运维和开发同学,也包括那些只会dpkg -i、每次遇到依赖问题就手忙脚乱的新手。我会从一次真实的依赖报错讲起,把依赖收集、仓库构建、索引生成、源配置、排错链路完整走一遍,最后给一套可以直接抄的脚本。全程围绕离线安装deb包和APT本地仓库构建这两个核心动作展开,不扯无关的发行版哲学。

1. 断网机器上的第一个deb包:从dpkg报错讲起

1.1 一条依赖报错背后的完整链条

先看现场。假设目标机上执行的是sudo dpkg -i node-exporter_1.6.1-1_amd64.deb,终端输出大致是这样:

(Reading database ... 187432 files and directories currently installed.) Preparing to unpack node-exporter_1.6.1-1_amd64.deb ... Unpacking node-exporter (1.6.1-1) ... dpkg: dependency problems prevent configuration of node-exporter: node-exporter depends on libsystemd0 (>= 245.4); however: Version of libsystemd0:amd64 on system is 241-7~deb10u9. dpkg: error processing package node-exporter (--install): dependency problems - leaving unconfigured Errors were encountered while processing: node-exporter

这段输出信息量其实很大,很多人只看到最后一行就慌了。拆开读:第一层是unpacking成功了,说明包体完整、架构匹配、磁盘也有空间;第二层是dependency problems prevent configuration,key word是configuration,不是installation——deb包已经被解压到文件系统里了,只是没跑完配置脚本、没注册成正常状态;第三层是版本号对比,机器上是241,包要求>= 245.4,这是版本不足而不是包不存在。

这两种失败原因的处置方式完全不同。包不存在,你把那个deb找过来装上就行;版本不足,你要么升级系统里已有的包(可能引发连锁反应),要么找一个对当前系统版本友好一点的软件包版本。很多人在这一步直接上--force-depends,把配置流程强行推过去,结果程序能启动但缺符号,运行几分钟后core掉,排查成本比一开始就老老实实补依赖高十倍。

1.2 dpkg 与 apt 到底谁在管依赖

要理解为什么离线环境这么难受,得先把dpkg和apt的分工掰清楚。dpkg是底层包管理器,它只认三件事:这个deb的control文件里写了依赖谁、这些依赖现在满不满足、满足就跑postinst脚本完成配置。它不会去任何地方下载东西,也不会帮你解依赖树——它只会告诉你"不满足",然后撂挑子。

apt是上层调度器,它读的是仓库索引(Packages文件),在索引里做依赖求解,算出需要下载哪些包、按什么顺序装,然后调用dpkg。换句话说,apt的能力完全来自那份索引文件,没有索引它就是个瞎子。离线环境下apt install报Unable to locate package,十有八九不是包不存在,而是你的sources.list里没有任何一个源包含它的索引。

这就引出核心思路:离线环境的本质是把"在线时apt自动做的事"手工做一遍。具体分两步——先在能上网的机器上,让apt帮你把依赖树算出来并把对应的deb全部下载下来;再把这一堆deb做成一个有索引的本地仓库,让目标机的apt重新"看得见"它们。做完这两步,apt install在断网机器上和在联网机器上的体验就完全一样了。

1.3 为什么不能靠 dpkg -i 一个个硬装

有人会问:搞清楚依赖顺序,手动dpkg -i按顺序装一遍不行吗?小规模可以,超过五个包基本就不现实了。原因有三个。

第一,依赖是有向图不是链。A依赖B和C,B又依赖C的某个最低版本,C反过来Breaks某个旧包,手工排拓扑序极易翻车,而且dpkg -i一次失败后系统会停在半配置状态,下一轮报错信息更难读。第二,隐性依赖多。除了Depends,还有Pre-Depends(必须在解包前就装好,常见于需要预建用户组的包)、Recommends(默认安装,影响功能完整性)、Conflicts和Replaces(涉及卸载旧包),这些用眼睛根本看不出来。第三,无法复用。今天装监控agent,明天装数据库,每次手工排一遍,成本线性增长;而本地仓库搭好一次,后面所有包都走同一条路。

提示:dpkg -i在离线场景并非完全没用。它适合"单个无依赖或依赖已满足"的小工具,以及作为本地仓库构建失败时的兜底手段。但把它当主力方案,早晚会被依赖图教做人。

2. 离线依赖的收集:把在线那一半完整搬过来

2.1 采集机必须与目标机版本对齐

这一步是整个流程里最容易被忽略、后果最严重的一环。你在自己开发机上(假设是较新的发行版)下载的deb,拿到目标机(可能是三年前的老系统)上装,大概率会遇到两类问题:一类是依赖版本对不上,就像1.1节那个libsystemd0的例子;另一类是GLIBC符号版本缺失,报version 'GLIBC_2.34' not found,这种属于运行库的硬门槛,除了换包版本没有别的解法。

所以采集机的选型规则很硬:发行版名称、主版本号、CPU架构三者必须与目标机完全一致。查目标机信息用三条命令:

cat /etc/os-release # 看发行版和版本号 dpkg --print-architecture # 看主架构,通常是 amd64 或 arm64 uname -r # 内核版本,部分驱动类包会依赖

如果手头没有同版本的物理机或虚拟机,最省事的办法是在采集机上用容器起一个同版本的最小环境,只用来跑apt download,不需要它真的能运行目标软件。容器镜像选对应版本的最小镜像即可,这样下载出来的依赖就是那个版本的正确依赖。

还有一种特殊情况:目标机是arm64,采集机是amd64。这时不能靠apt download直接搞定,因为apt默认按本机架构解依赖。可以给dpkg开启多架构支持后指定架构下载:

sudo dpkg --add-architecture arm64 apt-get download package:arm64

但要注意,--add-architecture会把arm64的索引也拉进本机,可能污染后续的依赖解析。更干净的做法是直接在arm64容器里操作,别在本机折腾。

2.2 apt download 与 download-only 的取舍

收集依赖有两条路,各有适用场景。

第一条是apt-get install --download-only。它的好处是完整走一遍apt的依赖求解,把主包和所有依赖一次性拉到/var/cache/apt/archives/。命令长这样:

sudo apt-get install --download-only --reinstall -y node-exporter ls /var/cache/apt/archives/*.deb

加--reinstall是因为如果采集机上已经装了这个包,apt会认为"无需操作"直接跳过,什么都不下。加完之后,/var/cache/apt/archives/里就是完整的一套deb。这个目录是apt的默认缓存位置,可以直接打包拷走。缺点是它会考虑采集机上已有的包——如果采集机已经装了某个依赖,apt就不会再下载它,而目标机可能是干净的。所以更稳妥的做法是配合一个干净容器,或者用第二条路。

第二条是apt download,它只下载指定的单个包,不解析依赖。所以需要先用apt-cache depends --recurse把依赖树展开,再喂给apt download:

apt-cache depends --recurse --no-recommends --no-suggests \ --no-conflicts --no-breaks --no-replaces --no-enhances \ node-exporter | grep '^\w' | sort -u > dep-list.txt

这里的几个--no-*参数值得说一下。--no-recommends和--no-suggests是砍掉软依赖,不然你会下载一堆根本不用的东西,比如某个CLI工具顺带把整个文档包、补全包都拖下来。--no-conflicts、--no-breaks、--no-replaces、--no-enhances是排除掉那些"反向关系"的条目——它们出现在输出里但不需要下载,留着会让apt download报找不到包。grep '^\w'是为了过滤掉|Depends:这类带缩进和管道符的行,sort -u去重。这一步做完,dep-list.txt就是纯净的包名清单。

然后批量下载:

mkdir -p /opt/offline-debs && cd /opt/offline-debs xargs -a dep-list.txt -n 20 apt-get download

用xargs -n 20分批是为了避免命令行过长,同时如果某个包下载失败,不影响后面的批次继续。

2.3 别忘了把索引和版本信息一起带走

只拷deb有时还不够。目标机上如果要做apt install,apt需要知道每个deb提供了什么包、什么版本、依赖谁——这些信息在deb文件的control里,但apt不读deb文件本身,它读的是仓库索引。所以构建本地仓库这一步不是可选项,是必经之路。

另外建议顺手记录一份采集时的软件源快照:

cp /etc/apt/sources.list /opt/offline-debs/sources.list.snapshot apt list --installed > /opt/offline-debs/installed.snapshot 2>/dev/null

前者用于事后追溯"这些包到底来自哪个源",后者用于出问题时对比"目标机上到底缺的是哪个版本"。这两个文件体积很小,但在排查版本冲突时能省下大量猜测时间。

提示:下载完成后先ls -lh扫一眼文件大小。出现过几十KB甚至0字节的deb,通常是下载中断或某个源返回了错误页。这种文件在dpkg -i时会报"file is corrupt",但因为你一次性装几十个包,很难一眼看出是哪一个坏了。

3. 用 dpkg-scanpackages 把一堆 deb 变成可用的本地源

3.1 目录布局与 Packages 索引的生成

拿到deb集合后,先确定目录结构。我习惯用扁平结构,简单直接:

/opt/localrepo/ ├── Packages.gz ├── Packages └── *.deb

所有deb平铺在根目录,索引文件也放根目录。这种做法对应apt源配置里的"flat repo"(扁平仓库),是最轻量的方案。

生成索引需要dpkg-dev这个包,它提供dpkg-scanpackages工具:

sudo apt-get install -y dpkg-dev # 采集机上装,目标机不需要 cd /opt/localrepo dpkg-scanpackages . /dev/null 2>/dev/null | gzip -9c > Packages.gz dpkg-scanpackages . /dev/null 2>/dev/null > Packages

这里有两个参数容易被误解。第一个.是扫描目录,表示当前目录;第二个/dev/null是override文件路径,作用是覆盖或补充control里的某些字段(比如强制指定Section、Priority)。绝大多数场景不需要override,传/dev/null表示"不需要",如果你传一个不存在的路径,工具会报警告但不影响结果。

同时生成Packages和Packages.gz两份是有讲究的:.gz是新版apt优先读的压缩版,体积小、解析快;未压缩的Packages是给某些老工具和调试用的。两个都放上去没有坏处,只放.gz在极少数环境(比如某些精简过的apt版本)下会读不到。

还有一个关键细节:每次往目录里增删deb后必须重新生成索引。这是新手最常见的失误——把新包扔进目录,apt update跑了,apt install还是报找不到。原因就是索引文件没更新,apt眼里那个包压根不存在。

如果目录里同时存在同一个包的多个版本,dpkg-scanpackages默认只索引最新版本,旧版本会被忽略。要保留多版本共存,加--multiversion:

dpkg-scanpackages --multiversion . /dev/null > Packages

这个参数在需要回滚软件版本时很有用,但要清楚:apt在install时默认仍然装最高版本,低版本需要显式写apt install pkg=1.2.3-1。

3.2 sources.list 那一行的写法与 trusted 的取舍

索引生成好了,接下来让apt认这个目录。编辑/etc/apt/sources.list.d/local.list:

deb [trusted=yes] file:///opt/localrepo ./

这一行有三个地方值得展开。

file://表示走本地文件系统,不走网络。路径必须是绝对路径,三个斜杠不能少——第一个是协议分隔符,后两个是根目录。写成file:/opt/localrepo在某些apt版本下能工作,但不规范,别赌。

最后的./是发行版代号(distribution)的位置。扁平仓库没有dists/目录结构,所以这里填./表示"就在根目录找索引"。如果只写到file:///opt/localrepo而不加./,apt会去/opt/localrepo/dists/下面找,结果就是经典的报错:

E: The repository 'file:///opt/localrepo ./ Release' does not have a Release file. N: Updating from such a repository can't be done securely, and is therefore disabled by default.

[trusted=yes]是跳过GPG签名校验。apt从1.1版本开始,对没有签名的源默认拒绝使用,会提示is not signed或can't be done securely。加trusted=yes等于告诉apt"这个源我信任,别校验了"。在本地文件源这种完全可控的场景下,这是合理且常见的做法。如果合规要求必须签名,那就得走4.2节的GPG流程,用signed-by=/path/key.gpg代替。

写完后执行:

sudo apt-get update -o Dir::Etc::sourcelist="sources.list.d/local.list" \ -o Dir::Etc::sourceparts="-" -o APT::Get::List-Cleanup="0"

这三行-o参数是只更新指定的这个源,避免联网源拖慢速度或报错。日常更简单的做法是直接sudo apt update,看着报错也无妨,只要本地源那行显示Reading package lists... Done并且apt-cache policy 包名能看到版本,就说明源生效了。

3.3 apt-ftparchive 作为第二种索引工具

dpkg-scanpackages是Debian系的老牌工具,但它有个小毛病:从某个版本开始,扫描时如果遇到架构不匹配的包会打警告,输出里可能混入非包信息的行。替代方案是apt-utils包提供的apt-ftparchive:

sudo apt-get install -y apt-utils cd /opt/localrepo apt-ftparchive packages . > Packages gzip -kf Packages

apt-ftparchive packages的优点是输出更干净,而且它能顺便生成Release文件(4.2节会展开)。缺点是它默认也会做架构过滤,需要显式加-o APT::FTPArchive::AlwaysStat=true之类的参数才能看到详细信息,调试时不如dpkg-scanpackages直观。

两种工具选一个就行,我一般用dpkg-scanpackages,因为它在老版本系统上更常见,不用额外装包。验证索引是否有效的最快方式是直接查:

apt-cache policy node-exporter

输出里应该能看到Candidate:指向你本地仓库里的版本,Version table里列出file:/opt/localrepo这一行。看到这个,仓库就算通了。

对比项dpkg-scanpackagesapt-ftparchive
所属包dpkg-devapt-utils
扁平仓库支持良好良好
多版本共存需--multiversion默认支持
生成 Release不支持支持
输出干净度有架构告警较干净

4. 走正规仓库结构:dists/pool 布局与签名

4.1 为什么有些场景绕不开 dists 结构

扁平仓库(flat repo)够用,但它在两个场景下会撞墙。

第一个是多发行版共存。扁平仓库的sources.list里发行版代号只能写./,意味着一个源只能服务一种系统。如果你要同时给老版本和新版本机器提供包,就得维护两个目录、两套sources.list。而标准仓库结构用dists/<codename>/区分,一个地址可以挂多个代号,客户端根据自己的代号自动选对应的索引。

第二个是工具链兼容性。有些自动化部署工具、镜像同步工具、内网yum/apt混合代理,只认标准的dists/pool结构,遇到flat repo会直接报错。如果你计划把本地仓库化成一个内网HTTP服务长期维护,一开始就走标准结构省事。

标准结构长这样:

/opt/aptrepo/ ├── dists/ │ └── stable/ │ ├── main/ │ │ └── binary-amd64/ │ │ ├── Packages │ │ └── Packages.gz │ ├── Release │ └── InRelease └── pool/ └── main/ └── n/ └── node-exporter/ └── node-exporter_1.6.1-1_amd64.deb

pool/放实际deb文件,按首字母和包名分目录;dists/放索引,main/binary-amd64/下是Packages。这个结构的构建命令要指定扫描路径和输出路径:

cd /opt/aptrepo dpkg-scanpackages -m pool /dev/null > dists/stable/main/binary-amd64/Packages gzip -kf dists/stable/main/binary-amd64/Packages

对应的sources.list写法变了:

deb [trusted=yes] file:///opt/aptrepo stable main

这里的stable必须和dists/下的目录名一致,main对应dists/stable/main/。写错任何一个,apt都会报Release file not found。

4.2 Release 文件与 GPG 签名的实操

标准结构必须有Release文件,它记录了索引的校验和,apt靠它判断索引有没有被篡改、是否过期。生成命令:

cd /opt/aptrepo/dists/stable apt-ftparchive release . > Release

Release里包含Date、Architectures、各Packages文件的MD5Sum/SHA256。这里有个坑:每次重新生成Packages后必须重新生成Release,否则校验和不匹配,apt会报:

W: Failed to fetch file:///opt/aptrepo/dists/stable/Release Hash Sum mismatch

如果环境要求签名(比如有安全审计),还要用GPG做两步:

# 生成签名用的密钥(一次性操作,建议用无密码的空口令密钥) gpg --batch --gen-key <<EOF %no-protection Key-Type: RSA Key-Length: 4096 Name-Real: Local Repo Signing Name-Email: repo@localhost Expire-Date: 0 %commit EOF # 生成 InRelease(明文签名)和 Release.gpg(分离签名) gpg --default-key "repo@localhost" --clearsign -o InRelease Release gpg --default-key "repo@localhost" -abs -o Release.gpg Release

导出公钥给客户端:

gpg --export "repo@localhost" > /opt/aptrepo/localrepo.gpg

客户端的sources.list改成这样:

deb [signed-by=/etc/apt/keyrings/localrepo.gpg] file:///opt/aptrepo stable main

注意signed-by里必须写二进制格式的密钥文件(用--export导出的就是二进制),不要用手工复制的-----BEGIN PGP PUBLIC KEY BLOCK-----文本,apt读不了。另外apt-key add这个老命令在新版本上已经废弃,虽然还能用但会打出醒目的弃用警告,别在新环境里继续用它。

提示:签名的坑主要在两处——一是Date字段。如果制作仓库的机器时间不准(比如内网机器的时间漂移了几个小时),客户端可能报Release file expired或not valid yet。二是Valid-Until字段。默认apt-ftparchive不生成这个字段,但如果你手动加了,注意不要设得太短,离线环境可能几天才更新一次索引。

4.3 file:// 与本地 HTTP 两种服务方式

单机使用file://就够了,零依赖、零配置。但如果是多台机器共享,每台都拷一份仓库既占空间又难维护,这时起一个本地HTTP服务更合适:

cd /opt/aptrepo && python3 -m http.server 8080 --bind 0.0.0.0

客户端sources.list改成deb [trusted=yes] http://仓库IP:8080 stable main。这个方案简单到不需要解释,但要注意两点:一是python -m http.server是单线程的,几十台机器同时apt update会明显变慢,量大时换成正经的静态服务软件;二是它会把目录列表暴露出去,虽然在内网风险不大,但如果仓库里有敏感的内部包,最好加个访问控制或者换个只提供文件的服务。

如果不想额外跑服务,file://配合NFS或共享目录也能实现多机共享,客户端挂载后指向挂载点即可。这种方式的好处是权限沿用文件系统,不需要单独做认证。

5. 上线过程中真正会卡住人的几个坑

5.1 apt update 报错的逐条排查链路

本地仓库搭好后,apt update的报错基本集中在四类,每一类对应不同的根因。下面这张表是我这几年攒下来的对照表,按报错原文查就行。

报错关键片段根因处置
does not have a Release file缺./或dists结构不全扁平源补./;标准源补Release
is not signed/can't be done securely未签名且未声明信任加[trusted=yes]或用signed-by
Hash Sum mismatchPackages变了但Release没重生成重新执行apt-ftparchive release
Unable to locate package索引里确实没这个包apt-cache policy确认,重生成索引
Conflicting distributionsources.list里的代号与Release不符两边代号改成一致

排查顺序建议从下往上:先ls确认文件在不在,再cat看Packages里有没有目标包名,再apt-cache policy看apt认不认,最后才怀疑权限和路径。我见过一次折腾了两小时的问题,最后发现是/opt/localrepo目录权限是700且属主是root,而执行apt update的用户虽然用了sudo,但apt的_apt沙箱用户读不到——报错是含糊的Permission denied,藏在apt update的W:行里,很容易被后面的E:行盖过去。

定位权限问题有个速查方法:

sudo -u _apt ls /opt/localrepo/Packages.gz

能列出来就说明权限没问题。列不出来就把目录改成755、文件改成644。

5.2 依赖版本被"降级"或"找不到"的真实原因

有个现象让不少人困惑:本地仓库明明有包,apt install却非要装一个更老的版本,或者干脆说but it is not going to be installed。这里通常是版本比较规则在作怪。

Debian的版本号规则是[epoch:]upstream_version[-debian_revision],比较顺序是:先比epoch(冒号前的数字,没有就是0),再比upstream版本,最后比debian修订号。所以1:1.0-1比2.0-1更大,因为epoch 1大于epoch 0。如果采集时把带epoch的包和不带epoch的包混在一起,apt的选择可能完全不符合直觉。

另一个常见原因是依赖的版本约束被本地仓库满足不了。比如主包要求libfoo (>= 2.1),你下载的是libfoo 2.0,因为采集机上那个版本恰好是2.0。这时apt install会给出很明确的提示:

node-exporter : Depends: libfoo (>= 2.1) but 2.0-1 is to be installed

看到这种,别硬来,回到采集机重新下载正确版本的libfoo。用apt-cache madison libfoo或者apt-cache policy libfoo能看到所有可用版本,挑一个满足约束的。

还有一种更隐蔽的:架构不匹配导致的"包存在但不可安装"。amd64机器上如果混进了arm64的deb,索引里会出现但状态标成不可安装,apt install报but it is not installable。检查方法是dpkg-deb -f xxx.deb Architecture,逐个核对。

5.3 脚本类包在离线环境的隐性依赖

有些包的postinst脚本会调用外部命令,这些命令不在Depends里声明,属于"运行时才暴露"的隐性依赖。典型场景:postinst里写systemctl daemon-reload,如果目标机跑的是容器或者chroot环境,没有systemd,脚本直接失败,包停在half-configured状态。

这类失败的报错长这样:

Setting up xxx (1.2.3) ... Failed to connect to bus: No such file or directory dpkg: error processing package xxx (--configure): installed xxx package post-installation script subprocess returned error exit status 1

处置方式分两种。如果是环境本身不该跑服务(比如容器里只想要二进制文件),可以跳过服务注册:

sudo dpkg -i --force-all xxx.deb # 或者解包不配置 sudo dpkg --unpack xxx.deb

如果是脚本缺命令,先把缺的命令装上再重跑配置:

sudo apt-get install -f # 或 sudo dpkg --configure -a

dpkg --configure -a这条命令值得记住,它会把所有处于half-configured状态的包重新跑一遍配置流程,是收拾残局的万能钥匙。但要注意:它只重跑配置,不解决依赖。依赖还缺着,重跑还是会失败。

还有一个隐蔽坑是磁盘空间。/var/cache/apt/archives/默认会堆积所有下载的deb,一台跑了半年的机器上这个目录可能有几个G。离线安装前先df -h看一下/var和/opt,空间不足时报错是No space left on device,但可能发生在解包到一半,导致包处于损坏状态,后续还需要dpkg --purge清理。清理缓存用apt-get clean,只清deb不删索引。

6. 把这套流程固化成可复用的脚本

6.1 采集端:一条命令拉齐所有依赖

手工敲那一长串apt-cache depends --recurse太累,包进脚本里。下面这个脚本接收包名列表,输出一个装满deb的目录:

#!/bin/bash # collect-debs.sh <output-dir> <pkg1> [pkg2 ...] set -euo pipefail OUT="${1:?usage: collect-debs.sh <output-dir> <pkg...>}" shift mkdir -p "$OUT" cd "$OUT" # 展开依赖树,去掉软依赖和反向关系 for pkg in "$@"; do apt-cache depends --recurse --no-recommends --no-suggests \ --no-conflicts --no-breaks --no-replaces --no-enhances \ "$pkg" 2>/dev/null | grep '^[a-z0-9]' || true done | sort -u > deps.txt # 只保留当前架构能装的包,避免因虚拟包报错 ARCH=$(dpkg --print-architecture) : > download.log while read -r p; do apt-get download "$p" >>download.log 2>&1 || echo "SKIP $p" >>download.log done < deps.txt # 索引 dpkg-scanpackages . /dev/null 2>/dev/null | gzip -9c > Packages.gz dpkg-scanpackages . /dev/null 2>/dev/null > Packages echo "collected $(ls -1 *.deb | wc -l) debs into $(pwd)"

几个细节:set -euo pipefail让脚本遇错即停,避免中间某步失败后继续跑出一堆垃圾文件;grep '^[a-z0-9]'比原来的^\w更严格,能过滤掉<开头的虚包标记(形如<foo>,表示这是个由其他包提供的虚拟包名,apt-get download会失败);|| true是允许某个包下载失败而整体不中断,失败记录进download.log供事后核对。

跑完之后,把整个目录打成tar包拷到U盘:

tar czf localrepo-$(date +%Y%m%d).tar.gz -C "$OUT" .

6.2 部署端:装完就生效的一键脚本

目标机上的脚本负责解包、配源、更新索引、安装:

#!/bin/bash # deploy-localrepo.sh <repo-tar.gz> <pkg-to-install> set -euo pipefail TAR="${1:?usage: deploy-localrepo.sh <repo.tar.gz> <pkg>}" PKG="${2:?missing package name}" DEST=/opt/localrepo mkdir -p "$DEST" tar xzf "$TAR" -C "$DEST" chmod -R a+rX "$DEST" cat >/etc/apt/sources.list.d/local.list <<EOF deb [trusted=yes] file://$DEST ./ EOF apt-get update -o Dir::Etc::sourcelist="sources.list.d/local.list" \ -o Dir::Etc::sourceparts="-" -o APT::Get::List-Cleanup="0" apt-cache policy "$PKG" | head -20 apt-get install -y --no-install-recommends "$PKG"

chmod -R a+rX那行是专门为5.1节提到的权限问题准备的——a+rX给所有用户读权限,X只对目录和已有执行位的文件加执行权限,避免把普通文件误设成可执行。--no-install-recommends是离线环境下减少意外依赖的手段,因为某些Recommends包本地仓库里可能没有。

如果apt-get install还是失败,脚本里可以退一步用apt-get install -f修复依赖,再不行就dpkg --configure -a收尾。但我的建议是别在脚本里塞太多兜底逻辑,失败了就该人工看日志,兜底会掩盖真正的版本冲突。

6.3 增量更新与长期维护的几条经验

仓库搭好不是结束,是开始。软件要升级、补丁要打,仓库得能增量更新。我踩过的几个点值得记录。

索引重建要幂等。每次往目录里加deb,都要跑一遍dpkg-scanpackages覆盖旧索引,而不是追加。因为apt读索引是全量读,追加会产生重复条目,apt-cache policy里出现两行相同的版本,看着很怪但也可能引发解析歧义。养成习惯:加完包立刻重建索引,把这条写进部署脚本的第一步。

用文件名规范避免覆盖。同一个包的不同版本deb文件名只差版本号,如果包名很长、版本号很短,人工核对时极容易看错。建议在仓库里按包名_版本_架构.deb严格命名,这是dpkg的默认产出格式,别自己改成node-exporter-new.deb这种——虽然dpkg-scanpackages认的是control里的字段不受文件名影响,但人看不出来,而且apt download生成的也是标准名,保持一致性省心。

定期清理过期架构。仓库跑久了会积一堆i386、armhf的包,每个几十MB到几百MB。清理前先apt-cache policy确认没有机器在用这些架构,再删文件、重建索引。删错了的代价是某台老机器apt update报找不到包,但你不会立刻发现,等到它需要装东西时才炸。

保留一份"最小可装集"。每次验证仓库是否健康,不要拿复杂的包试,用dpkg-dev这种依赖极少的包做apt install --reinstall的冒烟测试。能干净地重装成功,说明索引、权限、路径、版本全对。这个习惯帮我提前发现过好几次"索引生成了但Release没更新"的问题。

最后再分享一个小技巧:如果目标机上一堆机器都要装同样的东西,但又不想挨个配sources.list,可以把仓库目录放在一个共享位置,用一个极简的启动脚本只做三件事——挂载共享、写sources.list.d/local.list、跑apt update && apt install。整个流程五分钟能铺完一批机器,比一台台拷U盘靠谱得多。

这套东西我前后在不同环境里重搭过很多次,越到后面越觉得核心就一句话:离线环境的apt不是不能用,只是它需要的那份索引得你自己造出来。把依赖收全、把索引造对、把权限放开,剩下的操作和联网机器没有任何区别。真正花时间的从来不是命令本身,而是那些报错信息里没写出来的前提条件——版本、架构、权限、时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进