在SDN网络中基于Redis的转发路径缓存
在SDN架构下,交换机是根据流表来对数据转发的,如果交换机收到的数据包在流表中不能匹配,就会产生Packet-in消息来询问控制器,控制器根据自己已存储好的网络拓扑信息来产生相应的流表下发给交换机,交换机根据流表来对数据包进行转发,主流的SDN控制器都有相应的模块来根据自己的网络视图以及交换机的Packet-in消息来产生相应的流表,比如opendaylight用l2-switch模块产生相应的流表,并通过openflowplugin将流表下发给交换机,floodlight则是通过routing模块根据自己当前存储的网络视图生成相应的转发路径然后产生flow mod消息通过forwarding模块下发给相应的交换机。
但是,如果网络拓扑在没有任何改变的情况下,相同的源节点和目的节点之间的转发路径是一样的,对应的流表也是一样的,但是当这些流表在交换机上过期以后,交换机会产生重复的请求来询问控制器,控制器会进行重复的处理并下发流表,这会造成一些资源的浪费。实际上,在这个过程中,交换机即是客户端,而控制器即是服务端,在服务器后台开发时,面对高并发的问题,经常会使用各种粒度的缓存来缓解服务器的压力,提高运行效率,redis作为一个高性能的key-value数据库,经常用于后台开发来实现页面缓存、URL缓存以及访问数据库时候的对象缓存。那么redis能不能用于控制器上来实现对流表的缓存呢?通过redis缓存这些流表,避免控制器重复处理交换机的重复的流表请求,能大大提高控制器的工作效率。本文尝试在floodlight控制器上集成redis来实现对流表信息的缓存。
一、Redis介绍以及安装
首先对Redis做简单的介绍,Redis是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、高性能Key-Value数据库,并提供多种语言的API,它提供了Java,C/C++,C#,PHP,JavaScript,Perl,Object-C,Python,Ruby,Erlang等客户端,使用很方便。
简单来说,在使用redis时和我们在java中使用Map数据结构的操作一样,可以通过set在redis中存储一个特定的键值对,并通过get来获取一个键所对应的值,并且效率非常高,因此我们可以将源主机和目的主机的MAC地址以及IP地址等作为key,将他们之间的转发路径作为value缓存到redis中,当交换机产生重复的请求的时候,可以根据这个请求的源地址和目的地址直接查询到相应的转发路径,然后下发流表,从而避免重复的处理来提高转发的效率。
本文在ubuntu 16.04中使用docker容器来安装和使用redis,具体过程如下:
首先安装docker
sudo apt-get update sudo apt-get install -y docker.io |
然后使用docker pull命令下载redis的docker镜像
sudo docker pull redis |
然后使用docker run命令开启一个redis容器
sudo docker run --name myredis -it redis bash |
这样我们就进入到了我们创建的redis容器中
我们直接采用redis的默认配置来启动redis服务器,输入如下命令开启redis服务器(加上&让redis-server在后台运行不占据当前的shell)
redis-server & |
然后输入redis-cli进入到redis客户端,就可以使用redis了,关于redis的一些基本操作命令比如set或者get大家可以查一查官方文档。
二、在floodlight上集成redis
我们并不是要在redis自身的客户端中来使用redis,而是在floodlight上集成redis,通过调用redis的java接口来实现对redis的操作。redis通过jedis来提供java的API,因此我们需要在floodlight上通过jedis来实现对redis的操作。
本文使用floodlight的1.2版本,floodlight同样也可以通过maven来实现编译和依赖的添加,因此我们使用在eclipse中通过导入maven工程的方式导入floodlight,并在pom.xml添加jedis依赖
<!-- https://mvnrepository.com/artifact/redis.clients/jedis --> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.9.0</version> </dependency> |
然后建立net.floodlightcontroller.redis的package来实现和redis继承的模块,我们需要明确这一模块是需要提供service的:首先,在floodlight控制器中控制器通过forwarding模块来下发流表,因此forwarding模块需要调用redis模块提供的set接口来将转发的流表写入到缓存中,另外在处理重复交换机的请求的时候需要调用redis模块提供的get接口来获取到已缓存的流表信息避免forwarding模块的重复处理,并且当拓扑信息发生变化的时候,需要删除已经缓存的流表信息,避免缓存中的流表信息在新的拓扑下失效,因此我们首先创建IRedisService接口,这个接口包括如下函数
public interface IRedisService extends IFloodlightService{ public List<NodePortTuple> get(FlowKey key); public boolean set(FlowKey key, List<NodePortTuple> path); public boolean delete(); } |
FlowKey是自己创建的一个类,其中包括源目的主机的IP地址和MAC地址,并且在redis服务器中实际上缓存的是两个主机之间的路径信息即List<NodePortTuple>,我本来尝试直接缓存OFFlowMod消息,但是失败了,因为在缓存的时候需要把java对象转化成json形式来存储,但是OFFlowMod不能转化成json消息,是因为OFFlowMod接口的实现类中存在内部类。
然后在这个package下创建Redis类来实现IRedisService接口,同时需要实现IFloodlightModule接口使这个类作为floodlight的一个模块被控制器加载,另外需要实现IOFMessageListener接口来监听packet-in消息,并且需要实现ITopologyListener接口来监听拓扑信息的变化。
下面来简单介绍一下Redis这个类具体是如何实现的,文章最后我会附上源码,先来看看Redis这个模块需要依赖其他模块的哪些依赖以及需要初始化哪些对象
protected IFloodlightProviderService floodlightProvider; // 用来将自身注册成Packet-in消息的监听器 protected OFMessageDamper messageDamper;// 用来下发flow mod消息 protected IOFSwitchService switchService;// 用来根据具体的DPID获取相应的switch对象从而下发flow mod消息 protected ITopologyService topology;// 用来将自身注册成拓扑信息变化的监听器 protected JedisPool jedisPool;// 用来初始化jedis客户端 |
然后在getModuleDependencies()函数中添加我们依赖的其他模块的服务
@Override public Collection<Class<? extends IFloodlightService>> getModuleDependencies() { Collection<Class<? extends IFloodlightService>> l = new ArrayList<Class<? extends IFloodlightService>>(); l.add(IFloodlightProviderService.class); l.add(IOFSwitchService.class); l.add(ITopologyService.class); return l; } |
在init()函数中初始化这些服务和对象
@Override public void init(FloodlightModuleContext context) throws FloodlightModuleException { floodlightProvider = context.getServiceImpl(IFloodlightProviderService.class); switchService = context.getServiceImpl(IOFSwitchService.class); topology = context.getServiceImpl(ITopologyService.class); // 初始化messageDamper 给switch下发flowmod messageDamper = new OFMessageDamper(OFMESSAGE_DAMPER_CAPACITY, EnumSet.of(OFType.FLOW_MOD), OFMESSAGE_DAMPER_TIMEOUT); try { jedisPool = initJedisPool(); } catch (IOException e) { e.printStackTrace(); } } |
其中initJedisPool()的具体逻辑如下,我将redis相关的配置信息直接写到了floodlightdefault.properties中了,initJedisPool()函数主要就是读取配置信息并初始化JedisPool,在floodlightdefault.properties中添加的内容如下,首先是redis服务器也就是刚才我们创建的redis的docker容器的ip地址和端口号,后边是访问redis的超时时间和空闲时间等信息。
# redis redis.host=172.17.0.2 #默认情况下开启的第一个docker容器的ip地址是这个 redis.port=6379 #redis服务器的默认端口号 redis.timeout=10 redis.poolMaxTotal=1000 redis.poolMaxIdle=500 redis.poolMaxWait=500 |
那么initJedisPool()就是读取这些配置信息并初始化JedisPool
private JedisPool initJedisPool() throws IOException { // 初始化JedisPoolConfig CmdLineSettings cmdLineSettings = new CmdLineSettings(); File confFile = new File(cmdLineSettings.getModuleFile()); FileInputStream fis = null; fis = new FileInputStream(confFile); if (fis != null) { Properties fprop = new Properties(); fprop.load(fis); String host = fprop.getProperty("redis.host"); String port = fprop.getProperty("redis.port"); String timeout = fprop.getProperty("redis.timeout"); String poolMaxTotal = fprop.getProperty("redis.poolMaxTotal"); String poolMaxIdle = fprop.getProperty("redis.poolMaxIdle"); String poolMaxWait = fprop.getProperty("redis.poolMaxWait"); JedisPoolConfig poolConfig = new JedisPoolConfig(); poolConfig.setMaxIdle(Integer.parseInt(poolMaxIdle)); poolConfig.setMaxTotal(Integer.parseInt(poolMaxTotal)); poolConfig.setMaxWaitMillis(Integer.parseInt(poolMaxWait) * 1000); JedisPool jp = new JedisPool(poolConfig, host, Integer.parseInt(port), Integer.parseInt(timeout) * 1000); fis.close(); return jp; } fis.close(); return null; } |
初始化完成后我们需要实现的IRedisService接口的三个函数就可以通过jedisPool来实现了,这里举出来set函数实现的例子
@Override public boolean set(FlowKey key, List<NodePortTuple> path) { Jedis jedis = null; try { jedis = jedisPool.getResource(); // 通过jedisPool获取jedis对象 String realKey = key.getKey();// 将FlowKey转化成字符串作为key jedis.set(realKey, listToString(path)); // 直接调用jedis的set方法来存值 } finally { returnToPool(jedis); } return false; } |
其中listToString是一个将List<NodePortTuple>转化成json字符串的函数,转化的方法很多,大家可以使用Google的gson或者阿里的fastjson就可以实现
<dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.8.5</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.38</version> </dependency> |
实现了IRedisService接口再来看看如何实现IOFMessageListener接口,首先最关键的,要让缓存起作用必须得让我们实现的这个模块在forwarding模块之间处理packet-in消息如果缓存中存了相关的转发路径信息就不需要forwarding模块处理了,在floodlight中所有的packet-in消息的监听者会存储在一个List中,控制器收到的packet-in消息又这些监听者轮流处理,具体的逻辑可以在Contoller类中看到
for (IOFMessageListener listener : listeners) { pktinProcTimeService.recordStartTimeComp(listener); cmd = listener.receive(sw, m, bc); pktinProcTimeService.recordEndTimeComp(listener); if (Command.STOP.equals(cmd)) { break; } } |
因此我们需要实现的这个模块在这个listeners中要先于forwarding模块处理,通过isCallbackOrderingPostreq()函数就可以实现
@Override public boolean isCallbackOrderingPostreq(OFType type, String name) { return name.equals("forwarding");// 让redis模块处于forwarding之前来处理这个packet in消息 } |
然后我们来看看receive方法如何实现
@Override public Command receive(IOFSwitch sw, OFMessage msg, FloodlightContext cntx) { Ethernet eth = IFloodlightProviderService.bcStore.get(cntx, IFloodlightProviderService.CONTEXT_PI_PAYLOAD); MacAddress srcMac = null; MacAddress dstMac = null; IPv4Address srcIp = null; IPv4Address dstIp = null; switch (msg.getType()) { case PACKET_IN: // 处理packet in消息 // 构建FlowKey srcMac = eth.getSourceMACAddress(); dstMac = eth.getDestinationMACAddress(); if (eth.getEtherType() == EthType.IPv4) { IPv4 ip = (IPv4) eth.getPayload(); srcIp = ip.getSourceAddress(); dstIp = ip.getDestinationAddress(); } FlowKey key = new FlowKey(srcIp, dstIp, srcMac, dstMac);
// 从缓存中取出路径消息 List<NodePortTuple> path = get(key); if (path != null) { // 如果缓存中存在路径信息就直接下发流表 并停止对这个packet in消息向后续的监听者转发 sendFlowMod(key, path); log.info("Send flowMod messages by using Redis"); return Command.STOP; } else { // 否则不做处理交给forwarding模块来处理 return Command.CONTINUE; } default: break; } return Command.CONTINUE; } |
总体上来说,这个模块收到packet-in消息后会根据消息中的源目的主机的信息构建出来key,并查询redis中是否缓存了相应的路径信息如果有的话直接调用sendFlowMod()方法根据缓存的路径下发相应flow mod消息并且要返回Command.STOP,让控制器不再把这个消息再转发给forwarding模块,如果没有缓存的话就返回Command.CONTINUE,由forwarding模块继续处理。
最后我们需要实现ITopologyListener接口,这个接口只有一个函数需要实现,也就是当拓扑发生变化的时候,要清空redis中的缓存
@Override public void topologyChanged(List<LDUpdate> linkUpdates) { log.info("Topology is changed, flush all values"); // 当拓扑发生变化的时候 清空缓存的flowmod消息 delete(); } |
这样我们就实现了和redis的集成,最后记得在floodlightdefault.properties和net.floodlightcontroller.core.module.IFloodlightModule中添加我们写的Redis模块。
三、将转发路径写入缓存
实现了和redis的集成以后,我们需要将forwarding模块产生的路径信息写入到redis缓存中,因此我们需要定位forwarding模块是在哪里下发流表的,然后在下发流表的逻辑处添加将路径信息写入缓存的逻辑就实现了路径信息的缓存功能。
打开forwarding模块中的Forwarding类,发现他继承了ForwardingBase类,ForwardingBase是一个抽象类实现了IOFMessageListener,用来监听packet-in消息,查看他的receive()方法,发现他调用的是processPacketInMessage()方法,而这个方法是一个抽象的方法,Forwarding类就实现了这个方法,然后我们来看这个方法具体是如何实现的,这里只贴出来主要的代码逻辑
@Override public Command processPacketInMessage(IOFSwitch sw, OFPacketIn pi, IRoutingDecision decision, FloodlightContext cntx) { ...... switch(decision.getRoutingAction()) { ...... case FORWARD: doL2ForwardFlow(sw, pi, decision, cntx, false); return Command.CONTINUE; ...... } } else { // No routing decision was found switch(determineRoutingType()) { case FORWARDING: ...... doL2Forwarding(eth, sw, pi, decision, cntx); break; case ROUTING: ...... doL3Routing(eth, sw, pi, decision, cntx, instance.get(), inPort); break; }
} return Command.CONTINUE; }
|
我们可以看到主要是根据IRoutingDecision来决定如何处理packet-in消息的,我们所要缓存的流表都是主机之间通信的流表,因此我们只看FORWARD、FORWARDING、ROUTING这三种case,其他的case都是洪泛或者丢弃的情况,我们发现这三种case下分别调用doL2ForwardFlow()、doL2Forwarding()、doL3Routing()方法,在查看这三个方法,就可以发现除去洪范的情况,它们最后都会调用pushRoute()方法,因此我们就可以定位到下发流表的逻辑就在这个函数中,打开这个函数,我们发现这个函数的主要逻辑是根据传入的Path参数获取一个List,而这个List存放的是NodePortTuple对象,也就是交换机的DPID和端口端ID二元组,这也就是我们需要找的路径消息,因此在这个方法中添加如下逻辑
public boolean pushRoute(Path route, Match match, OFPacketIn pi, DatapathId pinSwitch, U64 cookie, FloodlightContext cntx, boolean requestFlowRemovedNotification, OFFlowModCommand flowModCommand, boolean packetOutSent) { List<NodePortTuple> switchPortList = route.getPath(); // 加入redis缓存 IPv4Address srcIP = match.get(MatchField.IPV4_SRC); IPv4Address dstIP = match.get(MatchField.IPV4_DST); MacAddress srcMac = match.get(MatchField.ETH_SRC); MacAddress dstMac = match.get(MatchField.ETH_DST); FlowKey flowKey = new FlowKey(srcIP, dstIP, srcMac, dstMac); redisService.set(flowKey, switchPortList);
for (int indx = switchPortList.size() - 1; indx > 0; indx -= 2) { ......// 循环路径中的每一个交换机并下发流表 } return true; } |
也就是根据Match获取到这些流表的需要实现哪两个主机之间的通信,然后生成相应的key,并将路径信息作为value存入到redis服务器中,而这个方法位于ForwardingBase类中,因此我们需要在这个类中引入我们实现的Redis模块的IRedisService服务接口,这样我们就实现了路径信息写入缓存的操作。
四、实验对比
那么这么做究竟能不能提高转发的效率呢?我们做实验来看一下,首先开启redis的容器,根据容器的ip地址和redis的端口号配置floodlightdefault.properties中的redis配置信息,并开启控制器,然后使用如下命令在mininet中创建一个线性的拓扑结构
sudo mn --controller=remote,ip=127.0.0.1,port=6653 --topo linear,2 |
在这个拓扑中主机h1和主机h2之间存在两个交换机,输入h1 ping h2命令,让forwarding模块下发相应的流表,并通过在pushRoute()函数中添加的将路径信息存入redis代码逻辑将路径信息存入到redis服务器中,然后在mininet中输入dpctl dump-flows查看交换机的流表信息,发现forwarding模块已经下发了流表(通过查看forwarding的源代码发现,forwarding下发的流表的cookie值均为0x20开头的)
此时在redis容器中的redis客户端中输入keys *命令查看已经缓存的键值对,发现已经将路径信息缓存
输入get 10.0.0.2:10.0.0.1:da:0f:bb:30:c2:5a:96:c7:5d:54:dc:10 命令发现确实缓存了从h2主机到h1主机之间的转发路径信息
等待交换机中的流表过期以后,再次输入h1 ping h2,然后输入dpctl dump-flows命令,发现交换机上同样存在流表,这个流表就是Redis模块下发的(为了区分,我将Redis模块下发的流表的cookie值设置为0x63开头)
这说明我们确实通过缓存实现了对交换机的流表下发,再来对比一下转发的效率,在没有使用redis缓存的情况下,使用h1 ping h2命令,在下发完流表之后,等待首次通信所产生的流表超时后(首次ping通消耗的时间更长,因为控制器需要学习两个主机的位置),再次使用h1 ping h2命令,并记录ping通的时间,也就是从产生通信请求开始到控制器完成流表下发的实践(这里,我们用第二次ping的结果作为交换机重复请求控制器下发流表的时延,严格来说应该多次重复取平均值,但是发现对比的差距还是挺明显的所以就用第二次ping的结果来对比,产生对比效果即可)
我们发现即便是重复请求,完成流表的下发还是需要7.55ms的时间,然后我们开启redis缓存,执行同样的操作,记录第二次ping通的时间
对比发现,这次的时间只有0.311ms,可见还是有很明显的提高的,然后再对比一下两个主机之间存在1个交换机、3个交换机、4个交换机、5个交换机的情况,结果如下
两个主机之间交换机的个数 |
不使用redis的时间消耗 |
使用redis的时间消耗 |
1 |
6.33ms |
0.176ms |
2 |
7.55ms |
0.311ms |
3 |
5.49ms |
0.340ms |
4 |
7.35ms |
0.495ms |
5 |
13.4ms |
0.346ms |
可以看到效率的提升还是很明显的,最后附上源码地址:https://github.com/ZhiYiFang/combie-redis-with-floodlight