Files

169 lines
11 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.
# 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。