Files

178 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。