Posted in

【Golang容器化发布终极指南】:从零到生产级CI/CD流水线的7大核心实践

第一章:Golang容器化发布的全景认知与演进脉络

Golang 自诞生起便以“云原生基因”著称——静态编译、轻量二进制、无运行时依赖等特性,天然契合容器化交付范式。从早期 Docker 1.0 时代手动构建镜像,到如今基于 BuildKit 的多阶段构建与 CI/CD 深度集成,Golang 容器化发布已跨越工具链整合、安全加固、可观测性嵌入三大演进阶段。

容器化价值的底层动因

Go 程序编译后生成单个静态链接二进制文件(如 ./main),无需操作系统级依赖(glibc 等)。这使镜像体积可压缩至极致:

  • Alpine Linux 基础镜像 + Go 二进制 ≈ 12–15 MB
  • Distroless 镜像(如 gcr.io/distroless/static:nonroot)甚至低于 5 MB

对比 Java 或 Node.js 应用动辄 300+ MB 的镜像,Go 显著降低网络分发开销与攻击面。

多阶段构建的标准实践

典型 Dockerfile 采用三阶段设计,兼顾构建效率与运行时精简:

# 构建阶段:完整 Go 环境编译
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s -w' -o /usr/local/bin/app .

# 运行阶段:零依赖最小镜像
FROM gcr.io/distroless/static:nonroot
WORKDIR /root/
COPY --from=builder /usr/local/bin/app .
USER nonroot:nonroot
EXPOSE 8080
CMD ["./app"]

注:CGO_ENABLED=0 禁用 cgo 保证纯静态链接;-ldflags '-s -w' 剥离调试符号与 DWARF 信息,减小二进制体积约 30%。

关键演进节点对照表

时期 核心技术特征 典型痛点
2014–2017 单阶段镜像 + 手动 COPY 镜像臃肿、构建缓存失效频繁
2018–2021 多阶段构建 + .dockerignore 构建上下文传输冗余、安全扫描缺失
2022至今 BuildKit + OCI Image Index + SBOM 生成 镜像签名验证、供应链溯源能力落地

现代发布流程已不再仅关注“能否运行”,而是围绕不可变镜像、SBOM 清单、SLSA 合规性、细粒度权限(如 CAP_NET_BIND_SERVICE 替代 root)构建可信交付链。

第二章:Go应用容器化构建的核心原理与工程实践

2.1 Go静态编译与多阶段Docker构建的底层机制解析

Go 默认静态链接所有依赖(包括 libc 的等效实现),生成无外部动态库依赖的单一可执行文件:

// main.go
package main
import "fmt"
func main() {
    fmt.Println("Hello, Docker!")
}

编译命令 CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o app . 中:CGO_ENABLED=0 禁用 cgo 避免动态链接;-a 强制重新编译所有依赖包;-ldflags '-extldflags "-static"' 确保底层链接器使用静态模式。

多阶段构建本质是利用 Docker 构建缓存隔离「构建环境」与「运行环境」:

阶段 作用 镜像基础
builder 编译 Go 二进制 golang:1.22-alpine
runtime 运行精简二进制 scratchalpine
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -a -o app .

FROM scratch
COPY --from=builder /app/app /app
CMD ["/app"]

该流程规避了将 Go 工具链、源码、中间对象残留于最终镜像,使镜像体积从 ~900MB 缩至 ~7MB。

graph TD A[源码] –>|CGO_ENABLED=0| B[Go编译器] B –> C[静态链接 libc/stdlib] C –> D[单二进制 app] D –> E[scratch 镜像] E –> F[无依赖、最小攻击面]

2.2 GOPROXY、GOOS/GOARCH与交叉编译在镜像构建中的协同实践

在多平台容器化部署中,Go 构建环境需兼顾依赖加速、目标平台适配与构建确定性。

GOPROXY 加速依赖拉取

# Dockerfile 片段
FROM golang:1.22-alpine
ENV GOPROXY=https://goproxy.cn,direct \
    GOSUMDB=sum.golang.org

GOPROXY 指定国内镜像源避免墙阻塞;direct 后缀确保校验失败时直连模块源;GOSUMDB 保障校验和可信性。

GOOS/GOARCH 驱动交叉编译

环境变量 值示例 作用
GOOS linux 目标操作系统
GOARCH arm64 目标 CPU 架构

构建流程协同示意

graph TD
  A[设置 GOPROXY] --> B[go mod download]
  B --> C[设定 GOOS/GOARCH]
  C --> D[go build -o app]
  D --> E[多阶段 COPY 到 alpine]

通过三者联动,单次构建即可产出适配 ARM64 Linux 的静态二进制,无需宿主机安装对应架构工具链。

2.3 Go Module依赖隔离与vendor一致性保障的CI级落地策略

核心原则:go mod vendor + GOPROXY=direct

CI流水线中强制启用 GOPROXY=direct 并执行 go mod vendor,杜绝网络依赖波动影响构建可重现性:

# CI脚本关键片段
set -e
export GOPROXY=direct
export GOSUMDB=off  # 避免sum.golang.org校验失败
go mod vendor
git diff --quiet vendor/ || (echo "vendor目录不一致,请运行go mod vendor并提交"; exit 1)

逻辑分析:GOPROXY=direct 强制从本地vendor/$GOPATH/pkg/mod加载模块;GOSUMDB=off绕过校验(仅限可信内网CI);git diff确保vendor变更被显式提交,实现“代码即依赖快照”。

关键检查项清单

  • ✅ 每次PR触发前校验 go mod verify
  • go list -m all 输出与 vendor/modules.txt 逐行比对
  • ❌ 禁止 go get 直接修改 go.mod(须经go mod tidy -v审计)

构建一致性验证流程

graph TD
    A[CI拉取源码] --> B[执行 go mod download]
    B --> C[运行 go mod vendor]
    C --> D[diff vendor/ 与 Git HEAD]
    D -->|不一致| E[构建失败]
    D -->|一致| F[继续编译测试]
检查点 工具命令 失败后果
模块完整性 go mod verify 中断构建
vendor一致性 git diff --quiet vendor/ 拒绝合并
无未提交变更 git status --porcelain 标记CI为脏构建

2.4 容器镜像瘦身:从distroless基础镜像到UPX压缩的全链路优化

为什么传统镜像臃肿?

Alpine虽轻量,但仍含包管理器、shell 和大量未使用工具。gcr.io/distroless/static:nonroot 镜像仅含运行时依赖(约2MB),彻底移除包管理、shell 和调试工具,强制最小攻击面。

构建 distroless 镜像示例

# 构建阶段:编译 Go 程序(无 CGO)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY main.go .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o myapp .

# 运行阶段:distroless 静态镜像
FROM gcr.io/distroless/static:nonroot
WORKDIR /root
COPY --from=builder /app/myapp .
USER nonroot:nonroot
ENTRYPOINT ["./myapp"]

-ldflags="-s -w" 去除符号表与调试信息,减小二进制体积约30%;CGO_ENABLED=0 确保纯静态链接,避免 libc 依赖。

UPX 进一步压缩(谨慎启用)

工具 压缩率 兼容性 启动开销
upx --ultra-brute ~55% x86_64/ARM64 +2~5ms
strip -s ~20% 全平台
# 对已构建的二进制执行 UPX(需在构建阶段显式安装)
upx --best --lzma ./myapp

UPX 是运行时解压,非透明;某些安全策略(如内存扫描)可能拦截,需配合 --no-syscalls 或白名单策略。

全链路优化流程

graph TD
    A[源码] --> B[CGO_ENABLED=0 编译]
    B --> C[-ldflags=\"-s -w\" 链接]
    C --> D[distroless 镜像打包]
    D --> E[UPX 可选压缩]
    E --> F[OCI 镜像推送]

2.5 构建缓存策略:Docker BuildKit与Buildx Cache Mount的高性能实践

BuildKit 默认启用分层缓存,但第三方依赖(如 pip installnpm install)仍易失效。--mount=type=cache 可显式复用中间产物:

# syntax=docker/dockerfile:1
FROM python:3.11-slim
# 挂载 pip 缓存目录,避免重复下载
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r requirements.txt

逻辑分析type=cache 创建持久化缓存挂载点;target 指定容器内路径;BuildKit 自动为相同 target+id 的 mount 复用缓存层,跨构建共享。

缓存挂载关键参数对比

参数 说明 是否必需
type=cache 声明缓存挂载类型
target 容器内挂载路径
id 缓存唯一标识(默认为 target 值) ❌(推荐显式指定)

构建时启用 BuildKit 与缓存后端

DOCKER_BUILDKIT=1 docker build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-to type=registry,ref=myapp/cache:latest \
  --cache-from type=registry,ref=myapp/cache:latest \
  .

启用 inline cache 并推送/拉取远程缓存镜像,实现 CI 环境跨节点复用。

graph TD A[源码变更] –> B{Dockerfile 中 mount ID 是否匹配?} B –>|是| C[复用本地缓存] B –>|否| D[重新执行 RUN 步骤] C –> E[加速构建] D –> E

第三章:生产就绪型Go服务的容器配置范式

3.1 Health Check与Readiness/Liveness探针的Go HTTP服务适配设计

标准化探针端点设计

Go服务需暴露三个独立HTTP端点:/healthz(liveness)、/readyz(readiness)、/livez(别名兼容)。避免复用同一路径,防止状态耦合。

探针语义分离实现

func setupProbes(mux *http.ServeMux) {
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) // 常驻进程存活即返回
    })
    mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
        if !dbConnected || !cacheWarmed { // 依赖组件检查
            http.Error(w, "dependencies unavailable", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })
}

/healthz仅验证进程可响应,无外部依赖;/readyz必须校验数据库连接、缓存预热等业务就绪条件,失败返回 503 触发K8s流量摘除。

Kubernetes配置映射

探针类型 初始延迟 超时 失败阈值 用途
liveness 30s 2s 3 防止僵死进程
readiness 5s 1s 1 控制流量注入

状态流转逻辑

graph TD
    A[Pod启动] --> B[/healthz OK?]
    B -->|Yes| C[/readyz OK?]
    B -->|No| D[重启容器]
    C -->|Yes| E[加入Service Endpoints]
    C -->|No| F[暂不接收流量]

3.2 环境变量、ConfigMap与Secret的Go应用配置注入最佳实践

配置优先级设计原则

应用应遵循:环境变量 > ConfigMap > 默认硬编码值;Secret 仅用于敏感字段,且绝不与 ConfigMap 混用。

Go 中安全读取 ConfigMap 的典型模式

// 从环境变量读取,fallback 到默认值
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
    dbHost = "localhost" // fallback
}

os.Getenv 无错误返回,需显式判空;适用于简单键值,但不校验类型或格式。

Secret 注入的最小权限实践

方式 是否推荐 原因
volumeMount 文件权限可控,避免内存泄露
envFrom + secretRef ⚠️ 全量注入,易误曝非敏感字段

配置加载流程(mermaid)

graph TD
    A[启动] --> B{环境变量存在?}
    B -->|是| C[直接使用]
    B -->|否| D[查 ConfigMap 卷文件]
    D -->|存在| E[解析 YAML]
    D -->|缺失| F[用内置默认值]

3.3 Go runtime指标暴露(pprof/metrics)与Prometheus容器监控集成

Go 应用可通过标准库 net/http/pprofruntime/metrics 暴露运行时指标,为 Prometheus 提供抓取端点。

内置 pprof 端点启用

import _ "net/http/pprof"

func main() {
    go http.ListenAndServe("localhost:6060", nil) // 启动 pprof HTTP 服务
}

该导入自动注册 /debug/pprof/* 路由;6060 是惯例端口,需在容器中通过 EXPOSE 6060 声明,并映射至 Prometheus 抓取配置。

Prometheus metrics 端点(结构化指标)

import "runtime/metrics"

func exposeMetrics(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; version=0.0.4")
    for _, name := range []string{"/gc/heap/allocs:bytes", "/memory/classes/heap/free:bytes"} {
        m := metrics.Read(metrics.Name(name))
        fmt.Fprintf(w, "# TYPE go_%s gauge\n", strings.ReplaceAll(name, "/", "_"))
        fmt.Fprintf(w, "go_%s %v\n", strings.ReplaceAll(name, "/", "_"), m.Value.(float64))
    }
}

metrics.Read() 返回实时采样值;/gc/heap/allocs:bytes 表示累计堆分配字节数,精度达纳秒级,适用于内存泄漏趋势分析。

容器监控集成关键配置

配置项 示例值 说明
scrape_interval 15s 高频采集 runtime 指标以捕捉 GC 尖峰
metric_relabel_configs __name__ =~ "^go_.*" 过滤并标准化指标命名空间
targets ["app:6060"] 对齐容器 Service 名称与端口
graph TD
    A[Go App] -->|HTTP /metrics| B[Prometheus Scraping]
    B --> C[Time-Series DB]
    C --> D[Grafana 可视化]
    A -->|/debug/pprof/heap| E[pprof 分析工具]

第四章:Kubernetes原生部署与弹性治理实战

4.1 Deployment滚动更新与蓝绿/金丝雀发布在Go微服务中的声明式实现

Go微服务的发布策略需兼顾可靠性与可观察性。Kubernetes原生支持滚动更新,但蓝绿与金丝雀需结合Service、Ingress及自定义CRD实现声明式控制。

声明式滚动更新示例

apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # 允许临时超出期望副本数的最大值(可为数字或百分比)
      maxUnavailable: 0  # 更新期间不可用Pod上限,设为0保证零中断

该配置确保新旧版本平滑过渡,每个新Pod就绪后才终止一个旧Pod,适用于无状态核心服务。

发布策略对比

策略 流量切分粒度 回滚速度 需求组件
滚动更新 全量 秒级 Deployment + ReadinessProbe
蓝绿 100%切换 秒级 Service + Label selector
金丝雀 百分比/Header 分钟级 Istio VirtualService 或 Gateway API

流量路由逻辑

graph TD
  A[Ingress] -->|Host: api.example.com| B[Gateway]
  B --> C{Route Rule}
  C -->|weight: 90%| D[v1 Deployment]
  C -->|weight: 10%| E[v2 Deployment]

金丝雀发布依赖Sidecar或网关层流量染色,配合Prometheus指标自动中止异常发布。

4.2 Horizontal Pod Autoscaler与Go应用CPU/Memory指标阈值调优

Go应用常因GC抖动或协程泄漏导致CPU使用率瞬时飙升,但HPA默认基于15秒窗口的平均值可能滞后响应。需结合应用行为特征精细化调参。

关键配置项解析

  • --horizontal-pod-autoscaler-sync-period:控制HPA控制器同步频率(默认15s)
  • metricsresource 类型支持 cpu/memory,但内存不可驱逐,仅作扩容参考

HPA资源配置示例

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: go-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: go-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60  # Go服务建议设为50–70,避开GC峰值区
  - type: Resource
    resource:
      name: memory
      target:
        type: AverageValue
        averageValue: "512Mi"  # 避免OOMKilled,需结合GOGC=100实测

该配置中 averageUtilization: 60 指Pod CPU请求值的60%;若Pod requests.cpu=500m,则触发扩容的阈值为300m。averageValue 对内存更稳妥,因Go RSS波动剧烈,利用率指标易误判。

推荐调优路径

  • 首轮压测:固定并发下观察kubectl top pods的CPU/MEM分布
  • 调整target.averageUtilization至P95稳定值的1.2倍
  • 启用kubectl get hpa -w实时验证扩缩容延迟
指标类型 推荐目标模式 典型风险点
CPU Utilization GC周期性尖峰误触发扩容
Memory AverageValue RSS突增(如大对象缓存)导致过早扩容
graph TD
  A[Go应用运行] --> B{CPU持续>60%?}
  B -->|Yes| C[HPA计算所需副本数]
  B -->|No| D[维持当前副本]
  C --> E[检查内存是否>512Mi]
  E -->|Yes| F[综合决策扩容]
  E -->|No| D

4.3 Service Mesh(Istio)下Go gRPC服务的mTLS与流量切分实操

启用Istio全局mTLS

Istio默认启用PERMISSIVE模式,需通过PeerAuthentication强制双向TLS:

# peer-auth.yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system
spec:
  mtls:
    mode: STRICT  # 强制所有服务间通信使用mTLS

此配置作用于istio-system命名空间,影响所有启用了Sidecar注入的Pod。STRICT模式要求客户端和服务端均提供有效证书,由Citadel(或Istiod内置CA)签发,确保gRPC连接自动加密且无需修改业务代码。

基于权重的gRPC流量切分

利用VirtualService对同一gRPC服务版本实施灰度路由:

route weight version
v1 80 stable
v2 20 canary
# vs-grpc.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: grpc-service
spec:
  hosts:
  - grpc-svc.default.svc.cluster.local
  http:
  - route:
    - destination:
        host: grpc-svc.default.svc.cluster.local
        subset: v1
      weight: 80
    - destination:
        host: grpc-svc.default.svc.cluster.local
        subset: v2
      weight: 20

gRPC基于HTTP/2,Istio通过http字段路由(非tcp),依赖subset匹配DestinationRule中定义的标签。权重生效需配合DestinationRuletrafficPolicy.loadBalancer.simple: ROUND_ROBIN确保负载均衡一致性。

4.4 StatefulSet与Go有状态服务(如etcd client、Redis连接池)的生命周期管理

StatefulSet 为有状态应用提供稳定的网络标识、有序部署/扩缩容及持久存储绑定,是 Go 微服务连接 etcd 或 Redis 等中间件的关键载体。

连接池初始化与 Pod 生命周期对齐

Go 应用应在 init()main() 中延迟初始化连接池,避免在容器启动早期因服务端点未就绪而失败:

// 初始化 Redis 连接池(配合 readinessProbe)
func newRedisPool() *redis.Pool {
    return &redis.Pool{
        MaxIdle:     16,
        MaxActive:   32,
        Wait:        true,
        IdleTimeout: 240 * time.Second,
        Dial: func() (redis.Conn, error) {
            return redis.Dial("tcp", "redis-0.redis-headless.default.svc.cluster.local:6379")
        },
    }
}

redis-0.redis-headless.default.svc.cluster.local 依赖 StatefulSet 提供的稳定 DNS 名(<pod-name>.<headless-svc>.<ns>.svc.cluster.local),确保连接指向确定 Pod 实例;IdleTimeout 避免长连接在滚动更新时僵死。

健康探针与优雅终止协同

需配置 readinessProbe 检查连接池可用性,preStop 执行连接回收:

探针类型 配置要点 作用
readinessProbe exec: curl -sf http://localhost:8080/healthz 确保仅当 Redis/etcd 连通时才纳入 Service 流量
preStop sleep 10 && kill -SIGTERM $PID 给 Go 应用 10s 完成连接池 Close() 和事务回滚
graph TD
    A[Pod 启动] --> B[initContainers 拉取证书]
    B --> C[主容器启动]
    C --> D[Go 初始化 etcd client]
    D --> E[readinessProbe 成功 → 加入 Endpoint]
    E --> F[收到 SIGTERM]
    F --> G[执行 defer pool.Close()]
    G --> H[连接释放完毕 → 容器终止]

第五章:从CI/CD流水线到SRE可观测性的闭环演进

可观测性不是监控的升级,而是反馈机制的重构

某金融级支付平台在完成容器化迁移后,将Jenkins流水线升级为GitLab CI,并在每个部署阶段注入OpenTelemetry SDK。当订单服务发布v2.3.1时,CI流水线自动触发/health/ready探针校验与Prometheus指标快照比对——发现payment_process_duration_seconds_p95较基线突增320ms,随即中止部署并触发告警工单。该策略使线上P1故障平均修复时间(MTTR)从47分钟降至8.3分钟。

数据链路必须贯穿全生命周期

下表展示了某电商大促前一次灰度发布的可观测性数据流转路径:

阶段 工具链 采集指标 自动化动作
构建 GitHub Actions 编译耗时、依赖漏洞数 漏洞≥3个则阻断PR合并
测试 Argo Workflows + Jaeger 接口成功率、Span延迟分布 失败率>0.5%自动回滚测试环境
发布 FluxCD + Grafana Alerting Pod就绪延迟、CPU瞬时峰值 超过阈值自动暂停Rollout并推送TraceID至Slack

告警必须携带可执行上下文

在Kubernetes集群中,当kube_pod_container_status_restarts_total触发告警时,SRE平台不再仅推送文本消息,而是通过Webhook向PagerDuty注入结构化Payload:

{
  "runbook_url": "https://ops.internal/runbooks/pod-restart-escalation",
  "trace_id": "0xabcdef1234567890",
  "related_logs_query": "kql: pod_name == 'checkout-7b8c' and timestamp > ago(15m)",
  "rollback_command": "kubectl rollout undo deployment/checkout --to-revision=12"
}

运维人员点击告警卡片即可直接执行回滚或跳转至关联日志分析界面。

SLO驱动的发布门禁成为新标准

某SaaS厂商将核心API的SLO(错误率≤0.1%、延迟p99≤300ms)嵌入Argo Rollouts的AnalysisTemplate:

- name: validate-slo
  args:
  - --slo-error-rate-threshold=0.001
  - --slo-latency-p99-threshold=300
  metrics:
  - name: error-rate
    provider:
      prometheus:
        address: http://prometheus.monitoring.svc
        query: sum(rate(http_request_duration_seconds_count{job="api",code=~"5.."}[10m])) / sum(rate(http_request_duration_seconds_count{job="api"}[10m]))

若连续2次分析失败,自动触发金丝雀流量切回旧版本。

工程师行为数据反哺流程优化

通过分析CI流水线日志中的git commit --amend频率、kubectl rollout history调用次数及告警响应时长,发现团队在发布窗口期存在高频手动干预。据此重构Pipeline,在pre-promote阶段增加自动化容量压测(基于Locust+Prometheus历史负载模型),将人工决策点减少67%。

可观测性闭环的关键不在工具而在契约

某跨国车企的车载OTA系统要求所有微服务必须在/metrics端点暴露service_slo_compliance_ratio自定义指标,且该指标由服务自身根据实时请求采样计算——而非由中心化监控系统推算。CI流水线强制校验该指标存在性及格式合规性,缺失则拒绝镜像入库。这种“服务自治可观测性”的契约设计,使跨地域团队在无需统一监控平台的前提下仍能保障SLO对齐。

graph LR
A[代码提交] --> B[CI构建+静态扫描]
B --> C[UT覆盖率≥85%?]
C -->|Yes| D[注入OTel探针生成TraceID]
D --> E[部署至Staging环境]
E --> F[自动执行SLO验证查询]
F -->|达标| G[触发金丝雀发布]
F -->|不达标| H[标记失败并归档Trace链路]
G --> I[生产环境渐进式流量切换]
I --> J[实时对比SLO偏差]
J -->|持续达标| K[完成发布]
J -->|偏差超限| L[自动回滚+推送完整诊断包]

记录 Golang 学习修行之路,每一步都算数。

发表回复

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