Posted in

Go零信任RPC框架设计实录:基于gRPC+SPIFFE双向mTLS+策略引擎,替代传统Service Mesh

第一章:Go零信任RPC框架设计概览

零信任架构要求“永不信任,始终验证”,在RPC通信场景中,这意味着每一次远程调用都必须独立完成身份认证、权限校验、传输加密与行为审计。本框架以Go语言原生net/rpcgRPC双协议栈为基底,融合SPIFFE/SPIRE身份体系、双向mTLS通道、细粒度ABAC策略引擎及可插拔的审计钩子,构建端到端可信的远程过程调用基础设施。

核心设计原则

  • 最小权限默认:服务注册时需显式声明所需能力(如read:db, write:cache),未声明即拒绝;
  • 身份绑定通信上下文:每个RPC请求携带经SPIRE签发的SVID(X.509证书),服务端通过spire-agent api fetch实时校验证书链有效性;
  • 策略动态加载:ABAC规则以JSON格式存于etcd,支持热更新,无需重启服务。

关键组件职责

组件 职责 可替换性
Authn Middleware 提取并验证SVID,注入context.Context中的spiffeid.ID 支持自定义OIDC适配器
Policy Engine 解析请求主体、资源、动作三元组,匹配etcd中最新策略 兼容Open Policy Agent(OPA) Rego接口
Audit Sink 将调用元数据(含签名摘要、延迟、结果码)写入WAL日志并同步至Loki 可对接Kafka或云原生日志服务

快速启动示例

以下代码片段演示服务端启用零信任中间件的核心逻辑:

// 初始化SPIFFE工作负载API客户端
client, _ := workloadapi.NewClient(workloadapi.WithAddr("/run/spire/sockets/agent.sock"))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
svid, err := client.FetchX509SVID(ctx) // 获取本地工作负载证书
if err != nil {
    log.Fatal("无法获取SVID:", err)
}

// 构建gRPC服务器,注入零信任拦截器
server := grpc.NewServer(
    grpc.Creds(credentials.NewTLS(&tls.Config{
        ClientAuth:   tls.RequireAndVerifyClientCert,
        ClientCAs:    svid.TrustBundle.X509Authorities(), // 验证客户端证书链
        GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
            return &tls.Config{Certificates: []tls.Certificate{svid.X509SVID()}}, nil
        },
    })),
    grpc.UnaryInterceptor(zeroTrustInterceptor), // 执行ABAC策略检查
)

该拦截器在每次调用前解析peer.AuthInfo()提取SPIFFE ID,并查询策略引擎判定是否允许访问目标方法。所有组件均遵循Go模块化设计,可通过go mod replace灵活替换底层实现。

第二章:gRPC基础与零信任通信层构建

2.1 gRPC服务定义与Protocol Buffer最佳实践

服务契约优先设计

gRPC 强调以 .proto 文件为唯一事实源,服务接口与数据结构需在编译前严格约定。

Protocol Buffer 命名与版本控制

  • 消息字段一律使用 snake_case(如 user_id
  • 服务方法采用 PascalCase(如 CreateUser
  • 保留字段编号避免重用,新增字段必须设默认值

示例:用户管理服务定义

syntax = "proto3";
package user.v1;

message User {
  int64 id = 1;           // 唯一标识,不可为空
  string email = 2;       // 格式校验由业务层保障
  bool is_active = 3 [default = true]; // 显式默认值支持向后兼容
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

该定义确保生成客户端/服务端代码时类型安全、零序列化开销,并为 gRPC 流控、超时、拦截器提供结构化元数据基础。

接口演进推荐策略

变更类型 是否允许 说明
添加可选字段 不破坏旧客户端
修改字段类型 导致二进制不兼容
重命名 message ⚠️ 需同步更新 package 或注释
graph TD
  A[编写 .proto] --> B[protoc 生成 stub]
  B --> C[强类型客户端/服务端]
  C --> D[运行时零反射开销]

2.2 基于SPIFFE身份的双向mTLS证书自动轮换实现

SPIFFE通过SpiffeID唯一标识工作负载,结合SDS(Secret Discovery Service)驱动证书生命周期管理,实现零人工干预的双向mTLS轮换。

核心流程

# SDS配置示例:告知Envoy从SPIRE Agent拉取证书
resources:
- name: spiffe://example.org/ns/default/sa/app
  resource_api_version: V3
  type_url: type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.SdsSecretConfig

该配置使Envoy定期轮询SPIRE Agent的Unix Domain Socket,按TTL自动触发证书续签;spiffe://前缀确保身份可验证且全局唯一。

轮换触发机制

  • 证书签发时嵌入NotAfter字段(默认24h)
  • SPIRE Agent在剩余30%有效期时主动签发新证书
  • Envoy热加载新密钥对,旧连接平滑终止
组件 职责 协议
SPIRE Server 签发SVID(含SPIFFE ID和签名) gRPC over mTLS
SPIRE Agent 本地证书分发与健康心跳 UDS + HTTP/2
graph TD
    A[Workload] --> B[SPIRE Agent]
    B --> C[SPIRE Server]
    C --> D[Issued SVID]
    D --> E[Envoy SDS]
    E --> F[Active mTLS Connection]

2.3 x509-SVID解析与Workload Identity上下文注入

x509-SVID(SPIFFE Verifiable Identity Document)是工作负载身份的权威载体,由 SPIRE Agent 签发,内嵌 SPIFFE ID、可验证签名及短时效证书链。

SVID 结构关键字段

  • Subject Alternative Name (SAN):包含 spiffe://<trust-domain>/<workload-path>
  • Extended Key Usage:标记为 clientAuth, serverAuth
  • Not Before/Not After:默认有效期 ≤ 1h,强制定期轮换

解析流程示例(Go)

svid, err := x509.ParseCertificate(svidPEM)
if err != nil {
    log.Fatal("invalid SVID PEM")
}
spiffeID := svid.URIs[0].String() // 唯一标识 workload 身份

逻辑分析:ParseCertificate 提取 DER 编码证书;URIs[0] 取首个 SPIFFE URI,即 workload 的逻辑身份锚点;该值后续注入至服务网格 sidecar 或应用上下文。

上下文注入方式对比

注入机制 触发时机 可观测性支持
Envoy SDS 启动时拉取 ✅(/certs 接口)
Kubernetes Downward API Pod 启动挂载 ⚠️(需重启生效)
graph TD
    A[SPIRE Agent] -->|签发| B[x509-SVID]
    B --> C[Workload 进程]
    C --> D[解析 SAN 字段]
    D --> E[注入 context.SPIFFEID]

2.4 gRPC拦截器链设计:认证、授权与审计一体化钩子

gRPC拦截器链通过 UnaryServerInterceptor 实现横切关注点的有序串联,天然支持责任链模式。

拦截器执行顺序

  • 认证拦截器(验证 JWT 签名与有效期)
  • 授权拦截器(基于 RBAC 检查 method → role 映射)
  • 审计拦截器(记录 clientIPmethodstatusduration_ms

一体化钩子实现示例

func AuditInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
        start := time.Now()
        resp, err = handler(ctx, req) // 继续调用下游拦截器或业务 handler
        log.Printf("AUDIT: %s | %s | %v | %dms",
            info.FullMethod,
            peer.FromContext(ctx).Addr.String(),
            err,
            time.Since(start).Milliseconds())
        return resp, err
    }
}

该拦截器在 handler 执行前后分别采集上下文与耗时,确保审计日志覆盖完整请求生命周期;peer.FromContext(ctx) 提取真实客户端地址,避免代理透传污染。

拦截器链注册对比

拦截器类型 执行时机 关键依赖
认证 最早执行 Authorization header
授权 认证成功后 ctx.Value("user")
审计 最终返回前 time.Now() & peer
graph TD
    A[Client Request] --> B[Auth Interceptor]
    B --> C[Auth OK?]
    C -->|Yes| D[AuthZ Interceptor]
    C -->|No| E[Reject 401]
    D --> F[AuthZ OK?]
    F -->|Yes| G[Audit Interceptor]
    F -->|No| H[Reject 403]
    G --> I[Business Handler]
    I --> J[Response + Audit Log]

2.5 零信任信道性能压测与连接复用优化策略

零信任信道在高并发场景下易因频繁 TLS 握手与连接重建导致吞吐下降。压测发现:10k QPS 下平均延迟跃升至 327ms,连接建立耗时占比达 68%。

连接复用关键参数调优

  • 启用 HTTP/2 多路复用,禁用 Connection: close
  • 调整 TLS session ticket 生命周期为 7200s(2 小时)
  • 客户端连接池 maxIdlePerRoute = 50keepAliveDuration = 30s

性能对比(单节点,10k 并发)

策略 P99 延迟 TPS 连接新建率
默认 TLS + 短连接 327ms 4.2k 890/s
HTTP/2 + Session 复用 89ms 9.8k 42/s
# 客户端连接池配置示例(Python requests + urllib3)
from urllib3.util.connection import create_connection
from urllib3.poolmanager import PoolManager

http = PoolManager(
    num_pools=20,
    maxsize=50,                    # 每路由最大空闲连接数
    keepalive=True,                # 启用 HTTP Keep-Alive
    retries=3,
    timeout=5.0,
    ssl_context=ssl.create_default_context()
)

该配置通过复用 TLS session ticket 减少握手开销,并利用连接池的 LRU 清理机制保障长连接健康度;maxsizenum_pools 需按服务端路由维度对齐,避免跨域争抢。

压测流量调度逻辑

graph TD
    A[压测请求] --> B{是否命中TLS Session ID缓存?}
    B -->|是| C[复用密钥材料,跳过ServerHello]
    B -->|否| D[完整TLS 1.3 handshake]
    C --> E[HTTP/2 stream multiplexing]
    D --> E

第三章:SPIFFE运行时集成与可信工作负载管理

3.1 SPIRE Agent/Server对接与SVID生命周期管理实战

SPIRE Agent 通过双向 TLS 与 Server 建立安全通信,完成节点身份注册与 SVID(SPIFFE Verifiable Identity Document)签发。

SVID 获取流程

  • Agent 启动后向 Server 发起 AttestationRequest
  • Server 验证工作负载身份(如 Kubernetes pod 身份、AWS IAM role)
  • 签发包含 SPIFFE ID 的 X.509 SVID 及对应私钥(有效期默认 1 小时)
# 示例:Agent 配置片段(agent.conf)
trust_domain = "example.org"
server_address = "spire-server.example.org"
server_port = 8081
# 启用自动轮换(关键!)
svid_ttl = "3600s"  # 必须 ≤ Server 策略中定义的 max_svid_ttl

该配置驱动 Agent 定期发起 RenewSVID 请求;svid_ttl 决定本地缓存 SVID 的生存窗口,过短将增加 Server 负载,过长则削弱密钥时效性。

生命周期关键状态

状态 触发条件 行为
Issued 初始注册成功 缓存 SVID + 私钥
Renewing 距到期 ≤ 10% TTL 异步请求新 SVID
Rotated 新 SVID 验证通过 原子替换内存/文件中的凭证
graph TD
    A[Agent 启动] --> B[执行 attestation]
    B --> C{Server 授权?}
    C -->|Yes| D[签发初始 SVID]
    C -->|No| E[拒绝并记录 audit log]
    D --> F[启动 TTL 倒计时]
    F --> G[剩余10%时触发 Renew]
    G --> H[验证新 SVID 并切换]

SVID 自动轮换依赖 Server 端策略一致性——若 max_svid_ttl=30m,而 Agent 设置 svid_ttl=1h,则 Renew 将失败并退回到 Issued 状态重试。

3.2 Go客户端SDK集成:同步获取SVID并绑定gRPC凭证

初始化SPIRE客户端

使用 spireapi.NewWorkloadClient() 连接本地UDS端点,需确保 SPIRE Agent 正在运行且 /run/spire/sockets/agent.sock 可访问。

同步获取SVID证书链

svid, err := client.FetchX509SVID(ctx)
if err != nil {
    log.Fatal("failed to fetch SVID:", err)
}
// svid.X509SVID: end-entity cert; svid.X509Bundle: upstream CA chain

该调用阻塞直至成功获取 X.509-SVID(含私钥、证书链及 TTL),返回结构体中 PrivateKey*ecdsa.PrivateKeyX509SVID[]byte PEM 编码证书。

构建gRPC TLS凭证

字段 类型 说明
credentials.NewTLS() TransportCredentials 需传入 tls.Config{Certificates: []tls.Certificate{...}}
tls.Certificate x509.CertPool + svid.X509SVID + svid.PrivateKey 组成

绑定凭证到gRPC连接

creds := credentials.NewTLS(&tls.Config{
    Certificates: []tls.Certificate{cert},
    RootCAs:      rootPool,
})
conn, _ := grpc.Dial("backend:8080", grpc.WithTransportCredentials(creds))

RootCAs 必须加载 SPIRE Server 的根 CA(从 svid.X509Bundle 解析),否则 TLS 握手将因证书链验证失败而中断。

3.3 工作负载身份验证失败的降级路径与可观测性埋点

当服务网格中工作负载(如 Pod)因证书过期或 SPIFFE ID 不匹配导致 mTLS 验证失败时,需启用安全可控的降级路径,同时确保全链路可观测性。

降级策略设计原则

  • 仅允许在明确标记 auth-fallback: trusted 的命名空间内启用明文回退
  • 降级请求必须携带 X-Auth-Fallback-Reason 头说明触发原因
  • 所有降级流量强制注入 fallback=1 标签并限速至 5 QPS/实例

关键可观测性埋点

# 在 Istio EnvoyFilter 中注入的 Lua 埋点逻辑
lua_code = """
  if not ssl.get_client_hello() then
    metrics.record("workload_auth_fallback_total", 1, {
      reason = "no_client_cert",
      workload = ngx.var.service_name,
      fallback_mode = "plaintext"
    })
  end
"""

该脚本在 TLS 握手阶段拦截无客户端证书请求,上报结构化指标:reason 区分失败根源(no_client_cert/spiffe_mismatch/cert_expired),workload 提供拓扑上下文,fallback_mode 标识降级类型。

降级决策流程

graph TD
  A[TLS 握手失败] --> B{SPIFFE ID 校验失败?}
  B -->|是| C[检查 fallback annotation]
  B -->|否| D[拒绝连接]
  C --> E[写入 audit_log + 上报 metric]
  E --> F[启用双向明文通道]

监控指标维度表

指标名 标签示例 用途
workload_auth_fallback_total reason="cert_expired", workload="api-v2", cluster="prod-us", fallback_mode="plaintext" 定位高频失效根因
fallback_latency_ms_bucket le="100", fallback_mode="plaintext" 评估降级性能影响

第四章:策略驱动的动态访问控制引擎

4.1 基于OPA+Wasm的轻量级策略执行单元封装

将策略决策能力下沉至边缘网关或服务网格数据平面,需兼顾安全性、可移植性与低开销。OPA(Open Policy Agent)通过 Rego 定义策略,但原生 bundle 加载存在启动延迟与依赖耦合;Wasm 提供沙箱化、跨平台的执行环境,二者结合形成轻量级策略执行单元(PEU)。

核心架构设计

  • 策略以 .rego 编译为 Wasm 模块(opa build -t wasm
  • 运行时仅加载 policy.wasm + JSON 输入,无 Go 运行时依赖
  • 支持热重载:通过 HTTP PUT 更新策略字节码,Wasm 实例动态替换

策略编译示例

# 编译策略为 Wasm 模块(含入口函数 `eval`)
opa build -t wasm -e example/authz/allow authz.rego

该命令生成 bundle.tar.gz,解压后提取 policy.wasm-e 指定入口路径,确保 Wasm 导出函数符合 wasmtime 调用约定;-t wasm 启用 WebAssembly 目标后端,剥离 OPA 运行时,体积压缩至

执行时接口契约

字段 类型 说明
input JSON object 动态请求上下文(如 JWT、HTTP headers)
output JSON boolean/string 策略决策结果(true/false{"allowed": true, "reason": "..."}
error string Wasm trap 或 Rego runtime error
graph TD
    A[HTTP Request] --> B{PEU Runtime}
    B --> C[Load policy.wasm]
    B --> D[Deserialize input JSON]
    C --> E[Call export::eval]
    D --> E
    E --> F[Return output/error]
    F --> G[Enforce or Reject]

4.2 RPC方法级ABAC策略建模:主体/资源/操作/环境四元组

ABAC(基于属性的访问控制)在RPC场景中需精确锚定到具体方法调用,其核心是动态评估四元组:主体(Subject)资源(Resource)操作(Action)环境(Environment)

四元组语义解析

  • 主体:调用方身份属性(如 user.role=“admin”, device.trustLevel=“high”
  • 资源:被访问的服务方法及参数(如 service="OrderService", method="cancelOrder", order.status="pending"
  • 操作:HTTP动词或RPC语义动作("invoke", "read", "modify"
  • 环境:实时上下文(time.hour ∈ [9,17], ip.country="CN", tls.version≥1.3"

策略定义示例(JSON Schema)

{
  "effect": "allow",
  "conditions": [
    {"var": "subject.role", "op": "==", "val": "operator"},
    {"var": "resource.method", "op": "==", "val": "refund"},
    {"var": "env.time.hour", "op": ">=", "val": 8},
    {"var": "env.time.hour", "op": "<=", "val": 20}
  ]
}

逻辑分析:该策略要求调用者角色为 operator,且仅在工作时段(8–20点)允许执行 refund 方法。var 指向运行时上下文中的属性路径,op 支持 ==, in, >= 等标准比较符,val 为期望值或范围。

策略匹配流程

graph TD
  A[RPC请求到达] --> B[提取四元组属性]
  B --> C[加载匹配策略集]
  C --> D[逐条求值条件表达式]
  D --> E{全部为true?}
  E -->|是| F[授权通过]
  E -->|否| G[拒绝并返回403]
维度 可扩展性关键点
主体 支持多源属性聚合(JWT + DB + OAuth2)
资源 方法签名+参数快照支持细粒度控制
环境 插件化接入时间/地理位置/IP信誉服务

4.3 策略热加载与版本灰度发布机制实现

核心设计思想

策略配置与业务逻辑解耦,通过监听配置中心变更事件触发动态刷新,避免应用重启。

热加载关键流程

@Configuration
public class StrategyAutoRefresher {
    @EventListener
    public void onConfigChange(ConfigChangeEvent event) {
        if (event.getKey().startsWith("strategy.")) {
            StrategyCache.refresh(event.getKey()); // 按key粒度刷新缓存
        }
    }
}

ConfigChangeEvent 来自Nacos/Consul监听器;StrategyCache.refresh() 原子替换策略实例并校验签名,确保线程安全与一致性。

灰度路由规则表

版本标识 流量比例 匹配条件 生效状态
v1.2 5% userId % 100
v1.3 0% header[“beta”] == “true” ⚠️(预发布)

灰度决策流程

graph TD
    A[请求到达] --> B{查策略版本路由规则}
    B -->|匹配v1.3| C[执行新策略逻辑]
    B -->|不匹配| D[执行默认v1.2]
    C --> E[上报灰度指标]

4.4 策略决策日志结构化输出与eBPF辅助审计追踪

策略决策日志需脱离非结构化文本,转向可查询、可关联的标准化格式。核心字段包括 timestamppolicy_iddecision(allow/deny)、subject(如 PID+comm)、object(如 path/cgroup)及 trace_id

日志结构定义(JSON Schema 片段)

{
  "type": "object",
  "properties": {
    "ts": {"type": "string", "format": "date-time"},  // RFC 3339 时间戳,纳秒精度
    "pid": {"type": "integer"},                        // 决策上下文进程ID
    "comm": {"type": "string", "maxLength": 16},       // 进程名截断,避免eBPF栈溢出
    "action": {"enum": ["allow", "deny", "audit"]},
    "rule_match": {"type": "array", "items": {"type": "string"}}
  }
}

此 schema 直接映射至 eBPF map(BPF_MAP_TYPE_PERCPU_HASH)的 value 结构,确保零拷贝序列化;comm 字段长度限制源于 TASK_COMM_LEN 内核常量,规避 verifier 拒绝。

eBPF 辅助审计链路

graph TD
    A[Policy Engine] -->|触发决策| B[eBPF tracepoint: security_bpf]
    B --> C[填充 log_entry struct]
    C --> D[per-CPU map 缓存]
    D --> E[userspace ringbuf poll]
    E --> F[JSONL 批量写入审计日志]

关键字段语义对齐表

字段 来源 用途
trace_id bpf_get_current_task()task_struct->pid + bpf_ktime_get_ns() 关联同一系统调用全链路(syscall → LSM → policy → audit)
rule_match bpf_map_lookup_elem(&policy_rules, &key) 实时匹配规则ID列表,支持策略溯源

第五章:生产就绪与未来演进方向

生产环境配置加固实践

在某金融级微服务集群(Kubernetes v1.28)中,我们通过启用 PodSecurityPolicy 替代方案——Pod Security Admission(PSA)强制执行 restricted 模式,并结合 OPA Gatekeeper 实现自定义策略校验。例如,禁止容器以 root 用户运行、限制 hostPath 卷挂载路径、强制设置 resource requests/limits。以下为实际生效的 Gatekeeper ConstraintTemplate 片段:

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
      validation:
        # Schema for the params field
        openAPIV3Schema:
          properties:
            labels:
              type: array
              items: {type: string}
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels
        violation[{"msg": msg, "details": {"missing_labels": missing}}] {
          provided := {label | input.review.object.metadata.labels[label]}
          required := {label | label := input.parameters.labels[_]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("missing labels: %v", [missing])
        }

多集群灰度发布流水线

采用 Argo Rollouts + Istio 实现跨三套生产集群(北京、上海、深圳)的渐进式流量切流。核心配置包含:

  • 基于 Prometheus 指标(HTTP 5xx 错误率
  • 每次发布仅向单集群注入 5% 流量,持续监控 15 分钟后按指数递增;
  • 所有 rollout 状态同步至内部 SRE 看板,支持 Slack 机器人实时告警。
阶段 流量比例 触发条件 超时阈值
初始化 0% 人工确认
北京集群 5% → 100% P95 15min
上海集群 5% → 50% 北京稳定运行 ≥30min 20min
深圳集群 5% 上海验证通过 10min

可观测性体系升级路径

将原有 ELK 栈迁移至 OpenTelemetry Collector 统一采集层,实现指标、日志、链路三态关联。关键改造包括:

  • 在 Nginx Ingress Controller 中注入 OTel sidecar,捕获真实客户端 IP 与 TLS 版本;
  • 使用 eBPF 探针(Pixie)无侵入获取 Pod 网络连接拓扑,定位 Service Mesh 外部调用瓶颈;
  • 构建基于 Grafana Tempo 的分布式追踪看板,支持按 traceID 关联对应日志行与 Prometheus 指标时间序列。

AI 辅助运维能力集成

在现有 Alertmanager 告警通道中嵌入 Llama-3-8B 微调模型,实现自然语言根因分析。当收到 etcd_leader_changes_total 飙升告警时,模型自动检索最近 2 小时内:

  • etcd 容器 CPU 使用率突增事件;
  • Kubernetes API Server 的 apiserver_request:burnrate4w 异常;
  • 对应节点的 node_disk_io_time_seconds_total 峰值; 输出结构化诊断建议:“检测到 etcd 主节点频繁切换,关联磁盘 I/O 等待超阈值(12.7s > 5s),建议立即检查 SSD 健康状态并执行 smartctl -a /dev/nvme0n1”。

边缘计算场景适配规划

针对 IoT 设备管理平台,正在验证 K3s + EdgeX Foundry + WebAssembly Runtime(WasmEdge)轻量组合。已落地案例:在 2GB 内存边缘网关上部署 Wasm 模块处理 Modbus TCP 数据解析,相比传统 Python 解析器内存占用降低 63%,启动耗时从 2.4s 缩短至 87ms,且支持热更新无需重启服务进程。下一阶段将集成 SPIFFE/SPIRE 实现设备零信任身份认证。

记录一位 Gopher 的成长轨迹,从新手到骨干。

发表回复

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