Istio中的透明代理问题

为何需要透明代理

Istio的Sidecar作为一个网络代理,它拦截入站、出站的网络流量。拦截入站流量后,会使用127.0.0.1作为源地址,将流量转发给本地服务进程。本地服务进程看不到真实源IP地址。

很多应用场景下,真实源IP地址是必须的,可能原因包括:

  1. IP地址作为标识的一部分。以ZooKeeper为例,它通过成员的IP地址来验证集群成员身份
  2. IP地址用于网络策略,或者用于审计目的

本文将设置这样的场景:一个启用了Istio Sidecar的Nginx Pod,需要被当前命名空间的另外一个Pod访问。我们将尝试解决Nginx不能看到真实的客户端IP地址的问题。

 

Envoy的现状

目前Envoy已经能够很好的支持IP Transparency了。 它提供了多种机制把真实源地址提供给上游服务。

 

http.original_src

真实源地址可以通过 x-forwarded-for这样的请求头获取,很多应用都能识别这种请求头。

Envoy还提供了 envoy.filters.http.original_src,此过滤器能够从请求头读取真实源地址,并修改底层TCP连接的源地址。此过滤器还能处理单一下游连接携带来自多个源的HTTP请求的情况。此过滤器的缺点包括:

  1. 下游连接必须正确设置了x-forwarded-for头
  2. 由于连接池方面的限制,会导致些许性能影响
  3. 配置较为复杂,可能需要路由的配合,即使在Sidecar场景(Envoy和上游在同一网络命名空间)下,也需要配置好iptables规则

 

listener.proxy_protocol

HAProxy代理协议提供了交换连接元数据的机制,这些元数据就包括真实源IP。Envoy通过监听器过滤器 envoy.filters.listener.proxy_protocol支持代理协议。此过滤器的缺点包括:

  1. 上游主机需要支持代理协议
  2. 仅仅支持TCP

该监听器过滤器可以和envoy.filters.listener.original_src联用。

 

listener.original_src

在受控部署环境下,通过监听器过滤器 envoy.filters.listener.original_src可以把下游连接源地址复制为上游连接的源地址。

这需要使用透明代理,让Envoy直接以下游地址向上游服务发起连接。对于上游服务,没有任何要求。此过滤器的缺点包括:

  1. Envoy要能够获得真实的下游地址
  2. 由于路由方面的限制,可能无法实现
  3. 由于连接池方面的限制,会导致些许性能影响

这个过滤器是让Istio能够解决透明代理问题的途径,回答一下对它的缺点的规避:

  1. Envoy获取真实下游IP地址,也就是入站连接的真实源地址:这可以通过TPROXY拦截模式让Envoy看到真实下游地址
  2. 路由方面的限制:不存在,因为Envoy和上游服务(入站连接需要访问的服务)在一个网络命名空间中,可以软件控制路由

Istio的现状

在两年前就有了关于此问题的Issue:https://github.com/istio/istio/issues/5679。到目前为止,Istio官方没有提供支持透明代理的方案。

关于拦截模式

Istio支持两种拦截模式:

  1. REDIRECT:使用iptables的REDIRECT目标来拦截入站请求,转给Envoy
  2. TPROXY:使用iptables的TPROXY目标来拦截入站请求,转给Envoy

你可以全局的设置默认拦截模式,也可以通过注解 sidecar.istio.io/interceptionMode: TPROXY给某个工作负载单独设置。

需要注意的是TPROXY模式解决的仅仅是Envoy看到的入站连接源IP地址的问题,被代理本地服务看到的地址仍然是127.0.0.1。

下面对比一下两种拦截模式下生成的iptables规则的差异:

TPROXY

mangle表的内容如下:

# iptables -t mangle -L -n
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination         
ISTIO_INBOUND  tcp  --  0.0.0.0/0            0.0.0.0/0           
 
Chain INPUT (policy ACCEPT)
target     prot opt source               destination         
 
Chain FORWARD (policy ACCEPT)
target     prot opt source               destination         
 
Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         
 
Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination         
 
Chain ISTIO_DIVERT (1 references)
target     prot opt source               destination         
MARK       all  --  0.0.0.0/0            0.0.0.0/0            MARK set 0x539
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_INBOUND (1 references)
target     prot opt source               destination        
# 不拦截特殊端口 
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:22
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:15090
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:15020
# 如果SRC_IP:SRC_PORT:DST_IP:DST_PORT已经建立拦截,则打标记,接受封包
ISTIO_DIVERT  tcp  --  0.0.0.0/0            0.0.0.0/0            socket
# 否则,如果目的地不是127.0.0.1,则重定向给Envoy
ISTIO_TPROXY  tcp  --  0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_TPROXY (1 references)
target     prot opt source               destination         
TPROXY     tcp  --  0.0.0.0/0           !127.0.0.1            TPROXY redirect 0.0.0.0:15001 mark 0x539/0xffffffff

 

可以看到,拦截的逻辑比较简单,仅仅改了 PREROUTING (关注进入的封包)链,增加以下逻辑:

  1. 对于一些特殊端口,不做拦截
  2. 对于已经建立了连接的封包,直接打标记1337并允许通过
  3. 对于目的地址不是127.0.0.1的封包,进行透明代理,发送给Envoy的15001监听器,给封包打标记1337

istio-init在启动工作负载之前会设置策略路由:

ip -f inet rule add fwmark 1337 lookup 133
ip -f inet route add local default dev lo table 133

 

这保证了目的地不是127.0.0.1的封包都会被15001处理,也就是所有外部请求都需要经过Envoy处理,而Envoy向本地被代理服务转发时,会使用目的地址127.0.0.1,不会被拦截。

nat表的内容如下:

# iptables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 1271 packets, 76260 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain INPUT (policy ACCEPT 1271 packets, 76260 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain OUTPUT (policy ACCEPT 38 packets, 3183 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    7   420 ISTIO_OUTPUT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain POSTROUTING (policy ACCEPT 38 packets, 3183 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain ISTIO_IN_REDIRECT (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15006
 
Chain ISTIO_OUTPUT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      lo      127.0.0.6            0.0.0.0/0 
# 下面根据UID进行匹配的规则,应该有问题。因为TPROXY模式下,UID固定为0,因此下面3条规则应该去掉
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner UID match 1337
    2   120 RETURN             all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner UID match 1337
    0     0 RETURN             all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner UID match 1337
# 根据用户不同决定行为,如果GID为1337,意味着是Envoy进程发起的封包,否则是其它进程发起的
# 对于将从lo发出的封包,如果用户是Envoy,目的地址非127.0.0.1的,则重定向到入站虚拟监听器15006
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner GID match 1337
# 对于将从lo发出的封包,如果用户不是Envoy,则允许通过。这保证了本机上的服务可以访问自己
    0     0 RETURN             all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner GID match 1337
# 对于将从非lo发出的封包,如果用户是Envoy,允许通过。这保证了Envoy可以访问外部
    5   300 RETURN             all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner GID match 1337
# 到这里,所有目的地址是127.0.0.1的都被允许
    0     0 RETURN             all  --  *      *       0.0.0.0/0            127.0.0.1           
# 重定向给出站虚拟监听器15001,可能情况:
# 对于将从非lo发出的封包,如果用户不是Envoy,目的地址不是本机,则重定向到出站虚拟监听器15001
#     这保证了服务的对外访问,需要经过Envoy代理
    0     0 ISTIO_REDIRECT     all  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_REDIRECT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15001

 

基于UID匹配的3条规则,我觉得没有意义。原因是TPROXY模式下,运行Envoy的用户是0,而非1337,这个可以从istio-sidecar-injector这个Configmap中看出来:

 

对nat表的更改发生在 OUTPUT 链(关注发出的封包)。核心逻辑:

  1. Envoy通过lo发出的,目的地址不是127.0.0.1的封包,重定向给入站监听器。根据观察,Envoy代理外部请求后,都是从lo发给127.0.0.1的,因此不会匹配此规则
  2. 允许本机的服务访问自身
  3. 服务对外发出的访问,必须经过Envoy

我们仔细分析一下重定向到的15001、15006是什么东西。这些端口是istio-iptables设置的,我们看一下它的帮助:

Script responsible for setting up port forwarding for Istio sidecar.
Usage:

  istio-iptables [flags]

Flags:

  -p, --envoy-port string             Specify the envoy port to which redirect all TCP traffic 
                                          (default $ENVOY_PORT = 15001)
  -z, --inbound-capture-port string   Port to which all inbound TCP traffic to the pod/VM should be redirected to 
                                          (default $INBOUND_CAPTURE_PORT = 15006)

看样子15006是需要将所有入站流量重定向到的端口,而在TPROXY中将入站流量都重定向到15001,这两端口如何分工?

这里Dump一下它们的配置。15001的:

/ istioctl proxy-config listener nginx-84c66c7fb9-95wrd  --port 15001 -o json
[
    {
        "name": "virtualOutbound",
        "address": {
            "socketAddress": {
                "address": "0.0.0.0",
                "portValue": 15001
            }
        },
        "filterChains": [
            {
                "filters": [
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "@type": "type.googleapis.com/envoy.config.filter.network.tcp_proxy.v2.TcpProxy",
                            "cluster": "PassthroughCluster",
                        }
                    }
                ]
            }
        ],
        // 使用原始的(被透明代理之前的)连接的目标地址来判断,由哪个监听器(Envoy进程内)来处理连接
        // 如果找不到这样的监听器,则当前监听器来处理,也就是Passthrough
        "useOriginalDst": true,
        // 可以作为TPROXY的目标,和useOriginalDst联用
        "transparent": true,
        // 期望的、相对于Envoy的流量方向
        "trafficDirection": "OUTBOUND"
    }
]
 
// istioctl proxy-config cluster nginx-84c66c7fb9-95wrd  --fqdn=PassthroughCluster -o json
{
    "name": "PassthroughCluster",
    "type": "ORIGINAL_DST",
    "connectTimeout": "1s",
    "lbPolicy": "CLUSTER_PROVIDED"
}

 

可以看到,这个监听器非常简单,仅仅是做穿透处理。从它的名字virtualOutbound和字段trafficDirection上来看,它是用来处理从Pod向外发起的流量的。但是iptables却把入站流量发给它,似乎有些矛盾?

再看看15006的配置:

// istioctl proxy-config listener nginx-84c66c7fb9-95wrd  --port 15001 -o json
[
    {
        "name": "virtualInbound",
        "address": {
            "socketAddress": {
                "address": "0.0.0.0",
                "portValue": 15006
            }
        },
        "filterChains": [
            // 兜底的过滤器链
            {
                "filterChainMatch": {
                    "prefixRanges": [
                        {
                            "addressPrefix": "0.0.0.0",
                            "prefixLen": 0
                        }
                    ]
                },
                "filters": [
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "statPrefix": "InboundPassthroughClusterIpv4",
                            "cluster": "InboundPassthroughClusterIpv4"
                        }
                    }
                ]
            },
            // 匹配请求本地Nginx进程的流量
            {
                "filterChainMatch": {
                    "destinationPort": 80,
                    "prefixRanges": [
                        {
                            "addressPrefix": "172.27.155.72",
                            "prefixLen": 32
                        }
                    ]
                },
                "filters": [
                    {
                        "name": "envoy.http_connection_manager",
                        "typedConfig": {
                            "statPrefix": "inbound_172.27.155.72_80",
                            "routeConfig": {
                                "name": "inbound|80|http|nginx.default.svc.k8s.gmem.cc",
                                "virtualHosts": [
                                    {
                                        "name": "inbound|http|80",
                                        "domains": [
                                            "*"
                                        ],
                                        "routes": [
                                            {
                                                "name": "default",
                                                "route": {
                                                    "cluster": "inbound|80|http|nginx.default.svc.k8s.gmem.cc"
                                                }
                                            }
                                        ]
                                    }
                                ]
                            }
                        }
                    }
                ],
            }
        ],
        "listenerFilters": [
            {
                "name": "envoy.listener.original_dst"
            },
            {
                "name": "envoy.listener.tls_inspector"
            }
        ],
        "transparent": true,
        "trafficDirection": "INBOUND"
    }
]
 
 
// istioctl proxy-config cluster nginx-84c66c7fb9-95wrd  --fqdn=InboundPassthroughClusterIpv4 -o json
{
    "name": "InboundPassthroughClusterIpv4",
    "type": "ORIGINAL_DST",
    "connectTimeout": "1s",
    "lbPolicy": "CLUSTER_PROVIDED",
    "upstreamBindConfig": {
        // 绑定新创建上游连接时使用的源地址
        "sourceAddress": {
            "address": "127.0.0.6",
            "portValue": 0
        }
    }
}
 
 
// istioctl proxy-config cluster nginx-84c66c7fb9-95wrd  --fqdn=nginx.default.svc.k8s.gmem.cc  --direction inbound -o json
[
    {
        "name": "inbound|80|http|nginx.default.svc.k8s.gmem.cc",
        "type": "STATIC",
        "loadAssignment": {
            "clusterName": "inbound|80|http|nginx.default.svc.k8s.gmem.cc",
            "endpoints": [
                {
                    "lbEndpoints": [
                        {
                            "endpoint": {
                                "address": {
                                    "socketAddress": {
                                        "address": "127.0.0.1",
                                        "portValue": 80
                                    }
                                }
                            }
                        }
                    ]
                }
            ]
        }
    }
]
可以看到,这个监听器叫virtualInbound,从它的名字和配置trafficDirection上来看,它是用来处理从外面发给Pod的流量的,它明确的定义了处理连接的集群,127.0.0.1:80,即本地Nginx服务。

 

REDIRECT

此模式下,mangle表没有变动,Istio只修改了nat表。入站、出站流量的处理都在此完成:
Chain PREROUTING (policy ACCEPT 23 packets, 1380 bytes)
 pkts bytes target     prot opt in     out     source               destination         
   23  1380 ISTIO_INBOUND  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain INPUT (policy ACCEPT 23 packets, 1380 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain OUTPUT (policy ACCEPT 21 packets, 1675 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    5   300 ISTIO_OUTPUT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain POSTROUTING (policy ACCEPT 21 packets, 1675 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain ISTIO_INBOUND (1 references)
 pkts bytes target     prot opt in     out     source               destination         
# 特殊端口不处理
    0     0 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:22
    1    60 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15090
   22  1320 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15020
# 其它的一律转发给15006
    0     0 ISTIO_IN_REDIRECT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_IN_REDIRECT (3 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15006
 
Chain ISTIO_OUTPUT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      lo      127.0.0.6            0.0.0.0/0           
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner UID match 1337
    0     0 RETURN     all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner UID match 1337
    5   300 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner UID match 1337
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner GID match 1337
    0     0 RETURN     all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner GID match 1337
    0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner GID match 1337
    0     0 RETURN     all  --  *      *       0.0.0.0/0            127.0.0.1           
    0     0 ISTIO_REDIRECT  all  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_REDIRECT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15001

 

可以看到,REDIRECT模式下,处理进入封包的逻辑是完全一样的。

REDIRECT模式下,将入站流量重定向给15006,这很好理解,因为15006是 virtualInbound监听器嘛。

有何区别

从Nginx的日志上看,不管是REDIRECT还是TPROXY模式,看到的IP都不是真实IP,没有区别。

Envoy访问日志也没有任何区别,至少可以说,在REDIRECT模式下,Envoy也是可以看到真实源IP的:

 

TPROXY模式下,Envoy也没有使用真实源IP来请求上游集群。

感觉这TPROXY很鸡肋,从https://github.com/istio/istio/issues/5679上看到的,它的价值是:

Contrary to REDIRECT, TPROXY doesn’t perform NAT, and therefore preserves both source and destination IP addresses and ports of inbound connections. One benefit is that the source.ip attributes reported by Mixer for inbound connections will always be correct, unlike when using REDIRECT.

也就是说,TPROXY模式下允许Mixer获得真实源IP地址。

EnvoyFilter

目前Istio支持一种自定义资源EnvoyFilter,使用它,你可以对生成的Envoy配置进行深度定制。比如添加监听器过滤器:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: nginx-original-src
  namespace: default
spec:
  workloadSelector:
    labels:
      app: nginx
  configPatches:
  - applyTo: LISTENER
    match:
      context: SIDECAR_INBOUND
      listener:
        portNumber: 80
    patch:
      operation: MERGE
      value:
        listenerFilters:
        - name: envoy.listener.original_src

像上面这个过滤器,它为入站监听器添加了envoy.listener.original_src这个监听器过滤器。生成的配置如下:

// istioctl proxy-config listener nginx-84c66c7fb9-7mfwz   --port 80 --type http -o json
 
...
        "deprecatedV1": {
            "bindToPort": false
        },
        "listenerFilters": [
            {
                "name": "envoy.listener.tls_inspector"
            },
            {
                "name": "envoy.listener.original_src",
            }
        ],
        "listenerFiltersTimeout": "0.100s",
        "continueOnListenerFiltersTimeout": true,
        "trafficDirection": "INBOUND"
    }
]

 

如何实现透明代理

关键点:

  1. 路由器发现目的地址、源地址是REAL_SERVER:80的,且不是来自透明代理的封包,都会路由给透明代理。而不是路由给服务器、客户端
  2. 透明代理能够在非本机IP地址上监听,例如REAL_SERVER:80
  3. 透明代理能够以非本机IP地址发起TCP连接,例如以客户端的IP地址

第一条,可能需要硬件支持。

后面两条,可以由透明代理在软件上支持,相关套接字选项:

  1. IP_FREEBIND:允许绑定非本地的,或者尚不存在的IP地址
  2. IP_TRANSPARENT:在套接字上启用透明代理。该选项运行应用程序绑定非本地地址,并使用这个外部地址来扮演客户端、服务器角色。需要CAP_NET_ADMIN权限才能启用

此外,根据实际需要,“透明度”可以变化:

  1. 如果仅仅想让客户端觉得透明,那么代理可以直接使用自己的IP地址请求服务器。这样服务器看不到客户端真实IP
  2. 如果服务器仅仅需要知道客户端真实IP,不关心真实端口,那么代理可以用客户端地址+任意端口发起请求
  3. 如果需要绝对透明,则代理必须以客户端地址+客户端端口发起请求
Sidecar场景

在Envoy Sidecar部署场景下,情况变的简单,透明代理和服务器位于同一台主机内部,这意味着:

  1. 不需要路由器/网关的配合
  2. 代理请求的目的地址可以从真实服务器地址换为127.0.0.1

可以实现透明代理的通信模型如下:

Istio的问题

在Istio的TPROXY拦截模式下,实际的通信模型如下:

差别似乎仅仅是Envoy用127.0.0.1作为源地址,而非客户端真实IP,向服务器发送请求。

使用EnvoyFilter,为virtualOutbound所引用的,80监听器配置一个EnvoyFilter,配置envoy.listener.original_src,可以让Envoy访问服务器时使用真实客户端IP,解决我们的问题吗?

我们参考3.2节配置好EnvoyFilter,然后从外部访问Pod的Nginx服务,很遗憾,并不能正常工作,curl给出的错误是:

upstream connect error or disconnect/reset before headers. reset reason: connection failure

从Envoy访问日志上看:

[2020-04-23T09:18:42.434Z] "GET / HTTP/1.1" 503 

# 日志格式取决于配置/版本。通过
#   kubectl exec nginx-tproxy-774fb7958c-t2lnk -c istio-proxy -- curl 0:15000/config_dump | grep .log_format
# 响应标记:
#   LR   本地重置
#   UH   没有健康的上游主机,和503一起发送
#   UF   连接到上游主机时失败,和503一起发送
#   UO   针对上游的访问溢出(断路器触发),和503一起发送
#   NR   没有匹配的路由,和404一起发送
#   URX  请求被拒绝,原因是超过上游的最大重试次数,或者TCP最大连接尝试次数



#  上游连接失败     收  发   耗时
   UF "-" "-"      0   91   999   - "-" "curl/7.67.0" "747cdfcb-5d1e-9ac0-8858-33aa1b1eaa4d" 
"nginx" "127.0.0.1:80" inbound|80|http|nginx.default.svc.k8s.gmem.cc 

# 访问上游使用的本地地址    下游访问本机使用目的地址   下游远程地址
-                       172.27.155.94:80         172.27.155.90:56356 outbound_.80_._.nginx.default.svc.k8s.gmem.cc default

 

存在如下异常:

  1. 访问上游时使用的源地址为空了
  2. 响应标记UF,耗时999,提示连接不到上游服务器

为什么连接不到上游服务器?我们尝试通过iptables日志诊断一下。在Nginx的例子里,数据报的特点是,源或目的端口为80,因此增加以下规则:

# 删除基于UID匹配的规则,因为TPROXY模式下Envoy的运行用户是0而非1337
iptables -t nat -D ISTIO_OUTPUT 2
iptables -t nat -D ISTIO_OUTPUT 2
iptables -t nat -D ISTIO_OUTPUT 2
 
# 增加入站流量TPROXY规则日志
iptables -t mangle -I ISTIO_INBOUND 5 -p tcp --dport 80 -j LOG --log-prefix "b-tproxy: " --log-tcp-sequence --log-uid
iptables -t mangle -A ISTIO_INBOUND -p tcp --dport 80 -j LOG --log-prefix "a-tproxy: " --log-tcp-sequence --log-uid
 
# 在nat表的OUTPUT链,需要增加源、目标端口是80的,分别对应服务向Envoy发出、Envoy向服务发出的封包
iptables -t nat -I ISTIO_OUTPUT 6 -p tcp --dport 80 -j LOG --log-prefix 't-redir-*-*-*-*: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 6 -p tcp --sport 80 -j LOG --log-prefix 'f-redir-*-*-*-*: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 5 -p tcp --dport 80 -j LOG --log-prefix 't-rturn-*-*-*-1: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 5 -p tcp --sport 80 -j LOG --log-prefix 'f-rturn-*-*-*-1: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 4 -p tcp --dport 80 -j LOG --log-prefix 't-rturn-*-*-*-*-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 4 -p tcp --sport 80 -j LOG --log-prefix 'f-rturn-*-*-*-*-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 3 -p tcp --dport 80 -j LOG --log-prefix 't-rturn-*-l-*-*-!1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 3 -p tcp --sport 80 -j LOG --log-prefix 'f-rturn-*-l-*-*-!1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 2 -p tcp --dport 80 -j LOG --log-prefix 't-inred-*-l-*-!1-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 2 -p tcp --sport 80 -j LOG --log-prefix 'f-inred-*-l-*-!1-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 1 -p tcp --dport 80 -j LOG --log-prefix 't-inred-*-l-6-*: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 1 -p tcp --sport 80 -j LOG --log-prefix 'f-inred-*-l-6-*: ' --log-tcp-sequence --log-uid

 

拦截到的日志:

# [30714.928765] 客户端往POD的连接,首次SYN,TPROXY之前
b-tproxy: IN=eth0 OUT=  SRC=172.27.155.90 DST=172.27.155.108     ID=35338   SPT=57252 DPT=80 SEQ=1901693983    SYN  
# 没有出现a-tproxy,说明SYN被TPROXY拦截,发往15001,也就是Envoy
 
# Envoy往Nginx的连接,出站,首次SYN,注意看到SRC是172.27.155.90:44297,和客户端172.27.155.90:57252的IP一致,端口用了新的
# 没有启用EnvoyFilter时是这样:
# inred-*-l-6-*: IN= OUT=lo SRC=127.0.0.1 DST=127.0.0.1 ...
# 可以看到EnvoyFilter达到我们的目的:传递真实源IP
 
t-inred-*-l-6-*: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
t-inred-*-l-*-!1-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
t-rturn-*-l-*-*-!1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
# 由于GID是1337,因此下面的规则匹配,ACCEPT,封包发出去了
t-rturn-*-*-*-*-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
 
# Envoy往Nginx的连接,入站,由于目的地址是127.0.0.1,因此不TPROXY
b-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  
a-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  
 
 
 
# [30715.971504] 一秒过了,客户端往POD的连接,二次SYN
b-tproxy: IN=eth0 OUT=  SRC=172.27.155.90 DST=172.27.155.108     ID=60309   SPT=57258 DPT=80 SEQ=829877388    SYN  
# Envoy往Nginx的连接,出站,二次SYN
t-inred-*-l-6-*: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
t-inred-*-l-*-!1-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
t-rturn-*-l-*-*-!1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
t-rturn-*-*-*-*-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
b-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  
a-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  
 
# [30717.046657] 一秒过了,客户端往POD的连接,三次SYN
b-tproxy: IN=eth0 OUT=  SRC=172.27.155.90 DST=172.27.155.108     ID=8963   SPT=57268 DPT=80 SEQ=3705219877    SYN  
t-inred-*-l-6-*: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
t-inred-*-l-*-!1-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
t-rturn-*-l-*-*-!1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
t-rturn-*-*-*-*-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
b-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  
a-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN

 

可以看到:

  1. 客户端向Pod发请求,被TPROXY给Envoy 15001
  2. Envoy 15001是透明套接字,因此它虽然客户端请求的DPT=80,它也接收并处理了
  3. Envoy执行代理,通过lo向127.0.0.1:80发送请求,注意这里它使用的源地址是客户端地址,这意味着我们的EnvoyFilter起作用了
  4. Envoy代理的请求,通过lo入站,由于目的地址是127.0.0.1,因此不被TPROXY,通过PREROUTING – mangle链

此外,在OUTPUT链中nat表里,好像根据SPT=80无法匹配,所以看不到任何f-开头的日志。此链对于nat表来说,应该是用于做DNAT,Istio生成的规则遵循了这一点,REDIRECT可以看作是一种DNAT。Istio的规则有基于源IP进行匹配的,我基于源端口为何不行,目前不清楚。

换个位置来诊断吧,目前我们已经明确,Envoy接收到请求后,会冒充客户端源IP向localhost:80发请求,此请求已经通过PREROUTING-mangle。它有没有被Nginx接收到?

我们可以在INPUT-mangle上做日志,如果能监控到发往127.0.0.1:80的封包,就可以认定Nginx接收到了,因为整个Iptables中没有设置INPUT链的任何拦截规则。

iptables -t mangle -I INPUT 1 -p tcp -d 127.0.0.1/32 --dport 80 -j LOG --log-prefix='input-mangle-d80: '
iptables -t nat -I INPUT 1 -p tcp -d 127.0.0.1/32  --dport 80 -j LOG --log-prefix='input-nat-d80: '
iptables -t filter -I INPUT 1 -p tcp -d 127.0.0.1/32  --dport 80 -j LOG --log-prefix='input-filter-d80: '

 

日志如下:

[3612374.269256] input-mangle-d80: IN=lo OUT= SRC=172.27.252.159 DST=127.0.0.1 SPT=40283 DPT=80 SYN
[3612374.269276] input-filter-d80: IN=lo OUT= SRC=172.27.252.159 DST=127.0.0.1 SPT=40283 DPT=80 SYN

 

nat表仍然没有日志,看样子是在DNAT时,不能使用源端口匹配,SNAT时,不能使用目的端口匹配。

不过从日志上,从lo端口进入的、Envoy仿冒客户端身份发往127.0.0.1:80的封包,的确是通过iptables了。

那么,应该是Nginx没有给出应答。我们需要监控一下源是Nginx,目的是客户端真实IP地址的出站封包的流向:

iptables -t mangle -R POSTROUTING 1 -p tcp -d 172.27.252.159/32 -s 127.0.0.1/32 \
         --sport 80  -j LOG --log-prefix='pr-mangle-to-clientip: '

 

日志如下:

pr-mangle-to-clientip: IN= OUT=eth0 SRC=127.0.0.1 DST=172.27.252.159 PROTO=TCP SPT=80 DPT=54969 ACK SYN URGP=0 
pr-mangle-to-clientip: IN= OUT=eth0 SRC=127.0.0.1 DST=172.27.252.159 PROTO=TCP SPT=80 DPT=50979 ACK SYN URGP=0 
pr-mangle-to-clientip: IN= OUT=eth0 SRC=127.0.0.1 DST=172.27.252.159 PROTO=TCP SPT=80 DPT=54969 ACK SYN URGP=0

 

相似的日志会连续出现很多条。我们可以看到Nginx收到首次握手SYN后,尝试ACK+SYN,但是一致没有收到第三次握手信息…… 原因很明显,出口网卡是eth0,封包发走了,没有返回给Envoy代理。

到这里,问题就算定位完毕了。

解决方案

我们需要保证,对于Envoy以客户端IP发起的,给Nginx的请求,它的响应能够原路返回。响应的封包具有以下特点:

  1. 源地址(请求封包的目的地址)是 127.0.0.1,因为Envoy总是向127.0.0.1发请求
  2. 目的地址(请求封包的源地址)不是本机地址,因为Envoy发请求时,FREEBIND源地址为客户端IP

我们需要将这种封包,从lo网卡,而非eth0路由出去。 可以使用下面的iptables规则:

iptables -t mangle -I OUTPUT 1 -s 127.0.0.1/32 ! -d 127.0.0.1/32 \
    -j MARK --set-xmark 0x539/0xffffffff

 

再次访问服务,Nginx可以看到真实客户端IP地址了:

# TPORXY mode without envoyfilter
127.0.0.1 - - [24/Apr/2020:02:58:53 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.67.0" "-"
# TPROXY mode + envoyfilter, iptable rule applied
172.27.252.159 - - [24/Apr/2020:05:52:04 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.67.0" "-"

 

到此为止,问题解决。初步测试,没有发现负面效果,已经将此方案提交社区讨论

提交社区

此方案已经通过PR https://github.com/istio/istio/pull/23275 合并到上游Istio仓库的master分支(1.7dev),并将自动Cherry Pick到1.6版本

1.5版本的逻辑稍有不同,仅仅在我Fork的Istio中实现:https://github.com/gmemcc/istio/tree/release-1.5.1-patch,不准备提交到上游Istio仓库。

1.6版本TPROXY问题

在此版本中验证时,发现TPROXY模式损坏,无限循环自我请求。我已经提起Issue:23369

解决无限循环的方法是把TPROXY目标从15001改为15006。我一直就怀疑为什么要把入站流量重定向给出站监听器15001,现在想想,最初只有一个“虚拟监听器”15001,最近版本的Istio才拆分为virtualInbound(15006)、virtualOutbound(15001)两个,在这个变更过程中,TPROXY相关代码没有跟着改动。、

问题23369

解决透明代理源IP的PR 23275并没有达到预期效果,问题原因参考ISSUE 23369。

即使按照上节的方法,将TPROXY目标从15001改为15006,也仅仅能解决无限自我请求的问题。新得到的错误信息是:upstream connect error or disconnect/reset before headers. reset reason: local reset

 

抓包分析

我们从10.0.0.1发起针对启用了Sidecar的、IP地址为172.27.0.10的请求。可以在Nginx Pod的网络命名空间中看到如下连接信息:

netstat -nt
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      1 10.0.0.1:50829          172.27.0.10:80          SYN_SENT

 

源地址为10.0.0.1:50829套接字,应该是Envoy发起上游请求时创建的,因为我们配置了监听器过滤器original_src。

但是,这个套接字的状态一直是SYN_SENT,这提示它没有收到答复。结合抓包结果:

# 从客户端发起的原始包,源端口39062
10.0.0.1.39062 > 172.27.0.10.80: Flags [S]
# Envoy给的ACK
172.27.0.10.80 > 10.0.0.1.39062: Flags [S.]
# 客户端发起HTTP请求
10.0.0.1.39062 > 172.27.0.10.80: Flags [.]
10.0.0.1.39062 > 172.27.0.10.80: Flags [P.] GET / HTTP/1.1
# Envoy给的ACK
172.27.0.10.80 > 10.0.0.1.39062
# Envoy向上游发起请求,注意这里它不是发给127.0.0.1,而是Pod IP
# 尽管目的地址是172.27.0.10.80,这个包仍然是从lo发出去的
# 当从本机访问时,不论使用哪个目的IP时,默认都会从lo出去
10.0.0.1.50829 > 172.27.0.10.80: Flags [S]
# Nginx给出ACK,但是这个ACK没有收到,所以SYN+ACK反复了几次
# 实际上这些封包都从eth0发出去了
172.27.0.10.80 > 10.0.0.1.50829: Flags [S.]
10.0.0.1.50829 > 172.27.0.10.80: Flags [S]
172.27.0.10.80 > 10.0.0.1.50829: Flags [S.]
172.27.0.10.80 > 10.0.0.1.50829: Flags [S.]
10.0.0.1.50829 > 172.27.0.10.80: Flags [S]
172.27.0.10.80 > 10.0.0.1.50829: Flags [S.]
172.27.0.10.80 > 10.0.0.1.50829: Flags [S.]
10.0.0.1.50829 > 172.27.0.10.80: Flags [S]
172.27.0.10.80 > 10.0.0.1.50829: Flags [S.]
# Envoy没有收到上游应答,认为服务不可用
172.27.0.10.80 > 10.0.0.1.39062  HTTP/1.1 503 Service Unavailable
# 终止连接
10.0.0.1.39062 > 172.27.0.10.80: Flags [.]
10.0.0.1.39062 > 172.27.0.10.80: Flags [F.]

 

可以看到,新版本的Istio,向上游发请求时,使用的目的地址是原始Dest地址,而不是127.0.0.1,因此, PR 23275也就失效了。

在当前的场景下,Envoy以客户端真实IP、通过lo向Nginx进程发起TCP连接,这个是OK的。但是回程报文从容器eth0发走了。回程报文到达宿主机后,被丢弃。

解决方案

我们需要识别,哪些请求是Envoy代表客户端转发的,并把这些请求的响应封包发回给Envoy,而不是通过eth0发送出去。

早前版本可以根据目的地址识别,现在直接来自客户端的、Envoy代表客户端转发的请求(以及响应),连接5元组完全一样,这意味着无法从IP地址上进行区分了。

幸运的是,iptables支持的CONNMARK目标可以在连接级别上打标记,这意味着往返报文可以共享信息。此外,original_src支持为封包设置标记,我们可以利用这一特性识别Envoy代表客户端发出的封包。结合这两点,我们可以得到23369的解决方案。

首先,我们需要为监听器过滤器original_src增加一个参数:

{
    "name": "envoy.listener.original_src",
    "typedConfig": {
        "@type": "type.googleapis.com/envoy.extensions.filters.listener.original_src.v3.OriginalSrc",
        "mark": 1337
    }
},

这样,Envoy请求上游(Nginx)时,发出的封包具有标记 1337。

然后,我们增加如下iptables规则:

# Envoy发出的封包,被Nginx处理之前,获取封包标记,保存为连接标记
iptables  -t mangle -I PREROUTING -m mark     --mark 1337  -j CONNMARK --save-mark
# Nginx处理请求...
# Nginx返回的响应封包,被打上从连接标记上取得的1337标记
iptables  -t mangle -I OUTPUT     -m connmark --mark 1337 -j CONNMARK --restore-mark

 

结合现有的策略路由,Nginx的回程封包就会从lo发出,并被Envoy接收到了。

到这一步,会出现先前的无限自我请求问题,这是由于规则:

Chain ISTIO_TPROXY (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    8   480 TPROXY     tcp  --  *      *       0.0.0.0/0           !127.0.0.1            TPROXY redirect 0.0.0.0:15006 mark 0x539/0xffffffff

 

该规则要求,只要目的地址不是127.0.0.1的请求,都会重定向到15006。在前面我们已经发现,TPROXY模式下访问上游Nginx不像先前版本那样使用127.0.0.1作为目的地址,因此这个规则必须要处理。

我的做法是,在它的前面做个判断,如果具有标记1337(意味着这是Envoy和上游Nginx之间的通信),就不走ISTIO_TPROXY:

iptables -t mangle -I ISTIO_INBOUND 5 -p tcp -m mark --mark 0x539   -j RETURN

 

修改后mangle表的整体内容如下:

# iptables -t mangle -L -n -v
Chain PREROUTING (policy ACCEPT 6280 packets, 680K bytes)
 pkts bytes target     prot opt in     out     source               destination         
1163K   97M CONNMARK   all  --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x539 CONNMARK save
1440K  115M ISTIO_INBOUND  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain INPUT (policy ACCEPT 7459 packets, 817K bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain OUTPUT (policy ACCEPT 6126 packets, 781K bytes)
 pkts bytes target     prot opt in     out     source               destination         
1107K   93M CONNMARK   all  --  *      *       0.0.0.0/0            0.0.0.0/0            connmark match  0x539 CONNMARK restore
    0     0 MARK       tcp  --  *      *       127.0.0.1           !127.0.0.1            MARK set 0x539
 
Chain POSTROUTING (policy ACCEPT 6126 packets, 781K bytes)
 pkts bytes target     prot opt in     out     source               destination         
 
Chain ISTIO_DIVERT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
1308K  107M MARK       all  --  *      *       0.0.0.0/0            0.0.0.0/0            MARK set 0x539
1308K  107M ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_INBOUND (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:22
    0     0 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15090
14058 1047K RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15021
 4713  814K RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15020
   39  7165 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x539
1308K  107M ISTIO_DIVERT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
 113K 6778K ISTIO_TPROXY  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           
 
Chain ISTIO_TPROXY (1 references)
 pkts bytes target     prot opt in     out     source               destination         
     480 TPROXY     tcp  --  *      *       0.0.0.0/0           !127.0.0.1            TPROXY redirect 0.0.0.0:15006 mark 0x539/0xffffffff

 

从Nginx Pod外部访问、从Nginx Pod内部访问localhost以及Pod IP,一切行为正常,解决方案有效。

发表评论