Istio1.1新特性之限制服务可见性

背景

对于服务的可见性,在 Istio 设计之初,是没有特别考虑的,或者说,Istio 一开始的假定,就是建立在如下这个前提下的:

Istio中的每个服务都可以访问Mesh中的任意服务

即在服务发现/请求转发这个层面,对服务访问的可见性不做任何限制,而通过安全机制来解决服务间调用权限的问题,如RBAC的使用。在这个思想的指导下,Pilot组件是需要将全量的服务信息(服务注册信息和服务治理信息)下发到 Sidecar,这样Sidecar才能在做到不管服务要请求的目标是哪个服务,都可以做到正确的路由。

这个机制在 demo 和小规模使用时不是问题,但是,在实际项目落地时,如果服务数量比较多,数以百计/数以千计,则立即显露出来:

  • 下发的信息量太大:因此Pilot和Sidecar的CPU使用会很高,因为每次都要将全量的数据下发到每一个sidecar,需要编解码。Sidecar的内存使用也会增加。
  • 下发的频度非常密集:系统中任何一个服务的变动,都需要通知到每一个Sidecar,即使这个Sidecar所在的服务完全不访问它

试想:假定 A 服务只需要访问 B/C 两个服务,但是在一个有1000个服务的系统中,A服务的 Sidecar 会不得不接收到其他 998 个服务的数据和每一次的变化通知。其所需有效数据和实际得到数据的比例高达 2:1000!

因此,在没有服务可见性控制的情况下,Pilot到Sidecar的数据下发的有效性低得可谓令人发指!

理想模式:同样假定 A 服务只需要访问 B/C 两个服务,如果能通过某个方式将这个信息(成为服务依赖或者服务可见性)提供出来,让Istio得到这个信息,那么在Pilot往A服务的Sidecar下发数据时,就可以做一个简单的过滤:只发送B/C服务的信息,和只在B/C服务发生变更时通知A。而这个简单过滤所带来的Pilot和Sidecar之间数据下发的性能提升,是和系统内服务数量成线性关系,很容易就实现两个或者三个数量级的提升。

在Istio问世快2年之际,Istio终于开始正视这个问题——好吧,我坦白,在这一点上,我是有怨言的:Istio的工程实现中,对实际生产问题的考虑,非常不到位。

Istio1.1新动态

Istio 1.1 即将发布,这几天陆续看到新文章介绍istio1.1的新功能:

其中和服务可见性直接相关的内容主要是 Sidecar CRD 和 ExportTo 属性。

引用上面文章的内容,”鸿沟前的服务网格—Istio 1.1 新特性预览” 的介绍如下:

  • 新增 Sidecar 资源

目前版本中,Sidecar 会包含整个网格内的服务信息,在 1.1 中,新建了 Sidecar 资源,通过对这一 CRD 的配置,不但能够限制 Sidecar 的相关服务的数量,从而降低资源占用,提高传播效率;还能方便的对 Sidecar 的代理行为做出更多的精细控制——例如对 Ingress 场景中的被代理端点的配置能力。

  • ExportTo

多个路由管理对象加入了这一字段,用于指定该资源的生效范围。

“Istio1.1 功能预告” 一文的介绍是:

  • 新的sidecar:资源:在指定命名空间中使用sidecar资源时,支持定义可访问的服务范围,这样可以降低发给proxy的配置数量。在大规模的集群中,我们推荐给每个namespace增加sidecar对象。 这个功能主要是为了提升性能,减轻proxy计算的负担。

  • 限制网络资源的生效范围:为所有的网络资源增加了exportTo的字段,用来表示此网络资源在哪些namespace中生效。这个字段目前只有两个值:

    • . 表示此网络资源只在自己定义的namespace生效;

    • * 表示此网络资源在所有的namespace生效。

我们来围绕服务可见性,对Istio1.1新特性做一个深入了解。

ExportTo 属性

Istio 1.1 在 DestinationRule / ServiceEntry / VirtualService 三个 CRD 上新增加了 export_to 字段(忽略其他字段):

message DestinationRule {
    repeated string export_to = 4;
}
message ServiceEntry {
    repeated string export_to = 7;
}
message VirtualService {
    repeated string export_to = 6;
}

下面是 exportTo 的字段说明,以 destination rule 为例:

字段 类型 描述
exportTo string[] 当前destination rule要导出的 namespace 列表。 应用于 service 的 destination rule 的解析发生在 namespace 层次结构的上下文中。 destination rule 的导出允许将其包含在其他 namespace 中的服务的解析层次结构中。 此功能为服务所有者和网格管理员提供了一种机制,用于控制跨 namespace 边界的 destination rule 的可见性。

如果未指定任何 namespace,则默认情况下将 destination rule 导出到所有 namespace。

. 被保留,用于定义导出到 destination rule 被声明所在的相同 namespace ,类似的值*保留,用于定义导出到所有 namespaces.

NOTE:在当前版本中,exportTo值被限制为.*(即, 当前namespace或所有namespace)。

从此,对以上三个CRD的使用,都必须满足 exportTo 对namespace的要求,才能被正确引用。如 gateway CRD 的说明:

  // NOTE: Only virtual services exported to the gateway's namespace
  // (e.g., `exportTo` value of `*`) can be referenced.
  // Private configurations (e.g., `exportTo` set to `.`) will not be
  // available. Refer to the `exportTo` setting in `VirtualService`,
  // `DestinationRule`, and `ServiceEntry` configurations for details.
  repeated string hosts = 2;

k8s原生service

前面 exportTo 只能用于 DestinationRule / ServiceEntry / VirtualService ,对于我们最关注的 k8s 原生的 service对象没有涉及。而日常大多数服务都还是普通k8s服务,既然 ServiceEntry 这样Istio管理之外的服务都有可见性支持,没有理由不控制Istio内的服务。

在 ServiceEntry 的定义中,发现注释部分有如下说明:

message ServiceEntry {
    // For a Kubernetes Service, the equivalent effect can be achieved by setting
    // the annotation "networking.istio.io/exportTo" to a comma-separated list
    // of namespace names.
    repeated string export_to = 7;
}

对于k8s原生service,上面的注释说用 annotation “networking.istio.io/exportTo” 可以达到同样的效果。

翻了一下Isito最新的代码,install/kubernetes/helm/istio/charts/mixer/templates/service.yaml 的例子:

apiVersion: v1
kind: Service
metadata:
  name: istio-{{ $key }}
  namespace: {{ $.Release.Namespace }}
  annotations:
    networking.istio.io/exportTo: "*"

pilot/pkg/serviceregistry/kube/conversion.go 文件中有这个annotation的常量定义:

// ServiceExportAnnotation specifies the namespaces to which this service should be exported to.
//   "*" which is the default, indicates it is reachable within the mesh
//   "." indicates it is reachable within its namespace
ServiceExportAnnotation = "networking.istio.io/exportTo"

只在 pilot/pkg/serviceregistry/kube/conversion.go 的convertService() 方法中使用,这个方法将k8s 的 api core 中的 v1.Service 转为 istio 抽象模型中的 service:

func convertService(svc v1.Service, domainSuffix string) *model.Service {
  ......
  if svc.Annotations[ServiceExportAnnotation] != "" {
    exportTo = make(map[model.Visibility]bool)
    for _, e := range strings.Split(svc.Annotations[ServiceExportAnnotation], ",") {
      exportTo[model.Visibility(e)] = true
    }
  }
}

逻辑很简单:用”,“将 annotation “networking.istio.io/exportTo” 的值拆开,然后转成对应 Visibility 对象作为key,以value为true保存起来。

Visibility 类型定义如下:

// Visibility defines whether a given config or service is exported to local namespace, all namespaces or none
type Visibility string
const (
  // VisibilityPrivate implies namespace local config
  VisibilityPrivate Visibility = "."
  // VisibilityPublic implies config is visible to all
  VisibilityPublic Visibility = "*"
  // VisibilityNone implies config is visible to none
  VisibilityNone Visibility = "~"
)

这里有三个特殊值,除了前面描述到的 .* 之外,还有一个 ~ 表示 none,不过目前没有使用。

理论上说,这里可以通过 exportTo 字段(或者等效的 annotation “networking.istio.io/exportTo” )指定特定的 namespace,比如”.,namespace1,namespace2”。但是目前文档中明确指出,只能使用 .*

> // NOTE: in the current release, the `exportTo` value is restricted to
> // "." or "*" (i.e., the current namespace or all namespaces).
> ```

## Sidecar CRD

在 Istio 1.1 中增加了新的CRD Sidecar,具体的定义可见 https://github.com/istio/api/blob/8463cba039d858e8a849847b872ecea50b0994df/networking/v1alpha3/sidecar.proto

从中摘录部分内容:

> Sidecar描述了sidecar代理的配置,sidecar代理调解与其连接的工作负载的 inbound 和 outbound 通信。 默认情况下,Istio将为网格中的所有Sidecar代理服务,使其具有到达网格中每个工作负载所需的必要配置,并在与工作负载关联的所有端口上接收流量。 Sidecar资源提供了一种的方法,在向工作负载转发流量或从工作负载转发流量时,微调端口集合和代理将接收的协议。 **此外,可以限制代理在从工作负载转发 outbound 流量时可以达到的服务集合。**

Sidecar CRD的其他功能我们暂时不展开,只看和服务可见性相关的内容,看看Sidecar CRD是如何限制可以到达的服务结合的。文档中给出了下面这个简单例子:

```yaml
apiVersion: networking.istio.io/v1alpha3
kind: Sidecar
metadata:
  name: default
  namespace: prod-us1
spec:
  egress:
  - hosts:
    - "prod-us1/*"
    - "prod-apis/*"
    - "istio-system/*"

这个示例在 prod-us1 命名空间中声明了Sidecar资源,该资源配置命名空间中的sidecars,允许出口流量到prod-us1,prod-apis和istio-system命名空间中的公共服务。

但是注意这个CRD需要配合前面的 exportTo 字段使用:即如果A服务(namespace为ns1)要访问B服务(namespace为ns2),则需要:

  1. 首先需要B服务申明 exportTo 到 ns1 中
  2. 然后再通过Sidecar CRD 设置 ns1 的 egress 的 hosts 为 "ns2/*"

这样通过 exportTo 字段 + Sidecar.egress.hosts 字段的配合,实现了对服务可见性的限制。

基本原理不复杂,具体实现时还有一些细节需要注意。

WorkloadSelector

上面的例子没有使用WorkloadSelector,因此设置的是整个 namespace 下所有的Sidecar的行为,或者说默认行为。可以通过带有 WorkloadSelector 的 Sidecar 资源来覆盖默认设置,hosts中也可以不用通配符,实现精确控制。

例如下面的例子,就明确限制了 ns1 下的 服务 service-a 可以访问 ns2 下的服务:

apiVersion: networking.istio.io/v1alpha3
kind: Sidecar
metadata:
  name: service-a-sidecar
  namespace: ns1
spec:
  workloadSelector:
    labels:
      app: service-a
  egress:
  - hosts:
    - "ns2/*"

hosts字段

hosts 字段也是可以灵活设置的。文档中描述 hosts 字段为:

必需:以 namespace/dnsName 格式被监听器暴露的的一个或多个服务主机。在指定namespace内与dnsName匹配的服务将被暴露(也就是可以访问)。相应的服务可以是服务注册表中的服务(例如,Kubernetes或cloud foundry服务)或使用ServiceEntry或 VirtualServicec 配置指定的服务。还可以使用同一名称空间中的任何关联的DestinationRule。

应使用FQDN格式指定dnsName,在最左侧的组件中可以包含通配符(例如,prod / * .example.com)。将dnsName设置为 * 可以从指定的命名空间中选择所有服务(例如,prod/*.example.com)。命名空间也可以设置为 * 以从任何可用的命名空间中选择特定服务(例如,”*/ foo.example.com”)。

前面的例子我们使用了通配符,也可以不使用通配符而明确的指定特定可以访问的服务:

apiVersion: networking.istio.io/v1alpha3
kind: Sidecar
metadata:
  name: service-a-sidecar
  namespace: ns1
spec:
  workloadSelector:
    labels:
      app: service-a
  egress:
  - hosts:
    - "ns2/service-b.example.com"
    - "ns2/service-c.example.com"

当然,记得有个前提条件:service-b/service-c 的 k8s service 和相关的 CRD(DestinationRule / ServiceEntry / VirtualService)都必须正确的设置 exportTo。

备注:这里设计的有点复杂,按照这个思路,如果要实现上述的精确限制,多个环节都必须明确设置。一旦有一个地方出错,就会无法访问,然后debug的过程估计不会轻松。

小结:Istio1.1 通过 exportTo 字段 + Sidecar.egress.hosts 字段的配合,实现了对服务可见性的约束

代码实现

pilot/pkg/model/push_context.go 中,PushContext 在保存 Service 和 VirtualService 信息时,都分为 private 和 public 两个结构:

type PushContext struct {
    // privateServices are reachable within the same namespace.
    privateServicesByNamespace map[string][]*Service
    // publicServices are services reachable within the mesh.
    publicServices []*Service

    privateVirtualServicesByNamespace map[string][]Config
    publicVirtualServices             []Config
}

以服务为例,会按照 ExportTo 字段的可见性设置来进行区分,将服务分别存放:

// Caches list of services in the registry, and creates a map
// of hostname to service
func (ps *PushContext) initServiceRegistry(env *Environment) error {
    ......
    for _, s := range allServices {
        ns := s.Attributes.Namespace
        if len(s.Attributes.ExportTo) == 0 {
            if ps.defaultServiceExportTo[VisibilityPrivate] {
               ps.privateServicesByNamespace[ns] 
                   = append(ps.privateServicesByNamespace[ns], s)
            } else if ps.defaultServiceExportTo[VisibilityPublic] {
                ps.publicServices = append(ps.publicServices, s)
            }
        } else {
            if s.Attributes.ExportTo[VisibilityPrivate] {
                ps.privateServicesByNamespace[ns] = 
                   append(ps.privateServicesByNamespace[ns], s)
            } else {
               ps.publicServices = append(ps.publicServices, s)
            }
        }
    }
    ps.ServiceByHostname[s.Hostname] = s
    ps.ServicePort2Name[string(s.Hostname)] = s.Ports
    ......
}

当给具体的proxy下发数据时:

// Services returns the list of services that are visible to a Proxy in a given config namespace
func (ps *PushContext) Services(proxy *Proxy) []*Service {
    // 如果 proxy 有 sidecar scope,则从 sidecar scope 获取 service 列表
    if proxy != nil && proxy.SidecarScope != nil && proxy.SidecarScope.Config != nil && 		   proxy.Type == SidecarProxy {
        return proxy.SidecarScope.Services()
    }
    
    out := []*Service{}

    // 没有 sidecar scope,就只考虑 exportTo 的影响
    if proxy == nil {
        for _, privateServices := range ps.privateServicesByNamespace {
            out = append(out, privateServices...)
        }
    } else {
        // 只给当前 proxy 所在 namespace 的 private 服务
        out = append(out, ps.privateServicesByNamespace[proxy.ConfigNamespace]...)
    }

    // 和 public 的服务
    out = append(out, ps.publicServices...)
    return out
}

SidecarScope 的说明,来自代码注释:

SidecarScope是 Sidecar resource 的包装器,带有一些预处理数据,用于确定给定 Sidecar 可访问的Service,VirtualService和 DestinationRule。 预先计算 Sidecar 的 Service,VirtualService和 DestinationRule 可以提高性能,因为我们不再需要为每个 Sidecar 计算此列表。 我们只需将 Sidecar 与 SidecarScope 相匹配。

type SidecarScope struct {
    // Union of services imported across all egress listeners for use by CDS code.
    services []*Service
}

预处理的细节就不继续展开了。

分析

从目前Istio1.1给出的信息看,Istio开始着手限制服务间可见性,以“降低资源占用,提高传播效率”——虽然我个人认为这个本应该是设计伊始就应该考虑的问题,但是无论如何,有比没有好。

对于目前Istio1.1在限制服务可见性的做法,聊一下个人看法(保留后续更新修改的权利):

  1. 总算提供了一个避免全量数据下发的方式,理论上在服务数量比较多时,通过严格约束服务间的可见性,是可以让 Pilot 到 Sidecar 的数据下发数量起码降低一到两个数量级(1/10到1/100),Pilot的CPU使用/Sidecar的CPU使用/Sidecar的内存占用 应该都可以有明显改善。当然这是理论推断,具体是否做到了还要看 Istio 1.1 的实际测试结果。拭目以待吧,希望是个惊喜。
  2. 可见性的边界,是 namespace,这一点我有些担心:k8s 的 namespace 在实践中一般不会做非常细致的细分,搞不好一个体系里面可能就几个甚至一个 namespace,以 namespace 为边界来决定服务的可见性我个人觉得粒度太大——这一点稍后咨询一下各方情况再做更新。
  3. 设置上有些麻烦,从上面的分析上看,要实现服务A对服务B的精确限制,需要设置服务B的exportTo,包括k8s Service/Istio VitualService/Istio Destination Rule,还要设置服务A的 Sidecar CRD,至少要设置4个地方。繁琐且容易出错,而且语义也不直白:我相信大部分同学如果没有看过类似本文这样的讲解,恐怕很难一下就把这里面的条条道道梳理清楚。
  4. 只是限制服务的可见性,而不是明确的强制要求管理服务间的静态依赖关系,后者其实是我,或者说我们团队想要的。服务可见性和服务静态依赖关系之间有语义上的明确差别:服务可见性不具备强制性的,是笼统的,是可以含糊一点的,从Istio的意图看主要是为了效率的提升(毕竟之前的做法太浪费资源);而服务静态依赖关系是强制性的,依赖明确,设置精准,目标是为体系中的服务调用关系进行强力管控。

先写到这,稍后深入后再补充。

Avatar
敖小剑
中年码农

我目前研究的方向主要在Microservice、Servicemesh、Serverless等和Cloud Native相关的领域,欢迎交流和指导

相关