软硬件融合 · 2021年11月08日

SDN数据面编程语言P4,协议无关的可编程包处理器

前面我们说过,跟硬件相关的网络处理由三部分组成:网络接口、网络包处理和高性能网络。而网络包处理是里面最复杂、跟上层软件关系最密切,并且决定了未来网络持续不断快速创新的关键。

P4是网络数据面编程的标准高级语言,本文是P4网络数据面处理编程语言介绍的经典论文,与大家共同学习。

参考文献:

P4: Programming Protocol-Independent Packet Processors, Pat Bossharty, Nick McKeown, etc., ACM SIGCOMM Computer Communication Review, July 2014

(标题)SDN数据面编程语言P4,协议无关的可编程包处理器

P4 是一种用于协议无关包处理器编程的高级语言。P4与OpenFlow等SDN控制协议协同工作。在其当前形式中,OpenFlow明确指定其操作的协议报头。这组字段在几年内从12个字段增加到 41 个字段,增加了规范的复杂性,同时仍然没有提供添加新报头的灵活性。在这篇论文中,我们提出P4作为OpenFlow未来应该如何发展的一个稻草人提案。我们有三个目标:

  • 现场可重构性:一旦部署,程序员应该能够改变交换机处理数据包的方式。
  • 协议独立性:交换机不应绑定任何特定的网络协议。
  • 目标独立性:程序员应该能够独立于底层硬件的细节来描述数据包处理功能。例如,我们描述如何使用P4配置交换机以添加新的分层标签。

1 介绍

软件定义网络(SDN)使运营商能够对其网络进行编程控制。在SDN中,控制平面与(数据)转发平面在物理上是分离的,一个控制平面控制多个转发设备。虽然可以通过多种方式对转发设备进行编程,但是,更好的选择是:拥有一个通用的、开放的、与供应商无关的接口(如OpenFlow),使控制平面能够控制来自不同硬件和软件供应商的转发设备。

image.png

表1:OpenFlow标准识别的字段

OpenFlow接口开始时很简单,只有单个规则表的抽象,可以匹配数据包的十几个报头字段(例如,MAC 地址、IP 地址、协议、TCP/UDP 端口号等)。过去五年多来,规范变得越来越复杂(见表1),有更多的包头字段和规则表的多个阶段,以允许交换机向控制器公开更多的功能。

新报头字段的增加没有停止的迹象。例如,数据中心网络运营商越来越希望应用新形式的数据包封装(例如 NVGRE、VXLAN和STT),为此他们求助于部署更易于扩展新功能的软件交换机。与其反复扩展OpenFlow规范,我们认为未来的交换机应该支持用于解析数据包和匹配报头字段的灵活机制,允许控制器应用程序通过一个通用的开放接口(即新的“OpenFlow 2.0”API)来利用这些功能。这种通用的、可扩展的方法比今天的OpenFlow 1.x标准更简单、更优雅、更适合未来。

image.png

图 1:P4是一种配置交换机的语言

最近的芯片设计表明,这种灵活性可以在Tb处理带宽级的定制ASIC中实现[1, 2, 3]。对新一代交换芯片进行编程绝非易事。每个芯片都有自己的低级接口,类似于微码编程。在本文中,我们勾画了一个更高层次的语言,用来编程协议无关的的包处理器(P4)。图1显示了P4(用于配置交换机,告诉它如何处理数据包)与旨在填充固定功能交换机中的转发表的现有API(例如OpenFlow)之间的关系。P4提高了网络编程的抽象水平,可以作为控制器和交换机之间的通用接口。也就是说,我们认为下一代OpenFlow应该允许控制器告诉交换机如何操作,而不是受到固定交换机设计的约束。关键的挑战是找到一个“平衡点”,在各种硬件和软件交换机之间平衡既满足要求又易于实现。在设计P4时,我们有三个主要目标:

  • 可重构性。控制器应该能够在现场重新定义数据包解析和处理。
  • 协议独立性。交换机不应绑定到特定的数据包格式。相反,控制器应该能够指定
  1. 用于提取具有特定名称和类型的报头字段的数据包解析器,以及
  2. 处理这些报头的类型化匹配+操作表的集合。
  • 目标独立。正如C程序员不需要知道底层CPU的细节一样,控制器程序员也不需要知道底层交换机的细节。相反,编译器应该考虑交换机的功能,负责把目标无关的描述(用P4编写的程序)翻译成特定交换机目标相关的底层程序(用于配置交换机)。

论文的大纲如下:

  • 开始,我们引入抽象的交换机转发模型。
  • 接下来,我们解释需要一种新语言来描述独立于协议的数据包处理。
  • 然后,我们展示了一个简单的激励示例,其中网络运营商想要支持新的数据包头字段并在多个阶段处理数据包。我们使用它来探索P4程序如何指定报头、数据包解析器、多个匹配+操作表以及通过这些表的控制流。
  • 最后,我们讨论编译器如何将 P4 程序映射到目标开关。

相关工作

2011年,Yadav等人[4]为OpenFlow提出了一个抽象的转发模型,但不太强调编译器。袋鼠[1] 引入了可编程解析的概念。最近,Song [5]提出了协议无意识转发,它与我们的协议独立目标相同,但更多地针对网络处理器。ONF引入了表类型模式来表达开关的匹配能力[6]。最近在NOSIX [7]上的工作分享了我们灵活规范匹配的目标+动作表,但不考虑协议独立性或提出用于指定解析器、表和控制流的语言。最近的其他工作提出了数据平面的编程接口,用于监控、拥塞控制和队列管理[8,9]。Click模块化路由器[10] 支持软件中灵活的数据包处理,但不将程序映射到各种目标硬件交换机。

2 抽象转发模型

在我们的抽象模型(图2)中,交换机通过可编程解析器转发数据包,然后是多个匹配+动作阶段,串联、并联或两者的组合。源自OpenFlow,我们的模型设计了三个概括:

  • 首先,OpenFlow假设一个固定的解析器,而我们的模型支持一个可编程的解析器以允许定义新的报头。
  • 其次,OpenFlow假设match + action阶段是串联的,而在我们的模型中它们可以是并联或串联的。
  • 第三,我们的模型假设动作由交换机支持的独立于协议的原语组成。

我们的抽象模型概括了在不同的转发设备(例如,以太网交换机、路由器、负载平衡器)和采用不同的技术(例如,固定功能交换机ASIC、NPU、可重构交换机、软件交换机、FPGA)中传输的数据包处理方式。就我们常见的抽象模型而言,这允许我们设计一种通用语言(P4)来表示数据包的处理方式。因此,程序员可以创建与目标无关的程序,编译器可以将这些程序映射到各种不同的转发设备,从相对较慢的软件交换机到最快的基于ASIC的交换机。

image.png

图 2:抽象转发模型

转发模型由两种类型的操作控制:配置和填充。

  • 配置操作程序解析器,设置匹配+动作阶段的顺序,并指定每个阶段处理的头字段。配置决定了支持哪些协议以及交换机如何处理数据包。
  • 填充操作向配置期间指定的匹配+操作表添加(和删除)条目。填充决定了在任何给定时间应用于数据包的策略。

出于本文的目的,我们假设配置和填充是两个不同的阶段。特别是,交换机在配置过程中不需要处理数据包。然而,我们预计实现将允许在部分或完全重新配置期间进行数据包处理,从而无需停机即可进行升级。我们的模型特意允许并鼓励不中断转发的重新配置。

显然,配置阶段在固定功能的ASIC交换机中意义不大。对于这种类型的交换机,编译器的工作就是简单的检查芯片是否可以支持P4程序。相反,我们的目标是捕捉快速可重构数据包处理流水线的总体趋势,如[2, 3]中所述。

到达的数据包首先由解析器处理。假设数据包主体被单独缓存,并且不可用于匹配。解析器从报头中识别并提取字段,从而定义交换机支持的协议。该模型不对协议头的含义做任何假设,只是解析的表示定义了匹配和操作操作的字段集合。

然后将提取的头字段传递到match + action表。匹配+动作表在入口和出口之间划分。虽然两者都可以修改数据包头,但入口匹配+动作确定出口端口并确定数据包所在的队列。基于入口处理,数据包可能被转发、复制(用于多播、跨度或控制平面)、丢弃或触发流量控制。出口匹配+动作对数据包头执行每个实例的修改——例如,对于多播副本。动作表(计数器、监管器等)可以与流相关联以跟踪帧到帧的状态。

数据包可以在阶段之间携带附加信息,称为元数据,这与数据包头字段相同。元数据的一些示例包括入口端口、传输目的地和队列、可用于数据包调度的时间戳,以及从表到表传递的不涉及更改已解析表示的数据,例如虚拟网络标识符。

队列规则的处理方式与当前的OpenFlow相同:Action将数据包映射到队列,队列被配置为接收特定的服务规则。选择服务规则(例如,最小速率、DRR)作为交换机配置的一部分。

尽管超出了本文的范围,但可以添加动作原语以允许程序员实现新的或现有的拥塞控制协议。例如,交换机可能被编程为基于新条件设置ECN位,或者它可能使用匹配+操作表实现专有拥塞控制机制。

3 编程语言

我们使用抽象转发模型来定义一种语言来表达如何配置交换机以及如何处理数据包。本文的主要目标是提出P4编程语言。然而,我们认识到许多语言都是可能的,它们很可能具有我们在这里描述的共同特征。例如,语言需要一种方式来表达解析器的编程方式,以便解析器知道期望的数据包格式;因此程序员需要一种方法来声明哪些报头类型是可能的。例如,程序员可以指定IPv4报头的格式以及哪些报头可以合法地跟在IP报头之后。这种动机通过在P4中声明合法的头文件类型来定义解析。同样,程序员需要表达如何处理包头。例如,必须递减和测试TTL字段,可能需要添加新的隧道头,并且可能需要计算校验和。这促使P4使用命令式控制流程序来描述使用声明的头类型和一组原始操作的头字段处理。

我们可以使用像Click [10]这样的语言,它从由任意C++组成的模块构建交换机。Click非常有表现力,非常适合表达数据包在CPU内核中的处理方式。但是它对于我们的需求来说是不够的——我们需要一种语言来反映专用硬件中的解析匹配动作流水线。此外,Click不是为控制器-交换架构设计的,因此不允许程序员描述由类型良好的规则动态填充的匹配+操作表。最后,正如我们现在讨论的那样,Click使得推断限制并行执行的依赖关系变得困难。

数据包处理语言必须允许程序员表达(隐式或显式)报头字段之间的任何串行相关性。依赖关系决定了哪些表可以并行执行。例如,IP路由表和ARP表由于数据依赖,需要顺序执行。可以通过分析表依赖关系图(TDG)来识别依赖关系;这些图描述了表之间的字段输入、操作和控制流。图3显示了L2/L3交换机的示例表依赖关系图。TDG节点直接映射匹配+动作表,依赖分析确定每个表可能驻留在流水线中的位置。不幸的是,大多数程序员不容易获得TDG。程序员倾向于使用命令式结构而不是图形来考虑数据包处理算法。

image.png

图3:L2/L3交换机的表依赖关系图

这导致我们提出一个两步编译过程。在最高级别,程序员使用表示控制流(P4)的命令式语言来表达数据包处理程序;在此之下,编译器将P4表示转换为TDG,以促进依赖性分析,然后将TDG映射到特定的交换机目标。P4旨在使将P4程序转换为TDG变得容易。总而言之,P4可以被认为是介于较通用的Click(难以推断依赖关系和映射到硬件)和不那么灵活的OpenFlow 1.0(无法重新配置协议处理)之间的最佳位置。

4 P4语言示例

我们通过深入研究一个简单的例子来探索P4。许多网络部署区分边缘和核心;终端主机直接连接到边缘设备,边缘设备又通过高带宽核心互连。整个协议都被设计为支持这种架构(例如MPLS [11]和PortLand [12]),主要旨在简化核心中的转发。

考虑一个示例L2网络部署,在边缘处具有架顶式(ToR)交换机,由两层核心连接。我们将假设终端主机的数量在增长,核心L2表正在溢出。MPLS是一种简化核心的选项,但实施具有多个标签的标签分发协议是一项艰巨的任务。PortLand看起来很有趣,但需要重写MAC地址——可能会破坏现有的网络调试工具——并且需要新的代理来响应ARP请求。

P4使我们能够在对网络架构进行最小更改的情况下表达自定义解决方案。我们称我们的玩具示例为mTag:它将PortLand的分层路由与简单的类似MPLS的标签相结合。通过核心的路由由一个四个单字节字段组成的32位标签编码。32位标签可以携带“源路由”或目的地定位符(如PortLand的伪MAC)。每个核心交换机只需要检查标签的一个字节并打开该信息。在我们的示例中,标签由第一个ToR交换机添加,但也可以由终端主机NIC添加。

所述MTAG例子非常简单,这样把焦点可以放在P4语言。整个交换机的P4程序在实践中会复杂很多倍。

4.1 P4概念

P4程序包含以下关键定义:

  • 报头:报头定义描述了一系列字段的顺序和结构。它包括字段宽度的规范和字段值的约束。
  • 解析器:解析器定义指定了如何识别数据包中的报头和有效报头序列。
  • 表:匹配+动作表是执行包处理的机制。,表用于匹配P4程序定义的字段,以及可能执行的动作。
  • 动作:P4支持从更简单的协议独立原语构造复杂操作。这些复杂的动作可以在匹配+动作表中使用。
  • 控制程序:控制程序决定了应用于数据包的匹配动作表的顺序。一个简单的命令式程序描述了匹配+动作表之间的控制流程。

接下来,我们将展示这些组件如何为P4中理想化mTag处理器的定义做出贡献。

4.2 报头格式

设计始于报头格式的规范。已经为此提出了几种特定于领域的语言[13, 14, 15];P4借鉴了他们的一些想法。通常,通过声明字段名称及其宽度的有序列表来指定每个标题。可选字段注释允许对可变大小字段的值范围或最大长度进行限制。例如,标准以太网和VLAN报头指定如下:

header ethernet {
  fields {
    dst_addr : 48; // width in bits
    src_addr : 48;
    ethertype : 16;
  }
}
header vlan {
  fields {
    pcp : 3;
    cfi : 1;
    vid : 12;
    ethertype : 16;
  }
}

所述mTag可以在不改变现有的声明情况下添加报头。字段名称表明核心有两层聚合。每个核心交换机都使用规则进行编程,以检查由其在层次结构中的位置和行进方向(向上或向下)确定的这些字节之一。

header mTag {
  fields {
    up1 : 8;
    up2 : 8;
    down1 : 8;
    down2 : 8;
    ethertype : 16;
  }
}

4.3 分组解析器

P4假设底层交换机可以实现一个状态机,从头到尾遍历数据报头,在它进行时提取字段值。提取的字段值被发送到匹配+动作表进行处理。

P4直接将此状态机描述为从一个报头到下一个报头的一组转换。每个转换都可以由当前报头中的值触发。例如,我们将mTag状态机描述如下。

parser start{
  ethernet;
}
parser ethernet {
  switch(ethertype) {
    case 0x8100: vlan;
    case 0x9100: vlan;
    case 0x800: ipv4;
    // Other cases
  }
}
parser vlan {
  switch(ethertype) {
    case 0xaaaa: mTag;
    case 0x800: ipv4;
    // Other cases
  }
}
parser mTag {
  switch(ethertype) {
    case 0x800: ipv4;
    // Other cases
  }
}

解析从开始状态开始并继续进行,直到达到明确的停止状态或遇到未处理的情况(可能被标记为错误)。在达到新报头的状态时,状态机使用其规范提取报头并继续识别其下一个转换。提取的报头被转发到交换机流水线后半部分的匹配+动作处理。

mTag的解析器非常简单:它只有四种状态。真实网络中的解析器需要更多的状态;例如,Gibb等人定义的解析器,[16,图3(e)] 有一百多个状态。

4.4 表规格

接下来,程序员描述如何在匹配+操作阶段匹配定义的头字段(例如,它们应该是精确匹配、范围还是通配符?)以及当匹配发生时应该执行什么动作。

在我们简单的mTag示例中,边缘交换机匹配L2目标和VLAN ID,然后选择要添加到报头的mTag。程序员定义一个表来匹配这些字段并应用一个动作来添加mTag报头(见下文)。read属性声明要匹配的字段,由匹配类型(精确、三元、等)限定。actions属性列出了可以通过表应用于数据包的可能动作。以下部分将解释动作。max_size属性指定表应支持的条目数。

表规范允许编译器决定它需要多少内存,以及实现表的内存类型(例如TCAM或SRAM)。

table mTag_table {
  reads {
    ethernet.dst_addr : exact;
    vlan.vid : exact;
  }
  actions {
    // At runtime, entries are programmed with params
    // for the mTag action. See below.
    add_mTag;
  }
  max_size : 20000;
}

为了完整性和以后的讨论,我们提供了控制程序引用的其他表的简要定义(第4.6节)。

table source_check {
  // Verify mtag only on ports to the core
  reads {
    mtag : valid; // Was mtag parsed?
    metadata.ingress_port : exact;
  }
  actions { // Each table entry specifies *one* action
    // If inappropriate mTag, send to CPU
    fault_to_cpu;
    // If mtag found, strip and record in metadata
    strip_mtag;
    // Otherwise, allow the packet to continue
    pass;
  }
  max_size : 64; // One rule per port
}

table local_switching {
  // Reads destination and checks if local
  // If miss occurs, goto mtag table.
}

table egress_check {
  // Verify egress is resolved
  // Do not retag packets received with tag
  // Reads egress and whether packet was mTagged
}

4.5 动作规格

P4定义了一组原始动作,从中构建更复杂的动作。每个P4程序声明一组由动作原语组成的动作函数;这些操作函数简化了表规范和填充。P4假设在动作函数中并行执行原语。(不能并行执行的交换机可以模拟语义。)

上面提到的add_mTag动作实现如下:

action add_mTag(up1, up2, down1, down2, egr_spec) {
  add_header(mTag);
  // Copy VLAN ethertype to mTag
  copy_field(mTag.ethertype, vlan.ethertype);
  // Set VLAN's ethertype to signal mTag
  set_field(vlan.ethertype, 0xaaaa);
  set_field(mTag.up1, up1);
  set_field(mTag.up2, up2);
  set_field(mTag.down1, down1);
  set_field(mTag.down2, down2);
  // Set the destination egress port as well
  set_field(metadata.egress_spec, egr_spec);
}

如果操作需要参数(例如,mTag的up1值),它在运行时从匹配表中提供。

在此示例中,交换机在VLAN标记之后插入mTag,将VLAN标记的ethertype复制到mTag中以指示后面的内容,并将VLAN标记的ethertype设置为0xaaaa以表示mTag。未显示的是从数据包中剥离mTag的反向动作规范以及在边缘交换机中应用此动作的表。

P4的原语动作包括:

  • set_field:将报头的特定字段设置为某个值。支持掩码集。
  • copy_field:将一个字段复制到另一个字段。
  • add_header:将特定报头实例(及其所有字段)设置为有效。
  • remove_header:从数据包中删除(“弹出”)报头(及其所有字段)。
  • increment:增加或减少字段中的值。
  • checksun:计算一些报头字段集的校验和(例如,IPv4校验和)。

我们期望大多数交换机实现能够限制动作处理以仅允许与指定数据包格式一致的报头修改。

4.6 控制程序

一旦定义了表和操作,剩下的唯一任务就是指定从一个表到下一个表的控制流。控制流通过函数、条件和表引用的集合被指定为程序。

image.png

图4:mTag示例的流程图

图4 显示了边缘交换机上mTag实现所需控制流的图形表示。解析后,source_check表验证接收到的数据包与入端口的一致性。例如,mTags只能在连接到的端口上看到核心交换机。source_check还从数据包中剥离mTags,记录该数据包元数据是否有一个mTag。流水线中后面的表可能会匹配此元数据,以避免重新标记数据包。

然后执行local_switching表。如果该表“未命中”,则表明该数据包的目的地不是本地连接的主机。在这种情况下,mTag表(如上定义)将应用于数据包。本地和核心转发控制都可以由出口检查表处理,该表通过向SDN控制堆栈向上发送通知来处理未知目的地的情况。

这个数据包处理流水线的命令式表示如下:

control main() {
  // Verify mTag state and port are consistent
  table(source_check);
  // If no error from source_check, continue
  if (!defined(metadata.ingress_error)) {
    // Attempt to switch to end hosts
    table(local_switching);
    if (!defined(metadata.egress_spec)) {
      // Not a known local host; try mtagging
      table(mTag_table);
    }
    // Check for unknown egress state or
    // bad retagging with mTag.
    table(egress_check);
  }
}

5 编译P4程序

对于实现我们P4程序的网络,我们需要一个编译器来将独立于目标的描述映射到目标交换机的特定硬件或软件平台上。这样做涉及分配目标的资源并为设备生成适当的配置。

5.1 编译数据包解析器

对于具有可编程解析器的设备,编译器将解析器描述转换为解析状态机,而对于固定解析器,编译器仅验证解析器描述与目标的解析器是否一致。生成状态机和状态表条目的详细信息可以在[16] 中找到。

表2显示了解析器的vlan和mTag部分的状态表条目(第4.3节)。每个条目指定当前状态、要匹配的字段值和下一个状态。为简洁起见,省略了其他列。

image.png

表2:mTag示例的解析器状态表条目

5.2 编写控制程序

第4.6节中的命令式控制流表示是一种指定交换机逻辑转发行为的便捷方式,但没有明确指出表之间的依赖关系或并发机会。因此,我们使用编译器来分析控制程序以识别依赖关系并寻找并行处理头字段的机会。最后,编译器为交换机生成目标配置。有许多潜在的目标:例如,软件交换机[17]、多核软件交换机[18]、NPU [19]、固定功能交换机[20] 或可重构匹配表 (RMT) 管道[ 2]。

如第3节所述,编译器遵循两阶段编译过程。它首先将P4控制程序转换为一个中间表依赖关系图表示,它对其进行分析以确定表之间的依赖关系。特定于目标的后端然后将此图映射到交换机的特定资源。

我们简要地研究了mTag示例在不同种类的交换机中的实现:

  • 软件交换机:软件交换机提供了完全的灵活性:表计数、表配置和解析都在软件控制之下。编译器直接将mTag表图映射到切换表。编译器使用表类型信息来约束每个表的表宽度、高度和匹配标准(例如,精确、前缀或通配符)。编译器还可以优化与软件数据结构的三元或前缀匹配。
  • 带有RAM和TCAM的硬件交换机:编译器可以配置哈希以使用RAM对边缘交换机中的mTag表执行有效的精确匹配。相比之下,匹配标签位子集的核心mTag 转发表将映射到TCAM。
  • 支持并行表的交换机:编译器可以检测数据依赖关系,并分派表并行或串行排列。在mTag示例中,表本地切换和mTag表可以并行执行,直到执行设置mTag的动作。
  • 在流水线末端应用动作的交换机:对于仅在流水线末端进行动作处理的交换机,编译器可以告诉中间阶段生成用于执行最终写入的元数据。在mTag示例中,可以在元数据中表示添加或删除 mTag。
  • 带有少量表的交换机:编译器可以将大量P4表映射到较少数量的物理表。在mTag示例中,本地交换可以与mTag表相结合。当控制器在运行时安装新规则时,编译器的规则翻译器可以“组合”两个P4表中的规则以生成单个物理表的规则。

6 结论

SDN的承诺是单个控制平面可以控制整个网络的交换机。OpenFlow通过提供单一的、与供应商无关的API来支持这一目标。然而,当前的OpenFlow的目标是固定功能的交换机,这些交换机识别一组预先确定的报头字段,并使用一小组预定义的动作处理数据包。控制平面无法表达应如何处理数据包以最好地满足控制应用程序的需求。

我们建议,朝着更灵活的交换机迈出一步,该交换机的功能已经在现场指定(并且可能会更改)。程序员决定转发平面如何处理数据包,而无需担心实现细节。编译器将命令式程序转换为可以映射到许多特定目标交换机的表依赖图,包括优化的硬件实现。

我们强调这只是第一步,旨在作为OpenFlow 2.0的稻草人提案为辩论做出贡献。在这个提议中,交换机的几个方面仍未定义(例如,拥塞控制原语、排队规则、流量监控)。然而,我们相信拥有一种配置语言的方法——以及为特定目标生成低级配置的编译器——将导致未来的交换机提供更大的灵活性,并释放软件定义网络的潜力。

7 参考文献

(正文完)


作者:Chaobowx
来源:https://mp.weixin.qq.com/s/OQOqpAv6jlfRLlI7BoQwuQ
微信公众号:
 title=

相关文章推荐

更多软硬件技术干货请关注软硬件融合专栏。
1 阅读 1.7k
推荐阅读
关注数
2752
内容数
82
软硬件融合
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息