
本文已同步发布到微信公众号「人言兑」
👈 扫描二维码关注,第一时间获取更新!
Xvfb + KasmVNC:无显示器服务器的 GUI 应用方案
本文详细讲解两个经典技术:虚拟显示 Xvfb 和 VNC 服务器 KasmVNC。读完后你不仅能理解它们各自做什么、如何配合,还能在自己的项目中运用这套方案。
本文适合人群:
假设你想要:
在自己的服务器(NAS/云主机)上跑微信,这样就能让多个人/设备打开浏览器就能操作同一个微信,可以一起处理消息
这个需求看似简单,其实涉及几个技术难点:
传统方案对比:
| 方案 | 虚拟机 + RDP | Xvfb + VNC | Xvfb + KasmVNC |
|---|---|---|---|
| 资源占用 | 很高(完整 OS) | 中等 | 低(仅进程级) |
| 多用户 | 单用户 | 需配置 | 原生支持 |
| 浏览器 | 需客户端 | 需第三方工具 | 原生 WebSocket |
| 部署难度 | 高 | 中 | 低(Docker) |
Xvfb + KasmVNC 方案的优势:
在你的 PC 上:
┌─────────────────────────────────┐
│ 你的程序 │
│ puts("Hello") │
│ draw_window() │
│ └─ 输出到 → 显示器(HDMI) │
│ │
│ X11 显示服务器 (Xserver) │
│ └─ 管理窗口、鼠标、键盘 │
└─────────────────────────────────┘
在服务器上(无显示器):
┌──────────────────────────────────────┐
│ 你的程序 │
│ export DISPLAY=:1 │
│ ./wechat │
│ └─ 尝试输出到 :1 显示 │
│ │
│ 但问题是::1 显示在哪儿?没有! │
│ → 程序崩溃或无法初始化 │
└──────────────────────────────────────┘
解决方案:Xvfb(X Virtual FrameBuffer)
┌────────────────────────────────────────┐
│ 微信进程 │
│ export DISPLAY=:1 │
│ ./wechat │
│ │
│ Xvfb :1 (虚拟显示) │
│ ├─ 分配内存缓冲区(帧缓冲) │
│ ├─ 每个像素 = 内存中的字节 │
│ ├─ 微信窗口坐标 → 内存地址映射 │
│ ├─ 维护 Z-Order、事件队列 │
│ └─ 等待客户端连接读取这块内存 │
└────────────────────────────────────────┘
# 启动一个虚拟 X11 显示,编号为 :1
# 分辨率 1920×1080,颜色深度 24 位
Xvfb :1 -screen 0 1920x1080x24
参数解释:
:1 — 显示编号(:0 通常是真实显示器,:1 是虚拟的)-screen 0 — 屏幕 01920x1080x24 — 宽×高×色深# 告诉程序"画到虚拟显示"
export DISPLAY=:1
export LIBGL_ALWAYS_SOFTWARE=1 # 无 GPU,强制软件渲染
# 启动微信
/config/wechat/opt/wechat/wechat
// 简化的概念模型
typedef struct {
uint8_t framebuffer[1920 * 1080 * 3]; // 1920×1080×RGB
uint32_t window_ids[100]; // 追踪 100 个窗口
mouse_x, mouse_y; // 鼠标坐标
event_queue[1000]; // 事件队列
} VirtualDisplay;
// 当应用要求"在 (100, 100) 画红色像素"时
void draw_pixel(x, y, color) {
offset = (y * 1920 + x) * 3; // 计算内存地址
framebuffer[offset] = color.r; // 写入红通道
framebuffer[offset+1] = color.g; // 写入绿通道
framebuffer[offset+2] = color.b; // 写入蓝通道
}
// 当客户端要求"读取屏幕画面"时
uint8_t* get_screen() {
return framebuffer; // 返回这块内存
}
Xvfb 只负责提供虚拟屏幕,但一个完整的桌面还需要:
# 启动轻量级窗口管理器(openbox)
openbox --replace
KasmVNC base 镜像已经预装了这一切,所以我们只需继承它:
FROM lscr.io/linuxserver/baseimage-kasmvnc:debianbookworm
# 这个镜像已包含:Xvfb + openbox + KasmVNC + supervisord
优势:
:1, :2, :3…)限制:
现在我们有了虚拟屏幕(Xvfb),像素数据在内存里。但怎样让浏览器用户看到这些像素?
旧方案(传统 VNC):
Xvfb 内存 → VNC 服务器
↓ (TCP 连接)
用户电脑
↓ (VNC 客户端软件)
显示器
用户需要装 VNC 客户端(如 VNC Viewer),才能看到。
新方案(KasmVNC):
Xvfb 内存 → KasmVNC 服务器
↓ (WebSocket)
浏览器 (原生支持)
↓
显示器
用户只需打开浏览器,无需装任何软件。
// KasmVNC 循环读取 Xvfb 的帧缓冲
void capture_screen() {
uint8_t frame[1920 * 1080 * 3];
// 每帧 30ms 读一次(约 30 FPS)
while (true) {
// 从 Xvfb 读取最新像素
read_xvfb_framebuffer(frame);
// 编码成视频流
encode_frame(frame);
// 广播给所有连接的客户端
broadcast_to_clients();
sleep(33); // 33ms ≈ 30 FPS
}
}
原始像素 (1920×1080×24bit ≈ 6.3 MB/帧)
↓ 压缩编码
H.264 (~ 200KB/帧 @ 30 FPS)
↓ 打包成 WebSocket 消息
浏览器播放
为什么要编码?
传统 VNC 用 TCP,KasmVNC 用 WebSocket(基于 HTTP):
// 浏览器端代码
const ws = new WebSocket("wss://nas.local:3001");
ws.onmessage = (event) => {
const frame = new Uint8Array(event.data);
// 解码 H.264 视频流
decoder.decode(frame);
// 在 canvas 上绘制
canvas.drawImage(decoded_image);
};
// 用户鼠标点击
canvas.addEventListener("click", (e) => {
const msg = {
type: "mouse",
x: e.offsetX,
y: e.offsetY,
};
ws.send(JSON.stringify(msg));
});
浏览器 (鼠标点击、打字)
↓ WebSocket
KasmVNC
↓ X11 事件
Xvfb 事件队列
↓
微信进程 (接收 MouseClick, KeyPress 事件)
这是 KasmVNC 相比传统 VNC 的大杀器:
Xvfb 虚拟显示 (单一像素源)
↓
KasmVNC 服务器
├─ WebSocket 连接 1 (用户 A 的浏览器)
│ └─ H.264 解码线程 1
│ └─ 音频缓冲 1
│
├─ WebSocket 连接 2 (用户 B 的浏览器)
│ └─ H.264 解码线程 2
│ └─ 音频缓冲 2
│
└─ WebSocket 连接 3 (用户 C 的浏览器)
└─ H.264 解码线程 3
└─ 音频缓冲 3
所有用户看到同一个虚拟屏幕,
同时可以鼠标 / 键盘 / 文件 / 音频 交互。
关键点:虽然有多个客户端连接,但只有一个像素源(Xvfb)。这意味着:
在 KasmVNC base 镜像中,配置由 supervisord 管理:
# /etc/supervisor/conf.d/kasmvnc.conf
[program:kasmvnc]
command=/usr/local/bin/kasmvnc
--vnc-port 3000
--web-port 3000
--ssl-port 3001
--users woc:$(cat /config/.kasm_pwd)
autorestart=true
redirect_stderr=true
[program:kasmvnc-audio]
command=/usr/local/bin/kasmvnc-pulseaudio
autorestart=true
KasmVNC 自带网页 UI,无需额外客户端:
// 简化的 KasmVNC web 客户端逻辑
class KasmClient {
constructor(target_url) {
this.ws = new WebSocket(target_url);
this.decoder = new H264Decoder(); // 硬件加速
this.canvas = document.getElementById("desktop");
}
onVideoFrame(data) {
// 硬件加速解码 H.264
const image = this.decoder.decode(data);
this.canvas.drawImage(image);
}
sendMouseMove(x, y) {
this.ws.send(
JSON.stringify({
type: "mouse_move",
x,
y,
}),
);
}
sendKeyPress(key) {
this.ws.send(
JSON.stringify({
type: "key",
key,
}),
);
}
}
现在我们理解了两个组件各自的角色,看看它们在实际系统中如何协作:
┌─────────────────────────────────────────────────────┐
│ Docker 容器 │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Xvfb :1 (虚拟显示) │ │
│ │ 内存帧缓冲: 1920×1080×24bit │ │
│ └─────────────────────────────────────────────┘ │
│ △ ↓ │
│ │ (X11 事件) (像素数据) │
│ ┌─────────────────────────────────────────────┐ │
│ │ 微信进程 │ │
│ │ • 绘制窗口 → Xvfb 帧缓冲 │ │
│ │ • 接收鼠标/键盘事件 → 更新状态 │ │
│ │ • 播放音频 → PulseAudio │ │
│ └─────────────────────────────────────────────┘ │
│ △ ↓ │
│ │ (X11 输入事件) (像素 + 音频) │
│ ┌─────────────────────────────────────────────┐ │
│ │ KasmVNC │ │
│ │ • 捕获 Xvfb 帧缓冲(30 FPS) │ │
│ │ • 编码成 H.264(200 KB/帧) │ │
│ │ • 混流音频 │ │
│ │ • 通过 WebSocket 广播给多个客户端 │ │
│ │ • 接收客户端输入(鼠标/键盘) → 注入 X11 │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
△ ↓
│ (输入注入) (WebSocket + H.264)
┌────┴─────────┬────────────┬──────────────┐
│ │ │ │
浏览器1(用户A) 浏览器2(用户B) 浏览器3(用户C) 手机浏览器
以在服务器上运行微信为例:
# 1. 容器启动时,KasmVNC base 的 init 脚本自动启动
supervisord
# 2. supervisord 启动几个关键进程
Xvfb :1 -screen 0 1920x1080x24
openbox --replace
kasmvnc (监听 3000/3001)
# 3. openbox 加载 autostart 脚本
/defaults/autostart
# 4. autostart 脚本示例
#!/bin/bash
# 等待微信文件就绪
while [ ! -x /config/wechat/opt/wechat/wechat ]; do
sleep 2
done
# 启动微信,并在退出时自动重启
while true; do
export DISPLAY=:1
/config/wechat/opt/wechat/wechat
sleep 2
done
# 同时启动看守进程(防止微信最小化后丢失焦点)
while true; do
xdotool search --name '微信' windowactivate 2>/dev/null || true
sleep 2
done
1. 用户打开浏览器
http://nas.local:8080
2. 面板(Panel)接收请求
• 验证用户权限
• 检查用户是否有权访问该实例
• 如果通过,生成 KasmVNC Basic Auth 凭据
3. 面板反向代理请求到实例的 KasmVNC
• 在 HTTP 头中注入 Basic Auth
• (重要:凭据只在服务端,浏览器看不到)
4. 实例的 KasmVNC 接收连接
• 验证 Basic Auth
• 建立 WebSocket
• 开始实时传输 H.264 视频流
5. 浏览器接收并播放
• 解码 H.264
• 绘制到 Canvas
• 捕获鼠标/键盘事件
• 通过 WebSocket 发回给 KasmVNC
6. KasmVNC 接收用户输入
• 注入 X11 事件到 Xvfb
• Xvfb 分发给微信进程
• 微信更新状态并重绘窗口
7. 循环回到步骤 5
这套方案(Xvfb + KasmVNC)不仅限于微信。你可以用它做:
在服务器上跑 VSCode,多个开发者同时编辑同一个项目:
# 在服务器容器内运行
Xvfb :1 &
code --display=:1 --no-sandbox
# 多个开发者通过浏览器访问
# 所有人看到相同的编辑器状态
优势:
讲师在服务器上操作,学生通过浏览器观看(read-only):
讲师的 PC
↓ (操作微信/网页)
服务器 Xvfb
↓
KasmVNC 广播
├─ 学生1 (只读)
├─ 学生2 (只读)
└─ 学生3 (只读)
公司内部应用只在服务器跑,员工用浏览器访问:
员工 A ──┐
员工 B ──┼─→ 服务器 (金融交易系统)
员工 C ──┘ • 高性能
• 集中备份
• 安全隔离
在 Linux 服务器上跑 Windows/Mac 应用(借助 Wine/Box):
# 在 Linux 容器内运行 Windows 应用
Xvfb :1 &
wine /path/to/app.exe --display=:1
# 从浏览器测试 UI
Selenium/Playwright 测试时,可视化看到浏览器操作:
# Selenium 测试
driver = webdriver.Firefox()
driver.get("https://example.com")
# 与此同时,通过浏览器观察:
# http://test-server:3000
# 实时看到 Firefox 在做什么
在服务器跑多个微信实例,支持:
这一节简单介绍一下,如何搭建一个真实可用的多人共享浏览器方案。多个人可以通过各自的浏览器访问和操作服务器中的一个 Firefox 实例。
安全警告:此最小化方案直接暴露完整浏览器能力到网络。如果部署到公网:
- 用户可通过浏览器访问你的内网服务(
http://localhost/、http://172.17.0.1/等)- 攻击者可能利用你的服务器作为跳板发起攻击
- 所有用户共享 Cookie、LocalStorage、登录状态
缓解措施:仅在内网/VPN 使用;或添加反向代理 + OAuth 认证;或限制浏览器能力(禁用文件协议、内网地址访问)。
用户 A(本地 Mac)
↓ 打开浏览器 http://server-ip:8080
用户 B(办公室 Windows)
↓ 打开浏览器 http://server-ip:8080
用户 C(手机)
↓ 打开浏览器 http://server-ip:8080
↓↓↓ 所有人看到同一个 Firefox 窗口,都能操作
服务器上的 Firefox
• 在 google.com 上浏览和操作
• 一个人点击链接,所有人立即看到跳转
• 一个人输入搜索,所有人看到结果加载
docker --version)和 Docker Compose(docker-compose --version)┌────────────────────────────────────────┐
│ Docker 容器 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Xvfb :1 (虚拟显示) │ │
│ │ 1920×1080,内存中的像素数据 │ │
│ └──────────────────────────────────┘ │
│ △ ↓ │
│ │ (像素) │
│ ┌──────────────────────────────────┐ │
│ │ Firefox 浏览器进程 │ │
│ │ 显示 www.google.com │ │
│ │ 接收鼠标/键盘操作 │ │
│ └──────────────────────────────────┘ │
│ △ ↓ │
│ │ (H.264 视频) │
│ ┌──────────────────────────────────┐ │
│ │ KasmVNC (Web 串流服务) │ │
│ │ 编码像素 → H.264 │ │
│ │ 监听 :3000 (WebSocket) │ │
│ │ 支持多用户并发 │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
△ ↓
│ (WebSocket)
用户操作 浏览器接收视频
(鼠标/键盘)
# 在你的服务器上执行
mkdir -p ~/shared-browser
cd ~/shared-browser
# 创建必要的文件
touch docker-compose.yml
touch Dockerfile
touch entrypoint.sh
创建 Dockerfile:
# 基础镜像已包含:Xvfb + openbox + KasmVNC
FROM lscr.io/linuxserver/baseimage-kasmvnc:debianbookworm
# 安装 Firefox(GUI 应用示例)
RUN apt-get update && apt-get install -y --no-install-recommends \
firefox-esr \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# 设置时区和语言
ENV LANG=zh_CN.UTF-8 LC_ALL=zh_CN.UTF-8 TZ=Asia/Shanghai
# 启动脚本:等待 Xvfb 就绪后启动 Firefox
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# KasmVNC 监听 3000 (HTTP) 和 3001 (HTTPS)
EXPOSE 3000 3001
CMD ["/entrypoint.sh"]
创建 entrypoint.sh:
#!/bin/bash
set -e
# 虚拟显示已由 KasmVNC base 镜像的 supervisord 自动启动
# 我们只需启动应用程序
export DISPLAY=:1
export LIBGL_ALWAYS_SOFTWARE=1
# 等待 Xvfb 就绪(KasmVNC base 镜像会启动)
echo "等待 Xvfb 就绪..."
for i in {1..30}; do
if xdpyinfo -display :1 >/dev/null 2>&1; then
echo "Xvfb 就绪!"
break
fi
echo "尝试连接 Xvfb... ($i/30)"
sleep 1
done
# 启动 Firefox,打开 www.google.com
echo "启动 Firefox..."
firefox "https://www.google.com/" &
# 保持容器运行(supervisord 会管理进程)
exec tail -f /dev/null
创建 docker-compose.yml:
version: "3.8"
services:
shared-browser:
build:
context: .
dockerfile: Dockerfile
container_name: shared-browser
# KasmVNC Web 客户端监听 3000 端口
# 映射到宿主机的 8080 端口
ports:
- "8080:3000"
# 资源限制
deploy:
resources:
limits:
cpus: "2"
memory: 2G
reservations:
cpus: "1"
memory: 1G
# 共享内存(避免 X11 性能问题)
shm_size: 1gb
# 环境变量
environment:
# KasmVNC 用户和密码(可选)
KASM_VNC_PASSWORD: "password123"
# 分辨率
VNC_RESOLUTION: "1920x1080"
# 自动重启
restart: unless-stopped
# 日志
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
cd ~/shared-browser
# 1. 构建镜像(首次约 3-5 分钟,取决于网速)
docker-compose build
# 2. 后台启动容器
docker-compose up -d
# 3. 查看日志,确认启动成功
docker-compose logs -f
# 等到看到类似这样的输出就说明成功了:
# shared-browser | 等待 Xvfb 就绪...
# shared-browser | Xvfb 就绪!
# shared-browser | 启动 Firefox...
现在,在你的电脑上打开浏览器,访问:http://server-ip:8080
进入后会看到服务器上运行的 Firefox 窗口,你可以点击网页链接、在地址栏输入 URL、操作所有浏览器功能。
所有人的浏览器只要访问 http://server-ip:8080 都会实时看到你的操作,他们也可以和你一起同时操作这个 Firefox 浏览器。
| 现象 | 原因 | 解决 |
|---|---|---|
| 黑屏/无画面 | X server 未启动 | 检查 docker logs,确认 xdpyinfo 成功 |
| 中文乱码 | 缺少字体 | 安装 fonts-noto-cjk |
| 无法输入中文 | 输入法未启动 | 参考输入法配置章节,启动 fcitx5 |
| 连接被拒绝 | 端口未映射/防火墙 | 检查 docker-compose ports 和 ufw/iptables |
| 画面卡顿 | 带宽不足 | 降低分辨率或 KasmVNC 质量参数 |
| 容器反复重启 | entrypoint 退出 | 检查 tail -f /dev/null 是否正常执行 |
| Firefox 崩溃 | 内存不足 | 增加 shm_size 或容器内存限制 |
Docker Desktop 的 GUI 是在宿主机上渲染的,而不是在容器内。对于服务器上无显示器的场景无用。
Xvfb + KasmVNC 的优势:
以 Qt/Chromium 应用为例(如某些即时通讯软件),内部依赖大量 X11 子系统和图形库。Dockerfile 中安装的依赖库(libxcb、libgtk、libcups 等)都是应用运行必需的。
# 这些不是可选的,都是应用需要的
libxcb-cursor0 # Qt 使用的光标库
libgtk-3-0 # Chromium 需要
libcups2 # 打印功能(即使不打印也可能需要)
会。KasmVNC 原生是所有人同时操作,没有冲突解决机制。如果多人同时操作,鼠标会跳来跳去,键盘输入会互相覆盖。
以微信为例,如果多人同时操作,一个人正在打字,另一个人点击了别的聊天窗口,输入就会被打断。
解决方案:生产环境通常需要实现"操作控制权锁":
// 同一时刻仅一用户可操作,其余只读
if (current_operator === user_a) {
user_b, user_c → 只读模式(灰色遮罩)
}
// 用户 B 想接管
user_b.request_control()
// 需要 user_a 确认或超时自动释放
这是上层应用自己实现的功能,不是 KasmVNC 原生支持的。
延迟取决于:
总延迟:通常 50-100ms(局域网),足够日常使用。
远程网络会更高(100-500ms),此时会感觉有点卡,但对异步操作(聊天、编辑)影响不大。
面板层面:
网络层面(需要自己做):
建议:
# 用 Caddy 反代,自动 HTTPS
caddy reverse-proxy --from https://example.com --to http://panel:8080
H.264 编码有质量参数。降低质量 = 降低文件大小 = 降低带宽:
# KasmVNC 启动参数
kasmvnc \
--quality 50 \ # 0-100,默认 80
--compression 6 \ # 0-9,越高越慢但越压缩
--max-frame-rate 15 # 30 → 15 FPS,减半带宽
理论上无限制(受内存限制),实际推荐:
1920×1080(推荐)—— 平衡分辨率和性能
1280×720(低端设备)
2560×1440(高分屏)
3840×2160(4K,占用大内存)
使用 Docker Volume:
# docker-compose.yml
services:
my-app:
volumes:
- app-data:/home/user/.config # 应用配置
- app-data:/home/user/Documents # 文档
这样容器重建后,数据依然存在。
可以,用 ffmpeg:
# 从 Xvfb 录屏
ffmpeg -f x11grab \
-video_size 1920x1080 \
-framerate 30 \
-i :1 \
-c:v libx264 \
output.mp4
或者用 KasmVNC 的内置录屏功能。
某些云桌面方案已实现此功能。原理是 v4l2loopback 虚拟设备:
# 在宿主加载 v4l2loopback 模块
sudo modprobe v4l2loopback
# 启动浏览器的虚拟摄像头
# → ffmpeg 把浏览器视频流推给 /dev/video0
# → 容器看到 /dev/video0 上有摄像头输入
Xvfb + KasmVNC 的组合是一个经典、成熟、轻量级的方案,用于在无显示器的服务器上运行 GUI 应用。
核心思想:
应用范围:
部署方式:
性能指标:
希望这篇文章帮你理解了这套方案。现在可以去体验一下,或者在自己的项目中应用这些技术了!