1. 从“打不出包”到“包有问题”:先搞清Spec文件在整个RPM链路中的位置
接触RPM打包的人通常分两种:一种是刚入门,照着网上的Hello World抄了一个Spec文件,结果rpmbuild连跑都跑不通;另一种是已经能出包了,但装到别的机器上各种报错——缺依赖、路径不对、配置文件被覆盖,最后只能靠一条条试错来填坑。我属于后者,而且是填坑填了相当长时间的那种。
先说一个很多教程没讲透的基础认知:RPM打包的本质不是“把文件塞进一个压缩包”,而是描述一次完整的产品交付过程。Spec文件不是一份文件清单,它更像一张配方表,里面定义了原料从哪来、怎么加工、成品装到系统的哪个位置、卸载时如何处理。理解不了这一点的人,写出的Spec文件往往能用,但一遇到复杂场景就崩。
那调试RPM打包到底是在调什么?拆开看,其实就四件事:
- 环境问题:rpmbuild工具链是否齐全、宏定义是否被覆盖、依赖包是否在系统里。
- 语法问题:Spec文件的宏展开结果是否符合预期,尤其是%prep、%build、%install脚本段的shell命令是否正确。
- 文件清单问题:%files段列出的文件是否真实存在、路径是否正确、是否会覆盖系统已有文件。
- 运行时问题:构建出的RPM包安装到干净系统后,程序能否正常运行、依赖是否正确声明。
这四个层面对应不同的调试手段。很多人卡住的原因是想用一个办法解决所有问题,比如反复删掉rpmbuild的BUILD目录重试,然而真实的报错原因可能在宏展开阶段就已经埋下了。
我的建议是:先把Spec文件当成一份“能被rpmbuild读懂并逐步执行的脚本”来对待,每一步都有对应的中间产物可以查看和验证。这个思路贯穿全文,后面的章节都是在教你怎么一步一步验证到哪出了问题。
提示:如果你的系统里连
rpmbuild命令都没有,第一步不是写Spec文件,而是先解决工具链。常见发行版对应包名:RHEL/CentOS系列为rpm-build,openSUSE为rpm-build,Debian系则需要借助alien或直接安装rpm与rpmbuild相关包。下面第二节会展开讲环境准备的具体细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备中的暗坑:rpmbuild目录、宏定义与最低工具链
2.1 目录结构不是摆设,它决定构建的隔离性
很多人第一次跑rpmbuild -ba xxx.spec时,系统会直接报错“Directory /root/rpmbuild/BUILDROOT does not exist”之类的问题。原因很简单:默认的rpmbuild工作路径是$HOME/rpmbuild,如果你没有初始化这个目录结构,构建自然没法进行。
标准的目录结构长这样:
code复制~/rpmbuild/
├── BUILD/ # 解压源码并执行编译的临时目录
├── RPMS/ # 当前架构的二进制包输出目录
├── SOURCES/ # 源码包、补丁文件、配置文件等原料
├── SPECS/ # Spec文件存放目录
├── SRPMS/ # 源码包(src.rpm)输出目录
└── BUILDROOT/ # 构建根,模拟“装到系统里”的临时根目录
可以手工创建这一整套目录,也可以直接用rpmdev-setuptree命令一键生成。但这里有一个值得注意的细节:BUILDROOT目录是rpmbuild在构建过程中动态管理的一个根文件系统视图,目的是在打包时不污染宿主系统。很多新手容易把“安装到BUILDROOT”和“安装到本机”搞混,以至于直接在%install段里写死了/usr/local/bin,最后打出来的包装到别的机器上文件位置完全不对。
如果你只是临时想跑一个包,不想初始化完整的目录树,可以用--define参数指定顶层目录:
bash复制rpmbuild -ba --define "_topdir /tmp/mybuild" myspec.spec
这样所有产物都会集中到/tmp/mybuild下面,适合快速验证。但正式的项目还是建议用标准目录树,因为mock、CI脚本、自动化发布流程都默认按标准位置找产物。
2.2 工具链识别:不只是rpmbuild一个命令
第二类是“工具链缺失”问题。写Spec文件的%build段时会调用make,%install时常需要install命令,如果源码还涉及autotools,那autoconf、automake、libtool一个都不能少。再加上编译器gcc、g++,缺一个都会在中途报错。
快速验证工具链是否完备,可以执行:
bash复制rpm -q rpm-build rpmdevtools gcc make autoconf automake libtool
缺什么就补什么。这里我想特别强调一点:很多构建失败并不是Spec文件写错,而是构建环境本身没装够依赖。我见过有人为了一个小工具包硬是在本机装了一整套桌面环境,就是因为某个库的devel包没找到,无奈之下安装了整个组包。
2.3 宏定义优先级:被覆盖的rpmbuild配置会让你抓狂
RPM宏体系是Spec文件调试中隐藏最深的一块。rpmbuild本身的默认宏定义在/usr/lib/rpm/macros里,用户级别的定义在~/.rpmmacros,Spec文件内部也可以用%global或%define临时定义。它们的优先级从低到高大致是:系统默认宏 < 发行版级macros.d < ~/.rpmmacros < Spec文件内部定义。
实际踩坑场景是这样的:你定义了一个%global _prefix /opt/myapp,结果rpmbuild最终编译出来的路径仍然是/usr,查了半天才发现是在~/.rpmmacros里覆盖了_prefix变量。由于用户级配置很容易被忽略,排查时一定要先看这个文件里有没有不该有的内容。
验证宏展开结果万能的命令:
bash复制rpm --eval '%{_prefix}'
rpm --eval '%{buildroot}'
如果Spec文件里有自定义宏,也可以单独写个小Spec文件用rpmbuild -bp只执行到%prep阶段,结合--debug查看具体展开结果。
3. Spec文件字段逐项拆解:脱离Hello World式的照抄
很多入门教程只放了几个字段,读者照抄完能出包,但根本不知道每个字段的边界在哪。这里我把一份相对完整的Spec文件骨架拆开讲,重点是字段之间的依赖关系和常见误用。
3.1 头部定义区:Name、Version、Release是构建指纹
spec复制Name: hello
Version: 1.0
Release: 1%{?dist}
Summary: The GNU Hello World program
License: GPLv3+
URL: https://www.gnu.org/software/hello/
Source0: https://ftp.gnu.org/gnu/hello/hello-%{version}.tar.gz
BuildRequires: gcc, make
Requires: libc >= 2.17
几个容易被忽略的点:
- Name不允许多级路径或空格,必须是一个简单的包名标识符。它会影响后续
%{name}宏的展开,间接影响源码包文件名和安装路径。 - Version不要带字母前缀(如
v1.0),应拆成Version:加数字版本、必要时在Release字段体现快照标识。 - Release建议用
%{?dist}后缀,这样构建出来的RPM包文件名会自动带上系统版本标识(如.el7、.el8),在多发行版环境中能有效区分。
BuildRequires和Requires是最容易混淆的一对:前者声明的是构建主机上需要的东西,后者声明的是运行目标机上需要的东西。把这两者混用了会怎样?轻则构建环境里多了一堆没用的包,重则打出来的包在干净系统上一装就报缺库。
3.2 %prep、%build、%install:三个脚本段的执行逻辑
这三个段落是Spec文件的灵魂,也是调试最耗时的地方。
%prep段默认动作是解压源码包并进入源码目录,配合%setup -q宏使用。很多派生场景都基于对这个宏的改造:
spec复制%prep
%setup -q -n hello-%{version}
%patch0 -p1
如果用-n指定了目录名,说明源码包解压后目录不叫hello-1.0;如果有补丁,%patch0需要对应Patch0字段。这里最容易犯的错是补丁路径相对于哪个目录不对——%patch默认是在解压后的源码根目录执行的,-p1表示去掉补丁文件路径中的第一级目录。
%build段就是标准编译流程:
spec复制%build
%configure
make %{?_smp_mflags}
%configure宏会展开为./configure --prefix=/usr ...加一堆RPM预置参数,这些参数来自/usr/lib/rpm/macros里的定义。如果项目自己的configure脚本不兼容这些参数,建议先看下展开结果再决定是否替换。
%install段则负责把编译产物安装到BUILDROOT里:
spec复制%install
rm -rf %{buildroot}
make install DESTDIR=%{buildroot}
必须把DESTDIR指到%{buildroot},否则make install会把文件直接装到构建机系统里,污染本机不说,BUILDROOT里啥都没有。这个问题在多人共用构建机时尤其严重。
注意:
rm -rf %{buildroot}这个清理动作在旧版rpmbuild里是默认执行的,但在新版中某些场景下不再自动清理。保留一行显式的清理更稳妥,防止BUILDROOT里残留上次构建的文件。
3.3 %files清单:每个路径都要对应BUILDROOT里的真实存在
%files段是构建成功与否的最后一道关卡。默认情况下,%files里列出的每个路径如果不存在于BUILDROOT中,rpmbuild会直接报错“File not found”,并拒绝生成包。
spec复制%files
%doc README AUTHORS
%license COPYING
/usr/bin/hello
/usr/share/man/man1/hello.1.gz
一些常见的坑位:
- 目录与文件重复:如果
/usr/share/hello是一个目录,且目录下还有其他文件,你需要用%dir /usr/share/hello显式声明只打包目录本身,或者直接用通配符/usr/share/hello/*。列一个裸目录而不写内容,会导致安装时目录里什么都没有,或者卸载时目录残留。 - 配置文件标记:
/etc/xxx.conf这类文件应使用%config(noreplace)标记,这样用户改过的配置在包升级时不会被静默覆盖。很多“升级后配置丢失”的问题就出在这。 - 文档与许可证分离:GPL类项目建议单独用
%license声明许可证文件,%doc只放README、CHANGELOG等文档。
3.4 脚本段:%pre、%post、%preun、%postun的四个生命周期
Spec文件还有一组可选的脚本段,分别在安装前、安装后、卸载前、卸载后执行。这四个段常用于服务注册、用户创建、缓存刷新等。
我最常踩的坑是脚本段执行时机与文件系统状态不一致。比如%post里写systemctl daemon-reload,但service文件是%files里新装的,按理说在%post阶段文件已经就位了;如果service文件的路径在%post里引用错了,命令会静默失败。更隐蔽的是%preun里执行systemctl stop,但包升级时也会触发%preun,导致服务在升级过程中被停掉后,新版本还没装完服务就起不来。
对于这类问题,一个稳妥的做法是区分安装和升级场景:
spec复制%preun
if [ "$1" -eq 0 ]; then
systemctl stop myapp.service
systemctl disable myapp.service
fi
$1等于0表示卸载,等于1表示升级。这个判断很多人不知道,但写进脚本段后能避免大量升级事故。
4. 调试三板斧:宏展开、构建日志与mock环境的组合用法
4.1 从rpmbuild的逐步选项看每一段发生了什么
rpmbuild支持分阶段执行,分别对应-bp(执行到%prep)、-bc(执行到%build)、-bi(执行到%install)、-bb(只生成二进制包)、-ba(生成二进制和源码包)、-bs(只生成源码包)。
调试时千万不要每次都从头跑到尾,应该根据报错位置选择合适的阶段。举几个真实场景:
- 如果错误信息是“解压失败”或“补丁不匹配”,用
-bp执行完查看~/rpmbuild/BUILD/里解压出的文件结构。 - 如果错误是在编译阶段,用
-bc把输出日志存到文件里慢慢看:bash复制rpmbuild -bc hello.spec > build.log 2>&1 tail -100 build.log - 如果编译能过但打包时说找不到文件,用
-bi把所有安装步骤执行一遍,手动检查BUILDROOT里的内容:
bash复制find ~/rpmbuild/BUILDROOT -type f > installed_files.txt
每阶段独立验证,能极大缩小问题范围。
4.2 宏展开结果怎么看:rpmbuild --debug 与 %{?宏} 的配合
Spec文件里的宏非常多,你写的时候觉得天衣无缝,展开出来可能完全是另一种内容。对应的验证手段就是让rpmbuild把展开后的Spec文件输出给你看。
bash复制rpmbuild -bp --debug hello.spec 2>&1 | less
在debug模式下,rpmbuild会显示Spec文件的宏展开结果,每个宏替换成什么内容一目了然。比如%{buildroot}展开成什么路径、%{_smp_mflags}展开成什么参数,这些都是排查路径类问题的高频信息。
还可以在Spec文件里临时加一行%define __spec_install_post true或者用echo输出关键变量,比如:
spec复制%build
echo "BUILDROOT is: %{buildroot}"
echo "PREFIX is: %{_prefix}"
打出来的日志会直接把宏值暴露给你。这个方法虽然土,但在复杂的构建链中特别有效。
4.3 为什么推荐用mock做跨发行版验证
mock是一个独立的RPM构建工具,它会在chroot环境里创建一个干净的构建环境,自动拉取依赖、执行构建,最后生成RPM包。相比直接在本机跑rpmbuild,mock隔离性更好,能暴露“本机装了很多额外包导致依赖缺失”这类问题。
基本用法:
bash复制mock -r epel-8-x86_64 --init
mock -r epel-8-x86_64 --buildsrpm --spec hello.spec --sources ~/rpmbuild/SOURCES/
mock -r epel-8-x86_64 --rebuild hello-1.0-1.el8.src.rpm
-r指定一个构建配置(root配置)。配置文件的命名规则一般是<发行版>-<版本>-<架构>,例如epel-8-x86_64、fedora-38-aarch64。首次使用需要先--init初始化基础环境,这个步骤因为要下载基础包会比较慢。
mock最大的价值是让你在一种干净的构建环境中复现问题。如果本机rpmbuild成功,但mock构建失败,说明你的构建环境缺了某个BuildRequires依赖;反过来如果mock成功而本机失败,多半是本机装了某个和你Spec文件冲突的包或宏配置。这两个方向的结论都很有意义。
4.4 日志分析:不是只有报错信息才有价值
调试RPM构建时,许多人只盯着最后一行报错,忽略前面几千行正常日志。实际上,构建过程的日志里包含大量线索:
%configure展开后的具体参数,能告诉你prefix、libdir是否按预期设置。make编译时哪些警告被忽略,可能会在运行时暴露成符号缺失。make install DESTDIR=...实际安装的文件列表,能帮你核对%files清单。
我的习惯是构建成功后把日志整体保存一份,然后搜索几个关键词:Installing、/usr/、cannot find、warning:。这么做能提前发现“包能构建成功但安装路径不对”的隐性bug。
5. 高频报错实战复盘:权限、路径、依赖与%files最常见翻车现场
5.1 实战一:%install里强制安装导致本机被污染
这是一个很典型的翻车事件。我早期给一个内部工具做RPM包时,在%install段直接写了make install,没加DESTDIR=%{buildroot}。rpmbuild跑完没报错,RPM包也生成了,但本机系统里的/usr/local/bin多了几十个文件,rpm -V全面报警。更麻烦的是有些被覆盖的系统文件没法直接回滚。
排查方式是先确认%install的执行位置和文件落点:
bash复制rpmbuild -bi --short-circuit hello.spec
--short-circuit可以跳过之前的阶段直接执行指定段,能加快验证。如果发现BUILDROOT里没文件,大概率就是install命令没有正确使用DESTDIR。
解决办法是规范%install段:
spec复制%install
rm -rf %{buildroot}
make install DESTDIR=%{buildroot}
如果你的Makefile不支持DESTDIR,那就手动install -D -m 755逐个安装到BUILDROOT对应路径。虽然繁琐但绝对可控。
5.2 实战二:BuildRequires写错导致mock构建失败
有一次我为公司内部一个基于Golang的CLI工具做RPM打包。本机rpmbuild -bb一次通过,因为开发机上装了Go工具链;但推到CI环境用mock构建就报错bash: go: command not found。
这个报错给了非常明确的信号:BuildRequires里漏了golang。补上:
spec复制BuildRequires: golang
但这里引申出另一个问题:每个环境的软件包名可能不一样。RHEL 8系列里包名叫golang,但某些旧版本里可能叫golang-bin或golang-google-cmd。更通用的做法是用BuildRequires: golang >= 1.19这样的版本下限声明,然后靠发行版包管理器自行匹配。
另外还有一个隐藏依赖问题:有些源码在编译时会动态检测系统里是否装了某些库,如果检测到就启用对应功能,没检测到就静默禁用。这种情况比直接报错还难查,因为构建是成功的,但功能少了。处理思路是在构建前用rpm -q --whatprovides反向确认关键库的包名,并显式加到BuildRequires里。
5.3 实战三:%files清单漂移——同名文件在不同架构下的差异
我在打一个x86_64和aarch64双架构包时遇到过这样的问题:同一个Spec文件,x86_64构建一切正常,aarch64构建却在%files阶段报“File not found”。
原因是这个项目的Makefile在aarch64下会把一个库文件安装到不同路径(比如lib目录变成了lib64)。但我的%files里写死了x86_64的路径:
spec复制%files
/usr/lib/libfoo.so
解决方案有几种:
- 在
%install后用find确认实际路径,但不建议写死; - 用
%{_libdir}宏替代硬编码路径:spec复制%files %{_libdir}/libfoo.so - 如果架构间路径差异太大,可以用条件判断:
spec复制%ifarch aarch64 %files /usr/lib64/libfoo.so %else %files /usr/lib/libfoo.so %endif
我的建议是尽量用%{_libdir}、%{_bindir}这类内置宏,它们会根据架构自动展开到正确路径。
5.4 实战四:依赖冲突与循环依赖的排查
RPM依赖解析是另一大报错源。常见场景:你的包用Requires: libssl.so.10,但目标系统上的OpenSSL版本只提供libssl.so.1.1;或者你声明Requires: foo,但foo本身又依赖你的包,形成循环依赖。
排查依赖问题有一个固定流程:
bash复制rpm -qpR your-package.rpm # 查看二进制包的运行时依赖
rpm -qp --provides your-package.rpm # 查看当前包提供了哪些能力
rpm -q --whatprovides "libssl.so.10" # 查看哪个包提供某个.so文件
如果发现依赖的是某个.so文件而不是具体包名,说明你用了自动依赖生成(rpmbuild默认扫描BUILDROOT中的二进制文件并生成依赖)。要想手动控制,可以在Spec文件里关闭自动依赖:
spec复制%define __find_provides %{nil}
%define __find_requires %{nil}
然后自己写Provides:和Requires:。但这样做风险较大,除非你非常了解目标运行环境的库情况,否则不建议随意关闭自动依赖机制。
5.5 实战五:/etc下的配置文件升级被覆盖
这个问题场景是:程序在运行过程中修改了/etc/myapp/config.ini,管理员也做过手工调整,结果RPM升级后配置被覆盖成默认值。
根因是在%files里没有用%config(noreplace)标记配置文件:
spec复制%files
%config(noreplace) /etc/myapp/config.ini
noreplace的语义是:如果目标机器上的配置文件已经被修改过,那么升级时保留用户版本,并把新版本命名为config.ini.rpmnew;如果没有修改过,则用新包里的配置替换。这个机制能避免绝大多数配置丢失事故。
如果你的配置项格式在版本之间有重大变化、必须强制覆盖,那就要用回%config(不带noreplace),但这属于高风险操作,必须在变更日志里写明原因,并在%post脚本里做配置迁移处理。
6. 进阶工作流:离线依赖、跨架构编译与自动化验证
6.1 离线环境的依赖准备:有一个坑叫BuildRequires链
生产环境经常是隔离内网,无法在线安装构建依赖。此时构建RPM包会面临一个棘手问题:rpmbuild在这个阶段需要安装BuildRequires里的依赖包,可环境里没有网络。
我的做法是在一台能联网的机器上先用yumdownloader把依赖包全部拉下来:
bash复制yumdownloader --resolve --destdir=/tmp/rpm-deps go gcc make
然后把整个/tmp/rpm-deps目录拷贝到内网机器上,用rpm -ivh批量安装,或者配置一个本地yum源:
bash复制createrepo /tmp/rpm-deps
在目标机器上添加一个repo文件指向这个目录,然后正常yum install即可。
还有一个细节:如果你用的是dnf,--downloadonly配合--resolve同样能下载依赖链。注意有些包有多层依赖,--resolve能自动解析并一并下载,不用手动逐个找。
6.2 源码包的溯源与SRPM的多架构重建
发布RPM包时,除了二进制RPM,建议同时生成源码包SRPM(rpmbuild -bs)。SRPM以.src.rpm为后缀,里面包含了Spec文件、源码压缩包、补丁等所有原料。别人拿到SRPM后,可以在任何兼容架构的机器上重新构建:
bash复制rpmbuild --rebuild your-app-1.0-1.src.rpm
或者用mock相对简单的多架构重建:
bash复制mock -r epel-8-aarch64 --rebuild your-app-1.0-1.src.rpm
前提是构建机上已经配置好了aarch64的mock root。这个工作流在跨架构发布时非常高效:同一份SRPM,既能出x86_64的包,也能出aarch64的包,不需要改任何内容。
如果你没有专门的aarch64硬件,也可以依赖QEMU用户态模拟来跑mock构建容器。相关配置方案的适用范围和性能差异比较大,纯文档描述不好说死,建议根据你的构建机资源自己评估。
6.3 用rpmbuild的短选项机制做快速验证
开发和调试Spec文件时,我常用的流程是:
rpmbuild -bp --clean检查解压和补丁;rpmbuild -bc --short-circuit只编译不打包;- 手动检查BUILD目录下的编译产物;
rpmbuild -bi --short-circuit执行安装段并核对BUILDROOT内容;rpmbuild -bb生成二进制包;rpm -K校验签名,rpm -qpl快速预览包内文件清单。
--short-circuit的存在意义是跳过前置阶段直接执行某个阶段,调试效率能提升不少。但要特别注意它只适合开发调试,正式打包不要用这个选项,否则你很可能拿着一个月前的BUILD目录内容出包,自己还不知道。
6.4 构建日志与CI的联动:让错误在提交时就暴露
如果你的项目已经用GitLab CI、Jenkins或GitHub Actions,完全可以把RPM打包接入CI流程。一个最小化的流水线大致包含:
- 拉取源码和Spec文件;
- 安装构建依赖;
- 在干净的容器或mock环境中执行构建;
- 生成RPM包和SRPM包作为CI产物。
以GitLab CI为例,一个简单的Job片段长这样:
yaml复制build_rpm:
stage: build
image: rockylinux:8
script:
- dnf install -y rpm-build make gcc
- rpmbuild -bb --define "_topdir $CI_PROJECT_DIR/rpmbuild" rpmbuild/SPECS/app.spec
artifacts:
paths:
- rpmbuild/RPMS/
- rpmbuild/SRPMS/
这里的关键点是使用--define "_topdir ..."把构建目录指到CI工作区,避免默认的$HOME/rpmbuild在每次runner执行后丢失。
把RPM构建纳入CI还有一个额外好处:它会强迫你把BuildRequires声明完整。因为CI环境是干净的,少了依赖直接报错,不会像本机一样被隐藏依赖“蒙混过关”。
最后再分享一点个人体会
Debug了这么久的Spec文件,我的操作习惯可以浓缩成一句话:把Spec文件的每个阶段拆开,逐步验证,再组装闭包。许多新手以为Spec文件是一次性写完然后rpmbuild能一把过的,但实际上成熟的打包流程和写代码一样,需要迭代、分段调试,尤其要善用-bp、-bc、-bi这些分阶段选项。
另外一个容易被忽视的技巧是:如果构建报错信息本身不够清晰,可以先尝试不带--short-circuit完整跑一遍,然后用rpmbuild -v和mock的详细日志交叉对比。很多时候本机环境太“脏”,反而掩盖了真实问题,而mock给的干净环境恰好能帮你说清楚到底是谁的锅。
最后分享一个文件管理的经验:建议把Spec文件本身纳入版本管理,每次修改都留提交记录。这样当某个版本的包在生产环境出问题时,你能快速回退到上一个可用的Spec文件对比差异,定位是哪个字段或脚本段改出了岔子。这套工作流坚持下去,RPM打包就不再是玄学,而是一门可控的工程。
