ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux 动态依赖库打包脚本:从翻车到自包含部署

Linux 动态依赖库打包脚本:从翻车到自包含部署 1. 这个脚本要解决什么问题1.1 从一次线下交付翻车说起前几年我给客户交付一个用 Qt 写的数据采集程序编译环境是 Ubuntu 18.04现场服务器是 CentOS 7.6。我在自己机器上跑得好好的用 scp 把整个目录复制过去一执行就是经典的./data_collector: error while loading shared libraries: libQt5Core.so.5: cannot open shared object file: No such file or directory我当时第一反应是“那就把 Qt 的 so 文件一起拷过去呗”然后就开始了长达半天的复制、报错、再复制、再报错循环。明明把libQt5Core.so.5放进去了又说找不到另一个库把另一个库也放进去了又开始报GLIBC_XX not found。那会儿才意识到Linux 可执行程序的依赖关系远不是“缺哪个文件复制哪个文件”这么简单。这就是我写这个依赖库打包脚本的初衷。它解决的问题很具体把你编译好的可执行程序、它依赖的所有动态共享库、以及程序启动时需要的动态链接器自动收集到一个独立目录里让这个目录可以整体拷到另一台 Linux 机器上直接运行而不要求目标机器预装一堆运行时环境。1.2 为什么静态编译不是万能解遇到动态库依赖问题很多人第一反应是“那你为什么不用静态编译”。实际上静态编译在很多场景下并不可行。一是部分库并没有提供静态版本或者静态版本会带来许可问题。尤其是一些使用 LGPL 协议的库Qt 就是一个典型如果你用静态编译可能需要开源你的应用代码。很多商业项目没法接受。二是静态编译出来的二进制体积巨大。一个小工具可能都要几十 MB一个带 UI 的程序轻松上百 MB而且每次更新都要整个重新编译交付和缓存都不舒服。三是有些功能只能运行时从共享库里加载例如 Qt 的插件机制、glibc 的 NSS 模块、OpenSSL 的 engine你没办法把“未来可能被 dlopen 打开的库”也一起静态进去。所以对大多数需要交付到 Linux 环境的应用来说合理路线还是“动态编译 动态依赖打包”脚本要解决的就是把动态依赖完整、正确地收集起来。1.3 对比三种交付方式后为什么选脚本打包在动手写脚本之前我对比过几种常见的移植方案第一种是传统的“把可执行文件放到目标机器然后在/etc/ld.so.conf.d/里写库路径执行ldconfig”。这种方式适合有 root 权限、能改系统的场景。但很多客户现场只有普通用户权限或者系统是固定模板不能随便改配置。而且你还要保证所有目标机器的路径一致否则要调整。第二种是用 AppImage 这类打包工具。AppImage 的核心思路是把程序、依赖库、资源都塞进一个镜像文件挂载后运行。它确实好用但构建环境需要安装额外工具而且有些内网环境对 FUSE 支持不完整AppImage 跑不起来。还有一个问题是如果程序内部还依赖外部可执行文件、配置文件、插件目录AppImage 的打包规则会更复杂。第三种就是自己写一个依赖收集脚本把依赖库、动态链接器、资源文件统一放到一个目录里再写一个启动脚本或者用 patchelf 改可执行文件自身的查找路径。这种方案最大优势是“所见即所得”打包产物就是一个普通目录拷到哪都能用不需要 root不需要额外运行时出问题也容易排查。脚本本身没有魔法每个步骤都可以手工验证。我后面所有生产环境给客户交付用的都是第三种方案。下面这篇博文会把整套脚本的实现思路、关键代码、以及我踩过的坑完整写出来。2. 依赖收集原理ldd 到底告诉了你什么2.1 ldd 的输出长什么样在开始写脚本之前你一定会用到ldd命令。随便找个可执行文件执行输出大概是这样的$ ldd /usr/bin/curl linux-vdso.so.1 (0x00007ffd8e7a3000) libcurl.so.4 /lib/x86_64-linux-gnu/libcurl.so.4 (0x00007f8a0b5a0000) libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a0b1a0000) /lib64/ld-linux-x86-64.so.2 (0x00007f8a0b9e0000)看起来很清楚左边是库的 SONAME中间是库在系统里的绝对路径括号里是加载地址。但这里有几个容易被忽略的点第一行linux-vdso.so.1是内核提供的虚拟动态库没有实际文件路径不能复制。最后一行ld-linux-x86-64.so.2是动态链接器也叫 interpreter很多打包脚本会漏掉它。如果目标机器上 glibc 版本和编译机差别比较大漏掉它直接导致“运行提示 No such file or directory”但文件明明还在。如果某个依赖库缺失中间那一列会直接显示not found这时候继续复制是没有意义的你要先解决依赖缺失问题。2.2 ldd 的隐藏风险ldd看似简单但它其实不是“读文件”而是通过动态链接器模拟加载目标程序。当你在一个不可信的可执行文件上执行ldd时按某些老版本 glibc 的行为是有可能触发程序内部代码执行的。虽然现代系统的ldd对普通二进制不直接运行程序本身但它会交给动态链接器运行安全上仍需谨慎。更实际的问题有两个一个是ldd输出的路径不一定是最终运行时真正加载的路径。如果目标机器上有同名但版本不同的库动态链接器可能按搜索顺序找到另一个路径。你要打包的应该是编译机上的那一份而不是目标机器上看似有、实际不匹配的那一份。另一个问题是ldd会受当前 shell 环境变量影响。如果你在打包机上提前设置了LD_LIBRARY_PATHldd输出的路径可能不是程序默认搜索路径下的库。这会导致你复制了错误的库版本打包到干净机器上又会出问题。所以在脚本里我更推荐用readelf读取 ELF 文件的DT_NEEDED标签然后自己做递归解析。这样不执行程序、不受环境变量影响得到的依赖列表更干净。2.3 更稳的收集方式readelf/objdump 查 DT_NEEDED你可以先用readelf -d查看一个可执行文件直接依赖了哪些库$ readelf -d /usr/bin/curl | grep NEEDED 0x0000000000000001 (NEEDED) Shared library: [libcurl.so.4] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]这告诉你需要哪些 SONAME但还没告诉你这些库的绝对路径。下一步可以用ldconfig -p查找系统缓存中该 SONAME 对应的路径或者直接用ldd的路径输出作为参考。更简单的做法是让脚本先拿到DT_NEEDED的名字再用find /lib /usr/lib /usr/local/lib去定位但实际用下来还是ldd的路径最直接。我的脚本采用了一个折中方案用ldd获取实际加载路径但过滤掉linux-vdso同时用readelf取出动态链接器路径。关键点不是用哪个命令而是要对结果去重、递归处理并且处理符号链接这些会在下一节展开。有人会问既然ldd有潜在风险为什么还要用因为对绝大多数自己编译的程序来说风险在可控范围内。你打包的是自己刚编译出来的东西不是网上随便下载的二进制。所以真正要防的是“环境变量污染”和“依赖缺失时不报错”而不是恶意代码。3. 打包脚本的完整实现3.1 目录设计和准备打包目标目录我建议固定成这样的结构app_bundle/ ├── bin/ │ └── my_app ├── lib/ │ ├── libQt5Core.so.5 │ ├── libQt5Gui.so.5 │ └── ... ├── ld/ │ └── ld-linux-x86-64.so.2 ├── plugins/ │ └── platforms/libqxcb.so ├── qt.conf └── run.shbin放可执行程序lib放所有依赖的共享库ld放动态链接器plugins放 Qt 等框架的运行时插件run.sh是最终启动器。这样安排的好处是目录内是自包含的你可以随时 diff 不同版本把整个目录拷贝到目标机器时不需要关心系统库里有什么出问题时可以拿着lib目录逐一给客户看“这里面都有”避免扯皮。打包环境建议用一台干净的 Linux 虚拟机或容器系统版本尽量与客户环境接近或者至少“编译机 glibc 不高于目标机 glibc”。这一条直接决定你打包的程序能不能在目标机器上跑。3.2 递归收集依赖的核心函数收集依赖最简单的思路是对每个库文件再次执行ldd或readelf直到没有新的依赖出现。下面这个函数是我在脚本里的核心逻辑#!/bin/bash set -euo pipefail declare -A seen_libs collect_deps() { local file$1 while IFS read -r lib; do [ -z $lib ] continue [ -n ${seen_libs[$lib]:-} ] continue if [ ! -f $lib ]; then echo [warning] missing: $lib 2 continue fi seen_libs[$lib]1 # 复制库文件到 lib 目录跟随符号链接复制真实文件避免悬空链接 local basename_lib basename_lib$(basename $lib) cp -a -L $lib $LIBDIR/$basename_lib 2/dev/null || { echo [error] fail to copy $lib 2 } # 递归处理这个库自身的依赖 collect_deps $lib done (ldd $file | awk / \// {print $3} /^\// {print $1} | sort -u) }这里有几个细节要说明awk / \// {print $3}只取“真实路径”那一列不会误把linux-vdso这种没有路径的条目当文件。cp -a -L表示保留权限属性但跟随符号链接复制真实文件。这样虽然丢失了链接关系但保证了文件实体完整。为什么要这样因为很多库的安装路径里libfoo.so是指向libfoo.so.1的符号链接libfoo.so.1又可能指向libfoo.so.1.2.3。如果只复制符号链接而不复制目标文件到目标机器上就会断链。seen_libs是全局去重表避免同一个库被反复复制、死循环。3.3 处理动态链接器与 rpath动态链接器是程序启动的第一个“程序”它负责加载所有依赖库。如果目标机器的 glibc 版本与编译机不一致或你希望完全脱离系统环境就需要把编译机的动态链接器也复制到ld目录并用 patchelf 修改可执行文件的 interpreter 路径。获取动态链接器路径的方法INTERP$(readelf -l $BIN | grep interpreter | awk {print $NF} | tr -d ])执行后可能得到/lib64/ld-linux-x86-64.so.2。复制到打包目录的ld/下mkdir -p $DEST/ld cp -a -L $INTERP $DEST/ld/然后使用 patchelf 修改可执行文件patchelf --set-interpreter ./ld/ld-linux-x86-64.so.2 $DEST/bin/my_app这里我使用相对路径./ld/ld-linux-x86-64.so.2目的是让整个目录移动后仍然有效。注意patchelf --set-interpreter接受的是 ELF 中的解释器路径如果写成相对路径动态链接器会基于当前工作目录查找而不是可执行文件所在目录所以更稳妥的做法是配合启动器cd到目录内运行或者使用$ORIGIN。同理设置运行时搜索路径也可以用 patchelfpatchelf --set-rpath $ORIGIN/lib $DEST/bin/my_app$ORIGIN是 ELF 支持的一个特殊变量代表可执行文件所在目录。这样程序启动时会自动去可执行文件目录/lib找依赖库比设置LD_LIBRARY_PATH更稳不会因为环境变量被外部覆盖而出问题。有一个坑patchelf 修改的是 ELF 文件头中的动态段如果目标程序本身就使用了比较特殊的 linker script或者程序带了完整性校验比如某些反调试保护patchelf 之后可能无法启动。遇到这种情况就不要改二进制的 interpreter而是用启动器里的export LD_LIBRARY_PATH方式。3.4 生成可移植的启动器即使使用了 rpath我仍然会生成一个run.sh启动器。它有两个作用一是作为用户入口二是兜底处理环境变量。最简单的启动器写法#!/bin/bash DIR$(cd $(dirname $0) pwd) export LD_LIBRARY_PATH$DIR/lib:${LD_LIBRARY_PATH:-} exec $DIR/bin/my_app $如果你同时用了 patchelf 设置$ORIGIN/lib那么这个LD_LIBRARY_PATH就是可选的但留着没坏处类型重复不影响启动。有一点必须注意cd $(dirname $0)不是多此一举。很多程序启动后会依赖相对路径读取配置文件、日志目录、插件目录如果你从别的路径执行run.sh而脚本没有先切到自身目录程序很可能找不到资源。对 Qt 程序我还会把qt.conf放到与可执行文件同级的目录下内容至少包含[Paths] Plugins plugins这样 Qt 才会在可执行文件相对路径下找plugins目录而不会只去系统默认的/usr/lib/qt5/plugins找。3.5 完整脚本汇总把上面各部分拼起来就是一个可用的打包脚本。我日常维护的版本比这个稍微长一点但核心逻辑如下#!/bin/bash set -euo pipefail if [ $# -ne 2 ]; then echo usage: $0 binary output-dir exit 1 fi BIN$(readlink -f $1) DEST$2 BINDIR$DEST/bin LIBDIR$DEST/lib LDDIR$DEST/ld mkdir -p $BINDIR $LIBDIR $LDDIR cp -a $BIN $BINDIR/ declare -A seen_libs collect_deps() { local file$1 while IFS read -r lib; do [ -z $lib ] continue [ -n ${seen_libs[$lib]:-} ] continue [ -f $lib ] || { echo [warning] missing $lib 2 continue } seen_libs[$lib]1 cp -a -L $lib $LIBDIR/$(basename $lib) collect_deps $lib done (ldd $file | awk / \// {print $3} /^\// {print $1} | sort -u) } collect_deps $BIN INTERP$(readelf -l $BIN | grep interpreter | awk {print $NF} | tr -d ]) if [ -n ${INTERP:-} ]; then cp -a -L $INTERP $LDDIR/ patchelf --set-interpreter \$ORIGIN/ld/$(basename $INTERP) $BINDIR/$(basename $BIN) || true fi patchelf --set-rpath $ORIGIN:$ORIGIN/lib $BINDIR/$(basename $BIN) || true cat $DEST/run.sh EOF #!/bin/bash DIR\$(cd \$(dirname \$0) pwd) export LD_LIBRARY_PATH\$DIR/lib:\${LD_LIBRARY_PATH:-} exec \$DIR/bin/$(basename $BIN) \$ EOF chmod x $DEST/run.sh echo bundle created at $DEST注意脚本里 patchelf 的两条命令都加了|| true原因是并非所有环境都有 patchelf。如果你的打包机上没有安装脚本会跳过修改可执行文件完全靠run.sh里的LD_LIBRARY_PATH来保证运行。在生产环境我建议还是装好 patchelf毕竟它能让二进制自带路径信息减少启动器对环境的依赖。4. 实操中的关键细节与踩坑记录4.1 符号链接处理不当会导致“搬运后悬空”这个问题我专门拿出来说因为我第一次写脚本时在这里栽过跟头。Linux 系统里很多动态库是以“系列链接”方式存在的。比如libfoo.so - libfoo.so.1 - libfoo.so.1.2.3如果ldd给出的路径是/usr/lib/libfoo.so.1而你用cp -a复制这个文件复制过来的是一个符号链接链接指向/usr/lib/libfoo.so.1.2.3。到了目标机器上这个绝对路径不一定存在程序启动就会报“No such file or directory”而实际文件却躺在你打包目录的lib里。更隐蔽的情况是你复制了符号链接也没复制链接目标或者复制了目标但名称不对。动态加载器在搜索库时不是随便找一个文件就能用它期望找到的是符合 SONAME 命名的文件或者至少 ELF 内部 SONAME 与请求匹配。所以稳妥做法就是cp -a -L直接把真实文件复制下来并用basename命名为ldd输出中的文件名。这样虽然失去了多级链接但加载器只关心它请求的libfoo.so.1文件名对得上就能加载。4.2 glibc 版本和架构带来的兼容性边界这是最容易让人沮丧的一个问题。你把依赖库全部复制了程序还是起不来报错可能长这样./my_app: /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.34 not found (required by ./my_app)原因很简单编译机 glibc 版本比目标机高程序在编译时依赖到了高版本的 glibc 符号。动态库可以打包但 glibc 本身是不能随便打包替换的因为它深入到了系统内核接口、NSS、locale 等底层逻辑。强行把编译机的 libc.so.6 复制过去并优先加载通常只会引发段错误或者更诡异的问题。所以打包前最好用ldd --version查看编译机与目标机的 glibc 版本。经验法则是在越低版本的 glibc 环境上编译打包产物越容易在更高版本环境运行反过来就会很痛苦。如果必须在新系统上编译又要兼容老系统可以考虑用 Docker 容器或 chroot 准备一个老版本编译环境。另外还要注意架构。最常见的组合是 x86_64 到 x86_64但如果你做嵌入式交付会遇到 ARM、ARM64、MIPS 等架构。跨架构打包不是“复制文件”能解决的必须在对应架构的编译环境或 sysroot 下准备依赖库。判断文件架构最直接的方式file bin/my_app readelf -h bin/my_app | grep Machine如果目标机器报Exec format error基本就是架构不匹配别再去查依赖库了。4.3 Qt 程序不是“收完 so 就完事”很多 Qt 程序在打包后常遇到这个报错This application failed to start because no Qt platform plugin could be initialized. Available platform plugins are: linuxfb, minimal, xcb, eglfs.原因就是 Qt 的插件机制。Qt 程序在启动时要动态加载platforms插件而插件通常不在可执行文件DT_NEEDED列表里它是运行时通过QApplication::platformName和插件目录查找后dlopen加载的。你只收集libQt5Core.so、libQt5Gui.so、libQt5Widgets.so是不够的。解决方法是把 Qt 安装目录下的plugins整个复制到打包目录并在可执行文件同级目录放一个qt.conf[Paths] Plugins plugins如果你的 Qt 程序还用了 QML那除了plugins还要把qml目录也复制进去并在qt.conf里加上Qml2Imports qml这里没有更好的捷径唯一的经验是先不打包任何依赖直接在目标机上跑一遍看它报什么找不到再针对性补充 Qt 资源。比一次性把所有 Qt 目录复制过去更可控。4.4 交叉编译到 ARM/ARM64 时的注意事项热词区域出现了很多arm和arm64内容这也提醒我值得单独说一段。如果你用 ARM 开发板、嵌入式 Linux或者国产化平台交叉编译后打包的基本思路一样但有几个额外注意点。第一个是动态链接器名称完全不同。x86_64 下是ld-linux-x86-64.so.2ARM 32 位下是ld-linux-armhf.so.3或ld-linux.so.3ARM64 下是ld-linux-aarch64.so.1。你的脚本不能写死解释器路径必须用readelf -l去读取。第二个是库目录结构差异。很多 ARM 发行版把库放在/lib/aarch64-linux-gnu/下但不同厂商的 rootfs 风格不同有的放在/usr/lib/arm-linux-gnueabihf/还有的放在自定义路径。脚本里cp -a -L $lib $LIBDIR/$(basename $lib)这个扁平化策略在 ARM 下反而更省心因为不用担心目录结构差异。第三个是 patchelf 的选择。宿主机器上的 patchelf 能不能修改 ARM 架构的 ELF大多可以但建议使用较新版本。如果遇到报错说 unsupported ELF就在目标架构环境里再跑一次 patchelf或者干脆放弃 patchelf只用run.sh里的LD_LIBRARY_PATH。第四个是某些 ARM 平台的板子内核版本很老即使架构能对上也可能因为内核太老导致程序加载失败。这种情况下优先检查编译时用的--sysroot和工具链版本尽量用目标平台厂商提供的交叉编译工具链打包。5. 常见问题与排查速查表5.1 十几条高频报错速查下面这些报错是我在过去几年打包交付时真实遇到过的整理成表格方便直接对照。报错信息常见原因排查/解决cannot open shared object file: No such file or directory依赖库没复制或路径不对用ldd检查缺失项确认lib目录里有没有对应 soNo such file or directory运行刚复制的二进制时动态链接器缺失或路径不对readelf -l bin/xxx查看 interpreter确认打包目录里有该 ld 文件Exec format error架构不匹配file命令对比程序和机器的架构version GLIBC_2.XX not found目标机 glibc 版本低于编译机换老版本编译环境重新编译version Qt_5.X not found目标机的 Qt 库版本低于程序编译时的版本复制对应版本的 Qt 库到打包目录并确认 SONAME 正确could not find or load the Qt platform plugin xcbQt plugins 没复制或路径不对复制 plugins/platforms 并添加 qt.confundefined symbol: ...库版本不匹配程序用的是编译时的接口运行时加载了其他版本库检查LD_LIBRARY_PATH中是否有旧库干扰确认库版本一致ldd: warning: ... executable should not have LD_LIBRARY_PATH环境变量里留了打包路径清理LD_LIBRARY_PATH用启动器或 rpath 取代kernel too old程序使用了目标内核不支持的特性检查可用性必要时换更老工具链编译Permission denied可执行文件没有执行权限chmod x可执行文件和 run.sh程序秒退、无报错依赖库中有 dlopen 的插件缺失用strace或LD_DEBUGlibs分析加载过程复制整个/lib还是报缺失复制了符号链接但没复制真实文件用cp -aL或readlink -f解析后复制skipping incompatible ... when searching for ...搜索路径中混入其他架构库检查LD_LIBRARY_PATH里的库架构只保留匹配架构readelf: Error: Not an ELF file文件损坏或不是 ELF用file确认必要时重新编译5.2 一个排查思路file 命令先行很多人在排查运行时问题时一上来就改代码、加日志。但依赖问题的排查步骤其实很固定第一步永远是用file看文件基础信息file bin/my_app file lib/libQt5Core.so.5输出里会包含架构、位宽、版本格式比如bin/my_app: ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0这一步能过滤掉大量无意义的问题。如果你在 ARM 板上跑一个 x86_64 的二进制后面所有排查都是白费。第二步再用ldd看依赖缺失如果依赖列表没问题第三步才考虑用LD_DEBUGlibs或strace -f -e openat分析程序到底尝试加载了哪些路径。很多时候程序崩溃前一两行LD_DEBUG输出会直接告诉你它加载了哪个旧的、或错误版本的库。这种现场信息比任何文档都有用。6. 结语脚本之外我更想分享的三个心得这个打包脚本我前前后后重写过三版最早期是暴力复制整个/usr/lib结果程序还是跑不起来反而把目标机器的库环境搞乱了。后来慢慢梳理出三个值得记住的点。第一依赖收集不是“把 so 文件放在一起”就叫打包。动态链接器、SONAME、符号链接、$ORIGIN、插件目录这些环节缺一个都会在目标机器上现场翻车。写脚本时宁愿多留几个warning日志也不要静默跳过找不到的库。第二尽量在接近目标机器环境的容器或虚拟机里编译。glibc 版本和内核版本这两座大山靠打包脚本很难真正搬走。脚本能解决的是“库文件路径问题”而不是“ABI 兼容问题”。第三每一个打包产物在交付前都要做“干净目录自测”。我通常在全新目录里执行一次LD_LIBRARY_PATHlib ./run.sh再用strace验证是否有库加载到系统路径确认通过后再打压缩包。这样能避免“在我机器上明明是好的”这种最常见的尴尬。希望这份脚本和踩坑记录能帮到你。如果你也在做 Linux 程序交付建议从一个小程序开始逐步验证把依赖收集脚本沉淀成自己团队的基础工具后面接再复杂的项目都能省下不少时间。
返回列表