网络设备配置建模Ⅰ

系统介绍

伴随着互联网业务增长,大型互联网公司的IDC网络建设规模和数量都在不断增加,多业务环境的IDC网络中网络架构迭代版本和商用设备型号也非常多。这就需要一个灵活的系统,通过一个统一的模型去支持我们规划网络架构,针对不同厂商生成不同的配置,针对不同feature去批量下发操作。

为了实现这个需求,就需要我们的系统去支持基于模型的模版化生成配置和配置下发交互这两个基本功能。本文也会详细介绍这两个基本功能的实现逻辑和方法,后续会介绍再此之上的基于需求的动态架构配置生成。

网络配置下发和交互

其实大型的互联网公司(bat)都有线上化的脚本生成和下发工具,但是很多互联网公司因为在网络团队中没有专门的开发人员,所以并没有一个很好的自动化配置下发的工具。但其实网络设备的本质和服务器是没有区别的,服务器批量配置下发常用的工具有ansible、saltstack等等,更有千秋,这里不做多余介绍,我们选用ansible。

想了解基本的ansible的操作可以去中文官网进行大概了解,一点不了解ansible也没事,我会大致介绍一下工作原理。



很多初学ansible的都会误以为,ansible是ssh到目的主机上,然后直接运行cli命令,其实并不是,ansible对管理的主机虽然不需要安装agent,但是需要其拥有python。ansible主机会把需要执行的module(完成特定功能的python程序)传送到目的主机去执行。



因此我们其实需要一个moudle能完成ssh到网络设备上,并下发指定命令的功能,当然图中ANSIBLE HOST和安装了python的机器我们完全可以上一台服务器,这样就不要ansible通过paramiko传输module了,只需要在配置文件中指定connection为local即可。

有了上面介绍,我们就大概说下这玩意咋写的,其实就说很简单的python登录设备的脚本。本身ansible为了网络自动化配置,也支持很多厂商的modules(hw cisco juniper)官网就可以查
https://docs.ansible.com/ansible/2.5/modules/list_of_network_modules.html

虽然有很多模块,但很多都是限定特定厂商os,使用起来就并不方便,我们只需要实现设备ssh到设备,下发配置,获取回显即可,网上写模块的教程也有很多,参考了网上一个写连接hw交换机的模块教程,写了一个通用的模块,通用的配置参数都不用改,支持指定cli下发,也可以指定配置文件。

下面大概讲一下功能代码,这段ssh登录设备下发操作获取回显的代码,我测过juniper mx\cisco nexus\asr\hw\h3c n多真实设备,即便不用ansible兼容性也是非常高的,想学习python的同学可以大概了解一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
stdmore = re.compile(r"-[\S\s]*[Mm]ore[\S\s]*-") 
#匹配more,网络设备中当显示不全时会显示more来提示分页显示,因此我们需要通过正则匹配所有可能的more格式。


hostname_endcondition = re.compile(r"\S+[#>\]]\s*$")
#根据登录完抓取设备名称,从而得到每次命令回显的终止。

class ssh_comm(object):
def __init__(self,address,username,password,port=22):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(address, port=port, username=username, password=password, look_for_keys=False,allow_agent=False)
self.shell = self.client.invoke_shell()
while True:
time.sleep(0.5)
if self.shell.recv_ready() or self.shell.recv_stderr_ready():
break
output = self.shell.recv(4096)
self.shell.send('\n')
output = self.shell.recv(4096)
while True:
if hostname_endcondition.findall(output):
self.hostname = hostname_endcondition.findall(output)[0].strip().strip('<>[]#')
break
while True:
time.sleep(0.1)
if self.shell.recv_ready() or self.shell.recv_stderr_ready():
break
output += self.shell.recv(4096)
#这一部分是通过paramiko 建立一个ssh连接,建立完成会判断是否有回显,获取第一个回显,从而获取每次命令回显的终止符。



def recv_all(self,interval,stdjudge,stdconfirm):
endcondition = re.compile(r"%s[#>\]]\s*$"%self.hostname)
while True:
time.sleep(interval)
if self.shell.recv_ready() or self.shell.recv_stderr_ready():
break
output = self.shell.recv(4096)
if (stdjudge != '') and (stdjudge in output):
self.shell.send(stdconfirm+'\n')
self.shell.send('\n')
while True:
if stdmore.findall(output.split('\n')[-1]):
break
elif endcondition.findall(output):
break
while True:
time.sleep(interval)
if self.shell.recv_ready() or self.shell.recv_stderr_ready():
break
output += self.shell.recv(4096)
return output
#这一部分就是为了抓全回显,我们要根据刚开始登录获取的回显结束字符,在回显没有包含结束字符之前,都会不断去抓并拼接在一起,并且判断当回显出现类似[Y/N]这种需要确认的回显后会输入确认。


def send_command(self,command_interval,command,endcondition,stdjudge,stdconfirm):
command += "\n"
self.shell.send(command)
stdout = self.recv_all(interval=command_interval,condition_type=endcondition,stdjudge=stdjudge,stdconfirm=stdconfirm)
data = stdout.split('\n')
while stdmore.findall(data[-1]):
self.shell.send(" ")
tmp = self.recv_all(interval=command_interval,condition_type=endcondition,stdjudge=stdjudge,stdconfirm=stdconfirm)
data = tmp.split('\n')
stdout += tmp
return stdout
#这一部分就是发送单条命令获取回显,但是如果回显包含more则会进行按空格,再次调用上一步的获取回显。


def close(self):
if self.client is not None:
self.client.close()
#关闭session

def run(self,cmds,command_interval,endcondition):
stderr = ['^','ERROR','Error','error','invalid','Invalid','Ambiguous','ambiguous']
stdout = ''
rc = 0
for cmd in cmds.split('\n'):
stdout += self.send_command(command=cmd,command_interval=command_interval,endcondition=endcondition)
for err in stderr:
if err in stdout:
rc = 1
return rc, stdout
#主调用进程,传入的命令如果多条则进行拆分下发,并将回显拼接返回。

其实以上都是介绍,如果想使用该模块只需要将模块放到ansible配置的library目录下即可,grep library /etc/ansible/ansible.cfg, 如果配置前面有#号记得去除掉,默认的目录是/usr/share/my_modules/。

使用方法就是通过ansible或者ansible-playbook调用,以playbook为例:

创建一个test.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
---

- hosts: localhost
gather_facts: no
vars:
port: 22 #ssh端口号
username: "test" #设备用户名
password: "test" #设备密码
address: "192.168.157.132" #设备地址
command_interval: 0.5 #命令之间等待的最小间隔,默认0.5s,可以不改

tasks: #针对不同任务需要指定每次任务的操作对象
- name: set ntp #任务名称
ssh_command: #调用的模块,这里就是我们的ssh_command
port: "{{ port }}" #可以单独指定,也可以通过全局调用
address: "{{ address }}" #可以单独指定,也可以通过全局调用
username: "{{ username }}" #可以单独指定,也可以通过全局调用
password: "{{ password }}" #可以单独指定,也可以通过全局调用
command_interval: "{{ command_interval }}" #可以单独指定,也可以通过全局调用
command: '' #指定操作的命令,可以多行
configfile: /root/ansible/switch/cfg/ntp.txt #指定操作的配置文件,这个优先级大于coomand

- name: display version
ssh_command:
port: 22
address: 192.168.1.1
username: test
password: test123
command_interval: 0.1
command: 'display version'
configfile: ''


执行ansible-playbook test.yml -vv即可下发,如果不需要看回显直接ansible-playbook test.yml即可。

网络配置模版化生成

网络配置本身是基于不同的feature的组合,不同厂商的cli本身就是基于功能分类设计的。我们通过模版将满足某一类功能的不同厂家的配置归为一类,提炼出公有参数和私有参数做成模版。

模版化语言非常多,其中最出名的就是jinja2,刚好ansible的template模块就使用的jinja2,我们可以通过这个模块生成我们需要的配置模版。

下面我已ntp协议配置为例,针对hw\h3c\cisco不同厂商生成不同的配置模版

首先我们需要针对每个厂商生成一份模版文件,以j2为后缀。

jinja2语法很简单,{ % % }用来标记控制块,比如if判断,for循环等等,{ { } }用来标记变量值,这些变量可以通过文件或者命令的方式传入进来。

hw_ce:

1
2
3
4
5
6
7
8
system-view immediately #写死,进入配置视图,如果我们这个模版想单独可以使用就就把进入配置视图的命令加进去,如果下结合其他模版使用,当然可以单独写一个 system-view的模版
{% if ntp.source_interface %} #判断字典ntp的key是source_interface的值是否为空,因为不为空我们才会去配置下面这条命令
ntp source-interface {{ ntp.source_interface }}{% if ntp.vrf %} vpn-instance {{ ntp.vrf }}{% endif %} #同理因为只有vrf有值才会需要加上vpn-instance的配置
{% endif %} #结束if判断逻辑的标志
{% if ntp.server %}
ntp unicast-server {{ ntp.server }}{% if ntp.vrf %} vpn-instance {{ ntp.vrf }}{% endif %}
{% endif %}
return



h3c_v7:

1
2
3
4
5
6
7
8
9
system-view
{% if ntp.server and ntp.vrf %}
ntp-service enable
ntp-service unicast-server {{ ntp.server }} {%if ntp.vrf %}vpn-instance {{ ntp.vrf }}{% endif %}
{% endif %}
{% if ntp.source_interface %}
ntp-service source {{ ntp.source_interface }}
{% endif %}
return



cisco_nexus:

1
2
3
4
5
6
7
{% if ntp.server %}
ntp server {{ ntp.server }} {% if ntp.vrf %}use-vrf {{ ntp.vrf }}{% endif %}
{% endif %}
{% if ntp.source_interface %}
ntp source-interface {{ ntp.source_interface }}
{% endif %}
end



有了配置模版,我们还需要我们一个传参文件,来给我们的参数进行赋值。这时候你可能会发现,其实做模版并不困难,关键是给参数的数据的结构定义会比较麻烦,因为每个厂商设计的命令结构都不太一样,有的可能是列表有的可能是字符串,这就需要我们针对我们的使用场景来进行抽象归类,比如你的网络架构中完全用不到vrf的配置,你完全可以把这部分配置从你的模版中剔除掉或者写死。

因为我们使用ansible的template模块,所以传参的文件使用的是yaml的语法文件(ansible的playbook用的也是yaml,也很简单,只是一个标记性语言),还是以ntp为例。

1
2
3
4
ntp:  #定义一个ntp字典
source_interface: MEth0/0/0 #key为source_interface的值为字符串MEth0/0/0
vrf: mgt
server: 1.1.1.1



你这时会发现,虽然模版定义了三份,但是参数完全一致,你只需要针对不同厂商传入特定的赋值就行,比如这个里面的source_interface,思科上的管理口名称,你甚至可以在这里使用 { { item.mgtinterface } } 变量的方式赋值,让上层通过其他方式传入真实管理口名称。这样你在做一个统一的配置规划的时候,就无须针对每个厂商在去思考其参数命令差异了。

最后就是根据我们的ansible来调用template模块生成实际配置。我们使用playbook(类似脚本,可以穿插多种任务)的方式,也是yaml的格式。
vim test.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
- name: gengerate configuration
hosts: localhost
vars: #定义这个playbook中的变量名称,因为你可能需要写一个通用的生成配置的playbook
model: h3c_v7
module: ntp

vars_files: #指定参数文件的地址,根据上面传参,文件就是 vars/h3c_v7/ntp.yml
- "vars/{{ model }}/{{ module }}.yml"

tasks:
- name: generate configuration
template: #指定模版地址和生成配置的地址,根据上面传参,模版文件是 templates/h3c_v7/ntp.j2 生成的配置文件是config/h3c_test1.txt和h3c_test2.txt
src: "templates/{{ model }}/{{ module }}.j2"
dest: "config/{{ item.hostname }}.txt"
with_items: #这个参数很关键,因为我们很多时候会需要生成多个配置文件,每个配置文件里面有一些值还不一样,比如设备名称,我们可以在模版文件中通过 {{ item.hostname }} 来引用,当然也可以添加其他参数
- { hostname: 'h3c_test1'}
- { hostname: 'h3c_test2'}



执行ansible-playbook test.yml就可以了,生成完成后会在config目录下看到对应的配置文件。

1
2
3
4
system-view
ntp-service enable
ntp-service unicast-server 1.1.1.1 vpn-instance mgtntp-service source MEth0/0/0
return



template模块功能很强大,比如说我们希望在一个模版中引用其他模版,{ % include ‘ntp.j2’ % }的方式引用和组合。这样真正意义上达到模版复用的效果。

总结

如果你把上面的实现思路都看完了,那你应该对这个系统非常了解了,如果没咋看也没关系,这边提供一个简单的上手操作指南。

下载文件

1
git clone https://github.com/luffycjf/network_automation

安装ansible和模块

1
2
3
4
5
6
yum -y install ansible
sed -i "s/#library/library/g" /etc/ansible/ansible.cfg
mkdir -p /usr/share/my_modules/
cd network_automation
cp module/ssh_command.py /usr/share/my_modules/
如果已经安装了ansible,直接把module文件夹下的ssh_command.py放到ansible配置的模块目录下即可。

使用

目录下主文件network_automation.yml就是一个playbook,其中的含义上面都有说,两个task分别是生成配置文件和下发配置用的,写了一些配置模版和变量分别放在templates和vars文件夹中,目前时间和精力原因只更新了部分基础配置,不涉及interface和路由部分,后续会补充完整,有兴趣的也可以自己来写更多的。

调用ansible-playbook network_automation.yml即可使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
---
- name: gengerate configuration
hosts: localhost
vars:
model: h3c_v7
module: general
port: 22
username: "test"
password: "test"
command_interval: 0.5
stdjudge: "Y/N"
stdconfirm: "Y"

vars_files:
- "vars/{{ model }}/{{ module }}.yml"

tasks:
- name: generate configuration
template:
src: "templates/{{ model }}/{{ module }}.j2"
dest: "config/{{ item.hostname }}.txt"
with_items:
- { hostname: 'h3c-test1'}

- name: config device
ssh_command:
port: "{{ port }}"
username: "{{ username }}"
password: "{{ password }}"
address: "{{ item.mgtip }}"
command_interval: "{{ command_interval }}"
stdjudge: "Y/N"
stdconfirm: "Y"
command: ''
configfile: "config/{{ item.hostname }}.txt"
with_items:
- { hostname: 'h3c-test1', mgtip: '1.1.1.1' }

基于这套系统的网络架构规划,自动化网络变更等场景,我将会在后续文章中介绍。

感谢您的支持将鼓励我继续创作!