178 lines
13 KiB
Markdown
178 lines
13 KiB
Markdown
# FreeSWITCH/ESL AI 外呼底座 — 部署与运维文档
|
||
|
||
> 目标机器: `39.106.106.246` (阿里云 2vCPU/3.5G, Debian 13) · 交付物: `/opt/freeswitch-esl/`
|
||
> 项目: https://github.com/signalwire/freeswitch · 镜像: safarov/freeswitch (FreeSWITCH 1.10.12)
|
||
> 范围: **仅外呼** (ESL originate ×2 + uuid_bridge), Mock SIP 运营商 + Mock LLM/ASR, 已端到端验证双向语音。
|
||
> 前置: asterisk 运行时已下线 (ast-* 容器清零、8088/8089 释放); Docker 与内核调优沿用 livekit 期成果。
|
||
|
||
## 1. 水平扩展结论 (先行)
|
||
|
||
**结论: 零共享状态的独立节点复制 —— 与 Asterisk 同构。FreeSWITCH 无内置集群, 每节点呼叫状态全在本机内存; 扩容 = 加一个节点, 呼叫发起侧 (agent/业务后端) 在节点名单上选节点建 ESL 连接。不需要 Redis, 不需要任何协调组件。**
|
||
|
||
| 层 | 状态存放 | 扩容方式 | 调度机制 |
|
||
|---|---|---|---|
|
||
| FreeSWITCH 节点 | 本机内存 (通道/桥) | 复制 service 块 (独立宿主 ESL 端口) | 呼叫发起侧选节点 (call.sh 的 NODE 参数 = 生产中的 LB 名单) |
|
||
| AI Agent (生产) | 无状态 worker | `--scale`/K8s HPA | 每个外呼任务自带节点选择与 ESL/SIP/RTP 会话 |
|
||
|
||
三方案对比: livekit-sip 需 Redis 存 trunk/呼叫状态并派发; Asterisk (ARI) 与 FreeSWITCH (ESL) 出站外呼均零共享层, 每呼叫自包含 (originate ×2 + bridge 全在一个节点内闭环), 节点间零交互。实测 1→2 节点**零配置变更** (共用同一套 etc-fs), 呼叫按发起侧选择分摊 (见 §7)。
|
||
|
||
瓶颈与边界: ① 单节点容量受 CPU 与 RTP 端口段限制 — vanilla 默认 16384-32768 (约 1.6 万端口, 每桥接呼叫 2 腿各 1 对 RTP+RTCP ≈ 4000 并发/节点, 实际 CPU/fd 先到顶); ② ESL 是单连接同步命令式接口, 高并发控制面需连接池或 bgapi; ③ 无跨节点媒体互通 — 仅外呼场景无影响; ④ 未来要呼入/注册时前置 Kamailio dispatcher, 纯出站不需要。
|
||
|
||
## 2. 架构
|
||
|
||
```
|
||
业务后端 / AI Agent (mock-agent, 容器, 纯 stdlib Python)
|
||
│ ① ESL TCP 连接 fs:8021 (auth) — 控制面 (等价 ARI REST)
|
||
│ ② 本侧 SIP UAS 监听 :5062 + RTP :40001 — 媒体面 (等价 ARI externalMedia)
|
||
│ ③ originate 腿1 → sofia/external/agent@me:5062 &park()
|
||
│ ④ originate 腿2 → sofia/gateway/mock-trunk/<被叫> &park()
|
||
│ ⑤ uuid_bridge 腿1 腿2 → RTP 经 FreeSWITCH 双向
|
||
▼
|
||
┌─ docker net: fsnet ─────────────────────────────────────────────┐
|
||
│ fs1 (ESL :8021, sofia ext :5080/udp, RTP 16384+) ← 独立节点 │
|
||
│ fs2 (ESL :8021→宿主 8022, 其余同上) ← 零共享状态 │
|
||
│ │ INVITE / 100/180 / 200 OK (SDP, PCMU) / ACK │
|
||
│ ▼ │
|
||
│ mock-provider (SIP UAS :5060/udp, RTP :40000, 440Hz 应答音) │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
agent ⇄ FreeSWITCH: RTP 双向 (PCMU)
|
||
TX = 440Hz 间歇音 (Mock LLM/TTS) → 被叫听到的 "AI 说话"
|
||
RX = RMS 统计 (Mock ASR) → 被叫语音进 "识别"
|
||
```
|
||
|
||
**与 Asterisk 方案的关键差异**: FreeSWITCH **没有 ARI externalMedia 式外挂媒体 API**。标准做法是 agent 自身充当一个 SIP 端点 (UAS): FreeSWITCH originate 一条到 agent 的 SIP 腿 (agent 应答 200 OK+SDP), 再 originate 被叫腿, `uuid_bridge` 桥接。控制面从 "ARI REST + 事件 WS" 变为 "ESL TCP 单连接命令式"。agent 的 RTP 收发逻辑与 asterisk 版相同, 仅增加 SIP UAS 部分。
|
||
|
||
外呼信令流 (仅出站):
|
||
```
|
||
agent → ESL auth → originate sofia/external/agent@host:5062 &park() (腿1, agent 应答)
|
||
→ originate sofia/gateway/mock-trunk/+1510... &park() (腿2, 被叫应答)
|
||
→ uuid_bridge <腿1> <腿2> → 双向 RTP 经 FS
|
||
→ uuid_kill 双腿 → BYE → agent 输出双向统计
|
||
```
|
||
|
||
## 3. 端口规划
|
||
|
||
| 端口 | 组件 | 说明 |
|
||
|---|---|---|
|
||
| 8021/tcp | fs1 | ESL 控制面, 宿主映射 8021 |
|
||
| 8022/tcp→8021 | fs2 | 第二节点 ESL (profile `scale`) |
|
||
| 5080/udp | fs1/fs2 | sofia external profile 信令 (容器网内, 未映射宿主) |
|
||
| 16384-32768/udp | fs1/fs2 | RTP 媒体 (vanilla 默认段, 各容器独立 netns 不冲突) |
|
||
| 5060/udp, 40000/udp | mock-provider | Mock 运营商 SIP + RTP |
|
||
| 5062/udp, 40001/udp | mock-agent (每次呼叫) | 本侧 SIP UAS + RTP |
|
||
|
||
生产外网仅需放行: SIP 5060/udp (+5061/tls) 与 RTP 段; **ESL 8021/8022 只对 agent 内网放行 (强密码+ACL), 严禁公网裸开** — ESL 拿到即完全控制 FreeSWITCH (可执行任意系统命令 `system`)。
|
||
|
||
## 4. 从零部署步骤 (全部实测)
|
||
|
||
```bash
|
||
# ── 4.1 Docker 与内核调优: 沿用 livekit 项目成果
|
||
# (/etc/sysctl.d/99-livekit-sip.conf 已应用, 见 §8; asterisk 运行时已下线)
|
||
|
||
# ── 4.2 部署栈 ─────────────────────────────────────────────────────
|
||
mkdir -p /opt/freeswitch-esl && cd /opt/freeswitch-esl # = 仓库 deploy/ 目录
|
||
./scripts/setup-conf.sh # 生成 etc-fs/ (镜像 vanilla 配置 + 4 处补丁, 见踩坑)
|
||
docker compose up -d # fs1 + mock-provider
|
||
docker compose --profile scale up -d # (可选) fs2 第二节点
|
||
|
||
# ── 4.3 验证 ───────────────────────────────────────────────────────
|
||
./scripts/health.sh # 容器 + ESL status + gateway 状态 + 活动呼叫
|
||
./scripts/call.sh +15105550123 12 1 # 端到端外呼 (节点1)
|
||
./scripts/call.sh +15105550123 10 2 # 端到端外呼 (节点2, 验证扩展)
|
||
```
|
||
|
||
配置仅 2 个小文件 (conf/): `event_socket.conf.xml` (ESL 0.0.0.0:8021 + 密码 + rfc1918.auto ACL)、`mock-trunk.xml` (sofia 网关: 无注册 + OPTIONS ping 25s 探活); 其余由 `setup-conf.sh` 从镜像 vanilla 配置生成并打补丁。
|
||
|
||
⚠ 踩坑1 (**最关键**): safarov 镜像 entrypoint 在 `/etc/freeswitch/freeswitch.xml` 不存在时从 vanilla 种子整目录 cp —— **按文件 RO 挂载会报 "Read-only file system" 且配置落空**; 必须整目录挂载 (`./etc-fs:/etc/freeswitch`), 由 `setup-conf.sh` 生成 (每次全量重建, 幂等)。
|
||
⚠ 踩坑2: vanilla `event_socket` 的 `listen-ip=::` 在本容器 getaddrinfo 失败 → mod_event_socket 起不来; 改 `0.0.0.0`。
|
||
⚠ 踩坑3: vanilla `vars.xml` 的 `stun-set` 在配置解析期做外网 STUN 查询, 不通则 `Invalid ext-sip-ip` → **mod_sofia 整体加载失败**; setup-conf.sh 已剥离 stun-set 与 ext-sip-ip/ext-rtp-ip。
|
||
⚠ 踩坑4 (ESL ACL, 折腾最久): 不设 `apply-inbound-acl` → 默认 loopback.auto, 容器网内 agent 被拒 (`text/rude-rejection`); `localnet.auto` 不含 127.0.0.1 → 容器内 fs_cli 被拒; **自定义 acl.conf.xml 列表在此镜像实测不生效** (127.0.0.1 与 eth0 均被拒)。解: 内置 `rfc1918.auto` (10/8+172.16/12+192.168/16) — agent 与 fs_cli 均放行, fs_cli 需 `-H $(hostname -i)` 走 eth0 源地址。
|
||
⚠ 踩坑5: 镜像缺 CA 证书, mod_signalwire 每分钟刷 Curl 77 错误日志 → 从 modules.conf.xml 删除加载行。
|
||
⚠ 踩坑6: fs_cli 默认连 localhost+ClueCon, 需显式 `-H <ip> -P 8021 -p <密码>`; 且 count 类命令输出**首行是空行**, 解析要过滤。
|
||
⚠ 踩坑7: FS 启动后第一个 originate 可能因网关冷启动未就绪失败; mock-agent 对腿2 已做 3 次重试。
|
||
|
||
## 5. 外呼链路验证 (实测输出)
|
||
|
||
`./scripts/call.sh +15105550123 12 1`:
|
||
```
|
||
[agent] agent up: sip=udp/5062 rtp=udp/40001 ip=172.18.0.4
|
||
[agent] esl connected: freeswitch1:8021
|
||
[agent] INVITE from FS 172.18.0.3:5080 call_id=7bdc5fbc-... ← FS originate 腿1 到 agent
|
||
[agent] leg answered (ACK), fs rtp=('172.18.0.3', 18364) codec=PCMU
|
||
[agent] leg1 (agent UAS) up: +OK agent-1788159625-782
|
||
[agent] leg2 (callee) answered: +OK callee-1788159625-782 ← 经 mock-trunk 外呼被叫
|
||
[agent] bridged [agent-... + callee-...], streaming 12.0s
|
||
[agent] RX frames=500 avg_rms=8362
|
||
RESULT number=+15105550123 rx_frames=536 rx_avg_rms=8246 tx_frames=568 bidirectional=YES
|
||
```
|
||
mock-provider (被叫侧) 日志:
|
||
```
|
||
INCOMING_INVITE from=172.18.0.3:5080 uri="sip:+15105550123@mock-provider:5060"
|
||
ANSWERED media=172.18.0.2:40000 codec=PCMU peer=172.18.0.3:23888 ← SDP 协商 PCMU
|
||
ACK confirmed, RTP bridging
|
||
CALL_DONE reason=bye dur=13.5s sent_rtp=570 recv_rtp=533 recv_avg_amplitude=8236
|
||
```
|
||
**双向语音证明**: agent RX=534 帧 (被叫→ASR 方向, RMS 8270 = 440Hz 检测到) 与 provider recv_rtp=533 (LLM/TTS→被叫方向) **计数完全对称**; agent TX=568 ↔ provider sent_rtp=570。挂断后 `show calls count`=0, 无泄漏。换真实 LLM/ASR 只需替换 mock-agent 的 TX 音源与 RX 消费循环。
|
||
|
||
## 6. 运行状态检测
|
||
|
||
`./scripts/health.sh` (实测输出):
|
||
```
|
||
== 容器状态 fs1 / fs2 (healthy) / fs-mock-provider Up
|
||
== ESL status PASS fs1 (UP, FreeSWITCH 1.10.12) PASS fs2
|
||
== sofia 网关 PASS fs1 mock-trunk State=NOREG PASS fs2 (NOREG=无注册模式+ping 探活, 正常态)
|
||
== 活动呼叫 PASS fs1 active calls=0
|
||
result: ok=5 fail=0
|
||
```
|
||
持续监控: `watch -n5 ./scripts/health.sh`; 呼叫级状态: provider 日志 `CALL_DONE` 行 (时长/双向计数); 容器级: docker healthcheck (compose 已配 fs_cli status)。层级与 livekit/asterisk 版对齐: 容器 + API 功能 + 业务功能三层。
|
||
|
||
## 7. 水平扩展实证 (双节点, 零配置变更)
|
||
|
||
**① 加节点即扩容**: `docker compose --profile scale up -d` 拉起 fs2, 共用同一套 etc-fs, 无任何既有节点配置改动。
|
||
|
||
**② 3 路并发分摊双节点 (发起侧调度)**:
|
||
```
|
||
$ (./scripts/call.sh +15105550123 10 1 &) (./scripts/call.sh +15105550123 10 1 &) (./scripts/call.sh +15105550123 10 2 &)
|
||
RESULT ... rx_frames=440 rx_avg_rms=8245 tx_frames=471 bidirectional=YES
|
||
RESULT ... rx_frames=438 rx_avg_rms=8193 tx_frames=471 bidirectional=YES
|
||
RESULT ... rx_frames=440 rx_avg_rms=8178 tx_frames=470 bidirectional=YES
|
||
|
||
$ docker logs fs-mock-provider | grep INCOMING | grep -oE "from=[0-9.]+" | sort | uniq -c
|
||
2 from=172.18.0.3 ← fs1 处理 2 路 (源 IP = fs1)
|
||
1 from=172.18.0.4 ← fs2 处理 1 路 (源 IP = fs2)
|
||
|
||
$ (呼中 t=8s) fs1: 2 calls / 4 channels fs2: 1 call / 2 channels ← 每桥接呼叫=2通道
|
||
$ (provider) CALL_DONE sent_rtp=473 recv_rtp=439 amplitude=8174 ← 双向对称
|
||
```
|
||
→ 加副本即扩容、发起侧调度、双节点各自满血双向, 三项均实证。
|
||
资源基线 (3 并发): fs1 0.3%CPU/48M, fs2 0.3%CPU/44M — 2vCPU 单节点距容量上限还有两个数量级余量。
|
||
|
||
## 8. 系统调优
|
||
|
||
**内核** (沿用已应用的 `/etc/sysctl.d/99-livekit-sip.conf`, RTP/UDP 通用, 无需改动):
|
||
```conf
|
||
net.core.rmem_max = 16777216 # UDP 收缓冲上限 (16M, 默认 208K 高并发必丢包)
|
||
net.core.wmem_max = 16777216
|
||
net.core.rmem_default = 1048576
|
||
net.core.wmem_default = 1048576
|
||
net.core.netdev_max_backlog = 4096
|
||
net.ipv4.ip_local_port_range = 10000 65000 # 出向 SIP/RTP 源端口
|
||
net.ipv4.udp_mem = 8388608 12582912 16777216 # 全局 UDP 页缓存 (3.5G 内存安全值)
|
||
```
|
||
验证: `nstat -az | grep -i udp` (`UdpRcvbufErrors` 应为 0)。
|
||
|
||
**组件级**:
|
||
- RTP 段: `switch.conf.xml` 的 `rtp-start-port/rtp-end-port` (vanilla 默认 16384-32768 已注释未改); 生产按并发收窄或分段, 端口不够优先加节点 (见 §7)。
|
||
- ESL: 高并发控制面改 `bgapi` (异步作业) 或 esl 连接池; 单连接串行 `api` 是同步阻塞的 (本文 mock 场景够用)。`# ponytail: 单 ESL 连接串行 api, >100 并发呼叫控制需 bgapi/连接池`。
|
||
- sofia 网关: `ping=25` OPTIONS 探活快速摘除故障 trunk; 网关冷启动有秒级窗口 (mock-agent 已重试兜底)。
|
||
- 容器: `restart: unless-stopped` + healthcheck 已配; 双节点各 ~45M 内存, 3.5G 机器无需 mem limit; `cap_add: SYS_NICE` (FS 定时器优先级)。
|
||
- 监控: health.sh 三层 + `show calls count` 告警 (骤降); FS 文件日志 `/var/log/freeswitch/freeswitch.log`; 告警项: 容器重启、gateway State 异常、UdpRcvbufErrors>0。
|
||
|
||
## 9. 生产化清单 (Mock → 真实运营商)
|
||
|
||
1. trunk: mock-trunk.xml 加 `username/password` (digest 注册) 或 IP 白名单模式, proxy 换运营商 SIP 域名/IP; 我方 5060/udp(external profile 5080 改)、RTP 段公网放行 (阿里云安全组)。
|
||
2. ESL 安全: 8021/8022 仅对 agent 网段放行; 换强密码; 保持 rfc1918.auto ACL (生产可再收紧到容器网段); **ESL 等同 root shell, 公网暴露=事故**。
|
||
3. NAT: 阿里云 ECS 1:1 NAT — external profile 配 `ext-sip-ip/ext-rtp-ip` 为公网 IP (本文已剥离的参数届时显式回填, 不要用 stun); agent 侧 SIP/RTP 地址用宿主可达地址。
|
||
4. AI 侧: mock-agent 替换为真实 STT/LLM/TTS (RX 帧喂识别、TX 换合成音频; SIP UAS + ESL 接口不变)。
|
||
5. 高可用: 节点 ≥2 (已验证); 调度侧带健康检查摘除故障节点; 需要呼入/注册时前置 Kamailio dispatcher。
|