概述

Nginx 作为经久不衰的负载均衡工具,具备 L7/L4 层流量的负载均衡能力,并且支持 Proxy Protocol、限速、负载均衡等能力。

本文将讨论在 Kubernetes 中使用 Nginx 作为容器流量入口的一些方案:

  • 实现类似 kubernetes NodePort 的四层负载均衡能力
  • Pod 获取 Client 真实 IP 的能力。

这些内容在我个人看来绝对不是 Kubernetes 生产环境中的最佳实践,但这些“奇淫技巧”或许在一些极端环境中能对我们有一些帮助。

NodePort 功能

通过 Nginx 原生的 stream 模块,我们可以实现和 Kubernetes NodePort 类似的流量 4 层负载均衡功能。

在 Kubernetes 中部署 Nginx 时,我们采取以下方式,Yaml 文件可以参考 Demo

  • 通过 DaemonSet 方式部署
  • Nginx 运行在主机网络中

我们部署 whoami 容器作为测试业务,并创建 ClusterIP类型的 Service ,Yaml 文件可以参考 Demo

为了使用户在 Kubernetes 集群外能正常范围 whoami 服务,我们使用 stream 模块配置反向代理,核心配置文件如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
stream {
    resolver kube-dns.kube-system.svc.cluster.local valid=5s; 
    map "" $whoami_server {
        default whoami.default.svc.cluster.local:80;
    }
    server {
        listen 18080;
        proxy_timeout 20s;
        proxy_pass $whoami_server;
    }
}

上述配置中,我们直接讲服务器的 18080 端口反向代理到 whoami.default.svc.cluster.local:80 从而实现类似 NodePort 的功能。

默认情况下 Nginx 会一直缓存域名的 IP 地址,因此当 whoami.default.svc.cluster.local 的 ip 地址变更时需要重启 Nginx 容器反向代理才能正常工作。

如何处理 nginx 开源版本的 DNS 动态解析问题,不在本文讨论范畴,大家可以自行百度。

Client 真实 IP

在运行 Kubernetes 的环境中,Pod 处理请求时获取 Client 的真实 IP 是一个比较重要的需求,目前比较靠谱的解决方案我在 《获取Client真实IP》 一文中进行了介绍,本文不再一一赘述。

这里我将主要验证一种基于 Nginx + Proxy Protocol 的方案来实现获取 Client 真实 IP 。

1. Proxy Protocol 协议

Proxy Protocol 是 HAProxy 的作者 Willy Tarreau 于2010年开发和设计的一个 Internet 协议,通过为 tcp 添加一个很小的头信息,使 TCP 连接能够在复杂网络场景下,传递真实的 Client IP 信息。

Proxy Protocol 工作时存在 Sender/Reciever 两种角色。Sender 负责构建 TCP 连接中的头信息并和 Reciever 建立 TCP 连接。当 Reciever 接受到 TCP 报文时,根据协议解析报文头获取 Sender 填入的内容。

目前 Proxy Protocol有两个版本,v1 仅支持 human-readable 报头格式(ASCIII码),v2 同时支持 human-readable 和二进制格式。v1 和 v2 版本仅在报文格式上存在差异,v2 并没有在 v1 基础上新增特殊功能。 Proxy Protocol 协议具体的报文格式,可以参考文档《Proxy Protocol》。

需要注意,Sender 构建报文时填写的源IP和端口信息可以是自身的 IP 或者端口,也可以是任意值。

Proxy Protocol 协议实际上改变了 Client/Server 建立 TCP 连接的过程,存在以下局限:

  • 使用 Proxy Protocol 通信时,Client/Server(Sender/Reciever) 都需要支持 Proxy Protocol 协议。
  • 对于服务器的同一个监听端口,无法同时兼容带 Proxy Protocol 包的连接和不带 Proxy Protocol 包的连接。

根据 HAProxy 官方的描述 Proxy Protocol 协议最主要的用途是:在多层负载均衡环境中保留Client源IP。

假定某个系统存在以下网络拓扑架构:

上述网络架构中,请求从 Client 到 Server 经历了 L4/L7 两次转发。这种情况下报文到达 nginx(L7 负载均衡)时,就已经丢失了 Client 原始 IP 信息。为了解决这个问题,我们可以在将 L4LB 和 Nginx 之间建立 Proxy Protocol 连接,使 nginx 可以解析 Proxy Protocol 包获取真实 IP 并设置到后续 HTTP 连接的 Header 中。该方案中 Client 和 Server 都无需支持 Proxy Protocol 协议,所有功能实现或者配置都下沉到了 L4LB / L7LB 两个基础设施中。

HAProxy 官方列出了不少支持 Proxy Protocol 协议的软件,其中绝大部分正是一些商业/开源的负载均衡软件:

2. 方案验证

截至到 Nginx 1.11.4 版本,用户可以将 Nginx 作为 Proxy Protocol 的 Sender/Reciever 部署到应用在自己的生产环境中。

在 Nginx 中可以通过下面的变量来获得对应的客户端信息,具体而言如下所示:

  • $proxy_protocol_addr和$proxy_protocol_port表示的是原始客户端的IP地址和端口号,即 nginx 作为 Proxy Protocol Reciever 时从 Proxy Protocol 协议中解析到 IP 信息。
  • $remote_addr和$remote_port表示的 Nginx 服务本身的 IP 地址和端口。

如果用户使用了 RealIP 扩展模块,那么这个模块会重写$remote_addr和$remote_port这两个值,将其替换成和$proxy_protocol_addr一样的值,然后使用$realip_remote_addr和$realip_remote_port来表示原有值。

使用 Nginx 的 Proxy Protocol 功能和 RealIP 模块,我们可以改进前文中的示例,使 whoami 容器在任意情况下都可以获取 Client 的真实 IP 地址。

在我们的方案中,nginx 转发用户请求时需要作为 Sender 将 Client 真实 IP 封装到 Proxy Protocol 包中,Pod 在接收到连接请求时从 Proxy Protocol 包获取 Client 的真实 IP 信息。为了验证上述方案,我修订了首先 whoami 使之可以作为 Server 正常处理 Proxy Protocol 。

修改 whoami 使它支持 Proxy Protocol 非常简单,有兴趣的可以参考: https://github.com/pires/go-proxyproto

修订 nginx 的配置文件,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
stream {
        resolver kube-dns.kube-system.svc.cluster.local valid=5s; 
        map "" $whoami_server {
            default whoami.default.svc.cluster.local:80;
        }

        server {
            # listen 18080 proxy_protocol; # 将 nginx 作为 proxy_protocol reciever
            listen 18080;
            proxy_timeout 20s;
            proxy_pass $whoami_server;
            proxy_protocol on;             # 将 nginx 作为 proxy_protocol sender
            set_real_ip_from 0.0.0.0;      # 使用 realip 模块向 proxy_protocol 包填充真实 IP
        }
    }

根据上述的验证,我们可以达到预期目标,但是需要推动业务修改底层代码以支持 proxy protocol。这些修订的成本需要业务组件进行评估,可能微不足道也可能非常巨大!

上述配置中 nginx 监听的 18080 没有开启 proxy_protocol 监听,只能接收普通的 tcp 请求。我们可以配置该端口处理 proxy_protocol 请求,此时我们需要修改 client 使它发送 proxy_protocol 报文。

这种情况下会有一个非常有意思的地方,client 在发送 proxyproto 头时,可以将源 ip 地址指定为任意值!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"github.com/pires/go-proxyproto"
	"io/ioutil"
	"log"
	"net"
	"net/http"
	"time"
)

func transportSupportProxyProtocol(sourceIP string, sourcePort int) (*http.Transport, error) {

	tr := &http.Transport{}

	if sourceIP == "" || sourcePort <= 0 || sourcePort >= 65535 {
		return nil, errors.New("error source addr")
	}

	sourceAddr := &net.TCPAddr{
		IP:   net.ParseIP(sourceIP),
		Port: sourcePort,
	}

	tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
		conn, err := (&net.Dialer{}).Dial(network, addr)
		if err != nil {
			return nil, err
		}

		header := &proxyproto.Header{
			Version:           1,
			Command:           proxyproto.PROXY,
			TransportProtocol: proxyproto.TCPv4,
			SourceAddr:        sourceAddr,
			DestinationAddr:   conn.RemoteAddr(),
		}

		_, err = header.WriteTo(conn)
		if err != nil {
			return nil, err
		}

		return conn, nil
	}
	return tr, nil
}

var (
	url        string
	sourceIP   string
	sourcePort int
)

func init() {
	flag.StringVar(&url, "url", "", "")
	flag.StringVar(&sourceIP, "sip", "9.9.9.9", "")
	flag.IntVar(&sourcePort, "sport", 999, "")

}

func main() {

	flag.Parse()

	tr, err := transportSupportProxyProtocol(sourceIP, sourcePort)
	if err != nil {
		log.Fatal(err)
	}

	httpclient := &http.Client{
		Timeout:   time.Second,
		Transport: tr,
	}

	resp, err := httpclient.Get(url)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		log.Fatalf("error http status code: %d ", resp.StatusCode)
	}

	body, err := ioutil.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		log.Fatal(err)
	}
	fmt.Println(string(body))

}

参考