把 FreeSWITCH 跑进 Kubernetes,这件事我一开始是拒绝的。SIP 是典型的长连接有状态协议,RTP 是实时性极强的媒体流,而 Kubernetes 的调度、网络、探针机制几乎每一项都在跟这类应用"拧着来"。最早接这个需求时,团队里一半人觉得直接上裸机最省事,另一半人拿 Kubernetes 上部署 nginx 那套思维照葫芦画瓢,结果端口冲突、SIP 注册抖动、录音文件丢失等一堆问题轮着炸。这篇文章把我后续在生产环境里稳定承载 FreeSWITCH 的完整经验整理出来,从镜像构建、网络模型、StatefulSet 设计、探针配置到扩缩容和排障心法,适合准备把电话系统容器化,或者已经在容器里跑 FreeSWITCH 但被各种报错折磨的读者。
1. 为什么把 FreeSWITCH 搬进 K8s:弹性、发布与资源困境
1.1 呼叫业务的弹性扩容需求
FreeSWITCH 做软交换或者 IVR 时,流量峰值往往集中在特定时段,比如工作日的早高峰、营销活动的拨打量暴涨。裸机部署下,硬件是按峰值采购的,日常大部分时间资源白白闲置;用虚拟机部署虽然能扩容,但新增一台虚拟机从申请到系统初始化到安装依赖,怎么都要小时级别,根本扛不住突发流量。
Kubernetes 在这里最大的价值,是让 FS 实例的扩容从"小时级"变成"分钟级"。我在生产里维护过一个 50 并发规模的 FS 集群,高峰期呼叫量能到 300 并发,直接kubectl scale statefulset freeswitch --replicas=6就能把媒体处理能力拉起来,等流量退了再缩回去。FS 本身是 CPU 密集型应用,尤其转码场景下压缩算法的计算开销很高,容器化之后资源用量可以被 Kubernetes 精确统计,扩缩容时的资源账单也清晰很多。
但这里要有一个清醒的认知:FreeSWITCH 不是无状态 Web 服务,扩容并不是简单地"多加几个 Pod 就完事"。呼叫在进行中的时候,SIP dialog 状态、媒体协商的结果、录音句柄都活在进程内存里,Pod 被杀掉意味着这些通话直接断掉。所以我们后面重点讲 StatefulSet、亲和性调度和优雅退出,这些才是 FS 上 K8s 的关键。
1.2 版本发布与配置管理的规范化
裸机阶段,FS 的配置管理往往靠 SSH 上去改文件,改完reloadxml或者重启服务。一次误操作可能就让线上系统起不来,而且多台机器之间的配置漂移问题非常严重——常常是这台机器的 external profile 开了 NAT 参数,另一台没开,出问题才想起来对比。
容器化之后,镜像本身是不可变单元,代码版本、依赖库、配置文件都固定在一个 Image 里。配置差异用 ConfigMap 或者 Helm 参数去表达,改配置就是一个新的发布流程,可以走 CI/CD 管道,可以回滚。我现在的做法是:基础镜像半年更新一次,负责系统依赖和 FreeSWITCH 版本;业务配置全部走 ConfigMap,每次改动都能在 Git 里看到 diff,出了事kubectl rollout undo一分钟内回滚。这种发布体验是裸机做不到的。
1.3 故障域隔离与资源复用
Kubernetes 让 FS 的故障域变得更可控。节点宕机了,控制器会把 Pod 重新调度到健康节点,配合前端 SIP 代理层的重试,用户感知的影响被压缩到很小。同时多个业务共享一个集群,FS 所在节点池可以跟 Web 服务隔离,既避免资源争抢,又提高了基础设施利用率。
不过,把 FS 放进 K8s 不代表自动获得"高可用"。FS 本身没有集群模式,多实例之间如果不共享注册数据,呼叫分发就会出现"注册在 A 实例、通话落在 B 实例"的问题。所以部署方案里必须设计好实例之间的关系,要么在前面挂一层 SIP 负载均衡,要么让 FS 作为纯媒体服务器接入已有软交换体系。这一点第 5 节里会详细展开。
2. FreeSWITCH 的"有状态"约束:SIP 会话与实时媒体流对调度的影响
2.1 为什么不能拿 nginx 那套习惯来部署 FS
很多刚接触容器化的同学会想:既然 nginx 能做成 Deployment 跑得飞起,FS 也应该差不多——把进程丢进容器,挂几个端口,配一个 Service,完事。这恰恰是最容易掉进去的坑。nginx 是无状态的反向代理,任何一个 Pod 死掉,流量会被其他 Pod 接住,用户层面几乎无感知。而 FS 一旦 Pod 被删除或者重启,正在进行的通话立刻断掉,已经录了一半的录音文件也会丢失。
这就是"有状态应用"的典型特征:运行时的会话上下文、业务数据、媒体资源都和进程生命周期强绑定。SIP 协议从设计之初就是面向一座座独立交换机的,每个呼叫的 dialog 状态、鉴权信息、媒体地址都维护在设备本地。在 K8s 上跑 FS,最先要接受的就是这个约束,然后才能谈怎么让调度器跟它和平共处。
2.2 CPU 实时性与资源配额设计
FreeSWITCH 对 CPU 的敏感度远超普通 Web 服务。一路 G.711 通话大约消耗 8Kbps 左右的带宽资源,但 CPU 占用很低;可一旦涉及转码,比如把 G.729 转成 G.711,一路通话可能吃掉单核 30% 甚至更多的算力。在一个繁忙的 IVR 实例上,抖动、时延超时、丢包都可能直接影响语音质量。
Kubernetes 默认的 CPU 管理策略是有可能给到应用带来调度延迟的。生产环境我建议用专门的节点池跑 FS,开启节点的 CPU Manager static 策略,让 Guaranteed QoS 的 Pod 独占物理核;至少也要保证requests和limits一致,避免 CPU 配额被 CFS 周期强制限制后出现周期性的调度停顿。内存方面,FS 每路通话的音频缓冲区可以估算,通常给每路通话预留 64KB 到 128KB 的内存,再用/usr/local/freeswitch/bin/freeswitch -rp这类参数启动,避免容器 OOM 时内核直接给 FS 一刀。
2.3 单节点实例数:端口冲突是第一道坎
FreeSWITCH 默认要监听一堆端口:SIP 的信令端口 5060(UDP/TCP)、SIP over TLS 的 5061、内部等多 Profile 可能用到 5080、Event Socket 的 8021,还有媒体流 RTP 的一大段 UDP 端口区间。这些端口在容器里没问题,因为每个 Pod 有自己的网络命名空间;但如果用 hostNetwork 模式(第 4 节会解释为什么这是首选),宿主机端口的独占性意味着一个节点上只能跑一个 FS 实例。
因此在设计调度策略时,我会给 FS 加 podAntiAffinity,强制同一个节点的 FS Pod 只能有一个,并且用 nodeSelector 指向专门的语音节点池。这样虽然牺牲了一点"容器随意打包"的灵活性,却换来了网络模型的简单和数据面的稳定,这笔账在生产里是划算的。
3. 镜像与配置文件:先解决"能不能跑"再谈"怎么编排"
3.1 官方镜像与自建镜像的取舍
Docker Hub 上有 SignalWire 官方维护的signalwire/freeswitch镜像,版本跟随主线,拿来做个概念验证非常快。我最早测试时就是直接docker pull signalwire/freeswitch:1.10.10起一个容器跑通的。但官方镜像并不适合直接上生产,原因有三:其一,编译选项不一定覆盖你需要的模块,比如某些商用编解码器、mod_odbc 之类的数据库模块可能没编译进去;其二,镜像内的非 root 用户、目录权限、locale 设置未必符合你的运行环境;其三,你没法保证镜像中的 FreeSWITCH 版本和内部测试过的一致。
生产环境我更建议基于 Debian 或者 Rocky Linux 自建镜像。Debian 系可以直接引入 FreeSWITCH 官方软件源,把freeswitch-meta-all和需要的外围模块一起装进去。下面是我一份可用的 Dockerfile 骨架:
FROM debian:bullseye RUN apt-get update && apt-get install -y --no-install-recommends \ gnupg2 wget ca-certificates lsb-release tzdata procps iproute2 dnsutils \ && wget -O /usr/share/keyrings/signalwire-freeswitch-repo.gpg \ https://files.freeswitch.org/repo/deb/debian-release/signalwire-freeswitch-repo.gpg \ && echo "deb [signed-by=/usr/share/keyrings/signalwire-freeswitch-repo.gpg] https://files.freeswitch.org/repo/deb/debian-release/ bullseye main" \ > /etc/apt/sources.list.d/freeswitch.list \ && apt-get update \ && apt-get install -y --no-install-recommends freeswitch-meta-all \ && apt-get clean ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /etc/freeswitch EXPOSE 5060/udp 5060/tcp 5080/udp 8021/tcp 16384-32768/udp CMD ["/usr/local/freeswitch/bin/freeswitch", "-nf", "-nonat"]启动参数里的-nf是让 FS 前台运行,日志直接打到标准输出,方便容器日志采集;-nonat表示不自动判断 NAT 环境,把 NAT 处理交给明确的配置去控制。镜像体积会比较大,但换来的是按需裁剪的自由度,以及生产环境与测试环境绝对一致的运行条件。
3.2 配置文件注入策略:整包 ConfigMap 与部分覆盖
FreeSWITCH 的配置是一棵完整的目录树,从freeswitch.xml到各个模块的autoload_configs/*.conf.xml,动辄几十个文件。把它们全部塞进 ConfigMap 是不现实的:ConfigMap 有 1MB 的单体大小限制,而且把一整套与运行环境耦合很深的配置放进编排模板里,会让后续维护变得痛苦。
我推荐的方案是:镜像内保留一份"出厂默认配置",启动时用 InitContainer 把默认配置复制到持久卷,然后只把需要经常改动的关键文件用 ConfigMap 挂载覆盖。实际操作中需要覆盖的文件很少:
| 文件路径 | 覆盖原因 |
|---|---|
/etc/freeswitch/vars.xml | 设置对外 IP、RTP 起始端口、默认语言、录音目录等全局变量 |
/etc/freeswitch/sip_profiles/external.xml | 定义 SIP 对外 Profile,绑定 ext-sip-ip、ext-rtp-ip、NAT 策略 |
/etc/freeswitch/autoload_configs/event_socket.conf.xml | 配置 Event Socket 监听地址、口令、ACL |
/etc/freeswitch/autoload_configs/acl.conf.xml | 声明内网网段、语音网关的访问控制清单 |
/etc/freeswitch/dialplan/default.xml | 按业务场景定义拨号计划,属于业务可变部分 |
InitContainer 的思路很直接:挂载同一个 PVC,启动时把镜像里的默认配置拷贝进去,然后主容器用 ConfigMap 的 volumeMount 把上述几个关键文件放到对应路径上。这样既能保证"空白机器也能跑出可用 FS",又能让配置变更走 GitOps 流程。
3.3 时钟、时区与编解码器许可的两个隐藏坑
容器镜像普遍默认 UTC 时区,FreeSWITCH 生成 CDR、录音文件名时用的都是系统时间,如果时区不设置成北京时间,你第二天的日报统计会全部错位。上面的 Dockerfile 里已经处理了TZ,但要注意:如果容器以非 root 用户运行,写入/etc/timezone可能没有权限,这种情况下更稳妥的做法是在 ConfigMap 里设置timezone环境变量,并通过 NTP 保证节点时间同步。
另一个容易踩的坑是商业编解码器。G.729 等专利编码格式并非所有 FreeSWITCH 发行版都内置,有些需要单独购买许可并放置许可证文件。容器化部署时,许可证文件要作为 Secret 或者独立 ConfigMap 挂载,并且确保启动 FS 的用户有权限读取。我在实际项目中遇到过镜像构建好了,结果因为运行时用户是 nobody,许可证文件权限是 600,导致 G.729 编解码一直没有加载成功,查了一整天才发现是权限问题。
4. 网络模型选型:SIP 信令与 RTP 媒体流的连通性设计
4.1 hostNetwork:首选但不是偷懒
FreeSWITCH 的媒体面需要一个非常大的 UDP 端口区间,默认配置是 16384 到 32768,也就是整整 16384 个端口。如果把 FS 放进普通 Pod 网络(CNI 分配的独立 IP),再通过 NodePort 或者 LoadBalancer 转发,要么你把这一万多端口一个个映射出去(不现实),要么只映射几个信令端口,媒体流绕开 K8s 直接穿透(又回到了外部网络层写死路由)。所以对绝大多数场景来说,hostNetwork 是唯一能把网络模型理清楚的选择。
hostNetwork 模式下,Pod 直接复用宿主机网络栈,FS 监听的 5060、8021 以及整个 RTP 端口区间都直接从宿主机的物理网卡进出。这样就没有了 DNAT 带来的地址转换问题,SIP 报文里的 Contact 头、SDP 里的媒体地址都可以直接填真实可路由的 IP。代价就是我前面说的:一台节点只能跑一个 FS 实例。这个代价在语音业务里完全可以接受,因为真正的扩容维度是"节点数"而不是"容器数"。
4.2 用 NodePort 的场景:明确 ext-sip-ip 和 ext-rtp-ip
如果你的宿主机本身没有直接暴露公网,或者对外的地址是某个内网 VLAN 里的虚 IP,那么即便用 hostNetwork,也要在 SIP Profile 里显式声明对外地址。FreeSWITCH 有两个关键参数管这事:ext-sip-ip和ext-rtp-ip。
我在一台处于内网、前面有防火墙做端口映射的节点上部署时,就在 external.xml 里这样配:
<param name="ext-sip-ip" value="203.0.113.20"/> <param name="ext-rtp-ip" value="203.0.113.20"/>这里填的必须是 SIP 对端能路由到你的 IP,也就是防火墙映射后的公网地址,而不是宿主机内网 IP。不填或者填错的话,FreeSWITCH 生成 SDP 时会把内网地址告诉对端,对端发来的 RTP 媒体包自然就到不了 FS,表现为"呼叫能建立但双方听不到声音"。
如果确实走的是 NodePort 形态,还要注意 K8s Service 的externalTrafficPolicy要设为Local,否则中间节点的 SNAT 会改写报文源 IP,让 FS 看到的所有对端地址都变成一个虚拟 IP,后续 ACL 和来源识别全会出问题。LoadBalancer 也是类似逻辑,云厂商的 LB 最好开启保留客户端源 IP 的模式。
4.3 ACL 与 NAT 参数的协同
FreeSWITCH 里有一套 ACL 机制,用来判断一个 SIP 请求是来自内网还是外网,进而决定是否对媒体流做 NAT 处理。光设置了 ext-rtp-ip 还不够,如果 ACL 里把语音网关的地址段识别成了"本地网段",FS 就不会对媒体做地址重写,SDP 里依然放内网 IP。
常见的做法是把语音网关所在的 IP 段加进内网域:
<list name="lan" default="deny"> <node type="allow" cidr="10.0.0.0/8"/> <node type="allow" cidr="172.16.0.0/12"/> <node type="allow" cidr="192.168.0.0/16"/> </list>然后在 external profile 里开启 NAT 穿透相关参数:
<param name="apply-nat-acl" value="wan.auto"/> <param name="nat-mode" value="force"/>apply-nat-acl="wan.auto"的含义是:对来自"非内网"的请求应用 NAT 重写;nat-mode="force"则强制把 SDP 和 Contact 里的地址改写成 ext-rtp-ip 和 ext-sip-ip。这种配置组合在云环境、混合网络里非常可靠。
4.4 RTP 端口区间的资源规划
RTP 端口默认是 16384 到 32768,每个媒体会话按偶数端口分配,一路通话至少占 2 个 UDP 端口(RTP 和 RTCP)。常规情况下一个节点按 500 并发通话规划,其实只需要 1000 个端口,完全不必要把 1.6 万个端口全部暴露在防火墙上。
可以通过 vars.xml 收窄端口范围:
<X-PRE-PROCESS cmd="set" data="rtp-start-port=20000"/> <X-PRE-PROCESS cmd="set" data="rtp-end-port=30000"/>收窄到 20000-30000 意味着大约 5000 个端口可以用,换算成 2500 路并发通话,对一个单节点 FS 来说已经非常宽裕。端口范围越小,防火墙和安全组规则越好管理,攻面也越小。
5. StatefulSet + hostNetwork 实测部署方案
5.1 为什么选 StatefulSet 而不是 Deployment
前文已经讲到 FS 有状态,那控制器选择上顺理成章用 StatefulSet。除了稳定的网络标识和有序滚动之外,StatefulSet 另一个核心价值是volumeClaimTemplates——每个 Pod 自动获得一个独立的 PVC,录音、CDR、配置持久化天然和 Pod 生命周期绑定。用 Deployment 管理 FS 的话,PVC 只能手动预先创建,Pod 重建后要手工重新挂载,一旦节点迁移就会在存储层面卡住。
我给生产环境准备的最小可用清单大致是这个思路:StatefulSet 管理 Pod,每个 FS Pod 通过 hostNetwork 直接绑定节点网络,配上 podAntiAffinity 保证同一节点只有一个 FS,PVC 挂载录音和 CDR 目录,ConfigMap 覆盖关键配置文件。
5.2 一套可以直接抄的 YAML 骨架
下面这份清单是精简过的可运行版本,关键点我都加了备注。实际生产里我会用 Helm 把这些资源参数化,方便多环境复用。
apiVersion: v1 kind: Service metadata: name: freeswitch-headless namespace: voice spec: clusterIP: None selector: app: freeswitch --- apiVersion: apps/v1 kind: StatefulSet metadata: name: freeswitch namespace: voice spec: serviceName: freeswitch-headless replicas: 2 podManagementPolicy: Parallel selector: matchLabels: app: freeswitch template: metadata: labels: app: freeswitch spec: hostNetwork: true terminationGracePeriodSeconds: 120 nodeSelector: role: voice affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app operator: In values: ["freeswitch"] topologyKey: kubernetes.io/hostname initContainers: - name: init-default-config image: myregistry/freeswitch:1.10.10 command: - /bin/sh - -c - | if [ ! -d /freeswitch-config/scripts ]; then cp -a /etc/freeswitch/. /freeswitch-config/ fi volumeMounts: - name: fs-config mountPath: /freeswitch-config containers: - name: freeswitch image: myregistry/freeswitch:1.10.10 imagePullPolicy: IfNotPresent ports: - containerPort: 5060 protocol: UDP - containerPort: 5060 protocol: TCP - containerPort: 5080 protocol: UDP - containerPort: 8021 protocol: TCP resources: requests: cpu: 2 memory: 2Gi limits: cpu: 2 memory: 4Gi securityContext: runAsUser: 998 capabilities: add: - SYS_NICE readinessProbe: exec: command: - /usr/local/freeswitch/bin/fs_cli - -t - "2000" - -x - "sofia status" initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 livenessProbe: exec: command: - /usr/local/freeswitch/bin/fs_cli - -t - "2000" - -x - "status" initialDelaySeconds: 60 periodSeconds: 30 timeoutSeconds: 10 lifecycle: preStop: exec: command: - /bin/sh - -c - "fs_cli -t 5000 -x 'shutdown' || true" volumeMounts: - name: fs-config mountPath: /etc/freeswitch - name: recording mountPath: /var/lib/freeswitch/recordings - name: cdr mountPath: /var/lib/freeswitch/cdr - name: config-overlay mountPath: /etc/freeswitch/vars.xml subPath: vars.xml readOnly: true - name: config-overlay mountPath: /etc/freeswitch/sip_profiles/external.xml subPath: external.xml readOnly: true - name: config-overlay mountPath: /etc/freeswitch/autoload_configs/event_socket.conf.xml subPath: event_socket.conf.xml readOnly: true volumes: - name: fs-config emptyDir: {} - name: config-overlay configMap: name: freeswitch-config-overlay volumeClaimTemplates: - metadata: name: recording spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 200Gi - metadata: name: cdr spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 50Gi这份清单里有几处细节我要专门解释一下。init-default-config这个 InitContainer 先把镜像内默认配置拷到一个 emptyDir 上,然后主容器把整个/etc/freeswitch指向这个 volume,这样主容器启动时就有了完整可用的配置树;同时 ConfigMap 的三个文件通过 subPath 方式覆盖到具体路径,相互不冲突。
资源配额上,requests和limits的 CPU 我故意设成了相同值 2 核,让 Pod 进入 Guaranteed QoS 级别,配合 CPU Manager static 可以实现核级别的隔离。SYS_NICEcapability 是让 FS 能调用nice()调整自身线程优先级,对媒体实时性有帮助。
5.3 部署完成后的一分钟验证
部署完成后不要急着断言"成功了",先在事件 Socket 层面做最基本的健康检查:
kubectl exec -it statefulset/freeswitch -n voice -- /usr/local/freeswitch/bin/fs_cli -x "status"看到版本号、运行时间、session 计数正常输出,再执行:
kubectl exec -it statefulset/freeswitch -n voice -- /usr/local/freeswitch/bin/fs_cli -x "sofia status"这一步会列出所有 SIP profile 的注册名、状态和监听地址。如果 external profile 显示RUNNING,并且能看到实际绑定的节点 IP,说明 SIP 信令面已经就绪。接下来用软电话或者 SIP 话机向该 IP:5060 发起注册,应该一两秒内就能完成。注册成功后再做一次拨号测试,确认媒体流能打通,语音双向正常。
6. 探针、优雅停机与持久化:别让 kubelet 杀掉你的通话
6.1 探针设计要保守,别把繁忙当异常
探针是 K8s 里最常见的 FS 杀手。很多团队从 nginx 迁移过来,习惯性用 HTTP 探针,但 FreeSWITCH 默认没有 HTTP 健康检查端点,探针自然会写错。即使改用 exec 探针,也要小心:livenessProbe并不适合做"功能完整性检查",它只需要回答一个问题——进程是否还活着。
我在生产里用的探针策略是:readiness 检查 event socket 是否响应、Sofia profile 是否加载完成,也就是执行fs_cli -x "sofia status";liveness 只检查 FS 主进程是否存活,用fs_cli -x "status"就够。两个探针的超时时间都给了 5 秒以上,并且periodSeconds压到 10 到 30 秒的梯度。呼叫高峰时 CPU 打满,FS 对 fs_cli 的响应会变慢,如果探针 timeout 设得太短(比如 1 秒),kubelet 会判定 Pod 不健康然后强制重启,这一下可能直接把几百路通话全部挂断。
6.2 preStop 与 terminationGracePeriodSeconds 的正确配合
Pod 被删除或者被重新调度时,kubelet 会先执行 preStop Hook,再等待 terminationGracePeriodSeconds 秒数,最后才发 SIGKILL。FS 不是那种可以随时被杀掉的应用,所以我把终止宽限期设成了 120 秒,并在 preStop 里执行fs_cli -x 'shutdown'。
这个 shutdown 会让 FreeSWITCH 进入正常关闭流程,对正在进行的通话发送 BYE 消息,跟对端协商好通道释放,再收拾录音文件、写 CDR 落盘,最后退出进程。如果没有这层优雅关闭,kubelet 会在几十秒后直接 SIGKILL,通话对端收到的可能只是网络超时,录音文件也可能只写了一半。
有个细节要提醒:preStop 里的fs_cli -x 'shutdown'执行完后,容器主进程已经在退出过程中,这时候如果 Kubernetes 还在等待进程完全终止,不会有什么负面影响。但要注意 fs_cli 连接不上 event socket 的情况(比如 FS 已经僵死),所以我在命令后面加了|| true,避免 preStop 一直卡着导致 Pod 无法完成删除。
6.3 持久化布局:录音、CDR、配置分开存放
FS 的持久化数据主要就三类:录音文件、CDR 话单、配置文件。录音文件和 CDR 对存储要求不一样,录音是大文件流式写入,CDR 是小文件高频写入,混在一起容易让备份策略和 IO 规划变得模糊。
我在清单里用两个 volumeClaimTemplates 分开挂载:/var/lib/freeswitch/recordings挂 200GB,/var/lib/freeswitch/cdr挂 50GB。如果用的是云盘或者分布式存储,可以按不同性能档位配置。配置文件则走 ConfigMap 覆盖——严格说它不算持久化数据,但它决定了 FS 的行为,必须跟代码一样纳入版本管理。
有一个现实问题要注意:PVC 的ReadWriteOnce模式决定了它只能被一个节点挂载,这正好和"一个节点一个 FS 实例"的调度策略匹配。如果哪天你确实要支持某实例在多个节点间漂移,存储层就得换成支持 ReadWriteMany 的 NAS 类产品,同时也会带来并发写冲突的风险,一般不建议这么干。
7. 扩容缩容与日常运维:通话不掉线的秘诀
7.1 多实例注册数据共享是扩容的第一道门槛
很多团队扩容 FS 后会发现,一部分用户死活注册不上,或者注册成功了但打不通电话,根源在于 FreeSWITCH 默认把注册信息存在本地 SQLite 里,两个实例之间的注册表完全不互通。
要支持多实例且让注册和通话都在同一个实例上闭环,有三个方向可以用。第一,把 FS 改成"媒体服务器"角色,全部注册由前端的 OpenSIPS/Kamailio 这类 SIP Proxy 处理,FS 只负责被转来的呼叫做媒体处理,这种情况下扩缩容几乎无脑,因为代理层会负责负载均衡。第二,让 FS 使用外部数据库保存注册信息,通过 mod_odbc 连 MySQL/PostgreSQL,多实例共享注册表,架构上要额外维护数据库的可用性。第三,最省事但最受局限的方案,用 DNS SRV 或者负载均衡把同一个域名的注册请求按实例权重分发,每个实例各管各注册表,但不保证"注册在 A、呼叫过来时恰好还是 A"。
我这里实际生产中用的是第三类加上一点启发式规则:通过 SIP 客户端的 sticky session 把同一注册源固定分配到同一个后端实例。但如果客户端会定期切换 IP,或者负载均衡器不支持 UDP 会话保持,这条路会走得很辛苦。如果有条件,我更推荐第一类架构,虽然要多持有一个代理层,但把无状态和状态分离之后,K8s 对 FS 的调度约束会小很多。
7.2 指标采集与告警:Event Socket + Prometheus
FreeSWITCH 本身不直接输出 Prometheus 格式指标,但可以通过 mod_event_socket 暴露控制接口,社区也有现成的 freeswitch_exporter,它通过 ESL 协议连上 8021 端口,周期性执行status、show calls、sofia status等命令,把会话数、注册数、CPU 负载、网络丢包等关键指标转成 metrics。
部署时我会把 exporter 作为 sidecar 容器放进同一个 Pod,这样它可以共享 localhost 的 event socket,不需要额外暴露端口。告警规则上有几条比较实用:
freeswitch_channel_count超过节点容量的 80% 时告警,提前观察扩容阈值;freeswitch_registration_count出现断崖式下跌,大概率是某个实例被重启了;- 探针连续失败(kubelet 层面已有重启动作)要立刻通知值班人员。
FS 的运行日志我建议让进程前台运行输出到 stdout,然后由集群级别的日志采集(Loki + Promtail 或者 ES 系)统一收集。-nf启动参数让日志直接打到标准输出,省掉了挂载日志文件的麻烦。
7.3 排障心法:先看网络,再看配置,最后查资源
在 K8s 上排 FS 的问题,我自己的排查顺序基本固定,踩了多少次坑总结出来的规律。
第一步是抓包或者看连接状态。FS 部署在 hostNetwork 模式下,直接在节点上tcpdump -i any -s 0 port 5060看 SIP 信令有没有进来;没有信令就先检查防火墙、安全组、节点路由,别在 FS 配置里瞎折腾。第二步确认 FS 认为的对端 IP 是不是真实来源,用fs_cli -x "sofia status profile external"看已注册会话的 contact 地址,如果看到的 IP 跟实际客户端不一致,百分之九十九是 NAT 配置或者负载均衡的 SNAT 问题。第三步才是看配置和资源,比如 dialplan 是否匹配、网关认证是否通过、节点负载是否打满。
有一次线上排查花了我整整两天,症状是部分用户间歇性掉线。后来抓包发现某个节点的 SIP 报文里有大量重传,节点 CPU 只有 20%,但网络延迟抖得厉害。定位到是同一个节点上另一个容器的日志采集组件在高峰期抢占 CPU 导致网卡中断延迟变大。加了资源配额和 CPU 隔离之后问题立刻消失。这就是为什么我一直强调 FS 节点最好打上专用标签,跟其他负载物理或逻辑隔离。
结尾的一点个人体会
把 FreeSWITCH 搬到 Kubernetes 上,跟部署一个 Web 服务完全是两种心态。Kubernetes 不会改变 FS 实时性、有状态这些本质,它只是让部署、发布、扩容这些运维动作变得可声明、可回滚、可重复。回过头看,最有价值的反而不是那几份 YAML,而是终于把"端口怎么通、媒体怎么流、数据怎么存、挂了怎么退"这些原本写在运维脑子和文档里的经验,沉淀成了可审查的代码。后面如果再往前走一步,我会把 OpenSIPS 这一层也加进去,让注册与媒体处理彻底解耦,到时候 FS 在 K8s 上就是一个真正可以随便扩缩的"媒体计算节点"了。