<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Playwright on Zata-砸它</title><link>https://www.zata.cc/tags/playwright/</link><description>Recent content in Playwright on Zata-砸它</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>Example Person</copyright><lastBuildDate>Tue, 09 Jun 2026 14:30:58 +0800</lastBuildDate><atom:link href="https://www.zata.cc/tags/playwright/index.xml" rel="self" type="application/rss+xml"/><item><title>从 noVNC 到 Playwright 截图流：容器内 VNC 踩坑记</title><link>https://www.zata.cc/p/%E4%BB%8E-novnc-%E5%88%B0-playwright-%E6%88%AA%E5%9B%BE%E6%B5%81%E5%AE%B9%E5%99%A8%E5%86%85-vnc-%E8%B8%A9%E5%9D%91%E8%AE%B0/</link><pubDate>Tue, 09 Jun 2026 14:00:00 +0800</pubDate><guid>https://www.zata.cc/p/%E4%BB%8E-novnc-%E5%88%B0-playwright-%E6%88%AA%E5%9B%BE%E6%B5%81%E5%AE%B9%E5%99%A8%E5%86%85-vnc-%E8%B8%A9%E5%9D%91%E8%AE%B0/</guid><description>&lt;h2 id="背景">背景
&lt;/h2>&lt;p>项目是一个 FastAPI 后端 + Playwright 驱动的浏览器自动化服务，目标是让用户在网页里远程操作一个跑在 Docker 容器内的 Chrome，完成 &lt;code>kimi.com&lt;/code> 的一次性扫码/账号登录。登录态写入持久化 profile，后续 PPT 生成任务复用。&lt;/p>
&lt;p>远程桌面的需求看起来很简单：&lt;/p>
&lt;ul>
&lt;li>容器内：&lt;code>Xvfb&lt;/code>（虚拟显示）+ &lt;code>openbox&lt;/code>（窗口管理）+ &lt;code>x11vnc&lt;/code>（VNC 服务端）&lt;/li>
&lt;li>后端：FastAPI WebSocket 做字节中继，把前端的 RFB 流量转发到 &lt;code>127.0.0.1:5910&lt;/code>&lt;/li>
&lt;li>前端：&lt;code>@novnc/novnc&lt;/code> 的 &lt;code>RFB&lt;/code> 类连接 WebSocket，渲染桌面&lt;/li>
&lt;/ul>
&lt;p>架构上也很规矩：5910 不对外暴露，WebSocket 走 443/HTTPS，用管理员 token + 一次性 ticket 鉴权，没有旁路端口。&lt;/p>
&lt;p>PRD 写得整整齐齐，验收清单全部打勾，看起来万无一失。然后我们就掉进了坑里。&lt;/p>
&lt;hr>
&lt;h2 id="坑一mit-shm-0-byte-画面">坑一：MIT-SHM 0-byte 画面
&lt;/h2>&lt;p>&lt;strong>症状&lt;/strong>：noVNC 连接成功，但画面全黑，或者显示 0-byte 的帧。&lt;/p>
&lt;p>&lt;strong>根因&lt;/strong>：x11vnc 默认使用 X11 的 MIT-SHM（共享内存扩展）加速画面抓取。在 Docker 容器里，共享内存行为跟宿主机不一致，x11vnc 读不到像素数据。&lt;/p>
&lt;p>&lt;strong>修复&lt;/strong>（&lt;code>2fa2602&lt;/code>）：给 x11vnc 加参数绕过 MIT-SHM：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">x11vnc -noxshm -noxkb -noxrecord -noxfixes -noxdamage ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或者通过环境变量透传：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">X11VNC_EXTRA_ARGS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-noxshm -ncache 0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这个坑还算好填，加参数就能绕过。&lt;/p>
&lt;hr>
&lt;h2 id="坑二chrome-vizdisplaycompositor-导致黑屏">坑二：Chrome VizDisplayCompositor 导致黑屏
&lt;/h2>&lt;p>&lt;strong>症状&lt;/strong>：x11vnc 终于有画面了，但 Chrome 窗口区域永远是黑的。其他窗口（xterm、openbox）显示正常。&lt;/p>
&lt;p>&lt;strong>根因&lt;/strong>：Chrome 默认启用 Viz Display Compositor，把实际渲染放到独立的合成器进程里，用 GPU 或特殊缓冲区，导致普通的 X11 像素读取（&lt;code>XGetImage&lt;/code>、&lt;code>XShmGetImage&lt;/code>）抓不到 Chrome 内容。&lt;/p>
&lt;p>&lt;strong>修复&lt;/strong>（&lt;code>58cb8ed&lt;/code>）：启动 Chrome 时禁用 VizDisplayCompositor：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">google-chrome --disable-features&lt;span class="o">=&lt;/span>VizDisplayCompositor ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这个坑比较隐蔽，因为其他应用都能正常显示，唯独 Chrome 黑屏。排查了好一阵才定位到是 Chrome 的合成器机制。&lt;/p>
&lt;hr>
&lt;h2 id="坑三x11vnc-在-azure-vm-上的兼容性">坑三：x11vnc 在 Azure VM 上的兼容性
&lt;/h2>&lt;p>&lt;strong>症状&lt;/strong>：本地 Mac Docker Desktop 测试正常，推到 Azure VM（Ubuntu 24.04，内核 &lt;code>6.14.0-1017-azure&lt;/code>）后，x11vnc 要么崩溃，要么帧率极低，要么随机断开。&lt;/p>
&lt;p>&lt;strong>根因&lt;/strong>：x11vnc 对内核版本、X11 扩展、共享内存的依赖比较深，不同宿主机环境表现差异很大。&lt;/p>
&lt;p>这时候我们开始尝试替换 VNC 服务端。&lt;/p>
&lt;h3 id="尝试-1x0vncserver">尝试 1：x0vncserver
&lt;/h3>&lt;p>TigerVNC 的 &lt;code>x0vncserver&lt;/code> 更轻量，但 Ubuntu 24.04 的软件源里根本没有这个包（&lt;code>6765172&lt;/code>）。&lt;/p>
&lt;h3 id="尝试-2xtigervnc">尝试 2：Xtigervnc
&lt;/h3>&lt;p>切换到 &lt;code>Xtigervnc&lt;/code>（&lt;code>1d3222c&lt;/code>），但发现它的 X0 扩展支持不完整，抓取现有 X 会话的能力不如 x11vnc。&lt;/p>
&lt;h3 id="尝试-3x11vnc--ncache">尝试 3：x11vnc + ncache
&lt;/h3>&lt;p>最后又滚回 x11vnc，加上 &lt;code>-ncache 10&lt;/code> 做客户端缓存（&lt;code>8ba411d&lt;/code>），试图缓解帧率问题。&lt;/p>
&lt;p>这一系列反复说明一个问题：&lt;strong>在容器化 + 云服务器环境下跑传统 VNC 服务端，是一件非常脆弱的事&lt;/strong>。每个环境变量、每个内核版本、每个 X11 扩展都可能成为压垮骆驼的最后一根稻草。&lt;/p>
&lt;hr>
&lt;h2 id="最终方案彻底放弃-vnc改用-playwright-截图">最终方案：彻底放弃 VNC，改用 Playwright 截图
&lt;/h2>&lt;p>与其继续跟 x11vnc 搏斗，我们换了一个思路：&lt;/p>
&lt;p>&lt;strong>既然 Playwright 已经开着 Chrome，为什么不直接让 Playwright 截屏？&lt;/strong>&lt;/p>
&lt;p>新方案（&lt;code>40b9014&lt;/code>）：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>后端&lt;/strong>：Playwright 的 &lt;code>Page.screenshot()&lt;/code> 每 250ms 截一张图&lt;/li>
&lt;li>&lt;strong>传输&lt;/strong>：自定义 &lt;code>WebSocketFrameBridge&lt;/code>，把 PNG 字节包上 length-prefixed JSON envelope，通过现有 WebSocket 推给前端&lt;/li>
&lt;li>&lt;strong>前端&lt;/strong>：不用 noVNC RFB 了，直接用原生 &lt;code>WebSocket&lt;/code> 收帧，把 PNG blob 塞进 &lt;code>&amp;lt;img&amp;gt;&lt;/code> 标签&lt;/li>
&lt;li>&lt;strong>输入&lt;/strong>：前端把鼠标/键盘事件序列化成 JSON，通过同一个 WebSocket 发回后端，后端用 &lt;code>page.mouse.click()&lt;/code>、&lt;code>page.keyboard.press()&lt;/code> 回放&lt;/li>
&lt;/ol>
&lt;p>Wire 格式：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="mi">4&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">byte&lt;/span> &lt;span class="n">LE&lt;/span> &lt;span class="n">length&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">uint32&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">N&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="n">JSON&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;frame&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;seq&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">N&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;size&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">M&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;format&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;jpeg&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="mi">4&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">byte&lt;/span> &lt;span class="n">LE&lt;/span> &lt;span class="n">length&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">uint32&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">M&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="n">PNG&lt;/span> &lt;span class="n">payload&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="新旧方案对比">新旧方案对比
&lt;/h2>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>维度&lt;/th>
&lt;th>noVNC + x11vnc&lt;/th>
&lt;th>Playwright 截图流&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>依赖&lt;/td>
&lt;td>Xvfb + openbox + x11vnc + MIT-SHM workaround&lt;/td>
&lt;td>只有 Xvfb&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chrome 兼容性&lt;/td>
&lt;td>需要 &lt;code>--disable-features=VizDisplayCompositor&lt;/code>&lt;/td>
&lt;td>不需要特殊 flags&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>容器稳定性&lt;/td>
&lt;td>极易受宿主机内核/环境差异影响&lt;/td>
&lt;td>纯用户态，跟宿主机无关&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>带宽&lt;/td>
&lt;td>RFB 协议有增量压缩，带宽低&lt;/td>
&lt;td>每帧完整 PNG/JPEG，带宽高&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>延迟&lt;/td>
&lt;td>RFB 增量更新，延迟低&lt;/td>
&lt;td>250ms 轮询，延迟稍高&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>实现复杂度&lt;/td>
&lt;td>需要 RFB 中继、VNC 参数调优&lt;/td>
&lt;td>自定义帧协议，前后端约 200 行&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>可维护性&lt;/td>
&lt;td>x11vnc 是黑盒，出问题难排查&lt;/td>
&lt;td>Playwright 截图是白盒，可控&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>牺牲了一点带宽和延迟，换来的是&lt;strong>极高的稳定性和可维护性&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="教训">教训
&lt;/h2>&lt;ol>
&lt;li>
&lt;p>&lt;strong>VNC 协议是为局域网桌面共享设计的&lt;/strong>，强行塞进 Docker + 浏览器自动化的场景，属于削足适履。MIT-SHM、GPU 合成器、X11 扩展这些历史包袱在容器里会集中爆发。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>当你在同一个组件上打了 3 个以上补丁还搞不定时，应该考虑替换它&lt;/strong>。我们在 x11vnc 上修了 MIT-SHM、VizDisplayCompositor、ncache、环境参数透传……最后发现换掉它比修好它更容易。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>用你已经有的工具链解决问题&lt;/strong>。既然 Playwright 已经开着浏览器，截屏是它的一等公民 API，为什么不直接用？不要为了追求&amp;quot;标准方案&amp;quot;而引入一整套 VNC 技术栈。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&amp;ldquo;网页远程桌面&amp;quot;不等于&amp;quot;必须实现 VNC/RDP 协议&amp;rdquo;&lt;/strong>。只要用户能看到画面、能发鼠标键盘，用什么协议不重要。自定义的 JSON+PNG 帧流完全够用。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="代码速查">代码速查
&lt;/h2>&lt;p>如果你也在用 Playwright + Docker + 远程桌面，可以直接参考这个模式。&lt;/p>
&lt;p>&lt;strong>后端截屏&lt;/strong>（Python）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">next_frame&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">bytes&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">screenshot&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;jpeg&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">quality&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">70&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">full_page&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>前端渲染&lt;/strong>（TypeScript/React）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">ws&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">WebSocket&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">url&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ws&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">binaryType&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;arraybuffer&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ws&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">onmessage&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">blob&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Blob&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="kr">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;image/jpeg&amp;#34;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">setFrameUrl&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">URL&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">createObjectURL&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">blob&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>后端输入回放&lt;/strong>（Python）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">dispatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;click&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mouse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;y&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">elif&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;keydown&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">keyboard&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">press&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></description></item></channel></rss>