仓库源文站点原文


key: 77 title: "AppImage: 一次打包,到处运行"

tag: [linux, tools]

我们知道,不同于 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 来说,一个软件就是一个可执行文件。

chmod +x app.AppImage
./app.AppImage

原理

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

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

#include <stdio.h>

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

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

$ mkdir -p AppDir/usr/bin
$ cp hello AppDir/usr/bin/
$ tree Appdir
AppDir
└── usr
    └── bin
        └── hello

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

$ 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 的对应目录:

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 程序:

#!/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 文件配置一些元数据,还要准备一个图标文件:

[Desktop Entry]
Name=hello
Exec=hello
Icon=hello
Type=Application
Categories=Utility;

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

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。运行它

$ ./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 安装:

pip install appimage-builder

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

AppDir
└── usr
    └── bin
        └── hello

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

$ 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。它的结构如下:

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:
  runtime:
    env:
      LD_PRELOAD: '${APPDIR}/usr/lib/libjemalloc.so'
    path_mappings:
        - /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:

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:

AppDir
└── usr
    └── bin
        └── ccls

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

$ 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:

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 一个空文件就好:

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

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

$ ./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 这种形态,即打包软件依赖,降低安装门槛,提高兼容性。

参考资料: