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. 从零部署步骤 (全部实测)
# ── 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 通用, 无需改动):
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; 告警项: 活动通道数骤降、trunkUnavailable、容器重启、UdpRcvbufErrors>0。
9. 生产化清单 (Mock → 真实运营商)
- trunk: pjsip.conf 加
outbound_auth(digest 用户名/密码) 与from_user/from_domain, contact 换运营商 SIP 域名/IP; 我方 5060/udp、RTP 段公网放行 (阿里云安全组)。 - ARI 安全: 8088/8089 仅对 agent 网段放行; 换强密码; 可开 http.conf TLS。
- NAT: 阿里云 ECS 1:1 NAT — PJSIP transport 配
external_media_address/external_signaling_address(公网 IP); externalMedia 的 external_host 用 agent 可达地址。 - AI 侧: mock-agent 替换为真实 STT/LLM/TTS (RX 帧喂识别、TX 换合成音频, RTP/ARI 接口不变)。
- 高可用: 节点 ≥2 (已验证); 调度侧带健康检查摘除故障节点; 需要呼入/注册时前置 Kamailio dispatcher。