在 EasyTier 组网下用 Docker 部署 Penpot:白屏/黑屏的四层排障实录
TL;DR:在 Windows + Docker Desktop 上部署 Penpot,围绕”本机 / 异地”两个访问入口一共踩了四层坑,任何一层没解决都会卡住:
- Windows 防火墙:Docker Desktop 自动放行的入站规则只作用于”公用”配置文件,而 EasyTier 虚拟网卡是”私有”,导致 EasyTier 流量进不来;
PENPOT_PUBLIC_URI写死成localhost:前端 JS 拿它当 API / WebSocket 的基址,异地浏览器就去连它自己电脑的 localhost,必然失败;config.js被浏览器缓存 7 天:就算改对了配置,旧值仍长期生效,普通刷新根本拉不到新配置——这是最隐蔽的一层;- 修完异地,本机反过来黑屏:
PENPOT_PUBLIC_URI改成 EasyTier 隧道 IP 后,它成了本机前端连后端的基址,而本机不一定开着 EasyTier(虚拟网卡可能根本不存在、进程可能挂死);且 EasyTier 每次启动的网卡名都不同(et_6_whx2→et_7_whx2…),把配置绑在隧道 IP 上等于绑在一个随时会消失的地址上。最终解法:config.js不再写死任何地址,改为 nginx 按客户端 Host 头动态下发——本机走localhost、异地走隧道 IP、局域网走内网 IP,一次配置三种入口全通,本机彻底不依赖 EasyTier 在线。四层都修完,本机 / 异地 / 局域网三个入口才同时可用。
补记(9-21 下午):本机黑屏又出现第二例——此时服务端已完全健康(动态
config.js下发正确、headless Edge 对照通过),黑屏的浏览器拿到的也是正确的新配置、JS 全部 200,但 API 请求为零,前端停在了执行中途。根因是客户端陈旧状态:已打开旧标签页的内存态 + 站点数据(localStorage/cookie)——而”清除历史记录”既不清站点数据、也消不掉已打开标签页的内存态。处置:关全部标签页 + 清三个入口 origin 的站点数据(或直接用隐身窗口),详见第四层补记。
一、环境与背景
| 项 | 值 |
|---|---|
| 宿主机 | Windows(Docker Desktop) |
| 组网方案 | EasyTier(隧道 IP 10.126.126.1,私有配置文件;虚拟网卡名每次启动都变:et_6_whx2 → et_7_whx2…) |
| 本机局域网网卡 | 192.168.1.247(WLAN,公用配置文件) |
| 应用 | Penpot 2.17.2,docker compose 部署(frontend / backend / exporter / postgres / valkey / mailcatch) |
| 端口映射 | 0.0.0.0:9001 -> 8080 |
| 访问方式 | 本机 http://localhost:9001;异地设备连入同一 EasyTier 隧道访问 http://10.126.126.1:9001;局域网 http://192.168.1.247:9001 |
部署后,本机浏览器打开 http://localhost:9001 完全正常。但异地设备组网成功后访问 http://10.126.126.1:9001,页面打开就是空白,刷新也无任何内容。
这是一个很典型的”本机好、异地坏”问题——问题不在代码,而在网络层 + 前端配置层 + 缓存层的叠加。下面按排查顺序逐层拆解。
二、第一层:Windows 防火墙(EasyTier 网卡是”私有”,Docker 只放行了”公用”)
现象
-
本机
localhost:9001正常。 -
局域网设备访问
192.168.1.247:9001也正常。 -
只有走 EasyTier 隧道的异地设备连不上 / 白屏。
这组对照很关键:说明应用和端口映射都没问题,问题出在从 EasyTier 网卡进来的流量被挡了。
排查
先看 Docker 端口映射和实际监听:
docker ps --format 'table {{.Names}}\t{{.Ports}}\t{{.Status}}'
netstat -ano | grep -i 9001
结果:0.0.0.0:9001 在监听(全网卡可达),映射 0.0.0.0:9001->8080 正常。所以不是 Docker 的问题。
再看 EasyTier 网卡和网络配置文件:
Get-NetConnectionProfile | Select-Object Name, InterfaceAlias, NetworkCategory
Name InterfaceAlias NetworkCategory
---- -------------- ---------------
et_6_whx2 et_6_whx2 Private ← EasyTier 是"私有"
WLAN 3030226Fast 2 Public ← 局域网是"公用"
最后看 Docker 自动创建的入站放行规则(netsh advfirewall firewall show rule name=all dir=in verbose):
规则名称: Docker Desktop Backend
配置文件: 公用 ← 只在"公用"配置文件生效
本地端口: 任何
操作: 允许
根因:Docker Desktop 为它自己后端进程(com.docker.backend.exe)创建的入站放行规则限定在 profile=public。而 EasyTier 虚拟网卡被 Windows 识别为 Private,从隧道进来的流量命中私有配置文件的默认策略 BlockInbound,直接被丢弃。于是”局域网(公用)能进、EasyTier(私有)进不来”。
修复
给私有网络补一条针对 9001 的入站放行:
netsh advfirewall firewall add rule \
name="Docker Penpot 9001 (private)" dir=in action=allow \
protocol=TCP localport=9001 profile=private
注意:以后若还要通过 EasyTier 访问别的 Docker 端口(mailcatcher 1080、mosquitto 1883、hmi-runtime 18090/18091 等),每个端口都得同样补一条
profile=private的入站规则;否则更推荐在 EasyTier 隧道层做统一放行。
修完这一层后,异地设备访问 http://10.126.126.1:9001——页面终于能打开 HTML 了,但还是白屏。说明又撞到了下一层。
三、第二层:PENPOT_PUBLIC_URI 写死成 localhost
现象
-
异地设备能拿到完整 HTML(200)。
-
但应用没有任何反应,页面白屏。
排查
从前端 nginx 访问日志入手(docker logs),对比本地和异地的请求差异,这一步非常关键:
# 本地浏览器(正常):加载完静态资源后,会发大量 API 请求
172.20.0.1 - - "POST /api/main/methods/update-file?id=..." 200
# 异地浏览器(origin = http://10.126.126.1:9001/,白屏):
# 所有 JS / CSS / 字体 / worker 全部 200 下载成功,
# 但—— 一条 /api/... 请求都没有,只有反复的 GET /
静态资源全 200,说明网络通了、文件能拉;但应用 JS 跑起来后一个后端请求都没发。这强烈指向:应用把”后端地址”算错了。
再看前端实际下发的运行时配置 /js/config.js:
// Frontend configuration
var penpotFlags = "disable-email-verification enable-smtp enable-prepl-server ...";
var penpotPublicURI = "http://localhost:9001"; // ← 问题在这
并解码了 shared.js 里的初始化逻辑(Cljs 混淆后):
(def public-uri
(ensure-path-slash
(or globalThis.penpotPublicURI ; ← 这里取到 localhost:9001
(.-origin (.-location globalThis)))))
也就是说:应用用 penpotPublicURI 作为 API / WebSocket 的基址。在异地浏览器里,这个值是 http://localhost:9001,于是它去连的是异地电脑自己的 localhost(那里根本没有 Penpot),API / WS 全连不上,应用初始化卡死 → 白屏。本地之所以正常,纯粹是因为本地浏览器的 localhost 恰好就是你部署 Penpot 的这台机器。
根因:docker-compose.yaml 里 PENPOT_PUBLIC_URI 配成了 http://localhost:9001,这是一个只对本机成立的地址。
修复
把它改成客户端实际访问到的地址——也就是 EasyTier 隧道里的 IP:
# Code/penpot/docker-compose.yaml
x-uri: &penpot-public-uri
# 必须使用客户端实际访问到的地址。
# 本机访问 10.126.126.1 也成立(本机接口 + 端口绑定 0.0.0.0),
# 因此本地与异地都能正常工作。不要用 localhost。
PENPOT_PUBLIC_URI: http://10.126.126.1:9001
cd Code/penpot
docker compose up -d --force-recreate penpot-frontend penpot-backend penpot-exporter
为什么不用
localhost?因为PUBLIC_URI是”告诉浏览器去哪连后端”的,必须对发起请求的那台机器有意义。异地设备的localhost是它自己,不是你的服务器。用隧道 IP(或对外域名)才对两边都成立。
理论上改到这里,异地应该就能用了。但实测刷新后依然是黑屏 + “未知错误”——说明还有最后一层没暴露出来。
四、第三层:config.js 被浏览器缓存 7 天,改了也白改(最隐蔽)
现象
改了 PENPOT_PUBLIC_URI 并重建容器,curl localhost:9001/js/config.js 确认已经是新值 10.126.126.1,但异地浏览器刷新后依旧黑屏报错。
排查
再次看前端访问日志,异地浏览器刷新后(04:11)只有一条:
"GET / HTTP/1.1" 200 68944
重新拉了 index.html,却没有重新请求 config.js,也没有任何 /api/ 请求。 这几乎可以断定:config.js 走了浏览器本地缓存,拿到的还是旧值 localhost:9001。
去查 config.js 的响应头:
curl -sI 'http://localhost:9001/js/config.js?version=...'
# Cache-Control: public, max-age=604800 ← 缓存 7 天!
再进容器看 Penpot 官方镜像的 nginx 配置,找到出处:
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}
根因(两个坑叠在一起):
- 官方镜像对所有
.js(包括运行时配置config.js)一律下发max-age=604800(7 天)强缓存; config.jsURL 上的?version=2.17.2-...是镜像构建号——改环境变量不重建镜像,版本号就不变。
结果:即使服务端配置已更新,客户端在 7 天内、只要 URL 不变,就永远命中磁盘缓存里的旧 config.js。普通 F5 刷新对”未到期”的强缓存资源不会重新请求,所以应用拿到的还是 localhost:9001,表现就是”改了没生效”。
这是一类很通用的坑:带版本号缓存的静态资源,如果版本号和”内容实际是否变化”脱钩(比如版本号只跟构建走、不跟运行时配置走),那么”改配置”就不会触发”换缓存”。
修复
利用 Penpot 镜像预留的 nginx override 目录,给 config.js 单独下发永不缓存头。
新建 Code/penpot/nginx/config-no-cache.conf:
# 让前端运行时配置永不缓存。
# config.js 由 PENPOT_PUBLIC_URI 生成,其 URL 上的 ?version= 是镜像构建号,
# 改配置不会改变版本号;若沿用默认的 7 天 max-age,客户端会长期使用旧值。
location = /js/config.js {
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
expires off;
try_files $uri =404;
}
用
location = /js/config.js(精确匹配),优先级高于通配正则,只影响这一个文件,不动其他静态资源的正常缓存。
挂载进 frontend 服务(docker-compose.yaml):
penpot-frontend:
volumes:
- penpot_assets:/opt/data/assets
# config.js 永不缓存:否则修改 PENPOT_PUBLIC_URI 后客户端会继续用 7 天内的旧值
- ./nginx/config-no-cache.conf:/etc/nginx/overrides/server.d/config-no-cache.conf:ro
cd Code/penpot
docker compose up -d --force-recreate penpot-frontend
验证:
curl -sI 'http://localhost:9001/js/config.js?version=2.17.2-1787817659'
# Cache-Control: no-store, no-cache, must-revalidate, max-age=0
# Pragma: no-cache
最后一步:让异地设备清掉这一次旧缓存
override 只对新请求生效,旧值还躺在异地浏览器的磁盘缓存里,需要强制清一次(二选一):
-
DevTools 硬刷新:F12 → 右键刷新按钮 → 「清空缓存并硬性重新加载」(会忽略 max-age 强拉
config.js); -
或:设置 → 隐私 → 清除”缓存的图片和文件” → 再访问。
硬刷成功后即可看到登录页,之后普通刷新就行(config.js 每次都会重新拉取)。
到这里,异地访问真正可用了。但故事没有结束——修完异地,本机反而坏了。
五、第四层:修完异地,本机黑屏(PUBLIC_URI 绑死隧道 IP 的反噬)
现象(2026-09-21)
-
Docker 服务全部正常,容器日志无异常。
-
Edge 已清过缓存。
-
本机浏览器打开
http://localhost:9001:网页正常加载(HTML、JS 全 200),但页面黑屏。
和第一轮的白屏症状很像,但这次问题方向相反:配置对异地成立,却对本机不成立。
排查
先看实际下发的 config.js(确认第三层的 no-cache 生效了,拿到的是新值):
var penpotPublicURI = "http://10.126.126.1:9001";
再看本机到 10.126.126.1 的连通性:
curl -sI --max-time 8 http://10.126.126.1:9001/js/config.js
# → HTTP 000,8s 超时
ipconfig
# → 没有任何 EasyTier 虚拟网卡(et_*)
tasklist | grep easytier
# → easytier-gui.exe 进程还活着(启动于 3 天前)
根因:本机前端的后端基址是 10.126.126.1,而本机此刻根本没有这个网卡——easytier-gui.exe 进程虽然还挂着(Tauri 桌面应用,窗口关了进程不一定退),但虚拟网卡早就没了。于是本机浏览器拿 HTML、JS 没问题(走 localhost:9001),所有 API / WS 请求全打到不存在的 10.126.126.1,应用初始化卡死 → 黑屏。
这里暴露出第二层方案里一个没被当时验证到的隐藏假设:它假定”本机访问 10.126.126.1 也成立”,但这只在本机开着 EasyTier 时才成立。日常用法里,本机经常不开 EasyTier 直接用 localhost——那套配置对本机就是错的。
弯路:给 Loopback 加隧道 IP 别名(可行但否决)
最直接的补丁:把 10.126.126.1 作为 32 位别名加到本机环回接口(Loopback Pseudo-Interface 1),这样 EasyTier 关不关,本机都能直连这个 IP:
netsh interface ipv4 add address "Loopback Pseudo-Interface 1" 10.126.126.1 255.255.255.255
实测 EasyTier 关闭状态下 http://10.126.126.1:9001 本机直连 200,思路成立。但否决,因为一开 EasyTier 就撞墙:EasyTier 要用 10.126.126.1 配置它的 TUN 网卡,与环回别名冲突,进程直接退出。也就是说这个补丁把”EasyTier 开着”和”EasyTier 关着”变成了互斥状态,比原问题更难用。
教训:给同一个 IP 做本地别名/路由劫持,会和网络软件对 IP 的所有权发生正面冲突。要解决”一个 IP 两个入口”,正确方向是让配置跟着访问者走,而不是让网络迁就配置。
最终方案:config.js 按客户端 Host 动态下发
前端代码(shared.js 解码后可见)取后端基址的逻辑是:
(or globalThis.penpotPublicURI
(.-origin (.-location globalThis)))
也就是说,penpotPublicURI 本质上就是”浏览器从哪来的,就回哪去”——这恰好就是 nginx 请求里的 $http_host。那么干脆让 nginx 按 Host 头实时生成 config.js,不写死任何地址:
新建 Code/penpot/nginx/config-dynamic.conf.tpl:
location = /js/config.js {
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
expires off;
default_type application/javascript;
return 200 "var penpotFlags = \"${PENPOT_FLAGS}\"; var penpotPublicURI = \"http://$http_host\";";
}
两个容易踩的细节:
- 必须用
$http_host(含端口),不能用$host——$host是去端口的小写主机名,会下发http://localhost(默认 80 端口),前端直接打不开; - 响应体必须是单行 JS——nginx 配置字符串里写不了换行,
\n会变成字面反斜杠下发,导致 JS 语法错误。
接管容器 CMD(镜像自带 /entrypoint.sh 负责写 flags、生成 nginx.conf,照旧执行;只需在其后追加一步再启 nginx)。新建 Code/penpot/nginx/frontend-start.sh:
#!/bin/bash
set -e
envsubst '${PENPOT_FLAGS}' < /opt/penpot/config-dynamic.conf.tpl \
> /etc/nginx/overrides/server.d/config-dynamic.conf
nginx -t
exec nginx -g 'daemon off;'
docker-compose.yaml 的 frontend 服务:
penpot-frontend:
entrypoint: ["/bin/bash", "/entrypoint.sh"] # 镜像自带逻辑不动
command: ["/bin/bash", "/opt/penpot/frontend-start.sh"] # 之后跑我们的脚本
volumes:
- penpot_assets:/opt/data/assets
- ./nginx/config-dynamic.conf.tpl:/opt/penpot/config-dynamic.conf.tpl:ro
- ./nginx/frontend-start.sh:/opt/penpot/frontend-start.sh:ro
(原来的 config-no-cache.conf 挂载删除:no-store 头已并入动态模板。)
效果:PENPOT_PUBLIC_URI 环境变量对前端不再起作用,config.js 内容随请求的 Host 实时变化:
| 访问入口 | 浏览器拿到的 penpotPublicURI |
|---|---|
http://localhost:9001(本机,EasyTier 开/关都行) | http://localhost:9001 |
http://10.126.126.1:9001(异地) | http://10.126.126.1:9001 |
http://192.168.1.247:9001(局域网) | http://192.168.1.247:9001 |
PENPOT_PUBLIC_URI 仍然保留 http://10.126.126.1:9001,但职责缩小为 backend / exporter 生成绝对 URL(邀请邮件里的链接等)——这类 URL 发给异地用户,必须是异地可达的地址,所以保持隧道 IP 不动。
cd Code/penpot
docker compose up -d --force-recreate penpot-frontend
验证
# 三种 Host 各自拿到自己的地址(含端口)
curl -s -H 'Host: localhost:9001' http://127.0.0.1:9001/js/config.js
# → var penpotPublicURI = "http://localhost:9001";
curl -s -H 'Host: 10.126.126.1:9001' http://127.0.0.1:9001/js/config.js
# → var penpotPublicURI = "http://10.126.126.1:9001";
curl -s -H 'Host: 192.168.1.247:9001' http://127.0.0.1:9001/js/config.js
# → var penpotPublicURI = "http://192.168.1.247:9001";
# EasyTier 进程不存在、网卡不存在时,本机 localhost 全链路 200
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:9001/
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:9001/readyz
附带清理:本机曾有 6 个 9-18 遗留的
msedgewebview2孤儿进程(EasyTier GUI 的 WebView2 渲染进程,主进程退了它们不退),疑似长期干扰 EasyTier 重新初始化——taskkill清掉后 EasyTier 重启恢复正常。这也是 EasyTier”进程活着但网卡起不来”的一个常见隐藏原因,值得记一笔。
补记(9-21 下午):第二例本机黑屏——服务端健康,元凶是客户端陈旧状态
动态下发方案已上线、EasyTier 也在线(et_6_whx2 Up、隧道 IP 可达),但本机黑屏又出现了:页面能打开(HTML/JS 全 200)但应用不渲染,Docker 全部正常,Edge 已清除过历史。这次不是配置问题,是客户端问题。
关键证据(全部来自前端 nginx 访问日志):
| 时间 (UTC) | 请求 | 结果 |
|---|---|---|
| 20:05:55 | GET / + 全套静态资源(config.js / main.js / libs.js / shared.js / css) | 全 200;其中 config.js = 168B,正是 127.0.0.1 入口的正确新值 |
| 20:05:55 → 20:08:39 | /api/* 请求 0 次、WebSocket 握手 (101) 0 次 | 前端在静态资源加载完后停止执行,应用从未初始化 → 黑屏 |
| 20:08:39 | 再次加载 config.js(156B ≈ 隧道 IP 版的 gzip 体积) | 这次重载的 Host 是 10.126.126.1:9001,API 依旧为零 |
| 20:16:12 | 同一服务端、headless Edge(全新 profile) | 请求序列完整:get-profile 200 → 登录页资源 → 流程正常 |
两个结论:
- 服务端健康:全新 profile 的 A/B 对照通过;四个入口(
localhost/127.0.0.1/ 隧道 IP / 局域网 IP)各自返回 200 且动态penpotPublicURI正确; - 不是旧配置值的问题:黑屏标签页的加载拿到的是正确的新
config.js(168B/156B 都是新逻辑按 Host 生成的值)——问题是前端在 JS 加载完成后停止执行(连第一个匿名 API 调用get-enabled-flags都没发出来)。“静态资源全 200、/api和 WS 为零”是前端执行中断的典型特征,指向客户端有陈旧/损坏的站点数据(localStorage/cookie)或扩展干扰。而”清除历史记录”既不清站点数据,也消不掉已打开标签页的内存态。
修复(用户侧,按序执行):
- 关闭所有 Penpot 标签页(已打开标签页的内存态只能靠关闭消除);
- 清除站点数据(不是”历史记录”):DevTools → Application → Clear storage;或页面加载后右键地址栏锁图标 → 站点设置 → 清除数据。注意:本部署有 三个 origin(
localhost:9001/127.0.0.1:9001/10.126.126.1:9001),陈旧状态在其中任何一个里都会出问题,而清历史一个都不清; - 新标签页 + 硬刷新(Ctrl+Shift+R);
- 若仍黑屏:先开隐身窗口(无扩展、无站点数据)——隐身正常则是扩展的锅,逐个禁用;隐身也黑,则把 DevTools Console 里的红色报错发出来。
黑屏页面自检(Console 里直接读内存值):
penpotPublicURI // 内存中的后端基址:应等于你正在用的入口(含端口)
location.host // 实际访问入口
值是旧隧道 IP / 与入口不符 → 该标签页带着旧内存配置,关标签页重开;值正确仍黑屏 → 看 Console 红色报错(JS 运行时异常,常见诱因是 localStorage 里损坏的会话数据)。
本部署没有 Service Worker / manifest(
/sw.js404、无manifest),config.js又是no-store——所以客户端的”陈旧态”只可能存在于标签页内存和站点数据两处,关标签页 + 清站点数据后不会复发。
六、四层根因对照表
| 层 | 症状 | 根因 | 定位手段 | 修复 |
|---|---|---|---|---|
| 1 防火墙 | 本机/局域网正常,EasyTier 流量进不来 | Docker Desktop 入站放行规则只作用于”公用”配置文件,EasyTier 网卡是”私有”,命中默认 BlockInbound | Get-NetConnectionProfile(看 NetworkCategory)+ netsh advfirewall ... show rule(看规则 profile) | netsh ... add rule ... profile=private(针对 9001) |
| 2 前端配置 | 页面能打开 HTML,但白屏、零 API 请求 | PENPOT_PUBLIC_URI=http://localhost:9001,前端拿它当 API/WS 基址,异地浏览器去连自己的 localhost | 前端 nginx 访问日志(本地有 /api/、异地零 /api/)+ curl /js/config.js(看到 penpotPublicURI) | compose 里改成隧道 IP http://10.126.126.1:9001,up -d --force-recreate |
| 3 缓存 | 改对配置、服务端已新值,但客户端仍报错 | config.js 被 max-age=604800 强缓存 7 天,且 ?version= 是镜像构建号不随配置变 → 客户端一直用旧 config.js | 访问日志(刷新后只有 GET /、无 config.js 再请求)+ curl -sI /js/config.js(看到 7 天缓存头)+ 镜像 nginx 配置 | 挂 nginx override 让 location = /js/config.js 永不缓存;异地设备硬刷新一次 |
| 4 入口绑死 | 本机页面能开、黑屏(EasyTier 未开/网卡丢失时) | 第二层把 PUBLIC_URI 绑死隧道 IP 10.126.126.1:本机前端拿它当基址,但该 IP 只在 EasyTier 在线时存在(进程可挂死、网卡名每次启动都变)→ 本机 API/WS 全超时 | curl http://10.126.126.1:9001/ 超时 + ipconfig/Get-NetAdapter 无 et_* 网卡 + 进程却还活着(假活) | config.js 改为 nginx 按 $http_host 动态下发(frontend-start.sh 接管 CMD + 模板挂载),PUBLIC_URI 只留给 backend 生成绝对链接用 |
| 5 客户端陈旧态(9-21 补记) | 服务端已健康、新 config.js 也拿到了,本机仍黑屏 | 客户端陈旧状态:已打开标签页的内存态 + 站点数据(localStorage/cookie),前端静态资源加载完后停止执行(/api 与 WS 为零);“清除历史记录”两者都不清 | nginx 日志时间线:静态资源全 200 但零 API/零 WS vs headless 全新 profile 序列完整(A/B 对照) | 关全部标签页 + 清各入口 origin 的站点数据(DevTools → Application → Clear storage);隐身窗口排除扩展干扰 |
七、完整解决方案(可直接照搬,最终形态)
1. Windows 防火墙放行(私有配置文件,9001)
netsh advfirewall firewall add rule name="Docker Penpot 9001 (private)" \
dir=in action=allow protocol=TCP localport=9001 profile=private
2. docker-compose.yaml 的 PENPOT_PUBLIC_URI
x-uri: &penpot-public-uri
# 前端不再使用这个值(config.js 已改为按 Host 动态下发)。
# 此值仅作用于 backend/exporter 生成的绝对 URL(邀请邮件链接等),
# 必须保持为异地可达的地址。
PENPOT_PUBLIC_URI: http://10.126.126.1:9001 # EasyTier 隧道 IP
3. config.js 按客户端 Host 动态下发(含永不缓存)
nginx/config-dynamic.conf.tpl:
location = /js/config.js {
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
expires off;
default_type application/javascript;
return 200 "var penpotFlags = \"${PENPOT_FLAGS}\"; var penpotPublicURI = \"http://$http_host\";";
}
两个坑:必须用
$http_host(含端口;$host会丢掉:9001);响应体必须单行(nginx 字符串里写不了换行)。
nginx/frontend-start.sh(接管容器 CMD,在镜像自带 entrypoint 之后运行):
#!/bin/bash
set -e
envsubst '${PENPOT_FLAGS}' < /opt/penpot/config-dynamic.conf.tpl \
> /etc/nginx/overrides/server.d/config-dynamic.conf
nginx -t
exec nginx -g 'daemon off;'
docker-compose.yaml 的 frontend 服务:
penpot-frontend:
entrypoint: ["/bin/bash", "/entrypoint.sh"]
command: ["/bin/bash", "/opt/penpot/frontend-start.sh"]
volumes:
- penpot_assets:/opt/data/assets
- ./nginx/config-dynamic.conf.tpl:/opt/penpot/config-dynamic.conf.tpl:ro
- ./nginx/frontend-start.sh:/opt/penpot/frontend-start.sh:ro
4. 重建并让旧客户端硬刷新一次
cd Code/penpot
docker compose up -d --force-recreate penpot-frontend
# 之前缓存过旧 config.js 的浏览器:DevTools → 清空缓存并硬性重新加载
5. 验证三个入口
curl -s -H 'Host: localhost:9001' http://127.0.0.1:9001/js/config.js
curl -s -H 'Host: 10.126.126.1:9001' http://127.0.0.1:9001/js/config.js
curl -s -H 'Host: 192.168.1.247:9001' http://127.0.0.1:9001/js/config.js
# 各自的 penpotPublicURI 应等于该 Host;再确认本机 EasyTier 关闭时 localhost:9001 全链路 200
八、可复用的排查心法
- 方向相反的症状,先分层再定位:「本机好、异地坏」→ 网络连通(防火墙/路由)→ 配置正确性(地址/基址)→ 缓存一致性;「异地好、本机坏」→ 检查前端基址是否绑死了一个本机不一定存在的地址(隧道 IP、虚拟网卡)。别一上来就怀疑代码。
- 善用前端 nginx 访问日志做 A/B 对比:本地 vs 异地的请求序列差异(有没有
/api/、有没有重新拉config.js)是最直接的证据,比看浏览器 Console 更快。 localhost只对本机成立,隧道 IP 只对隧道成员成立:任何会被下发到客户端、再用于拼后端 URL 的配置(PUBLIC_URI、WS 地址、回调地址…),写死单一地址就天然只对一种入口正确。多入口(本机 + 异地 + 局域网)场景,能动态就别写死:前端运行时配置(如config.js)直接用 nginx 的$http_host生成”浏览器从哪来就回哪去”;只有服务端主动发给别人的链接(邮件、通知)才必须写死一个对方可达的地址。- 带版本号缓存的静态资源,版本号要和”内容是否变化”绑定:如果版本号只跟构建走、不跟运行时配置走,改配置就不会换缓存。运行时配置文件(如
config.js)应一律no-cache。 - Windows 下 Docker 的防火墙规则是按”配置文件(profile)“生效的:Docker Desktop 默认只放行它自己后端进程、且常限定在 public;跨虚拟网卡(EasyTier/Tailscale/WireGuard 等私有网卡)访问时,要额外给对应 profile 放行目标端口。
- 不要把配置绑在”会消失的地址”上:虚拟网卡的 IP / 网卡名都可能随开关机、重启、进程挂死而消失(EasyTier 的网卡名每次启动都变)。“进程还活着”≠“网络还在线”——先
Get-NetAdapter/ipconfig确认网卡和 IP 真实存在,再谈应用层。 - 给 IP 做本地别名/路由劫持会和网络软件正面冲突:想”EasyTier 关着也能访问隧道 IP”而把隧道 IP 加到 Loopback 上,EasyTier 一开就因 IP 所有权冲突退出。正确方向是让配置跟着访问者走(动态 Host),不是让网络迁就配置(别名/劫持)。
- Tauri/WebView2 类桌面应用的”假活”进程:窗口关闭后 GUI 进程可能不退,还会留下孤儿
msedgewebview2渲染进程,占用虚拟网卡/端口资源,导致重新拉起后”进程活着但网卡/服务起不来”。排障时顺手清掉孤儿进程再重启。 - “清历史”≠“清缓存”≠“清站点数据”:浏览器”清除历史记录”只清浏览记录,不清 HTTP 缓存、不清 localStorage/cookie、更消不掉已打开标签页内存里的 JS 变量与 SPA 状态。多入口部署(每个入口一个 origin)要逐个 origin 清”站点数据”(DevTools → Application → Clear storage),或直接隐身窗口开测——隐身窗口是最快的”无状态对照”:它正常,问题就锁定在用户的站点数据或扩展里。
- “全 200 但零 API/零 WS”是前端执行中断的签名:静态资源全部成功、但应用第一个匿名 API 调用(如
get-enabled-flags)都没发出、也没有 WebSocket 握手,说明前端 JS 在加载完成后、初始化前就中断了。此时服务端多半无辜,优先怀疑客户端陈旧站点数据 / 扩展拦截;用”全新 profile 的 headless 浏览器 A/B 对照”可以最快证明服务端是否健康。
附录:关键排查命令速查
# 端口映射 / 监听
docker ps --format 'table {{.Names}}\t{{.Ports}}\t{{.Status}}'
netstat -ano | grep -i 9001
# EasyTier 网卡与配置文件(PowerShell)——网卡名每次启动都变,按 et_* 通配查,别写死
Get-NetConnectionProfile | Select Name, InterfaceAlias, NetworkCategory
Get-NetAdapter | Where-Object Name -like 'et_*' | Format-Table Name, Status
Get-NetIPAddress -IPAddress 10.126.126.1 # 隧道 IP 是否还在本机存在
# EasyTier 进程/孤儿渲染进程("进程活着但网卡没了"时清孤儿)
tasklist | grep -i easytier
tasklist | grep msedgewebview2
# Windows 防火墙规则(看 profile)
netsh advfirewall firewall show rule name=all dir=in verbose | findstr /i "Docker 配置文件 本地端口"
# 前端访问日志(对比本地 vs 异地)
docker logs --since 30m penpot-penpot-frontend-1
# "全 200 但零 API/零 WS"快速判定(前端执行中断签名):
# 某时间窗内静态资源全 200,但无 /api/、无 " 101 握手 → 前端初始化前中断,查客户端陈旧态
docker logs --since 2026-09-20T20:05:50Z --until 2026-09-20T20:09:00Z penpot-penpot-frontend-1
docker logs --since 24h penpot-penpot-frontend-1 2>&1 | grep '" 101 ' # WebSocket 握手时间线
# headless Edge 无状态对照(证明服务端是否健康;用临时 profile,用完即弃)
msedge --headless=new --user-data-dir=%TEMP%\pp-check http://localhost:9001/ --dump-dom --virtual-time-budget=15000 | findstr /i "login email"
# 运行时配置与缓存头
curl -s http://localhost:9001/js/config.js
curl -sI 'http://localhost:9001/js/config.js?version=2.17.2-1787817659'
# 镜像内 nginx 配置(定位缓存规则来源)
docker exec penpot-penpot-frontend-1 nginx -T
来源 / 参考
-
Penpot 官方配置文档:https://help.penpot.app/technical-guide/configuration/#penpot-configuration
-
本仓库实际改动:
Code/penpot/docker-compose.yaml、Code/penpot/nginx/config-dynamic.conf.tpl、Code/penpot/nginx/frontend-start.sh(config-no-cache.conf已被动态模板取代并移除) -
排障时间线:第一至三层 2026-09-20(异地白屏);第四层 2026-09-21 上午(本机黑屏,动态下发方案定稿);第四层补记 2026-09-21 下午(第二例本机黑屏:服务端健康,客户端陈旧态,心法 9/10)
九、Penpot MCP Server — AI Agent 集成(2026-09-25)
概述
通过 Penpot 官方 MCP(Model Context Protocol)服务器,使 AI Agent 能够通过 Penpot Plugin API 直接操作设计文件,实现 AI 生成工业 HMI 界面等设计任务。
架构
AI Agent (OpenCode) → MCP Server (localhost:4401/Streamable HTTP) ←→ Penpot MCP Plugin (浏览器, localhost:4400) ←→ Penpot (192.168.1.196:9001 via Easytier)
部署要点
- 克隆仓库:
git clone https://github.com/penpot/penpot.git --branch develop --depth 1 - 构建:
cd penpot/mcp && pnpm -r install && pnpm run build - 启动:
pnpm run start(同时启动 MCP Server 端口 4401 + Plugin Server 端口 4400 + WebSocket 4402) - OpenCode 配置:
opencode.json添加mcp.servers.penpot(type: remote, url: http://localhost:4401/mcp) - 浏览器加载插件:Penpot → Plugins → Load plugin →
http://localhost:4400/manifest.json→ Connect
MCP 工具
| 工具 | 说明 |
|---|---|
execute_code | 在 Penpot 插件上下文中执行 JavaScript |
high_level_overview | 获取 Penpot 工具使用说明 |
penpot_api_info | 获取 Penpot API 文档 |
export_shape | 导出形状为 PNG/SVG |
import_image | 从本地文件导入图像 |
验证
通过 MCP 成功创建了工业触摸屏 HMI 控制面板(43 个元素),验证了 AI Agent → MCP → Penpot Plugin → Penpot 的完整链路。
端口规划
| 用途 | 端口 | 说明 |
|---|---|---|
| SSH | 9222 | NAS 管理 |
| Penpot | 9001 | 设计工具 Web 界面 |
| MCP Server | 4401 | AI Agent 连接(Streamable HTTP) |
| Plugin Server | 4400 | 浏览器加载插件 |
| WebSocket | 4402 | MCP Server ↔ Plugin 通信 |
注意事项
-
Easytier 路由:
192.168.1.0/24通过10.126.126.1网关 -
Chromium 142+ 有 PNA 限制,加载
localhost:4400插件时需允许本地网络访问 -
Penpot 标签页需保持活跃(Chrome 设置 → Performance → Always keep active)
-
type: "local"不可用(MCP 服务器是 Streamable HTTP 而非 stdio),需用type: "remote"