Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.11 软件包、编译、库与镜像

本章回答

apt 和 dpkg 各做什么?./configure && make && make install 把文件装到了哪里,--prefix 与 DESTDIR 有什么区别?undefined symbol 和 cannot open shared object file 分别该从哪里查?容器镜像和包管理是什么关系?

读完你会

拆开一个 .deb 看控制信息和文件清单;用 apt-cache policy 判断一个包从哪个源来、为什么被 hold;从源码构建并 staged 安装一个程序;用 readelf/nm/LD_DEBUG 定位动态库问题;写一个多阶段、非 root 的运行镜像。

0.10 讲的是块设备上的字节。这一章讲这些字节怎么变成「能运行的程序」:发行版把二进制、配置、依赖关系打成一个 .deb;源码项目用 configure/make 把自己装进 /usr/local;动态链接器在运行时把 .so 找出来映射进地址空间;容器镜像则把一整套用户态再打一层。

四件事共享一个核心问题:依赖关系在哪里被解析。apt 在安装时解析包依赖,ld.so 在启动时解析库依赖,make 在构建时解析头文件和链接依赖,容器构建在打层时固化这些结果。搞清楚每一层由谁负责,Package ... has no installation candidate、undefined symbol、error while loading shared libraries 就不再是同一类「玄学错误」。

依赖在四个不同的时刻被解析,失败的表现也完全不同:

阶段谁解析依赖典型失败主要工具
构建编译器、链接器undefined reference、头文件找不到make、pkg-config
安装apt / dpkgdependency problems、版本冲突apt、dpkg
启动动态链接器 ld.socannot open shared object fileldd、readelf
镜像构建器与层缓存层里残留旧索引、版本漂移docker history

命令以 Ubuntu 24.04(dpkg 1.22、apt 2.7、glibc 2.39、binutils 2.42、CMake 3.28)为准。旧式的 aptitude、alien、ldconfig 手工维护 ld.so.conf 的细节只在必要处提一句。

0.11.1 .deb 包里有什么

.deb 是一个 ar 归档,里面固定三个成员:debian-binary(格式版本)、control.tar.*(元数据与安装脚本)、data.tar.*(要安装的文件)。dpkg-deb 能拆开看,不需要安装:

$ apt download bash
Get:1 http://archive.ubuntu.com/ubuntu noble/main amd64 bash amd64 5.2.21-2ubuntu4 [1,439 kB]
Fetched 1,439 kB in 0s (4,512 kB/s)
$ dpkg-deb -I bash_*.deb | head -8
 new Debian package, version 2.0.
 size 1439234 bytes: control archive=1548 bytes.
     572 bytes,    14 lines      control
     340 bytes,     5 lines      md5sums
 Package: bash
 Version: 5.2.21-2ubuntu4
 Architecture: amd64
 Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
 Depends: base-files (>= 2.1.12), debianutils (>= 2.15)
$ dpkg-deb -c bash_*.deb | head -4
drwxr-xr-x root/root         0 2024-04-01 10:00 ./
drwxr-xr-x root/root         0 2024-04-01 10:00 ./usr/
drwxr-xr-x root/root         0 2024-04-01 10:00 ./usr/bin/
-rwxr-xr-x root/root   1446024 2024-04-01 10:00 ./usr/bin/bash
$ dpkg -S /usr/bin/ls
coreutils: /usr/bin/ls
$ dpkg -L coreutils | head -4
/.
/usr
/usr/bin
/usr/bin/[

-I 看控制信息:包名、版本、架构、维护者、Depends。-c 看文件清单。dpkg -S 反查「这个文件属于哪个包」,dpkg -L 列出某个包装了哪些文件。dpkg -l 看所有包的状态(ii 表示已安装且配置完成),dpkg -s bash 看单个包的详细字段。

dpkg 的数据库在 /var/lib/dpkg/:status 是全部包的安装状态,info/ 下每个包有 .list(文件清单)、.md5sums、preinst/postinst 等脚本。dpkg --audit 检查半安装状态,dpkg --configure -a 重新配置所有未完成的包。系统升级中断后,这两条命令通常能救回来。

dpkg 只负责「把包里的文件放到位、跑脚本」,不解析依赖。dpkg -i foo.deb 遇到缺依赖会报错并留下未配置状态;apt install ./foo.deb 会先读包元数据、从源里补齐依赖,再调用 dpkg。两者不是替代关系,是分工:

命令解析依赖从源下载适合
dpkg -i pkg.deb否否已确认依赖满足的离线包
apt install ./pkg.deb是是本地 deb,推荐
apt install pkg是是正常安装
apt -f install是是修复 dpkg 留下的缺依赖

dpkg -i 失败后不要反复重试,先 apt -f install 让 apt 补齐依赖。dpkg --get-selections 能导出包清单,dpkg --set-selections 能批量恢复;迁移机器时比手抄包名可靠。

0.11.2 apt 的源、依赖与 pinning

Ubuntu 24.04 默认用 deb822 格式的 /etc/apt/sources.list.d/ubuntu.sources,不再是单行 sources.list:

Types: deb
URIs: http://archive.ubuntu.com/ubuntu/
Suites: noble noble-updates noble-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg

Types 是 deb(二进制)或 deb-src(源码);Suites 决定版本通道;Components 决定自由软件范围。apt update 只刷新索引,不升级;apt upgrade 升级已安装包但不删包;apt full-upgrade 允许为解决依赖而删包或换包,升级发行版时用后者,日常用前者。

apt-cache policy 是判断「这个包从哪来、为什么版本不对」的第一条命令:

$ apt-cache policy bash
bash:
  Installed: 5.2.21-2ubuntu4
  Candidate: 5.2.21-2ubuntu4
  Version table:
 *** 5.2.21-2ubuntu4 500
        500 http://archive.ubuntu.com/ubuntu noble/main amd64 Packages
        100 /var/lib/dpkg/status
$ apt-mark hold bash
bash set on hold.
$ apt-mark showhold
bash
$ apt-get -s install nginx | tail -4
The following NEW packages will be installed:
  nginx nginx-common nginx-core
0 upgraded, 3 newly installed, 0 to remove and 0 not upgraded.

Installed 是当前版本,Candidate 是 apt 准备安装的版本,Version table 里每一行前面的数字是 pin 优先级:500 是普通源,100 是已安装版本,990 只在设置了 target release(APT::Default-Release 或 apt-get -t)时出现;Ubuntu 24.04 默认没有 target release,noble/noble-updates/noble-security 都是 500,1000 是手动指定的高优先级。优先级决定候选版本,不决定「哪个更新」——apt 选的是优先级最高的版本,不是版本号最大的。

pinning 写在 /etc/apt/preferences.d/*.pref:

Package: *
Pin: release a=noble-updates
Pin-Priority: 900

Package: docker-ce
Pin: version 5:27.*
Pin-Priority: 1001

Pin-Priority 大于 1000 表示「允许降级到这个版本」;990–1000 表示「优先于普通源」;100–989 表示「只有没有更高优先级时才用」;小于 0 表示「禁止安装」。写 pin 之前先 apt-cache policy 看清现有优先级,否则很容易把安全更新钉死。

apt-mark hold 是临时的「别动这个包」,适合内核、驱动、数据库这类不能随便升级的组件;它不阻止依赖解析,只阻止升级。解除用 apt-mark unhold。apt list --upgradable 看可升级项,apt list --installed 看已安装项;脚本里用 apt-get -s 先模拟,确认输出再真跑。

0.11.3 源码构建:prefix、DESTDIR 与打包

源码包的通用流程是「配置 → 编译 → 安装」。配置阶段检查头文件、库、编译器特性,生成 Makefile;编译阶段把 .c 变成 .o 再链接;安装阶段把产物复制到 prefix 下的 bin、lib、include、share。

# 以源码目录内的构建为例;先读 README/INSTALL,确认依赖
sudo apt install -y build-essential pkg-config libssl-dev
./configure --prefix=/usr/local --sysconfdir=/etc --localstatedir=/var
make -j"$(nproc)"
make check                     # 有测试套件就跑,失败先别装
sudo make install

--prefix=/usr/local 表示装到 /usr/local/bin、/usr/local/lib,不覆盖发行版的 /usr/bin;--sysconfdir=/etc 把配置放到 /etc,--localstatedir=/var 把可变数据放到 /var。不写 --prefix 时默认也是 /usr/local,但有些项目默认 /usr,装进去会和包管理器打架。

DESTDIR 是「暂存目录」,不是安装路径:

make install DESTDIR="$PWD/stage"
find stage -maxdepth 3 -type f | head

它把 --prefix 下的文件树复制到 stage/usr/local/...,用于打包:先 staged 安装,再 dpkg-deb --build 打成 deb,最后用包管理器安装。DESTDIR 不会改变程序运行时看到的路径,程序仍然认为自己在 /usr/local。checkinstall 能拦截 make install 并生成 deb,适合临时把源码包纳入包管理,但它的元数据不如手写 debian/ 完整。

make -j"$(nproc)" 用全部 CPU 并行编译;内存小的机器用 -j2 或 -j4,否则链接阶段可能 OOM。make check 失败不要跳过:测试失败通常意味着 ABI、依赖版本或编译选项不对,装上去也会在运行时出问题。卸载源码安装的程序没有 make uninstall 时只能按 make install 的日志逐个删,或者用 stow 管理 /usr/local/stow/ 下的目录。

0.11.4 静态库、共享库与 soname

静态库在链接时把代码复制进可执行文件,共享库在运行时由动态链接器映射。两者的差别不只是「文件大小」:

维度静态库 .a共享库 .so
链接时机编译链接时程序启动/首次使用时
可执行文件大小大小
库更新要重新链接替换 .so 即可(ABI 兼容时)
内存共享每个进程一份多个进程共享代码页
部署单文件,依赖少需要 loader、soname、搜索路径
安全更新每个程序重编更新库文件即可

共享库的核心概念是 soname(shared object name):库文件可以叫 libfoo.so.1.2.3,但 soname 是 libfoo.so.1,可执行文件记录的是 soname。运行时 loader 按 soname 找文件,升级小版本只要替换 libfoo.so.1.2.3 并重建 libfoo.so.1 符号链接,不需要重编程序。

# 静态库
cc -std=c17 -O2 -c foo.c -o foo.o
ar rcs libfoo.a foo.o

# 共享库:-fPIC 必须,soname 必须
cc -std=c17 -O2 -fPIC -c foo.c -o foo.pic.o
cc -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.2.3 foo.pic.o
ln -sf libfoo.so.1.2.3 libfoo.so.1
ln -sf libfoo.so.1 libfoo.so

# 链接:-L 指定目录,-lfoo 找 libfoo.so
cc -std=c17 main.c -L. -lfoo -Wl,-rpath,'$ORIGIN/lib' -o app

-fPIC 生成位置无关代码,共享库必须;静态库可以不 -fPIC,但如果要链进共享库就必须。-Wl,-soname,... 把 soname 写进 .dynamic 段。libfoo.so 这个不带版本的名字只在链接时用,运行时用 soname;只装 libfoo.so 不装 libfoo.so.1,程序启动会报找不到。

foo.c ──cc -c──► foo.o ──ar──► libfoo.a ─────────┐
                    │                            ├──► app(静态链接,代码复制)
                    └─cc -shared -fPIC──► libfoo.so.1.2.3
                                              │
                                     soname: libfoo.so.1
                                              │
app(动态链接)──► ld-linux.so ──► ld.so.cache / RPATH / LD_LIBRARY_PATH

读图:静态链接在上半支,链接完就与库无关;动态链接在下半支,运行时才找库。找库的顺序由 loader 决定:DT_RPATH(如果没 RUNPATH)→ LD_LIBRARY_PATH → DT_RUNPATH → /etc/ld.so.cache → /lib、/usr/lib。RPATH 和 RUNPATH 的区别是前者会被 LD_LIBRARY_PATH 覆盖,后者不会;现代链接器默认生成 RUNPATH。

$ORIGIN 表示「可执行文件所在目录」,-Wl,-rpath,'$ORIGIN/lib' 让程序在自身目录的 lib/ 下找库,适合自包含发布。LD_LIBRARY_PATH 是临时的调试手段,不是部署方案:它会污染所有子进程,setuid 程序会忽略它(secure-execution mode),而且容易被恶意库劫持。生产环境用 RUNPATH、ldconfig 或把库装进系统路径。

ldconfig 扫描 /etc/ld.so.conf 和 /etc/ld.so.conf.d/*.conf 里的目录,生成 /etc/ld.so.cache。把 /usr/local/lib 加进去:

echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/usr-local.conf
sudo ldconfig
ldconfig -p | grep libfoo

ldconfig -p 看缓存里有哪些库。ldd ./app 看程序依赖哪些库、各自解析到哪个路径;但它会调用 loader,对不可信二进制不要用,改用 readelf -d ./app 和 objdump -p ./app。

0.11.5 两个链接错误的排查顺序

动态库问题最终只会落到两类报错。先分清是哪一类,再决定查什么:

报错含义第一条命令
error while loading shared libraries: libfoo.so.1: cannot open shared object fileloader 找不到这个 sonameldd ./app、readelf -d ./app
undefined symbol: foo库找到了,但里面没有这个符号nm -D libfoo.so.1、objdump -T
version 'GLIBC_2.38' not found程序要的 glibc 版本比运行环境新ldd --version、`objdump -T ./app
wrong ELF class: ELFCLASS3232/64 位不匹配file ./app libfoo.so.1
cannot open shared object file 但文件存在soname 不对、缓存没更新、RUNPATH 不包含该目录readelf -d、ldconfig -p

排查「找不到库」按这个顺序:

  1. file ./app 确认架构,file libfoo.so.1 确认库的架构一致;
  2. readelf -d ./app | grep -E 'NEEDED|RPATH|RUNPATH' 看它要的 soname 和搜索路径;
  3. ldd ./app 看 loader 实际解析到哪,not found 的那一行就是缺口;
  4. ldconfig -p | grep libfoo 看缓存里有没有;没有就检查 /etc/ld.so.conf.d/;
  5. find / -name 'libfoo.so*' 2>/dev/null 找文件在哪,确认目录是否在搜索路径里。
$ ldd ./app
	linux-vdso.so.1 (0x00007ffd8c3f9000)
	libfoo.so.1 => not found
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3c2a1b2000)
$ readelf -d ./app | grep -E 'NEEDED|RPATH|RUNPATH'
 0x0000000000000001 (NEEDED)   Shared library: [libfoo.so.1]
 0x000000000000001d (RUNPATH)  Library runpath: [$ORIGIN/lib]
$ ls -l lib/
total 24
-rwxr-xr-x 1 me me 15920 Mar  2 11:02 libfoo.so.1.2.3
$ ln -s libfoo.so.1.2.3 lib/libfoo.so.1
$ ldd ./app | grep foo
	libfoo.so.1 => ./lib/libfoo.so.1 (0x00007f3c2a3d4000)
$ LD_DEBUG=libs ./app 2>&1 | grep -m2 libfoo
     31245:	find library=libfoo.so.1 [0]; searching
     31245:	trying file=./lib/libfoo.so.1

第一段 not found 不是「文件不存在」,而是「按 soname 找不到」。ls lib/ 显示只有 libfoo.so.1.2.3,缺 libfoo.so.1 这个符号链接;补上链接后 loader 立刻能解析。这是自编译共享库最常见的坑:make install 生成了带版本的文件,但没建 soname 链接,或者链接建错。

排查 undefined symbol 则是另一条路:

nm -D --undefined-only ./app | grep foo      # 程序要但没定义的符号
nm -D --defined-only libfoo.so.1 | grep foo  # 库里提供的符号
objdump -T libfoo.so.1 | grep foo            # 动态符号表,含版本
c++filt < <(nm -D libfoo.so.1)               # C++ 符号反修饰

常见原因:链接顺序不对(-lfoo 必须放在使用它的 .o 后面)、C++ 的 extern "C" 漏了导致名字修饰不一致、符号被 -fvisibility=hidden 隐藏、库的版本比头文件旧。LD_DEBUG=bindings ./app 能看每个符号绑定到哪个库,输出很大,配合 grep 用。

例 0.11.1 · 明明加了 `-lfoo`,还是 `undefined symbol`

静态链接时链接器从左到右扫描:cc -lfoo main.o -o app 先看到 -lfoo,此时还没有任何未定义符号,库里的目标文件不会被拉进来;cc main.o -lfoo -o app 才是正确顺序。动态链接通常不受顺序影响,但 --as-needed 下仍可能把「当前没用到」的库丢掉。先调顺序,再用 nm -D --defined-only 确认库里确实有这个名字;C++ 还要用 c++filt 看是不是 extern "C" 漏了。

0.11.6 pkg-config 与 CMake 最小工程

手写 -I/-L/-l 容易漏。pkg-config 从 .pc 文件读编译和链接参数:

pkg-config --cflags --libs libcurl
# -lcurl
pkg-config --modversion libcurl
# 8.5.0

.pc 文件在 /usr/lib/x86_64-linux-gnu/pkgconfig/ 或 /usr/share/pkgconfig/,里面写 prefix、includedir、libdir、Libs、Cflags。装到非标准 prefix 的库要设 PKG_CONFIG_PATH=/usr/local/lib/pkgconfig,否则 pkg-config 找不到。pkg-config --validate 检查 .pc 语法。

CMake 工程用 find_package 或 pkg_check_modules,把依赖变成 target 的属性:

cmake_minimum_required(VERSION 3.28)
project(hello C)

find_package(PkgConfig REQUIRED)
pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl)

add_executable(hello main.c)
target_link_libraries(hello PRIVATE PkgConfig::CURL)
install(TARGETS hello RUNTIME DESTINATION bin)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
sudo cmake --install build --prefix /usr/local

IMPORTED_TARGET 把 pkg-config 的 Cflags/Libs 变成 PkgConfig::CURL,target_link_libraries 会自动带上头文件路径和链接参数,不用手写 include_directories。cmake --install --prefix 覆盖 CMAKE_INSTALL_PREFIX,等价于 make install DESTDIR 的「换安装根」但语义不同:它改变的是安装前缀,不是暂存目录。

0.11.7 容器镜像:把用户态打包

容器镜像是一组只读层的叠加,每层是一次文件系统变更。Dockerfile 的每条 RUN、COPY、ADD 都会生成一层;层会被缓存、复用、推拉。镜像不含内核,容器与宿主机共享内核——所以「在容器里升级内核」不成立,镜像里的 /etc/os-release 描述的是用户态,uname -r 显示的是宿主机内核。

一个多阶段、非 root 的 Go/C 程序镜像:

FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential ca-certificates \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN make -j"$(nproc)"

FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends libcurl4 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/app /usr/local/bin/app
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/app"]

三个关键点。多阶段:build 阶段带编译器,运行阶段只留运行时库,镜像从几百 MB 降到几十 MB。一次 RUN 里完成 update/install/清理:apt-get update 和 install 分开写会用到过期的索引缓存,rm -rf /var/lib/apt/lists/* 必须和 install 同一层,否则删除只发生在下一层,前一层仍然占体积。非 root:USER 65532:65532 让进程以 nobody 运行,减少容器逃逸后的权限。

构建并检查层:

$ docker build -t hello:1.0 .
 => [build 1/6] FROM docker.io/library/ubuntu:24.04
 => [build 4/6] RUN make -j"$(nproc)"
 => exporting to image
 => => naming to docker.io/library/hello:1.0
$ docker image ls hello
REPOSITORY   TAG   IMAGE ID       CREATED         SIZE
hello        1.0   9f2c1a7b4e10   5 seconds ago   78.4MB
$ docker history hello:1.0 | head -5
IMAGE          CREATED         CREATED BY                                      SIZE
9f2c1a7b4e10   5 seconds ago   ENTRYPOINT ["/usr/local/bin/app"]               0B
<missing>      5 seconds ago   USER 65532:65532                                0B
<missing>      5 seconds ago   COPY /src/app /usr/local/bin/app # buildkit     12.1MB

docker history 里 <missing> 是构建缓存层没有单独 ID 的显示;每层的 SIZE 是这一层新增的字节,不是累计。dive 能交互式看每层文件和浪费空间。镜像变大时先看 docker history,再决定合并哪条 RUN、删哪个构建依赖。

镜像和包管理的关系是「构建时用包管理器,运行时尽量不用」:build 阶段用 apt 装编译器和头文件,运行阶段只装运行时库;更极端的 distroless 镜像连 shell 和 apt 都不装,只留二进制和证书。snap/flatpak 是另一条路:自带运行时、沙箱挂载,和 apt 的依赖模型不同,服务器上通常不用。

镜像的可复现性靠固定基础镜像 digest、固定包版本、固定构建参数。ubuntu:24.04 是浮动 tag,今天和明天可能指向不同 digest;生产构建用 ubuntu:24.04@sha256:...,至少把 digest 记在构建记录里。apt 层不要写 apt upgrade:它让镜像随源变化,破坏可复现性,也可能引入未测试的库版本。

包、库与镜像的六个误解
  1. 「dpkg -i 会自动装依赖」——不会。它只解包和配置,缺依赖会留下未配置状态;用 apt install ./pkg.deb 或事后 apt -f install。
  2. 「apt upgrade 会删包、换内核」——普通 upgrade 不删包;full-upgrade(旧称 dist-upgrade)才会为解依赖而删换。升级前看模拟输出。
  3. 「--prefix 和 DESTDIR 是一回事」——--prefix 决定程序运行时认为自己在哪;DESTDIR 只是把安装结果暂存到另一个根,用于打包。
  4. 「ldd 说 not found 就是没装这个库」——文件可能就在磁盘上,但 soname 链接缺失、不在缓存、RUNPATH 不含该目录;用 readelf -d 和 ldconfig -p 分开查。
  5. 「LD_LIBRARY_PATH 是部署动态库的推荐方式」——它污染子进程、对 setuid 程序无效、易被劫持;生产用 RUNPATH、ldconfig 或标准路径。
  6. 「镜像里跑 apt upgrade 更安全」——它破坏可复现性,可能引入未测试的库;正确做法是重建基础镜像、固定 digest 和版本。

0.11.8 dpkg 状态机、维护者脚本与文件所有权

dpkg -l 的第一列不是「装没装」,而是三个字符:期望状态、当前状态、错误标志。读错这一列,就会把「已卸载但残留配置」当成「已安装」。

缩写期望当前含义常见原因
iiinstallinstalled已安装并配置完成正常
iUinstallunpacked已解包,未配置依赖缺失、postinst 失败
iFinstallhalf-configured配置到一半失败脚本报错、磁盘满
iHinstallhalf-installed解包到一半失败中断、空间不足
rcremoveconfig-files已删文件,保留配置apt remove 而非 purge
ununknownnot-installed从未安装查询结果
hFholdhalf-configured被 hold 且配置失败需先解 hold 再修

dpkg-query 的格式串比 dpkg -l 更适合脚本:

dpkg-query -W -f='${db:Status-Abbrev}\t${Package}\t${Version}\t${Architecture}\n' |
    awk -F'\t' '$1 !~ /^ii/ {print}'

${db:Status-Abbrev} 输出 ii 、rc 这样的三字符缩写;${Status} 输出完整英文(install ok installed)。查询已安装包的版本用 dpkg-query -W -f='${Version}\n' bash,不要用 dpkg -l | grep,后者会把描述行也匹配进来。

dpkg --audit 只报告「需要人工处理」的包,不报告 rc。真正的修复顺序是:

sudo dpkg --audit                 # 看半安装/半配置的包
sudo dpkg --configure -a          # 重跑所有未完成的 postinst
sudo apt -f install               # 让 apt 补齐依赖后再配置
sudo dpkg --remove --force-remove-reinstreq pkg   # 最后手段

--force-remove-reinstreq 会让包进入「已删但文件可能残留」的状态,之后必须手动清理 /var/lib/dpkg/info/pkg.* 与残留文件。它解决的是「包管理器自己卡住」,不是「软件坏了」。

维护者脚本是 .deb 里的可执行文件,dpkg 在四个时刻调用:

脚本调用时机典型用途失败后果
preinst解包前停服务、备份旧文件包不安装
postinst配置时(解包后)建用户、初始化数据库、systemctl daemon-reload包停在 iF/iU
prerm删除前停服务、注销包删不掉
postrm删除/ purge 后清缓存、删用户残留文件

脚本的退出码会被 dpkg 记录;postinst 返回非 0 不会回滚已解包的文件,只会把包留在半配置状态。所以维护者脚本必须幂等:重复执行不能报错,失败要给出可操作的错误信息。set -e 在维护者脚本里通常是必需的,但要先判断命令是否存在(command -v systemctl),否则在 chroot/容器里会直接失败。

配置文件(conffile)有特殊待遇:dpkg 记录它们的校验和,升级时如果本地改过、包里的版本也变了,会交互询问保留哪一份。非交互环境(DEBIAN_FRONTEND=noninteractive)必须指定策略:

sudo dpkg --force-confold -i pkg.deb    # 保留本地旧配置
sudo dpkg --force-confnew -i pkg.deb    # 用包里的新配置
sudo dpkg --force-confdef -i pkg.deb    # 没改过就用新的,改过就问

--force-confold 是自动化升级最安全的选择:不会覆盖运维改过的配置,但新版本需要的配置项可能缺失,升级后要人工 diff。ucf 是另一种配置管理工具,Debian 系一些包用它而非原生 conffile。

dpkg -V pkg 用 .md5sums 校验包内文件是否被改动;debsums -c 批量检查。它只校验包自带的文件,不校验你后来创建的文件,也不校验维护者脚本(脚本不在 md5sums 里)。

文件所有权冲突用 dpkg -S 查,两个包都想装同一个路径时,dpkg 会报 trying to overwrite ... which is also in package ...。正规修法是让其中一个包声明 Replaces/Breaks,或者用 dpkg-divert 把文件挪走:

sudo dpkg-divert --add --rename --divert /usr/bin/foo.orig /usr/bin/foo
sudo dpkg-divert --list | grep foo
sudo dpkg-divert --remove --rename /usr/bin/foo

update-alternatives 解决的是另一类冲突:多个包提供同一功能(/usr/bin/editor、/usr/bin/cc),但允许共存、按优先级切换。

sudo update-alternatives --install /usr/bin/editor editor /usr/bin/vim 50
sudo update-alternatives --install /usr/bin/editor editor /usr/bin/nano 30
sudo update-alternatives --config editor
update-alternatives --query editor

优先级只决定自动模式下的默认值,不决定「哪个更好」;--config 写入的是管理员选择,之后新装的更高优先级包不会自动抢走,除非切回 auto 模式。

0.11.9 apt 的版本、pin 与求解器

apt 的版本号不是字符串排序,而是 [epoch:]upstream[-revision] 的分段比较。1.0~rc1 小于 1.0,1:1.0 大于 2.0(epoch 优先),1.0-1ubuntu1 大于 1.0-1。比较用 dpkg --compare-versions,不要自己写 sort -V:

dpkg --compare-versions 1.0~rc1 lt 1.0 && echo "rc before release"
dpkg --compare-versions 1:1.0 gt 2.0 && echo "epoch wins"

~ 的语义是「比空串还小」,专门给预发布版用;写成 1.0-rc1 会大于 1.0,升级顺序就反了。Debian revision 里的 ubuntu1、+deb12u1 也是版本的一部分,别把它们当注释。

apt-cache policy 是判断「这个版本从哪来、为什么是候选」的唯一入口:

apt-cache policy bash
# bash:
#   Installed: 5.2.21-2ubuntu4
#   Candidate: 5.2.21-2ubuntu4
#   Version table:
#  *** 5.2.21-2ubuntu4 500
#         500 http://archive.ubuntu.com/ubuntu noble/main amd64 Packages
#         100 /var/lib/dpkg/status

500 是 pin 优先级。规则只有几条,但必须记准:

优先级含义效果
≥ 1000指定版本允许降级到该版本
990设置了 target release 时,属于该 release 的版本Ubuntu 默认没有 target release,不会出现 990
500普通仓库默认候选
100已安装但不在任何源不会被升级
0无不安装
< 0禁止永不安装

pin 写在 /etc/apt/preferences.d/*.pref:

# 只从 noble-updates 取 nginx,其余源降级
Package: nginx
Pin: release a=noble-updates
Pin-Priority: 900

# 禁止安装 experiment 源的所有包
Package: *
Pin: release a=experimental
Pin-Priority: -1

Pin: release a= 匹配 Suite/Archive,Pin: origin 匹配主机名,Pin: version 匹配版本通配。写错 suite 名不会报错,只会让规则不生效;改完用 apt-cache policy pkg 验证候选版本。

Ubuntu 24.04 默认启用 phased updates:同一个版本按机器哈希分批推送,apt list --upgradable 可能显示「被 phasing 挡住」。apt-cache policy 会给出 Phased-Update-Percentage;急着升级可以临时绕过:

sudo apt-get -o APT::Get::Always-Include-Phased-Updates=true upgrade

这不是故障,是降低大面积回归风险的分批发布。服务器批量升级时应该尊重 phasing,而不是全部绕过。

apt upgrade 与 apt full-upgrade 的差别只有一条:后者允许为了解依赖而删除或新增包。升级前先模拟:

apt-get -s upgrade | less
apt-get -s full-upgrade | grep -E '^(Remv|Inst|Conf)'
apt-get --no-install-recommends install pkg

-s(simulate)不会改系统;--no-install-recommends 只装 Depends,不装 Recommends,能显著减少服务器上的无关包,但也可能让某些功能缺失。apt-mark hold pkg 把包钉在當前版本,apt-mark unhold 解除,apt-mark showhold 查看;hold 的包会阻止升级,但不会阻止删除。

依赖求解失败时,先看 apt 的求解器日志,而不是盲目加 --fix-broken:

sudo apt-get -o Debug::pkgProblemResolver=yes install pkg 2>&1 | head -40
apt-cache rdepends --installed pkg        # 谁依赖它
aptitude why pkg                          # 为什么被装上(需 aptitude)

apt autoremove 删除的是「标记为 auto 且不再被依赖」的包。手动 apt install 的包是 manual,不会被自动删;但用 apt install 装依赖时,依赖是 auto。把某个 auto 包改为 manual 用 apt-mark manual pkg,反过来用 auto。在生产机上跑 autoremove 前,务必看模拟输出,尤其是内核与驱动包。

0.11.10 符号版本、ABI 与 patchelf

readelf -d 能看出「要哪个 soname」,但看不出「要哪个版本的符号」。glibc 用符号版本(symbol versioning)让同一个 .so 同时提供多个 ABI:

objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep ' GLIBC_2.38'
readelf -V ./app | grep -A3 'Version needs'

程序里记录的是 GLIBC_2.38 这样的版本需求,不是函数名。把在 Ubuntu 24.04 上编译的二进制拷到 glibc 2.35 的机器上,会报:

./app: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found (required by ./app)

这不是「缺 libc」,是「libc 太旧」。三条路:在目标发行版上重新编译;用旧版工具链/容器构建;静态链接 glibc(不推荐,NSS、dlopen、locale 都会出问题)。musl 静态链接可以做到单文件,但 DNS 解析与 getaddrinfo 的行为和 glibc 不同。

ABI 兼容的规则很具体:

改动是否破坏 ABI是否要升 soname
新增函数否否
新增结构体字段(末尾)否(调用方分配的结构体可能不够大)通常否,但要小心
改变函数签名/返回类型是是
删除导出符号是是
改变枚举值/常量是是
只改内部实现否否

soname 是 ABI 的契约:libfoo.so.1 承诺「1.x 之间二进制兼容」,不兼容时升到 libfoo.so.2,两个版本可以共存。只改文件名不升 soname,是自建库最常见的事故:程序仍记录 libfoo.so.1,loader 找到同名的新库,启动时报 undefined symbol 或行为异常。

链接时决定 RUNPATH 还是 RPATH:

# 现代 binutils 默认生成 RUNPATH(DT_RUNPATH)
cc main.c -L. -lfoo -Wl,-rpath,'$ORIGIN/../lib' -o app
readelf -d app | grep -E 'RPATH|RUNPATH'

# 强制旧式 RPATH(会被 LD_LIBRARY_PATH 覆盖,通常不想要)
cc main.c -L. -lfoo -Wl,-rpath,'$ORIGIN/../lib' -Wl,--disable-new-dtags -o app

$ORIGIN 在运行时展开为可执行文件所在目录,适合「解压即用」的绿色发布。它不支持 ~,也不能引用环境变量;路径里有空格要用引号包住整个 -Wl 参数。patchelf 可以在不重新编译的情况下改 RUNPATH 或 soname:

patchelf --set-rpath '$ORIGIN/lib' ./app
patchelf --set-soname libfoo.so.2 ./libfoo.so.2.0
patchelf --print-rpath ./app

patchelf 会重写 ELF 的 dynamic 段,可能破坏签名或调试信息;生产发布前要在目标系统上跑一遍 ldd 与功能测试。

dlopen 是运行时加载的另一条路,和启动时链接的规则不同:

void *h = dlopen("libplugin.so", RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
void (*fn)(void) = (void (*)(void))dlsym(h, "plugin_init");
if (!fn) { fprintf(stderr, "%s\n", dlerror()); dlclose(h); return 1; }
fn();
dlclose(h);

dlopen 的搜索路径同样受 RUNPATH/LD_LIBRARY_PATH/ld.so.cache 影响;RTLD_LOCAL 让符号不被后续库看到,RTLD_GLOBAL 则会污染全局符号表。插件系统要用 RTLD_LOCAL + 显式 dlsym,避免不同插件同名符号互相覆盖。glibc 2.34 起 libdl 合并进 libc,-ldl 仍可用但不再是必需。

0.11.11 三条包与库的巡检脚本

0.11.11.1 包状态巡检

#!/usr/bin/env bash
# pkg-audit.sh —— 只读:异常状态、hold、可升级、安全更新、最近事务
set -euo pipefail
export LC_ALL=C
bad=0

echo "== dpkg audit"
if ! out=$(dpkg --audit); then bad=1; fi
[[ -n $out ]] && { echo "$out"; bad=1; }

echo "== not fully installed"
dpkg-query -W -f='${db:Status-Abbrev}\t${Package}\t${Version}\n' |
    awk -F'\t' '$1 !~ /^ii/ {print; n++} END {exit n?1:0}' || bad=1

echo "== holds"
apt-mark showhold || true

echo "== upgradable"
apt list --upgradable 2>/dev/null | sed -n '2,$p' || true

echo "== security"
apt list --upgradable 2>/dev/null | grep -i security || echo "none"

echo "== foreign arch"
dpkg --print-foreign-architectures || true

echo "== last apt transactions"
tail -n 12 /var/log/apt/history.log 2>/dev/null || true

exit "$bad"

dpkg-query 的 Status-Abbrev 末尾带空格,awk -F'\t' 后 $1 是 ii ,所以正则用 ^ii 而不是 == "ii"。apt list 的输出第一行是 Listing...,用 sed -n '2,$p' 去掉;它的格式不稳定,不适合被其他程序解析,只适合人看。

0.11.11.2 二进制依赖清单

#!/usr/bin/env bash
# link-map.sh BINARY —— 只读:解释器、NEEDED、RUNPATH、解析结果
set -euo pipefail
bin=${1:?usage: link-map.sh BINARY}
[[ -f $bin ]] || { echo "no such file: $bin" >&2; exit 2; }

echo "== file"; file "$bin"
echo "== interpreter"
readelf -l "$bin" | awk '/interpreter/ {print $NF}' | tr -d ']'
echo "== dynamic"
readelf -d "$bin" | grep -E 'NEEDED|RPATH|RUNPATH|SONAME' || true

echo "== resolved (ldd; do NOT run on untrusted binaries)"
ldd "$bin" 2>&1 || true
echo "== missing"
ldd "$bin" 2>&1 | grep 'not found' || echo "none"

echo "== cache hits"
while read -r so; do
    ldconfig -p | grep -F "$so" | head -1 || echo "MISS $so"
done < <(readelf -d "$bin" | awk '/NEEDED/ {gsub(/[\[\]]/,"",$NF); print $NF}')

readelf -l 的 interpreter 行格式是 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2],awk '{print $NF}' 取到带 ] 的路径,tr -d ']' 去掉。ldd 会执行 loader,不能对不可信二进制用;脚本里只是演示,生产巡检应改用 objdump -p。

0.11.11.3 最小 .deb 构建

#!/usr/bin/env bash
# mkdeb.sh NAME VERSION BINARY —— 生成一个只含单个可执行文件的 deb
set -euo pipefail
name=${1:?name}; ver=${2:?version}; bin=${3:?binary}
[[ -x $bin ]] || { echo "not executable: $bin" >&2; exit 2; }

root=$(mktemp -d); trap 'rm -rf "$root"' EXIT
install -Dm755 "$bin" "$root/usr/local/bin/$name"
mkdir -p "$root/DEBIAN"
size=$(du -sk "$root/usr" | cut -f1)

cat >"$root/DEBIAN/control" <<EOF
Package: $name
Version: $ver
Section: utils
Priority: optional
Architecture: $(dpkg --print-architecture)
Installed-Size: $size
Maintainer: Lab <lab@example.invalid>
Description: minimal test package
 Built by mkdeb.sh for the tutorial.
EOF

out="$PWD/${name}_${ver}_$(dpkg --print-architecture).deb"
dpkg-deb --build --root-owner-group "$root" "$out"
dpkg-deb -I "$out"
dpkg-deb -c "$out"

--root-owner-group 让包内文件属主固定为 root:root,不需要 fakeroot;没有它,dpkg-deb -c 会显示构建者的 UID/GID,安装后文件属主也可能不对。Installed-Size 单位是 KiB,du -sk 正好匹配。

0.11.12 两个实验:自建 deb 与 soname 断裂

实验一:构建、安装、校验、卸载一个 deb

lab=/var/tmp/deblab; mkdir -p "$lab"; cd "$lab"
cat > hello.c <<'EOF'
#include <stdio.h>
int main(void) { puts("hello from deb"); return 0; }
EOF
cc -O2 -Wall -Wextra -o hello hello.c
bash /path/to/mkdeb.sh hello 1.0.0 ./hello
sudo dpkg -i hello_1.0.0_*.deb
hello
dpkg -L hello
dpkg -V hello; echo "verify rc=$?"
sudo dpkg -r hello
dpkg -l hello | tail -1        # 期望 rc(配置残留)
sudo dpkg -P hello             # purge

预期:hello 输出 hello from deb;dpkg -L 只列出 /usr/local/bin/hello;dpkg -V 无输出且返回 0;dpkg -r 后状态是 rc,dpkg -P 后是 un。

再验证 conffile 行为:在 DEBIAN/ 下加 conffiles,内容为 /etc/hello.conf,并在 $root/etc/hello.conf 放一个默认配置。安装后修改 /etc/hello.conf,把版本改成 1.0.1 重新构建并 dpkg -i,会看到交互提示。非交互环境用 --force-confold 保留本地版本。

实验二:soname 断裂与 ABI 升级

lab=/var/tmp/sonamelab; mkdir -p "$lab"; cd "$lab"
cat > foo.c <<'EOF'
int foo_v1(void) { return 1; }
EOF
cat > main.c <<'EOF'
#include <stdio.h>
int foo_v1(void);
int main(void) { printf("foo=%d\n", foo_v1()); return 0; }
EOF
cc -fPIC -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0 foo.c
ln -sf libfoo.so.1.0 libfoo.so.1
ln -sf libfoo.so.1 libfoo.so
cc main.c -L. -lfoo -Wl,-rpath,'$ORIGIN' -o app
./app
readelf -d app | grep NEEDED

# 不兼容的“升级”:同 soname,删掉旧符号
cat > foo.c <<'EOF'
int foo_v2(void) { return 2; }
EOF
cc -fPIC -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0.new foo.c
mv libfoo.so.1.0.new libfoo.so.1.0
./app; echo "rc=$?"          # 预期 undefined symbol: foo_v1

预期:第一次 ./app 输出 foo=1,NEEDED 是 libfoo.so.1;替换后启动失败,报 undefined symbol: foo_v1。这不是 loader 找不到库,而是库找到了但 ABI 断了。

正确做法是升 soname 并共存:

cc -fPIC -shared -Wl,-soname,libfoo.so.2 -o libfoo.so.2.0 foo.c
ln -sf libfoo.so.2.0 libfoo.so.2
readelf -d libfoo.so.2.0 | grep SONAME
# 旧 app 仍记录 libfoo.so.1,需要保留旧库或重新链接

readelf -d app | grep NEEDED 是判断「升级会不会影响现有程序」的第一条证据;只改库文件名、不改 soname,等于向所有调用方承诺了一个没有兑现的兼容性。

0.11.13 加载器审计:pkg-config、ldd、readelf 对账

0.11.5 讲了两类报错,0.11.11.2 的 link-map.sh 只做静态清单。这一节把三样东西放在同一张桌上:pkg-config 给出的编译参数、ldd 给出的运行时解析结果、readelf 记录的 ELF 事实。三者对不上时,先信 readelf,再信 ldd,最后才信 pkg-config——因为前两个是 loader 的事实,后一个是构建系统的约定。

实验三:从 .pc 到 ldd,一条依赖链的四个证据

目的:手写一个带 soname 的库和 .pc 文件,用 pkg-config 编译、用 readelf 看记录、用 ldd 看解析,最后故意删掉 soname 链接复现 not found。

sudo apt install -y build-essential pkg-config
lab=/var/tmp/pc-lab; rm -rf "$lab"; mkdir -p "$lab"/{src,stage/lib/pkgconfig,stage/include}
cd "$lab"
cat >src/foo.h <<'EOF'
int foo_version(void);
EOF
cat >src/foo.c <<'EOF'
int foo_version(void) { return 42; }
EOF
cat >src/main.c <<'EOF'
#include <stdio.h>
#include "foo.h"
int main(void) { printf("foo=%d\n", foo_version()); return 0; }
EOF

cc -std=c17 -O2 -fPIC -shared -Wl,-soname,libfoo.so.1 \
   -o stage/lib/libfoo.so.1.0 src/foo.c
ln -sf libfoo.so.1.0 stage/lib/libfoo.so.1
ln -sf libfoo.so.1   stage/lib/libfoo.so
cp src/foo.h stage/include/

cat >stage/lib/pkgconfig/foo.pc <<EOF
prefix=$lab/stage
exec_prefix=\${prefix}
libdir=\${exec_prefix}/lib
includedir=\${prefix}/include

Name: foo
Description: lab library for loader audit
Version: 1.0.0
Libs: -L\${libdir} -lfoo
Cflags: -I\${includedir}
EOF

export PKG_CONFIG_PATH="$lab/stage/lib/pkgconfig"
pkg-config --modversion foo
pkg-config --cflags --libs foo
pkg-config --variable=libdir foo
pkg-config --validate foo && echo "pc syntax ok"

cc -std=c17 -O2 src/main.c $(pkg-config --cflags --libs foo) \
   -Wl,-rpath,'$ORIGIN/stage/lib' -o app
./app

预期:pkg-config --modversion 输出 1.0.0,--cflags --libs 输出 -I/var/tmp/pc-lab/stage/include -L/var/tmp/pc-lab/stage/lib -lfoo,./app 输出 foo=42。--validate 成功时没有输出;失败时返回非 0,并指出缺字段或语法错误。

接着用三条命令核对同一个依赖:

readelf -d app | grep -E 'NEEDED|RPATH|RUNPATH'
readelf -l app | grep interpreter
ldd app | grep -E 'foo|libc'
证据命令回答的问题正常输出
ELF 需求readelf -d app程序记录的是哪个 soname、有没有 RUNPATHNEEDED libfoo.so.1、RUNPATH $ORIGIN/stage/lib
解释器readelf -l app谁来加载它/lib64/ld-linux-x86-64.so.2
运行时解析ldd apploader 实际找到哪个文件libfoo.so.1 => .../stage/lib/libfoo.so.1
符号objdump -T stage/lib/libfoo.so.1.0库里到底导出了什么foo_version 在 .text
包归属dpkg -S $(readlink -f stage/lib/libfoo.so.1.0)这个库由谁安装自建库通常无输出,说明不归 dpkg 管

readelf -d 是「程序要什么」,ldd 是「loader 给什么」。两者之间的差距就是 LD_LIBRARY_PATH、RUNPATH、ld.so.cache 和 soname 链接。现在删掉 soname 链接:

mv stage/lib/libfoo.so.1 stage/lib/libfoo.so.1.bak
./app; echo "rc=$?"
ldd app | grep foo
readelf -d app | grep NEEDED

预期:./app 报 error while loading shared libraries: libfoo.so.1: cannot open shared object file,ldd 显示 libfoo.so.1 => not found,而 readelf -d 仍然写着 NEEDED libfoo.so.1。这证明「文件存在」不等于「soname 存在」:loader 只按 soname 找,libfoo.so.1.0 在不在无关紧要。恢复链接后 ldd 立刻正常。

实验四:用 LD_DEBUG 与 strace 看 loader 的搜索顺序

目的:不背文档,直接看 loader 先开哪个文件、在哪一步放弃、RUNPATH 与缓存谁先谁后。

LD_DEBUG=libs ./app 2>&1 | grep -E 'find library=libfoo|trying file=.*libfoo' | head
strace -f -e trace=openat,openat2 -o /tmp/loader.trace ./app
grep -E 'ld\.so\.cache|libfoo|libc\.so\.6' /tmp/loader.trace | head -12
LD_DEBUG=statistics ./app 2>&1 | tail -8

预期:LD_DEBUG=libs 会打印 find library=libfoo.so.1 [0]; searching,随后是 search path=... 和 trying file=.../libfoo.so.1;strace 里能看到先 openat("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC),再按 RUNPATH/缓存顺序尝试库文件,失败的行是 ENOENT,属于正常搜索过程。LD_DEBUG=statistics 给出重定位次数与启动耗时。

LD_DEBUG 的输出全在 stderr,必须 2>&1;可选类别用 LD_DEBUG=help 列出,常用的有 libs、bindings、statistics、symbols、versions。bindings 输出极大,先 grep 再 head。

变量作用边界
LD_LIBRARY_PATH在缓存之前追加搜索目录污染子进程;setuid 程序忽略
LD_PRELOAD先加载指定库,可覆盖符号setuid 程序忽略;调试/劫持都用它
LD_DEBUG打印 loader 的搜索/绑定/统计只对动态链接程序有效;输出在 stderr
LD_BIND_NOW启动时解析全部符号等价于链接时 -z now;启动变慢、故障提前暴露
LD_ORIGIN_PATH告诉 loader 可执行文件在磁盘上的路径,供 $ORIGIN 展开现代系统一般不需要

strace 看到的 openat 顺序是最终事实,但它会拖慢启动;线上排障优先 LD_DEBUG=libs 的定向输出。对不可信二进制不要跑 ldd,它可能执行 loader;用 readelf -d + objdump -p 代替。

0.11.14 从源码到 deb:debian/ 目录与依赖闭合

0.11.11.3 的 mkdeb.sh 只打一个二进制;真实发行版包还要解决三件事:文件按 FHS 装到 /usr、依赖由工具从 ELF 里算出来、构建过程可重复。debian/ 目录就是这三件事的声明。

文件必需作用常见错误
debian/control是源码包与二进制包的元数据、Build-Depends、Depends漏 ${shlibs:Depends}
debian/changelog是版本、发行版、变更记录;dpkg-buildpackage 从这取版本日期格式错、版本不以数字开头
debian/rules是构建脚本,通常用 dh 序列没有执行权限
debian/copyright是许可证与版权缺字段被 lintian 报
debian/source/format是3.0 (quilt) 或 3.0 (native)与上游 tarball 结构不匹配
debian/install否把构建产物装到包内路径路径写错导致空包
debian/*.install否多二进制包各自的文件清单通配符匹配到构建目录
debian/patches/否quilt 补丁队列补丁未在 series 里登记

第一次可以用 dh_make --native --single 生成骨架,再改 control、rules 和 changelog;不要直接把 dh_make 生成的模板提交进仓库而不改 Maintainer 与 Description。

一个最小的 debian/control:

Source: hello
Section: utils
Priority: optional
Maintainer: Lab <lab@example.invalid>
Build-Depends: debhelper-compat (= 13), gcc, make
Standards-Version: 4.6.2
Rules-Requires-Root: no

Package: hello
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: minimal hello package
 A one-line summary followed by a longer description.

${shlibs:Depends} 不是手写的,是 dpkg-shlibdeps 扫二进制里的 NEEDED 后生成的。少了它,包能装、能构建,但在没装 libc6 的 chroot 里一跑就报 cannot open shared object file。${misc:Depends} 收 debhelper 生成的杂项依赖。

debian/rules 用 dh 序列,最小版本只有三行:

#!/usr/bin/make -f
%:
	dh $@

如果项目用 configure,用 override_dh_auto_configure 传 prefix;如果只有 Makefile,覆盖 build/install:

override_dh_auto_build:
	$(MAKE) CFLAGS="-O2 -g"

override_dh_auto_install:
	$(MAKE) install DESTDIR=$(CURDIR)/debian/hello PREFIX=/usr

DESTDIR 把文件装到 debian/hello/usr/...,这是打包的暂存根;程序运行时看到的仍是 PREFIX=/usr。这与 0.11.3 的 --prefix/DESTDIR 是同一组概念,只是在 debian/rules 里由 dh_auto_install 自动传入。

debian/changelog 的格式不能错,第一行决定包版本:

hello (1.0.0-1) noble; urgency=medium

  * Initial release.

 -- Lab <lab@example.invalid>  Mon, 02 Mar 2026 10:00:00 +0000

构建与检查:

sudo apt install -y build-essential debhelper devscripts lintian
mkdir -p debian/source && printf '3.0 (native)\n' > debian/source/format
dpkg-buildpackage -us -uc -b          # 不签名,只构建二进制包
lintian ../hello_1.0.0-1_amd64.changes
dpkg-deb -I ../hello_1.0.0-1_amd64.deb
dpkg-deb -c ../hello_1.0.0-1_amd64.deb | head

-us -uc 跳过签名,-b 只构建二进制包(不做源码包)。dpkg-buildpackage 会先跑 debian/rules clean,再在 debian/hello/ 里 staged 安装,最后用 dpkg-deb 打包。lintian 的 E: 是必须修的,W: 要逐条判断;最常见的 W 是缺 manpage、库缺 soname 链接、包名与 soname 不匹配。

依赖闭合的核心命令是 dpkg-shlibdeps,dh_shlibdeps 会自动调用它:

dpkg-shlibdeps -O debian/hello/usr/bin/hello
# shlibs:Depends=libc6 (>= 2.34)

它做三件事:读二进制的 NEEDED、在 dpkg 数据库里找提供该 soname 的包、把最小版本写进 shlibs:Depends。如果库是自建的、没有对应的 deb,会报:

dpkg-shlibdeps: error: no dependency information found for .../libfoo.so.1

正确修法是把库也打成包,并在 debian/libfoo1.shlibs 里声明 libfoo 1 libfoo1 (>= 1.0);用 --ignore-missing-info 只是把错误藏起来,装到干净机器上仍然缺库。

多架构路径要用 dpkg-architecture 算,不要写死:

multiarch=$(dpkg-architecture -qDEB_HOST_MULTIARCH)
install -Dm644 libfoo.so.1.0 "debian/libfoo1/usr/lib/$multiarch/libfoo.so.1.0"

库装到 /usr/lib 而不是 /usr/lib/x86_64-linux-gnu,在 multiarch 系统上会和 ldconfig 的搜索路径错位;lintian 会报 sharedobject-in-library-directory-missing-soname 或 package-installs-into-usr-local。

可复现构建至少要固定三样:SOURCE_DATE_EPOCH(dpkg-buildpackage 从 changelog 推导)、编译器路径映射(-ffile-prefix-map=$(CURDIR)=.)、以及不把构建主机的绝对路径写进二进制。DEB_BUILD_OPTIONS=nostrip 只用于调试,不要带进发布包。

脚本:elf-audit.sh 做一次依赖闭合审计

#!/usr/bin/env bash
# elf-audit.sh BIN... —— 只读:解释器、NEEDED、RUNPATH、未解析库、GLIBC 版本
set -euo pipefail
(( $# > 0 )) || { echo "usage: elf-audit.sh BIN..." >&2; exit 2; }

for bin in "$@"; do
    [[ -f $bin ]] || { echo "skip: $bin" >&2; continue; }
    echo "== $bin"
    file -b "$bin"
    printf 'interp: '
    readelf -l "$bin" | awk '/interpreter/ {print $NF}' | tr -d ']'
    echo "-- NEEDED/RUNPATH"
    readelf -d "$bin" | grep -E 'NEEDED|RPATH|RUNPATH' || true
    echo "-- unresolved"
    ldd "$bin" 2>&1 | grep 'not found' || echo none
    echo "-- glibc versions"
    objdump -T "$bin" 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -3 || true
    echo "-- owners"
    while read -r so; do
        path=$(ldd "$bin" 2>/dev/null | awk -v s="$so" '$1 == s {print $3}')
        if [[ -n ${path:-} && $path != not ]]; then
            dpkg -S "$(readlink -f "$path")" 2>/dev/null || echo "UNPACKAGED $path"
        fi
    done < <(readelf -d "$bin" | awk '/NEEDED/ {gsub(/[\[\]]/, "", $NF); print $NF}')
done

它把「要什么、找到什么、谁提供的、要哪个 GLIBC 版本」一次打出来。输出里出现 UNPACKAGED 就说明这个库不归包管理器管,升级/迁移时会丢;出现 not found 则是路径或 soname 问题,按 0.11.5 的顺序查。

打包链路的八个坑

#现象原因修复/判断
1dpkg-buildpackage: debian/rules ... Permission deniedrules 没有执行权限chmod +x debian/rules
2包能装,运行缺库control 里漏了 ${shlibs:Depends}加字段,确认 dh_shlibdeps 跑过
3dpkg-shlibdeps: no dependency information自建库没打包,或没有 .shlibs打包库并写 debian/libfoo1.shlibs,别用 --ignore-missing-info
4dh_make 后构建报 compat 冲突同时有 debian/compat 与 debhelper-compat二选一,现代包用 Build-Depends: debhelper-compat (= 13)
5版本号解析失败changelog 版本不以数字开头,或日期格式错用 1.0.0-1,日期写 Mon, 02 Mar 2026 10:00:00 +0000
6lintian 报 package-installs-into-usr-localPREFIX/--prefix 用了 /usr/local发行版包装 /usr;/usr/local 留给本地源码安装
7库在 /usr/lib 但运行找不到多架构目录应为 /usr/lib/<triplet>用 dpkg-architecture -qDEB_HOST_MULTIARCH
8安装后 pkg-config 指向构建目录.pc 的 prefix 写死为临时路径用 --define-variable=prefix= 或安装时替换

第 6 条和第 7 条经常一起出现:源码项目默认 --prefix=/usr/local,打包时忘了覆盖,结果文件全进了 /usr/local,lintian 报错、卸载也卸不干净。debian/rules 里显式写 --prefix=/usr,并在 debian/*.install 里只列 /usr 下的路径,能避免大部分这类问题。

0.11.15 十三个包、库与构建陷阱

#现象原因修复/判断
1dpkg -i 后包停在 iU依赖未满足或 postinst 失败apt -f install;看 /var/log/dpkg.log
2apt install 报「没有候选版本」源里没有该包/架构,或 pin 为 -1apt-cache policy pkg、检查 sources
3包一直不升级apt-mark hold 或 phasingapt-mark showhold、apt-cache policy
4第三方源 GPG 报错缺 signed-by、key 过期、时间不对装 keyring 到 /etc/apt/keyrings,校对时间
5混用 Debian/Ubuntu 源后依赖爆炸libc/ABI 不同删除混来源,按发行版重装
6make install 覆盖了包管理器文件装到 /usr 而非 /usr/local用 --prefix=/usr/local,记录 manifest
7ldconfig 后仍找不到库目录不在 ld.so.conf、soname 缺失ldconfig -p、readelf -d
8LD_LIBRARY_PATH 对 setuid 无效secure-execution 模式忽略用 RUNPATH 或系统路径
9升级库后程序 undefined symbol同 soname 但 ABI 断了升 soname、保留旧库、重编
10ldd 卡住或执行了程序ldd 会调用 loader不可信二进制用 readelf -d/objdump -p
11apt autoremove 删掉了要用的包它被标记为 auto 且无反向依赖先 -s 模拟,必要时 apt-mark manual
12容器里 apt update 404基础镜像 EOL,源已下架换 old-releases 或升级基础镜像
13自建 deb 安装后属主是 UID 1000构建时没固定属主dpkg-deb --root-owner-group

第 9 条与第 7 条容易混:前者是「库找到了,符号不对」,后者是「库根本没找到」。ldd 的输出能区分:not found 是路径问题;路径正常但启动报 undefined symbol 是 ABI 问题。先看 ldd 有没有 not found,再看 nm -D,顺序不要反。

0.11.16 本章出口检查

检查项达标线没达标去哪
拆开 .deb 看控制信息和文件清单会用 dpkg-deb -I/-c0.11.1
解释 dpkg -i 与 apt install ./x.deb 的差别说出依赖解析0.11.1
用 apt-cache policy 判断版本来源能读出 pin 优先级0.11.2
从源码构建并 staged 安装区分 --prefix 与 DESTDIR0.11.3
说出 soname 的作用能解释 .so.1 符号链接0.11.4
按顺序排查两类动态库错误先 file/readelf,再 nm/LD_DEBUG0.11.5
用 CMake + pkg-config 构建能生成并安装 target0.11.6
写多阶段、非 root 镜像层里不留 apt 索引和编译器0.11.7
用 pkg-config 生成参数并与手工参数对账能解释 .pc 的 prefix/libdir0.11.13
用 LD_DEBUG/strace 追 loader 顺序能说出 RUNPATH 与 ld.so.cache 的先后0.11.13
用 debian/ 构建一个 deb能解释 ${shlibs:Depends} 从哪来0.11.14
即时自测 · 0.11

Q1 dpkg -i app.deb 报 dependency problems prevent configuration,app 处于 iU 状态。怎么修?为什么不建议直接 dpkg -i 重试?

Q2 程序在开发机跑得好,拷到服务器报 libfoo.so.1: cannot open shared object file,但服务器上 find / -name 'libfoo.so*' 能找到 libfoo.so.1.2.3。最可能缺什么?

Q3 ./configure --prefix=/opt/app && make && sudo make install 之后,程序启动报 error while loading shared libraries: libbar.so.1。libbar 是同一个项目刚装到 /opt/app/lib 的。列出两种修法及各自代价。

Q4 Dockerfile 里 RUN apt-get update 和 RUN apt-get install -y curl 分成两层,构建经常用到旧索引。为什么?怎么改?

答案

A1 iU 表示已解包但未配置,缺依赖。先 sudo apt -f install 让 apt 从源里补齐依赖并配置;离线环境用 dpkg -i 按依赖顺序装齐,或 apt install ./app.deb。直接重试 dpkg -i 不会解决依赖,只会重复留下未配置状态。

A2 缺 soname 符号链接 libfoo.so.1。loader 按 soname 找,不按真实文件名找;ln -s libfoo.so.1.2.3 libfoo.so.1 后 ldconfig 或把目录加进搜索路径。也可能是架构不匹配或 RUNPATH 不含该目录,先用 file 和 readelf -d 排除。

A3 ① 把 /opt/app/lib 加进 /etc/ld.so.conf.d/app.conf 并 ldconfig,代价是全局可见、可能与其他版本冲突;② 链接时加 -Wl,-rpath,/opt/app/lib(或 $ORIGIN/../lib),代价是路径写死、迁移目录要重链接;③ 用 LD_LIBRARY_PATH,只适合临时调试,不适合部署。

A4 每条 RUN 是独立层,apt-get update 的索引留在第一层;后续层或缓存复用时索引可能已过期,而 install 不会自动重新 update。把 update、install、rm -rf /var/lib/apt/lists/* 放进同一条 RUN,或用 apt-get update && apt-get install -y --no-install-recommends ... 一行完成。

母本对应

母本章节PDF 页本书落点程度
鸟哥第 21 章 软件安装:源代码与 Tarballp.1010–10460.11.3、0.11.13完整(configure/make/prefix、p.1037–1041 库管理)
鸟哥第 22 章 软件安装 RPM、SRPM 与 YUMp.1047–10940.11.1–0.11.2背景(包管理与依赖,Debian 侧改写)
shell-book第 9 章 安装软件p.183–2040.11.1–0.11.3、0.11.14精选(包管理器、源码安装、tarball)
Shotts第 14 章 软件包管理、第 23 章 编译程序p.158–165;p.298–3090.11.1–0.11.3、0.11.13完整(包、编译流程)
Kerrisk第 41–42 章 共享库基础与高级特性—0.11.4–0.11.5、0.11.13完整(soname、loader、符号解析)
deb(5) / Debian Policydpkg-buildpackage(1)、deb-control(5)man / 在线0.11.14精选(debian/、${shlibs:Depends})
pkg-config(1) / ld.so(8).pc 格式、loader 搜索顺序man0.11.13完整(PKG_CONFIG_PATH、LD_DEBUG)
Docker 文档Multi-stage builds、Build cache在线0.11.7精选(多阶段与层缓存)

下一站:0.12 systemd、启动、日志与定时任务把「装好的程序」变成「开机自动运行、失败自动重启、日志可查、按时执行」的服务;那一章会给一个完整的 unit 和 timer。