Linux虚拟网络设备之tun-tap

虚拟设备和物理设备的区别

在Linux网络数据包的接收过程和数据包的发送过程这两篇文章中,介绍了数据包的收发流程,知道了Linux内核中有一个网络设备管理层,处于网络设备驱动和协议栈之间,负责衔接它们之间的数据交互。驱动不需要了解协议栈的细节,协议栈也不需要了解设备驱动的细节。

对于一个网络设备来说,就像一个管道(pipe)一样,有两端,从其中任意一端收到的数据将从另一端发送出去。

比如一个物理网卡eth0,它的两端分别是内核协议栈(通过内核网络设备管理模块间接的通信)和外面的物理网络,从物理网络收到的数据,会转发给内核协议栈,而应用程序从协议栈发过来的数据将会通过物理网络发送出去。

那么对于一个虚拟网络设备呢?首先它也归内核的网络设备管理子系统管理,对于Linux内核网络设备管理模块来说,虚拟设备和物理设备没有区别,都是网络设备,都能配置IP,从网络设备来的数据,都会转发给协议栈,协议栈过来的数据,也会交由网络设备发送出去,至于是怎么发送出去的,发到哪里去,那是设备驱动的事情,跟Linux内核就没关系了,所以说虚拟网络设备的一端也是协议栈,而另一端是什么取决于虚拟网络设备的驱动实现。

tun/tap的另一端是什么?

上图中有两个应用程序A和B,都在用户层,而其它的socket、协议栈(Newwork Protocol Stack)和网络设备(eth0和tun0)部分都在内核层,其实socket是协议栈的一部分,这里分开来的目的是为了看的更直观。

tun0是一个Tun/Tap虚拟设备,从上图中可以看出它和物理设备eth0的差别,它们的一端虽然都连着协议栈,但另一端不一样,eth0的另一端是物理网络,这个物理网络可能就是一个交换机,而tun0的另一端是一个用户层的程序,协议栈发给tun0的数据包能被这个应用程序读取到,并且应用程序能直接向tun0写数据。

这里假设eth0配置的IP是10.32.0.11,而tun0配置的IP是192.168.3.11.

这里列举的是一个典型的tun/tap设备的应用场景,发到192.168.3.0/24网络的数据通过程序B这个隧道,利用10.32.0.11发到远端网络的10.33.0.1,再由10.33.0.1转发给相应的设备,从而实现VPN。

下面来看看数据包的流程:

  • 应用程序A是一个普通的程序,通过socket A发送了一个数据包,假设这个数据包的目的IP地址是192.168.3.1。

  • socket将这个数据包丢给协议栈。

  • 协议栈根据数据包的目的IP地址,匹配本地路由规则,知道这个数据包应该由tun0出去,于是将数据包交给tun0。

  • tun0收到数据包之后,发现另一端被进程B打开了,于是将数据包丢给了进程B。

  • 进程B收到数据包之后,做一些跟业务相关的处理,然后构造一个新的数据包,将原来的数据包嵌入在新的数据包中,最后通过socket B将数据包转发出去,这时候新数据包的源地址变成了eth0的地址,而目的IP地址变成了一个其它的地址,比如是10.33.0.1。

  • socket B将数据包丢给协议栈。

  • 协议栈根据本地路由,发现这个数据包应该要通过eth0发送出去,于是将数据包交给eth0。

  • eth0通过物理网络将数据包发送出去。

  • 10.33.0.1收到数据包之后,会打开数据包,读取里面的原始数据包,并转发给本地的192.168.3.1,然后等收到192.168.3.1的应答后,再构造新的应答包,并将原始应答包封装在里面,再由原路径返回给应用程序B,应用程序B取出里面的原始应答包,最后返回给应用程序A。

这里不讨论Tun/Tap设备tun0是怎么和用户层的进程B进行通信的,对于Linux内核来说,有很多种办法来让内核空间和用户空间的进程交换数据。

从上面的流程中可以看出,数据包选择走哪个网络设备完全由路由表控制,所以如果我们想让某些网络流量走应用程序B的转发流程,就需要配置路由表让这部分数据走tun0。

tun/tap设备有什么用?

从上面介绍过的流程可以看出来,tun/tap设备的用处是将协议栈中的部分数据包转发给用户空间的应用程序,给用户空间的程序一个处理数据包的机会。于是比较常用的数据压缩,加密等功能就可以在应用程序B里面做进去,tun/tap设备最常用的场景是VPN,包括tunnel以及应用层的IPSec等。

tun和tap的区别

用户层程序通过tun设备只能读写IP数据包,而通过tap设备能读写链路层数据包,类似于普通socket和raw socket的差别一样,处理数据包的格式不一样。

示例程序

#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <linux/if_tun.h>
#include<stdlib.h>
#include<stdio.h>

int tun_alloc(int flags)
{

    struct ifreq ifr;
    int fd, err;
    char *clonedev = "/dev/net/tun";

    if ((fd = open(clonedev, O_RDWR)) < 0) {
        return fd;
    }

    memset(&ifr, 0, sizeof(ifr));
    ifr.ifr_flags = flags;

    if ((err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0) {
        close(fd);
        return err;
    }

    printf("Open tun/tap device: %s for reading...\n", ifr.ifr_name);

    return fd;
}

int main()
{

    int tun_fd, nread;
    char buffer[1500];

    /* Flags: IFF_TUN   - TUN device (no Ethernet headers)
     *        IFF_TAP   - TAP device
     *        IFF_NO_PI - Do not provide packet information
     */
    tun_fd = tun_alloc(IFF_TUN | IFF_NO_PI);

    if (tun_fd < 0) {
        perror("Allocating interface");
        exit(1);
    }

    while (1) {
        nread = read(tun_fd, buffer, sizeof(buffer));
        if (nread < 0) {
            perror("Reading from interface");
            close(tun_fd);
            exit(1);
        }

        printf("Read %d bytes from tun/tap device\n", nread);
    }
    return 0;
}
#--------------------------第一个shell窗口----------------------
#将上面的程序保存成tun.c,然后编译
dev@debian:~$ gcc tun.c -o tun

#启动tun程序,程序会创建一个新的tun设备,
#程序会阻塞在这里,等着数据包过来
dev@debian:~$ sudo ./tun
Open tun/tap device tun1 for reading...
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device

#--------------------------第二个shell窗口----------------------
#启动抓包程序,抓经过tun1的包
# tcpdump -i tun1
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tun1, link-type RAW (Raw IP), capture size 262144 bytes
19:57:13.473101 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 1, length 64
19:57:14.480362 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 2, length 64
19:57:15.488246 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 3, length 64
19:57:16.496241 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 4, length 64

#--------------------------第三个shell窗口----------------------
#./tun启动之后,通过ip link命令就会发现系统多了一个tun设备,
#在我的测试环境中,多出来的设备名称叫tun1,在你的环境中可能叫tun0
#新的设备没有ip,我们先给tun1配上IP地址
dev@debian:~$ sudo ip addr add 192.168.3.11/24 dev tun1

#默认情况下,tun1没有起来,用下面的命令将tun1启动起来
dev@debian:~$ sudo ip link set tun1 up

#尝试ping一下192.168.3.0/24网段的IP,
#根据默认路由,该数据包会走tun1设备,
#由于我们的程序中收到数据包后,啥都没干,相当于把数据包丢弃了,
#所以这里的ping根本收不到返回包,
#但在前两个窗口中可以看到这里发出去的四个icmp echo请求包,
#说明数据包正确的发送到了应用程序里面,只是应用程序没有处理该包
dev@debian:~$ ping -c 4 192.168.3.12
PING 192.168.3.12 (192.168.3.12) 56(84) bytes of data.

--- 192.168.3.12 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3023ms

平时我们用到tun/tap设备的机会不多,不过由于其结构比较简单,拿它来了解一下虚拟网络设备还不错,为后续理解Linux下更复杂的虚拟网络设备(比如网桥)做个铺垫。

阅读全文

Linux网络-数据包的接收过程

本文将介绍在Linux系统中,数据包是如何一步一步从网卡传到进程手中的。

本文只讨论以太网的物理网卡,不涉及虚拟设备,并且以一个UDP包的接收过程作为示例。

本文基于其他博文总结、翻译、梳理

网卡到内存

网卡需要有驱动才能工作,驱动是加载到内核中的模块。Linux设计了网络设备子系统(网络模块)负责衔接网卡驱动和内核,驱动在加载的时候将自己注册进网络模块,当相应的网卡收到数据包时,网络模块会调用相应的驱动函数处理数据。

下图展示了数据包(packet)如何进入内存,并被内核的网络模块开始处理:

  1. 数据包从外面的网络进入物理网卡。

  2. 网卡将数据包通过DMA的方式写入到指定的内存地址,该地址由网卡驱动初始化时分配,在内存中的数据结构便是经常提到的ring buffer。

  3. 网卡通过硬件中断(Interrupt Request IRQ)通知CPU,告诉它有数据来了。

  4. CPU调用已经注册的中断函数,这个中断函数在启动网卡(ifconfig eth0 up)的时候由网卡驱动注册。

  5. 中断函数首先更新网卡中特定的寄存器,跟踪中断到达的频率,启动"中断节流",关闭对cpu的中断。在关闭中断的同时会启动对NAPI子系统的调度工作。NAPI子系统是一种机制来收集数据帧(从ring buffer拿数据),在网卡驱动初始化时启用但是处于非工作状态,在数据到来后调度至工作状态,没有更多数据处理时再次到非工作状态,进入非工作状态的同时重新打开网卡中断。

  6. 启动软中断,这步结束后,硬件中断处理函数就结束返回了。由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致CPU没法响应其它硬件的中断,于是内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理。

原译者没有讨论NAPI相关的内容,所以图中没有体现NAPI。

内核的网络模块

  1. 内核中的ksoftirqd进程专门负责软中断的处理,当它收到软中断后,就会调用相应软中断所对应的处理函数,对于上面第6步中是网卡驱动模块抛出的软中断,ksoftirqd会调用网络模块的net_rx_action函数。

  2. net_rx_action调用网卡驱动注册的poll函数来一个一个的处理数据包,在pool函数中驱动会一个接一个的读取网卡写到内存中的数据包,然后合并成内核网络栈要的skb格式,然后调用napi_gro_receive函数。值得注意的是,从硬中断到到poll函数,都会是在同一个cpu核中处理,多队列网卡一个队列对应一个cpu核。

  3. napi_gro_receive会处理GRO(Generic Receive Offloading)相关的内容,GRO是一种优化技术,优化将可以合并的包进行合并,这样在交付给上层协议栈的时候,协议栈只需处理一个相对大的数据包的一个包头,而不是多个小数据包多个包头,降低cpu使用。

  4. napi_gro_receive最后调用netif_receive_skb,netif_receive_skb负责将数据包交付上层协议栈。netif_receive_skb中有两个路径,一个启用了RPS,一个没有启用RPS。RPS(Receive Packet Steering)是一种向上层协议栈交付包的优化。在前面net_rx_action中无法把处理包的过程分散到多个cpu,但是交付协议栈的过程可以使用RPS进行分散,使用更多核进行包协议栈交付。

  5. 如果没有开启RPS,netif_receive_skb直接调用__netif_receive_skb_core,进入协议栈流程。

  6. 如果开启了RPS,netif_receive_skb会调用enqueue_to_backlog,最后skb放入对应cpu核的input_pkt_queue。input_pkt_queue满了的话,该数据包将会被丢弃,queue的大小可以通过net.core.netdev_max_backlog来配置。这里还可以配置对skb的流控,流控达到了的话,数据包也会被丢弃,默认流控是不开启的。每个核对queue会有一个poller过程,最后调用__netif_receive_skb_core,进入协议栈流程。

  7. 在进协议栈之前会检查下,是否有捕获包的packet taps,如果有则拷贝一份给对应的tap(比如是不是有AF_PACKET类型的socket也就是我们常说的原始套接字,tcpdump抓包就是抓的这里的包),再送入协议栈。

原译者没有讨论netif_receive_skb过程,所以图中没有体现netif_receive_skb流程。

IP层

由于是UDP包,所以第一步会进入IP层,然后一级一级的函数往下调:

  • ip_rcv: ip_rcv函数是IP模块的入口函数,在该函数里调用netfilter注册在NF_INET_PRE_ROUTING的函数修改或者丢弃数据包,如果数据包没被丢弃,将继续往下走。
  • routing: 进行路由,如果是目的IP不是本地IP,且没有开启ip forward功能,那么数据包将被丢弃,如果开启了ip forward功能,那将进入ip_forward函数。
  • ip_forward: ip_forward会先调用netfilter注册在NF_INET_FORWARD的相关函数,如果数据包没有被丢弃,那么将继续往后调用dst_output_sk函数。
  • dst_output_sk: 该函数会调用IP层的相应函数将该数据包发送出去。
  • ip_local_deliver:如果上面routing的时候发现目的IP是本地IP,那么将会调用该函数,在该函数中会先调用netfilter注册在NF_INET_LOCAL_IN的相关函数,如果数据包没有被丢弃,数据包将会向下发送到UDP层。

netfilter是在内核协议栈上的防火墙框架,常用的iptables工具便是基于netfilter实现。

UDP层

  • udp_rcv:udp_rcv函数是UDP模块的入口函数,它里面会调用其它的函数,主要是做一些必要的检查,其中一个重要的调用是__udp4_lib_lookup_skb,该函数会根据包信息找对应的socket,如果没有找到相应的socket,那么该数据包将会被丢弃,否则继续。
  • sock_queue_rcv_skb: 主要干了两件事,一是检查这个socket的receive buffer是不是满了,如果满了的话,丢弃该数据包。然后调用在该socket上的sk_filter,sk_filter会执行BPF(Berkeley Packet Filter)过滤逻辑。
  • __skb_queue_tail:将数据包放入socket接收队列。
  • sk_data_ready:通知等待在该socket上的进程。

调用完sk_data_ready之后,一个数据包处理完成,等待应用层程序来读取。IP层到UDP层所有函数的执行过程都在软中断的上下文中。

Socket

应用层一般有两种方式接收数据,一种是recvfrom函数阻塞在那里等着数据来,这种情况下当socket收到通知后,recvfrom就会被唤醒,然后读取接收队列的数据。

另一种是通过epoll或者select监听相应的socket,当收到通知后,再调用recvfrom函数去读取接收队列的数据。

两种情况都能正常的接收到相应的数据包。

相关文章

阅读全文

代理服务器之Treafik

Traefik是纯go实现的一个代理服务器,目前支持代理http、tcp、udp流量代理,http代理功能支持的相对完善。相比nginx c语言实现,traefik纯go实现入门使用和做功能开发容易上手很多,同时traefik目标为云原生场景下的边界网关路由,在服务发现和动态配置方面支持的更好。

快速开始

我们还是从一个例子开始,快速上手traefik,再逐步展开解释其中概念。

Treafik使用go实现,安装只需从官网下载一个对应平台的二进制即可,这里跳过安装过程。

/tmp/tfk $ tree
.
├── conf.yaml
├── sd
│   └── svc.yaml
└── traefik

# /tmp/tfk/conf.yaml

entryPoints:
  web:
    address: :8080

providers:
  file:
    directory: "/tmp/tfk/sd"

# /tmp/tfk/sd/svc.yaml

http:
  # Add the router
  routers:
    router0:
      entryPoints:
        - web
      service: service-foo
      rule: Path(`/`)

  # Add the service
  services:
    service-foo:
      loadBalancer:
        servers:
          - url: http://127.0.0.1:8000

主配置文件conf.yaml定义了traefik的监听entrypoints、基于文件的规则与服务发现providerssvc.yaml定义了路由规则routers和服务组services。Traefik静态加载的是conf.yaml主配置,svc.yaml服务信息修改后可以实时生效。

上面例子,我们定义了一个监听入口web,然后定义了一个路由规则router0,以/为前缀的请求分发到服务组service-foo

特性与概念

  • entrypoints 监听入口。
  • routers 路由规则,一个路由规则可以关联多个entrypoint、middleware,并指定service。
  • middlwares 中间件,在转发的过程中修改请求,类似nginx的rewrite和access控制便可在middleware中实现。
  • services 服务组,类似nginx的upstream,可以指定多个real server并配置负载均衡。
  • providers 配置动态服务发现services、routers。

应用集成

traefik通过providers支持了各种服务发现,我们可以用在各种场景下作为我们的网关入口,尤其是容器部署的无状态服务,服务更新后providers可以动态加载服务地址更新service。

目前支持的provider:

  • Docker
  • Kubernetes
  • Consul
  • KV(etcd、redis)
  • 等等

性能相关

网上已有的benchmark https://github.com/NickMRamirez/Proxy-Benchmarks ,traefik与nginx的性能相比稍逊一点。

基于go原生的goroutine和底层调度相关的io循环思考,这个性能与nginx基于epoll的事件循环原理上是一样的,所以这个性能表现可以理解,需要注意的是traefik每个代理请求会丢给单独的goroutine处理,每个goroutine所占内存大小和nginx一个请求占用内存大小相比成本比较高,所以承载相同的qps traefik使用的内存会多很多。

关于go的io调度与nginx的事件机制,在网络编程文章中再仔细讨论。

阅读全文

2021年度计划

2021年度计划

一季度

  • 已经过了

二季度

技术

  • kubernetes 开发
    • 自定义控制器
    • 部分组件源码
  • 刷完APUE
  • 学会Rust

兴趣

  • 模拟电路
  • 嵌入式开发with raspberry pi.

三季度

  • nginx 开发
    • c/lua模块开发
    • 部分源码阅读

四季度

  • etcd深入
阅读全文