博文

在 EasyTier 组网下用 Docker 部署 Penpot:白屏/黑屏的四层排障实录

TL;DR:在 Windows + Docker Desktop 上部署 Penpot,围绕”本机 / 异地”两个访问入口一共踩了四层坑,任何一层没解决都会卡住:

  1. Windows 防火墙:Docker Desktop 自动放行的入站规则只作用于”公用”配置文件,而 EasyTier 虚拟网卡是”私有”,导致 EasyTier 流量进不来;
  2. PENPOT_PUBLIC_URI 写死成 localhost:前端 JS 拿它当 API / WebSocket 的基址,异地浏览器就去连它自己电脑的 localhost,必然失败;
  3. config.js 被浏览器缓存 7 天:就算改对了配置,旧值仍长期生效,普通刷新根本拉不到新配置——这是最隐蔽的一层;
  4. 修完异地,本机反过来黑屏: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
}

根因(两个坑叠在一起):

  1. 官方镜像对所有 .js(包括运行时配置 config.js)一律下发 max-age=604800(7 天)强缓存;
  2. config.js URL 上的 ?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\";";
}

两个容易踩的细节:

  1. 必须用 $http_host(含端口),不能用 $host——$host 是去端口的小写主机名,会下发 http://localhost(默认 80 端口),前端直接打不开;
  2. 响应体必须是单行 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:55GET / + 全套静态资源(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 → 登录页资源 → 流程正常

两个结论:

  1. 服务端健康:全新 profile 的 A/B 对照通过;四个入口(localhost / 127.0.0.1 / 隧道 IP / 局域网 IP)各自返回 200 且动态 penpotPublicURI 正确;
  2. 不是旧配置值的问题:黑屏标签页的加载拿到的是正确的新 config.js(168B/156B 都是新逻辑按 Host 生成的值)——问题是前端在 JS 加载完成后停止执行(连第一个匿名 API 调用 get-enabled-flags 都没发出来)。“静态资源全 200、/api 和 WS 为零”是前端执行中断的典型特征,指向客户端有陈旧/损坏的站点数据(localStorage/cookie)或扩展干扰。而”清除历史记录”既不清站点数据,也消不掉已打开标签页的内存态。

修复(用户侧,按序执行):

  1. 关闭所有 Penpot 标签页(已打开标签页的内存态只能靠关闭消除);
  2. 清除站点数据(不是”历史记录”):DevTools → Application → Clear storage;或页面加载后右键地址栏锁图标 → 站点设置 → 清除数据。注意:本部署有 三个 origin(localhost:9001 / 127.0.0.1:9001 / 10.126.126.1:9001),陈旧状态在其中任何一个里都会出问题,而清历史一个都不清;
  3. 新标签页 + 硬刷新(Ctrl+Shift+R);
  4. 若仍黑屏:先开隐身窗口(无扩展、无站点数据)——隐身正常则是扩展的锅,逐个禁用;隐身也黑,则把 DevTools Console 里的红色报错发出来。

黑屏页面自检(Console 里直接读内存值):

penpotPublicURI   // 内存中的后端基址:应等于你正在用的入口(含端口)
location.host     // 实际访问入口

值是旧隧道 IP / 与入口不符 → 该标签页带着旧内存配置,关标签页重开;值正确仍黑屏 → 看 Console 红色报错(JS 运行时异常,常见诱因是 localStorage 里损坏的会话数据)。

本部署没有 Service Worker / manifest(/sw.js 404、无 manifest),config.js 又是 no-store——所以客户端的”陈旧态”只可能存在于标签页内存和站点数据两处,关标签页 + 清站点数据后不会复发。


六、四层根因对照表

层症状根因定位手段修复
1 防火墙本机/局域网正常,EasyTier 流量进不来Docker Desktop 入站放行规则只作用于”公用”配置文件,EasyTier 网卡是”私有”,命中默认 BlockInboundGet-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

八、可复用的排查心法

  1. 方向相反的症状,先分层再定位:「本机好、异地坏」→ 网络连通(防火墙/路由)→ 配置正确性(地址/基址)→ 缓存一致性;「异地好、本机坏」→ 检查前端基址是否绑死了一个本机不一定存在的地址(隧道 IP、虚拟网卡)。别一上来就怀疑代码。
  2. 善用前端 nginx 访问日志做 A/B 对比:本地 vs 异地的请求序列差异(有没有 /api/、有没有重新拉 config.js)是最直接的证据,比看浏览器 Console 更快。
  3. localhost 只对本机成立,隧道 IP 只对隧道成员成立:任何会被下发到客户端、再用于拼后端 URL 的配置(PUBLIC_URI、WS 地址、回调地址…),写死单一地址就天然只对一种入口正确。多入口(本机 + 异地 + 局域网)场景,能动态就别写死:前端运行时配置(如 config.js)直接用 nginx 的 $http_host 生成”浏览器从哪来就回哪去”;只有服务端主动发给别人的链接(邮件、通知)才必须写死一个对方可达的地址。
  4. 带版本号缓存的静态资源,版本号要和”内容是否变化”绑定:如果版本号只跟构建走、不跟运行时配置走,改配置就不会换缓存。运行时配置文件(如 config.js)应一律 no-cache。
  5. Windows 下 Docker 的防火墙规则是按”配置文件(profile)“生效的:Docker Desktop 默认只放行它自己后端进程、且常限定在 public;跨虚拟网卡(EasyTier/Tailscale/WireGuard 等私有网卡)访问时,要额外给对应 profile 放行目标端口。
  6. 不要把配置绑在”会消失的地址”上:虚拟网卡的 IP / 网卡名都可能随开关机、重启、进程挂死而消失(EasyTier 的网卡名每次启动都变)。“进程还活着”≠“网络还在线”——先 Get-NetAdapter / ipconfig 确认网卡和 IP 真实存在,再谈应用层。
  7. 给 IP 做本地别名/路由劫持会和网络软件正面冲突:想”EasyTier 关着也能访问隧道 IP”而把隧道 IP 加到 Loopback 上,EasyTier 一开就因 IP 所有权冲突退出。正确方向是让配置跟着访问者走(动态 Host),不是让网络迁就配置(别名/劫持)。
  8. Tauri/WebView2 类桌面应用的”假活”进程:窗口关闭后 GUI 进程可能不退,还会留下孤儿 msedgewebview2 渲染进程,占用虚拟网卡/端口资源,导致重新拉起后”进程活着但网卡/服务起不来”。排障时顺手清掉孤儿进程再重启。
  9. “清历史”≠“清缓存”≠“清站点数据”:浏览器”清除历史记录”只清浏览记录,不清 HTTP 缓存、不清 localStorage/cookie、更消不掉已打开标签页内存里的 JS 变量与 SPA 状态。多入口部署(每个入口一个 origin)要逐个 origin 清”站点数据”(DevTools → Application → Clear storage),或直接隐身窗口开测——隐身窗口是最快的”无状态对照”:它正常,问题就锁定在用户的站点数据或扩展里。
  10. “全 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)

部署要点

  1. 克隆仓库:git clone https://github.com/penpot/penpot.git --branch develop --depth 1
  2. 构建:cd penpot/mcp && pnpm -r install && pnpm run build
  3. 启动:pnpm run start(同时启动 MCP Server 端口 4401 + Plugin Server 端口 4400 + WebSocket 4402)
  4. OpenCode 配置:opencode.json 添加 mcp.servers.penpot(type: remote, url: http://localhost:4401/mcp)
  5. 浏览器加载插件: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 的完整链路。

端口规划

用途端口说明
SSH9222NAS 管理
Penpot9001设计工具 Web 界面
MCP Server4401AI Agent 连接(Streamable HTTP)
Plugin Server4400浏览器加载插件
WebSocket4402MCP 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"

相关阅读

← 返回博文列表