现象:无法审计的客户端地址

对于运行在kubernetes上、且允许从集群外访问的应用来说,使用nodePort是一个不错的方案。客户端可以使用集群任一节点+nodePort来访问,如果再配置一个vip,就更方便了。

但nodePort有一个问题是,在容器中运行的应用,无法正确的审计客户端的ip地址:容器中看到的socket连接的源地址,总是容器所在node的网桥cni0的地址。

举个例子。

先创建一个nginx应用。

kind: ReplicationController
apiVersion: v1
metadata:
  name: nginx-controller
spec:
  replicas: 1
  selector:
    component: nginx
  template:
    metadata:
      labels:
        component: nginx
    spec:
      containers:
        - name: nginx
          image: docker.ieevee.com/ieevee/nginx:1.11
          ports:
            - containerPort: 80
---
kind: Service
apiVersion: v1
metadata:
  name: nginx-service
spec:
  type: NodePort
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080
  selector:
    component: nginx

上面的编排文件将创建一个简单的80端口的nginx服务。k8s集群外的客户端,可以通过http://{node ip}/30080来访问nginx服务。

当集群外客户端访问nginx服务时,观察下nginx容器中的socket连接,可以看到,源地址、目的地址都做了转换,即同时做了DNAT和SNAT。

ss -t -a
State      Recv-Q Send-Q   Local Address:Port     Peer Address:Port
LISTEN     0      128                  *:http                 *:*       
ESTAB      0      0          10.244.3.82:http      10.244.3.1:35410 

显然,这样对容器内审计是很不友好的。

原因:不得不做的SNAT

为什么会这样呢?

原因是,为了支持从任一节点IP+NodePort都可以访问应用。

          client
             \ ^
              \ \
               v \
   node 1 <--- node 2
    | ^   SNAT
    | |   --->
    v |
 endpoint

假设跳过上图的SNAT,只做DNAT。报文的确可以经过 node 2 转发到 node 1上的endpoint,但是endpoint如何应答呢?endpoint内的确有默认路由指向 node 1,但是如果没有做SNAt,应答时会直接从 node 1 发送给client。

这可是一个三角流量啊!

跳过SNAT后,node 1 在转发应答流量的时候,会将应答报文的源地址替换为 node 1的地址,这样的报文,client是不会接受的,连接将无法建立。

所以,必须要做SNAT,必须要FULLNAT。

实现:iptables

明白了为什么要SNAT,来看下如果通过iptables来实现SNAT。

下面是nginx-service创建以后的iptables规则,他们在k8s集群上任一Node上完全一致。

*nat
-A POSTROUTING -m comment --comment "kubernetes postrouting rules" -j KUBE-POSTROUTING  /* 1 */

-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000  /* 2 */

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/nginx-service:nginx-http" -m tcp --dport 30080 -j KUBE-MARK-MASQ  /* 3 */

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/nginx-service:nginx-http" -m tcp --dport 30080 -j KUBE-SVC-LU77NTQGUFTDG7JB  /* 4 */
-A KUBE-SVC-LU77NTQGUFTDG7JB -m comment --comment "default/nginx-service:nginx-http" -j KUBE-SEP-UCGM5KC3U2QSWPEY  /* 5 */
-A KUBE-SEP-UCGM5KC3U2QSWPEY -s 10.244.3.82/32 -m comment --comment "default/nginx-service:nginx-http" -j KUBE-MARK-MASQ  /* 6 */
-A KUBE-SEP-UCGM5KC3U2QSWPEY -p tcp -m comment --comment "default/nginx-service:nginx-http" -m tcp -j DNAT --to-destination 10.244.3.82:80  /* 7 */

-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE  /* 8 */

DNAT在谈谈kubernets的service组件的Virtual IP中有介绍,对应第4/5/6/7条;而SNAT对应第1/2/3/8。

DNAT:对于收到的目的端口30080的报文,首先匹配第2条规则,该规则会让报文继续经过第1条规则处理;而第1条规则会为报文设置mark 0x4000/0x4000;之后报文继续经过3/4/6,做DNAT,将报文的目的地址转为10.244.3.82:80;

SNAT:发生在POSTROUTING阶段。报文接收时,打了mark 0x4000/0x4000,因此在KUBE-POSTROUTING时会匹配第8条规则(-m mark –mark 0x4000/0x4000),MASQUERADE 的意思就是做SNAT,源地址自动选择,此时客户端的IP就被替换为了cni0的地址。

linux上通过/proc/net/nf_conntrack查看iptables表项(或者称之为iptables会话)。

cat /proc/net/nf_conntrack | grep 30080
ipv4     2 tcp      6 86397 ESTABLISHED src=10.84.1.138 dst=192.168.128.149 sport=39452 dport=30080 src=10.244.3.83 dst=10.244.3.1 sport=80 dport=39452 [ASSURED] mark=0 zone=0 use=2

解决方法:externalTrafficPolicy,跳过SNAT

显然,之所以会在POSTROUTING阶段组SNAT,是因为k8s给报文打了mark(其实是打在内核里的skb上,报文并没有做任何修改),那么要跳过SNAT,很容易想到的就是只要删掉第三条规则(-j KUBE-MARK-MASQ)就可以了。

这个目标可以通过kubernetes的externalTrafficPolicy来实现。externalTrafficPolicy的目的是保留报文的源地址,其方法也就是我们希望的跳过SNAT。

service.spec.externalTrafficPolicy设置为Local(v1.7 later)之后,kubernetes将在Pod所在Node上针对nodePort下发DNAT规则,而在其他节点上针对nodePort下发DROP规则。

        client
       ^ /   \
      / /     \
     / v       X
   node 1     node 2
    ^ |
    | |
    | v
 endpoint

还是nginx的例子。

在svc的编排文件中增加externalTrafficPolicy后,重新创建nginx-service。

kind: Service
apiVersion: v1
metadata:
  name: nginx-service
spec:
  type: NodePort
  externalTrafficPolicy: Local
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080
  selector:
    component: nginx

Pod所在Node下发的规则:

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/nginx-service:" -m tcp --dport 30080 -j KUBE-XLB-GKN7Y2BSGW4NJTYL
-A KUBE-XLB-GKN7Y2BSGW4NJTYL -m comment --comment "Balancing rule 0 for default/nginx-service:" -j KUBE-SEP-64YUZF3BIP6Q2HFU

-A KUBE-SEP-64YUZF3BIP6Q2HFU -s 10.244.3.83/32 -m comment --comment "default/nginx-service:" -j KUBE-MARK-MASQ
-A KUBE-SEP-64YUZF3BIP6Q2HFU -p tcp -m comment --comment "default/nginx-service:" -m tcp -j DNAT --to-destination 10.244.3.83:80

非Pod所在Node下发的规则:

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/nginx-service:" -m tcp --dport 30080 -j KUBE-XLB-GKN7Y2BSGW4NJTYL
-A KUBE-XLB-GKN7Y2BSGW4NJTYL -m comment --comment "default/nginx-service: has no local endpoints" -j KUBE-MARK-DROP

此时从k8s容器外访问nginx服务,在nginx容器内查看socket连接,可以看到对端已经是真实的客户端IP了。

ss -t -a
State       Recv-Q Send-Q           Local Address:Port               Peer Address:Port   
LISTEN      0      128                          *:http                          *:*       
ESTAB       0      0                  10.244.3.82:http                10.84.1.138:34748  

显然,客户端只能使用 Pod所在Node的IP地址 + nodePort来访问Pod内的应用。

externalTrafficPolicy从v1.5开始支持,v1.5 ~ v1.6 版本以Annotations形式支持(叫做external-traffic),v1.7已经正式进入spec了。

在v1.5 ~ v1.6版本上需要这样来创建externalTrafficPolicy

kind: Service
apiVersion: v1
metadata:
  name: nginx-service
  annotations:
    service.beta.kubernetes.io/external-traffic: OnlyLocal
spec:
  type: NodePort
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080
  selector:
    component: nginx

externalTrafficPolicy的好处是,Pod内应用的确可以拿到真实的客户端地址了,但坏处是,只能某一node的地址,无法使用vrrp之类的virtual IP来实现ha,对客户端来说会麻烦一点。

Ref: