istio dns拦截

k8s原生dns在istio场景的问题

dns的作用

DNS解析是Kubernetes上任何应用程序基础架构的重要组成部分.当您的应用程序代码尝试访问Kubernetes集群中的另一个服务甚至是Internet上的服务时,它必须先查找与该服务的主机名相对应的IP地址,然后再启动与该服务的连接.此名称查找过程通常称为服务发现。在Kubernetes中,server(无论是kube-dnsCoreDNS还是CoreDNS)将服务的主机名解析为唯一的不可路由的虚拟IP(VIP),如果它是clusterIP类型的服务.在kube-proxy每个节点上这个VIP映射到该服务的一组pod,并随机选择一个pod进行转发。使用服务网格时,边车的工作原理就流量转发而言与kube-proxy相同。

下图描述了当今DNS的作用:

DNS带来的问题

尽管DNS在服务网格中的作用似乎微不足道,但它始终代表着将网格扩展到VM并实现无缝多集群访问的方式。

虚拟机访问Kubernetes服务

考虑到VM带有sidecar的情况。如下图所示,VM上的应用程序会查找Kubernetes集群内服务的IP地址,因为它们通常无法访问集群的DNS服务器。

如果有人愿意参与一些涉及dnsmasq和使用NodePort服务对kube-dns进行外部暴露的复杂变通方法,从技术上讲,可以在虚拟机上使用kube-dns作为域名服务器:假设您设法说服集群管理员这样做.即使这样,您仍在打开许多安全问题的大门.归根结底,对于那些组织能力和领域专业知识有限的人来说,这些解决方案通常超出范围。

没有VIP的外部TCP服务

不仅网状网络中的VM遭受DNS问题。为了使Sidecar能够准确地区分网格外部的两个不同TCP服务之间的流量,这些服务必须位于不同的端口上,或者它们需要具有全局唯一的VIP,就像clusterIP分配给Kubernetes服务一样。但是,如果没有VIP,该怎么办?云托管服务(例如托管数据库)通常没有VIP。取而代之的是,提供者的DNS服务器返回实例IP之一,然后可由应用程序直接访问这些实例IP。例如,考虑以下两个服务条目,它们指向两个不同的AWS RDS服务:

apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
  name: db1
  namespace: ns1
spec:
  hosts:
  - mysql–instance1.us-east-1.rds.amazonaws.com
  ports:
  - name: mysql
    number: 3306
    protocol: TCP
  resolution: DNS
---
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
  name: db2
  namespace: ns1
spec:
  hosts:
  - mysql–instance2.us-east-1.rds.amazonaws.com
  ports:
  - name: mysql
    number: 3306
    protocol: TCP
  resolution: DNS

边车上有一个监听器 0.0.0.0:3306,该监听器从公共DNS服务器查找mysql-instance1.us-east1.rds.amazonaws.com的IP地址并将流量转发给它。它无法将流量路由至db2因为它无法区分到达的流量 0.0.0.0:3306是绑定db1还是绑定db2。实现此目的的唯一方法是将解析设置为NONE,使Sidecar将端口上的所有流量盲目转发3306到应用程序请求的原始IP。这类似于在防火墙上打一个洞,使所有流量都可以3306传入端口,而与目标IP无关。为了使流量畅通,现在您不得不在系统的安全性上做出妥协。

为远程集群中的服务解析DNS

多集群网格的DNS限制是众所周知的。如果没有笨拙的解决方法(例如在调用方名称空间中创建存根服务),则一个集群中的服务无法查找其他集群中服务的IP地址。

控制DNS

总而言之,DNS在Istio中一直是一个棘手的问题。现在是时候杀死那只野兽了。我们(Istio网络团队)决定以对您(最终用户)完全透明的方式彻底解决该问题。我们的首次尝试涉及利用Envoy的DNS代理。事实证明这是非常不可靠的,并且由于Envoy使用的c-ares DNS库普遍缺乏复杂性,因此总体上令人失望。为了解决这个问题,我们决定在Go语言编写的Istio sidecar代理中实现DNS代理。我们能够优化实现,以处理我们要解决的所有场景,而不会影响规模和稳定性。我们使用的Go DNS库与可扩展DNS实现(例如CoreDNS,Consul,Mesos等)使用的库相同。

从Istio 1.8开始,Sidecar上的Istio代理将附带由Istiod动态编程的缓存DNS代理。Istiod基于Kubernetes服务和集群中的服务条目,为应用程序可以访问的所有服务推送主机名到IP地址的映射。来自应用程序的DNS查找查询被Pod或VM中的Istio代理透明地拦截并提供服务。如果查询是针对网格中的服务,则无论该服务所在的集群是什么,代理都会直接对应用程序做出响应。如果不是,它将查询转发到/etc/resolv.conf中定义的上游域名服务器。下图描述了当应用程序尝试使用其主机名访问服务时发生的交互。

正如您将在以下各节中看到的那样,DNS代理功能已在Istio的许多方面产生了巨大的影响。

降低DNS服务器的负载并提高解析度

集群中Kubernetes DNS server上的负载急剧下降,因为Istio在Pod内几乎解决了所有DNS查询。集群上的网格使用范围越大,DNS服务器上的负载就越小。在Istio代理中实现自己的DNS代理使我们能够实现出色的优化,例如CoreDNS auto-path,而不会出现CoreDNS当前面临的正确性问题。

要了解此优化的影响,让我们在标准Kubernetes集群中采用简单的DNS查找方案,而无需为Pod进行任何自定义DNS设置-即,默认/etc/resolv.conf中设置为ndots:5。当您的应用程序启动DNS查找 productpage.ns1.svc.cluster.local时,它会在按原查询主机之前将DNS搜索名称空间作为DNS查询的一部分附加在/etc/resolv.conf(例如ns1.svc.cluster.local)中。结果,实际上发出的第一个DNS查询看起来像 productpage.ns1.svc.cluster.local.ns1.svc.cluster.local,当不涉及Istio时,它将不可避免地使DNS解析失败。如果您 /etc/resolv.conf有5个搜索名称空间,则应用程序将为每个搜索名称空间发送两个DNS查询,一个用于IPv4 A记录,另一个用于IPv6 AAAA记录,然后是最后一对查询,其中包含代码中使用的确切主机名。在建立连接之前,该应用程序将为每个主机执行12个DNS查找查询!

使用Istio实现的CoreDNS样式自动路径技术,Sidecar代理将检测到在第一个查询中查询的真实主机名,并将cname记录 返回productpage.ns1.svc.cluster.local为该DNS响应的一部分以及的A/AAAA记录 productpage.ns1.svc.cluster.local。现在,收到此响应的应用程序可以立即提取IP地址,并继续建立与该IP的TCP连接。Istio代理中的智能DNS代理将DNS查询数量从12个大大减少到2个!

虚拟机到Kubernetes集成

由于Istio代理对网格内的服务执行了本地DNS解析,因此从VM进行的Kubernetes服务的DNS查找查询现在将成功完成,而无需笨拙的变通办法来暴露kube-dns 到集群外部。现在,无缝解析集群中内部服务的能力将简化您到微服务的旅程,因为VM现在可以访问Kubernetes上的微服务,而无需通过API网关进行其他级别的间接访问。

尽可能自动分配VIP

您可能会问,代理中的此DNS功能如何解决区分在同一端口上没有VIP的多个外部TCP服务的问题?

从Kubernetes获得启发,Istio现在将自动将不可路由的VIP(来自E类子网)分配给此类服务,只要它们不使用通配符主机即可。边车上的Istio代理将使用VIP作为来自应用程序的DNS查找查询的响应。现在,Envoy可以清楚地区分绑定到每个外部TCP服务的流量,并将其转发到正确的目标。通过引入DNS代理,您将不再需要resolution: NONE用于非通配TCP服务,从而改善了整体安全性。Istio在通配符外部服务(例如*.us-east1.rds.amazonaws.com)方面无济于事。您将不得不诉诸NONE解析模式来处理此类服务。

多集群DNS查找

对于喜欢冒险的人来说,尝试编织一个多集群网格,其中应用程序直接调用远程集群中名称空间的内部服务,DNS代理功能非常方便。您的应用程序可以解析任何名称空间中任何集群上的Kubernetes服务,而无需在每个集群中创建存根Kubernetes服务。

DNS代理的优势超出了Istio当前描述的多集群模型。在Tetrate,我们在客户的多集群部署中广泛使用此机制,以使Sidecar能够为网格中所有集群的入口网关处暴露的主机解析DNS,并通过相互的TLS访问它们。

istio DNS实现原理

简介

在istio1.8中为了支持DNS解析功能,并且实现了dns cache,不需要通过search域进行多次查询,例如解析bar.foo.svc.cluster.local可能需要依次解析bar.foo.svc.cluster.local.foo.svc.cluster.local/bar.foo.svc.cluster.local.svc.cluster.local/bar.foo.svc.cluster.local.cluster.local/bar.foo.svc.cluster.local,这是因为search域的配置影响,而istio pilot-agent dns代理则只需要一次解析,提高了解析速度。

为了支持dns cache,则需要控制面下发网格内部的现有服务的域名IP配置到agent,为了解决数据传输问题引入了NDS(NameTable Service Discovery)协议,其url定义为type.googleapis.com/istio.networking.nds.v1.NameTable

本位将探索NDS资源的下发方式及客户端的dns解析原理

pilot-agent

在通过以下方式安装istio后

 istioctl install --set profile=demo  --set meshConfig.defaultConfig.proxyMetadata.ISTIO_META_DNS_CAPTURE='\"true\"'

pilot-agent的istio-iptables将在添加iptables规则时将53的dns请求重定向到pilot-agent同时启动dns server

func (sa *Agent) initLocalDNSServer(isSidecar bool) (err error) {
    if sa.cfg.DNSCapture && sa.cfg.ProxyXDSViaAgent && isSidecar {
        if sa.localDNSServer, err = dns.NewLocalDNSServer(sa.cfg.ProxyNamespace, sa.cfg.ProxyDomain); err != nil {
            return err
        }
        sa.localDNSServer.StartDNS()
    }
    return nil
}

NewLocalDNSServer

根据集群域及命名空间获取LocalDNSServer

func NewLocalDNSServer(proxyNamespace, proxyDomain string) (*LocalDNSServer, error) {
    h := &LocalDNSServer{
        proxyNamespace: proxyNamespace,
    }
    // proxyDomain可以包含使其成为冗余的名称空间.我们只需要.svc.cluster.local部分
    parts := strings.Split(proxyDomain, ".")
    if len(parts) > 0 {
        if parts[0] == proxyNamespace {
            parts = parts[1:]
        }
        h.proxyDomainParts = parts
        h.proxyDomain = strings.Join(parts, ".")
    }
    // 使用本地的resolv.conf作为dns代理配置
    dnsConfig, err := dns.ClientConfigFromFile("/etc/resolv.conf")
    if err != nil {
        log.Warnf("failed to load /etc/resolv.conf: %v", err)
        return nil, err
    }
     // 与传统的DNS解析器不同,不需要将搜索名称空间附加到查询再解析。这是因为代理充当应用程序进行的DNS查询的DNS拦截器。应用程序的解析器已经向我们发送了DNS查询,每个DNS搜索名称空间都有一个。我们只需要在本地命名表中检查此名称是否存在.如果没有,我们会将查询原样转发给上游解析器。
    if dnsConfig != nil {
        for _, s := range dnsConfig.Servers {
            h.resolvConfServers = append(h.resolvConfServers, s+":53")
        }
        h.searchNamespaces = dnsConfig.Search
    }

    if h.udpDNSProxy, err = newDNSProxy("udp", h); err != nil {
        return nil, err
    }
    if h.tcpDNSProxy, err = newDNSProxy("tcp", h); err != nil {
        return nil, err
    }

    return h, nil
}

newDNSProxy

将LocalDNSServer作为resolver传递给对应的tcp和udp proxy

StartDNS

分别启动tcpDNSProxy和udpDNSProxy

ServeDNS

对于miekg/dns来说handler需要实现ServeDNS也就是LocalDNSServer的ServeDNS方法

func (h *LocalDNSServer) ServeDNS(proxy *dnsProxy, w dns.ResponseWriter, req *dns.Msg) {
    var response *dns.Msg

    if len(req.Question) == 0 {
        response = new(dns.Msg)
        response.SetReply(req)
        response.Rcode = dns.RcodeNameError
    } else {
        // 获取 LookupTable
        lp := h.lookupTable.Load()
        if lp == nil {
            response = new(dns.Msg)
            response.SetReply(req)
            response.Rcode = dns.RcodeNameError
            _ = w.WriteMsg(response)
            return
        }
        lookupTable := lp.(*LookupTable)
        var answers []dns.RR

        //获取要解析的主机名
        hostname := strings.ToLower(req.Question[0].Name)
        // 判断LookupTable中是否存在,存在则直接返回
        answers, hostFound := lookupTable.lookupHost(req.Question[0].Qtype, hostname)

        if hostFound {
            response = new(dns.Msg)
            response.SetReply(req)
            response.Answer = answers
            if len(answers) == 0 {
                // 我们在预编译的已知主机列表中找到了该主机,但是该查询类型没有有效记录.所以返回NXDOMAIN
                response.Rcode = dns.RcodeNameError
            }
        } else {
            // 我们没有在内部缓存中找到主机.向上游查询并按原样返回响应。
            response = h.queryUpstream(proxy.upstreamClient, req)
        }
    }

    _ = w.WriteMsg(response)
}

更新本地缓存

if p.localDNSServer != nil && len(resp.Resources) > 0 {
    var nt nds.NameTable
    // TODO we should probably send ACK and not update nametable here
    if err = ptypes.UnmarshalAny(resp.Resources[0], &nt); err != nil {
        log.Errorf("failed to unmarshall name table: %v", err)
    }
    p.localDNSServer.UpdateLookupTable(&nt)
}

通过UpdateLookupTable更新localDNSServer数据

pilot-discovery

在istio的xds实现中分为以下两个channel

  • reqChannel 用于处理xds请求

  • pushChannel 用于主动推送xds数据变更

他们最终都将调用 pushXds

pushXds

func (s *DiscoveryServer) pushXds(con *Connection, push *model.PushContext,
    currentVersion string, w *model.WatchedResource, req *model.PushRequest) error {
    if w == nil {
        return nil
    }
    gen := s.findGenerator(w.TypeUrl, con)
    if gen == nil {
        return nil
    }

    t0 := time.Now()

    cl := gen.Generate(con.proxy, push, w, req)
    if cl == nil {
        // If we have nothing to send, report that we got an ACK for this version.
        if s.StatusReporter != nil {
            s.StatusReporter.RegisterEvent(con.ConID, w.TypeUrl, push.Version)
        }
        return nil // No push needed.
    }
    defer func() { recordPushTime(w.TypeUrl, time.Since(t0)) }()

    resp := &discovery.DiscoveryResponse{
        TypeUrl:     w.TypeUrl,
        VersionInfo: currentVersion,
        Nonce:       nonce(push.Version),
        Resources:   cl,
    }

    err := con.send(resp)
    if err != nil {
        recordSendError(w.TypeUrl, con.ConID, err)
        return err
    }

    // Some types handle logs inside Generate, skip them here
    if _, f := SkipLogTypes[w.TypeUrl]; !f {
        adsLog.Infof("%s: PUSH for node:%s resources:%d", v3.GetShortType(w.TypeUrl), con.proxy.ID, len(cl))
    }
    return nil
}

通过对应的TypeUrl,获取对应的Generator

s.findGenerator(w.TypeUrl, con)

然后从根据客户端的配置生产成对应的资源

cl := gen.Generate(con.proxy, push, w, req)

对于nds服务发现类型其对应的url为type.googleapis.com/istio.networking.nds.v1.NameTable 其对应的Generate实现为

func (n NdsGenerator) Generate(proxy *model.Proxy, push *model.PushContext, w *model.WatchedResource, req *model.PushRequest) model.Resources {
    if !ndsNeedsPush(req) {
        return nil
    }
    nt := n.Server.ConfigGenerator.BuildNameTable(proxy, push)
    if nt == nil {
        return nil
    }
    resources := model.Resources{util.MessageToAny(nt)}
    return resources
}

其主要逻辑为BuildNameTable,获取nametable序列化后返回给客户端

BuildNameTable

BuildNameTable生成一个主机名及其关联的IP的表,然后代理可以使用该表来解析DNS.此逻辑始终处于活动状态.但是,只有在代理中启用DNS捕获后,本地DNS解析才会生效

func (configgen *ConfigGeneratorImpl) BuildNameTable(node *model.Proxy, push *model.PushContext) *nds.NameTable {
    // 只对sidecar类型的代理生效
    if node.Type != model.SidecarProxy {
        return nil
    }

    out := &nds.NameTable{
        Table: map[string]*nds.NameTable_NameInfo{},
    }

    for _, svc := range push.Services(node) {
        目前无法解析泛域名
        if svc.Hostname.IsWildCarded() {
            continue
        }

        svcAddress := svc.GetServiceAddressForProxy(node, push)
        var addressList []string

        //对于headless svc或者对于自动分配的serviceentry此处未指定IP,缺乏对有状态应用解析的支持
        if svcAddress == constants.UnspecifiedIP {
            // 用ep填充
            if svc.Attributes.ServiceRegist ry == string(serviceregistry.Kubernetes) &&
                svc.Resolution == model.Passthrough && len(svc.Ports) > 0 {
                for _, instance := range push.ServiceInstancesByPort(svc, svc.Ports[0].Port, nil) {
                    addressList = append(addressList, instance.Endpoint.Address)
                }
            }

            if len(addressList) == 0 {
                continue
            }
        } else {
            addressList = append(addressList, svcAddress)
        }

        nameInfo := &nds.NameTable_NameInfo{
            Ips:      addressList,
            Registry: svc.Attributes.ServiceRegistry,
        }
        if svc.Attributes.ServiceRegistry == string(serviceregistry.Kubernetes) {
            nameInfo.Namespace = svc.Attributes.Namespace
            nameInfo.Shortname = svc.Attributes.Name
        }
        out.Table[string(svc.Hostname)] = nameInfo
    }
    return out
}

Last updated