跳过正文
gRPC 微服务实践:协议、负载均衡与 Kubernetes 集成

gRPC 微服务实践:协议、负载均衡与 Kubernetes 集成

·1612 字·8 分钟·
目录

为什么内部微服务选 gRPC 而不是 REST
#

在面向外部用户的 API 中,REST + JSON 是无可争议的首选——生态成熟、调试简单、前端友好。但在内部微服务之间的调用场景,gRPC 有几个结构性优势:

协议效率:Protobuf 二进制编码比 JSON 体积通常小 3-10 倍,序列化/反序列化 CPU 开销也更低。在高频 RPC(如每秒数万次的服务间调用)场景下,这个差距会直接反映在延迟和机器成本上。

强类型契约.proto 文件是服务间接口的唯一真相来源,IDL 驱动生成客户端/服务端骨架代码,避免了 REST 文档与实现不同步的问题。字段类型不匹配在编译期就能发现,不会等到运行时。

HTTP/2 多路复用:gRPC 基于 HTTP/2,单连接可并发多个 stream,消除了 HTTP/1.1 的队头阻塞。四种调用模式(Unary、Server Streaming、Client Streaming、Bidirectional Streaming)可以覆盖推送、大文件分片、实时事件等复杂场景。

生态完整:拦截器机制统一处理认证、限流、链路追踪;gRPC-Web 可以让浏览器直接调用;grpc-gateway 可以将 gRPC 服务同时暴露为 REST 接口,兼顾存量系统。

当然 gRPC 也有代价:调试没有 curl 方便(需要 grpcurl 或 BloomRPC)、浏览器原生支持需要额外代理、错误码体系与 HTTP 状态码不对应需要转换层。


Protobuf 设计最佳实践
#

字段编号与向后兼容
#

Protobuf 的字段编号一旦发布就不能变更,这是向后兼容的基础。几条核心规则:

syntax = "proto3";

package user.v1;

option go_package = "github.com/yourorg/proto/user/v1;userv1";

message User {
  // 1-15 编号只占 1 个字节,用于高频字段
  int64  id          = 1;
  string name        = 2;
  string email       = 3;
  UserStatus status  = 4;

  // 16-2047 占 2 个字节,用于低频或后加字段
  string avatar_url  = 16;
  int64  created_at  = 17;  // Unix timestamp,避免 Timestamp 类型跨语言问题

  // 废弃字段:不能复用编号,用 reserved 保留
  reserved 5, 6;
  reserved "old_nickname";
}

// 枚举第 0 值必须是 UNSPECIFIED,表示未设置,不能作为业务值
enum UserStatus {
  USER_STATUS_UNSPECIFIED = 0;
  USER_STATUS_ACTIVE      = 1;
  USER_STATUS_SUSPENDED   = 2;
  USER_STATUS_DELETED     = 3;
}

向后兼容规则

  • 只能新增字段,不能删除或重命名(可用 reserved 保护废弃编号)
  • 不能修改已有字段类型(int32int64 在 wire format 上不兼容)
  • 不能修改字段编号
  • 可以将 optional 字段改为 repeated(反之不行)

oneof 处理多态请求
#

message NotificationRequest {
  string title   = 1;
  string content = 2;

  oneof channel {
    EmailChannel  email  = 10;
    SmsChannel    sms    = 11;
    PushChannel   push   = 12;
  }
}

message EmailChannel {
  repeated string to  = 1;
  string          cc  = 2;
}

message SmsChannel {
  string phone    = 1;
  string template = 2;
}

oneof 确保只有一个字段被设置,避免调用方同时填入多个渠道导致歧义。代码侧通过类型断言或 switch 处理不同 case,比用 string 类型标记再解析 JSON 更安全。

版本管理策略
#

推荐按 package 版本化(user.v1user.v2),而非文件名。破坏性变更(如字段语义变化)发新版本 package,旧版本继续运行直到迁移完成。目录结构:

proto/
├── user/
│   ├── v1/
│   │   └── user.proto
│   └── v2/
│       └── user.proto
└── notification/
    └── v1/
        └── notification.proto

Go 实现 gRPC 服务端
#

项目结构
#

.
├── cmd/server/main.go
├── internal/
│   ├── handler/         # gRPC handler 实现
│   ├── interceptor/     # 拦截器
│   └── service/         # 业务逻辑
├── proto/               # .proto 文件
└── gen/                 # protoc 生成代码

服务实现
#

// internal/handler/user.go
package handler

import (
    "context"
    "time"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    userv1 "github.com/yourorg/proto/user/v1"
    "github.com/yourorg/svc-user/internal/service"
)

type UserHandler struct {
    userv1.UnimplementedUserServiceServer
    svc service.UserService
}

func NewUserHandler(svc service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

func (h *UserHandler) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
    if req.GetId() <= 0 {
        return nil, status.Errorf(codes.InvalidArgument, "id must be positive, got %d", req.GetId())
    }

    user, err := h.svc.GetByID(ctx, req.GetId())
    if err != nil {
        if errors.Is(err, service.ErrNotFound) {
            return nil, status.Errorf(codes.NotFound, "user %d not found", req.GetId())
        }
        return nil, status.Errorf(codes.Internal, "internal error: %v", err)
    }

    return &userv1.GetUserResponse{User: toProto(user)}, nil
}

// Server Streaming 示例:批量导出用户
func (h *UserHandler) ListUsers(req *userv1.ListUsersRequest, stream userv1.UserService_ListUsersServer) error {
    cursor := int64(0)
    for {
        users, nextCursor, err := h.svc.List(stream.Context(), cursor, 100)
        if err != nil {
            return status.Errorf(codes.Internal, "list error: %v", err)
        }
        for _, u := range users {
            if err := stream.Send(&userv1.ListUsersResponse{User: toProto(u)}); err != nil {
                return err // client 断开,直接返回
            }
        }
        if nextCursor == 0 {
            break
        }
        cursor = nextCursor
    }
    return nil
}

拦截器链
#

拦截器是 gRPC 中横切关注点的标准实现位置。使用 grpc.ChainUnaryInterceptor 组合多个拦截器,执行顺序与注册顺序一致。

// internal/interceptor/logging.go
package interceptor

import (
    "context"
    "time"

    "go.uber.org/zap"
    "google.golang.org/grpc"
    "google.golang.org/grpc/status"
)

func UnaryLogging(logger *zap.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        start := time.Now()
        resp, err := handler(ctx, req)
        st, _ := status.FromError(err)
        logger.Info("grpc call",
            zap.String("method", info.FullMethod),
            zap.Duration("duration", time.Since(start)),
            zap.String("code", st.Code().String()),
            zap.Error(err),
        )
        return resp, err
    }
}
// internal/interceptor/ratelimit.go
package interceptor

import (
    "context"

    "golang.org/x/time/rate"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

func UnaryRateLimit(limiter *rate.Limiter) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        if !limiter.Allow() {
            return nil, status.Errorf(codes.ResourceExhausted, "rate limit exceeded")
        }
        return handler(ctx, req)
    }
}
// internal/interceptor/tracing.go
package interceptor

import (
    "context"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
)

// 从 gRPC metadata 提取 trace context 并注入到 context
func UnaryTracing() grpc.UnaryServerInterceptor {
    propagator := otel.GetTextMapPropagator()
    tracer := otel.Tracer("grpc-server")

    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        md, _ := metadata.FromIncomingContext(ctx)
        ctx = propagator.Extract(ctx, metadataCarrier(md))

        ctx, span := tracer.Start(ctx, info.FullMethod)
        defer span.End()

        return handler(ctx, req)
    }
}

// metadataCarrier 实现 propagation.TextMapCarrier
type metadataCarrier metadata.MD

func (c metadataCarrier) Get(key string) string {
    vals := metadata.MD(c).Get(key)
    if len(vals) == 0 {
        return ""
    }
    return vals[0]
}
func (c metadataCarrier) Set(key, val string) { metadata.MD(c).Set(key, val) }
func (c metadataCarrier) Keys() []string {
    keys := make([]string, 0, len(c))
    for k := range c {
        keys = append(keys, k)
    }
    return keys
}
// cmd/server/main.go
package main

import (
    "net"

    "golang.org/x/time/rate"
    "google.golang.org/grpc"
    "google.golang.org/grpc/health"
    healthpb "google.golang.org/grpc/health/grpc_health_v1"
    "go.uber.org/zap"

    userv1 "github.com/yourorg/proto/user/v1"
    "github.com/yourorg/svc-user/internal/handler"
    "github.com/yourorg/svc-user/internal/interceptor"
    "github.com/yourorg/svc-user/internal/service"
)

func main() {
    logger, _ := zap.NewProduction()
    limiter := rate.NewLimiter(rate.Limit(1000), 100) // 1000 RPS,burst 100

    svc := service.New(/* deps */)
    userHandler := handler.NewUserHandler(svc)

    srv := grpc.NewServer(
        grpc.ChainUnaryInterceptor(
            interceptor.UnaryTracing(),
            interceptor.UnaryLogging(logger),
            interceptor.UnaryRateLimit(limiter),
        ),
        grpc.MaxRecvMsgSize(4*1024*1024), // 4MB
    )

    userv1.RegisterUserServiceServer(srv, userHandler)

    // 注册健康检查服务
    healthSrv := health.NewServer()
    healthpb.RegisterHealthServer(srv, healthSrv)
    healthSrv.SetServingStatus("user.v1.UserService", healthpb.HealthCheckResponse_SERVING)

    lis, _ := net.Listen("tcp", ":50051")
    logger.Info("gRPC server listening", zap.String("addr", ":50051"))
    if err := srv.Serve(lis); err != nil {
        logger.Fatal("serve failed", zap.Error(err))
    }
}

Kubernetes 中 gRPC 负载均衡的陷阱
#

这是生产环境最容易踩的坑。

问题根因
#

HTTP/1.1 是短连接模型,K8s Service(ClusterIP + kube-proxy iptables)对每个新 TCP 连接做轮询,天然负载均衡。

gRPC 基于 HTTP/2,客户端与服务端建立一条持久长连接,所有 RPC 都在这条连接上复用。结果:如果你有 3 个 Pod 副本,某个客户端实例可能永远只打到其中一个 Pod,其他 Pod 空载。

解法 1:headless Service + 客户端 round_robin
#

headless Service 不分配 ClusterIP,DNS 解析直接返回所有 Pod IP,客户端自行做负载均衡。

# headless-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: svc-user-headless
  namespace: production
spec:
  clusterIP: None          # 关键:headless
  selector:
    app: svc-user
  ports:
    - name: grpc
      port: 50051
      targetPort: 50051

客户端 Go 代码使用 dns resolver + round_robin balancer:

import (
    "google.golang.org/grpc"
    "google.golang.org/grpc/balancer/roundrobin"
    "google.golang.org/grpc/credentials/insecure"
    _ "google.golang.org/grpc/resolver/dns" // 注册 dns resolver
)

func NewUserClient(addr string) (userv1.UserServiceClient, error) {
    // addr 格式: "dns:///svc-user-headless.production.svc.cluster.local:50051"
    conn, err := grpc.NewClient(
        addr,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithDefaultServiceConfig(`{
            "loadBalancingPolicy": "round_robin",
            "methodConfig": [{
                "name": [{"service": "user.v1.UserService"}],
                "retryPolicy": {
                    "maxAttempts": 3,
                    "initialBackoff": "0.1s",
                    "maxBackoff": "1s",
                    "backoffMultiplier": 2,
                    "retryableStatusCodes": ["UNAVAILABLE"]
                },
                "timeout": "5s"
            }]
        }`),
    )
    if err != nil {
        return nil, err
    }
    return userv1.NewUserServiceClient(conn), nil
}

注意:DNS 解析有缓存,新 Pod 上线后客户端可能不会立即感知。生产中建议设置较短的 DNS TTL,或使用 grpc.WithResolverBuildRegistry 注入自定义 resolver(如 etcd/consul 服务发现)。

解法 2:Envoy/Istio L7 负载均衡
#

客户端侧负载均衡的问题:每个服务都要正确配置,维护成本高;服务发现逻辑下沉到应用。

更推荐的方案是让 Envoy Sidecar(Istio) 在 L7 做 gRPC 负载均衡,应用代码无感知,只需指向普通 ClusterIP Service。

# VirtualService 配置 gRPC 路由(Istio)
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: svc-user
  namespace: production
spec:
  hosts:
    - svc-user
  http:
    - match:
        - headers:
            content-type:
              prefix: "application/grpc"
      route:
        - destination:
            host: svc-user
            port:
              number: 50051
      timeout: 10s
      retries:
        attempts: 3
        perTryTimeout: 3s
        retryOn: "reset,connect-failure,retriable-status-codes"
        retryRemoteStatuses: 14  # UNAVAILABLE
# DestinationRule:启用 gRPC 健康检查探测
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: svc-user
  namespace: production
spec:
  host: svc-user
  trafficPolicy:
    loadBalancer:
      simple: LEAST_CONN   # gRPC 场景下比 ROUND_ROBIN 更均匀
    connectionPool:
      http:
        h2UpgradePolicy: UPGRADE
        http2MaxRequests: 1000
    outlierDetection:
      consecutive5xxErrors: 5
      interval: 30s
      baseEjectionTime: 30s

健康检查:gRPC Health Protocol + K8s Probe
#

gRPC 有标准健康检查协议(grpc.health.v1),比 HTTP /healthz 更原生。

服务端注册(已在上面 main.go 中展示),Kubernetes Probe 配置如下:

# deployment.yaml(片段)
containers:
  - name: svc-user
    image: yourorg/svc-user:v1.2.0
    ports:
      - containerPort: 50051
        name: grpc
    livenessProbe:
      grpc:
        port: 50051
        service: "user.v1.UserService"  # 空字符串表示检查整体健康
      initialDelaySeconds: 10
      periodSeconds: 15
      failureThreshold: 3
    readinessProbe:
      grpc:
        port: 50051
        service: "user.v1.UserService"
      initialDelaySeconds: 5
      periodSeconds: 10
      failureThreshold: 2
    # startupProbe 适用于启动慢的服务(如需要预热缓存)
    startupProbe:
      grpc:
        port: 50051
      failureThreshold: 30
      periodSeconds: 2

注意grpc probe 类型需要 Kubernetes 1.24+。旧版本集群需要用 grpc_health_probe 二进制作为 exec probe:

livenessProbe:
  exec:
    command:
      - /bin/grpc_health_probe
      - -addr=:50051
      - -service=user.v1.UserService
  initialDelaySeconds: 10

反射 API 与 grpcurl 调试
#

生产环境建议只在 dev/staging 开启反射,prod 关闭(避免接口信息泄露):

import "google.golang.org/grpc/reflection"

if os.Getenv("GRPC_REFLECTION") == "true" {
    reflection.Register(srv)
}

常用 grpcurl 命令:

# 列出所有服务
grpcurl -plaintext localhost:50051 list

# 列出某服务的方法
grpcurl -plaintext localhost:50051 list user.v1.UserService

# 查看方法详情
grpcurl -plaintext localhost:50051 describe user.v1.UserService.GetUser

# 调用(JSON 请求体)
grpcurl -plaintext \
  -d '{"id": 123}' \
  localhost:50051 \
  user.v1.UserService/GetUser

# 带 metadata(模拟 trace header)
grpcurl -plaintext \
  -H 'x-b3-traceid: abc123' \
  -d '{"id": 123}' \
  localhost:50051 \
  user.v1.UserService/GetUser

# 从 proto 文件调用(不依赖反射)
grpcurl -plaintext \
  -proto proto/user/v1/user.proto \
  -import-path proto \
  -d '{"id": 123}' \
  localhost:50051 \
  user.v1.UserService/GetUser

Prometheus Metrics 采集
#

使用 go-grpc-prometheus 库,自动暴露 gRPC 调用的 QPS、延迟直方图、错误率:

import grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"

srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        grpc_prometheus.UnaryServerInterceptor,  // 放在链首,确保所有请求都被计量
        interceptor.UnaryTracing(),
        interceptor.UnaryLogging(logger),
        interceptor.UnaryRateLimit(limiter),
    ),
    grpc.ChainStreamInterceptor(
        grpc_prometheus.StreamServerInterceptor,
    ),
)

// 初始化 metrics(在所有服务注册后调用)
grpc_prometheus.EnableHandlingTimeHistogram(
    grpc_prometheus.WithHistogramBuckets([]float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5}),
)
grpc_prometheus.Register(srv)

// 暴露 /metrics 端点(独立端口,不与 gRPC 混用)
http.Handle("/metrics", promhttp.Handler())
go http.ListenAndServe(":9090", nil)

关键 Prometheus 指标:

# gRPC 请求 QPS(按方法、状态码分组)
sum(rate(grpc_server_handled_total[1m])) by (grpc_method, grpc_code)

# P99 延迟
histogram_quantile(0.99,
  sum(rate(grpc_server_handling_seconds_bucket[5m])) by (grpc_method, le)
)

# 错误率
sum(rate(grpc_server_handled_total{grpc_code!="OK"}[1m])) by (grpc_method)
/
sum(rate(grpc_server_handled_total[1m])) by (grpc_method)

grpc-gateway:同端口暴露 REST 接口
#

.proto 文件中添加 HTTP 映射注解:

import "google/api/annotations.proto";

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option (google.api.http) = {
      get: "/v1/users/{id}"
    };
  }

  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }
}

服务端使用 cmux 在同一端口同时处理 gRPC 和 HTTP:

import (
    "net/http"

    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "github.com/soheilhy/cmux"
    "google.golang.org/grpc"
    "google.golang.org/protobuf/encoding/protojson"
)

func main() {
    lis, _ := net.Listen("tcp", ":8080")
    m := cmux.New(lis)

    // HTTP/2 走 gRPC
    grpcL := m.MatchWithWriters(
        cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"),
    )
    // 其余走 HTTP/1.1(REST)
    httpL := m.Match(cmux.HTTP1Fast())

    grpcSrv := buildGRPCServer()
    httpSrv := buildHTTPGateway()

    go grpcSrv.Serve(grpcL)
    go httpSrv.Serve(httpL)
    m.Serve()
}

func buildHTTPGateway() *http.Server {
    mux := runtime.NewServeMux(
        runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
            MarshalOptions: protojson.MarshalOptions{
                UseProtoNames:   true,  // 使用 proto 字段名,不做驼峰转换
                EmitUnpopulated: false,
            },
        }),
        // 从 HTTP Header 透传 Authorization 到 gRPC metadata
        runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
            switch strings.ToLower(key) {
            case "authorization", "x-request-id":
                return key, true
            }
            return "", false
        }),
    )

    opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
    userv1.RegisterUserServiceHandlerFromEndpoint(context.Background(), mux, "localhost:50051", opts)

    return &http.Server{Handler: mux}
}

生产问题排查
#

连接超时与 RST_STREAM
#

现象:gRPC 调用偶发 transport is closingRST_STREAM

排查路径

  1. 检查中间负载均衡器(ALB/NLB)的 idle timeout:AWS ALB 默认 60s,gRPC 长连接如果超过这个时间没有流量会被强制关闭。

    # 客户端配置 keepalive 参数
    
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                20 * time.Second, // 每 20s 发一次 ping
        Timeout:             5 * time.Second,  // 5s 内没有响应则断开
        PermitWithoutStream: true,             // 空闲连接也发 ping
    })
    

    服务端对应配置:

    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionIdle:     30 * time.Second,
        MaxConnectionAge:      2 * time.Minute,
        MaxConnectionAgeGrace: 5 * time.Second,
        Time:                  20 * time.Second,
        Timeout:               5 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             10 * time.Second,
        PermitWithoutStream: true,
    }),
    
  2. 检查流控窗口(flow control):大量 streaming 调用时,如果 sender 速度远超 receiver 处理能力,会触发流控。通过 GRPC_TRACE=flowcontrol 环境变量开启 trace 日志分析。

排查工具
#

# 抓包分析 HTTP/2 帧
tcpdump -i eth0 -w /tmp/grpc.pcap port 50051
# 用 Wireshark 打开,过滤 http2,可以看到每个 stream 的帧类型和标志位

# 开启 gRPC 详细日志
GRPC_GO_LOG_VERBOSITY_LEVEL=99 GRPC_GO_LOG_SEVERITY_LEVEL=info ./server

# 查看连接状态
grpc.ClientConn.GetState() // IDLE/CONNECTING/READY/TRANSIENT_FAILURE/SHUTDOWN

总结
#

gRPC 在 K8s 内部微服务的效率和类型收益都很真实,但落地时这几件事容易翻车:

  1. Protobuf 设计时就按向后兼容做,reserved 保护废弃字段
  2. 负载均衡是最容易被忽视的陷阱:ClusterIP Service + gRPC 长连接 = 负载不均,要么 headless + round_robin,要么 Istio L7
  3. 拦截器链统一处理横切关注点,注意顺序,tracing 要最先执行
  4. Keepalive 要对齐基础设施的 idle timeout,否则偶发断连排查半天
  5. grpc-gateway 做渐进迁移很顺,存量 REST 客户端不用动
Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。

相关文章

Flagger 渐进式交付实战:金丝雀、蓝绿、A/B 与 Istio/NGINX/Gateway API 集成

·4105 字·20 分钟
传统的 kubectl apply 发布方式让风险集中在发布那一刻。Flagger 通过指标驱动的渐进式切流(Canary Analysis),把风险摊到整个发布过程,异常自动回滚。本文基于官方文档,系统讲解 Canary CR 的完整字段、三种策略的配置模板、与 Istio/NGINX Ingress/Gateway API 的集成、自定义指标分析、自动化回滚机制,以及与 Argo Rollouts 的选型对比。

Temporal 分布式工作流引擎实战:Worker、Activity、重试语义与生产部署

·4135 字·20 分钟
长流程业务编排历来头疼——状态机、定时器、补偿、幂等、失败恢复都要自己写。Temporal 用 event sourcing + 确定性 replay 把这些问题一次性解决。本文以 Go SDK 为主线,从编程模型、Workflow 确定性约束、Activity 重试、Signal/Query、child workflow、到生产集群部署、监控和容量规划,给出可直接落地的范式。

Istio Ambient Mode 无 Sidecar 服务网格实践

·1464 字·7 分钟
Sidecar 模式已经陪我们走了六七年,但它的问题也越来越难以忽视。Ambient Mode 不是缝缝补补,而是从架构层面重新设计了服务网格的数据面。本文从实际运维视角深入拆解 ztunnel + Waypoint 两层架构,并给出从 Sidecar 迁移到 Ambient 的完整路径。