Posted in

K8s Event-driven架构 × Golang:基于Controller Runtime EventRecorder构建可观测性中枢(含Prometheus指标自动注入)

第一章:K8s Event-driven架构 × Golang:可观测性中枢的演进与定位

在云原生演进过程中,Kubernetes 的事件模型(Event API 资源)长期被用作集群健康状态的“脉搏信号”,但传统上仅作为诊断辅助——kubectl get events 输出短暂、离散、缺乏上下文。当微服务规模突破百级、部署频率达分钟级时,被动轮询式观测已无法支撑 SLO 保障与根因快速收敛。真正的可观测性中枢,必须从“事后查看”转向“实时响应”,即以事件为触发原点,驱动结构化采集、语义化 enriched、策略化分发的闭环。

事件驱动范式的本质跃迁

K8s Event 不再是只读日志片段,而是具备明确 schema(involvedObject, reason, type, lastTimestamp)的领域事件。Golang 凭借其轻量协程、强类型反射与成熟 client-go 生态,天然适配事件监听与处理流水线构建。通过 Watch 接口建立长连接,可实现毫秒级事件捕获,避免 List + Poll 带来的延迟与资源开销。

构建可扩展的事件处理器骨架

以下代码片段启动一个最小可行事件监听器,自动过滤 Warning 类型并注入命名空间标签:

// 初始化 clientset
clientset := kubernetes.NewForConfigOrDie(rest.InClusterConfig())

// 创建事件 watcher
watcher, err := clientset.CoreV1().Events("").Watch(context.TODO(), metav1.ListOptions{
    Watch:         true,
    ResourceVersion: "0", // 从当前最新开始监听
})
if err != nil {
    log.Fatal("failed to watch events: ", err)
}

// 启动处理 goroutine
go func() {
    for event := range watcher.ResultChan() {
        if ev, ok := event.Object.(*corev1.Event); ok && ev.Type == corev1.EventTypeWarning {
            // enrich with namespace label for routing
            enriched := map[string]interface{}{
                "reason":      ev.Reason,
                "namespace":   ev.Namespace,
                "involved":    ev.InvolvedObject.Kind + "/" + ev.InvolvedObject.Name,
                "timestamp":   ev.LastTimestamp.Time.UTC().Format(time.RFC3339),
            }
            // send to metrics exporter or alerting channel
            log.Printf("[WARN] %s in %s: %s", ev.Reason, ev.Namespace, ev.Message)
        }
    }
}()

可观测性中枢的核心能力矩阵

能力维度 实现方式 价值体现
语义增强 关联 Pod/Deployment 元数据补全上下文 将孤立事件映射至业务拓扑层
动态路由 基于 reason/namespace 标签匹配规则引擎 分流至 Prometheus、Loki、Slack
失败自愈 对连续失败事件触发自动诊断 Job 缩短 MTTR 至秒级
审计留痕 加密写入不可变对象存储(如 S3) 满足 SOC2/GDPR 合规要求

第二章:Controller Runtime EventRecorder深度解析与定制化实践

2.1 EventRecorder核心机制与事件生命周期建模

EventRecorder 是 Kubernetes 客户端中用于结构化记录集群事件的关键抽象,其本质是 EventSink 接口的封装器,通过 Scheme 序列化事件并交由 Clientset.EventsV1().Events(namespace) 提交。

事件生命周期三阶段

  • 生成(Emit):调用 Eventf() 触发,自动填充 EventIDTimestampReportingController
  • 缓冲(Queue):经 eventBroadcasterwatch.Broadcaster 内存队列暂存,支持去重与节流
  • 投递(Dispatch):异步写入 etcd,并广播至所有 EventSink 监听者

核心同步逻辑示例

// 创建带节流的 EventRecorder 实例
recorder := record.NewBroadcaster(&record.EventBroadcasterOptions{
    LeaseDuration: 30 * time.Second,
    MaxQueuedEvents: 1000,
}).NewRecorder(scheme, corev1.EventSource{Component: "my-controller"})

LeaseDuration 控制租约续期间隔,避免事件丢失;MaxQueuedEvents 防止 OOM,超限后丢弃旧事件。

阶段 触发条件 状态变更
Pending Eventf() 调用 Event.Type = "Normal"
Dispatching 队列调度器轮询 Event.FirstTimestamp 设置
Stable etcd 写入成功 Event.LastTimestamp 更新
graph TD
    A[Emit Eventf] --> B[Apply RateLimiter]
    B --> C[Enqueue to Memory Queue]
    C --> D[Async Dispatch via HTTP]
    D --> E[etcd Write + Watch Broadcast]

2.2 基于Client-go与Manager的事件注入点识别与Hook注册

Kubernetes控制器的核心在于对资源生命周期事件的精准捕获。Manager 作为协调中心,通过 Cache 监听集群状态变化,而 Client-goSharedInformer 则是实际事件分发的底层载体。

关键注入点识别

  • Manager.GetCache():获取已启动的缓存实例,是事件监听的起点
  • Cache.GetInformer():按 GVK 获取对应 Informer,暴露 AddEventHandler 接口
  • Controller-runtimeBuilder.WithEventFilter():在构建阶段预置过滤逻辑

Hook 注册示例

mgr.GetCache().GetInformer(ctx, &corev1.Pod{}).AddEventHandler(
    cache.ResourceEventHandlerFuncs{
        AddFunc: func(obj interface{}) { /* 处理新建Pod */ },
        UpdateFunc: func(old, new interface{}) { /* 对比变更 */ },
    },
)

该代码将自定义逻辑注入 Pod 资源的 Informer 事件队列。AddEventHandler 是线程安全的,但回调函数需自行保障幂等性;objruntime.Object,需类型断言为 *corev1.Pod 后使用。

Hook 类型 触发时机 典型用途
AddFunc 资源首次同步或创建 初始化关联资源
UpdateFunc 对象字段发生变更 执行差异驱动的 reconcile
graph TD
    A[Manager.Start] --> B[Cache.Sync]
    B --> C[Informer.Run]
    C --> D[Reflector.ListAndWatch]
    D --> E[EventHandler.OnAdd/OnUpdate]

2.3 结构化事件Schema设计与上下文增强(OwnerReference、TraceID、Labels)

结构化事件需承载可追溯性、归属关系与多维分类能力。核心在于将运行时上下文注入事件元数据层。

上下文字段语义定义

  • ownerReference:声明事件所属资源拓扑路径,支持跨控制器链路追踪
  • traceID:全局唯一128位字符串,对齐OpenTelemetry规范
  • labels:键值对集合,用于事件路由、采样与RBAC策略匹配

示例Schema片段

# event.yaml:标准化事件结构
metadata:
  traceID: "0af7651916cd43dd8448eb211c80319c"  # W3C Trace Context 兼容格式
  ownerReference:
    apiVersion: "apps/v1"
    kind: "Deployment"
    name: "frontend-web"
    uid: "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8"
  labels:
    env: "prod"
    team: "frontend"
    severity: "warning"

该YAML中traceID确保分布式调用链贯通;ownerReference.uid提供强一致性资源绑定,避免命名冲突;labels支持基于Kubernetes标签选择器的事件过滤与告警分级。

字段组合价值

字段 可观测性提升点 典型使用场景
traceID + labels[env] 跨环境链路染色 生产问题快速定位
ownerReference + labels[team] 责任归属自动化 SLO报表按团队聚合
graph TD
  A[事件生成] --> B{注入上下文}
  B --> C[TraceID:分布式追踪]
  B --> D[OwnerReference:资源拓扑锚点]
  B --> E[Labels:策略路由标签]
  C & D & E --> F[统一事件总线]

2.4 高频事件降噪策略:限流、聚合、采样与语义去重实现

在实时数据管道中,传感器上报、用户点击、日志埋点等场景常产生爆发式事件流。单一降噪手段易导致信息失真或延迟激增,需分层协同治理。

四维协同降噪模型

  • 限流:令牌桶控制峰值吞吐(QPS ≤ 100)
  • 聚合:500ms窗口内合并同源事件
  • 采样:随机保留10%高熵事件(如异常状态变更)
  • 语义去重:基于事件指纹(hash(payload.type + payload.key))判重

语义去重核心实现

def semantic_dedup(event: dict, cache: TTLCache, threshold: int = 300) -> bool:
    # 生成语义指纹:忽略时间戳/trace_id等噪声字段
    fingerprint = hashlib.md5(
        json.dumps({
            k: v for k, v in event.items() 
            if k not in ['timestamp', 'trace_id', 'seq_no']
        }, sort_keys=True).encode()
    ).hexdigest()
    # 利用TTL缓存实现滑动窗口去重(300s有效期)
    if fingerprint in cache:
        return False  # 重复事件丢弃
    cache[fingerprint] = True
    return True

逻辑说明:TTLCache 提供自动过期能力,threshold=300 表示仅对最近5分钟内相同语义的事件去重,避免长周期误杀;字段过滤保障语义一致性。

策略 适用场景 延迟开销 信息保真度
限流 流量洪峰防护
聚合 UI交互连击优化 ~500ms
采样 全链路监控降载 0ms
语义去重 消息重发/网络抖动 ~2ms 极高
graph TD
    A[原始事件流] --> B{限流器}
    B -->|通过| C[聚合窗口]
    C --> D[采样过滤]
    D --> E[语义指纹计算]
    E --> F{TTL缓存查重}
    F -->|新指纹| G[输出净化后事件]
    F -->|已存在| H[丢弃]

2.5 自定义EventSink扩展:对接Loki/OTLP/Slack的双写与失败回退逻辑

数据同步机制

采用主备双写策略:事件优先写入 Loki(日志可观测性)与 OTLP(指标/追踪统一通道),Slack 作为告警通知通道异步兜底。

失败回退流程

func (s *DualWriteSink) Write(ctx context.Context, evt *Event) error {
    // 并发写入 Loki 和 OTLP,任一成功即视为主路径成功
    errCh := make(chan error, 2)
    go func() { errCh <- s.lokiSink.Write(ctx, evt) }()
    go func() { errCh <- s.otlpSink.Write(ctx, evt) }()

    var primaryErr error
    for i := 0; i < 2; i++ {
        if err := <-errCh; err != nil && primaryErr == nil {
            primaryErr = err // 记录首个失败(非阻塞)
        }
    }

    // 主路径均失败时,触发 Slack 异步降级
    if primaryErr != nil {
        s.slackSink.AsyncNotify(fmt.Sprintf("Event write failed: %v", primaryErr))
    }
    return primaryErr
}

逻辑说明:errCh 容量为 2 避免 goroutine 阻塞;primaryErr 仅捕获首个错误以避免覆盖;AsyncNotify 使用带重试的队列,不阻塞主流程。超时由 ctx 统一控制。

通道可靠性对比

通道 吞吐量 一致性 重试支持 适用场景
Loki 最终一致 日志归档与检索
OTLP 中高 强一致(gRPC) 链路追踪与指标
Slack 弱一致 ⚠️(需自实现) 告警与人工介入
graph TD
    A[接收事件] --> B{并发写 Loki & OTLP}
    B -->|成功| C[返回 success]
    B -->|均失败| D[异步推送 Slack]
    D --> E[本地落盘待重试]

第三章:Golang Controller中事件驱动可观测性的工程落地

3.1 Reconcile函数内嵌事件埋点模式与错误传播链路追踪

在控制器的 Reconcile 函数中,事件埋点需与错误传播深度耦合,确保可观测性不破坏控制流语义。

埋点与错误协同设计原则

  • 埋点调用必须在 deferrecover 前完成,避免 panic 导致日志丢失
  • 所有错误须携带 errors.WithStack()fmt.Errorf("%w", err) 构建链路
  • 事件类型(Normal/Warning)依据错误是否可恢复动态判定

示例:带上下文追踪的埋点逻辑

func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    start := time.Now()
    defer func() {
        r.eventRecorder.Eventf(&corev1.ObjectReference{...}, corev1.EventTypeNormal,
            "Reconciled", "took %v", time.Since(start))
    }()

    obj := &appsv1.Deployment{}
    if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
        r.logger.Error(err, "failed to fetch object") // 自动注入 spanID/traceID
        return ctrl.Result{}, client.IgnoreNotFound(err) // 错误被包装但不中断链路
    }
    // ...
}

该代码中 r.logger.Error() 自动注入 OpenTelemetry trace ID;client.IgnoreNotFound() 保留原始错误栈,使上游能区分业务失败与资源不存在。

埋点位置 是否捕获panic 是否透传error stack 适用场景
defer event 成功路径耗时统计
logger.Error() 可恢复错误诊断
return err 是(若已包装) 终止性错误链路归因
graph TD
    A[Reconcile Entry] --> B{Fetch Resource}
    B -->|Success| C[Process Logic]
    B -->|Error| D[Log with Stack]
    D --> E[Wrap & Return]
    C --> F[Update Status]
    F --> G[Fire Normal Event]
    E --> H[Upstream Trace Aggregation]

3.2 Owner-aware事件关联:从Pod异常到Deployment级根因推导示例

在Kubernetes事件分析中,Owner-aware关联通过metadata.ownerReferences自动构建资源拓扑链,实现跨层级根因定位。

核心关联逻辑

# Pod事件中提取的ownerReference片段
ownerReferences:
- apiVersion: apps/v1
  kind: ReplicaSet
  name: nginx-deploy-7f89b9d4c
  uid: a1b2c3d4-...
  controller: true

该字段标识Pod由ReplicaSet控制器创建;进一步解析该ReplicaSet的ownerReferences,可上溯至Deployment,形成 Pod → ReplicaSet → Deployment 推导路径。

推导流程可视化

graph TD
    A[Pod Failed] --> B[Read ownerReferences]
    B --> C{Kind == ReplicaSet?}
    C -->|Yes| D[Fetch ReplicaSet]
    D --> E[Read its ownerReferences]
    E --> F[Find Deployment]
    F --> G[Root Cause: Deployment imageTag mismatch]

关键字段对照表

字段 作用 示例值
controller: true 标识直接管理控制器 true
blockOwnerDeletion 控制级联删除行为 true
uid 全局唯一资源标识 a1b2c3d4-...

3.3 事件驱动调试闭环:kubectl get events -w + kubebuilder debug trace联动实践

在 Operator 开发中,实时捕获集群状态变更与控制器执行轨迹的耦合分析至关重要。

实时事件监听

kubectl get events -w --field-selector involvedObject.kind=MyApp,involvedObject.name=myapp-sample

该命令持续监听 MyApp 类型资源 myapp-sample 的所有关联事件(如 CreatedFailedValidation),-w 启用 watch 流式输出,--field-selector 精准过滤,避免噪声干扰。

调试轨迹注入

启用 Kubebuilder 生成的控制器调试追踪:

// 在 Reconcile 方法中插入
ctrl.Log.WithName("trace").Info("Reconciling MyApp", "name", req.NamespacedName)

日志自动携带 span ID,可与 OpenTelemetry Collector 关联。

联动分析模式

事件类型 对应控制器行为 典型日志关键词
ScalingStarted 触发副本扩缩逻辑 "scaling: desired=3"
ConfigUpdated 重新加载 ConfigMap "config hash changed"
graph TD
    A[kubectl get events -w] -->|推送事件流| B(Operator Pod)
    B --> C[Reconcile 执行]
    C --> D[kubebuilder debug trace]
    D --> E[结构化日志+traceID]
    E --> F[ELK/Jaeger 关联查询]

第四章:Prometheus指标自动注入体系构建

4.1 Metrics Registry动态注册机制:基于Controller Runtime Manager的指标生命周期绑定

Controller Runtime 的 Manager 不仅协调控制器生命周期,还通过 MetricsOptions 将指标注册与组件启停深度耦合。

指标注册的时机绑定

当调用 mgr.Add() 注册控制器时,Manager 自动触发其 SetupWithManager() 中定义的 prometheus.MustRegister() —— 但仅在 manager 启动(Start())前完成注册,避免竞态。

动态注销保障

Manager 停止时,自动调用 prometheus.Unregister() 清理所属指标,确保进程退出后无残留 metric descriptor。

// 在 SetupWithManager 中注册指标
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
    r.metrics = prometheus.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "reconcile_duration_seconds",
            Help: "Duration of reconcile loops in seconds",
        },
        []string{"controller", "result"},
    )
    return ctrl.NewControllerManagedBy(mgr).
        For(&appsv1.Deployment{}).
        Complete(r)
}

此处 r.metrics 是未注册的 Vec 实例;实际注册由 Manager 在 Start() 阶段统一执行,确保所有指标与 runtime 生命周期严格对齐。

阶段 指标状态 触发方
mgr.Add() 实例创建,未注册 Reconciler
mgr.Start() 批量注册 Manager runtime
mgr.Stop() 批量注销 Context cancel
graph TD
    A[Add Controller] --> B[SetupWithManager]
    B --> C[创建 metrics 实例]
    D[Start Manager] --> E[批量注册到 Default Register]
    E --> F[HTTP /metrics 可见]
    G[Stop Manager] --> H[Unregister all controller metrics]

4.2 事件-指标双向映射模型:将Warning/Normal事件自动转化为counter/gauge指标

核心映射规则

事件类型决定指标语义:

  • Warning → 累加型 counter(如 event_warnings_total
  • Normal → 状态型 gauge(如 system_health_status,值为 1 表示健康)

映射配置示例

# event_to_metric_mapping.yaml
mappings:
  - event_type: "Warning"
    metric_name: "app_errors_total"
    metric_type: "counter"
    labels: ["service", "severity"]

该配置声明:所有 Warning 事件经解析后,按 serviceseverity 维度聚合为 Prometheus counter。metric_type 控制指标注册行为,labels 指定动态标签提取字段。

转换流程

graph TD
  A[原始事件流] --> B{事件类型判断}
  B -->|Warning| C[递增 counter]
  B -->|Normal| D[更新 gauge 值]
  C & D --> E[暴露至 /metrics]

支持的指标类型对照表

事件类型 指标类型 示例用途
Warning counter 错误累计次数
Normal gauge 当前服务可用状态

4.3 控制器维度指标自动打标:namespace、controller-name、reconcile-result、error-type标签注入

控制器指标需携带可追溯的上下文语义,方能支撑精准根因分析。自动打标机制在指标采集链路中嵌入元数据注入逻辑,避免人工埋点遗漏。

标签注入时机与来源

  • namespace:从 reconcile.Request.Namespace 直接提取
  • controller-name:由控制器注册时传入的 ctrl.NewControllerManagedBy(mgr).For(&appsv1.Deployment{}) 中的 For() 类型推导,或显式配置 WithName("deployment-controller")
  • reconcile-result:基于 ctrl.Result 和 error 状态联合判定(成功/重试/跳过)
  • error-type:对 err 进行类型断言(如 apierrors.IsNotFoundkerrors.IsConflict

指标打标代码示例

// metrics.go:在 Reconcile 方法末尾注入标签
labels := prometheus.Labels{
  "namespace":       req.Namespace,
  "controller-name": "deployment-controller",
  "reconcile-result": getReconcileResult(result, err),
  "error-type":       getErrorType(err),
}
reconcileTotalVec.With(labels).Inc()

逻辑说明:getReconcileResult(result.Requeue==true, err==nil) 映射为 "retry"getErrorType 对常见错误做归类(如 validation, network, permission),提升告警聚合精度。

标签组合效果示意

namespace controller-name reconcile-result error-type
default deployment-controller retry network
kube-system daemonset-controller success “”

4.4 指标可观测性增强:结合event.duration_histogram与reconcile.latency_quantile自动仪表盘生成

Kubernetes Operator 场景中,事件处理耗时(event.duration_histogram)与协调循环延迟(reconcile.latency_quantile)是核心性能信号。二者协同可驱动可观测性闭环。

自动仪表盘生成逻辑

通过 Prometheus 查询表达式自动提取指标特征,注入 Grafana dashboard JSON 模板:

{
  "panels": [
    {
      "title": "P95 Reconcile Latency (ms)",
      "targets": [{
        "expr": "reconcile_latency_quantile{quantile=\"0.95\"} * 1000",
        "legendFormat": "{{namespace}}/{{name}}"
      }]
    }
  ]
}

reconcile_latency_quantile 是直方图分位数指标,单位为秒;乘以 1000 转为毫秒便于人因读取;{{namespace}}/{{name}} 标签实现资源级下钻。

关键指标映射表

指标名 类型 用途 采样维度
event.duration_histogram Histogram 事件入队到处理完成耗时 event_type, kind
reconcile.latency_quantile Summary 单次 Reconcile 循环延迟分位数 namespace, name

数据流示意

graph TD
  A[Operator] -->|emit| B[Prometheus Client]
  B --> C[Prometheus scrape]
  C --> D[Auto-Dashboard Generator]
  D -->|POST| E[Grafana API]

第五章:架构收敛与生产就绪性验证

在某大型金融级微服务迁移项目中,团队历经四轮架构演进后进入关键收口阶段:将原本分散在12个独立技术栈(Spring Cloud、Dubbo、gRPC、K8s原生Service Mesh等)中的37个核心服务,统一收敛至基于OpenTelemetry + Istio 1.21 + Kubernetes 1.28的标准化运行平面。该过程并非简单替换组件,而是以生产就绪性为唯一标尺驱动的技术决策闭环。

核心收敛策略落地路径

  • 协议层统一:强制所有服务对外暴露gRPC-Web+HTTP/2双协议,内部通信100%启用mTLS双向认证,通过Istio PeerAuthenticationDestinationRule 实现零配置证书轮换;
  • 可观测性基线固化:所有服务必须注入OpenTelemetry Collector Sidecar,指标采集间隔≤15s,链路采样率动态调整(错误率>0.1%时自动升至100%),日志结构化字段强制包含trace_idservice_versionenv=prod
  • 弹性能力契约化:在CI/CD流水线中嵌入Chaos Engineering Gate,每次发布前自动执行三项必过实验:Pod随机终止(持续90s)、下游依赖延迟注入(p99+500ms)、CPU资源压制(限制至500m)。

生产就绪性验证矩阵

验证维度 合格阈值 自动化工具 实测失败案例(第3轮)
启动冷加载时间 ≤800ms(P95) k6 + Prometheus Alerting Kafka消费者组重平衡超时导致启动阻塞
故障自愈恢复时长 ≤22s(从Pod Ready→全链路健康) Argo Rollouts Analysis Envoy xDS配置热更新延迟引发5分钟流量抖动
资源泄漏检测 内存RSS增长≤0.3%/小时(稳定态) eBPF bpftrace脚本 gRPC客户端未关闭Channel导致FD耗尽

真实故障驱动的收敛加固

2024年Q2一次生产事件成为架构收敛的转折点:支付网关在大促峰值期间出现间歇性503,根因定位为多版本Envoy Proxy混用(1.20.2与1.22.1共存)导致HTTP/2流控算法不一致。团队立即冻结所有非1.22.3版本的Sidecar镜像,并在Helm Chart中通过values.yaml硬编码global.proxy.image.version: "1.22.3",同时将该约束写入OPA策略引擎:

package istio.sidecar

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  container.name == "istio-proxy"
  container.image != "docker.io/istio/proxyv2:1.22.3"
  msg := sprintf("Sidecar image %v violates production baseline", [container.image])
}

持续验证机制设计

采用GitOps模式构建双轨验证通道:

  • 预发布轨道:每小时从生产集群同步实时拓扑快照(含Service、EndpointSlice、VirtualService),在隔离环境回放过去24小时真实流量(使用Kraken流量录制工具);
  • 灰度轨道:新版本服务上线后,自动触发10%流量切流至灰度集群,并并行比对两套环境的OpenTelemetry指标差异(重点监控http.server.durationgrpc.client.attempt_countistio_requests_total{response_code=~"5.."})。

架构决策留痕系统

所有收敛相关的技术决议均需提交至ArchRepo仓库,包含decision-record.md模板字段:status: accepteddate: 2024-06-17applicability: all-services-in-prodrollback-plan: helm rollback --revision 12,并关联Jira EPIC ID FIN-ARCH-CONVERGE-2024。该机制使后续审计可追溯至每一次Sidecar升级、每一次CRD变更、每一次指标标签规范修订的原始上下文。

验证平台每日生成收敛健康度看板,覆盖17项SLI指标,其中sidecar-compliance-rate(符合基线的Pod占比)已连续92天维持在99.997%,mttr-for-config-errors(配置类故障平均修复时长)从初始47分钟降至当前8.3分钟。

在并发的世界里漫游,理解锁、原子操作与无锁编程。

发表回复

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