自包含运行时:从 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 交互
两者协作流程:
- QEMU 打开
/dev/kvm,通过KVM_CREATE_VMioctl 创建虚拟机 - 通过
KVM_CREATE_VCPU创建 vCPU,KVM_SET_USER_MEMORY_REGION映射内存 - 调用
KVM_RUN启动 vCPU 执行 - CPU 执行
VMLAUNCH/VMRESUME进入 Guest Mode,直接在硬件上运行客户机代码 - 遇到敏感操作(如 I/O 访问)时触发 VM Exit,退回 KVM 处理
- 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 核心理念对比
| Flatpak | Snap | AppImage | |
|---|---|---|---|
| 自包含方式 | 应用 + 额外依赖;共享 Runtime(如 GNOME/KDE 平台) | 应用 + 额外依赖;共享 Base Snap(如 core22) | 完全自包含:一个文件 = 一个应用 |
| 安装 | 需要 flatpak install | 需要 snap install | 下载即用,无需安装 |
| 运行 | flatpak run com.xxx.App | snap 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 说:给它一个文件。
这些技术不是竞争者,而是不同抽象层次上的同一思想。理解了这一点,你会发现它们不再是孤立的工具,而是一条连续的技术演进路线。
参考资源
Hacker News 讨论: "Shared libraries are not a good thing in general"
KVM 内核文档: docs.kernel.org/virt/kvm/api.html
OCI 规范: opencontainers.org
PhoenixNAP: Flatpak vs. Snap vs. AppImage
AppImage 官方文档: Concepts
Wikipedia: Dependency Hell
延伸阅读: