最近在处理 Kubernetes 工作的时候,被问及这样一个命题:Pod 能对 CPU 和内存施加限制,那同样属于资源范畴的网络带宽是否能限制呢?使用 Kubernetes 的一个核心优势在于每个 Pod 都等同于一个轻量级的“操作系统”。建立在 Linux 命名空间(namespace)和控制组(cgroups)基础上的容器技术将每个 Pod 的资源进行了隔离和限制。但是,限流只针对 CPU 和内存,对网络、磁盘 IO 的解决方案仅仅局限在隔离,难道技术上实现不了吗?自然不是,Kubernetes 有意识地将网络模块拆解,只定义插件规范,而将实现的可能性交由下游开发自由决策。当然,本篇并不在意 Kubernetes 网络限流的解决方案,只是以此作为引子。

流量控制(Traffic Control, TC) 是 Linux 内核提供的流量限速、整形、策略控制机制。近乎完美地支持网络限流的命题,除了,这是比 Netfilter 更难理解的模块。Netfilter 作用在内核网络协议栈上,通过在各个枢纽设立关卡对网络包(sk_buff 数据结构, skb)进行检查,并实施 ACCEPT、DROP、MASQUERADE 等策略。相比之下,TC 是绑定在网络设备上实施的。提供 enqueue, dequeue 两个核心函数,也是作为关卡对到达网络设备的网络包实施策略。要说核心的不同之处,Netfilter 是流式地处理网络包,先到的网络包一定先出(也可能是被丢弃);TC 的处理方式就依照策略,类比块设备的随机读/随机写了。

前置知识:发送网络包流程

此处提供一个内核网络包的发送流程,辅助了解网络协议栈。跳过不影响后文阅读

网络包的发送由应用层通过系统调用 sendmsg (类似的系统调用还有 send, sendto 等) 发起,后陷入内核态,由内核代码实现网络协议栈逐层封装,逐层下发的工作。

 0)               |              sock_sendmsg() {       /* 系统调用 sendmsg 对应的内核函数 */
 0)               |                inet_sendmsg() {     /* 使用 IPv4 (not IPv6) 实现的 sendmsg */
 0)               |                  tcp_sendmsg() {    /* 传输层,TCP 协议的处理 */
 0)               |                      sk_stream_alloc_skb() {    /* 创建 sk_buff(skb) 数据结构,网络包在内核中存在的形式 */
 0)               |                        __alloc_skb() {
 0)   5.273 us    |                        }
 0)   0.056 us    |                        sk_forced_mem_schedule();
 0)   6.026 us    |                      }
 0)               |                      tcp_push() {
 0)               |                        __tcp_push_pending_frames() {
 0)               |                          tcp_write_xmit() {
 0)   0.132 us    |                            tcp_tso_segs();
 0)   0.058 us    |                            tcp_init_tso_segs();
 0)               |                            tcp_transmit_skb() { /* 下发 skb ,交由网络层继续 */
 0)   0.046 us    |                              skb_push();
 0)               |                              ip_queue_xmit() {  /* 网络层,IP 协议的处理 */
 0)               |                                __sk_dst_check() {
 0)   0.069 us    |                                  ipv4_dst_check();
 0)   0.429 us    |                                }
 0)   0.049 us    |                                skb_push();
 0)               |                                  ip_output() {
 0)               |                                    ip_finish_output() {
 0)               |                                      ip_finish_output2() {
 0)   0.044 us    |                                        skb_push();
 0)               |                                        dev_queue_xmit() {   /* 数据链路层,由网卡设备(内核根据物理设备抽象出来的概念)继续处理 */
 0)               |                                          __dev_queue_xmit() {
 0)   0.151 us    |                                            netdev_pick_tx();
 0)   0.046 us    |                                            _raw_spin_lock();
 0)               |                                            /* some important things omitted */
 0) + 39.674 us   |                                          }
 0) + 40.053 us   |                                        }
 0) + 41.656 us   |                                      }
 0) + 43.041 us   |                                    }
 0) + 47.121 us   |                                  }
 0) + 68.614 us   |                                }
 0) + 70.558 us   |                              }
 0) + 75.995 us   |                            }
 0) + 85.718 us   |                          }
 0) + 86.418 us   |                        }
 0) + 87.275 us   |                      }
 0) ! 101.636 us  |                    }
 0) ! 105.024 us  |                  }
 0) ! 105.516 us  |                }
 0) ! 107.861 us  |              }
 0) ! 108.230 us  |            }
 0) ! 108.653 us  |          }
 0) ! 109.066 us  |        }
 0) ! 112.023 us  |      }
 0) ! 113.078 us  |    }
 0) ! 113.374 us  |  }

本篇核心关注流量控制,就不细数网络协议栈了。待到网络包(skb)到达数据链路层,dev_queue_xmit 标志着 skb 终于交付给网络设备,下面就是 TC 策略的运转了。

Qdisc (queueing discipline) ,是整个 TC 的基本模型。所有需要通过网卡接口发送的数据包,都会进入接口绑定的 Qdisc 等待队列(enqueue)。再由 ksoftirq 内核线程读取接口 Qdisc 中的数据包(dequeue),尽最大能力发送出去。

Traffic Control 原理

对于 TC 核心的概念——队列规程,类别,过滤器,网上充斥着大量的资料,由于没有自信讲得更加通透,附上链接一枚。

https://blog.csdn.net/dog250/article/details/40483627

Traffic Control 实现

数据结构

遵循 TC Qdisc 的生命周期,首先要介绍的是 Qdisc 的创建流程。不过在此之前,看了解下相关的数据结构。

struct net_device
{
    /* 设备的发送队列列表 (这是数组的头指针)*/
    struct netdev_queue *_tx;

    /* 发送队列的个数 */
    unsigned int num_tx_queues;

    /* 使用中的发送队列个数 */
    unsigned int real_num_tx_queues;

    /* 默认的队列策略 */
    struct Qdisc *qdisc;

    /* 每个发送队列的最大长度 */
    unsigned long tx_queue_len;

    /* 发送队列的全局锁 */
    spinlock_t tx_global_lock;
}

struct netdev_queue {
    /* 所属网络设备 */
    struct net_device *dev;
    /* 队列相应的队列策略 */
    struct Qdisc *qdisc;
    struct Qdisc *qdisc_sleeping;
    spinlock_t _xmit_lock;
    int            xmit_lock_owner;
    unsigned long        trans_start;
    unsigned long        trans_timeout;
    /* 队列状态 */
    unsigned long        state;
};

#define TCQ_F_BUILTIN        1
#define TCQ_F_INGRESS        2
#define TCQ_F_CAN_BYPASS    4
#define TCQ_F_MQROOT        8
#define TCQ_F_ONETXQUEUE    0x10
#define TCQ_F_WARN_NONWC    (1 << 16)

struct Qdisc {
    /* skb 入队函数 */
    int (*enqueue)(struct sk_buff *skb, struct Qdisc *dev);
    /* skb 出队函数 */
    struct sk_buff * (*dequeue)(struct Qdisc *dev);
    unsigned int flags;
    struct list_head    list;
    int padded;
    /* 队列策略函数集 */
    const struct Qdisc_ops *ops;
    int            (*reshape_fail)(struct sk_buff *skb,
                    struct Qdisc *q);
    /* 所属设备队列 */
    struct netdev_queue *dev_queue;
    struct Qdisc *next_sched;

    /* 队列策略的状态 */
    unsigned long state;
    struct sk_buff_head q;
    u32 limit;
};

网络设备初始化与 Qdisc

TC 的实施与网络设备密切相关。跳过设备的注册和创建流程,设备状态由 state DOWN 切换到 state UP 标志着设备正式启用

对应到内核流程,调用栈类似:

dev_open
|-- __dev_open
    |-- dev_active
        |-- attach_default_qdiscs

static void attach_default_qdiscs(struct net_device *dev)
{
    struct netdev_queue *txq;
    struct Qdisc *qdisc;

    /* 获得设备的第 0 个 queue */
    txq = netdev_get_tx_queue(dev, 0);

    /* 如果发送队列个数 <= 1 || 发送队列长度 = 0 */
    if (!netif_is_multiqueue(dev) || dev->tx_queue_len == 0) {
        /* 单队列的流量控制 */
        netdev_for_each_tx_queue(dev, attach_one_default_qdisc, NULL);
        dev->qdisc = txq->qdisc_sleeping;
        atomic_inc(&dev->qdisc->refcnt);
    } else {
        /* 多队列的流量控制; 此处 mq 指 multiqueue*/
        qdisc = qdisc_create_dflt(txq, &mq_qdisc_ops, TC_H_ROOT);
        if (qdisc) {
            qdisc->ops->attach(qdisc);
            dev->qdisc = qdisc;
        }
    }
}

static void attach_one_default_qdisc(struct net_device *dev,
                     struct netdev_queue *dev_queue,
                     void *_unused)
{
    /* noqueue 默认的,无队列控制实现,一般应用在虚拟设备上 */
    struct Qdisc *qdisc = &noqueue_qdisc;

    /* 如果有发送队列长度限制 (默认应用在物理网卡设备上) */
    if (dev->tx_queue_len) {
        /* 创建 快速优先队列 策略的 Qdisc */
        qdisc = qdisc_create_dflt(dev_queue,
                      &pfifo_fast_ops, TC_H_ROOT);
    }
    dev_queue->qdisc_sleeping = qdisc;
}

Qdisc 的生命周期

应用程序发送数据包都将涉及到系统调用,一般来说是 sendmsgsendto 之类的系统调用。上面👆给了内核 sendmsg 的函数调用链路。很长,涉及的操作相当多。但是,这还仅仅是由 CPU 执行的一部分流程。试想,还有内存到网卡设备的数据交互。这里等待的时间将更加漫长,更不要说像是网络重试之类的实现了。内核究竟做了怎样的实现呢?在这篇网络中,描述了接收网络数据时的三阶段流程。通过接收队列作为中介,recv 系统调用需要做的只是去检测接收队列是否有数据包,其它流程全部交由内核线程和网络硬中断完成。类似的,发送数据包 sendmsg 这类系统调用,也只是将数据包提交到一个等待队列,再由内核线程和中断实施尽力发送。这里的等待队列,就是网络设备绑定的 Qdisc。

先别管 Qdisc 是怎么实现的,它提供了两个核心函数 enqueue, dequeue ,完全符合定义一个队列的基本要求。

额外地,一些函数 peek, reset 等,作为这个黑盒的辅助函数,在个别情况下进行使用。

  • peek: 类似 dequeue,但获取的网络包仍然保留在队列中

  • reset: 将 Qdisc 重置到初始状态

  • init: 初始化一个新创建的 Qdisc

  • destroy: 销毁一个 Qdisc 生命周期中所使用的资源

  • change: 修改 Qdisc 的参数

Qdisc 的整个生命周期,最初和最末分别是向内核注册一种新的 Qdisc 和取消注册一种 Qdisc

/* from net/sched/sch_api.c */
int register_qdisc(struct Qdisc_ops *qops)
int unregister_qdisc(struct Qdisc_ops *qops)

再就是创建一个 Qdisc 实例绑定到网络设备,以及从网络设备上解绑 Qdisc 实例

static struct Qdisc *
qdisc_create(struct net_device *dev, struct netdev_queue *dev_queue,
	     struct Qdisc *p, u32 parent, u32 handle,
	     struct nlattr **tca, int *errp)

// n->nlmsg_type == RTM_DELQDISC
static int tc_get_qdisc(struct sk_buff *skb, struct nlmsghdr *n)

再就是正常的工作流程了,enqueue, dequeue 以及其它辅助函数的使用。

Qdisc 执行流

noop 是 Qdisc 最简单,最无赖的一种实现,对于所有的数据包直接抛弃。当然,这种实现正常情况下只会出现在网络设备状态为 DOWN 的时候。

static int noop_enqueue(struct sk_buff *skb, struct Qdisc * qdisc)
{
    kfree_skb(skb);
    return NET_XMIT_CN;
}

static struct sk_buff *noop_dequeue(struct Qdisc * qdisc)
{
    return NULL;
}

pfifo_fast 是普遍使用一种 Qdisc 实现,根据数据包 Tos 将网络包划分到三个通道,进入第0通道的网络包有 dequeue 的最高优先级。

static int pfifo_fast_enqueue(struct sk_buff *skb, struct Qdisc *qdisc)
{
    // 检测 Qdisc 队列数据包数量是否达到 dev 预定的最大值
    if (skb_queue_len(&qdisc->q) < qdisc_dev(qdisc)->tx_queue_len) {
        // 确定数据包需要进入哪个通道
        int band = prio2band[skb->priority & TC_PRIO_MAX];
        struct pfifo_fast_priv *priv = qdisc_priv(qdisc);
        // 获取通道列表的 head
        struct sk_buff_head *list = band2list(priv, band);

        priv->bitmap |= (1 << band);
        qdisc->q.qlen++;
        // 添加到通道队尾
        return __qdisc_enqueue_tail(skb, qdisc, list);
    }

    return qdisc_drop(skb, qdisc);
}

static struct sk_buff *pfifo_fast_dequeue(struct Qdisc *qdisc)
{
    struct pfifo_fast_priv *priv = qdisc_priv(qdisc);
    int band = bitmap2band[priv->bitmap];

    if (likely(band >= 0)) {
        struct sk_buff_head *list = band2list(priv, band);
        struct sk_buff *skb = __qdisc_dequeue_head(qdisc, list);

        qdisc->q.qlen--;
        if (skb_queue_empty(list))
            priv->bitmap &= ~(1 << band);

        return skb;
    }

    return NULL;
}

至于类别队列,首先也还是 Qdisc,符合它的所有要素,只是在 Qdisc_ops 中加入了分类的流程。例如 htb 的实现中,额外地加入了 class_ops,对关于分类操作的方法做了相关的实现。

static struct Qdisc_ops htb_qdisc_ops __read_mostly = {
    .cl_ops    =    &htb_class_ops,
    .id        =    "htb",
    .priv_size =    sizeof(struct htb_sched),
    .enqueue   =    htb_enqueue,
    .dequeue   =    htb_dequeue,
    .peek      =    qdisc_peek_dequeued,
    .drop      =    htb_drop,
    .init      =    htb_init,
    .reset     =    htb_reset,
    .destroy   =    htb_destroy,
    .dump      =    htb_dump,
    .owner     =    THIS_MODULE,
};
static const struct Qdisc_class_ops htb_class_ops = {
    .graft       =    htb_graft,
    .leaf        =    htb_leaf,
    .qlen_notify =    htb_qlen_notify,
    .get         =    htb_get,
    .put         =    htb_put,
    .change      =    htb_change_class,
    .delete      =    htb_delete,
    .walk        =    htb_walk,
    .tcf_chain   =    htb_find_tcf,
    .bind_tcf    =    htb_bind_filter,
    .unbind_tcf  =    htb_unbind_filter,
    .dump        =    htb_dump_class,
    .dump_stats  =    htb_dump_class_stats,
}