AppImage: 一次打包,到处运行
我们知道,不同于 Windows 将软件的所有文件安装在一个目录,一个 Linux 软件的不同部分会被安装在不同路径。例如,可执行文件安装在 /usr/bin
下;库文件安装在 /usr/lib
下;文档、脚本等资源文件通常安装在 /usr/share
下等。这是因为 Linux 认为软件包之间会相互依赖,不同的软件可能依赖于同一个库,那么这个库就只应该存在一份。例如 curl, ssh 和 nginx 都依赖于 libcrypt.so 这个共享库,其为 openssl 的一部分。当我们使用 apt
安装 nginx 时,先会检测 openssl 是否已经安装。如果没有,就先安装 openssl;否则直接安装 nginx。
这样做的好处是可以节省磁盘:所有软件依赖的相同的库只存在一份。因此安装一个 Linux 系统通常只需要几 G 的磁盘空间,而 Windows 通常需要几十 G。同时可以节省内存,因为共享库的加载方式是 mmap,同一个共享库在内存中也只有一份。
但是这么做是有代价的。假设 A, B 软件都依赖于库 L,那么 A 和 B 就只能依赖于同一个版本的库 L。一台机器上有这么多软件,意味着整个依赖网络让他们相互钳制,版本号被限制,不能随意升级。一个发行版会确定各种软件包的版本(确定主次版本号,补丁号通常不做限制),组成软件库,确保它们相互兼容,没有依赖冲突。也就是说 apt
安装的软件版本由当前 Ubuntu 版本决定的。这也是为什么 Linux 发行版通常每年都要发布一个新版本,否则软件库会落后于时代。
如果你在用一个较老的发行版,想安装一些新软件,通常需要自己编译。自己编译的软件通常安装在 /usr/local/*
下,与 /usr/*
区分开。但是我公司用的开发环境的发行版太老了,g++ 版本 4.8,只支持 C++ 11,无法编译要求支持 C++ 17 的新软件。更糟糕的是这个发行版的 glibc 版本也非常老,新软件即使在新系统中编译出来,也无法在这个老系统上运行。而 gcc 工具链(包括 glibc)是操作系统的一部分,不能随意升级。
要是能像 Windows 一样将软件的依赖的各种 DLL 都打包到一起就好了!Linux 有类似的解决方案,AppImage 就是其中一种。它可以将软件打包成一个二进制 AppImage 文件,这是一个标准的 ELF 可执行文件。用户下载 AppImage 文件后,直接 chmod +x
后就可以直接运行,非常方便。对于 AppImage 来说,一个软件就是一个可执行文件。
1 |
|
原理
AppImage 的原理是将软件和相关依赖归档成一个磁盘镜像,打包在 AppImage 文件里。这个归档的目录称为 AppDir,它的结构大概是这样的:
1 |
|
运行 AppImage 文件时,其中的磁盘镜像会被挂载到 /tmp/.mount_XXX.XXXXX
上,然后执行其中的 AppRun。AppRun 可以是一个脚本,也可以是一个二进制,它负责做一些前序工作,设置各种环境变量(如 LD_LIBRARY_PATH
),然后启动目标程序。
Hello, AppImage
接下来我们动手制作一个 AppImage。我们有一个 C 程序 hello.c
1 |
|
然后编译它 gcc -o hello hello.c
。接着我们创建一个 AppDir
目录,将 hello
放到 AppDir/usr/bin/
中。
1 |
|
接着我们要将程序依赖的共享库也打包进去。我们用 ldd
查看 hello
依赖的共享库:
1 |
|
hello
很简单,只依赖 libc。链接器 /lib64/ld-linux-x86-64.so.2
为程序加载各种共享库,是程序的解释器 (interpreter),也需要打包进去。我们把这两个 .so 文件复制到 AppDir 的对应目录:
1 |
|
接下来我们创建 AppRun
脚本。这个脚本先设置 LD_LIBRARY_PATH
环境变量,然后用 AppDir 中的链接器加载运行 hello
程序:
1 |
|
AppImage 运行时环境变量 APPDIR
便是 AppDir 挂载的路径(/tmp/.mount_XXX.XXXXX
),我们可以直接在脚本中引用它。最后我们需要一个 desktop 文件配置一些元数据,还要准备一个图标文件:
1 |
|
最终 AppDir 的目录结构是这样的:
1 |
|
要将 AppDir 打包成可执行文件,需要用到的工具是 appimagetool,可以到 Github 下载。appimagetool 本身也是个 AppImage,下载后即可运行。执行 appimagetool AppDir
便可将 AppDir 打包成一个 AppImage。运行它
1 |
|
因为它打包了程序所需的所有依赖,所以理论上它可以在任意一个同架构(这里是 X86_64)的 Linux 系统上运行,无论这个系统的 libc 版本是多少。你也可以修改这个程序,让它引用一些较新的 libc 里才有的函数(如 gettid
, glibc 2.30 被加入),打包成 AppImage 后再发给一个老系统(如 CentOS 7),看看它能不能正常运行。
使用 appimage-builder
上面例子中的程序很简单,只依赖一个 libc。而实际情况下程序通常依赖很多共享库,这些共享库有可能又依赖更多其它的共享库。手动找出来非常麻烦,我们可以使用工具。appimage-builder 就是一个很方便的工具。它的原理是运行目标程序,分析它访问了哪些共享库;然后使用包管理器(如 apt)获取依赖,并制作成 AppDir。此外它还提供了一个功能强大的 AppRun,支持路径映射,通过 hook 程序的文件访问函数,将指定路径映射到 AppDir 中。
appimage-builder 是一个 Python 工具,可以使用 pip 安装:
1 |
|
要用 appimage-builder 制作 AppImage,我们首先需要准备一个“基础版”的 AppDir,包含软件的可执行文件和一些相关依赖。通常那些 make install
复制到 /usr/local/
下的文件就是基础 AppDir 应当包含的文件。上面例子的基础 AppDir 结构如下:
1 |
|
appimage-builder 基于一个 yaml 配置文件制作 AppImage,称为 recipe。我们不必手动创建 recipe,可以用 appimage-builder --generate
命令生成,然后再根据需要修改。generate 命令是一个向导程序,会询问这个应用的基本信息。
1 |
|
接着 appimage-builder 会用 strace
运行目标程序,分析它打开了哪些共享库文件;然后用包管理工具分析共享库属于哪个软件包。结束后就会生成 recipe 文件 AppImageBuilder.yml
。它的结构如下:
1 |
|
我们通常需要关注这些配置:
AppDir.apt
软件包相关信息,由 generate 命令探测出,通常不用自行修改。include
为要用到的软件包,sources
是这些软件包相关的软件源。AppDir.files
控制要包含哪些文件。支持使用 Glob 表达式(如*
和**
通配符)匹配文件路径。include
为需要包含到 AppDir 的文件的绝对路径列表,这些文件会被复制到 AppDir 中的对应位置。例如/usr/bin/bash
对应$APPDIR/usr/bin/bash
。exclude
则为需要在 AppDir 中排除的文件路径列表,路径相对于 AppDir。
AppDir.test
为测试环境。appimage-builder 会拉取其中指定的 Docker 镜像,并在其中测试 AppDir。
除这些自动生成的配置外,还有很实用的运行时配置。
1 |
|
env
指定运行时的环境变量。appimage-builder 自带的 AppRun 程序还支持一些特殊的环境变量APPDIR_EXEC_ARGS
程序的命令行参数,默认为$@
,即原样透传传给 AppRun 的参数。APPDIR_LIBRARY_PATH
共享库搜索路径,效果等同于LD_LIBRARY_PATH
。
path_mappings
设置路径映射。支持将一个绝对路径映射到 AppDir 中的路径,格式为源路径:目标路径
。例如/bin/bash:$APPDIR/bin/bash
,每当程序访问/bin/bash
都会实际访问 AppDir 中的bin/bash
。
准备好 recipe 文件后执行 appimage-builder --recipe AppImageBuilder.yml
即可生成 AppImage。也可以加上 --skip-tests
跳过测试。
实战:制作 ccls 的 AppImage
ccls 是一个 C++ 的 language server。我想在公司的开发环境用上 ccls,但是 ccls 依赖的工具链和运行时环境都比较新,无法直接在公司的开发环境上编译、运行。因此我准备在 Ubuntu 20.04 下编译 ccls 并制作成 AppImage,让这个老系统也能用上新软件。
执行如下命令构建 ccls:
1 |
|
这样我们就有了基础 AppDir:
1 |
|
接着我们执行 appimage-builder --generate
生成 recipe:
1 |
|
根据 ccls 的文档(和我的测试结果),ccls 运行时要访问 clang 的 lib 目录。我的 clang 是用 apt 安装的,路径在 /usr/lib/llvm-10/lib/clang/10.0.0
。我们需要把这个路径打包进 AppDir,并且将其映射到 AppDir 内。我们修改 AppImageBuilder.yml
:
1 |
|
然后我们还要创建个图标。虽然是命令行程序,但是 AppImage 要求每个应用都要有个图标,所以没办法。这里我们就 touch 一个空文件就好:
1 |
|
最后执行 appimage-builder --recipe AppImageBuilder.yml
生成 AppImage。大功告成!
1 |
|
总结
Linux 的软件管理方式虽然节省了磁盘和内存空间,但是也增加了软件安装的难度。导致 Linux 的软件要么进入发行版使用包管理器安装;要么发布源码,编译安装。前者虽然安装方便,但是版本受限,不能随意升级;后者需要准备开发环境,安装较为麻烦。当编译依赖的工具链不满足要求时,软件安装会变得很棘手。
针对这个问题,Linux 有几种解决方案,例如 snap、容器,以及本文介绍的 AppImage 等。它们的解决思路其实差不多,都是将软件与其依赖一起打成包发布。它们各有优劣,对于 AppImage 来说,优点就是使用方便,用户不需要安装任何环境,下载 AppImage 即可执行;缺点是依赖于 AppRun 的前序处理,兼容性可能不如 snap 和容器。个人感觉 Linux 桌面系统要想推广,软件安装还是要走 Windows 和 macOS 这种形态,即打包软件依赖,降低安装门槛,提高兼容性。
参考资料: