本文要点
- deb 包不神秘,它就是一个 ar 归档:实测
ar t列出三个成员debian-binary、control.tar.zst、data.tar.zst,文件头 8 字节是!<arch>\n(xxd显示213c 6172 6368 3e0a)。Ubuntu 22.04 的 dpkg 1.21.1 默认用 zstd 压缩这俩 tar - 打包只需要两样东西:一个
DEBIAN/control元数据文件,加一棵「按目标路径摆好」的目录树。dpkg-deb --build --root-owner-group一条命令就把 768 字节的包打出来 - 装前可以只看不装:
dpkg-deb -I读元数据、dpkg-deb -c列文件清单、dpkg-deb --ctrl-tarfile | tar -xO ./control直接从 tar 流里读 control、dpkg-deb -x解包到 /tmp——四个动作都不碰系统 - 安装分「解包」和「配置」两步,状态栏会说实话:正常装完是
ii;把依赖改成一个不存在的包再装,dpkg 会报dependency problems - leaving unconfigured,状态停在iU(已解包、未配置)——文件已经落地,但包没配好 - 卸载要靠 dpkg 而不是 rm:
dpkg -P之后/usr/bin/cchello消失、dpkg -l报no packages found matching、dpkg --audit无输出——文件、数据库登记、配置三样一起清干净,全程磁盘占用可以忽略(7.6G 的盘装完再卸仍是 5.1G 可用) - 维护者脚本拿到的参数不是版本号:实测首次安装时 postinst 收到
$1=configure、$2为空;升级到 1.1 时$1=configure、$2=1.0($2 才是「上一个已配置版本」)
Linux 手工打包 deb:从 control 元数据到 dpkg-deb 构建与安装卸载
把一堆脚本和配置部署到十几台机器,你有几个选择:scp 过去、写个 Ansible playbook、或者——打成一个 deb 包。
前两种做法的问题会在几周后暴露:机器上的文件是「谁什么时候放的、是哪个版本」全靠记忆;想卸载只能手动找文件删;想回滚得把旧文件再拷一遍。打成 deb 之后,这些问题的答案都在 dpkg -l、dpkg -L、dpkg -P 里:版本、文件清单、依赖、卸载,全都由包管理器记账。
本文讲的是造包这一侧——写 control、摆目录树、dpkg-deb 构建、装前验包、安装升级卸载。日常用 apt 装软件、删软件、清缓存那一侧,见 Linux 软件包管理:从 apt 软件源到 dpkg 包管理。全程在一台 Ubuntu 22.04(dpkg 1.21.1)上实测,演示包只往 /tmp 和 /usr/bin 放一个小脚本,装完立刻卸载干净。
deb 包解剖:一个 ar 归档,里面装三样东西
先把「包」这层皮剥掉。下面的命令从零构建一个最小 deb(DEBIAN/control 是唯一的必填元数据):
rm -rf /tmp/cchello-pkg && mkdir -p /tmp/cchello-pkg/DEBIAN /tmp/cchello-pkg/usr/bin /tmp/cchello-pkg/usr/share/doc/cchello
cat > /tmp/cchello-pkg/DEBIAN/control <<'EOF'
Package: cchello
Version: 1.0
Architecture: all
Maintainer: demo <demo@example.com>
Depends: bash (>= 4.0)
Section: utils
Priority: optional
Description: 手工打包演示用的最小 deb 包
只包含一个 /usr/bin/cchello 脚本。
EOF
printf '#!/bin/sh\necho "cchello v1.0 — 来自手工打包的 deb"\necho "参数: $*"\n' > /tmp/cchello-pkg/usr/bin/cchello
chmod 755 /tmp/cchello-pkg/usr/bin/cchello
dpkg-deb --build --root-owner-group /tmp/cchello-pkg /tmp/cchello_1.0_all.deb
echo "--- 产物是什么 ---"; ls -l /tmp/cchello_1.0_all.deb
file /tmp/cchello_1.0_all.deb
echo "--- ar 归档成员 ---"; ar t /tmp/cchello_1.0_all.deb
echo "--- 头 8 字节 ---"; head -c 8 /tmp/cchello_1.0_all.deb | xxd
几个一眼可见的事实:
① 目录结构就是「目标路径」本身。 cchello-pkg/usr/bin/cchello 装完就是 /usr/bin/cchello——没有映射表、没有安装脚本,目录树在哪,文件就落在哪。唯一的例外是顶层的 DEBIAN/ 目录,它是元数据区,不会被装进系统。
② deb 是 ar 归档,不是 tar。 ar t 看到的三个成员各司其职:
| 成员 | 作用 | 说明 |
|---|---|---|
debian-binary | 格式版本号 | 内容就一行 2.0,file 报的 format 2.0 来自它 |
control.tar.zst | 元数据与控制脚本 | 内含 control(必填)、postinst/prerm 等(可选)、conffiles、md5sums |
data.tar.zst | 真正的文件树 | 会被解包到 / 的那棵目录树 |
文件头 !<arch>\n 是 ar 格式的魔数——ar、deb、.a 静态库共用同一套容器格式。
③ 压缩算法默认是 zstd。 同一台机器上 dpkg-deb --build 打出来的成员名是 control.tar.zst / data.tar.zst,这是 dpkg 1.21 起的默认值(更早的版本是 xz,再早是 gzip)。想换回兼容性更好的老格式可以显式指定 -Zgzip / -Zxz / -Zzstd——包本身的内容不变,只是 tar 的压缩方式不同。几种算法的压缩率与耗时对比见 Linux 压缩工具实测:gzip、bzip2、xz 与 zstd 的压缩率、耗时与内存。
④ --root-owner-group 值得养成习惯。 不加它时,包内文件的属主取「构建时的属主」;用普通用户构建出来的包,装到系统里文件属主会是一个不存在的普通用户名。加上这个参数,dpkg-deb 会把包内所有文件的属主统一写成 root/root——你用非 root 用户打包,装到目标机上也是 root 所有。
control 文件:包的身份证
DEBIAN/control 是纯文本的 字段: 值 列表,字段名大小写敏感。上面那个包用到的字段:
| 字段 | 是否必填 | 说明 |
|---|---|---|
Package | 必填 | 包名。全小写,只能用小写字母、数字和 + - .,且至少 2 个字符 |
Version | 必填 | 版本号。dpkg 只做版本比较,不去理解你改了什么;升级 = 同名包 + 更高的版本 |
Architecture | 必填 | amd64、arm64,或 all(与架构无关的脚本、文档类包) |
Maintainer | 必填 | 名字 <邮箱>,出问题时别人知道找谁 |
Depends | 可选 | 依赖,格式见下文;装的时候不满足会卡在未配置状态 |
Section / Priority | 可选 | 归类用,utils、admin、optional 这类 |
Installed-Size | 可选 | 安装后占用的 KB 数,包管理器用来预估磁盘占用 |
Description | 必填 | 第一行是一句话摘要,后续续行以空格开头(上面例子里那行「只包含一个…」开头就有一个空格) |
两个实操中容易踩的点:
- 续行必须缩进。
Description的详细说明如果不以空格开头,会被当成一个新字段,dpkg-deb --build直接报错。 - 包名不是随便起的。 大写字母和下划线都会被打包工具拒绝(
Package: CC_Hello这种名字在 Debian 体系里不合法)——包名要能安全地出现在文件名和 URL 里。
文件名本身也有约定:<包名>_<版本>_<架构>.deb,即 cchello_1.0_all.deb。dpkg 并不强制这个命名,但 dpkg -i 认不出文件名时会去读包内 control,而人工分发时这个名字能省很多事。
装前先验包:四个「只看不装」的动作
拿到一个来路不明的 deb(比如客户发来的、内部构建机产出的),最安全的做法是先看内容再决定装不装。这四个命令都不碰系统:
echo "--- 装前先验包:dpkg-deb -I 读 control 元数据 ---"
dpkg-deb -I /tmp/cchello_1.0_all.deb
echo "--- 不用解包,直接把 control 从 tar 流里读出来 ---"
dpkg-deb --ctrl-tarfile /tmp/cchello_1.0_all.deb | tar -xO ./control
echo "--- 包内文件清单(-c)---"
dpkg-deb -c /tmp/cchello_1.0_all.deb
三个命令的差别值得说清楚:
dpkg-deb -I(--info):读 control 元数据。截图里的包只有control一个成员,所以元数据区就 9 行;如果包里带了控制脚本,这里会多列一行(实测形如65 bytes, 3 lines * postinst)——看到控制脚本就该多看一眼,因为它们在安装时以 root 身份执行。dpkg-deb --ctrl-tarfile:把 control 归档的 tar 流原样吐出来,可以接tar -xO ./control只读那一个文件,也可以接tar -tvf -看全部成员。不解包、不落地。dpkg-deb -c(--contents):列data.tar的文件清单,含权限、属主、大小。./usr/bin/cchello是 78 字节、-rwxr-xr-x——这个包的全部内容就这些。dpkg-deb -x(--extract):把 data 归档解到指定目录,比如dpkg-deb -x xxx.deb /tmp/cchello-ext。装到/tmp里跑一遍再决定,比直接dpkg -i稳妥得多。
如果包是从网上下载的,装之前顺手核对一下哈希——sha256sum 与已发布的校验值对比,思路见 Linux 文件完整性校验:从 sha256sum 到 dpkg -V 篡改排查。
安装、升级与状态机
前面都是「看」,下面真装。这段演示了三件事:安装、把版本号从 1.0 改成 1.1 再打一个包(这就是升级)、以及故意把依赖写成不存在的包会发生什么:
echo "--- 安装 1.0 ---"
dpkg -i /tmp/cchello_1.0_all.deb 2>&1
cchello 你好
echo "--- 改版本号再打一个:这就是升级包 ---"
sed -i -e 's/^Version: .*/Version: 1.1/' -e 's/^Depends: .*/Depends: bash (>= 4.0)/' /tmp/cchello-pkg/DEBIAN/control
dpkg-deb --build --root-owner-group /tmp/cchello-pkg /tmp/cchello_1.1_all.deb >/dev/null
dpkg -i /tmp/cchello_1.1_all.deb 2>&1 | tail -3
dpkg -l cchello | tail -1
echo "--- 依赖写成一个不存在的包 ---"
sed -i -e 's/^Version: .*/Version: 1.2/' -e 's/^Depends: .*/Depends: libfoo-nonexistent (>= 9.9)/' /tmp/cchello-pkg/DEBIAN/control
dpkg-deb --build --root-owner-group /tmp/cchello-pkg /tmp/cchello_1.2_all.deb >/dev/null
dpkg -i /tmp/cchello_1.2_all.deb 2>&1 | tail -9
dpkg -l cchello 2>&1 | tail -1
echo "--- 卸载清理 ---"
dpkg -P cchello 2>&1 | tail -2
ls -l /usr/bin/cchello 2>&1
dpkg -l cchello 2>&1 | tail -1
① 安装是「先解包、后配置」两个阶段。 输出里的 Unpacking cchello (1.0) ... 是第一阶段(把 data.tar 的文件铺到磁盘),Setting up cchello (1.0) ... 是第二阶段(跑 postinst、登记状态)。这个分界解释了很多现象:解包成功不等于装好了。
② dpkg -l 的状态列是排障的入口。 它由两个字符组成:
| 状态 | 含义 | 怎么处理 |
|---|---|---|
ii | 已安装、已配置,一切正常 | 不用管 |
iU | 已解包、未配置(通常是依赖不满足) | 装齐依赖后 dpkg --configure -a |
iF | 配置过程中失败 | 看 /var/log/dpkg.log 与对应脚本 |
rc | 已卸载,配置文件还留着 | 想彻底清就 dpkg -P <包名> |
un | 从未安装 / 已彻底清除 | — |
演示里 1.2 版的失败原文是 dependency problems - leaving unconfigured:dpkg 明明知道缺 libfoo-nonexistent,却仍然把文件解包了,只是拒绝配置。所以此时 /usr/bin/cchello 大概率已经是最新的了——这正是「iU 状态很危险」的原因:文件在,账没记全。
③ 修一个 iU 包,正规做法是把依赖补上再配置。 最小修复是 dpkg --configure -a(把所有未配置的包重新配置一遍),或者用 apt-get -f install 让 apt 去补依赖。注意这两种写法都要求依赖真的能被装上——把包名写错成不存在的包,再多的 -f 也救不回来,最后只能 dpkg -P 卸掉重打。
④ 卸载要用 dpkg,不要 rm。 dpkg -P cchello 之后:ls -l /usr/bin/cchello 报 No such file or directory,dpkg -l cchello 报 no packages found matching cchello。后者尤其重要——只有包管理器知道哪些文件是这个包带来的;手删文件会让包数据库留下一个「已安装但文件不全」的幽灵条目。
⑤ dpkg -r 与 dpkg -P 的区别:-r(remove)卸载但保留配置文件(状态变成 rc),-P(purge)连配置一起删。演示用的是 -P,所以状态直接从 iU 跳到彻底消失。
顺带说下升级:同一个 Package 名、更高的 Version,dpkg -i 就会走 Unpacking cchello (1.1) over (1.0) 的覆盖流程,老版本的文件被新版本替换,配置状态保持 ii。dpkg 不做「智能差异升级」,它只按版本比较——所以版本号就是你的事:改了内容却忘了改版本号,目标机器会认为「已经是最新的」。
维护者脚本:安装时以 root 身份跑的钩子
DEBIAN/ 目录下除了 control,还能放四个可选的脚本,dpkg 会在特定时机调用它们:
| 脚本 | 调用时机 | 典型用途 |
|---|---|---|
preinst | 解包之前 | 停掉将要被替换的服务 |
postinst | 解包并配置时 | 初始化配置、创建用户、启动服务 |
prerm | 卸载之前 | 停服务、清理运行时状态 |
postrm | 卸载之后 | 删除自建用户、清理残留 |
这些脚本的入参不是版本号,这一点在实测里被验证得很清楚。给包加一个这样的 postinst:
cat > /tmp/ccargs-pkg/DEBIAN/postinst <<'POST'
#!/bin/sh
echo "[postinst] 动作=$1 上一个已配置版本=${2:-(无)}"
POST同一台机器上先装 1.0、再把版本改成 1.1 装一遍,两段输出分别是:
--- 首次安装 1.0 ---
[postinst] 动作=configure 上一个已配置版本=(无)
--- 升级到 1.1 ---
[postinst] 动作=configure 上一个已配置版本=1.0$1 是 dpkg 传进来的动作名(安装/升级时都是 configure,卸载时可能是 remove、purge、abort-upgrade 等),$2 才是「上一个已配置的版本」——首次安装时它为空,升级时是旧版本号。想按版本做条件分支(比如「从 1.0 升上来的才需要迁移数据」),就得读 $2 而不是 $1。
三条经验:脚本第一行写 #!/bin/sh 并 chmod 755(dpkg 不会替你补执行位);脚本里任何失败都可能让包卡在未配置状态,所以要么写 set -e 并在关键步骤后校验,要么明确忽略错误;脚本最好做成可重复执行(幂等),因为 dpkg --configure -a 会再跑一次。
依赖字段的完整语法也值得记一下:
Depends: bash (>= 4.0), curl | wget—— 逗号是「且」,|是「或」(二选一即可)- 版本关系符:
>=、<=、=、>>(严格大于)、<<(严格小于) Recommends:建议安装(默认装,缺失只警告)、Suggests:可选(默认不装)Conflicts:不能共存、Breaks:会导致对方损坏(升级场景常用)、Replaces:我接管对方的文件
把它发给别的机器
包打出来之后,分发有几条路:
- 直接拷过去装:
scp cchello_1.0_all.deb host:/tmp/ && ssh host dpkg -i /tmp/cchello_1.0_all.deb。包本身自带版本与依赖信息,比拷脚本靠谱。 - 建一个最简本地仓库:把若干
.deb放进一个目录,用dpkg-scanpackages生成Packages.gz,客户端在sources.list里指向它(deb [trusted=yes] http://内网地址/debs ./)。这样目标机器可以apt install cchello,也能享受依赖自动解析。dpkg-scanpackages来自dpkg-dev包,默认不装。 - 上传到内网制品库(Nexus、Artifactory 之类),走版本管理。
无论哪条路,校验哈希都别省:分发的每一个字节都可能是传输错误或被替换的内容,sha256sum 一行就能排除前者。
小结
手工打包 deb 这件事,真正需要记住的东西比想象的少:
- 一个必填文件:
DEBIAN/control;一棵目录树:按目标路径摆好;一条命令:dpkg-deb --build --root-owner-group。 - 装前四看:
-I元数据、-c清单、--ctrl-tarfile读 control、-x解到 /tmp。看到preinst/postinst就多留个心眼——它们以 root 运行。 - 状态列是仪表盘:
ii正常,iU说明解包了但没配置,rc说明卸了但配置还在。 - 卸载交给 dpkg:
-r保配置、-P清干净,手删文件只会留下幽灵条目。 - 版本号是你自己的纪律:dpkg 只做版本比较,不会替你看内容差异。
演示机上这一整套(打包含 heredoc、装、升级、依赖失败、卸载)跑完再清干净,磁盘可用量始终是 5.1G——包本身 768 字节,dpkg --audit 无输出,没有留下任何残留登记。
相关阅读:
- Linux 软件包管理:从 apt 软件源到 dpkg 包管理——用包的那一侧:源、install、remove、清缓存
- Linux 文件完整性校验:从 sha256sum 到 dpkg -V 篡改排查——分发前后的哈希核对与
dpkg -V文件校验 - Linux tar 打包与压缩:从归档原理到增量备份——deb 里那两个
tar.zst的归档侧知识 - Linux 压缩工具实测:gzip、bzip2、xz 与 zstd 的压缩率、耗时与内存——
-Zgzip/-Zxz/-Zzstd三种选择的依据 - Linux 二进制分析:从 file 类型识别到 ELF 结构与 objdump 反汇编——用
file/xxd看文件格式的通用方法
评论 (0)
暂无评论,快来抢沙发吧!