Traefik 是一个开源应用程序代理,也是 Traefik Hub 运行时平台的核心。
如果从 Traefik 开始进行服务发现和路由,您可以根据需要无缝添加API 管理、API 网关、AI 网关和API 模拟功能。
Traefik 的工作原理如下:它代表您的系统接收请求,识别负责处理这些请求的组件,并安全地路由它们。它通过检查您的基础架构来自动发现适合您服务的正确配置,从而识别相关信息以及哪个服务处理哪个请求。
因为一切都是自动实时发生的(无需重启,无需连接中断),您可以专注于开发和部署系统的新功能,而不是配置和维护其工作状态。
0. 一分钟心智模型 #
一个请求在 Traefik 里的旅程(记住这条链,所有配置都是给链上某一环填参数):
┌──────────── 一个 HTTP 请求的旅程 ────────────┐
请求 ─▶ EntryPoint ─▶ Router ─▶ Middleware ─▶ Service ─▶ 后端容器
(监听端口) (规则匹配) (链式加工,可选) (负载均衡)
:80/:443 Host/Path 认证/限流/改头 轮询/粘性
| 概念 | 一句话 | 配什么 |
|---|---|---|
| EntryPoint 入口点 | 监听的端口 | web=:80、websecure=:443 |
| Router 路由器 | 规则 + 指向哪个 service + 挂哪些中间件 | Host()、PathPrefix() |
| Middleware 中间件 | 请求到后端前做加工(认证/限流/改写…) | basicauth、stripprefix… |
| Service 服务 | 一组后端 + 负载均衡策略 | loadbalancer.server.port |
| Provider 提供者 | Traefik 从哪读配置 | docker / file / consulcatalog |
Traefik 的精髓:你不写路由文件,它从 Docker label / Consul tag / YAML 自动生成路由。 反向代理会自动给后端加上
X-Forwarded-For、X-Forwarded-Host、X-Forwarded-Proto、X-Real-Ip等头,后端靠它们识别真实客户端。
1. 静态配置 vs 动态配置 #
最容易混的概念,先分清:
| 静态配置 | 动态配置 | |
|---|---|---|
| 是什么 | 启动时确定:entryPoints、providers、日志、TLS resolver、API | 路由 / 中间件 / 服务 |
| 改了要不要重启 | 要重启 Traefik | 热加载,不重启 |
| 写在哪 | 命令行 flag / traefik.yml |
Docker label / Consul tag / file provider 的 YAML |
静态配置三种等价写法(挑一种):
# 写法A:docker-compose 的 command(最直观,01~04 用)
command:
- "--entrypoints.web.address=:80"
- "--providers.docker=true"
- "--api.dashboard=true"
# 写法B:traefik.yml 文件(生产常见,05/06 用),挂载进容器 /etc/traefik/traefik.yml
entryPoints:
web:
address: ":80"
providers:
docker: {}
api:
dashboard: true
flag 与 yml 一一对应:--api.dashboard=true ⟺ api.dashboard: true、--providers.docker=true ⟺ providers.docker: {}。文件方式适合配置变多时;flag 方式适合一眼看全。
2. EntryPoint 入口点(监听端口) #
# command 写法
- "--entrypoints.web.address=:80" # 名为 web 的入口,监听 80
- "--entrypoints.websecure.address=:443" # 名为 websecure 的入口,监听 443
- "--entrypoints.internal.address=:8081" # 自定义内部入口(08:不发布给宿主机=内网专用)
HTTP→HTTPS 全局自动跳转(在入口点上配,05):
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure # 跳到哪个入口
scheme: https
permanent: true # 301(false=302)
技巧:一个入口发布到宿主机(compose 里
ports: ["80:80"])才对外可达;不写 ports 的入口只在容器网络内可达 → 天然的"内部入口"(08 用它做服务间安全回调,宿主机/外网访问不到)。
3. Provider 提供者(配置来源) #
三种 provider 可同时启用。对象跨来源引用时用 @provider 后缀:whoami@docker、echo-a@consulcatalog、mw@file。
3.1 docker provider(读容器 label,01~04、07、08) #
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false" # 默认不暴露,必须显式 traefik.enable=true(更安全)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # 必须挂 socket(只读),靠它监听容器事件
3.2 file provider(读 YAML,05/06) #
providers:
file:
directory: /etc/traefik/dynamic
watch: true # ★ 改 YAML 立即热加载,不掉一个请求(相比 nginx reload 的优势)
何时用 file provider:后端不是 Docker 容器(外部 VM/裸机/第三方 API);想把全部网关配置集中进 Git 做 review;需要 label 表达不了的高级特性(加权服务、流量镜像)。不用 file provider 时甚至不需要挂 docker.sock。
3.3 consulcatalog provider(读 Consul,09/10) #
详见 CONSUL 速查手册。一句话:服务自己注册到 Consul,Traefik 从 Consul 读路由,完全不挂 docker.sock。
4. Router 路由(规则匹配) #
4.1 Label 语法拆解(看懂这一条,所有 label 都能读) #
traefik.http.routers.whoami.rule=Host(`whoami.localhost`)
\__/ \_____/ \____/ \__/
| | | └─ 属性(rule / entrypoints / middlewares / service / priority / tls)
| | └─ router 的名字(自起,全局唯一)
| └─ 对象类型:routers / services / middlewares
└─ 协议:http / tcp / udp
通式:traefik.<协议>.<对象>.<名字>.<属性>=<值>
4.2 一个完整 router 的最小三件套 #
labels:
- "traefik.enable=true" # ① 允许被代理
- "traefik.http.routers.myapp.rule=Host(`app.localhost`)" # ② 规则
- "traefik.http.routers.myapp.entrypoints=web" # ③ 绑定入口
# 单容器单端口时 service 可省略,Traefik 自动建;多端口/多副本才需显式指定 port:
- "traefik.http.services.myapp.loadbalancer.server.port=8000"
4.3 匹配器(matcher)速查 #
| 匹配器 | 含义 | 例子 |
|---|---|---|
Host(\a.com`)` |
精确域名 | Host(\api.localhost`)` |
HostRegexp(...) |
正则域名 | HostRegexp(\^.+.localhost$`)` |
PathPrefix(\/x`)` |
路径前缀 | PathPrefix(\/api`)` |
Path(\/x`)` |
路径精确 | Path(\/health`)` |
Header(\K`,`V`)` |
请求头 | Header(\X-Env`,`canary`)` |
Method(\GET`)` |
HTTP 方法 | Method(\POST`)` |
Query(\k`,`v`)` |
查询参数 | Query(\debug`,`1`)` |
组合用 &&(与)||(或)!(非)和括号:
Host(`app.localhost`) && (PathPrefix(`/api`) || PathPrefix(`/v2`))
4.4 优先级(多条规则都命中时谁赢) #
- priority 数字越大越优先。
- 不设时,Traefik 用规则字符串长度当 priority —— 规则越长越"具体"越优先。
- 典型用法:兜底 router 显式设
priority=1,让更具体的规则先匹配。
app.localhost/api/users ─┐
├─▶ Host(app) && PathPrefix(/api) (长/高优先) → API 后端
app.localhost/home ─┘ Host(app) priority=1 (兜底) → WEB 后端
- "traefik.http.routers.api.rule=Host(`app.localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.web.rule=Host(`app.localhost`)"
- "traefik.http.routers.web.priority=1" # 兜底,确保 /api 那条先匹配
灰度路由:
Host(\app.localhost`) && Header(`X-Canary`,`true`)指向新版本,命令行curl.exe -H “X-Canary: true” …` 验证。
5. Service 服务(负载均衡 / 粘性 / 健康检查) #
5.1 多副本自动轮询(04) #
docker compose up -d --scale whoami=3 # 起 3 个同名副本
请求1 ─▶ 副本A
请求2 ─▶ 副本B ← Traefik 默认 加权轮询(weighted round-robin)
请求3 ─▶ 副本C 你没配后端列表,它从 Docker 自动发现同名 service 全部副本
请求4 ─▶ 副本A ...
labels:
- "traefik.enable=true"
- "traefik.http.routers.lb.rule=Host(`lb.localhost`)"
- "traefik.http.services.lb.loadbalancer.server.port=80"
# ★ 多副本服务不要设 container_name(否则无法 --scale 到多个)
停掉一个副本,Traefik 自动把流量切到剩余副本(该副本的 Hostname 不再出现)。
5.2 粘性会话 sticky(同一客户端始终落同一副本) #
首次请求 ─▶ 副本B + Set-Cookie: gm_sticky=B
带 cookie 再请求 ─▶ 永远 副本B (适合有本地 session、不便共享的旧应用)
- "traefik.http.services.sticky.loadbalancer.sticky.cookie=true"
- "traefik.http.services.sticky.loadbalancer.sticky.cookie.name=gm_sticky"
验证:curl.exe -c cookies.txt ... 存 cookie,再 curl.exe -b cookies.txt ... 多次 → Hostname 不变。
5.3 健康检查(主动探活,自动摘除坏副本,07) #
- "traefik.http.services.goapp.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.goapp.loadbalancer.healthcheck.interval=10s"
- "traefik.http.services.goapp.loadbalancer.healthcheck.timeout=3s" # 超 3s 算失败→踢出负载池
5.4 file provider 里手写后端列表(06) #
http:
services:
whoami-cluster:
loadBalancer:
servers:
- url: "http://whoami1:80" # 靠 Docker 内部 DNS,用服务名即可解析
- url: "http://whoami2:80"
# healthCheck:
# path: /health
# interval: "10s"
6. Middleware 中间件(请求到后端前的加工) #
两步:定义 + 引用。只定义不引用 = 不生效。
# 定义
- "traefik.http.middlewares.<名>.<类型>.<参数>=<值>"
# 引用(在 router 上)
- "traefik.http.routers.<r>.middlewares=<名1>,<名2>" # 逗号分隔,★ 列表顺序 = 执行顺序
6.1 类型速查 #
| 类型 | 作用 |
|---|---|
basicauth / digestauth / forwardauth |
认证 |
ratelimit |
限流(令牌桶) |
inflightreq |
限制并发请求数 |
headers |
增删请求/响应头、CORS、安全头(HSTS) |
stripprefix / addprefix / replacepath |
路径改写 |
redirectscheme / redirectregex |
重定向(http→https 等) |
compress |
gzip/br 压缩响应 |
retry |
失败重试 |
circuitbreaker |
熔断 |
ipallowlist |
IP 白名单 |
chain |
把多个中间件组合成一条复用链 |
6.2 常用例子(直接抄,03) #
BasicAuth(test/test):
- "traefik.http.routers.x.middlewares=myauth"
- "traefik.http.middlewares.myauth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/"
# 生成凭据:docker run --rm httpd:alpine htpasswd -nbB 用户 密码
# ★ docker-compose 里 $ 必须写成 $$ 转义
# 验证:curl.exe -u test:test -H "Host: auth.localhost" http://127.0.0.1/ (不带凭据→401)
RateLimit(平均 5 req/s,突发 10,超出返回 429):
- "traefik.http.middlewares.myrl.ratelimit.average=5"
- "traefik.http.middlewares.myrl.ratelimit.burst=10"
Headers(注入请求头/响应头):
- "traefik.http.middlewares.h.headers.customrequestheaders.X-From-Traefik=hello" # 给后端加请求头
- "traefik.http.middlewares.h.headers.customresponseheaders.X-Powered-By=GoldenMatrix" # 给响应加头
安全头(file provider 写法,06):
middlewares:
secure-headers:
headers:
frameDeny: true # X-Frame-Options: DENY
contentTypeNosniff: true # X-Content-Type-Options: nosniff
browserXssFilter: true
customResponseHeaders:
X-Managed-By: "file-provider"
StripPrefix(剥前缀,把多后端挂不同子路径时极常用):
- "traefik.http.routers.x.rule=Host(`strip.localhost`) && PathPrefix(`/service1`)"
- "traefik.http.middlewares.mystrip.stripprefix.prefixes=/service1"
# 访问 /service1/hello → 后端实际收到 /hello(并被注入 X-Forwarded-Prefix)
Redirect(正则重定向):
- "traefik.http.middlewares.to-new.redirectregex.regex=^http://old.localhost/(.*)"
- "traefik.http.middlewares.to-new.redirectregex.replacement=http://new.localhost/$${1}" # $$ 转义
- "traefik.http.middlewares.to-new.redirectregex.permanent=true" # 301
Retry(失败重试,换健康副本,07):
- "traefik.http.middlewares.r.retry.attempts=3"
- "traefik.http.middlewares.r.retry.initialinterval=100ms" # 之后指数退避
清空请求头(把值设空,08 用它防伪造身份):
- "traefik.http.middlewares.strip-identity.headers.customrequestheaders.X-User-Id="
Chain(把"标准套餐"定义一次到处复用):
- "traefik.http.middlewares.compress-mw.compress=true"
- "traefik.http.middlewares.mychain.chain.middlewares=myauth,compress-mw"
- "traefik.http.routers.x.middlewares=mychain"
7. TLS / HTTPS(05) #
HTTP :80 ──(入口点自动 301)──▶ HTTPS :443 ──按 SNI 选证书──▶ 后端
第一步:生成自签证书(用 Docker 里的 openssl,无需本地装;完整脚本见 13.5):
./generate-certs.ps1 # 生成 certs/local.crt 和 certs/local.key
第二步:在 file provider 里注册证书(dynamic/tls.yml):
tls:
certificates: # 证书库,Traefik 按 SNI(域名) 自动选匹配证书
- certFile: /etc/traefik/certs/local.crt
keyFile: /etc/traefik/certs/local.key
stores:
default:
defaultCertificate: # 没有证书匹配域名时的兜底,避免握手失败
certFile: /etc/traefik/certs/local.crt
keyFile: /etc/traefik/certs/local.key
第三步:router 开 TLS:
- "traefik.http.routers.x.entrypoints=websecure"
- "traefik.http.routers.x.tls=true"
访问自签:curl.exe -k ...(-k 忽略证书校验);浏览器会有"不安全"警告,点继续即可,是预期行为不是配置错误。看证书细节:curl.exe -kv ... 2>&1 | Select-String "subject:|issuer:|CN="。
生产:Let’s Encrypt 自动证书(需公网域名 + 80/443 可达,本地无法验证):
certificatesResolvers:
myresolver:
acme:
email: you@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
然后 router 加 traefik.http.routers.x.tls.certresolver=myresolver,Traefik 自动申请+续期。
8. Label ↔ File 写法对照 #
同一个 router,两种等价表达:
# Docker label
- "traefik.http.routers.x.rule=Host(`a.localhost`)"
- "traefik.http.routers.x.service=mysvc"
- "traefik.http.services.mysvc.loadbalancer.server.port=80"
# file provider
http:
routers:
x:
rule: "Host(`a.localhost`)"
entryPoints: [web]
service: mysvc
middlewares: [secure-headers]
services:
mysvc:
loadBalancer:
servers:
- url: "http://backend:80"
middlewares:
secure-headers:
headers:
frameDeny: true
理解这层映射,两种 provider 就都会用了。Consul tag 写法 = label 写法去掉无关部分(见 Consul 手册)。
9. forwardauth 鉴权 + 安全模型(08 demo) #
最实战的一课:用两个 Go 服务跑通 认证 → 身份透传 → 应用层授权,并把"接口必须经过 Traefik"做成强约束。完整源码与 compose 见 13.8。
9.1 forwardauth 原理 #
把"放不放行"委托给一个外部认证服务。Traefik 对每个受保护请求先发一个子请求到认证服务:
forwardauth 子请求
请求 ─▶ Router ─▶ [forward-auth 中间件] ──GET /auth/verify──▶ 认证服务
│ ◀── 2xx + X-User-* ──┘ (有效)
│ ◀── 401 ──────────────┘ (无效→直接拒绝,到不了后端)
▼
把认证服务返回的 X-User-* 头「覆盖」到原请求 ─▶ 后端
# 定义 forwardauth
- "traefik.http.middlewares.forward-auth.forwardauth.address=http://authsvc:4000/auth/verify"
- "traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-User-Id,X-User-Name,X-User-Roles"
# 引用(多个中间件按顺序执行)
- "traefik.http.routers.user-api.middlewares=strip-identity,forward-auth,edge-secret"
forwardauth 给认证服务的子请求会带上
X-Forwarded-Method、X-Forwarded-Uri、X-Forwarded-Host等上下文,并转发原始Authorization头。认证服务返回非 2xx → Traefik 立即把该状态码回给客户端,链路中断,请求根本到不了后端。
9.2 系统全景(08 的两层门禁) #
浏览器 ──▶ Traefik :80(web,发布) ──┬─ / → frontend(页面,无门禁)
├─ /sys/* → syscheck(控制台,无门禁)
├─ /auth/login → [L1] → authsvc
├─ /auth/renew → [L1] → authsvc
└─ /api/* → [L1→strip→L2→edge] → usersvc
Traefik :8081(internal,不发布) ─ /internal → [edge] → usersvc
Traefik :8080(dashboard)
[L1]=system-gate 系统鉴权(forwardauth→syscheck) [L2]=forward-auth 登录鉴权(forwardauth→authsvc)
[edge]=edge-secret 注入网关密钥
| 组件 | 端口 | 角色 | 关键机制 |
|---|---|---|---|
| traefik | 80/8081/8080 | 边缘网关 | 路由、两道 forwardauth、注密钥、访问日志 |
| frontend | 80 | 体验界面(nginx) | 静态页,驱动并可视化两道门禁 |
| syscheck | 7100 | 系统鉴权(L1) | 租约 + 后台续期;吊销→全员 503 |
| authsvc | 4000 | 登录鉴权(L2) | 签发/校验/续期 HMAC token |
| usersvc | 5000 | 业务 + 用户库 | edgeGuard 守门;读身份头;角色授权 |
9.3 两层门禁(核心设计) #
┌──── L1 系统鉴权(syscheck) ────┐ ┌── L2 登录鉴权(authsvc) ──┐
任何接口请求 ──▶ │ system-gate:租约还有效吗?(廉价)│──▶│ forward-auth:登录了吗?(验token)│──▶ 后端
│ ✗ 租约过期 → 503(含登录都拦) │ │ ✗ 无/过期 token → 401 │
└───────────────────────────────┘ └───────────────────────────────┘
▲ 后台每 3s 续租 ▲ 前端每 8s 滑动续期
│ (续不上→10s后到期) │ (不续→20s后过期)
syscheck.renewLoop 前端定时 POST /auth/renew
| 接口 | system-gate(L1) | forward-auth(L2) | 说明 |
|---|---|---|---|
/(前端) |
✗ | ✗ | 页面始终可加载(否则吊销后无法恢复) |
/sys/*(控制台) |
✗ | ✗ | 吊销/恢复/状态,必须始终可达 |
/auth/login |
✓ | ✗ | 登录也要先过系统鉴权 |
/auth/renew |
✓ | ✗ | token 续期也要先过系统鉴权 |
/api/*(业务) |
✓ | ✓ | 两道门禁都要过 |
为什么用"租约 + 后台续期"而非每请求重鉴权:每请求只查一个内存时间戳(极廉价);昂贵的真实核验由 syscheck 后台每几秒做一次。代价:吊销后最多一个 LEASE_TTL(10s) 延迟才彻底失效——缓存换性能的固有取舍,也是真实许可/心跳系统的常态。
9.4 “必须经过 Traefik” 的四道防线 #
| 威胁 | 防线 | 怎么做 |
|---|---|---|
| 外网/宿主机直连后端 | 后端不发布端口 | usersvc/authsvc 无 ports: |
| 走错入口访问内部接口 | 入口隔离 | /internal 只在不发布的 :8081 入口 |
| 同网络容器绕过网关直连 | Edge Secret | usersvc 校验 X-Edge-Auth,只有 Traefik 注入 |
| 客户端伪造身份头 | 清空 + 覆盖 | strip-identity 先清空,再由 forward-auth 用真实值覆盖 |
中间件链 strip-identity,forward-auth,edge-secret 三步分工(缺一不可,顺序即执行顺序):
1. strip-identity 清空客户端伪造的 X-User-* (防伪造第一层)
2. forward-auth 验 token,把真实身份头覆盖回来 (没 token→401,到不了后端)
3. edge-secret 注入 X-Edge-Auth 密钥 (证明"我来自 Traefik")
请求头在每一跳的变化(攻击者伪造了 X-User-Id: 9999):
【①客户端发出】 Authorization: Bearer <alice token>; X-User-Id: 9999 ← 伪造
【③strip-identity 后】 Authorization: Bearer <token>; (X-User-Id 被清掉)
【④forward-auth 子请求】 GET /auth/verify + Authorization + X-Forwarded-*
authsvc 验签 → 200, X-User-Id:1001 / X-User-Name:Alice / X-User-Roles:user,admin
【④覆盖回原请求】 X-User-Id: 1001 ← 真实值覆盖了刚清空的位置
【⑤edge-secret 后(发给后端)】 ... + X-Edge-Auth: ONLY-TRAEFIK-KNOWS-...
【⑥usersvc】 edgeGuard 校验 X-Edge-Auth 通过 → 读 X-User-Id=1001 返回 Alice 档案
伪造的 9999 经过两道防线:先被 strip-identity 清空,即便没清掉也会被 forward-auth 用 token 真实值覆盖。
为什么登录要"绕一圈"经 Traefik 调 usersvc:usersvc 只信 X-Edge-Auth,而它只有 Traefik 注入。authsvc 自己不持有密钥,所以它校验账号密码也只能走 Traefik 内部入口(USERSVC_URL=http://traefik:8081)让 Traefik 帮它盖章 → “持有密钥 ⟺ 来自 Traefik” 这条不变式无任何例外。
9.5 认证 vs 授权分工 #
认证(你是谁,token 真不真)在边缘(Traefik + authsvc);细粒度授权(这角色能不能进 admin)是业务逻辑,留应用层(usersvc 读 X-User-Roles 判 admin)。Traefik 只负责把可信角色信息送到,不替你判业务规则。
9.6 服务 API 参考 #
syscheck(系统鉴权 · :7100)
| 方法 路径 | 用途 | 返回 |
|---|---|---|
GET /gate/check |
forwardauth 目标(廉价租约检查) | 200 有效 / 503 过期 |
GET /sys/state |
状态(前端轮询) | {entitled,leaseValid,secondsLeft,...} |
POST /sys/revoke |
吊销许可 | revoked |
POST /sys/grant |
恢复许可 | granted |
authsvc(登录鉴权 · :4000)
| 方法 路径 | 用途 | 返回 |
|---|---|---|
POST /auth/login |
校验凭据(回调usersvc)→签发 token | {token} / 401 |
GET /auth/verify |
forwardauth 目标,验 token | 200+X-User-* / 401 |
POST /auth/renew |
token 滑动续期 | {token} / 401(需重登) |
token 结构:base64url(JSON{uid,name,roles,exp}) + "." + hex(HMAC-SHA256(secret, base64部分))。
usersvc(业务+用户库 · :5000)
| 方法 路径 | 用途 | 门禁 |
|---|---|---|
POST /internal/login-check |
供 authsvc 校验凭据 | edgeGuard,仅 internal 入口可达 |
GET /api/profile |
读自己档案 | edgeGuard + 身份头来自 L2 |
GET /api/admin/users |
用户列表 | edgeGuard + 角色含 admin |
内置用户:alice/alice123(roles=user,admin)、bob/bob123(roles=user)。
9.7 配置参考(环境变量) #
| 服务 | 变量 | 默认 | 作用 |
|---|---|---|---|
| syscheck | RENEW_INTERVAL_SEC |
3 | 后台多少秒续一次期 |
| syscheck | LEASE_TTL_SEC |
10 | 租约时长(吊销后最多这么久才失效) |
| authsvc | USER_TOKEN_TTL_SEC |
20 | 用户 token 有效期 |
| authsvc | TOKEN_SECRET |
(demo值) | token 签名密钥 |
| authsvc | USERSVC_URL |
http://traefik:8081 |
登录校验回调地址(走内部入口) |
| usersvc | EDGE_SECRET |
(demo值) | 网关密钥,须与 edge-secret 中间件一致 |
9.8 命令行验证 #
# 登录拿 token(请求体写文件规避 PowerShell 引号转义)
'{"username":"alice","password":"alice123"}' | Set-Content -Encoding ascii alice.json
$token = (curl.exe -s -X POST -H "Host: api.localhost" -H "Content-Type: application/json" `
-d "@alice.json" http://127.0.0.1/auth/login | ConvertFrom-Json).token
# 带 token 访问业务
curl.exe -s -H "Host: api.localhost" -H "Authorization: Bearer $token" http://127.0.0.1/api/profile
# 没 token → 401
curl.exe -s -i -H "Host: api.localhost" http://127.0.0.1/api/profile | Select-String "401"
# 伪造身份头无效(返回的 id 仍是真实的 1001)
curl.exe -s -H "Host: api.localhost" -H "Authorization: Bearer $token" -H "X-User-Id: 9999" http://127.0.0.1/api/profile
# 宿主机直连后端 → 连不上(端口未发布)
curl.exe -s -m 3 http://127.0.0.1:5000/api/profile
# 同网络绕过网关直连 → 403(缺 X-Edge-Auth)
docker compose exec authsvc sh -c 'wget -qO- http://usersvc:5000/api/profile 2>&1'
9.9 生产加固清单 #
- Edge Secret 升级为 mTLS:Traefik 持客户端证书访问后端,后端要求该证书——“服务必须来自 Traefik"的金标准(
serversTransport+ 后端双向 TLS)。 - 密钥别写 label:用 Docker secrets / 环境变量注入
EDGE_SECRET与 token 密钥,定期轮换。 - 关掉
api.insecure:Dashboard 改用带认证的安全 router 暴露,或只在内网开放。 - token 用正规 JWT/OIDC 库,接入真正的身份提供方(Keycloak / Authentik / oauth2-proxy)。
- 网络分段:后端放独立 internal network,Traefik 跨接 public + internal。
10. 可观测性:日志 #
# 应用日志(Traefik 自身,带内置轮转)
- "--log.filepath=/var/log/traefik/traefik.log"
- "--log.format=json" # common(默认) / json
- "--log.level=INFO"
- "--log.maxsize=10" # 超 10MB 切割
- "--log.maxbackups=3" # 最多保留 3 个旧文件
- "--log.maxage=7" # 旧文件最多 7 天
- "--log.compress=true" # 切割后 gzip
# 访问日志(每请求一行,★无内置轮转,需外部 logrotate + 给容器发 USR1 信号)
- "--accesslog=true"
- "--accesslog.filepath=/var/log/traefik/access.log"
- "--accesslog.format=json" # common(CLF,默认) / json / genericCLF
- "--accesslog.bufferingsize=100" # 攒够 100 行批量落盘,降磁盘 IO
访问日志(common 格式)行尾直接告诉你命中哪个 router、转发到哪个后端 URL——“实时看请求转发到哪"就靠它:
172.x - - [..] "GET /api/profile HTTP/1.1" 200 .. "user-api@docker" "http://172.x:5000" 2ms └─命中的router─┘ └─转发到的后端URL─┘
11. 命令速查 #
docker compose up -d # 启动;改了 label 需重新 up 才生效
docker compose up -d --scale app=3 # 多副本
docker compose up -d --build # 改了代码/Dockerfile 重新构建并滚动替换
docker compose ps # 状态
docker compose logs -f traefik # 看 Traefik 日志(排错神器)
docker compose down # 停止清理
docker compose down --rmi local # 顺便删本地构建的镜像
# Dashboard:http://localhost:8080/dashboard/ ★ 末尾斜杠不能少
域名访问(Windows 最容易踩坑):
# 浏览器:Chrome/Edge 自动把 *.localhost 指向 127.0.0.1,直接 http://whoami.localhost
# 命令行:用 curl.exe(curl 是 Invoke-WebRequest 别名,参数不同!)并手动带 Host 头:
curl.exe -H "Host: whoami.localhost" http://127.0.0.1
# 想让任何工具都能解析,编辑 C:\Windows\System32\drivers\etc\hosts(需管理员):
# 127.0.0.1 whoami.localhost api.localhost app.localhost
12. 排错对照表 #
| 现象 | 多半原因 |
|---|---|
| 路由 404 | Host 头不对 / label 写错 → 开 Dashboard 看 Routers 是否出现、是否绿色 |
| 看不到服务 | 没 traefik.enable=true,或没和 Traefik 在同一 Docker 网络 |
| 改了 label 不生效 | label 改了要 docker compose up -d 重建容器 |
| Dashboard 打不开 | URL 必须带末尾斜杠 /dashboard/ |
| 重定向后 404 | 目标域名没配路由(正常,重点看 301 + Location 头) |
| 一切都 503(含登录) | (08) system-gate 租约过期 → /sys/grant 恢复 |
| 业务接口 401 | (08) 未登录或 token 过期 → 重新登录 |
| 业务接口 403 | (08) 角色不足,或 edgeGuard 没收到密钥 |
forbidden: must go through Traefik |
(08) 绕过网关直连后端(缺 X-Edge-Auth) |
13. 完整可运行范例(复现用) #
以下是原 demo 文件夹的完整内容。每个范例独立可跑:建对应目录、放好文件、
docker compose up -d。 通用环境:Docker + Docker Compose;镜像traefik:v3.7.1首次自动拉取。
13.1 01-basics(Dashboard + 自动发现) #
docker-compose.yml:
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-basics
command:
- "--api.insecure=true"
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--log.filepath=/var/log/traefik/traefik.log"
- "--log.format=json"
- "--log.level=INFO"
- "--log.maxsize=10"
- "--log.maxbackups=3"
- "--log.maxage=7"
- "--log.compress=true"
- "--accesslog=true"
- "--accesslog.filepath=/var/log/traefik/access.log"
- "--accesslog.format=json"
- "--accesslog.bufferingsize=100"
ports:
- "80:80"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./logs:/var/log/traefik
whoami:
image: traefik/whoami
container_name: whoami-basics
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.localhost`)"
- "traefik.http.routers.whoami.entrypoints=web"
验证:curl.exe -H "Host: whoami.localhost" http://127.0.0.1 → 返回含 X-Forwarded-* 头。
13.2 02-routing(Host/Path/组合/优先级) #
docker-compose.yml:
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-routing
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--log.level=INFO"
ports: ["80:80", "8080:8080"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]
blog:
image: traefik/whoami
environment: ["WHOAMI_NAME=BLOG-service"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`blog.localhost`)"
- "traefik.http.routers.blog.entrypoints=web"
api:
image: traefik/whoami
environment: ["WHOAMI_NAME=API-service"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`app.localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.api.entrypoints=web"
web:
image: traefik/whoami
environment: ["WHOAMI_NAME=WEB-service"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`app.localhost`)"
- "traefik.http.routers.web.entrypoints=web"
- "traefik.http.routers.web.priority=1"
验证:app.localhost/api/users→API;app.localhost/home→WEB(兜底)。
13.3 03-middlewares(认证/限流/改头/剥前缀/链/重定向) #
docker-compose.yml:
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-mw
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--log.level=INFO"
ports: ["80:80", "8080:8080"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]
auth-demo:
image: traefik/whoami
environment: ["WHOAMI_NAME=AUTH-protected"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.authdemo.rule=Host(`auth.localhost`)"
- "traefik.http.routers.authdemo.entrypoints=web"
- "traefik.http.routers.authdemo.middlewares=myauth"
- "traefik.http.middlewares.myauth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/"
ratelimit-demo:
image: traefik/whoami
environment: ["WHOAMI_NAME=RATELIMITED"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.rldemo.rule=Host(`ratelimit.localhost`)"
- "traefik.http.routers.rldemo.entrypoints=web"
- "traefik.http.routers.rldemo.middlewares=myratelimit"
- "traefik.http.middlewares.myratelimit.ratelimit.average=5"
- "traefik.http.middlewares.myratelimit.ratelimit.burst=10"
headers-demo:
image: traefik/whoami
environment: ["WHOAMI_NAME=CUSTOM-HEADERS"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.hdemo.rule=Host(`headers.localhost`)"
- "traefik.http.routers.hdemo.entrypoints=web"
- "traefik.http.routers.hdemo.middlewares=myheaders"
- "traefik.http.middlewares.myheaders.headers.customrequestheaders.X-From-Traefik=hello"
- "traefik.http.middlewares.myheaders.headers.customresponseheaders.X-Powered-By=GoldenMatrix-Traefik"
strip-demo:
image: traefik/whoami
environment: ["WHOAMI_NAME=STRIPPED"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.sdemo.rule=Host(`strip.localhost`) && PathPrefix(`/service1`)"
- "traefik.http.routers.sdemo.entrypoints=web"
- "traefik.http.routers.sdemo.middlewares=mystrip"
- "traefik.http.middlewares.mystrip.stripprefix.prefixes=/service1"
chain-demo:
image: traefik/whoami
environment: ["WHOAMI_NAME=CHAINED"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.cdemo.rule=Host(`chain.localhost`)"
- "traefik.http.routers.cdemo.entrypoints=web"
- "traefik.http.routers.cdemo.middlewares=mychain"
- "traefik.http.middlewares.compress-mw.compress=true"
- "traefik.http.middlewares.mychain.chain.middlewares=myauth,compress-mw"
redirect-demo:
image: traefik/whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.redir.rule=Host(`old.localhost`)"
- "traefik.http.routers.redir.entrypoints=web"
- "traefik.http.routers.redir.middlewares=to-new"
- "traefik.http.middlewares.to-new.redirectregex.regex=^http://old.localhost/(.*)"
- "traefik.http.middlewares.to-new.redirectregex.replacement=http://new.localhost/$${1}"
- "traefik.http.middlewares.to-new.redirectregex.permanent=true"
13.4 04-loadbalancing(轮询 + 粘性) #
docker-compose.yml(启动:docker compose up -d --scale whoami=3 --scale sticky=3):
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-lb
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--log.level=INFO"
ports: ["80:80", "8080:8080"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]
whoami:
image: traefik/whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.lb.rule=Host(`lb.localhost`)"
- "traefik.http.routers.lb.entrypoints=web"
- "traefik.http.services.lb.loadbalancer.server.port=80"
sticky:
image: traefik/whoami
labels:
- "traefik.enable=true"
- "traefik.http.routers.sticky.rule=Host(`sticky.localhost`)"
- "traefik.http.routers.sticky.entrypoints=web"
- "traefik.http.services.sticky.loadbalancer.server.port=80"
- "traefik.http.services.sticky.loadbalancer.sticky.cookie=true"
- "traefik.http.services.sticky.loadbalancer.sticky.cookie.name=gm_sticky"
13.5 05-tls #
docker-compose.yml:
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-tls
ports: ["80:80", "443:443", "8080:8080"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./dynamic:/etc/traefik/dynamic:ro
- ./certs:/etc/traefik/certs:ro
whoami:
image: traefik/whoami
container_name: whoami-tls
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami-tls.rule=Host(`secure.localhost`)"
- "traefik.http.routers.whoami-tls.entrypoints=websecure"
- "traefik.http.routers.whoami-tls.tls=true"
traefik.yml:
api:
dashboard: true
insecure: true
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
file:
directory: /etc/traefik/dynamic
watch: true
log:
level: INFO
accessLog: {}
dynamic/tls.yml:见 第 7 节(tls.certificates + stores.default.defaultCertificate)。
generate-certs.ps1(用 Docker 里的 openssl,无需本地装):
$ErrorActionPreference = "Stop"
$certDir = Join-Path $PSScriptRoot "certs"
New-Item -ItemType Directory -Force -Path $certDir | Out-Null
docker run --rm -v "${certDir}:/certs" alpine/openssl req -x509 -nodes `
-newkey rsa:2048 -keyout /certs/local.key -out /certs/local.crt -days 365 `
-subj "/CN=secure.localhost/O=GoldenMatrix" `
-addext "subjectAltName=DNS:secure.localhost,DNS:*.localhost,DNS:localhost,IP:127.0.0.1"
generate-certs.sh(bash/WSL 等价):
#!/usr/bin/env bash
set -e
certDir="$(cd "$(dirname "$0")" && pwd)/certs"
mkdir -p "$certDir"
docker run --rm -v "${certDir}:/certs" alpine/openssl req -x509 -nodes \
-newkey rsa:2048 -keyout /certs/local.key -out /certs/local.crt -days 365 \
-subj "/CN=secure.localhost/O=GoldenMatrix" \
-addext "subjectAltName=DNS:secure.localhost,DNS:*.localhost,DNS:localhost,IP:127.0.0.1"
13.6 06-file-provider #
docker-compose.yml(注意:不挂 docker.sock):
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-file
ports: ["80:80", "8080:8080"]
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./dynamic:/etc/traefik/dynamic:ro
whoami1:
image: traefik/whoami
container_name: whoami1
environment: ["WHOAMI_NAME=backend-1"]
whoami2:
image: traefik/whoami
container_name: whoami2
environment: ["WHOAMI_NAME=backend-2"]
traefik.yml:
api:
dashboard: true
insecure: true
entryPoints:
web:
address: ":80"
providers:
file:
directory: /etc/traefik/dynamic
watch: true
log:
level: INFO
accessLog: {}
dynamic/config.yml:
http:
routers:
file-router:
rule: "Host(`file.localhost`)"
entryPoints: [web]
service: whoami-cluster
middlewares: [secure-headers]
services:
whoami-cluster:
loadBalancer:
servers:
- url: "http://whoami1:80"
- url: "http://whoami2:80"
middlewares:
secure-headers:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
customResponseHeaders:
X-Managed-By: "file-provider"
热加载体验:不重启容器,直接改
config.yml里的 Host,保存即生效。
13.7 07-go-app(真实 Go 后端) #
启动:docker compose up -d --build --scale goapp=3
docker-compose.yml:
services:
traefik:
image: traefik:v3.7.1
container_name: traefik-goapp
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--log.level=INFO"
- "--accesslog=true"
ports: ["80:80", "8080:8080"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]
goapp:
build:
context: ./app
labels:
- "traefik.enable=true"
- "traefik.http.routers.goapp.rule=Host(`app.localhost`)"
- "traefik.http.routers.goapp.entrypoints=web"
- "traefik.http.services.goapp.loadbalancer.server.port=8000"
- "traefik.http.services.goapp.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.goapp.loadbalancer.healthcheck.interval=10s"
- "traefik.http.services.goapp.loadbalancer.healthcheck.timeout=3s"
- "traefik.http.routers.goapp.middlewares=goapp-retry"
- "traefik.http.middlewares.goapp-retry.retry.attempts=3"
- "traefik.http.middlewares.goapp-retry.retry.initialinterval=100ms"
app/go.mod:module goldenmatrix/traefik-demo + go 1.22
app/main.go:
// 端点:GET / (主机名)、GET /api/info (回显请求头)、GET /healthz、GET /slow (慢2秒)
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
func main() {
hostname, _ := os.Hostname()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "👋 Hello from GoldenMatrix demo app\n")
fmt.Fprintf(w, "Hostname: %s\n", hostname)
fmt.Fprintf(w, "Path: %s\n", r.URL.Path)
fmt.Fprintf(w, "Client: %s\n", r.RemoteAddr)
})
mux.HandleFunc("/api/info", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"app": "goldenmatrix-demo", "hostname": hostname,
"method": r.Method, "path": r.URL.Path, "headers": r.Header,
})
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintf(w, "slow response from %s after 2s\n", hostname)
})
addr := ":8000"
log.Printf("goldenmatrix-demo listening on %s (hostname=%s)", addr, hostname)
log.Fatal(http.ListenAndServe(addr, withLogging(mux)))
}
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s from=%s took=%s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
})
}
app/Dockerfile(多阶段构建,通用模板):
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/server .
FROM alpine:3.20
RUN adduser -D -u 10001 appuser
COPY --from=build /out/server /server
USER appuser
EXPOSE 8000
ENTRYPOINT ["/server"]
13.8 08-forwardauth #
5 个服务:traefik / frontend(nginx) / syscheck / authsvc / usersvc。架构与原理见 第 9 节。启动:
docker compose up -d --build,浏览器开 http://localhost/。
docker-compose.yml:
services:
traefik:
image: traefik:v3.7.1
container_name: fa-traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.internal.address=:8081"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "8080:8080"
# :8081 internal 故意不发布 → 宿主机访问不到,只有容器网络内可达
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [edge]
syscheck:
build: ./syscheck
container_name: fa-syscheck
environment:
- RENEW_INTERVAL_SEC=3
- LEASE_TTL_SEC=10
networks: [edge]
labels:
- "traefik.enable=true"
- "traefik.http.services.syscheck.loadbalancer.server.port=7100"
- "traefik.http.services.syscheck.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.routers.sys-control.rule=PathPrefix(`/sys`)"
- "traefik.http.routers.sys-control.entrypoints=web"
- "traefik.http.routers.sys-control.service=syscheck"
- "traefik.http.middlewares.system-gate.forwardauth.address=http://syscheck:7100/gate/check"
authsvc:
build: ./authsvc
container_name: fa-authsvc
environment:
- TOKEN_SECRET=demo-token-signing-key-rotate-in-prod
- USERSVC_URL=http://traefik:8081
- USER_TOKEN_TTL_SEC=20
networks: [edge]
labels:
- "traefik.enable=true"
- "traefik.http.services.authsvc.loadbalancer.server.port=4000"
- "traefik.http.services.authsvc.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.routers.login.rule=PathPrefix(`/auth/login`)"
- "traefik.http.routers.login.entrypoints=web"
- "traefik.http.routers.login.service=authsvc"
- "traefik.http.routers.login.middlewares=system-gate"
- "traefik.http.routers.renew.rule=PathPrefix(`/auth/renew`)"
- "traefik.http.routers.renew.entrypoints=web"
- "traefik.http.routers.renew.service=authsvc"
- "traefik.http.routers.renew.middlewares=system-gate"
usersvc:
build: ./usersvc
container_name: fa-usersvc
environment:
- EDGE_SECRET=ONLY-TRAEFIK-KNOWS-THIS-EDGE-SECRET
networks: [edge]
labels:
- "traefik.enable=true"
- "traefik.http.services.usersvc.loadbalancer.server.port=5000"
- "traefik.http.services.usersvc.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.routers.user-api.rule=PathPrefix(`/api`)"
- "traefik.http.routers.user-api.entrypoints=web"
- "traefik.http.routers.user-api.service=usersvc"
- "traefik.http.routers.user-api.middlewares=system-gate,strip-identity,forward-auth,edge-secret"
- "traefik.http.routers.user-internal.rule=PathPrefix(`/internal`)"
- "traefik.http.routers.user-internal.entrypoints=internal"
- "traefik.http.routers.user-internal.service=usersvc"
- "traefik.http.routers.user-internal.middlewares=edge-secret"
- "traefik.http.middlewares.forward-auth.forwardauth.address=http://authsvc:4000/auth/verify"
- "traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-User-Id,X-User-Name,X-User-Roles"
- "traefik.http.middlewares.edge-secret.headers.customrequestheaders.X-Edge-Auth=ONLY-TRAEFIK-KNOWS-THIS-EDGE-SECRET"
- "traefik.http.middlewares.strip-identity.headers.customrequestheaders.X-User-Id="
- "traefik.http.middlewares.strip-identity.headers.customrequestheaders.X-User-Name="
- "traefik.http.middlewares.strip-identity.headers.customrequestheaders.X-User-Roles="
frontend:
image: nginx:alpine
container_name: fa-frontend
volumes:
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
networks: [edge]
labels:
- "traefik.enable=true"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
- "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
- "traefik.http.routers.frontend.entrypoints=web"
- "traefik.http.routers.frontend.priority=1"
- "traefik.http.routers.frontend.service=frontend"
networks:
edge:
driver: bridge
syscheck/main.go(系统鉴权·租约续期,零依赖):
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
)
func envInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
var (
renewInterval = time.Duration(envInt("RENEW_INTERVAL_SEC", 3)) * time.Second
leaseTTL = time.Duration(envInt("LEASE_TTL_SEC", 10)) * time.Second
)
type state struct {
mu sync.Mutex
entitled bool // 系统许可是否有效(可被 revoke/grant 切换)
leaseUntil time.Time // 租约到期时刻;只有续期成功才往后延
lastRenew time.Time
lastOK bool
}
var st = &state{entitled: true}
func main() {
go renewLoop()
mux := http.NewServeMux()
mux.HandleFunc("/gate/check", handleGateCheck) // forwardauth 目标,廉价、每请求
mux.HandleFunc("/sys/state", handleState)
mux.HandleFunc("/sys/revoke", handleRevoke)
mux.HandleFunc("/sys/grant", handleGrant)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
log.Printf("syscheck listening on :7100 (续期间隔=%v, 租约时长=%v)", renewInterval, leaseTTL)
log.Fatal(http.ListenAndServe(":7100", mux))
}
func renewLoop() {
renewOnce()
t := time.NewTicker(renewInterval)
defer t.Stop()
for range t.C {
renewOnce()
}
}
func renewOnce() {
st.mu.Lock()
defer st.mu.Unlock()
st.lastRenew = time.Now()
// 真实环境:这里调 license SDK / 远程鉴权核验。demo 用本地 entitled 标志模拟。
if st.entitled {
st.leaseUntil = time.Now().Add(leaseTTL)
st.lastOK = true
} else {
st.lastOK = false
}
}
func handleGateCheck(w http.ResponseWriter, r *http.Request) {
st.mu.Lock()
valid := time.Now().Before(st.leaseUntil)
st.mu.Unlock()
if !valid {
http.Error(w, "system authorization lease expired", http.StatusServiceUnavailable) // 503 拦一切
return
}
w.WriteHeader(http.StatusOK) // 2xx → Traefik 放行
}
func handleState(w http.ResponseWriter, r *http.Request) {
st.mu.Lock()
defer st.mu.Unlock()
secondsLeft := int(time.Until(st.leaseUntil).Seconds())
if secondsLeft < 0 {
secondsLeft = 0
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"entitled": st.entitled, "leaseValid": time.Now().Before(st.leaseUntil),
"secondsLeft": secondsLeft, "lastRenew": st.lastRenew.Format("15:04:05"),
"lastRenewOK": st.lastOK, "renewEverySec": int(renewInterval.Seconds()),
"leaseTtlSec": int(leaseTTL.Seconds()),
})
}
func handleRevoke(w http.ResponseWriter, r *http.Request) {
st.mu.Lock()
st.entitled = false
st.mu.Unlock()
w.Write([]byte("revoked"))
}
func handleGrant(w http.ResponseWriter, r *http.Request) {
st.mu.Lock()
st.entitled = true
st.mu.Unlock()
w.Write([]byte("granted"))
}
authsvc/main.go(登录鉴权·HMAC token,零依赖):
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func envInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
var (
tokenSecret = env("TOKEN_SECRET", "dev-token-secret")
userSvcURL = env("USERSVC_URL", "http://traefik:8081") // 经 Traefik 内部入口回调
userTokenTTL = time.Duration(envInt("USER_TOKEN_TTL_SEC", 20)) * time.Second
)
type claims struct {
UID string `json:"uid"`
Name string `json:"name"`
Roles []string `json:"roles"`
Exp int64 `json:"exp"`
}
func sign(c claims) string {
payload, _ := json.Marshal(c)
b := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, []byte(tokenSecret))
mac.Write([]byte(b))
return b + "." + hex.EncodeToString(mac.Sum(nil))
}
func verify(tok string) (*claims, bool) {
parts := strings.SplitN(tok, ".", 2)
if len(parts) != 2 {
return nil, false
}
mac := hmac.New(sha256.New, []byte(tokenSecret))
mac.Write([]byte(parts[0]))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[1])) { // 恒定时间比较
return nil, false
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, false
}
var c claims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, false
}
if time.Now().Unix() > c.Exp {
return nil, false
}
return &c, true
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/auth/login", handleLogin)
mux.HandleFunc("/auth/verify", handleVerify)
mux.HandleFunc("/auth/renew", handleRenew)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
log.Println("authsvc listening on :4000")
log.Fatal(http.ListenAndServe(":4000", mux))
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// 打到 Traefik(:8081) 而非直连 usersvc → edge secret 由 Traefik 注入
body, _ := json.Marshal(req)
resp, err := http.Post(userSvcURL+"/internal/login-check", "application/json", bytes.NewReader(body))
if err != nil {
http.Error(w, "auth backend error", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
var u struct {
ID string `json:"id"`
Name string `json:"name"`
Roles []string `json:"roles"`
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
http.Error(w, "decode error", http.StatusBadGateway)
return
}
tok := sign(claims{UID: u.ID, Name: u.Name, Roles: u.Roles, Exp: time.Now().Add(userTokenTTL).Unix()})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok})
}
func handleVerify(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
c, ok := verify(tok)
if !ok {
w.WriteHeader(http.StatusUnauthorized) // 非2xx → Traefik 拒绝原始请求
io.WriteString(w, "invalid or expired token")
return
}
w.Header().Set("X-User-Id", c.UID)
w.Header().Set("X-User-Name", c.Name)
w.Header().Set("X-User-Roles", strings.Join(c.Roles, ","))
w.WriteHeader(http.StatusOK)
}
func handleRenew(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
c, ok := verify(tok)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, "token expired, please login again")
return
}
nt := sign(claims{UID: c.UID, Name: c.Name, Roles: c.Roles, Exp: time.Now().Add(userTokenTTL).Unix()})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": nt})
}
usersvc/main.go(业务后端 + edgeGuard,零依赖):
package main
import (
"crypto/subtle"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
var edgeSecret = env("EDGE_SECRET", "dev-edge-secret")
type user struct {
Pass string `json:"-"` // 不序列化,避免泄露密码
ID string `json:"id"`
Name string `json:"name"`
Roles []string `json:"roles"`
}
var users = map[string]user{
"alice": {Pass: "alice123", ID: "1001", Name: "Alice", Roles: []string{"user", "admin"}},
"bob": {Pass: "bob123", ID: "1002", Name: "Bob", Roles: []string{"user"}},
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
mux.HandleFunc("/internal/login-check", handleLoginCheck)
mux.HandleFunc("/api/profile", handleProfile)
mux.HandleFunc("/api/admin/users", handleAdminUsers)
log.Println("usersvc listening on :5000")
log.Fatal(http.ListenAndServe(":5000", edgeGuard(mux)))
}
// edgeGuard:没有正确的 X-Edge-Auth 一律 403(/healthz 例外,健康检查不经中间件)
func edgeGuard(next http.Handler) http.Handler {
want := []byte(edgeSecret)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
got := []byte(r.Header.Get("X-Edge-Auth"))
if subtle.ConstantTimeCompare(got, want) != 1 {
http.Error(w, "forbidden: requests must go through Traefik", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
u, ok := users[req.Username]
if !ok || subtle.ConstantTimeCompare([]byte(u.Pass), []byte(req.Password)) != 1 {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
}
func handleProfile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": r.Header.Get("X-User-Id"), "name": r.Header.Get("X-User-Name"),
"roles": r.Header.Get("X-User-Roles"),
"msg": "受保护档案:身份来自 Traefik(forwardauth) 注入",
})
}
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if !hasRole(r.Header.Get("X-User-Roles"), "admin") {
http.Error(w, "forbidden: requires admin role", http.StatusForbidden) // 应用层授权
return
}
list := make([]user, 0, len(users))
for _, u := range users {
list = append(list, u)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(list)
}
func hasRole(csv, role string) bool {
for _, x := range strings.Split(csv, ",") {
if strings.TrimSpace(x) == role {
return true
}
}
return false
}
三个服务的 go.mod 都是零依赖:module fa/syscheck(或 authsvc/usersvc)+ go 1.22。
三个 Dockerfile 同 13.7 模板,只改 -o /out/<名>、EXPOSE <端口>、ENTRYPOINT。
frontend/index.html:一个纯静态体验台(dark theme),JS 逻辑——
- 每 1.5s 轮询
/sys/state渲染系统鉴权状态(🟢授权/🟡吊销倒计时/🔴失效); - 登录调
/auth/login,每 8s 自动/auth/renew滑动续期,可点按钮停止续期模拟用户离开; - 业务按钮调
/api/profile、/api/admin/users,并能伪造X-User-Id:9999验证无效; - 右下「请求日志」面板显示每次调用状态码 + body,并标注 503=system-gate / 401=forward-auth / 403=应用层授权。
该页面是演示外壳,非 Traefik 功能本身;核心逻辑等价于 9.8 命令行验证。需要原样恢复时按上述行为用任意前端框架重建即可。