169 lines
11 KiB
Markdown
169 lines
11 KiB
Markdown
# Asterisk + ARI AI 外呼底座 — 部署与运维文档
|
||
|
||
> 目标机器: `39.106.106.246` (阿里云 2vCPU/3.5G, Debian 13) · 交付物: `/opt/asterisk-ari/`
|
||
> 项目: https://github.com/asterisk/asterisk · 镜像: andrius/asterisk (Asterisk 22.10.1 LTS)
|
||
> 范围: **仅外呼** (ARI originate + externalMedia), Mock SIP 运营商 + Mock LLM/ASR, 已端到端验证双向语音。
|
||
> 前置: livekit 运行时已下线 (lk-* 容器清零、端口释放); Docker 与内核调优沿用其成果。
|
||
|
||
## 1. 水平扩展结论 (先行)
|
||
|
||
**结论: 零共享状态的独立节点复制 —— Asterisk 无内置集群, 每节点呼叫状态全在本机内存; 扩容 = 加一个节点, 呼叫发起侧 (agent/业务后端) 在节点名单上选节点发起 ARI originate。不需要 Redis, 不需要任何协调组件。**
|
||
|
||
| 层 | 状态存放 | 扩容方式 | 调度机制 |
|
||
|---|---|---|---|
|
||
| Asterisk 节点 | 本机内存 (通道/桥/endpoint) | 复制 service 块 (独立宿主端口) | 呼叫发起侧选节点 (call.sh 的 NODE 参数 = 生产中的 LB 名单) |
|
||
| AI Agent (生产) | 无状态 worker | `--scale`/K8s HPA | 每个外呼任务自带节点选择与 ARI/RTP 会话 |
|
||
|
||
对比 livekit 方案: livekit-sip 需要 Redis 存 trunk/呼叫状态并做派发; Asterisk 出站外呼场景连共享层都不需要 —— 每个呼叫自包含 (originate + bridge + externalMedia 全在一个节点内闭环), 节点间零交互。实测 1→2 节点**零配置变更** (共用同一套 conf), 呼叫按发起侧选择分摊 (见 §7)。
|
||
|
||
瓶颈与边界: ① 单节点容量受 CPU (RTP 收发 + 桥混音) 与 RTP 端口段限制 — 本文 10000-10800 共 800 端口, 每呼叫 2 腿 (被叫 + externalMedia) 各占 1 对端口 (RTP+RTCP) ≈ 200 并发/节点; ② 无跨节点媒体互通 — 仅外呼场景无影响 (呼叫不跨节点); ③ 未来要呼入/注册时需前置 Kamailio dispatcher (SIP 负载均衡), 纯出站不需要。
|
||
|
||
## 2. 架构
|
||
|
||
```
|
||
业务后端 / AI Agent (mock-agent, 容器或 host)
|
||
│ ① POST /bridges (mixing) ─ ARI REST, 节点可选 (ast1/ast2 轮询 = 调度层)
|
||
│ ② POST /channels/externalMedia ─ Asterisk 将桥内混音以 ulaw RTP 发往 agent
|
||
│ ③ POST /channels (originate) ─ PJSIP/mock-trunk 外呼被叫
|
||
│ ④ 轮询通道至 Up → addChannel 双通道入桥
|
||
▼
|
||
┌─ docker net: astari ──────────────────────────────────────────────┐
|
||
│ ast1 (ARI :8088, PJSIP :5060/udp, RTP 10000-10800) ← 独立节点 │
|
||
│ ast2 (ARI :8088→宿主 8089, 其余同上) ← 零共享状态 │
|
||
│ │ INVITE / 100/180 / 200 OK (SDP, PCMU) / ACK │
|
||
│ ▼ │
|
||
│ mock-provider (SIP UAS :5060/udp, RTP :40000, 440Hz 应答音) │
|
||
└────────────────────────────────────────────────────────────────────┘
|
||
agent ⇄ Asterisk: 混音 RTP 双向 (ulaw)
|
||
TX = 440Hz 间歇音 (Mock LLM/TTS) → 被叫听到的 "AI 说话"
|
||
RX = RMS 统计 (Mock ASR) → 被叫语音进 "识别"
|
||
```
|
||
|
||
外呼信令流 (仅出站):
|
||
```
|
||
业务后端 → POST /ari/channels (endpoint=PJSIP/+1510...@mock-trunk, app=outbound)
|
||
→ Asterisk 发 INVITE → mock-provider 100/180 → 200 OK (PCMU) → ACK
|
||
→ 被叫通道 Up, 与 externalMedia 通道同入 mixing 桥
|
||
→ agent 经 RTP 双向收发; 挂断 DELETE 通道 → BYE
|
||
```
|
||
|
||
## 3. 端口规划
|
||
|
||
| 端口 | 组件 | 说明 |
|
||
|---|---|---|
|
||
| 8088/tcp | ast1 | ARI REST + 事件 websocket, 宿主映射 8088 |
|
||
| 8089/tcp→8088 | ast2 | 第二节点 ARI (profile `scale`) |
|
||
| 5060/udp | ast1/ast2 | PJSIP 信令 (容器网内, 未映射宿主) |
|
||
| 10000-10800/udp | ast1/ast2 | RTP 媒体 (各容器独立 netns 不冲突) |
|
||
| 5060/udp, 40000/udp | mock-provider | Mock 运营商 SIP + RTP |
|
||
| 40001/udp | mock-agent (每次呼叫) | externalMedia 目的端口 |
|
||
|
||
生产外网仅需放行: SIP 5060/udp (+tcp/5061 tls) 与 RTP 段; **ARI 8088/8089 只对 agent 内网放行, 严禁公网裸开**。
|
||
|
||
## 4. 从零部署步骤 (全部实测)
|
||
|
||
```bash
|
||
# ── 4.1 Docker 与内核调优: 沿用 livekit 项目成果
|
||
# (Docker 已装; /etc/sysctl.d/99-livekit-sip.conf 已应用, 见 §8)
|
||
# livekit 运行时下线: cd /opt/livekit-sip && docker compose --profile multinode down
|
||
|
||
# ── 4.2 部署栈 ─────────────────────────────────────────────────────
|
||
mkdir -p /opt/asterisk-ari && cd /opt/asterisk-ari # = 仓库 deploy/ 目录
|
||
docker compose up -d # ast1 + mock-provider
|
||
docker compose --profile scale up -d # (可选) ast2 第二节点
|
||
|
||
# ── 4.3 验证 ───────────────────────────────────────────────────────
|
||
./scripts/health.sh # 容器 + ARI REST + trunk qualify + 活动通道
|
||
./scripts/call.sh +15105550123 12 1 # 端到端外呼 (节点1)
|
||
./scripts/call.sh +15105550123 10 2 # 端到端外呼 (节点2, 验证扩展)
|
||
```
|
||
|
||
配置仅 4 个文件 (~30 行, conf/): `http.conf` (ARI HTTP 8088)、`ari.conf` (用户 outbound)、`pjsip.conf` (出站 trunk endpoint+aor, 无注册, qualify 10s 探活)、`rtp.conf` (RTP 段 10000-10800)。
|
||
|
||
⚠ 踩坑1 (**最关键**): ARI 要求 Stasis app 有**活跃的 websocket 事件订阅**, 否则通道一进 app 就被立即挂断 — 表现为外呼 1.5s 即 BYE、轮询通道 404。mock-agent 内 `AppKeeper` 用原生 socket 保持 `/ari/events` 连接 (只丢事件), 控制面仍纯 REST。
|
||
⚠ 踩坑2: `pjsip show contacts` 显示 `NonQual` 只是首个 qualify 周期前的初始态, 等一个周期 (已设 10s) 即 `Avail`, 不是故障。
|
||
⚠ 踩坑3: agent 回发 RTP 的地址不是 external_host 自己, 而是通道变量 `UNICASTRTP_LOCAL_ADDRESS/PORT` (Asterisk 为该 externalMedia 通道分配的本机 RTP 端口); 拿不到时可用收到的首包源地址兜底。
|
||
⚠ 踩坑4: andrius/asterisk 的 entrypoint 会对 `/etc/asterisk` 做 chown — 按文件 RO 挂载没问题 (chown 带 `|| true` 兜底); 不要挂空目录, 否则内置配置全丢导致 Stasis 初始化失败。
|
||
|
||
## 5. 外呼链路验证 (实测输出)
|
||
|
||
`./scripts/call.sh +15105550123 12 1`:
|
||
```
|
||
[agent] stasis app 'outbound' running (event ws connected)
|
||
[agent] externalMedia channel up: UnicastRTP/mock-agent-12981-...
|
||
[agent] asterisk RTP return addr: 172.18.0.2:10550
|
||
[agent] INVITE sent via mock-trunk -> +15105550123 (channel callee-1788153974-699)
|
||
[agent] event StasisStart PJSIP/mock-trunk-00000000
|
||
[agent] callee answered: state=Up
|
||
[agent] bridged [em-... + callee-...], streaming 12.0s
|
||
[agent] RX frames=500 avg_rms=8374
|
||
RESULT number=+15105550123 rx_frames=512 rx_avg_rms=8318 tx_frames=512 bidirectional=YES
|
||
```
|
||
mock-provider (被叫侧) 日志:
|
||
```
|
||
INCOMING_INVITE from=172.18.0.2:5060 uri="sip:+15105550123@mock-provider:5060"
|
||
ANSWERED media=172.18.0.3:40000 codec=PCMU peer=172.18.0.2:10528 ← SDP 协商 PCMU
|
||
ACK confirmed, RTP bridging
|
||
CALL_DONE reason=bye dur=13.7s sent_rtp=517 recv_rtp=512 recv_avg_amplitude=8167
|
||
```
|
||
**双向语音证明**: agent RX=512 帧 (被叫→ASR 方向, RMS 8318 = 440Hz 检测到) 与 provider recv_rtp=512 (LLM/TTS→被叫方向) **完全对称**; 挂断后 ARI channels=0, 无泄漏。换真实 LLM/ASR 只需替换 mock-agent 的 TX 音源与 RX 消费循环。
|
||
|
||
## 6. 运行状态检测
|
||
|
||
`./scripts/health.sh` (实测输出):
|
||
```
|
||
== 容器状态 ast1 / ast2 / ast-mock-provider Up (healthy)
|
||
== ARI REST PASS ast1 /asterisk/info (200) PASS ast2 (200)
|
||
== PJSIP trunk PASS ast1 mock-provider Avail PASS ast2 Avail (OPTIONS qualify)
|
||
== 活动呼叫 PASS ast1 active channels=0
|
||
result: ok=5 fail=0
|
||
```
|
||
持续监控: `watch -n5 ./scripts/health.sh`; 呼叫级状态: provider 日志 `CALL_DONE` 行 (时长/双向计数); 容器级: docker healthcheck (镜像自带)。层级与 livekit 版对齐: 容器 + HTTP/API + 业务功能三层。
|
||
|
||
## 7. 水平扩展实证 (双节点, 零配置变更)
|
||
|
||
**① 加节点即扩容**: `docker compose --profile scale up -d` 拉起 ast2, 共用同一套 conf, 无任何既有节点配置改动。
|
||
|
||
**② 3 路并发分摊双节点 (发起侧调度)**:
|
||
```
|
||
$ (./scripts/call.sh +15105550123 10 1 &) (./scripts/call.sh +15105550123 10 1 &) (./scripts/call.sh +15105550123 10 2 &)
|
||
RESULT ... rx_frames=401 rx_avg_rms=8438 tx_frames=401 bidirectional=YES
|
||
RESULT ... rx_frames=398 rx_avg_rms=7991 tx_frames=401 bidirectional=YES
|
||
RESULT ... rx_frames=404 rx_avg_rms=8077 tx_frames=405 bidirectional=YES
|
||
|
||
$ docker logs ast-mock-provider | grep INCOMING | grep -oE "from=[0-9.]+" | sort | uniq -c
|
||
2 from=172.18.0.2 ← ast1 处理 2 路 (源 IP = ast1)
|
||
1 from=172.18.0.4 ← ast2 处理 1 路 (源 IP = ast2)
|
||
```
|
||
→ 加副本即扩容、发起侧调度、双节点各自满血双向, 三项均实证。
|
||
资源基线 (3 并发): ast1 1.4%CPU/53M, ast2 1.1%CPU/52M — 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.conf: 每呼叫 2 腿 × 1 对端口 (RTP+RTCP); 800 端口 ≈ 200 并发/节点, 不够就扩段或加节点 (加节点更快, 见 §7)。
|
||
- pjsip: `direct_media=no` 必须保留 — 媒体必须过 Asterisk 进桥, 否则 externalMedia 拿不到音频; `qualify_frequency=10` 快速摘除故障 trunk。
|
||
- asterisk.conf (如需): `maxcalls`/`maxload` 做背压 (默认无限, 生产按容量设)。
|
||
- 容器: `restart: unless-stopped` 已配; 双节点各 ~52M 内存, 3.5G 机器无需 mem limit。
|
||
- 监控: ARI `GET /channels` 数 + docker healthcheck; 告警项: 活动通道数骤降、trunk `Unavailable`、容器重启、`UdpRcvbufErrors>0`。
|
||
|
||
## 9. 生产化清单 (Mock → 真实运营商)
|
||
|
||
1. trunk: pjsip.conf 加 `outbound_auth` (digest 用户名/密码) 与 `from_user/from_domain`, contact 换运营商 SIP 域名/IP; 我方 5060/udp、RTP 段公网放行 (阿里云安全组)。
|
||
2. ARI 安全: 8088/8089 仅对 agent 网段放行; 换强密码; 可开 http.conf TLS。
|
||
3. NAT: 阿里云 ECS 1:1 NAT — PJSIP transport 配 `external_media_address/external_signaling_address` (公网 IP); externalMedia 的 external_host 用 agent 可达地址。
|
||
4. AI 侧: mock-agent 替换为真实 STT/LLM/TTS (RX 帧喂识别、TX 换合成音频, RTP/ARI 接口不变)。
|
||
5. 高可用: 节点 ≥2 (已验证); 调度侧带健康检查摘除故障节点; 需要呼入/注册时前置 Kamailio dispatcher。
|