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 / dpkg | dependency problems、版本冲突 | apt、dpkg |
| 启动 | 动态链接器 ld.so | cannot open shared object file | ldd、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 file | loader 找不到这个 soname | ldd ./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: ELFCLASS32 | 32/64 位不匹配 | file ./app libfoo.so.1 |
cannot open shared object file 但文件存在 | soname 不对、缓存没更新、RUNPATH 不包含该目录 | readelf -d、ldconfig -p |
排查「找不到库」按这个顺序:
file ./app确认架构,file libfoo.so.1确认库的架构一致;readelf -d ./app | grep -E 'NEEDED|RPATH|RUNPATH'看它要的 soname 和搜索路径;ldd ./app看 loader 实际解析到哪,not found的那一行就是缺口;ldconfig -p | grep libfoo看缓存里有没有;没有就检查/etc/ld.so.conf.d/;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 用。
静态链接时链接器从左到右扫描: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:它让镜像随源变化,破坏可复现性,也可能引入未测试的库版本。
- 「
dpkg -i会自动装依赖」——不会。它只解包和配置,缺依赖会留下未配置状态;用apt install ./pkg.deb或事后apt -f install。 - 「
apt upgrade会删包、换内核」——普通upgrade不删包;full-upgrade(旧称dist-upgrade)才会为解依赖而删换。升级前看模拟输出。 - 「
--prefix和DESTDIR是一回事」——--prefix决定程序运行时认为自己在哪;DESTDIR只是把安装结果暂存到另一个根,用于打包。 - 「
ldd说 not found 就是没装这个库」——文件可能就在磁盘上,但 soname 链接缺失、不在缓存、RUNPATH 不含该目录;用readelf -d和ldconfig -p分开查。 - 「
LD_LIBRARY_PATH是部署动态库的推荐方式」——它污染子进程、对 setuid 程序无效、易被劫持;生产用 RUNPATH、ldconfig 或标准路径。 - 「镜像里跑
apt upgrade更安全」——它破坏可复现性,可能引入未测试的库;正确做法是重建基础镜像、固定 digest 和版本。
0.11.8 dpkg 状态机、维护者脚本与文件所有权
dpkg -l 的第一列不是「装没装」,而是三个字符:期望状态、当前状态、错误标志。读错这一列,就会把「已卸载但残留配置」当成「已安装」。
| 缩写 | 期望 | 当前 | 含义 | 常见原因 |
|---|---|---|---|---|
ii | install | installed | 已安装并配置完成 | 正常 |
iU | install | unpacked | 已解包,未配置 | 依赖缺失、postinst 失败 |
iF | install | half-configured | 配置到一半失败 | 脚本报错、磁盘满 |
iH | install | half-installed | 解包到一半失败 | 中断、空间不足 |
rc | remove | config-files | 已删文件,保留配置 | apt remove 而非 purge |
un | unknown | not-installed | 从未安装 | 查询结果 |
hF | hold | half-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、有没有 RUNPATH | NEEDED libfoo.so.1、RUNPATH $ORIGIN/stage/lib |
| 解释器 | readelf -l app | 谁来加载它 | /lib64/ld-linux-x86-64.so.2 |
| 运行时解析 | ldd app | loader 实际找到哪个文件 | 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 的顺序查。
打包链路的八个坑
| # | 现象 | 原因 | 修复/判断 |
|---|---|---|---|
| 1 | dpkg-buildpackage: debian/rules ... Permission denied | rules 没有执行权限 | chmod +x debian/rules |
| 2 | 包能装,运行缺库 | control 里漏了 ${shlibs:Depends} | 加字段,确认 dh_shlibdeps 跑过 |
| 3 | dpkg-shlibdeps: no dependency information | 自建库没打包,或没有 .shlibs | 打包库并写 debian/libfoo1.shlibs,别用 --ignore-missing-info |
| 4 | dh_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 |
| 6 | lintian 报 package-installs-into-usr-local | PREFIX/--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 十三个包、库与构建陷阱
| # | 现象 | 原因 | 修复/判断 |
|---|---|---|---|
| 1 | dpkg -i 后包停在 iU | 依赖未满足或 postinst 失败 | apt -f install;看 /var/log/dpkg.log |
| 2 | apt install 报「没有候选版本」 | 源里没有该包/架构,或 pin 为 -1 | apt-cache policy pkg、检查 sources |
| 3 | 包一直不升级 | apt-mark hold 或 phasing | apt-mark showhold、apt-cache policy |
| 4 | 第三方源 GPG 报错 | 缺 signed-by、key 过期、时间不对 | 装 keyring 到 /etc/apt/keyrings,校对时间 |
| 5 | 混用 Debian/Ubuntu 源后依赖爆炸 | libc/ABI 不同 | 删除混来源,按发行版重装 |
| 6 | make install 覆盖了包管理器文件 | 装到 /usr 而非 /usr/local | 用 --prefix=/usr/local,记录 manifest |
| 7 | ldconfig 后仍找不到库 | 目录不在 ld.so.conf、soname 缺失 | ldconfig -p、readelf -d |
| 8 | LD_LIBRARY_PATH 对 setuid 无效 | secure-execution 模式忽略 | 用 RUNPATH 或系统路径 |
| 9 | 升级库后程序 undefined symbol | 同 soname 但 ABI 断了 | 升 soname、保留旧库、重编 |
| 10 | ldd 卡住或执行了程序 | ldd 会调用 loader | 不可信二进制用 readelf -d/objdump -p |
| 11 | apt 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/-c | 0.11.1 |
解释 dpkg -i 与 apt install ./x.deb 的差别 | 说出依赖解析 | 0.11.1 |
用 apt-cache policy 判断版本来源 | 能读出 pin 优先级 | 0.11.2 |
| 从源码构建并 staged 安装 | 区分 --prefix 与 DESTDIR | 0.11.3 |
| 说出 soname 的作用 | 能解释 .so.1 符号链接 | 0.11.4 |
| 按顺序排查两类动态库错误 | 先 file/readelf,再 nm/LD_DEBUG | 0.11.5 |
| 用 CMake + pkg-config 构建 | 能生成并安装 target | 0.11.6 |
| 写多阶段、非 root 镜像 | 层里不留 apt 索引和编译器 | 0.11.7 |
用 pkg-config 生成参数并与手工参数对账 | 能解释 .pc 的 prefix/libdir | 0.11.13 |
用 LD_DEBUG/strace 追 loader 顺序 | 能说出 RUNPATH 与 ld.so.cache 的先后 | 0.11.13 |
用 debian/ 构建一个 deb | 能解释 ${shlibs:Depends} 从哪来 | 0.11.14 |
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 章 软件安装:源代码与 Tarball | p.1010–1046 | 0.11.3、0.11.13 | 完整(configure/make/prefix、p.1037–1041 库管理) |
| 鸟哥 | 第 22 章 软件安装 RPM、SRPM 与 YUM | p.1047–1094 | 0.11.1–0.11.2 | 背景(包管理与依赖,Debian 侧改写) |
| shell-book | 第 9 章 安装软件 | p.183–204 | 0.11.1–0.11.3、0.11.14 | 精选(包管理器、源码安装、tarball) |
| Shotts | 第 14 章 软件包管理、第 23 章 编译程序 | p.158–165;p.298–309 | 0.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 Policy | dpkg-buildpackage(1)、deb-control(5) | man / 在线 | 0.11.14 | 精选(debian/、${shlibs:Depends}) |
pkg-config(1) / ld.so(8) | .pc 格式、loader 搜索顺序 | man | 0.11.13 | 完整(PKG_CONFIG_PATH、LD_DEBUG) |
| Docker 文档 | Multi-stage builds、Build cache | 在线 | 0.11.7 | 精选(多阶段与层缓存) |
下一站:0.12 systemd、启动、日志与定时任务把「装好的程序」变成「开机自动运行、失败自动重启、日志可查、按时执行」的服务;那一章会给一个完整的 unit 和 timer。