第一次意识到"装虚拟机"这件事不一定非要在自己电脑上完成,是在一次远程交付现场。客户的机器没有虚拟化权限,管理员密码锁在千里之外的运维手里,我手头只有一个浏览器。那时候我才认真琢磨 Docker 里跑 QEMU 这条路——把 QEMU 装进容器,用 noVNC 把 VNC 画面推到网页上,宿主机放机房,任何一台有浏览器的设备都能连上去操作虚拟机。这套方案断断续续跑了大半年,期间踩过不少坑,也总结出一套相对稳定的部署流程。这篇文章就把完整链路、关键配置、常见故障和扩展玩法一次讲透,适合想搭"浏览器可控虚拟机平台"的研发同学、测试团队或者自建实验室的人参考。
1. 为什么要把 QEMU 塞进 Docker,还要用浏览器去操作
1.1 这套方案到底解决了什么痛点
传统玩法是每台工作机装 VirtualBox 或 VMware,本地建虚拟机,本地打开图形界面操作。短期用着还行,一旦机器多了就乱:镜像散落在不同电脑上,版本对不上;排障时我得远程到对方桌面,还得等他先把虚拟机开起来。更麻烦的是宿主机之间互相抢虚拟化资源——同一台电脑上同时装 VMware 和 VirtualBox,VT-x 经常被其中一个锁住,另一个直接报错。
把 QEMU 搬进 Docker 之后,整个局面变了。虚拟机跑在服务器上,本地只需要一个浏览器,通过 noVNC 就能看到完整的虚拟屏幕、键盘、鼠标,操作手感跟本地虚拟机几乎没有差别。镜像、磁盘文件、启动参数全部收进容器,用 Dockerfile 和 docker-compose.yml 管理,团队里任何一个人都能用同一条命令拉起一套一模一样的实验环境。宿主机集中放在机房或者云上,资源利用率比个人电脑高得多,也给后续的权限控制、审计留了窗口。
1.2 哪些人适合用这套架构
我实际用下来,下面几类场景特别适合这种 Docker + QEMU + 浏览器的组合:
- 跨架构测试:x86 服务器上模拟 ARM64 环境,编译产物直接扔进模拟器里跑,不需要买实体 ARM 板卡。这是"qemu模拟arm64"被搜爆的主要原因。
- 团队共享测试环境:测试同事不用装任何客户端,打开浏览器输入 URL 就能进虚拟机,测完一键销毁重建。
- 教学和演示:给学员发一个链接,任何人都能连进同一样式的虚拟机环境,省去"我装的是 32 位还是 64 位"这类十万个为什么。
- 个人 Homelab:一台 NAS 或小主机,跑几个 Docker 容器,每个容器里一台虚拟机,浏览器访问,手机也能凑合操作。
2. 底层架构拆解:KVM 透传、VNC 协议和 noVNC 的关系
2.1 QEMU 的两种执行模式:TCG 纯模拟与 KVM 加速
QEMU 本身是个万能模拟器,它有两种截然不同的工作模式。
第一种是纯软件模拟(TCG,Tiny Code Generator)。QEMU 把客户机的每一条指令翻译成宿主机指令来执行,不需要 CPU 的虚拟化扩展,兼容性极强,但性能差。实测在没有 KVM 的情况下跑一个 Ubuntu 桌面虚拟机,开机都要好几分钟,装软件更是煎熬。
第二种是硬件加速(KVM)。宿主机把/dev/kvm设备透传给 QEMU,QEMU 变成轻量级 hypervisor,让虚拟机指令直接跑在 CPU 上,性能接近裸机。这个模式要求 CPU 支持 VT-x/AMD-V,并且宿主机已加载 KVM 模块。
| 对比项 | TCG 纯模拟 | KVM 加速 |
|---|---|---|
| CPU 虚拟化扩展要求 | 不要求 | 必须支持 VT-x/AMD-V |
| 性能 | 大约比原生慢 10~50 倍 | 接近原生性能 |
| 适用场景 | 跨架构模拟、临时测试 | 同架构高性能虚拟化 |
| Docker 透传设备 | 无 | 需--device=/dev/kvm |
部署时我会优先尝试 KVM,透传失败则由 QEMU 自动回退到 TCG。搞清楚当前容器到底跑在哪种模式下,是后续排查性能问题的关键,这一点在第 4 章会详细展开。
2.2 noVNC 怎么把 VNC 变成浏览器能打开的页面
QEMU 自带 VNC server,启动虚拟机时加一个-vnc :0参数,QEMU 就会在 5900 端口监听 VNC 协议。但 VNC 协议走的是原始 TCP,浏览器直接连不了,这时候就需要 noVNC 登场。
noVNC 是一个纯前端的 VNC 客户端,配合 websockify 这个 WebSocket 代理使用。流程是这样:浏览器先发起 WebSocket 连接到 websockify(默认 6080 端口),websockify 把 WebSocket 数据包翻译成原始 TCP 数据,转发给 QEMU 的 VNC 端口。整个链路里的数据"翻译"过程对用户完全透明,浏览器里的<canvas>元素负责实时渲染虚拟屏幕,键盘鼠标事件则反过来通过 WebSocket 回传。
从用户视角看,最终效果就是打开一个网页,看到虚拟机的桌面,直接在网页里操作。这里的核心要点是端口规划要理顺:
- QEMU VNC 端口:5900(display 编号 0 对应 5900,display 1 对应 5901,以此类推)
- websockify 监听端口:6080(对外只暴露这一个端口即可)
- noVNC 网页资源:websockify 启动时用
--web参数指定网页文件目录
2.3 容器的网络、设备透传和存储规划
这一层很多人会忽略,但它直接决定了部署是否稳。先看网络方案。桥接模式下,容器有自己的虚拟网卡,端口通过-p 6080:6080映射到宿主机,多台容器互不冲突。host 模式下容器直接用宿主机网络,端口直接用但容易碰撞,我一般只用桥接。
再看设备透传。KVM 加速必须把宿主机的/dev/kvm设备映射进容器:
--device=/dev/kvm如果容器里还希望虚拟机做网卡桥接或者创建复杂网络拓扑,往往还需要--cap-add=NET_ADMIN和--cap-add=NET_RAW。这个配置在 Docker Compose 里对应devices和cap_add字段。
存储方面,虚拟机的磁盘文件(qcow2 格式)必须放在持久化卷里,否则容器删了磁盘也一起没了:
-v /opt/vm/data:/data我把-v挂载目录固定为/data,虚拟机磁盘统一放这里面,备份、迁移都方便。
3. 完整部署步骤:写 Dockerfile、起容器、装系统、连浏览器
3.1 宿主机准备:先确认虚拟化能力
在写 Dockerfile 之前,先在宿主机上执行这几条命令,确认硬件虚拟化条件是否满足:
# 检查 CPU 是否支持虚拟化扩展,输出大于等于 1 说明支持 egrep -c '(vmx|svm)' /proc/cpuinfo # 检查 KVM 设备是否存在 ls -l /dev/kvm如果第一条命令输出为 0,基本可以断定 BIOS 里的 VT-x/AMD-V 没开。如果/dev/kvm不存在,即使 CPU 支持,也需要先加载 KVM 内核模块:
modprobe kvm_intel # Intel CPU modprobe kvm_amd # AMD CPU处理完这些再安装 Docker。Linux 服务端直接装 Docker Engine 和 Compose 插件即可;Windows 上则需要 Docker Desktop + WSL2 后端,并且 BIOS 里必须启用"虚拟机平台"和"适用于 Linux 的 Windows 子系统"这两个功能,这也是很多人反复重启后仍报虚拟化检测失败的原因,第 4 章会专门拆解。
3.2 镜像构建:一条 Dockerfile 打通 QEMU、noVNC 和 supervisor
QEMU、VNC server、websockify、noVNC 页面这四样东西需要同时跑在容器里。我直接用 supervisor 把这些进程统一管起来,避免手写一堆后台启动脚本。
FROM debian:bookworm-slim RUN apt-get update && \ apt-get install -y --no-install-recommends \ qemu-system-x86 qemu-system-aarch64 qemu-utils \ novnc websockify supervisor ca-certificates && \ rm -rf /var/lib/apt/lists/* RUN mkdir -p /data /iso /opt/novnc COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf EXPOSE 6080 CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]对应 supervisord.conf 配置同样很关键:
[supervisord] nodaemon=true [program:novnc] command=websockify --web=/usr/share/novnc 6080 localhost:5900 autorestart=true [program:qemu] command=qemu-system-x86_64 -m 2048 -smp 2 -hda /data/ubuntu.qcow2 -vnc :0 autorestart=false注意这里我特意没加-enable-kvm,先让 QEMU 自己跑起来,再在容器里验证/dev/kvm是否存在。如果确实存在,启动 QEMU 时手动加上这个参数,或者干脆把-enable-kvm写进启动参数里,让 QEMU 在设备不可用时自动报错,方便定位问题。
3.3 用 docker compose 创建磁盘、启动容器
在工作目录下建好目录结构:
vm-platform/ ├── Dockerfile ├── docker-compose.yml ├── supervisord.conf └── data/docker-compose.yml 内容如下:
services: qemu-novnc: build: . container_name: qemu-novnc ports: - "6080:6080" devices: - "/dev/kvm:/dev/kvm" cap_add: - NET_ADMIN - NET_RAW volumes: - ./data:/data - ./iso:/iso restart: unless-stopped第一次启动前,先用 qemu-img 创建虚拟磁盘。这里我建议在宿主机上先拉一个系统镜像 ISO,放在./iso目录下,然后进入容器执行:
docker compose build docker compose run --rm qemu-novnc qemu-img create -f qcow2 /data/ubuntu.qcow2 20G docker compose up -d磁盘创建好之后,supervisor 会自动把 QEMU 拉起来。但要注意一个问题:上面 supervisord.conf 里 QEMU 的-hda指向的是ubuntu.qcow2,如果你创建的是其他名字的磁盘,得先把这条命令改对,再启动容器。
3.4 从浏览器安装系统并完成首次启动
容器起来后,浏览器访问宿主机的 6080 端口:
http://你的服务器IP:6080/vnc.htmlnoVNC 页面会自动尝试连接,看到连接界面后点 Connect,就能看到 QEMU 的启动画面了。因为磁盘是空的,QEMU 会从 ISO 引导。但前面那个 supervisord 配置里并没有-cdrom参数,所以想要从 ISO 装系统,第一次启动时可以把 QEMU 启动命令临时改成:
[program:qemu] command=qemu-system-x86_64 -m 2048 -smp 2 -hda /data/ubuntu.qcow2 -cdrom /iso/ubuntu-24.04-server.iso -boot d -vnc :0或者在容器里手动停掉 supervisor 管理的 QEMU 进程,用上面的命令前台跑一次,等系统安装完成再恢复。整个安装过程画面都在浏览器里,跟本地虚拟机体验差别不大。
安装完成后,我给 QEMU 的启动参数补上几个生产中必要的项:
qemu-system-x86_64 \ -enable-kvm \ -m 2048 -smp 2 \ -hda /data/ubuntu.qcow2 \ -vnc :0 \ -usbdevice tablet \ -k en-us-usbdevice tablet是为了让 noVNC 的鼠标指针不再漂移,这个坑务必要提前踩平,否则后面操作会非常痛苦。
4. 踩坑实录:我从这些故障里学到的教训
4.1 Docker Desktop 虚拟化检测失败的连锁反应
Windows 上装 Docker Desktop,很多人会卡在启动报错:"Docker Desktop failed to start because virtualisation support wasn't detected." 或者搜索框里常见的"适用于linux的windows子系统和虚拟机平台。这两个勾不上每次重启他都会恢复原状"。
这个问题的根源往往不是 Docker 本身,而是 Windows 功能开关和 BIOS 设置配合不上。正确排查路径是:
- 进入 BIOS,确认 SVM(AMD)或 VT-x(Intel)已开启,注意部分主板还需要开启"VT-d"。
- 打开"Windows 功能"窗口,勾选"适用于 Linux 的 Windows 子系统"和"虚拟机平台"。如果勾选后每次重启都恢复原状,说明 Hyper-V 相关组件未完全安装,需要在管理员 PowerShell 里执行:
执行完重启机器,再重新打开功能窗口确认。Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform - Docker Desktop 设置里选 WSL2 后端,确认默认发行版已安装。
这一步每台机器情况都不一样,但核心原则是:先 BIOS、后 Windows 功能、再 Docker Desktop 设置,顺序不能乱。
4.2 KVM 透传失败时的性能断崖
容器里没有/dev/kvm,QEMU 会默默回退到 TCG 模式。如果你没注意,虚拟机也能跑起来,只是慢到让你怀疑人生。CPU 密集型的编译任务从几分钟拉长到几个小时,这种情况最容易在踩坑初期出现。
判断当前是 KVM 还是 TCG,最直接的方法是在 noVNC 里按Ctrl+Alt+2进入 QEMU monitor,输入:
info kvm输出kvm support: enabled就是 KVM 模式,否则就是 TCG。另一个快速判断是看启动速度:虚拟机开机如果超过三分钟还进不了系统,基本可以断定走了 TCG。
如果容器里确实没有 KVM 设备,先回宿主机执行ls -l /dev/kvm看看设备是否存在。存在的话,多半是 Docker Compose 的devices配置写错或者容器没重建,修正后docker compose up -d --force-recreate重新创建容器即可。实在没有 KVM 的环境,只能接受 TCG 的性能,或者考虑换一台支持嵌套虚拟化的云主机。
4.3 模拟 ARM64 时最容易掉进去的三个坑
模拟 ARM64 的完整命令格式跟 x86 差别很大,光是把之前 x86 的启动命令换个qemu-system-aarch64是起不来的。我总结三个高频坑:
机器类型和 CPU 型号。ARM64 模拟必须显式指定机器类型和 CPU:
qemu-system-aarch64 -M virt -cpu cortex-a57 ...-M virt是通用的 ARM 虚拟化机器类型,-cpu cortex-a57是比较常见的模拟 CPU,也可以换成cortex-a72或max。
UEFI 固件缺失。ARM64 不像 x86 那样有 BIOS,必须加载 UEFI 固件。Debian/Ubuntu 下一般需要:
qemu-system-aarch64 -M virt -cpu cortex-a57 \ -bios /usr/share/qemu-efi-aarch64/QEMU_EFI.fd \ ...如果QEMU_EFI.fd不存在,说明没装qemu-efi-aarch64软件包。
系统镜像选型。ARM64 模拟对 ISO 镜像的兼容性比 x86 挑剔得多。很多 x86 的安装 ISO 放到 ARM64 QEMU 里连引导都过不去。我推荐直接用云镜像或者专门的 ARM64 Server ISO,比如 Ubuntu 的 ARM64 Server ISO 或者 Debian 的 arm64 netinst ISO,成功率会高很多。
启动后的 VNC 和 noVNC 配置跟 x86 完全一致,-vnc :0、-usbdevice tablet这些参数照抄就行。
4.4 noVNC 鼠标漂移和快捷键失效的修复
noVNC 的鼠标指针漂移是最磨人的问题。浏览器里看到的鼠标和虚拟机里的鼠标位置对不上,点都点不准。原因是 VNC 默认使用相对坐标定位,但浏览器端的触摸屏、高分屏缩放会把坐标搞乱。解决办法是让 QEMU 使用绝对坐标的 USB 平板设备:
-usbdevice tablet加了这条参数后,鼠标位置由虚拟机直接获取绝对坐标,不再依赖相对移动,漂移问题基本消失。
快捷键方面,noVNC 的 Ctrl+Alt+Del 这类组合键需要从 noVNC 的菜单里发送,直接按键盘是无效的。Ctrl+Alt+2 切换 QEMU monitor 这类特殊键,在 noVNC 里通常也要通过菜单或粘贴面板操作。如果键盘布局不对,检查 QEMU 的-k参数,例如美式键盘用-k en-us。
5. 进阶实战:多虚拟机编排、磁盘扩容、快照恢复
5.1 用多容器编排跑多台虚拟机
单台虚拟机跑通了,接下来的需求一定是同时跑多台。我倾向于每个 QEMU 进程一个容器,通过 Compose 来管理。
新建一个docker-compose.multi.yml:
services: vm1: image: qemu-novnc:latest container_name: vm1 environment: - VM_DISK=/data/vm1.qcow2 - VM_MEMORY=2048 - VM_SMP=2 ports: - "6081:6080" devices: - "/dev/kvm:/dev/kvm" volumes: - ./data/vm1.qcow2:/data/vm1.qcow2 vm2: image: qemu-novnc:latest container_name: vm2 environment: - VM_DISK=/data/vm2.qcow2 - VM_MEMORY=4096 - VM_SMP=4 ports: - "6082:6080" devices: - "/dev/kvm:/dev/kvm" volumes: - ./data/vm2.qcow2:/data/vm2.qcow2这里需要把 supervisor 配置改成支持环境变量注入的形式,让 QEMU 启动命令从$VM_DISK、$VM_MEMORY、$VM_SMP读取参数。不同容器暴露不同的宿主机端口(6081、6082),浏览器分别访问对应端口即可。
资源限制也不能忘。在容器配置里加deploy.resources.limits或--cpus、--memory,防止某台虚拟机把宿主机内存吃爆。
5.2 磁盘扩容与回收空间
qcow2 磁盘是动态增长的,但用久了也不是无限制变大。扩容流程分两步。先扩展磁盘文件本身:
qemu-img resize /data/vm1.qcow2 40G然后在虚拟机内部调整分区和文件系统:
# 在虚拟机里执行,假设分区是 /dev/vda1,文件系统是 ext4 sudo growpart /dev/vda 1 sudo resize2fs /dev/vda1回收空间则要反过来:虚拟机里用fstrim或者删除大文件后,宿主机上执行:
qemu-img convert -O qcow2 /data/vm1.qcow2 /data/vm1_compact.qcow2convert会把磁盘里未使用的块去掉,新文件比旧文件小很多,然后再替换旧文件。
5.3 快照、回滚与备份
QEMU 自带的快照功能对测试场景非常实用。创建快照:
qemu-img snapshot -c clean_state /data/vm1.qcow2列出现有快照:
qemu-img snapshot -l /data/vm1.qcow2回滚到某次快照:
qemu-img snapshot -a clean_state /data/vm1.qcow2注意,做快照前最好让虚拟机处于关机状态或者把磁盘设为只读模式,否则快照可能不一致。生产环境里我还会再加一层宿主机层面的备份,直接在数据目录做 rsync 或者定期用qemu-img convert导出完整磁盘,双保险。
整个这套部署方案,从单台虚机起步,到多机编排、快照恢复,链路打通之后,后续维护成本其实很低。我个人实际用下来的体会是,最难啃的部分永远是"虚拟化能力是否正确透传"——这个问题排查清楚了,剩下的都是常规配置。另外一个值得提早做的小事是给 noVNC 页面加一个简单的访问令牌,或者说在 websockify 前面套一层反向代理做认证。这套平台一旦开放出去,任何拿到 URL 的人都能操作你的虚拟机,安全边界必须提前划好。