npm 包的 RPM 打包实战

从一个前端角度来说,搞 RPM 打包这事可真够冷门的。但公司服务器有外网权限管控,每次装 pm2 都要单独申请,不如做成 rpm 丢到私有 yum 源一劳永逸。

一、背景

公司在服务器外网权限上卡得很严,访问外网需要走审批工单。但 pm2 是 Node.js 应用部署的事实标准,总不能每台机器都让运维去跑一遍 npm install -g pm2——先不说外网权限的问题,光是版本一致性就够头疼的。

干脆把它打包成 rpm,推到私有 yum 源上配成基线。新机器装系统时自动带上,运维也不用每次手动装。

二、环境准备

yum install rpm-build -y
useradd builduser
su builduser
mkdir -pv ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}

五个目录各司其职:

目录 用途
SOURCES 源码包就放这,后面 spec 的 Source0 从这里读
SPECS spec 文件,rpmbuild 只认这个入口
BUILD 解压和编译的工作区
BUILDROOT %install 阶段的临时根目录,不是系统路径
RPMS 打完的 rpm 最终输出到这

不要用 root 做 rpmbuild。 用 root 打出来的文件所有者是 root,安装后普通用户读写权限可能出问题。

三、源码准备:让 tgz 包含整个 node_modules

npm 官方的 tgz 是不带 node_modules 的——它假设你安装时会自己去解析依赖。rpm 打包显然不能这样,不能指望构建时还能联网跑 npm install。

用 npm-bundle:

npm install -g pm2
cd /usr/local/lib/node_modules/pm2
npm-bundle
mv pm2-3.2.2.tgz ~/rpmbuild/SOURCES/pm2-3.2.2.tgz

npm-bundle 把当前目录下包括 node_modules 在内的所有文件打成 tgz,丢到 SOURCES 目录即可。

四、spec 文件编写:六个坑的排查

4.1 头部定义

Name:           pm2
Version:        3.2.2
Release:        1%{?dist}
Summary:        pm2 program
License:        AGPL-3.0
URL:            https://pm2.io/
Source0:        %{name}-%{version}.tgz

Requires(post):  nodejs
Requires(preun): nodejs

Source0 路径要跟 SOURCES 目录的实际文件名对上。用 %{name} 和 %{version} 替代硬编码,以后升级只改 Version 一行。

4.2 %prep:解压目录名对不上

%prep
%autosetup -n package

坑一:直接把 tgz 丢进去,rpmbuild 报「找不到源代码目录」。

原因是 npm 打出来的包解压后目录名是 package,不是 pm2-3.2.2。rpmbuild 默认按 %{name}-%{version} 去找,找不到就炸。-n package 告诉它「目录叫 package」。

4.3 %install:忘写 buildroot,文件没进包

%install
mkdir -p %{buildroot}/usr/local/nodejs/lib/node_modules/pm2
cp -af . %{buildroot}/usr/local/nodejs/lib/node_modules/pm2

坑二:一开始觉得直接把文件拷过去就行,没把 %{buildroot} 当回事。

%{buildroot} 是 rpmbuild 的临时根目录,大概路径是 ~/rpmbuild/BUILDROOT/pm2-3.2.2-1.el7.centos.x86_64/。文件必须拷到这个目录下对应的子路径,之后 rpmbuild 会把它打包起来。安装 rpm 时,文件会被解到系统的对应位置 /usr/local/nodejs/lib/node_modules/pm2。

如果直接 cp 到系统路径而不是 %{buildroot} 下,rpm 包里根本没有文件。装完后系统什么都没多——排查了好一会儿才明白。

4.4 %files:.* 匹配到 . 和 ..

%files
/usr/local/nodejs/lib/node_modules/pm2/*
/usr/local/nodejs/lib/node_modules/pm2/.[a-z0-9_]*

坑三:最开始写的是 /usr/local/nodejs/lib/node_modules/pm2/.*,rpmbuild 直接报错——. 和 .. 不是文件,无法打包。换成 .[a-z0-9_]* 就能匹配所有正常的隐藏文件(.npmignore、.babelrc 等),完美避开两个特殊目录。

4.5 %preun:升级时不该删软链

%post
ln -s /usr/local/nodejs/lib/node_modules/pm2/bin/pm2 /usr/local/bin/pm2
ln -s /usr/local/nodejs/lib/node_modules/pm2/bin/pm2-dev /usr/local/bin/pm2-dev

%preun
if [ $1 = 0 ]; then
  rm /usr/local/bin/pm2 /usr/local/bin/pm2-dev
fi

%post 在安装完成后把 pm2 的二进制软链到 /usr/local/bin。%preun 在卸载前执行。

坑四:$1 的语义。一开始想「卸载时删掉软链就完了」,没管这个参数:

  • $1 = 0:这是真正的卸载,放心删
  • $1 = 1:这是升级过程中的旧版本卸载——新版本的 %post 会在后面重建软链,这里别多手

五、自动依赖误识别

坑五:在测试机上装完 rpm 跑 pm2,报错「缺少 /sbin/openrc-run」。

pm2 根本不依赖 openrc-run。排查后发现是 rpm 的自动依赖检测扫描了文件中的 shebang——pm2 代码里有段初始化模板写了 #!/sbin/openrc-run,rpm 就把这当成了硬依赖。

CentOS 7+ 用 __requires_exclude 过滤:

%global __requires_exclude ^/sbin/openrc-run$

但在 CentOS 6 上发现 %global 和 %define 都不认这个宏。最后只能暴力关掉自动检测:

AutoReq: no

副作用是必须在 Requires: 里手动声明所有依赖,漏一个就会在运行时才暴露。

六、跨系统兼容

事情到这只做了一半。项目组的构建机是 CentOS 7,但有两台遗留的 CentOS 6 机器也需要部署。换到 CentOS 6 打包后,%autosetup 报不支持,__requires_exclude 也不认。

用条件宏区分:

%if 0%{?rhel} && 0%{?rhel} >= 7
%global __requires_exclude ^/sbin/openrc-run$
%autosetup -n package
%else
%setup -n package
AutoReq: no
%endif

%setup -n package 和 %autosetup -n package 在功能上等价,只是少了个自动打补丁的能力,对纯 JS 包没影响。

另外,pm2 没有原生模块,声明 BuildArch: noarch 让打出来的 rpm 不绑定 CPU 架构,x86_64 和 arm64 都能装。

七、调试

# 构建并保留日志
rpmbuild -ba pm2.spec 2>&1 | tee build.log

# 安装测试(-ihvv 打印每步细节)
sudo rpm -ihv ~/rpmbuild/RPMS/noarch/pm2-3.2.2-1.el7.centos.noarch.rpm

# 查看安装位置和依赖
rpm -ql pm2
rpm -qpR pm2-3.2.2-1.el7.centos.noarch.rpm

# 开发阶段的静默卸载(跳过 scripts)
sudo rpm -e --noscripts pm2

构建日志里重点看两个地方:

  • %install 阶段:确认 cp 后 %{buildroot} 下的文件结构正确——可以在 spec 的 %install 里临时加一行 ls -la %{buildroot}/.../ 验证
  • %files 段:如果看到 Checking for unpackaged file(s),说明有文件拷贝到了 %{buildroot} 但没在 %files 中声明

本质上就是在做一件事:把 npm install -g 这条命令的执行过程拆解成 rpmbuild 的六个阶段,保证构建结果可重复、可分发、可追溯。