STUNner 是一个原生 Kubernetes TURN 服务器,TURN 是一种中继协议,可为 WebRTC 客户端提供用于传输媒体的公共端点。它基于 Gateway API,将 TURN 请求终止于单个负载均衡器端点,并将媒体数据中继到其后方的 Pod,因此媒体服务器 Pod 仍位于普通的 ClusterIP 服务中,仅限集群内部访问,且不拥有自己的公共 IP 地址。本文中的示例使用的是 Elastic Kubernetes Service(Amazon EKS),但资源模型在各云平台中是通用的,因此大部分内容可直接套用。
该模式适用于任何在 Kubernetes 上私有运行的 WebRTC 媒体服务器。我们最近将其用于将 Media Resource Broker 后方的 Janus 实例池对外暴露,其中每个房间都被固定到一个特定实例上,而不是通过负载均衡自由分配。这一区别决定了理解 STUNner 路由模型的方式。
本文将探讨在生产环境中运行 STUNner,包括使用临时凭据、多租户网关配置,以及在不影响服务的情况下轮换共享密钥。
从架构到配置:STUNner 的网关 API 资源模型
中继器,而非负载均衡器
在编写任何配置之前,需要理解一个关键概念:STUNner 并不对媒体流量进行负载均衡。它的作用是将媒体流量中继到已配置的后端 Pod,即媒体服务器。
STUNner 由两个主要组件构成:控制平面和数据平面。
- 控制平面即 STUNner 操作员。它监听 STUNner 自定义资源,并在配置有效时创建运行时组件——中继 Pod 以及用于暴露这些 Pod 的负载均衡器。
- 数据平面由 stunnerd Pod 组成。这些组件负责处理 TURN 协议(包括中继分配、权限管理及媒体转发)。浏览器将 TURN 流量发送到一个稳定的负载均衡器 IP 地址。stunnerd 会验证客户端的凭据,接受中继连接,并将媒体数据转发至目标 Pod。
当媒体数据到达 STUNner 时,客户端已经知道要连接哪个 Pod。STUNner 只需验证 TURN 请求,并将媒体数据中继到该目的地即可。它不会选择后端、重试其他 Pod,也不会将流量分发到各个副本上。
这一区别至关重要。STUNner 为客户提供一个稳定的公共中继端点,而媒体堆栈(无论是媒体服务器本身,还是 Jitsi 的 Jicofo 这样的配套服务)则仍负责决定哪个 Pod 拥有该会话。
下图展示了主要组件以及媒体流向媒体服务器 pod 的过程。

底部的那条实线显示了媒体路径。流量始于浏览器,通过云负载均衡器到达 STUNner,穿过 stunnerd pod,然后以 UDP 形式在集群内部继续传输,最终到达选定的媒体 pod。
虚线显示了由 STUNner 运维人员执行的控制平面操作。在协调过程中,运维人员会创建 stunnerd 数据平面、创建负载均衡服务,并连接资源引用,以便 stunnerd 知道应使用哪个密钥进行凭证验证。
一旦控制平面引用正确,运维人员就会为您构建并连接运行时组件。配置工作主要就是确保这些引用的准确性。
看似一体的两个 API 组
STUNner 由四个 Kubernetes 资源构成:GatewayClass、Gateway、GatewayConfig 和 UDPRoute。它们彼此相邻,看起来像是一个相互关联的模型,但需要注意一个重要细节:它们来自两个不同的 API 组。GatewayClass 和 Gateway 源自上游的 Gateway API(一个用于管理 Ingress 和路由的 Kubernetes 标准扩展),而 GatewayConfig 和 UDPRoute 则是 STUNner 专有的自定义资源。Kubernetes 将其视为拥有不同所有者且遵循不同规则的独立资源类型。
# Upstream Gateway API — gateway.networking.k8s.io/v1
kind: GatewayClass # cluster-scoped; names the controller
kind: Gateway # per media server; TURN listeners + static address
# STUNner's own group — stunner.l7mp.io/v1
kind: GatewayConfig # auth mode, realm, load balancer annotations
kind: UDPRoute # allow-list of backend Services
一旦您编写基于角色的访问控制(RBAC)规则、准入策略或审计查询,这一点就变得至关重要。对一个 API 组的访问权限不会自动涵盖另一个 API 组。如果您授予了 Gateway 资源的权限,却忘记了 STUNner 资源,那么该故障看起来就像一个普通的权限错误,直到您注意到错误消息中的 API 组与您配置的 API 组不同。
UDPRoute 是一个白名单,而非路由池
上文所述的“中继而非负载均衡”行为不仅仅是一种思维模型。它被编码在定义媒体流向的唯一资源中:即 UDPRoute 后端引用。
一个重要的区别在于:STUNner 的 UDPRoute 属于 stunner.l7mp.io/v1 组,而非上游网关 gateway.networking.k8s.io。它们虽然名称相同,但属于不同的资源。
backendRefs:
- name: media-server
namespace: media # no port: the client's peer address decides it
UDPRoute 后端引用看起来像一个普通的 Service 后端,因此人们很容易将其误认为是一个负载均衡池。但事实并非如此。它是一个白名单:它告诉 STUNner 允许中继到的后端 Pod IP 地址。请注意,这里没有后端端口字段——stunnerd 不会转发到固定的端口。它会将数据转发至客户端在 TURN Allocate 请求中提供的对等方地址,并将该请求的对等方地址与列表中 Services 对应的 Pod IP 进行比对。若匹配则转发;若不在列表中则丢弃。
关于白名单的一个细节:如果 backendRefs 块中列出了多个后端 Service,它们将构成一个单一的允许集(即其 Pod IP 的并集),而非轮询池。
资源归属:集群与媒体服务器资源
了解现有资源后,接下来要考虑的问题是:由谁来应用这些资源,以及何时应用?
STUNner 的配置分为两个所有权层级:集群基础设施层和单个媒体服务器层。
集群基础设施层由 GatewayClass 和 GatewayConfig 组成。简而言之,集群中各有一个,均创建在 STUNner 系统命名空间中。这些配置在初始化过程中应用一次,此后极少更改。这里存放着共享的 STUNner 设置,包括身份验证模式、共享密钥的引用,以及运维人员应用于其创建的每个服务的负载均衡器注解。
# platform tier — created once, in the STUNner system namespace
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: stunner-gatewayclass
spec:
controllerName: stunner.l7mp.io/gateway-operator
parametersRef: # points at the GatewayConfig below
kind: GatewayConfig
name: stunner-config
namespace: stunner
---
apiVersion: stunner.l7mp.io/v1
kind: GatewayConfig
metadata:
name: stunner-config
namespace: stunner
spec:
authRef: # points at the shared-secret Secret
name: stunner-auth-secret
namespace: stunner
loadBalancerServiceAnnotations: {} # cloud LB annotations, applied to every Service
每个媒体服务器层都包含网关、UDP路由和认证配置。网关声明TURN监听器和STUNner应使用的静态地址。UDP路由连接到该网关并指向媒体服务器的后端服务。
# per-media-server tier — in the media server's namespace
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: media-server-gateway
namespace: media
spec:
gatewayClassName: stunner-gatewayclass
addresses:
- type: IPAddress
value: <static-LB-IP> # optional; recommended for production
listeners:
- name: turn-udp
port: 3478
protocol: TURN-UDP
---
apiVersion: stunner.l7mp.io/v1
kind: UDPRoute
metadata:
name: media-server-route
namespace: media
spec:
parentRefs:
- name: media-server-gateway
rules:
- backendRefs:
- name: media-server # the Service fronting your media pods
网关配置中有一个字段值得注意:静态负载均衡器 IP 地址。虽然它是可选的,但建议在云环境中的生产环境中启用,因为它可以确保指向 STUNner 的 DNS 记录在 Service 重建过程中保持稳定。集群不会为您预留此 IP 地址;您需要通过云提供商(例如 AWS 上的弹性 IP)预留该 IP 地址,并将其传递给网关,然后由运维人员将其转换为负载均衡器注解,从而将 Service 绑定到该 IP 地址(有关 EKS 示例,请参阅负载均衡器部分)。
对于开发或临时环境,您可以让负载均衡器使用其默认 IP 地址或 DNS 名称。但请记住,如果网关服务被重新创建,该地址可能会更改,因此必须更新 DNS。
最后,所有权边界同时也是命名空间边界。共享的基础架构位于 STUNner 系统命名空间中,而您的媒体服务器资源则位于其自身的命名空间中。
信任流量:生产环境中的身份验证与多租户机制
STUNner 临时凭证:客户端如何证明其身份
在任何媒体流经共享基础设施之前,STUNner 必须先信任传入的流量。TURN 会话的身份验证通过以下两种模式实现:
- 静态凭证:每个客户端使用一组固定的用户名和密码。这在开发/测试阶段很有用,但在生产环境中并不安全,因为泄露的凭证没有自然过期时间。
- 临时凭证:每个客户端都会获得一组由共享密钥生成的短效用户名和密码。这是生产环境中的默认模式,遵循 TURN REST API 的凭证方案。这是一种被广泛支持的模式,凭证具有时效性,并由共享密钥派生而来。
在临时凭证模式下,双方使用相同的共享密钥。媒体服务器的信令层(负责协调客户端之间会话建立的组件)使用该密钥生成凭证,并将该凭证发送至浏览器。stunnerd 使用相同的密钥对其进行验证。
凭证的格式如下:
username = <expiry-timestamp>:<user-id>
password = base64( HMAC-SHA1( shared-secret, username ) )
用户名包含过期时间戳。密码是使用该共享密钥对该用户名进行 HMAC 校验的结果。当浏览器连接时,stunnerd 会重新计算相同的 HMAC,并且仅当密码匹配且过期时间尚未到期时才接受凭据。
凭证的生成方式取决于媒体服务器。Jitsi可能使用Prosody,LiveKit可能通过其房间令牌流程生成凭证,而Janus可能使用应用服务器。生成方法各不相同,但合约本身不变:
media server: mints credential from shared key
stunnerd: validates credential against the same key
大多数身份验证失败都源于一个简单的问题:共享密钥的两个副本不匹配。即使它们相差一个字节,stunnerd 也会拒绝凭据,TURN 请求会因未授权而失败,并且调用可能会挂起,而不会出现明显的“密钥错误”提示。调试此问题时,需要先直接比较这两个值,然后再追踪下游症状。
在生产环境中轮换 TURN 共享密钥
该共享密钥是关键所在,因此其存储位置和轮换方式至关重要。
通常,它以 Kubernetes 密钥的形式存储。默认情况下,除非集群已配置静态数据加密,否则该值会采用 Base64 编码(这是一种传输编码,而非加密)。任何能够读取 STUNner 系统命名空间中密钥的工作负载或用户,均可读取此密钥。
apiVersion: v1
kind: Secret
metadata:
name: stunner-auth-secret
namespace: stunner-system
type: Opaque
stringData:
type: ephemeral
secret: <shared-HMAC-key> # identical to the media server's copy
将该命名空间视为敏感基础设施。限制其他工作负载、广泛集群读取者和开发人员凭据的访问。如果您的威胁模型包含对 etcd 备份或集群后端存储的访问,请启用 Kubernetes Secret 静态加密。
外部密钥管理器(例如 Vault)可以进一步加强安全性,但并非强制性的。您可以将密钥值整合到 Kubernetes Secret 中,或者在 Pod 准入时注入。这样做可以移除或减少集群中长期存在的密钥副本,但会增加运维依赖。对于许多部署而言,使用具有严格 RBAC 和静态加密的普通 Kubernetes Secret 仍然是一个有效的选择。
轮换是最需要注意的部分,因为双方必须同步更新。如果 STUNner 和媒体服务器使用不同的密钥,则每次创建新的 TURN 凭证都会立即失效。
安全的旋转顺序如下:
- 替换 STUNner 端的共享密钥。
- 重启 stunnerd 数据平面,使其加载新值。
- 更新媒体服务器或凭证生成服务。
- 将铸币面翻转,以便新凭证使用新密钥。
现有的 TURN 分配可以继续使用,直到其嵌入的有效期到期,因此切换过程是渐进的。新的凭证必须使用新的密钥。
重启步骤至关重要。stunnerd 会将 TURN 分配状态(活动中继会话)保存在内存中,因此数据平面轮换会导致使用这些 Pod 的活动调用中断。客户端需要重新连接并再次分配资源,但用户可能会遇到短暂的服务中断。任何导致数据平面轮换的更改,包括镜像更新和配置编辑,都会出现同样的问题,因此请将此类轮换和升级安排在流量较低的时段。
同时重启正确的 Pod。Stunnerd 数据平面不在 Operator 的系统命名空间中运行。Operator 会在媒体服务器的命名空间中为每个网关创建一个数据平面 Deployment。使用 Operator 设置的标签,在该 Deployment 中重启 Stunnerd Pod。
多租户 STUNner:每个媒体服务器一个网关
以上内容描述的是单个媒体服务器的配置:一个网关和一个 UDP 路由,它可以连接同一媒体服务器的多个副本(例如,一个 Service 后面可以连接多个 Janus 实例)。当需要运行独立的媒体部署时,例如在不同的环境中运行 Jitsi 集群和 LiveKit 集群,或者运行相同的平台,重复上述模式:每个部署在其自身的命名空间中拥有自己的网关和 UDP 路由,共享相同的集群级 GatewayClass 和 GatewayConfig。

这样可以将凭据、指标、重启和故障限制在单个媒体服务器实例范围内。但缺点是成本:每个实例每个环境都需要一个负载均衡器。您可以通过共享一个网关并配置多个指向不同后端的路由来降低成本,但这也会共享身份验证凭据并增加影响范围,因此建议仅在开发或测试环境中使用。如果您运行多个租户,请为每个租户分配一个租户范围的 GatewayClass,而不是让它们都默认使用同一个 GatewayClass。
连接外部世界
负载均衡层(EKS 示例)
到目前为止所涉及的内容大多与云平台无关。主要差异体现在负载均衡层,即集群与公共互联网的连接点。
在 EKS 上,每个 STUNner 网关都是通过一个由 AWS 网络负载均衡器(NLB)支持的 Kubernetes 服务对外暴露的。AWS 负载均衡器控制器负责配置 NLB、绑定预留的弹性 IP,并通过 DNS 将用户引导至这些地址。
STUNner 操作员会创建该服务,并应用来自平台自有 GatewayConfig 中的负载均衡器注解。大多数注解都是通用的;以下是一些重要的注解:
kind: Service # operator-stamped, type LoadBalancer
spec:
externalTrafficPolicy: Local # preserve the real client IP
loadBalancerClass: service.k8s.aws/nlb
metadata:
annotations:
...aws-load-balancer-eip-allocations: "eipalloc-<azA>,eipalloc-<azB>"
...aws-load-balancer-cross-zone-load-balancing-enabled: "true"
...aws-load-balancer-enable-tcp-udp-listener: "true" # both protocols, one listener
最重要的配置是 externalTrafficPolicy: Local,该设置可保留真实的客户端 IP 地址。STUNner 需要该源地址来处理 STUN 绑定响应(客户端用于发现其对外公开地址的机制);如果负载均衡器重写了该地址,连接故障可能会表现得隐蔽且难以排查。
其余注解用于处理 AWS 特有的基础配置:将流量直接路由到 Pod IP 地址,并将负载均衡器分布在各个可用区中。
使用弹性 IP 获取稳定的公共 IP
在 EKS 上启用公共访问时,请预留所需的弹性 IP(EIP),并将它们的分配 ID 传递给负载均衡器 Service。当部署跨多个可用区(AZ)时,请为负载均衡器使用的每个可用区预留一个弹性 IP。
关键要求是弹性 IP 的数量必须与负载均衡器子网的数量相匹配。
弹性 IP 属于区域范围,而非与单个子网绑定。启用跨可用区负载均衡后,哪个弹性 IP 与哪个可用区相关联并不重要。
如果服务始终处于“待处理”状态,请首先验证弹性 IP 的数量是否与配置的子网数量一致。然后确认弹性 IP 分配 ID 和子网 ID 均有效。
同一端口上的两种协议
在受限网络(例如企业 VPN、酒店)上,TURN 客户端无法使用 UDP,因此需要 TCP 作为备用方案。如果负载均衡器能够在同一端口上同时支持这两种协议,则所有客户端都可以使用同一个端点。否则,则需要两个独立的端点。这是该层中与云环境相关的关键决策。
STUNner 网关通常会在 3478 端口上通过 UDP 和 TCP 提供 TURN 功能。您还可以添加通过 TLS 提供的 TURN 功能,通常使用 5349 端口,或者对于只允许类似 TLS 流量的限制性网络,有时会使用 443 端口。
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
...
spec:
gatewayClassName: stunner-gatewayclass
listeners:
- name: turn-udp
port: 3478
protocol: TURN-UDP
allowedRoutes:
namespaces:
from: All
- name: turn-tcp
port: 3478
protocol: TURN-TCP
allowedRoutes:
namespaces:
from: All
- name: turn-tls
port: 443 # or 5349
protocol: TURN-TLS
tls:
mode: Terminate
certificateRefs:
- kind: Secret
namespace: stunner
name: tls-secret
...
UDP 是实时媒体传输的常用路径。TCP 为受限网络(例如企业 VPN、酒店或屏蔽 UDP 流量的网络)提供备用方案。TURN over TLS 是可选的,但在加密流量更容易通过的严格环境中非常有用。
STUNner 本身并不能自动将两种协议都放在同一个端口上。TCP 和 UDP 在同一端口上的支持需要两个注解协同工作。
其中一个注解添加到 STUNner 网关(如下所示)。它告诉 STUNner 操作符公开两个相同端口的监听器,而不是将它们合并。
metadata:
annotations:
stunner.l7mp.io/enable-mixed-protocol-lb: "true"
另一部分配置则位于负载均衡器服务上,通常是通过平台自有的 GatewayConfig,由运维人员将其添加到服务中。在 EKS 中,当服务在同一端口上同时定义了 TCP 和 UDP 时,AWS 负载均衡器控制器会读取此配置以创建一个组合的 TCP/UDP 监听器。
service.beta.kubernetes.io/aws-load-balancer-enable-tcp-udp-listener: "true"
如果网关端注解缺失,STUNner 只会保留端口 3478 上的第一个监听器,并丢弃另一个监听器。在通常的配置中,UDP 协议排在第一位,因此 TCP 协议会被忽略。
如果缺少云端注解,负载均衡器无法将 TCP 和 UDP 放在同一个端口上。
无论哪种方式,结果都一样:UDP 可以正常工作,但仅支持 TCP 的网络会失败,因为 3478 端口上没有 TCP 监听器(反之亦然,如果 TCP 监听器先出现)。当 STUNner 在开放网络上工作但在受限网络上失败时,请检查这些注释。
不同云服务提供商之间有哪些变化
在不同的云服务提供商之间,主要区别在于负载均衡器是否可以在单个同一端口的监听器上承载这两种协议。
在现代 AWS 和 Azure 上,这是可以实现的。一个带有混合协议注解的网关可以为两种传输方式提供一个服务、一个负载均衡器、一个公网 IP 地址、一个 DNS 记录和一个主机名。
在无法实现的情况下(例如较旧的 AWS 负载均衡控制器和 GCP),为每个协议部署一个网关。这将创建两个服务、两个负载均衡器和两个公有 IP 地址。每个协议都有自己的 DNS 记录,一个 UDP 主机记录和一个 TCP 主机记录,这两个记录都会传递给客户端的 iceServers 配置(浏览器在连接建立期间尝试的 TURN 服务器列表)。
集群内部一切照旧。STUNner 的架构、认证流程和媒体中继路径均保持不变。
有两条规则适用于所有情况。首先,必须在创建服务之前选择负载均衡器类,因为创建后就无法更改。其次,不要通过 HTTP 或 CDN 代理代理 TURN DNS 记录——TURN 流量并非 HTTP 流量,任何检查或缓冲 TURN 流量的代理都会破坏中继。
当系统出现故障时
当 STUNner 出现故障时,请从资源链入手排查,而不是凭空猜测。请从上到下依次检查:GatewayClass、GatewayConfig、Secret、Gateway、UDPRoute 以及生成的 Service。出现故障的层级通常指向真正的问题所在。
常见问题:
- 网关始终无法获取地址:运维人员尚未完成同步,或者负载均衡控制器无法申领静态 IP。请检查控制器日志,并确认 IP 是否已被预留。
- UDPRoute 未被接受:后端服务缺失或位于错误的命名空间中。
- 中继请求未获授权:媒体服务器使用的共享密钥与 stunnerd 使用的密钥不匹配。请直接对比这两个值。
- UDP 正常工作,但受限网络连接失败:混合协议注解对中缺失了一方,因此无法通过 3478 端口的 TCP 进行连接。
- 配置更改或镜像更新后通话中断:stunnerd 数据平面发生重启,导致其内存中的 TURN 分配状态丢失。
主要原则很简单:按照资源之间相互依赖的顺序进行故障排查。引用链也是诊断路径。
STUNner 在 Kubernetes 生产环境中的应用:核心理念
一旦各个组件分离,整个设置过程就非常简单。共享的 GatewayClass 和 GatewayConfig 只需应用一次,即可将 STUNner 指向共享的 Secret。然后,媒体服务器在其自身的命名空间中添加自己的 Gateway 和 UDPRoute。
接下来,运维人员创建运行时组件:一个 stunnerd Deployment 和一个用于网关的负载均衡器 Service。网关通过 3478 端口暴露 TURN 服务,UDPRoute 定义了允许的后端 Pod IP 地址。
在运行时,媒体服务器会向浏览器提供一个由共享密钥生成的短期 TURN 凭证。stunnerd 会验证该凭证,接受稳定公网 IP 上的中继,并将媒体转发到信令平面先前选择的确切 pod。
这就是核心思想:一个公共媒体入口点,其后是普通的 Kubernetes pod,以及一小组配置细节,这些细节决定了该设置是仅适用于演示还是已准备好用于生产。
作者:Brian Collins
原文:https://webrtc.ventures/2026/08/stunner-production-kubernetes/
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/71107.html