Posted in

网络工程师转岗云平台开发的Go跃迁路径:3个月达成K8s CNI插件开发能力

第一章:网络工程师的Go语言认知重构

传统网络工程师习惯于使用Python处理设备配置、解析日志或调用REST API,而Go语言以其并发模型、静态编译、低内存开销和原生网络库,正成为云原生网络自动化的新基础设施语言。这种转变不仅是语法迁移,更是对“网络即服务”范式下可靠性和可观测性要求的认知升级——Go强制的显式错误处理、无隐式类型转换、以及基于接口的组合设计,天然契合网络系统对确定性行为的严苛需求。

为什么是Go而非其他语言

  • 零依赖部署:go build -o nettool main.go 生成单个二进制文件,可直接拷贝至无Go环境的Linux路由器管理主机运行
  • 并发安全的网络编程:net/http 和 net 包内置goroutine友好设计,无需额外线程池即可高效处理数百并发TCP连接
  • 强类型与工具链协同:go vet、staticcheck 可在CI中自动捕获常见网络编程陷阱(如未关闭HTTP响应体、UDP缓冲区溢出)

快速体验:一个轻量级CLI网络探测器

以下代码实现一个支持Ping与HTTP状态检查的命令行工具,体现Go的模块化与跨平台能力:

package main

import (
    "fmt"
    "net/http"
    "os"
    "os/exec"
    "time"
)

func main() {
    target := os.Args[1] // 使用方式:./netprobe google.com
    if len(os.Args) < 2 {
        fmt.Println("Usage: ./netprobe <host>")
        os.Exit(1)
    }

    // ICMP探测(Linux/macOS兼容)
    pingCmd := exec.Command("ping", "-c", "1", "-W", "2", target)
    pingCmd.Stdout = nil
    pingCmd.Stderr = nil
    pingTimeout := time.AfterFunc(3*time.Second, func() { pingCmd.Process.Kill() })
    err := pingCmd.Run()
    pingTimeout.Stop()
    pingOK := err == nil

    // HTTP探测(统一超时控制)
    client := &http.Client{Timeout: 3 * time.Second}
    resp, _ := client.Get("http://" + target)
    httpOK := resp != nil && resp.StatusCode < 400

    fmt.Printf("Target: %s | Ping: %t | HTTP: %t\n", target, pingOK, httpOK)
}

执行步骤:保存为 netprobe.go → 运行 go build -o netprobe netprobe.go → 执行 ./netprobe github.com。该工具不依赖外部库,编译后体积小于12MB,可在嵌入式网管终端静默运行。

关键心智模型切换

传统思维 Go语言实践
“脚本快速上线”优先 “编译期校验+运行时确定性”优先
异步靠回调/事件循环 Goroutine + channel 协同通信
配置即代码(YAML/JSON) 结构体标签(json:"ip")直连序列化

第二章:Go语言核心能力筑基

2.1 Go并发模型与网络IO实践:从epoll到goroutine+channel的范式迁移

epoll的阻塞与扩展瓶颈

传统Linux网络服务依赖epoll_wait轮询就绪事件,需手动管理fd集合、缓冲区与状态机。高并发下线程/进程模型导致上下文切换开销陡增,且难以自然表达“等待某连接完成读取后处理”的业务逻辑。

goroutine + channel:语义即调度

Go运行时将网络IO(如net.Conn.Read)自动挂起goroutine,由netpoller(封装epoll/kqueue)唤醒,开发者仅关注逻辑流:

// 服务端典型模式:每个连接独立goroutine
listener, _ := net.Listen("tcp", ":8080")
for {
    conn, _ := listener.Accept() // 阻塞但不消耗OS线程
    go func(c net.Conn) {
        defer c.Close()
        buf := make([]byte, 1024)
        n, _ := c.Read(buf) // 自动注册到netpoller,goroutine休眠
        c.Write(buf[:n])     // 唤醒后继续执行
    }(conn)
}

逻辑分析:conn.Read()底层触发syscall.Syscall,若无数据则调用runtime.netpollblock使goroutine进入Gwait状态;netpoller在fd就绪时调用runtime.netpollunblock恢复调度。参数buf为用户分配的切片,避免逃逸;n为实际读取字节数,决定后续写入范围。

范式对比核心差异

维度 epoll + 线程池 goroutine + channel
并发单元 OS线程(~MB栈) 用户态协程(~2KB初始栈)
同步原语 mutex/condvar/信号量 channel(带内存顺序保证)
错误传播 errno全局变量 error返回值(类型安全)

数据同步机制

channel天然承载通信与同步双重语义:

  • ch <- data:发送阻塞直至接收方就绪(或缓冲满)
  • <-ch:接收阻塞直至有数据到达(或channel关闭)
  • 关闭channel后,所有接收操作立即返回零值,实现优雅退出信号传递
graph TD
    A[Accept新连接] --> B[启动goroutine]
    B --> C[Read阻塞 → netpoller注册]
    C --> D[epoll_wait返回就绪]
    D --> E[唤醒goroutine继续执行]
    E --> F[Write响应]

2.2 Go标准库net/netip与syscall深度解析:构建零拷贝网络数据包处理原型

netip vs net.IP:内存布局与零拷贝前提

netip.Addr 是不可变值类型,直接内嵌 16 字节 IPv6 地址(IPv4 映射为 ::ffff:a.b.c.d),避免指针间接与堆分配;而 net.IP 是 []byte 切片,含 header 开销且易逃逸。

syscall.RawConn 与 socket 控制权接管

// 获取底层 socket fd 并禁用内核缓冲
rawConn, _ := ln.(*net.TCPListener).SyscallConn()
rawConn.Control(func(fd uintptr) {
    unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, 0) // 关闭接收缓冲
})

逻辑分析:Control 在 goroutine 安全上下文中执行系统调用;SO_RCVBUF=0 强制绕过内核协议栈缓冲区,使 recvfrom 直接读取网卡 DMA 区域(需配合 AF_PACKET 或 XDP)。

零拷贝关键路径对比

组件 内存拷贝次数 用户态地址空间映射 适用场景
net.Conn.Read() 2+(kernel→user buffer→application) 否 通用 TCP/UDP
syscall.Recvfrom() + netip 0(DMA→userspace ring buffer) 是(需 mmap) 高吞吐报文嗅探
graph TD
    A[网卡 DMA] --> B[userspace ring buffer]
    B --> C[syscall.Recvfrom]
    C --> D[netip.AddrFrom16]
    D --> E[无堆分配解析]

2.3 Go模块化工程实践:基于go.mod的CNI插件项目结构设计与依赖治理

CNI插件需严格隔离网络逻辑与宿主环境,go.mod 是实现可复现构建与版本锁定的核心。

项目结构约定

cni-plugin/
├── go.mod                # 模块声明为 github.com/org/cni-plugin
├── cmd/plugin/           # 主二进制入口(独立构建)
├── pkg/ipam/             # 可复用IPAM子模块
└── internal/             # 非导出实现细节

依赖治理关键策略

  • 使用 replace 临时覆盖上游不稳定依赖(如 github.com/containernetworking/cni v1.1.2 => ./vendor/cni)
  • 通过 go mod graph | grep cni 审计间接依赖污染
  • 禁止 indirect 依赖出现在生产构建中(go list -m all | grep 'indirect$')

版本兼容性约束表

组件 CNI Spec 兼容 Go Module 要求
libcni v1.0.0+ v1.1.0
plugins v1.2.0+ v1.3.0
# 强制清理并重解析依赖树
go mod tidy -v && go mod verify

该命令重建 go.sum 并校验所有模块哈希,确保 CNI 插件在不同 CI 环境中生成完全一致的二进制。-v 输出详细解析路径,便于定位跨版本冲突点。

2.4 Go测试驱动开发(TDD)实战:为IPAM分配器编写单元测试与集成测试用例

单元测试:验证单个IP分配逻辑

使用 testify/assert 验证地址池初始化与分配行为:

func TestAllocator_Allocate(t *testing.T) {
    alloc := NewAllocator("192.168.1.0/24")
    ip, err := alloc.Allocate()
    assert.NoError(t, err)
    assert.Equal(t, net.ParseIP("192.168.1.1"), ip)
}

逻辑分析:NewAllocator 构建含 254 个可用地址的 CIDR 池;Allocate() 返回首个未占用 IP(跳过网络地址 .0)。参数 t 为测试上下文,确保并发安全。

集成测试:模拟多客户端竞争分配

通过 goroutine 并发调用,验证锁机制与唯一性:

场景 并发数 期望结果 实际分配数
正常分配 10 10 唯一 IPv4 10
耗尽池 300 分配失败 254

数据一致性保障

graph TD
    A[Allocate请求] --> B{地址池有空闲?}
    B -->|是| C[返回IP并标记占用]
    B -->|否| D[返回ErrNoAvailableIP]
    C --> E[更新内存状态]

2.5 Go性能剖析与调优:pprof分析CNI插件内存泄漏与调度延迟瓶颈

pprof采集实战配置

在CNI插件启动时注入性能探针:

import _ "net/http/pprof"

// 启动独立pprof服务端(非阻塞)
go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

该配置启用标准/debug/pprof/端点,支持heap、goroutine、trace等多维度采样。注意避免与主HTTP服务端口冲突,且需在容器中开放6060端口。

内存泄漏定位流程

  • curl http://localhost:6060/debug/pprof/heap?gc=1 获取强制GC后的堆快照
  • 使用go tool pprof -http=:8080 heap.pb 可视化分析
  • 关键指标:inuse_objects持续增长 + alloc_space无回落 → 典型泄漏信号

调度延迟诊断表

指标 正常阈值 异常表现
sched.latency > 1ms(协程阻塞)
goroutines > 2000(泄漏堆积)
gc.pause (avg) > 5ms(GC压力)

调用链瓶颈可视化

graph TD
    A[CNI Add请求] --> B[IPAM分配]
    B --> C[NetNS挂载]
    C --> D[bridge规则写入]
    D --> E[pprof trace捕获]
    E --> F[发现C→D耗时占比78%]

第三章:Kubernetes网络模型与CNI协议精要

3.1 CNI规范v1.1协议栈深度拆解:ADD/DEL/CHECK接口语义与错误码契约实践

CNI v1.1 将网络生命周期操作严格收敛为三个幂等性接口,其语义契约直接决定插件互操作可靠性。

接口语义契约

  • ADD:必须完成容器网络命名空间注入、IP分配、路由/ARP规则配置,不可假定前序状态
  • DEL:须幂等地清理所有ADD引入的资源,即使容器已销毁或网络命名空间不存在
  • CHECK:仅验证当前网络配置一致性(如IP可达性、路由存在性),禁止修改任何状态

核心错误码语义表

错误码 含义 插件行为要求
CNI_ERR_UNKNOWN 未定义错误 必须返回结构化JSON含code/msg字段
CNI_ERR_UNSUPPORTED_VERSION 版本不兼容 DEL/CHECK中仍需安全降级处理
// CHECK响应示例(符合v1.1错误码契约)
{
  "cniVersion": "1.1.0",
  "code": 100, // CNI_ERR_NETWORK_NOT_FOUND
  "msg": "network 'mynet' not found in plugin config"
}

该JSON必须由插件原样输出至stdout;任何stderr输出将被运行时忽略。错误码100表示网络配置缺失,调用方应终止重试而非降级执行。

graph TD
  A[ADD请求] --> B{命名空间是否存在?}
  B -->|是| C[分配IP并注入网络栈]
  B -->|否| D[返回CNI_ERR_INVALID_ARGS]
  C --> E[写入/proc/<pid>/ns/net路径]

3.2 kube-proxy与kubelet网络协同机制:从Pod生命周期看CNI调用上下文

Pod创建时的网络协同时序

当kubelet接收到API Server下发的Pod对象后,按序触发:

  1. 调用CNI插件(如bridge或calico)配置Pod网络命名空间
  2. 向kube-proxy同步Service端点信息(通过EndpointSlice API)
# kubelet调用CNI的典型参数(由runtimeConfig生成)
{
  "cniVersion": "1.0.0",
  "name": "mynet",
  "type": "calico",           # CNI插件类型
  "mode": "ipam",             # IP地址分配模式
  "args": { "K8S_POD_NAMESPACE": "default" }  # 注入Pod上下文
}

该JSON由kubelet构造并传给CNI二进制,K8S_POD_NAMESPACE等环境变量为CNI提供Pod元数据,确保IPAM能正确关联Namespace与NetworkPolicy。

数据同步机制

kubelet与kube-proxy通过共享etcd中的Endpoints和EndpointSlices实现状态对齐:

组件 关注资源 触发动作
kubelet Pod状态变更 调用CNI ADD/DEL
kube-proxy EndpointSlice更新 重写iptables/ipvs规则
graph TD
  A[Pod创建] --> B[kubelet: CNI ADD]
  B --> C[分配IP、设置veth]
  C --> D[kubelet上报Ready]
  D --> E[kube-proxy监听EndpointSlice]
  E --> F[更新Service流量转发规则]

3.3 主流CNI实现对比分析:Calico eBPF vs Cilium IPAM vs Flannel VXLAN的架构取舍

网络模型与数据面定位

  • Calico eBPF:绕过 iptables,直接在内核 eBPF 钩子(如 TC_INGRESS)注入策略,实现 L3/L4 级细粒度策略执行;
  • Cilium IPAM:基于 CRD 的分布式 IP 地址管理,支持 IPv4/IPv6 双栈、CIDR 分配与跨节点 CIDR 协调;
  • Flannel VXLAN:纯 L2 封装转发,依赖 flanneld 分配子网并写入 subnet.env,无策略能力。

性能与扩展性权衡

维度 Calico eBPF Cilium IPAM Flannel VXLAN
数据面延迟 最低(eBPF JIT) 低(eBPF + XDP) 中(UDP 封包开销)
策略模型 支持 NetworkPolicy + HostEndpoint CNP + EgressPolicy 无原生策略
# Cilium IPAM 配置示例(通过 CiliumClusterwideNetworkPolicy)
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: allow-internal-dns
spec:
  endpointSelector:
    matchLabels:
      k8s:io.kubernetes.pod.namespace: kube-system
  ingress:
  - fromEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: default
    toPorts:
    - ports:
      - port: "53"
        protocol: UDP

该配置将 DNS 访问策略编译为 eBPF 程序加载至 socket 层,避免 conntrack 查表;matchLabels 触发 CRD watch 事件,触发 IPAM 子网同步更新。

数据同步机制

graph TD
  A[etcd/k8s API] --> B[Cilium Operator]
  B --> C[IPPool 分配]
  C --> D[Node 上的 cilium-agent]
  D --> E[eBPF map 更新]
  E --> F[Pod 网络命名空间注入]

第四章:云原生网络插件开发实战

4.1 轻量级L2 Bridge CNI插件开发:支持VLAN隔离与MAC地址学习的Go实现

为满足多租户二层网络隔离需求,该插件基于 Linux bridge + 802.1Q VLAN 实现轻量级 L2 连通性,同时集成动态 MAC 学习机制。

核心能力设计

  • 基于 netlink 操作 bridge 和 VLAN 设备(非 shell 调用)
  • 每 Pod 接口绑定唯一 VLAN ID,实现广播域隔离
  • 利用 fdb 表实时学习并老化 MAC 条目

MAC 学习同步逻辑

// 启动后台协程定期同步 FDB 条目到内存映射
func (b *Bridge) startFDBSync() {
    ticker := time.NewTicker(5 * time.Second)
    for range ticker.C {
        fdbEntries, _ := netlink.FdbList(b.BridgeLink.Attrs().Index, netlink.FDB_NOFLAGS)
        b.macTable.UpdateFromFDB(fdbEntries) // 更新本地 LRU 缓存
    }
}

逻辑说明:通过 netlink.FdbList() 获取内核 FDB 表快照,UpdateFromFDB 过滤 port-type 为 self 的条目,并按 MAC→PortIndex 建立映射。参数 netlink.FDB_NOFLAGS 表示获取全部条目(含静态/动态)。

VLAN 配置关键字段对照表

字段 类型 说明
vlanId uint16 分配给 Pod 的 VLAN 标签
bridgeName string 宿主机侧网桥名(如 cni0)
master bool 是否作为主桥(启用 STP)
graph TD
    A[Pod 创建请求] --> B{CNI ADD}
    B --> C[分配VLAN ID]
    C --> D[创建 veth pair]
    D --> E[设置 host-end 为 trunk]
    E --> F[设置 cont-end 为 access + vlanId]
    F --> G[添加至 bridge + VLAN filter]

4.2 基于netlink的Linux网络栈编程:使用github.com/vishvananda/netlink配置veth、bridge与路由

github.com/vishvananda/netlink 提供了纯 Go 的 netlink 协议封装,绕过 shell 调用,实现原子性网络配置。

创建 veth 对并启用

link := &netlink.Veth{
    LinkAttrs: netlink.LinkAttrs{Name: "veth0"},
    PeerName:  "veth1",
}
if err := netlink.LinkAdd(link); err != nil {
    log.Fatal(err)
}
// 启用两端
netlink.LinkSetUp(&link.LinkAttrs)
netlink.LinkSetUp(&netlink.LinkAttrs{Name: "veth1"})

LinkAdd 触发 NETLINK_ROUTE 的 RTM_NEWLINK 消息;LinkSetUp 等价于 ip link set up,修改 IFF_UP 标志位。

桥接与 IP 分配

操作 netlink 方法 底层 netlink 消息
创建 bridge netlink.LinkAdd(&Bridge{...}) RTM_NEWLINK + IFLA_LINKINFO
加入端口 netlink.LinkSetMaster(veth, bridge) RTM_SETLINK + IFLA_MASTER
配置 IPv4 netlink.AddrAdd(link, &Addr{IP: ip}) RTM_NEWADDR

路由注入示例

route := netlink.Route{
    LinkIndex: veth0.Attrs().Index,
    Dst:       mustParseIPNet("10.1.1.0/24"),
    Gw:        net.ParseIP("10.1.1.1"),
}
netlink.RouteAdd(&route) // 触发 RTM_NEWROUTE

4.3 CNI插件安全加固实践:非root容器运行、Capability最小化与seccomp策略嵌入

CNI插件作为Kubernetes网络平面的关键组件,其运行权限直接影响集群边界安全。默认以root身份运行的插件一旦被利用,将导致宿主机网络栈劫持。

非root容器化部署

通过 securityContext.runAsNonRoot: true 强制降权:

securityContext:
  runAsNonRoot: true
  runAsUser: 65534  # nobody 用户 UID
  runAsGroup: 65534

该配置阻止容器以UID 0启动,并指定不可信低权限用户;若插件尝试setuid()或绑定特权端口(CreateContainerError。

Capability 最小化

移除默认继承的 CAP_NET_ADMIN 外所有能力:

capabilities:
  drop: ["ALL"]
  add: ["NET_ADMIN", "NET_RAW"]

仅保留网络命名空间操作必需能力,规避CAP_SYS_MODULE等高危能力滥用风险。

能力项 是否必需 风险示例
NET_ADMIN ✅ 配置路由、iptables
SYS_ADMIN ❌ 挂载宿主机文件系统
DAC_OVERRIDE ❌ 绕过文件读写权限检查

seccomp 策略嵌入

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "syscalls": [
    { "names": ["socket", "bind", "setsockopt"], "action": "SCMP_ACT_ALLOW" }
  ]
}

该策略默认拒绝所有系统调用,仅放行网络初始化必需调用,阻断openat、execve等潜在恶意行为。

4.4 插件可观测性建设:OpenTelemetry指标埋点与CNI执行链路追踪日志注入

为实现CNI插件全链路可观测,需在关键执行节点注入OpenTelemetry上下文并采集多维指标。

埋点位置与指标设计

  • ADD/DEL 请求入口:记录延迟、错误码、Pod/IP 分配耗时
  • IPAM 调用前后:采集子网利用率、地址池剩余量
  • CNI 配置解析阶段:统计配置合法性与字段缺失率

OpenTelemetry 指标埋点示例

// 初始化全局 meter
meter := otel.Meter("cni-plugin")

// 定义延迟直方图(单位:毫秒)
latency, _ := meter.Float64Histogram(
    "cni.operation.latency", 
    metric.WithDescription("CNI operation execution time in milliseconds"),
    metric.WithUnit("ms"),
)
// 记录 ADD 操作耗时
latency.Record(ctx, float64(elapsedMs), 
    metric.WithAttributes(
        attribute.String("cni_command", "ADD"),
        attribute.String("plugin_name", "ipvlan-cni"),
        attribute.Bool("success", success),
    ))

逻辑分析:Float64Histogram 用于观测延迟分布;ctx 携带 traceID 实现指标与链路关联;attribute 标签支持多维下钻分析,如按 plugin_name 和 success 聚合 P99 延迟。

追踪日志注入机制

通过 otel.GetTextMapPropagator().Inject() 将 trace context 注入 CNI result JSON 的 cni.dev/trace 字段,供下游组件延续链路。

字段名 类型 说明
trace_id string W3C 格式 32 位十六进制
span_id string 当前 span 的 16 位 ID
trace_flags int 是否采样(0x01 表示采样)
graph TD
    A[CNI ADD Request] --> B[Parse Config & Start Span]
    B --> C[IPAM Allocate]
    C --> D[Configure Network Namespace]
    D --> E[Inject trace context into result]
    E --> F[Return to kubelet]

第五章:跃迁完成与职业路径再定义

从DevOps工程师到平台工程负责人的真实转型路径

2023年,前阿里云某业务线DevOps工程师李哲在主导CI/CD平台重构项目后,推动团队落地统一可观测性栈(Prometheus + OpenTelemetry + Grafana Loki),将平均故障恢复时间(MTTR)从47分钟压缩至6.2分钟。其技术影响力延伸至跨BU协作——牵头制定《集团级SRE能力成熟度评估模型》,被纳入2024年阿里云内部人才晋升评审标准附件。该案例印证:当工程实践沉淀为组织级资产,个体角色必然突破原有职能边界。

关键能力迁移矩阵

原始能力项 迁移场景 新角色所需强化能力 验证方式
Kubernetes调优 主导多集群联邦治理架构设计 云原生治理策略制定能力 输出《混合云资源调度SLA白皮书》
Shell脚本自动化 构建低代码运维编排平台 领域特定语言(DSL)设计 平台上线后运维工单减少63%
日志分析 构建AIOps异常根因推理引擎 机器学习特征工程能力 模型准确率89.7%(生产环境验证)

技术债清偿驱动的职业跃迁

某金融科技公司核心交易系统长期依赖Shell+Ansible混合部署,2022年Q3因配置漂移导致支付失败率突增0.8%。CTO成立专项攻坚组,要求“6个月内实现基础设施即代码(IaC)全覆盖”。组长王薇带领团队完成Terraform模块化改造,同步构建GitOps流水线与合规性扫描门禁(基于OPA策略)。项目交付后,其个人职级由P7晋升为P8,并获得独立负责平台工程部的授权——技术债清理过程本身成为能力认证的硬通货。

graph LR
A[完成3个高影响力项目] --> B{能力评估}
B -->|通过| C[参与高管技术路线研讨会]
B -->|未通过| D[补强领域知识:参与CNCF TOC会议]
C --> E[输出平台工程三年演进蓝图]
E --> F[获董事会批准设立新部门]
F --> G[担任首任平台工程负责人]

社区影响力转化为组织话语权

GitHub上维护开源项目kubeflow-katib的Maintainer张磊,在2023年KubeCon EU分享《超参优化服务在金融实时风控中的落地挑战》,引发多家银行技术团队跟进。回国后,其所在公司据此立项“AI Infra中台”,张磊直接向CTO汇报并组建12人专项组。值得注意的是,其晋升材料中“社区Star增长曲线”与“企业客户POC成功率”被列为同等权重指标——开源贡献已实质性重构技术人才评价维度。

跨域知识整合催生新岗位

某新能源车企智能驾驶平台遭遇算力调度瓶颈,传统运维团队无法理解CUDA上下文切换与K8s Device Plugin协同机制。公司破格设立“AI基础设施架构师”岗位,由原算法平台开发工程师陈默竞聘成功。其核心职责包括:定义GPU资源抽象层API、设计模型训练任务优先级调度策略、建立NVIDIA DCGM与Prometheus指标映射规范。该岗位诞生于具体业务痛点,而非HR岗位说明书预设。

技术演进速度持续超越组织架构调整周期,职业路径的再定义本质是解决真实世界复杂问题时自然形成的分工重构。

深入 goroutine 与 channel 的世界,探索并发的无限可能。

发表回复

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