Posted in

为什么Kubernetes CNI插件87%用Go写?深度拆解虚拟网卡在云原生中的底层设计逻辑

第一章:Kubernetes CNI插件为何87%选择Go语言

Go语言已成为Kubernetes生态中CNI(Container Network Interface)插件开发的绝对主流——据2023年CNCF年度调研,活跃维护的CNI实现中87%采用Go编写。这一高度集中的技术选型并非偶然,而是由Kubernetes原生架构、运行时约束与工程实践共同驱动的结果。

原生兼容性与二进制分发优势

Kubernetes核心组件(如kubelet、kube-proxy)全部用Go编写,共享相同的runtime(gc、goroutine调度器)和标准库。CNI插件以可执行二进制形式被调用,Go的静态链接特性使其无需依赖外部libc或复杂环境,单文件即可部署:

# 编译生成无依赖二进制(Linux AMD64)
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o my-cni-plugin ./cmd/main.go
# 部署到节点后,kubelet直接调用(无需安装Go运行时)
/opt/cni/bin/my-cni-plugin

该特性极大简化了插件分发与版本管理,避免了Python/Java插件常见的虚拟环境或JVM版本冲突问题。

并发模型与网络I/O效率

CNI插件需在毫秒级完成容器网络配置(如IP分配、iptables规则注入)。Go的轻量级goroutine与非阻塞net/http、net包天然适配高并发网络操作:

  • 单个插件进程可同时处理多个Pod的ADD/DEL请求;
  • netlink库(如github.com/vishvananda/netlink)提供零拷贝socket通信,直接对接Linux内核网络子系统;
  • 对比Python的GIL限制或Rust需手动管理异步生命周期,Go在开发效率与性能间取得更优平衡。

生态工具链深度集成

工具类别 Go生态支持示例 CNI场景价值
测试框架 testing.T, gomock 快速验证IPAM分配逻辑与错误路径
CLI构建 spf13/cobra 标准化my-cni-plugin add/del/check命令
Kubernetes客户端 kubernetes/client-go 直接读取ConfigMap获取集群网络策略

这种开箱即用的工程支持,使开发者能将90%精力聚焦于网络逻辑本身,而非基础设施胶水代码。

第二章:Go语言在虚拟网卡开发中的核心优势

2.1 Go的并发模型与网络I/O零拷贝实践

Go 的 goroutine + channel 模型天然适配高并发网络服务,而 io.Copy 结合 net.Conn 的底层 splice(Linux)或 sendfile 调用,可绕过用户态缓冲区实现零拷贝。

零拷贝关键路径

  • 用户态无需分配/复制数据缓冲区
  • 内核直接在 socket buffer 与文件 page cache 间搬运指针
  • 仅需一次系统调用(如 sendfile(2)),避免 read/write 四次上下文切换

Go 标准库支持现状

场景 是否零拷贝 条件
http.FileServer ✅(Linux/macOS) 文件句柄 + net.Conn 支持 WriterTo
io.Copy(net.Conn, *os.File) 底层自动降级为 sendfilesplice
bytes.Buffer → Conn 必经用户态内存
// 使用 WriterTo 触发零拷贝路径
f, _ := os.Open("large.bin")
defer f.Close()
_, err := io.Copy(conn, f) // 若 conn 实现 net.Conn.WriteTo 且内核支持,自动启用 sendfile

该调用会尝试 conn.(io.WriterTo).WriteTo(f),成功则跳过用户态内存拷贝;失败时优雅回退至常规 Read/Write 循环。

graph TD
    A[io.Copy] --> B{conn implements WriterTo?}
    B -->|Yes| C[call conn.WriteTo(file)]
    B -->|No| D[read into buf → write to conn]
    C --> E{kernel supports sendfile?}
    E -->|Yes| F[zero-copy transfer]
    E -->|No| D

2.2 Go标准库net/netlink深度解析与CNI接口对接实战

Go 的 net/netlink 包虽未公开导出核心类型,但通过 golang.org/x/sys/unix 可直接构造 Netlink 消息,实现与内核网络子系统的零拷贝交互。

CNI 接口调用关键路径

  • CNI 插件接收 JSON 配置(ADD/DEL 操作)
  • 调用 netlink.LinkAdd() 创建 veth 对
  • 使用 netlink.AddrAdd() 配置 IP 并启用 UP 状态

veth 创建与命名空间挂载示例

// 创建 veth 对:hostVeth ↔ containerVeth
link := &netlink.Veth{
    LinkAttrs: netlink.LinkAttrs{Name: "veth-host"},
    PeerName:  "veth-cont",
}
if err := netlink.LinkAdd(link); err != nil {
    log.Fatal(err) // e.g., "operation not permitted" → 需 CAP_NET_ADMIN
}

LinkAttrs.Name 指定宿主机侧接口名;PeerName 自动创建对端并绑定至当前 netns。失败常见于权限不足或命名冲突。

字段 类型 说明
Name string 宿主机可见接口名(如 cni0
MTU int 默认 1500,CNI 常设为 1450 适配 VXLAN 封装
Flags netlink.LinkFlags LINK_UP 控制启停
graph TD
    A[CNI Plugin] -->|ADD request| B[netlink.LinkAdd]
    B --> C[veth pair created]
    C --> D[netlink.LinkSetNsFd]
    D --> E[Move peer to container netns]

2.3 Go内存管理机制对veth pair生命周期控制的影响分析

Go 的垃圾回收(GC)不感知内核网络对象,导致 veth pair 的用户态句柄(如 *net.Interfaceos.File)被回收后,内核中对应的网络设备仍可能残留。

GC 与资源释放的语义鸿沟

  • Go 中 netlink 连接或 syscall.Socket 文件描述符若未显式 Close(),仅依赖 finalizer 清理,存在延迟风险;
  • veth 设备的销毁需主动调用 netlink 删除接口,而非等待 GC。

典型误用示例

func createVeth() error {
    fd, _ := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW, unix.NETLINK_ROUTE, 0)
    // 忘记 defer unix.Close(fd) —— GC 不保证及时释放 netlink socket
    return setupVethViaNetlink(fd) // fd 泄漏 → netlink 消息丢失 → veth 残留
}

fd 是内核资源代理,Go 的 runtime.SetFinalizer 触发时机不可控(可能在程序退出前未执行),造成 veth 设备无法被正确解绑或删除。

推荐实践对比

方式 可靠性 时序可控性 是否需手动 Close
defer fd.Close() ★★★★☆
runtime.SetFinalizer ★★☆☆☆ 否(但不可靠)
graph TD
    A[创建 veth pair] --> B[获取 netlink socket fd]
    B --> C[发送 RTM_NEWLINK 消息]
    C --> D[内核创建 veth 设备]
    D --> E[Go runtime 管理 fd]
    E --> F{fd 是否显式 Close?}
    F -->|是| G[立即释放 socket,确保消息送达]
    F -->|否| H[依赖 Finalizer → 可能错过设备清理窗口]

2.4 基于Go的eBPF辅助程序加载与虚拟网卡策略注入实操

核心依赖与初始化

需引入 github.com/cilium/ebpfgolang.org/x/sys/unix,确保内核版本 ≥5.4(支持 BPF_PROG_TYPE_CGROUP_SKB)。

加载eBPF程序到cgroup

// 加载eBPF字节码并附加至虚拟网卡对应cgroup路径
spec, err := ebpf.LoadCollectionSpec("prog.o")
if err != nil {
    log.Fatal(err)
}
coll, err := ebpf.NewCollection(spec)
if err != nil {
    log.Fatal(err)
}
// 将程序挂载到 /sys/fs/cgroup/veth0(对应容器网络命名空间)
link, err := link.AttachCgroup(&link.CgroupOptions{
    Path:    "/sys/fs/cgroup/veth0",
    Attach:  unix.BPF_CGROUP_INET_EGRESS,
    Program: coll.Programs["filter_egress"],
})

逻辑分析AttachCgroup 将 eBPF 程序绑定至 cgroup v2 路径,BPF_CGROUP_INET_EGRESS 指定在数据包离开网络栈前拦截;veth0 需提前通过 ip link add 创建并挂入目标命名空间。

策略注入流程

graph TD
    A[Go应用启动] --> B[解析策略规则]
    B --> C[编译eBPF字节码]
    C --> D[加载至内核]
    D --> E[挂载到veth对应cgroup]
    E --> F[实时过滤/标记流量]

支持的策略类型

类型 示例值 作用域
源IP白名单 10.244.1.5/32 egress流量过滤
端口限速 80:10mbit TC qdisc集成
协议标记 TCP+DSCP=0x20 QoS分级调度

2.5 Go模块化设计如何支撑CNI插件热插拔与多租户隔离

Go 的接口抽象与包级封装天然适配 CNI 插件的解耦需求。核心在于 github.com/containernetworking/cni/pkg/types.CurrentResultPluginInterface 的契约定义,使插件实现可独立编译、动态加载。

插件注册与运行时发现

// plugin/loader.go:基于文件系统扫描自动注册插件
func LoadPlugins(dir string) (map[string]cni.Plugin, error) {
    plugins := make(map[string]cni.Plugin)
    files, _ := os.ReadDir(dir)
    for _, f := range files {
        if strings.HasSuffix(f.Name(), ".so") {
            p, err := cni.LoadPlugin(f.Name()) // 使用 go:embed + plugin.Open 加载
            if err == nil {
                plugins[f.Name()] = p
            }
        }
    }
    return plugins, nil
}

该机制允许在不重启 kubelet 的前提下,向 /opt/cni/bin/ 目录新增/替换 .so 插件二进制,实现热插拔。

多租户网络命名空间隔离

租户标识 网络策略作用域 插件配置路径
tenant-a Pod NetworkPolicy + vrfID /etc/cni/net.d/tenant-a.conflist
tenant-b Calico eBPF + tenant label /etc/cni/net.d/tenant-b.conflist

生命周期与上下文隔离

func (p *TenantPlugin) SetUpPod(ctx context.Context, args *skel.CmdArgs) error {
    // ctx.Value(TenantKey{}) 提供租户上下文,避免跨租户状态污染
    tenant := ctx.Value(TenantKey{}).(string)
    network, _ := p.configs.Get(tenant) // 配置按租户分片
    return network.Configure(args)
}

通过 context.Context 注入租户元数据,结合 sync.Map 缓存租户专属配置,保障并发安全与资源隔离。

第三章:虚拟网卡在云原生网络栈中的底层定位

3.1 从Linux内核网络命名空间到Pod虚拟网卡的映射路径追踪

当Kubernetes创建Pod时,kubelet调用CNI插件在宿主机上为Pod分配独立网络命名空间,并注入虚拟以太网对(veth pair)。

网络命名空间绑定路径

  • 宿主机侧veth(如 vethabc123)桥接到cbr0k8sbr0
  • Pod侧veth(如 eth0)被move进目标netns,通过ip link set netns <pid>完成迁移

关键内核接口映射

# 查看Pod进程的网络命名空间绑定
ls -l /proc/<pod-pid>/ns/net
# 输出示例:net:[4026532745] —— 唯一inode号即netns标识

该inode号与/var/run/netns/中挂载点或nsenter -n -t <pid>所操作的命名空间严格一一对应。

veth设备双向关联表

宿主机端口 Pod内设备名 netns inode 桥接状态
vetha7f2c9 eth0 4026532745 UP
vethb3d8e1 eth0 4026532746 UP
graph TD
    A[Pod YAML定义] --> B[kubelet调用CNI]
    B --> C[创建netns并fork init进程]
    C --> D[生成veth pair]
    D --> E[move veth右端入netns]
    E --> F[配置IP、路由、DNS]

逻辑上,/proc/<pid>/ns/net 的inode是整条映射链的锚点——所有后续网络设备归属、路由表隔离、iptables规则匹配均以此为上下文边界。

3.2 veth、macvlan、ipvlan三类虚拟网卡在CNI场景下的选型决策树

核心选型维度

需权衡:网络隔离强度、L2/L3拓扑需求、宿主机路由负担、MAC地址管理复杂度。

典型场景对比

特性 veth macvlan ipvlan
隔离层级 Namespace级(L3) L2广播域隔离 L3模式下共享MAC但隔离IP
宿主机参与转发 必需(bridge/iptables) 可绕过(直连物理网卡) 可绕过(L3 mode)
MAC地址约束 无限制 物理网卡需启用promisc 无需promisc,MAC复用
# CNI配置片段:ipvlan L3模式示例
{
  "cniVersion": "1.0.0",
  "type": "ipvlan",
  "master": "eth0",
  "mode": "l3",  # 关键:避免ARP冲突,适配云环境多租户IP路由
  "ipam": { "type": "static", "addresses": [{"address": "10.10.1.10/24"}] }
}

mode: l3 强制IPv4层转发,规避macvlan的MAC泛洪与交换机端口安全策略冲突;master 必须为物理上行接口,不可为bond或vlan子接口。

决策路径

graph TD
  A[容器需独立MAC?] -->|是| B[物理网卡支持promisc?]
  A -->|否| C[选ipvlan L3或veth]
  B -->|是| D[macvlan bridge/mode]
  B -->|否| E[ipvlan L3]
  C --> F[veth:兼容性最优,但宿主机开销高]

3.3 虚拟网卡MTU、ARP、GRO/GSO参数调优对Service Mesh性能的实际影响

在Envoy+iptables透明拦截模式下,虚拟网卡(如veth对、cni0)的底层参数直接影响数据平面吞吐与延迟。

MTU对分片与TLS握手的影响

默认1500字节MTU在Istio mTLS场景中易触发IPv4分片,增加丢包率。建议统一设为9000(需底层支持Jumbo Frame):

# 在Pod initContainer中执行
ip link set dev eth0 mtu 9000

该命令避免TLS record被分割,减少TCP重传,实测P99延迟下降23%(基于eBPF观测)。

GRO/GSO协同优化

启用GRO(接收端聚合)与禁用GSO(发送端分段)可降低Envoy用户态处理开销: 参数 推荐值 作用
net.ipv4.tcp_gro_enable 1 合并同一流的TCP分段
net.ipv4.tcp_gso_disable 1 避免内核GSO与Envoy TLS分段冲突
graph TD
    A[原始TCP包] --> B{GRO启用?}
    B -->|是| C[内核合并→单次syscall]
    B -->|否| D[多小包→多次epoll_wait]
    C --> E[Envoy解析效率↑]

第四章:基于Go实现轻量级CNI虚拟网卡插件的全链路拆解

4.1 初始化阶段:netns挂载、veth创建与IPAM协同逻辑编码

容器网络初始化依赖 netns 隔离、veth 对连通性及 IP 地址分配的原子协同。三者需严格时序控制,避免竞态。

netns 挂载与上下文切换

# 创建并挂载独立网络命名空间
ip netns add ns0
mount --bind /var/run/netns/ns0 /proc/self/ns/net

ip netns add 实质调用 unshare(CLONE_NEWNET) 并在 /var/run/netns/ 下绑定 ns/net 文件;后续操作需通过 nsenter -n -t <pid>ip netns exec ns0 切换上下文。

veth 对创建与跨命名空间绑定

# 创建 veth 对,并将一端移入 netns
ip link add veth0 type veth peer name veth1
ip link set veth1 netns ns0

veth1netns 属性变更触发内核将其从初始命名空间解绑,并注入目标 netns;此时 veth0 留在宿主机,veth1ns0 中——为后续 IP 分配提供端点。

IPAM 协同关键约束

阶段 执行主体 必须前置条件
netns 创建 CNI 插件主进程 root 权限、unshare 支持
veth 绑定 主进程 netns 已存在且可访问
IP 分配 IPAM 模块 veth 设备已就绪(UP 状态)
graph TD
    A[创建 netns] --> B[创建 veth 对]
    B --> C[移动 veth1 至 netns]
    C --> D[设置 veth0/veth1 UP]
    D --> E[调用 IPAM 分配地址]
    E --> F[配置双方 IP & 路由]

4.2 配置阶段:iptables/nftables规则动态生成与Go netlink事务封装

动态规则生成的核心抽象

基于策略模板与运行时上下文(如Pod IP、端口映射、标签选择器),通过结构化数据驱动生成nftables规则集。相比静态规则文件,支持实时增删改查与原子提交。

Go netlink事务封装关键设计

  • 使用github.com/google/nftables构建规则树,避免裸socket操作
  • 封装ConnRuleManager,内置事务缓冲与错误回滚机制
  • 每次Apply()触发nftables.Flush()前先校验规则语法一致性
// 构建一条允许特定源IP访问服务端口的nftables rule
rule := &nftables.Rule{
    Table: ipTable, // ipv4/ipv6表实例
    Chain: inputChain,
    Exprs: []expr.Expression{
        &expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
        &expr.Cmp{
            Op:       expr.CmpOpEq,
            Register: 1,
            Data:     []byte{unix.NFPROTO_IPV4},
        },
        &expr.Payload{
            DstRegister: 1,
            Offset:      12, // IPv4 src addr offset
            Len:         4,
        },
        &expr.Cmp{
            Op:       expr.CmpOpEq,
            Register: 1,
            Data:     net.ParseIP("10.244.1.5").To4(),
        },
        &expr.Counter{},
    },
}

该代码构造IPv4入向链中匹配指定源IP的规则表达式序列;Payload提取IP头源地址字段,Cmp执行精确比对,Counter启用流量计数——所有操作均在用户态完成,无需内核模块介入。

规则生命周期管理对比

特性 iptables-legacy nftables + Go netlink
规则批量提交 依赖iptables-restore文本解析 原生支持nftables.Transaction原子写入
并发安全 依赖文件锁或-w参数 Conn内部使用sync.Mutex保护netlink socket
graph TD
    A[策略变更事件] --> B[生成nftables Rule对象]
    B --> C{是否启用dry-run?}
    C -->|是| D[模拟执行并输出diff]
    C -->|否| E[构造Transaction]
    E --> F[调用Conn.BatchUpdate]
    F --> G[内核netlink socket写入]

4.3 运行时阶段:通过Go channel监听pod网络事件并触发网卡重配置

事件驱动架构设计

采用 k8s.io/client-go 的 Informer 机制监听 Pod 资源变更,将事件经 channel 异步投递给处理协程,解耦监听与执行逻辑。

核心监听循环示例

// podEventCh 接收 Pod Add/Update/Delete 事件
for event := range podEventCh {
    pod := event.Object.(*corev1.Pod)
    if isTargetPod(pod) && needsNetReconf(pod) {
        go reconfigureVeth(pod.Name, pod.Namespace) // 触发网卡重配
    }
}

逻辑分析:event.Object 断言为 *corev1.Pod 确保类型安全;isTargetPod() 过滤带 network.k8s.io/reconfigure=true annotation 的 Pod;needsNetReconf() 检查 status.phase == Runningannotations 中存在 net-config-hash 变更。

重配置关键参数表

参数 类型 说明
pod.Name string 容器运行时命名空间内唯一标识
vethName string 由 CNI 插件生成的宿主机侧 veth 名(如 vethabc123
mtu int 动态从 Pod annotation k8s.v1.cni.cncf.io/mtu 读取

流程概览

graph TD
    A[Informer ListWatch] --> B[Pod Event]
    B --> C{Channel 分发}
    C --> D[Hash比对]
    D -->|变更| E[调用 ip link set ... mtu/addr]
    D -->|未变| F[忽略]

4.4 清理阶段:SIGTERM信号捕获与虚拟网卡资源原子性释放实践

信号捕获与优雅退出入口

使用 signal.Notify 捕获 SIGTERM,避免进程被粗暴终止导致资源泄漏:

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
    <-sigChan
    cleanup() // 触发原子性释放
}()

逻辑分析:os.Signal 通道容量为1,确保首次信号必被接收;syscall.SIGINT 兼容调试场景;cleanup() 必须幂等且不可重入。

虚拟网卡释放的原子性保障

采用“标记-清理-验证”三步法,防止网卡设备残留:

步骤 操作 安全约束
标记 设置 isShuttingDown = true(atomic.Bool) 阻止新连接绑定该网卡
清理 调用 netlink.LinkDel(link) 并等待 RTM_DELLINK 确认 依赖 netlink socket 同步响应
验证 查询 /sys/class/net/ 目录确认设备消失 文件系统级最终一致性校验

资源释放流程

graph TD
    A[收到 SIGTERM] --> B[设置 shutdown 标志]
    B --> C[关闭监听套接字]
    C --> D[异步删除 veth pair]
    D --> E[等待 netlink ACK]
    E --> F[校验 sysfs 设备移除]

第五章:云原生网络演进趋势与Go语言的长期适配性

服务网格数据平面的轻量化重构

随着 Istio 1.20+ 推出基于 eBPF 的 Sidecar 替代方案(如 Istio Ambient Mesh),传统 Envoy + Go 控制平面组合面临性能再平衡。某金融级 API 网关项目实测表明:将核心流量路由模块从 Python 改写为 Go 后,P99 延迟从 82ms 降至 14ms,内存常驻占用减少 63%。其关键在于 Go 的 goroutine 调度器与 eBPF Map 的高效协同——通过 github.com/cilium/ebpf 库直接操作 XDP 程序,避免内核态/用户态频繁拷贝。

WebAssembly 边缘网络扩展实践

Cloudflare Workers 和 Fastly Compute@Edge 已支持 Go 编译的 Wasm 模块。某 CDN 厂商将 TLS 握手策略引擎用 Go 实现并编译为 Wasm,部署至全球 320 个边缘节点。对比 Node.js 版本,冷启动时间缩短 78%,且因 Go 的内存安全特性,零 CVE 报告持续 18 个月。其构建链路如下:

GOOS=wasip1 GOARCH=wasm go build -o policy.wasm ./cmd/policy
wabt-wasm-strip policy.wasm  # 移除调试符号

多运行时服务网格的协议兼容性挑战

在混合部署场景中(Kubernetes + VM + IoT 设备),gRPC-Web、HTTP/3、MQTT over QUIC 等协议共存。某工业物联网平台采用 Go 实现统一协议网关,关键代码片段展示其动态协议协商能力:

func (g *Gateway) negotiateProtocol(req *http.Request) (string, error) {
    if req.Header.Get("Alt-Svc") == "h3=" && quic.IsAvailable() {
        return "http/3", nil
    }
    if strings.Contains(req.Header.Get("Accept"), "application/grpc-web+json") {
        return "grpc-web", nil
    }
    return "http/1.1", nil
}

云原生网络可观测性栈的 Go 原生集成

OpenTelemetry Collector 的 Go SDK 已成为默认采集层。某电商系统将 Go 服务的 net/http 中间件与 OTel 自动注入深度耦合,实现 0 代码修改的全链路指标捕获。下表对比不同语言 SDK 在高并发下的资源开销(10k RPS 场景):

语言 CPU 占用率 内存增量 Trace 采样精度
Go 12.3% +48MB 99.98%
Java 34.7% +210MB 95.2%
Rust 9.1% +32MB 99.99%

eBPF + Go 的零信任网络策略引擎

某政务云平台基于 libbpf-go 构建动态网络策略执行器,实时解析 Kubernetes NetworkPolicy 并生成 eBPF 程序。当 Pod 标签变更时,策略更新延迟控制在 87ms 内(P99)。其核心机制是 Go 进程监听 APIServer 的 watch 事件流,并调用 bpf.NewProgram() 加载新字节码,绕过传统 iptables 规则重载瓶颈。

异构网络拓扑的声明式配置同步

在跨 AZ、跨云、跨边缘的混合网络中,Go 的结构化配置能力凸显优势。某跨国物流企业使用 Go 编写的 network-sync 工具,通过 CRD 定义全局网络策略,自动转换为 AWS Security Group、阿里云 ACL、本地 Calico NetworkPolicy 三套语法。其 YAML 到多平台 DSL 的转换规则采用 Go 的 text/template 实现,模板复用率达 82%。

网络函数即服务(NFaaS)的 Go 运行时优化

CNCF Sandbox 项目 NetFunc 使用 Go 构建无状态网络函数沙箱,通过 gvisor 隔离容器与宿主机网络栈。实测显示:单节点可并发运行 1200+ 网络函数实例,每个实例平均启动耗时 23ms,远低于 Rust(41ms)和 Python(187ms)同类方案。其关键优化点包括:预分配 goroutine 池、禁用 GC 扫描网络缓冲区、使用 unsafe.Slice 直接操作 socket buffer。

云原生网络正加速向数据平面卸载、协议栈融合、策略驱动演进,而 Go 语言凭借其并发模型、内存确定性、Wasm/eBPF 生态支持,在此进程中展现出不可替代的工程韧性。

守护服务器稳定运行,自动化是喵的最爱。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注