用 gRPC 元数据实现请求链路中的租户识别
一开始想得太简单
前段时间给内部服务加多租户,入口是个 HTTP 网关,鉴权完把 X-Tenant-Id 放进 header,后面服务 A 调服务 B、B 调 C 都走 gRPC。我一开始的想法很朴素:客户端在 metadata 里塞个 x-tenant-id,服务端读出来不就完了?结果链路一长,发现事情没这么简单。A 能读到,B 读不到;单测能过,一上流式接口又丢了;最离谱的是有些异步任务里 ctx 一换,租户直接变空。后来干脆把这块收口到拦截器里,才算是稳定下来。
metadata 到底适合放什么
gRPC 的 metadata 可以理解成 HTTP header 的同类东西,跟着一次 RPC 走,key 不区分大小写,实际会被转成小写。租户 ID 这种短字符串、每个请求都要带、又不适合塞进业务 proto 字段的东西,放 metadata 很合适。注意别把大对象、敏感信息往里扔,metadata 不是给你传文件用的。还有二进制内容要用 -bin 后缀,租户 ID 一般用不上。
约定一个 key,比如 x-tenant-id。不要用 tenant-id、TenantId、tenant_id 混着来,最后排查问题的时候会想骂人。我们统一用小写中划线,代码里也只认这一个。
客户端:把租户放进 outgoing context
Go 里客户端设置 metadata 大概是这样:
md := metadata.Pairs("x-tenant-id", tenantID)
ctx = metadata.NewOutgoingContext(ctx, md)
resp, err := client.DoSomething(ctx, req)
如果 ctx 里已经有 metadata,比如链路追踪的 traceparent,别直接 NewOutgoingContext 覆盖掉,用 metadata.AppendToOutgoingContext,或者先 FromOutgoingContext 取出来再 Set。Set 会覆盖同名 key,适合保证只有一个租户 ID;Append 会追加,搞不好服务端拿到两个值,后面又得写去重逻辑。
入口处要特别注意:租户 ID 不能直接信任客户端传上来的 header。正常做法是网关鉴权完,从 token 或 session 里拿到租户,然后覆盖掉外部传进来的 x-tenant-id。否则用户随便改个 header 就能串租户,这坑就不用多说了。
服务端:拦截器统一收口
服务端不要在每一个 handler 里写 metadata.FromIncomingContext,写多了肯定会漏。用 unary 拦截器统一提取、校验、放进 context:
type tenantKey struct{}
func TenantFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(tenantKey{}).(string)
return v, ok
}
func UnaryTenantInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
vals := md.Get("x-tenant-id")
if len(vals) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing tenant id")
}
tenantID := strings.TrimSpace(vals[0])
if !validTenantID(tenantID) {
return nil, status.Error(codes.PermissionDenied, "invalid tenant id")
}
ctx = context.WithValue(ctx, tenantKey{}, tenantID)
return handler(ctx, req)
}
validTenantID 别偷懒,至少限制长度和字符,比如只允许字母、数字、下划线、中划线,长度 1 到 64。不要拿租户 ID 直接拼 SQL、拼表名、拼 Redis key 前缀,这个和 gRPC 没关系,但多租户系统里迟早会遇到。
业务代码里就只认 TenantFromContext,拿不到就报错,别给默认租户。默认租户这种东西,平时看起来很方便,出问题的时候就是数据串到别人家。
下游继续传:别在第二跳丢了
服务端 A 从 context 里拿到租户之后,调服务 B 的时候还得再放进 outgoing metadata。有人会在每个业务方法里手动写一遍:
ctx = metadata.AppendToOutgoingContext(ctx, "x-tenant-id", tenantID)
能跑,但很容易漏。更稳的是加一个客户端拦截器,出站时自动从 context 里取:
func UnaryTenantClientInterceptor(
ctx context.Context,
method string,
req, reply interface{},
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
if tenantID, ok := TenantFromContext(ctx); ok {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.MD{}
} else {
md = md.Copy()
}
md.Set("x-tenant-id", tenantID)
ctx = metadata.NewOutgoingContext(ctx, md)
}
return invoker(ctx, method, req, reply, cc, opts...)
}
这样 A 调 B、B 调 C,只要 ctx 没断,租户就一直跟着。注意 md.Copy(),别把原 metadata 改了,有些时候同一个 ctx 会被复用。
流式接口和异步代码别漏
unary 拦截器只覆盖一元调用,流式接口要单独写 stream 拦截器。核心是把原始 ServerStream 包一层,让 Context() 返回带租户的 ctx:
type wrappedStream struct {
grpc.ServerStream
ctx context.Context
}
func (w *wrappedStream) Context() context.Context {
return w.ctx
}
func StreamTenantInterceptor(
srv interface{},
ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) error {
md, ok := metadata.FromIncomingContext(ss.Context())
if !ok {
return status.Error(codes.Unauthenticated, "missing metadata")
}
vals := md.Get("x-tenant-id")
if len(vals) == 0 {
return status.Error(codes.Unauthenticated, "missing tenant id")
}
ctx := context.WithValue(ss.Context(), tenantKey{}, strings.TrimSpace(vals[0]))
return handler(srv, &wrappedStream{ServerStream: ss, ctx: ctx})
}
异步代码更要注意。开了 goroutine 之后还用 context.Background(),租户当然就没了。要么从原 ctx 派生,要么显式把 tenantID 当参数传进去,别指望 context 会自己飞过去。
几个容易踩的坑
第一个是 key 大小写。metadata 的 key 会被转成小写,写 md.Get("X-Tenant-Id") 可能也能用,但别赌,统一小写最省事。
第二个是多值。md.Get 返回的是 slice,有人直接取 vals[0],但如果上游重复塞了两个值,你取第一个还是最后一个?最好在入口就覆盖成单值,服务端再做一次校验。
第三个是日志和链路追踪。拿到租户后可以在日志里打 tenant_id,排查问题很方便。但别把租户 ID 直接做成 Prometheus 的 label,租户一多指标基数会爆炸。trace 的 baggage 可以带,但也要注意别把敏感信息带进去。
第四个是权限。metadata 只负责“传”,不负责“信”。服务端不能因为 metadata 里有 x-tenant-id 就认为这个请求一定有权访问该租户,内部服务之间该有的鉴权、mTLS 还是要有。数据层查询也必须带租户条件,不能只靠网关拦一道。
最后,gRPC 重试、超时、中间件顺序这些也会影响 metadata。拦截器顺序最好固定下来,认证、租户、日志、恢复各管各的,别在一个拦截器里又读 header 又写日志又做鉴权,后面想改都改不动。
这套东西写完其实不复杂,核心就是三件事:入口覆盖租户、服务端拦截器收口、出站拦截器继续传。把这三步做扎实,链路里少一个 x-tenant-id 的概率就小很多。至于数据层隔离,那是另一个话题,但千万别觉得 gRPC metadata 传过去了就万事大吉。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。