惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Visual Studio Blog
月光博客
月光博客
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
量子位
人人都是产品经理
人人都是产品经理
IT之家
IT之家
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
罗磊的独立博客
博客园 - 三生石上(FineUI控件)
有赞技术团队
有赞技术团队
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园_首页
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - Franky
The Cloudflare Blog
博客园 - 【当耐特】
Hugging Face - Blog
Hugging Face - Blog
大猫的无限游戏
大猫的无限游戏
S
SegmentFault 最新的问题
Jina AI
Jina AI
阮一峰的网络日志
阮一峰的网络日志
小众软件
小众软件
Last Week in AI
Last Week in AI

Luyu Huang's Blog

程序员是什么阶级 组了一台 NAS 新一代排版系统 Typst 介绍 Clang 编译安装指南 使用 Address Sanitizer 排查内存越界 用 C 语言实现协程 理想国 2023 Annual Review Scheme 语言
AppImage: 一次打包,到处运行
Luyu Huang · 2024-04-19 · via Luyu Huang's Blog

我们知道,不同于 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
2
chmod +x app.AppImage
./app.AppImage

原理

AppImage 的原理是将软件和相关依赖归档成一个磁盘镜像,打包在 AppImage 文件里。这个归档的目录称为 AppDir,它的结构大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
AppDir
├── AppRun
├── icon.svg
├── app.desktop
└── usr
├── bin
│   └── app
├── lib
│   └── x86_64-linux-gnu
│   ├── ld-2.31.so
│   ├── libm.so.6
│   ├── libpthread.so.0
│   └── libc.so.6
└── share
└── icons
   └── icon.svg

运行 AppImage 文件时,其中的磁盘镜像会被挂载到 /tmp/.mount_XXX.XXXXX 上,然后执行其中的 AppRun。AppRun 可以是一个脚本,也可以是一个二进制,它负责做一些前序工作,设置各种环境变量(如 LD_LIBRARY_PATH),然后启动目标程序。

Hello, AppImage

接下来我们动手制作一个 AppImage。我们有一个 C 程序 hello.c

1
2
3
4
5
6
#include <stdio.h>

int main() {
printf("Hello Appimage\n");
return 0;
}

然后编译它 gcc -o hello hello.c。接着我们创建一个 AppDir 目录,将 hello 放到 AppDir/usr/bin/ 中。

1
2
3
4
5
6
7
$ mkdir -p AppDir/usr/bin
$ cp hello AppDir/usr/bin/
$ tree Appdir
AppDir
└── usr
└── bin
└── hello

接着我们要将程序依赖的共享库也打包进去。我们用 ldd 查看 hello 依赖的共享库:

1
2
3
4
$ ldd hello
linux-vdso.so.1 (0x00007ffe66f8f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f849544b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8495650000)

hello 很简单,只依赖 libc。链接器 /lib64/ld-linux-x86-64.so.2 为程序加载各种共享库,是程序的解释器 (interpreter),也需要打包进去。我们把这两个 .so 文件复制到 AppDir 的对应目录:

1
2
3
4
5
6
7
8
9
AppDir
├── lib
│   └── x86_64-linux-gnu
│   └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── usr
└── bin
└── hello

接下来我们创建 AppRun 脚本。这个脚本先设置 LD_LIBRARY_PATH 环境变量,然后用 AppDir 中的链接器加载运行 hello 程序:

1
2
3
4
#!/usr/bin/sh

export LD_LIBRARY_PATH=${APPDIR}/lib/x86_64-linux-gnu
${APPDIR}/lib64/ld-linux-x86-64.so.2 ${APPDIR}/usr/bin/hello

AppImage 运行时环境变量 APPDIR 便是 AppDir 挂载的路径(/tmp/.mount_XXX.XXXXX),我们可以直接在脚本中引用它。最后我们需要一个 desktop 文件配置一些元数据,还要准备一个图标文件:

1
2
3
4
5
6
[Desktop Entry]
Name=hello
Exec=hello
Icon=hello
Type=Application
Categories=Utility;

最终 AppDir 的目录结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
AppDir
├── AppRun
├── hello.desktop
├── hello.svg
├── lib
│   └── x86_64-linux-gnu
│   └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── usr
└── bin
└── hello

要将 AppDir 打包成可执行文件,需要用到的工具是 appimagetool,可以到 Github 下载。appimagetool 本身也是个 AppImage,下载后即可运行。执行 appimagetool AppDir 便可将 AppDir 打包成一个 AppImage。运行它

1
2
$ ./hello-x86_64.AppImage
Hello Appimage

因为它打包了程序所需的所有依赖,所以理论上它可以在任意一个同架构(这里是 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
pip install appimage-builder

要用 appimage-builder 制作 AppImage,我们首先需要准备一个“基础版”的 AppDir,包含软件的可执行文件和一些相关依赖。通常那些 make install 复制到 /usr/local/ 下的文件就是基础 AppDir 应当包含的文件。上面例子的基础 AppDir 结构如下:

1
2
3
4
AppDir
└── usr
└── bin
└── hello

appimage-builder 基于一个 yaml 配置文件制作 AppImage,称为 recipe。我们不必手动创建 recipe,可以用 appimage-builder --generate 命令生成,然后再根据需要修改。generate 命令是一个向导程序,会询问这个应用的基本信息。

1
2
3
4
5
6
7
8
9
10
11
$ appimage-builder --generate
INFO:Generator:Searching AppDir
? ID [Eg: com.example.app]: tech.luyuhuang.hello
? Application Name: hello
? Icon: hello
? Executable path: usr/bin/hello
? Arguments [Default: $@]: $@
? Version [Eg: 1.0.0]: latest
? Update Information [Default: guess]: guess
? Architecture: x86_64
INFO:AppRuntimeAnalyser:/usr/bin/strace -f -E LD_LIBRARY_PATH= -e trace=openat --status=successful AppDir/usr/bin/hello

接着 appimage-builder 会用 strace 运行目标程序,分析它打开了哪些共享库文件;然后用包管理工具分析共享库属于哪个软件包。结束后就会生成 recipe 文件 AppImageBuilder.yml。它的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
version: 1
AppDir:
path: /path/to/AppDir
app_info: # 应用基础信息
id: tech.luyuhuang.hello
name: hello
icon: hello
version: latest
exec: usr/bin/hello
exec_args: $@
apt:
arch:
- amd64
allow_unauthenticated: true
sources: # 用到的软件源
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal main restricted
- sourceline: deb http://archive.ubuntu.com/ubuntu/ focal-updates main restricted
- ...
include: # 用到的软件包
- libc6:amd64
files:
include: [] # 额外需要包含到 AppDir 的文件
exclude: # 需要排除的文件
- usr/share/man
- usr/share/doc/*/README.*
- ...
test: # 测试配置
fedora-30:
image: appimagecrafters/tests-env:fedora-30
command: ./AppRun
debian-stable:
...
AppImage:
arch: x86_64
update-information: guess

我们通常需要关注这些配置:

  • 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
2
3
4
5
6
AppDir:
runtime:
env:
LD_PRELOAD: '${APPDIR}/usr/lib/libjemalloc.so'
path_mappings:
- /bin/bash:$APPDIR/bin/bash
  • 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
2
3
4
5
6
7
8
9
10
11
sudo apt-get install clang libclang-10-dev # 安装依赖
git clone --depth=1 --branch=0.20220729 --recursive https://github.com/MaskRay/ccls # 获取 ccls, 版本 0.20220729
cd ccls
cmake -H. -BRelease -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH=/usr/lib/llvm-10 \
-DLLVM_INCLUDE_DIR=/usr/lib/llvm-10/include \
-DLLVM_BUILD_INCLUDE_DIR=/usr/include/llvm-10/ \
-DCMAKE_INSTALL_PREFIX=/usr # 设置 prefix 为 /usr
cd Release
make -j8
make install DESTDIR=AppDir # 安装到 ./AppDir

这样我们就有了基础 AppDir:

1
2
3
4
AppDir
└── usr
└── bin
└── ccls

接着我们执行 appimage-builder --generate 生成 recipe:

1
2
3
4
5
6
7
8
9
10
$ appimage-builder --generate
INFO:Generator:Searching AppDir
? ID [Eg: com.example.app]: com.github.MaskRay.ccls
? Application Name: ccls
? Icon: ccls
? Executable path relative to AppDir [usr/bin/app]: usr/bin/ccls
? Arguments [Default: $@]: $@
? Version [Eg: 1.0.0]: latest
? Update Information [Default: guess]: guess
? Architecture: x86_64

根据 ccls 的文档(和我的测试结果),ccls 运行时要访问 clang 的 lib 目录。我的 clang 是用 apt 安装的,路径在 /usr/lib/llvm-10/lib/clang/10.0.0。我们需要把这个路径打包进 AppDir,并且将其映射到 AppDir 内。我们修改 AppImageBuilder.yml:

1
2
3
4
5
6
7
AppDir:
files:
include:
- /usr/lib/llvm-10/lib/clang/10.0.0/** # 把这个路径下的全部文件包含进去
runtime:
path_mappings:
- /usr/lib/llvm-10/lib/clang/10.0.0:$APPDIR/usr/lib/llvm-10/lib/clang/10.0.0 # 映射到 AppDir 内

然后我们还要创建个图标。虽然是命令行程序,但是 AppImage 要求每个应用都要有个图标,所以没办法。这里我们就 touch 一个空文件就好:

1
2
mkdir -p AppDir/usr/share/icons
touch AppDir/usr/share/icons/ccls.svg

最后执行 appimage-builder --recipe AppImageBuilder.yml 生成 AppImage。大功告成!

1
2
3
$ ./ccls-latest-x86_64.AppImage --version
ccls version 0.20220729-0-g7445891
clang version 10.0.0-4ubuntu1

总结

Linux 的软件管理方式虽然节省了磁盘和内存空间,但是也增加了软件安装的难度。导致 Linux 的软件要么进入发行版使用包管理器安装;要么发布源码,编译安装。前者虽然安装方便,但是版本受限,不能随意升级;后者需要准备开发环境,安装较为麻烦。当编译依赖的工具链不满足要求时,软件安装会变得很棘手。

针对这个问题,Linux 有几种解决方案,例如 snap、容器,以及本文介绍的 AppImage 等。它们的解决思路其实差不多,都是将软件与其依赖一起打成包发布。它们各有优劣,对于 AppImage 来说,优点就是使用方便,用户不需要安装任何环境,下载 AppImage 即可执行;缺点是依赖于 AppRun 的前序处理,兼容性可能不如 snap 和容器。个人感觉 Linux 桌面系统要想推广,软件安装还是要走 Windows 和 macOS 这种形态,即打包软件依赖,降低安装门槛,提高兼容性。

参考资料: