前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >张亦鸣 : eBPF 简史 (下篇)

张亦鸣 : eBPF 简史 (下篇)

原创
作者头像
Linuxer
修改2017-11-09 09:41:10
6.2K0
修改2017-11-09 09:41:10
举报
文章被收录于专栏:Linuxer的专栏Linuxer的专栏

张亦鸣 : eBPF 简史 (上篇)

进化:extended BPF

自 3.15 伊始,一个套源于 BPF 的全新设计开始逐渐进入人们的视野,并最终(3.17)被添置到了 kernel/bpf 下。这一全新设计最终被命名为了 extended BPF(eBPF):顾名思义,有全面扩充既有 BPF 功能之意;而相对应的,为了后向兼容,传统的BPF 仍被保留了下来,并被重命名为 classical BPF(cBPF)。

相对于 cBPF,eBPF 带来的改变可谓是革命性的:一方面,它已经为内核追踪(Kernel Tracing)、应用性能调优/监控、流控(Traffic Control)等领域带来了激动人心的变革;另一方面,在接口的设计以及易用性上,eBPF 也有了较大的改进。

Linux 内核代码的 samples 目录下有大量前人贡献的eBPF sample,这里笔者先挑选其中相对简单的 sockex1 来帮助读者们建立一个 eBPF 的初步印象:

清单 3 sockex1_user.c

代码语言:txt
复制
#include <…>
// 篇幅所限,清单 3 和 4 都只罗列出部分关键代码,有兴趣一窥全貌的读者可以移步 http://elixir.free-electrons.com/linux/v4.12.6/source/samples/bpf深入学习
int main(int ac, char **argv)
{
    // 1. eBPF 的伪代码位于 sockex1_kern.o 中,这是一个由 llvm 生成的 elf 格式文件,指令集为 bpf;
    snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
    if (load_bpf_file(filename)) {
        // load_bpf_file()定义于 bpf_load.c,利用 libelf 来解析 sockex1_kern.o
        // 并利用 bpf_load_program 将解析出的伪代码 attach 进内核;
    }
    // 2. 因为 sockex1_kern.o 中 bpf 程序的类型为 BPF_PROG_TYPE_SOCKET_FILTER
    // 所以这里需要用用 SO_ATTACH_BPF 来指明程序的 sk_filter 要挂载到哪一个套接字上
    sock = open_raw_sock("lo");
    assert(setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, prog_fd,
    sizeof(prog_fd[0])) == 0);
    //……
    for (i = 0; i < 5; i++) {
        // 3. 利用 map 机制获取经由 lo 发出的 tcp 报文的总长度
        key = IPPROTO_TCP;
        assert(bpf_map_lookup_elem(map_fd[0], &key, &tcp_cnt) == 0);
        // ……
    }
    return 0;
}

清单 4 sockex1_kern.c

代码语言:txt
复制
#include <……>
// 预先定义好的 map 对象
// 这里要注意好其实 map 是需要由用户空间程序调用 bpf_create_map()进行创建的
// 在这里定义的 map 对象,实际上会在 load_bpf_file()解析 ELF 文件的同时被解析和创建出来
// 这里的 SEC(NAME)宏表示在当前 obj 文件中新增一个段(section)
struct bpf_map_def SEC("maps") my_map = {
    .type = BPF_MAP_TYPE_ARRAY,
    .key_size = sizeof(u32),
    .value_size = sizeof(long),
    .max_entries = 256,
};
SEC("socket1")
int bpf_prog1(struct __sk_buff *skb)
{
    // 这个例子比较简单,仅仅是读取输入报文的包头中的协议位而已
    // 这里的 load_byte 实际指向了 llvm 的 built-in 函数 asm(llvm.bpf.load.byte)
    // 用于生成 eBPF 指令 BPF_LD_ABS 和 BPF_LD_IND
    int index = load_byte(skb, ETH_HLEN + offsetof(struct iphdr, protocol));
    long *value;
    // ……
    // 根据 key(&index,注意这是一个指向函数的引用)获取对应的 value
    value = bpf_map_lookup_elem(&my_map, &index);
    if (value)
        __sync_fetch_and_add(value, skb->len); //这里的__sync_fetch_and_add 是 llvm 中的内嵌函数,表示 atomic 加操作
    return 0;
}

// 为了满足 GPL 毒药的需求,所有会注入内核的 BPF 代码都须显式的支持 GPL 协议
char _license[] SEC("license") = "GPL";

对比一下清单 3&4 以及清单 2 的代码片段,很容易看出一些 eBPF 显而易见的革新:

  • 用 C 写成的 BPF 代码(sockex1_kern.o);
  • 基于 map 的内核与用户空间的交互方式;
  • 全新的开发接口;
  • 除此之外,还有一些不那么明显的改进隐藏在内核之中:
  • 全新的伪指令集设计;
  • In-kernel verifier;

由一个文件(net/core/filter.c)进化到一个目录(kernel/bpf),eBPF 的蜕变三言两语间很难交代清楚,下面笔者就先基于上述的几点变化来帮助大家入个门,至于个中细节,就只能靠读者以后自己修行了。

再见了汇编

利用高级语言书写 BPF 逻辑并经由编译器生成出伪代码来并不是什么新鲜的尝试,比如 libpcap 就是在代码中内嵌了一个小型编译器来分析 tcpdump 传入的 filter expression 从而生成 BPF 伪码的。只不过长久以来该功能一直没有能被独立出来或者做大做强,究其原因,主要还是由于传统的 BPF 所辖领域狭窄,过滤机制也不甚复杂,就算是做的出来,估计也不堪大用。

然而到了 eBPF 的时代,情况终于发生了变化:现行的伪指令集较之过去已经复杂太多,再用纯汇编的开发方式已经不合时宜,于是,自然而然的,利用 C 一类的高级语言书写 BPF 伪代码的呼声便逐渐高涨了起来。

目前,支持生成 BPF 伪代码的编译器只有 llvm 一家,即使是通篇使用 gcc 编译的 Linux 内核,samples 目录下的 bpf 范例也要借用 llvm 来编译完成。还是以 sockex1 为例,用户态下的代码 sockex_user.c 是利用 HOSTCC 定义的编译器编译的;但 sockex_kern.c 就需要用到 clang 和 llvm 了。在samples/bpf/Makefile中,可以看到:

清单 5 samples/bpf/Makefile

代码语言:txt
复制
# ......
# List of programs to build
hostprogs-y := test_lru_dist
hostprogs-y += sockex1
# ……
sockex1-objs := bpf_load.o $(LIBBPF) sockex1_user.o
# ……
# 注意,这里有一个小 tip,就是如果在内核的 Makefile 中,
# 有某一个目标文件你不希望使用内核的通用编译规则的话(类似于本文的 sockex1_kern.o),
# 可以像这里一样,并不把该文件加入任何 xxxprogs 或 xxx-objs,
# 而是直接放入 always,这样内核就会在本地 Makefile 中搜索编译规则了。
always := $(hostprogs-y)
<strong>always </strong><strong>+= sockex1_kern.o</strong>
# ……
LLC ?= llc
CLANG ?= clang
# ……
# sockex1_kern.o 就是使用了下述规则编译为 BPF 代码的,请注意笔者加粗的部分
$(obj)/%.o: $(src)/%.c
$(CLANG) $(NOSTDINC_FLAGS) $(LINUXINCLUDE) $(EXTRA_CFLAGS) \
-D__KERNEL__ -D__ASM_SYSREG_H -Wno-unused-value -Wno-pointer-sign \
-Wno-compare-distinct-pointer-types \
-Wno-gnu-variable-sized-type-not-at-end \
-Wno-address-of-packed-member -Wno-tautological-compare \
-Wno-unknown-warning-option \
-O2 -emit-llvm -c $< -o -|
$(LLC) -march=bpf -filetype=obj -o
$@

能用 C 书写 BPF 自然是便利了许多,但也不代表余下的开发工作就是一片坦途了:首先 llvm 的输出是 elf文件,这也意味着想要获取能传入内核的代码,我们还需要额外做一段解析 elf 的工作,这也是为什么 Sample 下的范例几乎无一例外地都链接了 libelf 库;其次,同时也是比较重要的一点,不要忘记 BPF的代码是跑在内核空间中的,因此书写时必得煞费苦心一番才好,以防一个不小心就做出个把内核干趴下的漏洞来:下文中提及的 verifier就是为了这一点而生,每一个被放进内核的 BPF 代码,都须要经过它的检验才行。

BPF 程序的类别以及 Map 机制

清单 3 中我们看到 sockex1_kern.o 是由 load_bpf_file()函数载入内存的,但实际上 eBPF 提供用来将 BPF 代码载入内核的正式接口函数其实是 bpf_load_program(),该接口负责通过参数向内核提供三类信息:

  • BPF 程序的类型、
  • BPF 代码
  • 代码运行时所需要的存放 log 的缓存地址(位于用户空间);

有意思的是,目前所有注入内核的 BPF 程序都需要附带 GPL 协议支持信息,bpf_load_program()的 license参数就是用来载入协议字串的。

由 eBPF 伊始,BPF 程序开始有分类了,通过 bpf_load_program 的参数 bpf_prog_type,我们可以看到 eBPF 支持的程序类型。这里笔者将一些常用的类型罗列于下表之中供读者参考:

表 2 常见 bpf_prog_type 定义

bpf_prog_type

BPF prog 入口参数(R1)

程序类型

BPF_PROG_TYPE_SOCKET_FILTER

struct __sk_buff

用于过滤进出口网络报文,功能上和 cBPF 类似。

BPF_PROG_TYPE_KPROBE

struct pt_regs

用于 kprobe 功能的 BPF 代码。

BPF_PROG_TYPE_TRACEPOINT

这类 BPF 的参数比较特殊,根据 tracepoint 位置的不同而不同。

用于在各个 tracepoint 节点运行。

BPF_PROG_TYPE_XDP

struct xdp_md

用于控制 XDP(eXtreme Data Path)的 BPF 代码。

BPF_PROG_TYPE_PERF_EVENT

struct bpf_perf_event_data

用于定义 perf event 发生时回调的 BPF 代码。

BPF_PROG_TYPE_CGROUP_SKB

struct __sk_buff

用于在 network cgroup 中运行的 BPF代码。功能上和 Socket_Filter 近似。具体用法可以参考范例 test_cgrp2_attach。

BPF_PROG_TYPE_CGROUP_SOCK

struct bpf_sock

另一个用于在 network cgroup 中运行的 BPF 代码,范例 test_cgrp2_sock2 中就展示了一个利用 BPF 来控制 host 和 netns 间通信的例子。

深入对比清单 3(eBPF)和清单 2(cBPF)的实现的差异,还会发现一个比较明显的不同之处:BPF 代码进内核之后,cBPF 和内核通讯的方式是 recv();而 eBPF 则将 socket 丢到一边,使用一种名为 map 的全新机制和内核通讯,其大致原理下图所示:

[1510042567228_9628_1510042613273.png]
[1510042567228_9628_1510042613273.png]

图 4 eBPF 的 map 机制

从图上看,这套设计本身不复杂:位于用户空间中的应用在内核中辟出一块空间建立起一个数据库用以和 eBPF 程序交互(bpf_create_map());数据库本身以 Key-Value 的形式进行组织,无论是从用户空间还是内核空间都可以对其进行访问,两边有着相似的接口,最终在逻辑上也都殊途同归。

不难发现,map 带来的最大优势是效率:相对于 cBPF 一言不合就把一个通信报文从内核空间丢出来的豪放,map 机制下的通讯耗费就要小家碧玉的多了:还是以 sockex1 为例,一次通信从内核中仅仅复制 4个字节,而且还是已经处理好了可以直接拿来就用的,做过内核开发的人都知道这对于性能意味着什么。

map 机制解决的另一个问题是通信数据的多样性问题。cBPF 所覆盖的功能范围很简单,无外乎是网络监控和 seccomp 两块,数据接口设计的粗放一点也就算了;而 eBPF 的利用范围则要广的多,性能调优、内核监控、流量控制什么的应有尽有,数据接口的多样性设计就显得很必要了。下表中就列出了现有 eBPF 中的 map机制中常见的数据类型:

表 3. map 机制下的常见数据类型

[1510042796485_7163_1510042842468.png]
[1510042796485_7163_1510042842468.png]
[1510042818435_5220_1510042864270.png]
[1510042818435_5220_1510042864270.png]

新的指令集

eBPF 对于既有 cBPF 令集的改动量之大,以至于基本上不能认为两者还是同一种语言了。个中变化,我们可以通过反汇编清单 4 的源代码(llvm-objdump --disassemble)略知一二:

清单 6 Disassemble of sockex1_kern.o

代码语言:txt
复制
sockex1_kern.o: file format ELF64-BPF
Disassembly of section socket1:
bpf_prog1:
0: bf 16 00 00 00 00 00 00 r6 = r1
1: 30 00 00 00 17 00 00 00 r0 = *(u8 *)skb[23]
2: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
3: 61 61 04 00 00 00 00 00 r1 = *(u32 *)(r6 + 4)
4: 55 01 08 00 04 00 00 00 if r1 != 4 goto 8
5: bf a2 00 00 00 00 00 00 r2 = r10
6: 07 02 00 00 fc ff ff ff r2 += -4
7: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0ll
9: 85 00 00 00 01 00 00 00 call 1
10: 15 00 02 00 00 00 00 00 if r0 == 0 goto 2
11: 61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
12: db 10 00 00 00 00 00 00 lock *(u64 *)(r0 + 0) += r1
LBB0_3:
13: b7 00 00 00 00 00 00 00 r0 = 0
14: 95 00 00 00 00 00 00 00 exit

我们不用管这段汇编写了点儿什么,先跟清单 2 开头的那段 cBPF 代码对比一下两者的异同:

寄存器:eBPF 支持更多的寄存器;

  • cBPF:A, X + stack, 32bit;
  • eBPF:R1~R10 + stack, 64bit,显然,如此的设计主要针对现在大行其道的 64 位硬件,同时更多的寄存器设计也便于运行时和真实环境下的寄存器进行对应,以提高效率;

opcode:两者的格式不同;

  • cBPF: op 16b, jt 8b, jf 8b, K 32b;
  • eBPF: op 8b, dstReg 4b, srcReg 4b, off 16b, imm 32b;

其他:sockex1_kern.o 设计的比较简单,但还是可以从中看出 eBPF 的一大改进:可以调用内核中预设好的函数(Call 1,这里指向的函数是 bpf_map_lookup_elem(),如果需要比较全的预设函数索引的话可以移步这里)。除此之外,eBPF 命令集中比较重要的新晋功能还有:

  • cBPF:仅可以读 packet(即 skb)以及读写 stack;
  • eBPF:可以读写包括 stack/map/context,也即 BPF prog 的传入参数可读写。换句话说,任意传入 BPF 代码的数据流均可以被修改;
  • load/store 多样化:
  • 除开预设函数外,开发者还可以自定义 BPF 函数(JUMP_TAIL_CALL);
  • 除了前向跳转外(Jump Forward,cBPF 支持),还可以后向跳转(Jump Backword);

至于 eBPF 具体的指令表,因为过于庞杂这里笔者就不作文抄公了。不过 eBPF 中的几个寄存器的利用规则这里还是可以有的,否则要读懂清单 6中的代码略有困难:

  • R0:一般用来表示函数返回值,包括整个 BPF 代码块(其实也可被看做一个函数)的返回值;
  • R1~R5:一般用于表示内核预设函数的参数;
  • R6~R9:在 BPF 代码中可以作存储用,其值不受内核预设函数影响;
  • R10:只读,用作栈指针(SP);

In-kernel Verifier

其实结合前面那么多的内容看下来不难发现 eBPF其实近似于一种改头换面后的内核模块,只不过它比内核模块更短小精干,实现的功能也更新颖一些罢了,但无论是什么样的架构,只要存在注入的代码就会有安全隐患,eBPF 也不外如是——毕竟注入的代码是要在内核中运行的。

为了最大限度的控制这些隐患,cBPF 时代就开始加入了代码检查机制以防止不规范的注入代码;到了 eBPF 时代则在载入程序(bpf_load_program())时加入了更复杂的verifier 机制,在运行注入程序之前,先进行一系列的安全检查,最大限度的保证系统的安全。具体来说,verifier机制会对注入的程序做两轮检查:

1 .首轮检查(First pass,实现于check_cfg())可以被认为是一次深度优先搜索,主要目的是对注入代码进行一次 DAG(Directed Acyclic Graph,有向无环图)检测,以保证其中没有循环存在;除此之外,一旦在代码中发现以下特征,verifier 也会拒绝注入:

  • 代码长度超过上限,目前(内核版本 4.12)eBPF 的代码长度上限为 4K 条指令——这在 cBPF 时代很难达到,但别忘了 eBPF 代码是可以用 C 实现的;
  • 存在可能会跳出 eBPF 代码范围的 JMP,这主要是为了防止恶意代码故意让程序跑飞;
  • 存在永远无法运行(unreachable)的 eBPF 令,例如位于 exit 之后的指令;

2 .次轮检查(Second pass,实现于do_check())较之于首轮则要细致很多:在本轮检测中注入代码的所有逻辑分支从头到尾都会被完全跑上一遍,所有的指令的参数(寄存器)、访问的内存、调用的函数都会被仔细的捋一遍,任何的错误都会导致注入程序被退货。由于过分细致,本轮检查对于注入程序的复杂度也有所限制:首先程序中的分支(branch)不允许超过 1024 个;其次经检测的指令数也必须在 96K 以内。

Overview: eBPF 的架构

诚然,eBPF 设计的复杂程度已是超越 cBPF 太多太多,笔者罗里吧嗦了大半天,其实也就是将将领着大家入门的程度而已,为了便于读者们能够把前文所述的碎片知识串到一起,这里笔者将 eBPF 的大体架构草绘一番,如下图所示,希望能帮助大家对 eBPF 构建一个整体的认识。

[1510043002773_5693_1510043049695.jpg]
[1510043002773_5693_1510043049695.jpg]

图 5. Architecture of eBPF

追求极简:BPF Compiler Collection(BCC)

现在让我们将目光聚焦到 eBPF 的使用——相信这是大部分读者最感兴趣的部分,毕竟绝大多数人其实并没有多少机会参与 eBPF 的开发——重新回到清单 &4 中的 sockex1:说句良心话,虽然现在可以用 C 来实现 BPF,但编译出来的却仍然是 ELF文件,开发者需要手动析出真正可以注入内核的代码。这部分工作多少有些麻烦,如果可以有一个通用的方案一步到位的生成出 BPF 代码就好了,开发者的注意力应该放在其他更有价值的地方,不是吗?

于是就有人设计了 BPF Compiler Collection(BCC),BCC 是一个 python 库,但是其中有很大一部分的实现是基于 C 和 C++的,python 只不过实现了对 BCC 应用层接口的封装而已。

使用 BCC 进行 BPF 的开发仍然需要开发者自行利用 C 来设计 BPF 程序——但也仅此而已,余下的工作,包括编译、解析 ELF、加载 BPF代码块以及创建 map 等等基本可以由 BCC 一力承担,无需多劳开发者费心。

限于篇幅关于 BCC 笔者不再过多展开,文章的最后笔者再给出一个基于 BCC 实现的 sockex1 的例子,读者可以感受一下使用 BCC 带给开发者们的便利性:

清单 7 A sample of BCC

代码语言:txt
复制
from bcc import BPF
# 和清单 2 一样,篇幅所限,这里只贴一部分源码,完全版请移步 https://raw.githubusercontent.com/windywolf/example/master/eBPF/bccsample.py
interface="ens160"
# BCC 可以接受直接将 BPF 代码嵌入 python code 之中
# 为了方便展示笔者使用了这一功能
# 注意:prog 中的中文注释是由于笔者需要写作之故加入,如果读者想尝试运行这段代码,
# 则请将中文全部删除,因为目前 BCC 还不支持在内嵌 C 代码中使用中文注释
prog = """
#include <net/sock.h>
#include <bcc/proto.h>
// BCC 中专门为 map 定义了一系列的宏,以方便使用
// 宏中的 struct 下还定义了相应的函数,让开发者可以如 C++一般操作 map
// 这里笔者定义了一个 array 类型的 map,名为 my_map1
BPF_ARRAY(my_map1, long);
// BCC 下的 BPF 程序中不再需要定义把函数或变量专门放置于某个 section 下了
int bpf_prog1(struct __sk_buff *skb)
{
    // ……
    struct ethernet_t *eth = cursor_advance(cursor, sizeof(*eth));
    // ……
    struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));
    int index = ip->nextp;
    long zero = 0; // BCC 下的 bpf 书写还是有很多坑的
    // 例如,这里如果不去定义一个局部变量 zero,
    // 而是直接用常量 0 作为 lookup_or_init()的变量就会报错
    // map 类下的各个方法的具体细节可以参照 reference_guide.md
    value = my_map1.lookup_or_init(&index, &zero);
    if (value)
        __sync_fetch_and_add(value, skb->len);
    return 0;
}
"""
bpf = BPF(text=prog, debug = 0)
# 注入 bpf_prog1 函数
function = bpf.load_func("bpf_prog1", BPF.SOCKET_FILTER)
# 这是一段 SOCKET_FILTER 类型的 BPF,所以需要挂载到某一个 interface 上
BPF.attach_raw_socket(function, interface)
# 利用 map 机制获取进出 interface 的各个协议的报文总长
bpf_map = bpf["my_map1"]
while 1:
    print ("TCP : {}, UDP : {}, ICMP: {}".format(
bpf_map[socket.IPPROTO_TCP].value,
# …

结束语

本文从 BPF 的源头开始,一路讲到了近年来刚刚杀青的 eBPF,虽说拘泥于篇幅,大多内容只能蜻蜓点水、浅尝辄止,但文中 BPF 的原理、设计、实现和应用均有所涉猎,勉强也能拿来入个门了。加之近年来基于 eBPF 的应用层出不穷,希望本文能激发读者们的奇思妙想,从而设计出更多基于 BPF 的优秀应用来。

参考资源

  1. Steven McCanne, Van Jacobson The BSD Packet Filter: A New Architecture for User-level Packet Capture, BPF 的源头,可在这里查阅;
  2. http://www.tcpdump.org/tcpdump_man.html, tcpdump 的 manual page;

1.https://www.kernel.org/doc/Documentation/networking/filter.txt, 内核的 BPF 文档;

  1. http://man7.org/linux/man-pages/man7/socket.7.html, Linux 套接字的 manual page,其中有对 Linux Kernel 中 BPF 接口的大致介绍,不过论及详细程度还是不及参考文献 3 的;
  2. https://lwn.net/Articles/437981/,LWN 上刊载的 JIT For BPF 首次被引入的消息:A JIT For Packet Filters;
  3. https://lwn.net/Articles/603983/, LWN 上刊载的 eBPF 的介绍文献:Extending extended BPF。由于年代久远接口相关的内容已经落后于最新代码了,权作参考;
  4. https://github.com/iovisor/bcc, BCC的源码,但由于编译需要比较多的依赖,如果想偷个懒的话可以移步这里直接获取 docker image;
  5. https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md, BCC 开发指南;
  6. http://elixir.free-electrons.com/linux/latest/source, Linux Kernel 源码阅读利器。

本文来源于 linuxer 微信公众号

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 进化:extended BPF
  • 再见了汇编
  • BPF 程序的类别以及 Map 机制
  • 新的指令集
  • In-kernel Verifier
  • Overview: eBPF 的架构
  • 追求极简:BPF Compiler Collection(BCC)
  • 结束语
  • 参考资源
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档