01 payments (EN+ZH) - idempotency gate is now a whitelist: only PENDING and a late-settled EXPIRED may enter fulfilment. Checking only for FULFILLED left the whole PROCESSING window open to a second queue push (consultant finding). - k-of-n -> two-tier: both chassis anchors (SMBIOS board UUID + CPU architectural signature) matching is sufficient; if one anchor changed, every auxiliary digest must match. Aux dimension moved off the volatile OS GUID onto permanent NIC MAC. - FSM diagram: RETRY_QUEUE branches from PROCESSING, not FULFILLED 02 comfyui covers (EN+ZH) - subscribe the WebSocket BEFORE queue_prompt, settimeout(120), try/finally ws.close(), /history fallback on timeout: fixes the lost-terminal-frame hang and the per-image socket leak - document Pillow half-wave rectified Laplacian (offset=0 clamps negative responses to 0) and why a signed float kernel would not work here - replace the unverifiable "89 covers in 48 minutes" with the filesystem evidence: 216 assets (108 banner+OG pairs) inside a 25.9-min window - ZH: drop the colloquial imperative; add self-bootstrapping note 03 traefik yaegi (ZH) - the .traefik.yml contract paragraph and scoreThreshold 120 had only landed in the EN file; both now applied to ZH - <summary> line counts corrected 29 -> 30 in both languages (verified programmatically for all eight code blocks) layout - standing provenance note in the site footer (sanitised client identifiers / production thresholds), per consultant Q7 ruling
9.0 KiB
ogImage, banner, title, description, pubDate, category, blueprint, blueprintLayer, tags, draft, summary, dynamicCta
| ogImage | banner | title | description | pubDate | category | blueprint | blueprintLayer | tags | draft | summary | dynamicCta | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| /og/zh-traefik-yaegi-plugin-package-name-trap.jpg | /banners/traefik-yaegi-plugin-package-name-trap.jpg | Traefik 自定义插件启动崩溃:Yaegi 解释器的连字符与下划线陷阱 | 为什么本地测试全部通过的 Go 插件在 Traefik 启动时报错 'CreateConfig failed with Yaegi':深度排查 Yaegi 动态解释器对模块路径连字符转下划线的包命名隐蔽契约。 | 2026-10-27 | notes | self-hosted-infrastructure | L2-edge-proxy-runtime |
|
true |
|
true |
本地测试全绿,上云启动暴雷的 Go 插件
在为私有化部署的公网服务构建边缘安全防护时,使用 Go 语言为 Traefik 编写自定义中间件插件是最轻量、性能最好的方案之一。它无需引入 Redis 或外部校验服务,所有扫描拦截与轻量级客户端评分逻辑均在反向代理进程内部原生运行。
近期我编写了 traefik-botfilter 插件,用于检测异常的 HTTP 请求头、阻断常见的黑客扫描路径(如 .env、wp-login.php),并在内存中对高危 IP 进行平滑扣分与限时封禁。
在本地开发环境中,测试用例表现非常完美:
go test -v ./...
# PASS: TestBotFilter (0.04s)
# PASS: TestCacheExpiration (0.12s)
# ok github.com/hoelee/traefik-botfilter 0.182s
然而,当我将代码挂载到 Traefik 的 plugins-local 目录并启动生产容器时,Traefik 却直接崩溃退出了:
2026-10-23T04:47:12Z ERR Failed to create middleware error="failed to load plugin botfilter: CreateConfig failed with Yaegi: symbol not found" entryPointName=web routerName=app@file
Yaegi 动态解释器的包名推断规则
Traefik 加载中间件插件的方式并不是像常见 Nginx 模块那样加载预先编译好的 .so 动态链接库。相反,Traefik 内部嵌入了 Yaegi——一个轻量级的 Go 语言动态解释器。在 Traefik 启动时,Yaegi 会在内存中直接对 Go 源码进行语法分析、符号解析与动态编译。
在 Traefik 的静态配置文件中声明插件时:
experimental:
localPlugins:
botfilter:
moduleName: github.com/hoelee/traefik-botfilter
Traefik 会要求 Yaegi 在插件包中寻找并导出符合中间件契约的两个标准函数入口:
CreateConfig() *ConfigNew(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error)
然而,这里隐藏了一个极其隐蔽的契约断层。
在标准的 Go 语言工程中,模块路径与目录名经常带有连字符(如 module github.com/hoelee/traefik-botfilter),而源码文件首行通常声明不带前缀的合法标识符(如 package botfilter)。官方 Go 编译器(gc)通过模块导入路径可以完美绑定解析。
这里还有一层语言层面的根因:在 Go 中连字符 - 是减法运算符,因此 package traefik-botfilter
不是「不常见」,而是不合语法、永远无法编译。标准工具链正是靠 go.mod 把带连字符的目录名/模块路径
与文件内声明的标识符解耦的。Yaegi 不解析模块图,它从源码文本出发,先合成求值上下文,再在自己算出的
命名空间里反射查找 CreateConfig——这条路径根本不读 go.mod,所以声明的包名必须与加载器算出的那个
完全一致。
但是,Yaegi 动态解释器并不走标准 Go 模块语义解析流程。
Traefik 的动态插件加载器会直接从静态配置中 moduleName 的最后一个路径段 提取包标识符,并且自动将所有的连字符(-)强制转换为下划线(_):
\text{模块路径}: \texttt{github.com/hoelee/traefik-botfilter} \longrightarrow \text{预期包名}: \texttt{traefik\_botfilter}
而我当时的 Go 源码首行写的是:
// botfilter.go
package botfilter // <-- YAEGI 在这个命名空间下找不到导出的符号!
结果就是:Yaegi 去 AST 抽象语法树中检索 traefik_botfilter.CreateConfig 时,内存中只有 botfilter.CreateConfig。由于命名空间无法对齐,Yaegi 直接判定入口符号缺失,导致 Traefik 在启动阶段抛出致命异常并拒绝挂载网关端口。
修复步骤与目录树对齐
要让 Yaegi 正常识别插件入口,需要完成两步对齐:
1. 将所有 Go 源文件的 package 声明重命名
把项目下所有的 .go 文件(包括单元测试)首行的包名统一改为连字符转下划线后的名称:
// Package traefik_botfilter is a Traefik middleware plugin...
package traefik_botfilter
2. 严格对齐物理目录层级
对于 Traefik 的本地插件(localPlugins),源码所在的物理路径必须严格复刻完整的 Go 模块导入路径,挂载于 plugins-local/src/ 下:
plugins-local/
└── src/
└── github.com/
└── hoelee/
└── traefik-botfilter/
├── .traefik.yml
├── go.mod
├── botfilter.go
├── config.go
└── ...
该目录下的 .traefik.yml 清单属于同一份契约:其中的 import 字段指向加载器将要清洗取末段
的那个模块路径,displayName 与 type 又必须与动态配置中的引用一致。清单里的 import 路径
与源码的 package 声明只要有一处不一致,产生的错误与本文的连字符陷阱完全相同——都是那句
语焉不详的 symbol not found。所以这两处必须一起核验,而不是一个一个单独去试。
🔍 查看修正后的动态 Traefik 中间件配置示例(YAML 摘录,30 行)
# 示例脱敏配置 —— 生产环境实际阈值不同
http:
middlewares:
botfilter:
plugin:
botfilter:
statusCode: 403
requireUserAgent: true
requireAccept: true
browserValidation: true
temporaryBanMinutes: 15
scoreThreshold: 120
blockedPaths:
- /.env
- /.git
- /wp-login.php
blockedUserAgents:
- sqlmap
- nikto
- masscan
logBlockedRequests: true
routers:
protected-app:
rule: Host(`service.internal`)
entryPoints:
- websecure
middlewares:
- botfilter
service: backend-service
交付结果
将源码包名从 botfilter 修正为 traefik_botfilter 后重新启动 Traefik,容器在 0.42 秒 内平稳拉起并成功装载插件:
2026-10-23T04:48:43Z INF Loading local plugin: botfilter
2026-10-23T04:48:44Z INF Plugin loaded successfully module=github.com/hoelee/traefik-botfilter
在 Traefik 动态插件工程里,Go 官方编译器的通过只算赢了一半。 包声明必须与模块路径清洗后的
末段一致,.traefik.yml 的 import 又必须指向同一个模块——否则 Yaegi 会拒绝拉起网关,而报错里点名的
那个符号,在每一轮本地测试里都明明存在。
Yaegi 插件在什么情况下不再是正解
在每秒数千请求量级的边缘节点上,进程内解释型插件是最便宜的正解:不需要 Redis、不需要边车进程、 不需要额外守护,拦截器与路由共享同一块内存。代价在两个地方兑现——解释执行比编译码慢,且插件的 每一次分配都落在 Traefik 进程自己的堆上,于是 GC 压力随流量上升,而不是随规则复杂度上升。当单机 吞吐进入数万 QPS 量级,或面对洪峰型扫描时,顺序必须反过来:先在代理之前丢包(CDN 侧边缘 WAF,或宿主机 上的 CrowdSec 配合 nftables / eBPF),再让 Go 插件处理包过滤看不见的语义问题——头部形态、请求节流、 分 IP 评分。