☰
ODL下发SRv6卡壳?用Expect脚本打通SSH批量配置的最后一公里
2026/10/18 3:01:51 网站建设 项目流程

记不清这是第几次被问“ODL能不能直接下发SRv6策略”了,尤其在刚接触OpenDaylight(ODL)的人眼里,控制器有了RESTCONF、NETCONF,批量下发OSPF、BGP这类配置应该是顺手的事。但真在现场跑一次,你会发现理想和现实之间的缝隙比想象中大得多:业务侧要求在几十台设备上同时下发OSPF、BGP和SRv6策略,ODL北向接口却因为设备YANG模型不完整,频繁报“数据缺失”,折腾到凌晨两点也推进不下去。最后救场的居然是一个看起来有点“土”的Expect脚本——直接通过SSH模拟人工敲键盘,把配置按模板批量打进去。

这篇文章就想把这类场景里的完整思路写清楚:ODL在高级配置上到底卡在哪、Expect脚本的设计逻辑是什么、OSPF/BGP/SRv6三类配置怎么模板化、实际踩坑的完整排查链路,以及它在Ansible、NETCONF这些正经自动化方案里的选型边界。适合正在做SDN控制器落地、手里有一批异构设备要批量跑配置的工程师,也适合刚接触网络自动化的朋友理解一条不依赖厂商文档也能推进的路子。

1. ODL配置下发卡在最后一公里:高级路由协议的模型断层

1.1 控制器模型与设备命令行的“翻译”鸿沟

ODL的核心能力来自MD-SAL(Model-Driven Service Abstraction Layer),它把网络设备的配置能力抽象成YANG模型,北向通过RESTCONF对外暴露。管理员调RESTCONF API提交一段JSON或XML,控制器把它翻译成南向指令,最终交给设备执行。这个设计本身没毛病,问题出在“词典”不够用。

YANG模型描述的是设备能力的规范视图,但真实设备命令行里的东西远远超过模型覆盖范围。OSPF这种老协议,不同厂商有一堆私有命令和扩展参数;BGP涉及地址族、路由策略、团体、扩展团体,模型化之后字段很容易丢;SRv6这类较新的技术,设备端要么没实现对应YANG模型,要么ODL模型库的版本滞后,北向接口的API树里根本找不到对应的叶子节点。

用个直观类比:ODL像远程指挥中心,RESTCONF是对话通道,YANG是双方约定好的翻译词典。设备端没有某段“词典”条目时,指挥中心说的话设备根本听不懂,更别提执行。这就是我常说的“最后一公里断层”——控制面模型和实际命令行之间,始终隔着一道没法绕过去的翻译鸿沟。

1.2 哪些场景最容易触发这条路走不通

结合几次真实交付经历,最容易撞墙的通常集中在下面几类环境:

  • 异构设备环境:多厂商设备混跑,某厂商提供完整NETCONF模型,另外几家只实现了基础接口配置,高级路由特性全靠命令行。一套RESTCONF脚本根本没法统一处理。
  • 新特性业务:SRv6、Flex-Algo、EVPN这类新型功能,设备侧命令行已经支持,但控制器侧模型没跟上。ODL版本升级跟不上网络版本的节奏是常态。
  • 设备只开了SSH:现场设备默认只开启SSH管理,NETCONF服务没开,或者需要单独License。等商务流程走完,项目早就该交付了。
  • 下发无法验证:有些配置通过ODL提交后看API返回是成功,但登录设备一查,实际命令行里根本没有对应配置。这种“假成功”比直接失败更可怕,排查成本极高。

在这些场景里,与其继续跟YANG模型较劲,不如直接下探到设备的命令行层,用SSH模拟人来干活。这听起来不够“软件定义”,但确实是解决现场问题的务实路径。

2. Expect脚本凭什么能接这个盘:交互式自动化的底层逻辑

2.1 从spawn到expect:模拟人手敲键盘的机制

Expect最早是Tcl生态里的一个自动化交互工具,核心就三件事:spawn启动一个进程(通常是SSH客户端)、expect等待进程输出的特定字符串、send回复预设的输入。这三板斧组合起来,就能模拟一个工程师坐在终端前敲命令的完整过程。

看一段最基础的登录脚本就明白了:

#!/usr/bin/expect set timeout 10 spawn ssh admin@198.51.100.10 expect { -re "(?i)password:" { send "YourPass\r" } timeout { puts "连接超时"; exit 1 } } expect ">" send "enable\r" expect "#" send "terminal length 0\r" expect "#" send "show version\r" expect "#" send "exit\r" expect eof

这里面的几个细节值得说清楚:-re表示用正则匹配,(?i)是忽略大小写,因为不同设备密码提示可能有大小写差异;\r对应回车键;expect eof确保脚本等SSH会话正常结束后再退出,防止僵尸进程。这套机制的关键优势在于它是面向交互流设计的,天然匹配网络设备终端“回显-等待-输入”的节奏,而不是像无脑sleep那样瞎等。

2.2 为什么2025年还在用这个“老古董”

很多人觉得Expect是上世纪的东西,现在自动化应该用Python加Paramiko,或者直接上Ansible。这话对了一半。Expect在特定场景下依然是不可替代的轻量级方案,原因很实际:

  • 零依赖:所有Linux发行版几乎默认装了expect和ssh,不需要在跳板机上额外装Python、配虚拟环境、下载第三方库。在很多运维网络隔离环境里,能装东西的权限都很受限,expect开箱即用。
  • 快速响应:写一个临时Expect脚本通常五到十分钟,覆盖登录、配置、验证、退出。同样功能用Python写,要处理缓冲区、异常、超时,代码量翻倍不止。
  • 交互匹配能力:Expect的正则匹配是面向输出流的,支持多分支expect { pattern1 { action1 } pattern2 { action2 } },可以在一个等待期间内处理多种可能的输出——比如密码错误、登录超时、按键提示,这比Python里手写轮询要简洁得多。
  • 复用门槛低:团队里网络工程师不一定懂Python,但Expect脚本语法非常接近“期望看到什么就写什么”,给人家看一眼基本能上手改。

2.3 暴力破解提示符匹配的正确姿势

Expect真正难的不是语法,是匹配策略。设备回显的提示符是脚本判断“是否该发下一条命令”的唯一信号,匹配错了整个流程就会乱套。

我见过最典型的错误写法是expect "#",想匹配特权模式提示符,结果在配置模式下(config)#也带#,脚本提前发送了命令,而设备当前还在子模式里,命令直接变成了非法输入。正确做法是用正则精确锚定:

  • 特权模式:expect -re "# $"(#后跟行尾)
  • 用户模式:expect -re ">$"
  • 全局配置:expect -re "\(config\)#$"
  • 接口配置:expect -re "\(config-if\)#$"

不同厂商的提示符风格差异很大,有的用尖括号(如<H3C>风格),有的用方括号(如[Huawei]风格),有的带设备名。建议把提示符匹配单独封装成一个函数,设备名和模式作为参数,这样换厂商时只改一行配置,不用重写整个脚本。

3. SSH批量下发OSPF配置:一套能直接改改就用的脚本

3.1 设备列表与配置模板的设计

批量下发的前提是把“人肉重复操作”抽象成模板。我的习惯是准备两个文件:设备清单和配置模板。

设备清单用竖线分隔字段,每一行代表一台设备:

# devices.txt 198.51.100.10|admin|YourPass1|192.0.2.1|100 198.51.100.11|admin|YourPass1|192.0.2.2|100 198.51.100.12|admin|YourPass1|192.0.2.3|100

字段分别是IP、SSH用户名、密码、Router-ID、OSPF进程号。用竖线分隔是为了防止IP或密码里出现空格导致awk切分错乱。

配置模板这里不单独做文件了,直接写在Expect脚本的参数里。这样改Router-ID、进程号很方便。真正要加网络段时,再考虑从外部文件读配置列表。

3.2 OSPF下发脚本的完整代码拆解

把单台设备的交互逻辑写成主执行脚本,外层再用bash循环读设备清单,这是最稳妥的组合方式。先看单台设备的Expect核心:

#!/usr/bin/expect # ospf_one.exp set timeout 30 set ip [lindex $argv 0] set user [lindex $argv 1] set pass [lindex $argv 2] set rid [lindex $argv 3] set proc [lindex $argv 4] spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $user@$ip expect { -re "(?i)password:" { send "$pass\r" } timeout { puts "连接超时: $ip"; exit 1 } } expect { ">" { send "enable\r"; exp_continue } "#" { } } expect { -re "(?i)password:" { send "$pass\r" } "#" { } } expect -re "# $" send "configure terminal\r" expect -re "\(config\)# $" send "router ospf $proc\r" expect -re "\(config-router\)# $" send "router-id $rid\r" expect -re "\(config-router\)# $" send "network 198.51.100.0 0.0.0.255 area 0\r" expect -re "\(config-router\)# $" send "end\r" expect -re "# $" send "write memory\r" expect -re "# $" send "exit\r" expect eof

几个关键点展开说:

-o StrictHostKeyChecking=no是跳过首次SSH登录时的主机指纹确认,避免交互卡住。生产环境有更高安全要求时,可以把指纹提前打进known_hosts。-o UserKnownHostsFile=/dev/null是防止脚本跑多了把真实指纹写脏,临时任务可以这么搞。

"还原"模式下,登录后可能出现两种情况:普通用户直接进用户模式(>),或者有的设备直接进了特权模式(#)。所以在第二个expect里用多分支处理:看到>就发enable,然后exp_continue继续等,看到#就直接往下走。

expect -re "# $"是精确匹配特权模式行尾,避免(config)#这种提前命中。同样的逻辑用在(config-router)#的匹配上。

write memory是保存配置。不同厂商保存命令不同,有save、有commit、有write,这个要根据设备型号改。我习惯把保存命令单独做成变量,方便整套脚本换厂商复用。

外层批量循环用bash:

#!/bin/bash while IFS='|' read -r ip user pass rid proc; do [ -z "$ip" ] && continue echo "==== 处理设备 $ip ====" ./ospf_one.exp "$ip" "$user" "$pass" "$rid" "$proc" done < devices.txt

这样每台设备独立进程执行,一台卡死不会拖垮整个批处理。脚本全部跑完后,再手动抽查几台,或者用验证阶段自动汇总。

3.3 批量执行与结果回收的工程化处理

光把配置打进去还不够,配置到底成没成、邻居建没建起来,必须自动验证。我通常会在主配置下发之后追加一段验证逻辑:

send "show ip ospf neighbor\r" expect -re "# $" send "show run | section router ospf\r" expect -re "# $" send "exit\r" expect eof

再把执行过程中终端的回显输出重定向到日志文件:

./ospf_one.exp "$ip" "$user" "$pass" "$rid" "$proc" > "log_${ip}.txt" 2>&1

事后用grep扫描日志里的关键词,比如Full(邻居状态)、router ospf(配置存在),就能自动化判断每台设备的执行结果。这一步看起来原始,但在几十台设备的场景里非常省事,比逐个登录查看高效得多。

4. 从BGP到SRv6:复杂配置下发的模板化套路

4.1 BGP多地址族配置的交互难点

BGP相比OSPF的交互复杂度上了一个台阶,主要体现在配置层级多、地址族切换频繁、模式嵌套深。典型的一段BGP配置长这样:

router bgp 65000 bgp router-id 192.0.2.1 neighbor 198.51.100.2 remote-as 65001 address-family ipv4 neighbor 198.51.100.2 activate exit-address-family address-family vpnv4 neighbor 198.51.100.2 activate exit-address-family

这门命令序列在Expect里对应的提示符会依次跳变:(config)#、(config-router)#、(config-router-af)#。很多人在这一步翻车,就是因为在address-family子模式下还傻等(config-router)#,导致匹配不上,脚本以为命令没执行成功,开始乱发后续内容。

解决思路是写一个通用的“逐级下发”函数:

proc send_cmd {cmd pattern} { send "$cmd\r" expect $pattern return }

调用时显式指定下一级提示符:

send_cmd "router bgp 65000" "\(config-router\)# $" send_cmd "neighbor 198.51.100.2 remote-as 65001" "\(config-router\)# $" send_cmd "address-family ipv4" "\(config-router-af\)# $" send_cmd "neighbor 198.51.100.2 activate" "\(config-router-af\)# $"

这样虽然多写几行,但每一步都卡在正确模式上,脚本不可能因为模式错乱而跑飞。想偷懒也行,但后续排错成本一定翻倍。

4.2 SRv6配置碎片化问题的应对

SRv6是这几类配置里最麻烦的,因为不同厂商的命令差异极大,而且单条命令之间依赖关系强,漏一条就整体不生效。以支持SRv6的设备为例,常见命令片段是:

segment-routing ipv6 encapsulation source-address 2001:db8::1 locator L1 prefix 2001:db8:1::/48 node-length 16 static sid fc00:0:1::1/48

这种多层嵌套结构,如果用固定Expect脚本写死,换一台设备就得重写。我的做法是把整段SRv6配置做成外部命令清单文件,Expect只当“搬运工”:

# srv6_cmds.txt configure terminal segment-routing ipv6 encapsulation source-address 2001:db8::1 locator L1 prefix 2001:db8:1::/48 node-length 16 static sid fc00:0:1::1/48 end write memory

脚本逐行读取并下发:

set fp [open "srv6_cmds.txt" r] while {[gets $fp cmd] >= 0} { send "$cmd\r" expect -re "(\#|\)|>)$" } close $fp

这里expect -re "(\#|\)|>)$"是宽匹配,目的是在每一级提示符下都向下执行,因为清单里的命令可能来自不同配置层级,具体落在哪个提示符不好预测。用宽匹配反而稳妥。这种“配置驱动”的思路,让Expect脚本彻底与具体配置解耦,以后新增协议或改参数,只需要改文本文件,不用动脚本主体。

4.3 通用Expect模板函数:一套逻辑吃下三种协议

基于上面的思路,我最终沉淀出一套通用流程脚本,核心逻辑只有四步:

  1. 读取设备清单,拿到登录凭据和参数
  2. 读取协议命令清单文件,按行下发
  3. 下发结束后,执行一段验证命令,输出结果
  4. 写日志,供外层汇总扫描

这套逻辑在不同协议之间唯一要改的是命令清单文件和保存命令。OSPF写一份OSPF的命令清单,BGP写一份BGP的,SRv6写一份SRv6的,Expect主流程完全不动。这个设计几乎就是把“人工敲命令”变成了“自动读剧本”,灵活性和可维护性都高得多。

5. 现场踩坑记录:提示符、分页、超时与批量并发的四个大坑

5.1 提示符匹配过宽导致的误发送

开头就提过的expect "#"问题,实际现场表现很隐蔽。一次我在现场调试时,脚本跑到send "router ospf 100"后,马上提示符匹配到(config)#,提前发送了network命令。设备都在全局配置模式下,这条命令被当作非法命令拒了,但脚本没意识到,继续往下发,最后整台设备配置一片混乱。

修复方式就是改成精确匹配,并且把提示符做成变量统一管理:

set prompt_priv "# $" set prompt_cfg "\(config\)# $" set prompt_rt "\(config-router\)# $"

这样一目了然,哪个模式配哪个提示符,不会乱。

5.2 设备分页卡死整个脚本

大型配置输出时,设备默认分页,屏幕底部会出现--More--。如果Expect脚本没针对它做处理,会一直等下去直到timeout,整个过程看似“卡死”。

解决手段有两个,最好是双管齐下:

  • 在进入配置模式之前先发terminal length 0或screen-length disable,关掉分页
  • 在expect匹配中加入--More--分支,遇到就发空格继续翻页:
expect { -re "--More--" { send " "; exp_continue } -re "# $" { } }

很多线上自动化脚本卡住,根因都不是设备故障,而是没处理这个翻页交互。

5.3 超时与重试机制怎么设计才稳妥

设备在高负载时,命令回显可能延迟好几秒。set timeout 30在绝大多数场景够用,但遇到设备CPU飙高或者链路拥塞,30秒内没等到提示符,脚本就会误判失败退出。

我建议在批量场景里把超时分成两级:连接阶段30秒,配置阶段60秒,并且在expect分支里对timeout做显式处理:

expect { -re "\(config-router\)# $" { } timeout { puts "配置指令响应超时,记录失败设备"; exit 1 } }

超时后不做任何补救,直接记录并跳下一台,比反复重试更高效。为什么?批量任务里偶发超时是常态,重试机制会放大并发压力,甚至拖垮设备。最好的办法是先按原始节奏跑完,第二轮再对失败设备集中重试。

5.4 批量下发时的不一致问题

批量执行时最容易出现的是“部分成功、部分失败、还有一部分半成功”。有一次我批量下发BGP配置,脚本跑完看日志,绝大多数设备都出现了预期提示符,只有两台设备因为密码策略过期,卡在密码验证阶段。外层bash循环无法感知这个问题,最终日志里那两台设备没有任何输出。

从此以后,我在每个脚本开头就增加“登录握手”标记:

expect -re "# $" send "show clock\r" expect -re "# $"

如果设备能正常回显时间,说明登录成功;否则把该设备标记为“登录失败”,统一跳过配置阶段。这种校验成本极低,却能把“假成功”的概率降下来。

另外,并发批量下发时设备之间会产生资源竞争,尤其是同一台设备同时被多个线程SSH连入,配置可能互相覆盖。我的经验是保持串行,或者至多按设备组做2到3路并发,不要盲目开几百个并发进程。

5.5 密码与凭据管理的安全边界

Expect脚本动态语言,密码以明文写进脚本的风险一直存在。我见过公司将含生产设备密码的脚本直接提交进代码仓库,后面又忘了清理,结果可想而知。

最基础的处理是把密码放到环境变量或外部受控文件里,再在脚本读取:

set pass $env(DEVICE_PASS)

外层调用时:

DEVICE_PASS="$REAL_PASS" ./ospf_one.exp "$ip" "$user" ...

再进一步,可以给Expect脚本做一个包装函数,密码不经过命令行参数,避免被ps命令看到进程参数。文本文件中保存密码则要设置为600权限,并且限制访问用户组。能坚持最小权限原则,尽量不给设备管理账户最高权限。

6. Expect方案与Ansible、NETCONF的选型边界

6.1 三种方案的适用场景对比

方案交互式适应配置依赖适用规模典型场景
Expect强几乎为零1-50台临时变更、故障恢复、控制器通道断裂补位
Python + Netmiko强Python环境、第三方库10-1000台批量变更、巡检、复杂逻辑编排
Ansible中Ansible环境、厂商模块100+台标准化运维、声明式配置、持续集成
NETCONF/RESTCONF弱设备YANG模型支持大规模SDN控制器集成、标准化设备管理

这个表说明一个核心问题:谈不上谁替代谁,完全是场景匹配。Expect的优势是轻和灵活,劣势是配置逻辑和验证逻辑都是命令式,维护成本随规模上升。Ansible优势是声明式、可复用、生态好,但要求环境标准化、设备模块覆盖到位。NETCONF/RESTCONF是最“正宗”的软件定义路径,前提是设备模型完整。

6.2 在ODL生态中如何结合使用

在ODL生态里,我不建议把所有功能都寄托在控制器上。比较务实的做法是双轨并行:

  • ODL能管住的部分(基础接口、VLAN、部分路由协议),走RESTCONF/NETCONF,保证配置可编程、可审计。
  • ODL模型覆盖不到的高级特性(SRv6策略、部分BGP私有扩展、厂商私有命令),用Expect脚本做“应急通道”,先保证业务能交付,后续再推动设备或控制器升级模型。

我在交付中就采用过这个策略:核心骨干配置走ODL北向接口批量下发,SRv6策略走Expect脚本逐台核对,两套指令集在一份变更方案里同时执行,最终业务上线时间比原来预估的缩短了一半。

6.3 我的选型建议

给不同情况的团队一个粗线条建议:

  • 团队里就一两个人,设备几十台,没有标准CI/CD环境,直接上Expect,先把活干完,再慢慢沉淀。
  • 团队规模中等,设备上百台,有统一的配置基线,优先搭Ansible或Netmiko体系,Expect只做紧急兜底。
  • 正在做SDN控制器落地,设备管理规范化是大方向,那么主体还是走RESTCONF/NETCONF,Expect作为补位工具存在。

核心一句话:自动化工具永远要服从现场约束。控制器模型全,就用控制器;模型不全,就不必死磕,SSH加Expect把业务跑通,等后续模型补齐了再迁移。技术方案的稳妥从来不是看工具多先进,而是看能不能在受限条件下稳定解决问题。

最后分享一个我自己的习惯:写Expect脚本时,永远不要只用expect "#"这种宽匹配。我通常会封装一个prompt_check函数,用正则精确匹配当前设备的提示符,并且把不同厂商的提示符风格统一成三个变量:用户模式、特权模式、二级子模式。不同品牌设备提示符千差万别,但只要这一层封装好,脚本复用率会高很多。这个习惯帮我在多个异构环境里少踩了很多坑,也给刚入门的你做一个参考。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询