Files

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; 告警项: 活动通道数骤降、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。