Posted on :: Source Code ::

自包含运行时:从 QEMU 到 AppImage

最近在折腾 Linux 桌面,接触到了 AppImage、Flatpak、Snap 这些新型的软件打包格式。与此同时,工作中也一直在用 Docker,偶尔还要用 QEMU/KVM 跑虚拟机。有天突然意识到,这些看似风马牛不相及的技术,其实共享着同一个核心思想:把应用和它需要的运行时环境一起打包,自给自足,不依赖宿主系统。

这篇文章就是我的学习笔记,梳理这条线索。


1. 问题:依赖地狱

在 Linux 的世界里,传统软件分发依赖共享库(.so 文件)。一个程序安装时,包管理器(如 APT、DNF)会解析它的依赖图,确保所有需要的库都已安装在系统中。这个模型看起来很优雅:

  • 多个程序共享同一份库,节省磁盘和内存
  • 库的漏洞修复一次,所有程序受益

但现实是残酷的。当程序 A 需要 libfoo.so.1,程序 B 需要 libfoo.so.2,而这两个版本不兼容时,你就陷入了依赖地狱(Dependency Hell)。再加上 Linux 发行版碎片化——Ubuntu 20.04 的库版本和 Fedora 39 的不一样——开发者想发布一个“到处都能跑”的二进制包几乎不可能。

有人甚至说:「共享库是个错误,这个想法二十年前就该被抛弃了。」1

解决思路很简单:既然共享会出问题,那就不共享了。每个应用自带它需要的所有东西。


2. 一条谱系:从底层到上层

如果按照“自包含”的颗粒度从粗到细排列,你会发现一个清晰的技术谱系:

┌──────────────────────────────────────────────────────────┐
│  KVM/QEMU        →  打包整个操作系统(内核 + 用户空间)      │
│  Docker          →  打包用户空间(应用 + 系统库,共享内核)    │
│  Flatpak / Snap  →  打包应用 + 运行时 + 沙箱策略             │
│  AppImage        →  打包应用 + 依赖库,单文件运行             │
└──────────────────────────────────────────────────────────┘

越往下,隔离层越厚,移植性越强,但开销也越大;越往上,越轻量,但依赖宿主越多。


3. KVM / QEMU:打包整个操作系统

KVM(Kernel-based Virtual Machine)是 Linux 内核的一个模块,它利用 Intel VT-x 或 AMD-V 等硬件虚拟化扩展,将 Linux 内核变成一个 Type-1 虚拟机管理程序(Hypervisor)。它于 2007 年合并进 Linux 2.6.20 主线内核2。

在 KVM 架构中,每个虚拟机表现为宿主机上的一个普通进程,其 vCPU 表现为该进程中的线程,由 Linux 的 CFS 调度器统一调度。这使得 KVM 天然继承了 Linux 内核的所有能力——内存管理、进程调度、安全机制(SELinux/AppArmor)等。

QEMU 则运行在用户空间,负责:

  • 创建虚拟机、分配内存、加载固件(如 SeaBIOS)
  • 模拟 I/O 设备(或使用高性能的 VirtIO 半虚拟化设备)
  • 通过 /dev/kvm 设备的 ioctl 接口与 KVM 交互

两者协作流程:

  1. QEMU 打开 /dev/kvm,通过 KVM_CREATE_VM ioctl 创建虚拟机
  2. 通过 KVM_CREATE_VCPU 创建 vCPU,KVM_SET_USER_MEMORY_REGION 映射内存
  3. 调用 KVM_RUN 启动 vCPU 执行
  4. CPU 执行 VMLAUNCH/VMRESUME 进入 Guest Mode,直接在硬件上运行客户机代码
  5. 遇到敏感操作(如 I/O 访问)时触发 VM Exit,退回 KVM 处理
  6. KVM 处理简单情况(如部分 MSR 操作),复杂 I/O 转发给 QEMU

内存虚拟化方面,KVM 使用 EPT(Intel,Extended Page Tables)或 NPT(AMD,Nested Page Tables)实现二级地址翻译:客户机虚拟地址 → 客户机物理地址 → 宿主机物理地址,避免了昂贵的影子页表。

自包含角度:KVM/QEMU 打包的是整个操作系统——内核、驱动、库、应用,一切。这是最彻底的隔离,但开销也最大:每个 VM 都要跑一个完整的 OS。


4. Docker / 容器:打包用户空间

Docker 的核心思想是:既然大家都用 Linux,为什么还要每人跑一个内核? 容器的做法是共享宿主机的 Linux 内核,只打包用户空间——应用 + 系统库 + 文件系统。

4.1 三大基石

Docker 的隔离能力来自 Linux 内核的三个特性3:

内核特性作用类比
Namespaces进程隔离(PID、网络、挂载点、用户等 8 种)给每个容器一个“私密房间”
Cgroups资源限制(CPU、内存、I/O、PID 数量等)防止一个容器吃掉所有资源
UnionFS (OverlayFS)分层文件系统,镜像层只读 + 容器层可写高效存储和镜像复用

Namespaces 让容器内的进程以为自己独占一台机器——有自己的 PID 1、自己的网络栈、自己的根文件系统。实际上,在宿主机上它只是一个普通进程,ps aux 就能看到。

Cgroups(Control Groups)则负责“限流”。docker run --memory=512m --cpus=1.5 背后,Docker 为容器创建了一个 cgroup,并通过内核控制器强制执行这些限制。

4.2 镜像与分层

Docker 镜像的核心是分层。一个典型的 Dockerfile:

FROM ubuntu:22.04          # Layer 1: 基础 OS
RUN apt-get install python3 # Layer 2: 安装 Python
COPY requirements.txt .     # Layer 3: 依赖声明
RUN pip install -r ...      # Layer 4: 安装依赖
COPY . .                    # Layer 5: 应用代码

每一层都是不可变的(immutable),由内容寻址的 SHA-256 哈希标识。多个镜像可以共享相同的基础层,大幅节省存储。容器运行时,Docker 在只读层之上叠加一个薄的可写层(Copy-on-Write),容器停止后可以丢弃或提交为新层。

这遵循了 OCI(Open Container Initiative)标准,确保镜像可以在 Docker、Podman、containerd 等不同运行时之间互操作4。

自包含角度:Docker 打包的是用户空间。应用不需要知道宿主机的库版本,因为所有依赖都封装在镜像里。代价是每个镜像都要带一套文件系统,但共享内核让容器比 VM 轻量得多。


5. Flatpak / Snap / AppImage:打包桌面应用

Docker 解决了服务器端的“在我机器上能跑”问题。那桌面端呢?你下载一个 .deb,发现它依赖 libgtk-4.1,而你的系统只有 libgtk-4.0——这就是 Flatpak、Snap、AppImage 要解决的问题56。

5.1 核心理念对比

FlatpakSnapAppImage
自包含方式应用 + 额外依赖;共享 Runtime(如 GNOME/KDE 平台)应用 + 额外依赖;共享 Base Snap(如 core22)完全自包含:一个文件 = 一个应用
安装需要 flatpak install需要 snap install下载即用,无需安装
运行flatpak run com.xxx.Appsnap run xxx./xxx.AppImage
沙箱默认开启(Bubblewrap + seccomp + Portals)默认开启(AppArmor/seccomp)无(需配合 Firejail)
更新手动 flatpak update自动(snapd 每天检查 4 次)手动(或借助 AppImageUpdate)
存储解压存储(最大)SquashFS 压缩存储压缩存储,单文件
仓库Flathub(支持自定义 remote)Snap Store(中央集中)AppImageHub(去中心化)

5.2 Flatpak:共享 Runtime 的平衡方案

Flatpak 的设计很聪明:它不要求每个应用都打包所有依赖,而是引入Runtime 的概念。Runtime 是一组基础库的大集合(如 Freedesktop Runtime 提供 D-Bus、GLib、GTK3 等),安装一次,所有 Flatpak 应用共享。

如果应用需要的库版本跟 Runtime 不一样,或者需要 Runtime 不提供的库,开发者可以将其打包进应用。这实现了“共享”和“自包含”之间的平衡。

5.3 Snap:Canonical 的全能方案

Snap 由 Canonical(Ubuntu 母公司)开发,理念与 Flatpak 类似,但有几个关键区别:

  • 支持范围更广:不仅限于桌面 GUI 应用,还支持 CLI 工具、服务器服务、IoT 设备
  • 使用 SquashFS 压缩存储,以只读方式挂载运行
  • 中央化的 Snap Store,不支持自定义 remote
  • 自动更新,用户无法关闭(这是很多人不喜欢 Snap 的原因)

5.4 AppImage:最纯粹的自包含

AppImage 的哲学最激进:一个应用 = 一个文件。下载下来,chmod +x,直接运行。不需要安装运行时、不需要 daemon 进程、不需要 root 权限。你可以把它放在 U 盘里,插到任何 Linux 机器上就能跑7。

AppImage 官方明确表示:永远不会引入共享 Runtime 机制,因为那会破坏“一个文件”的简单性8。

它的构建策略是“在旧系统上构建,在新系统上运行”——在旧版 Ubuntu LTS 上编译,以确保它能在所有较新的发行版上运行。同时,AppImage 维护一份 excludelist,列出那些不应打包的“基础库”(如 libc.so.6、libGL.so.1 等),这些库被认为在所有目标系统上必然存在。


6. 本质:自包含运行时的哲学

现在回过头来看,从 QEMU 到 AppImage,这些技术共享的根本思想是什么?

6.1 从“包管理器协调”到“不可变制品”

传统 Linux 模型:

apt install firefox → 解析依赖图 → 安装 47 个依赖包 → 运行

问题在于:依赖图是“运行时”求解的,不同机器、不同时间可能解出不同的结果。

自包含模型:

curl -O https://app.example.com/firefox.AppImage → ./firefox.AppImage

依赖关系在“构建时”就被固定了。开发者打包时确定一切,用户运行时不需要求解任何东西。

6.2 隔离:手段还是目的?

有趣的是,不同技术对“隔离”的态度不同:

技术隔离是...
KVM/QEMU目的——安全、多租户
Docker手段——为了可复现构建和部署
Flatpak/Snap副作用——沙箱安全是自包含的附带收益
AppImage不需要——只做打包,不做隔离

6.3 自包含的代价

没有任何技术是免费的:

  • 磁盘空间:每个应用自带依赖,势必冗余。Flatpak 的 Discord 客户端 237MB,而原生的可能只有几十 MB(但 Flatpak 解压存储,AppImage 和 Snap 的压缩存储小得多)
  • 安全更新:共享库模型下,修复一个库的所有应用都受益。自包含模型下,每个应用都要单独更新
  • 启动时间:Snap 的 SquashFS 挂载有一定开销,Flatpak 的沙箱初始化也有开销,AppImage 通常最快

但这些代价在“它能跑”面前,往往值得接受。


7. 总结

┌──────────────────────────────────────────────────────────────┐
│                     自包含运行时谱系                            │
│                                                              │
│   QEMU/KVM      Docker        Flatpak/Snap     AppImage      │
│   打包整个 OS    打包用户空间    打包应用+运行时    打包单文件应用   │
│   ──────────────────────────────────────────────────→         │
│   隔离最强         ← 通用性 →          ← 最简单                │
│   开销最大         ← 轻量化 →          开销最小                │
│   共享内核? 否    共享内核? 是      共享 Runtime? 部分  共享?否│
└──────────────────────────────────────────────────────────────┘

这一切的核心,都是对同一个问题的回答:如何让软件在任何环境下可预测地运行?

  • QEMU/KVM 说:给它一整个虚拟硬件。
  • Docker 说:给它一个容器镜像,共享内核就行。
  • Flatpak/Snap 说:给它一个带 Runtime 的沙箱。
  • AppImage 说:给它一个文件。

这些技术不是竞争者,而是不同抽象层次上的同一思想。理解了这一点,你会发现它们不再是孤立的工具,而是一条连续的技术演进路线。


参考资源

4

OCI 规范: opencontainers.org

7

AppImage 官方文档: Concepts

8

Wikipedia: Dependency Hell

延伸阅读:

Table of Contents