Please enable Javascript to view the contents

Envoy/Istio数据面 Buffer 的设计与流控

 ·  ☕ 8 分钟

image-20250705213536671

和所有网络类型的软件一样,Envoy 很重视 Buffer 相关的设计,因为这关系到性能与资源使用。本文试图说明 Buffer 核心设计以及与之相关的流控设计。

本文摘录自我的书《Envoy Proxy 内幕》 最近的一些更新:Buffer 。这是 Envoy Proxy 的基础核心。Envoy 是一个 Proxy 拥有高负载低延迟要求。设计上必须考虑暂存网络数据 buffer 对资源的高效利用以及流控。良好的调度设计必须平衡内存与CPU资源消耗(footprint) 。

关于封面:这是于 2013 年老家村里雨后拍摄的荷花。从那时开始,我特别喜欢拍摄荷花。

Buffer

Envoy 的 Buffer (Buffer::OwnedImpl) 是其高性能设计的基石之一。传统网络编程中常见的 char* 数组或 std::vector<char> 这类连续内存缓冲区,在需要频繁增长或移动数据时,会涉及大量的内存分配和数据拷贝,从而导致严重的性能开销。

为了解决这个问题,Envoy 的 Buffer 设计遵循了以下几个核心原则:零拷贝(Zero-Copy)分片管理(Slice-based Management)水位线流控(Watermark-based Flow Control)

总述

1. 核心数据结构:非连续的“切片链”(Chain of Slices)

Envoy Buffer 最核心的设计是,它不是一块连续的内存。相反,它内部维护了一个双向队列(Buffer::SliceDeque,队列中的每个元素是一个称为 Slice 的内存切片。

  • 什么是 Slice
    • 一个 Slice 是一个独立的、连续的内存块。它通常包含一个指向内存的指针和数据长度。
    • Buffer::OwnedImpl 本质上就是 Buffer::SliceDeque
  • 这种设计的优势是什么?
    • 避免内存重分配和拷贝:当需要向 Buffer 中添加数据时,Envoy 无需像 std::vector 那样在空间不足时重新分配一个更大的连续内存块,然后将旧数据拷贝过去。它只需要在队列末尾添加一个新的 Slice 即可。这使得追加(append)操作非常高效。
    • 高效的数据消耗(Drain):当数据从 Buffer 中被消耗时,Envoy 只需从队列头部移除 Slice 或调整第一个 Slice 的起始指针,而无需移动后面所有的数据。

2. 零拷贝操作(Zero-Copy Operations)

基于切片链的设计,Envoy 可以实现高效的零拷贝操作,这对于代理性能至关重要。

  • move() 操作:

    这是最典型的零拷贝操作。当你需要将一个 Buffer 的数据 “移动” 到另一个 Buffer 时(例如,从 downstream 连接的 read buffer 移动到 upstream 连接的 write buffer),Envoy 并不拷贝任何实际的字节数据。它只是将源 Buffer 的 Slice 队列的所有权转移给目标 Buffer。这个操作仅涉及指针操作,速度极快。

3. 内存管理与分配

为了进一步提升效率和减少内存碎片,Envoy 的 Buffer 在分配 Slice 时也做了优化。

  • 固定大小的内存块: Slice 通常是从一个内存池中分配的,并且大小是固定的(例如 16KB)。当需要添加少量数据时,会复用最后一个 Slice 的剩余空间;如果空间不足或没有 Slice,则会分配一个全新的、标准大小的 Slice
  • 减少 malloc 调用: 通过池化和固定大小的分配策略,Envoy 减少了对系统调用 malloc/free 的频繁请求,从而降低了内存管理的开销。

4. 水位线与流控(Watermarks and Flow Control)

Buffer 的大小是 Envoy 实现网络流控的关键。

  • 高水位线 (High Watermark): 每个连接的缓冲区都有一个“高水位线”配置。当 Buffer 中累积的数据总量超过这个阈值时,Envoy 会停止从数据源(如 downstream TCP连接)读取数据。这可以有效防止因为 upsteam 消费慢而导致代理内存耗尽,即所谓的**背压(Backpressure)**机制。
  • 低水位线 (Low Watermark): 当 Buffer 中的数据被消耗,使其总量低于“低水位线”时,Envoy 会重新开始从数据源读取数据。

这种“启停式”的机制确保了 Envoy 在处理快慢不一的上下游连接时能够保持稳定和健壮。

5. 线性化(Linearize)

尽管非连续内存设计非常高效,但某些场景(如与需要连续内存的库或系统调用交互)仍然需要一块完整的内存。为此,Envoy 提供了 linearize() 方法。

  • linearize(size): 这个方法会开辟一块新的连续内存,并将 Buffer 中指定长度的数据从各个 Slice 中拷贝到这块新内存里。
  • 这是一个性能损耗较大的操作,因为它打破了零拷贝的原则。因此,Envoy 的内部逻辑会尽可能地避免调用它,只在绝对必要时才使用。

总结

总而言之,Envoy Proxy 的 Buffer 设计是一个高度优化的实现,其精髓在于:

  • Slice 链(非连续内存)代替传统连续内存,从根本上避免了昂贵的内存重分配和数据移动。
  • move() 等操作实现高效的零拷贝,极大提升了数据在代理内部流转的性能。
  • 结合水位线机制实现强大的流控,保证了代理在不同网络状况下的稳定性和弹性。

Buffer framework

图:Buffer 类图
图:Buffer 类图
用 Draw.io 打开

上图信息量比较大,有兴趣的学习可以细心参透。简要罗列以下几方面:

  1. 基本的 Buffer 抽象设计:其中包括
    1. Buffer::Instance 的基本 add/prepend 等等读写操作
    2. watermark 的概念
    3. Reservation 的概念
    4. Buffer Memory Account 的概念
  2. Buffer 的实现
    1. Slice 的概念
    2. Buffer::SliceDeque 的队列设计
  3. 外部子系统与 Buffer 的互动
    1. 流控配置如果应用到各外部子系统
    2. 外部子系统如何利用 Buffer 构架的 watermark 等等功能,实现流控

Flow Control and Buffer

在 Envoy Proxy 中,流的 Buffer 限制主要通过流量控制机制以及与 HTTP/2 和 HTTP/3 连接相关的设置来管理。

以下是 Stream Buffer 处理方式和相关配置的详细说明:

流量控制与水位线

Envoy 通过其内部 Buffer 的高水位线 (high watermark)低水位线 (low watermark) 来实现流量控制。当某个 Buffer (例如,用于某个流的 Buffer )超过其高水位线时,Envoy 会向数据源(upstream 或 downstream)发出信号,要求暂停发送数据。当 Buffer 排空并低于低水位线时,数据流将恢复。

当 Envoy 使用非流式 L7(http) filter(例如 transcoder 或 http buffer filter),并且请求或响应体超出 L7 缓冲区限制时,流量控制可能会导致问题(hard limits)。

对于请求,如果请求体必须缓冲且超出配置的限制,Envoy 将向用户返回 413 错误,并增加 downstream_rq_too_large 指标。

响应路径上,如果响应体必须缓冲且超出限制,Envoy 将增加 rs_too_large 指标,并可能:

  • 中断响应(如果响应头已发送到 downstream)。
  • 发送 500 错误响应

概念上,流控可以分为两种层次,分别是:

  • Network Flow Control (可理解 L3/L4 为 TCP/IP 层控制)
    • listener limits (downstream)
    • cluster limits (upstream)
  • HTTP Flow Control (可理解为 L7 HTTP 层控制)
    • http2 stream limits

Network Flow Control

listener per_connection_buffer_limit_bytes

listener limits 适用于每次 read() 调用从 downstream 读取的原始数据量,以及 Envoy 和 downstream 之间在用户空间中缓冲的数据量。

listener limits 也会传播到 HttpConnectionManager,所以:

  • 对于HTTP/1.1 :基于每个 http steam 应用于下文所述的 HTTP/1.1 L7 buffer。因此,它们限制了可缓冲的 HTTP/1 请求和响应主体的大小。

  • 对于 HTTP/2 和 HTTP/3:由于多个流可以在一个连接上复用,因此可以分别调整 L7 和 L4 buffer limits,并且 http 的 配置选项 initial_connection_window_size 将应用于所有 L7 buffer 。

请注意,对于所有版本的 HTTP,Envoy 可以在所有 L7 filter 都是流式传输的路由路径(routes)上代理任意大的 http body ,但许多 http filter(例如 transcoder 或 http buffer filter)需要缓冲完整的 HTTP body,这种情况下, listener limits 会限制请求和响应的大小。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static_resources:
  listeners:
    name: http
    address:
      socket_address:
        address: '::1'
        portValue: 0
    filter_chains:
      filters:
        name: envoy.filters.network.http_connection_manager
        ...
    per_connection_buffer_limit_bytes: 1024

cluster per_connection_buffer_limit_bytes

per_connection_buffer_limit_bytes 很重要。这是 cluster 级别上的一个设置,定义了 cluster 连接的读写 Buffer 的软限制。如果未指定,则会应用一个实现定义的默认值(通常是 1MiB)。此限制适用于整个连接,因此会影响该连接上的所有多路复用流。如果一个 http connection buffer 已满,最终也会影响各个 http stream。

cluster 限制会影响每次 read() 调用从 upstream 读取的原始数据量,以及在 Envoy 和 upstream 之间的用户空间中 buffer 的数据量。

per_connection_buffer_limit_bytes 的配置示例(集群配置):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
clusters:
- name: my_upstream_cluster
  connect_timeout: 5s
  type: LOGICAL_DNS
  per_connection_buffer_limit_bytes: 32768 # 32 KB (32千字节), 对不受信任的 upstream 很有用
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: my_upstream_cluster
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: example.com
              port_value: 80

作用对象: 这个参数是针对 Envoy 集群 (Cluster) 的,它定义了 Envoy 与 upstream 集群中每个连接读写缓冲区大小

目的: 这个设置控制的是 Envoy 在其内部为每个 TCP 连接(无论是 HTTP/1.1 还是 HTTP/2 连接)分配的用户空间缓冲区的软限制。它主要用于防止 Envoy 自身因缓冲过多的数据而耗尽内存。

性质: 这是一个Envoy 内部的资源管理和背压机制。当 Envoy 与 upstream 建立的某个连接的读或写缓冲区达到这个限制时,Envoy 会触发内部的流量控制回调(例如,停止从套接字读取数据),从而将背压传递给下游或上游。

影响: 它影响 Envoy 进程的内存使用,尤其是在有大量并发连接或慢速上游/下游的情况下。如果未指定,Envoy 会使用一个默认值(通常是 1MiB)。

HTTP Flow Control

initial_stream_window_size

对于 HTTP/2 和 HTTP/3,控制每 Stream Buffer 的主要机制是初始流级别流量控制接收窗口大小 (initial stream-level flow-control receive window size)。此设置规定了 Envoy 在从对端收到窗口更新之前,允许对端在一个流上发送多少数据。

initial_stream_window_size: 此参数位于 http2_protocol_optionsquic_protocol_options(用于 HTTP/3)配置中。

  • 对于 HTTP/2: 您可以在 cluster 或 listener 上的 http_protocol_optionshttp2_protocol_options 中配置此项。它作为 Envoy 在发送和接收 Buffer 中为每个 Stream Buffer 的字节数的软限制。
  • 对于 HTTP/3 (QUIC): 它作为 initial_stream_window_size 在 cluster 或 listener 的 quic_protocol_options 下可用。

initial_connection_window_size

  • 作用对象: 这个参数是针对 HTTP/2 协议的,它定义了 HTTP/2 连接级别的流量控制窗口大小
  • 目的: 在 HTTP/2 中,流量控制是端到端的,既有流级别的窗口,也有连接级别的窗口。initial_connection_window_size 决定了在不收到对端窗口更新帧的情况下,Envoy 允许在一个 HTTP/2 连接上发送或接收的总字节数
  • 性质: 这是一个协议层面的流量控制机制。当连接上的数据传输量达到这个窗口大小时,Envoy 会暂停在该连接上发送更多数据,直到对端发送一个窗口更新帧来“打开”更多的窗口空间。它确保了发送方不会压垮接收方,是 HTTP/2 协议固有的背压机制。
  • 影响: 它直接影响 HTTP/2 连接的吞吐量和缓冲区使用,但其核心是协议规范的一部分,用于管理数据流。

HTTP/2 的配置示例(cluster 配置):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
clusters:
- name: my_upstream_cluster
  connect_timeout: 5s
  type: LOGICAL_DNS
  lb_policy: ROUND_ROBIN
  typed_extension_protocol_options:
    envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
      "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
      explicit_http_config:
        http2_protocol_options:
          initial_stream_window_size: ...
          # max_concurrent_streams: ...
          initial_connection_window_size: ...
  load_assignment:
    cluster_name: my_upstream_cluster
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: example.com
              port_value: 80

这些设置为何重要

  • 资源管理: 限制 Buffer 大小有助于防止 Envoy 占用过多的内存,特别是在 upstream 服务缓慢的情况下。
  • 流量控制: 它们对于背压至关重要,确保快速的发送方不会压垮慢速的接收方,从而防止 OOM(内存溢出)问题。
  • DDoS 保护: 缓冲可以通过在转发请求之前缓冲整个请求来保护 upsteam 服务器免受慢速攻击,确保 upsteam 以 Envoy 的速度接收请求,而不是 downstream 的速度。

参考

分享

Mark Zhu
作者
Mark Zhu
An old developer