暗色模式

Linux 手工打包 deb:从 control 元数据到 dpkg-deb 构建与安装卸载

技术教程
2026-10-02
7
0
本文要点
  • 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

终端截图:用 heredoc 写 DEBIAN/control,dpkg-deb --build --root-owner-group 打出 768 字节的 cchello_1.0_all.deb;file 识别为 Debian binary package (format 2.0),ar t 列出 debian-binary、control.tar.zst、data.tar.zst 三个成员;前 8 字节为 213c 6172 6368 3e0a 对应 !<arch>

几个一眼可见的事实:

① 目录结构就是「目标路径」本身。 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 输出包大小 768 字节、control archive=326 字节,以及完整的 control 字段;dpkg-deb --ctrl-tarfile 管道给 tar -xO ./control 打印出同样的字段;dpkg-deb -c 列出包内 8 个条目,包括 -rwxr-xr-x root/root 78 字节的 ./usr/bin/cchello

三个命令的差别值得说清楚:

  • 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

终端截图:dpkg -i 安装 1.0 后执行 cchello 你好 输出 cchello v1.0;升级到 1.1 后 dpkg -l 显示状态 ii;把 Depends 改成 libfoo-nonexistent 后安装 1.2 报 dependency problems - leaving unconfigured,状态变成 iU;dpkg -P 卸载后 /usr/bin/cchello 不存在,dpkg -l 报 no packages found matching cchello

① 安装是「先解包、后配置」两个阶段。 输出里的 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: 我接管对方的文件

把它发给别的机器

包打出来之后,分发有几条路:

  1. 直接拷过去装:scp cchello_1.0_all.deb host:/tmp/ && ssh host dpkg -i /tmp/cchello_1.0_all.deb。包本身自带版本与依赖信息,比拷脚本靠谱。
  2. 建一个最简本地仓库:把若干 .deb 放进一个目录,用 dpkg-scanpackages 生成 Packages.gz,客户端在 sources.list 里指向它(deb [trusted=yes] http://内网地址/debs ./)。这样目标机器可以 apt install cchello,也能享受依赖自动解析。dpkg-scanpackages 来自 dpkg-dev 包,默认不装。
  3. 上传到内网制品库(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 无输出,没有留下任何残留登记。

相关阅读:

发表评论

暂无评论,快来抢沙发吧!