目录
1 传统微服务MicroService的问题:侵入式 Client 端服务发现 LoadBalance
1.1 Client 端服务发现 负载均衡
2 Istio 是如何实现流量劫持的?
2.1 要做哪些事?
2.2 透明代理
2.3 Sidecar
2.4 iptables
2.5 Init 容器
3 问题:如何判断目标服务的类型?
3.1 Cluster IP
1 传统微服务MicroService的问题:侵入式 Client 端服务发现 LoadBalance
1.1 Client 端服务发现 负载均衡
传统微服务,服务发现 负载均衡的代码,是和业务代码耦合在一起的,并且在运行过程中,也是和业务跑在同一个进程里。
例如 Springboot 项目启动的 Tomcat 服务,业务逻辑跑在这个 tomcat 里,同时服务发现的代码,及服务发现后的负载均衡代码,也跑在这个 tomcat 里。
那么能不能将业务代码和框架代码解耦呢?
能不能实现 tomcat 服务器里只跑业务代码,而服务发现 负载均衡交给其他进程去实现?
答案是可以的,将服务发现 负载均衡放在单独的 sidecar 进程中,与业务代码解耦,同时通过流量劫持来实现对于服务流量的 proxy。
Istio 的项目中有一个亮点就是可以将旧的应用无缝接入到 Service Mesh 的平台上来,不用修改一行代码。实现这个功能,目前主要是通过 iptables 来截获流量转发给 proxy。
2 Istio 是如何实现流量劫持的?
参考 Istio 的实现方式,我们可以自己设计一个简单的流量劫持的方案。
2.1 要做哪些事?
- 首先要有一个支持透明代理的 proxy,处理被劫持的流量,能够获取到连接建立时的原来的目的地址。在 k8s 中这个 proxy 采用 sidecar 的方式和要劫持流量的服务部署在一个 Pod 中。
- 通过 iptables 将我们想要劫持的流量劫持到 proxy 中。proxy 自身的流量要排除在外。
- 要实现零侵入,最好不修改服务的镜像,在 k8s 中可以采用 Init 容器的方式在应用容器启动之前做 iptables 的修改。
2.2 透明代理
proxy 作为一个透明代理,对于自身能处理的流量,会经过一系列的处理逻辑,包括重试,超时,负载均衡等,再转发给对端服务。对于自身不能处理的流量,会直接透传,不作处理。
通过 iptables 将流量转发给 proxy 后,proxy 需要能够获取到原来建立连接时的目的地址。在 Go 中的实现稍微麻烦一些,需要通过 syscall
调用来获取,
示例代码:
代码语言:javascript复制package redirect
import (
"errors"
"fmt"
"net"
"os"
"syscall"
)
const SO_ORIGINAL_DST = 80
var (
ErrGetSocketoptIPv6 = errors.New("get socketopt ipv6 error")
ErrResolveTCPAddr = errors.New("resolve tcp address error")
ErrTCPConn = errors.New("not a valid TCPConn")
)
// For transparent proxy.
// Get REDIRECT package's originial dst address.
// Note: it may be only support linux.
func GetOriginalDstAddr(conn *net.TCPConn) (addr net.Addr, c *net.TCPConn, err error) {
fc, errRet := conn.File()
if errRet != nil {
conn.Close()
err = ErrTCPConn
return
} else {
conn.Close()
}
defer fc.Close()
mreq, errRet := syscall.GetsockoptIPv6Mreq(int(fc.Fd()), syscall.IPPROTO_IP, SO_ORIGINAL_DST)
if errRet != nil {
err = ErrGetSocketoptIPv6
c, _ = getTCPConnFromFile(fc)
return
}
// only support ipv4
ip := net.IPv4(mreq.Multiaddr[4], mreq.Multiaddr[5], mreq.Multiaddr[6], mreq.Multiaddr[7])
port := uint16(mreq.Multiaddr[2])<<8 uint16(mreq.Multiaddr[3])
addr, err = net.ResolveTCPAddr("tcp4", fmt.Sprintf("%s:%d", ip.String(), port))
if err != nil {
err = ErrResolveTCPAddr
return
}
c, errRet = getTCPConnFromFile(fc)
if errRet != nil {
err = ErrTCPConn
return
}
return
}
func getTCPConnFromFile(f *os.File) (*net.TCPConn, error) {
newConn, err := net.FileConn(f)
if err != nil {
return nil, ErrTCPConn
}
c, ok := newConn.(*net.TCPConn)
if !ok {
return nil, ErrTCPConn
}
return c, nil
}
通过 GetOriginalDstAddr
函数可以获取到连接原来的目的地址。
这里需要格外注意的是,当启用 iptables 转发后,proxy 如果接收到直接访问自己的连接时,会识别到自身不能处理,会再去连接此目的地址(就是自己绑定的地址),这样就会导致死循环。所以在服务启动时,需要将目的地址为自身 IP 的连接直接断开。
2.3 Sidecar
使用 Sidecar 模式部署服务网格时,会在每一个服务身边额外启一个 proxy 去接管容器的部分流量。在 kubernetes 中一个 Pod 可以有多个容器,这多个容器可以共享网络,存储等资源,从概念上将服务容器和 proxy 容器部署成一个 Pod,proxy 容器就相当于是 sidecar 容器。
我们通过一个 Deployment 来演示,这个 Deployment 的 yaml 配置中包括了 test 和 proxy 两个 container,它们共享网络,所以登录 test 容器后,通过 127.0.0.1:30000
可以访问到 proxy 容器。
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
namespace: default
labels:
app: test
spec:
replicas: 1
template:
metadata:
labels:
app: test
spec:
containers:
- name: test
image: {test-image}
ports:
- containerPort: 9100
- name: proxy
image: {proxy-image}
ports:
- containerPort: 30000
为每一个服务都编写 sidecar 容器的配置是一件比较繁琐的事情,当架构成熟后,我们就可以利用 kubernetes 的 MutatingAdmissionWebhook
功能,在用户创建 Deployment 时,主动注入 sidecar 相关的配置。
例如,我们在 Deployment 的 annotations 中加入如下的字段:
代码语言:javascript复制annotations:
xxx.com/sidecar.enable: "true"
xxx.com/sidecar.version: "v1"
表示在此 Deployment 中需要注入 v1 版本的 sidecar。当我们的服务收到这个 webhook 后,就可以检查相关的 annotations 字段,根据字段配置来决定是否注入 sidecar 配置以及注入什么版本的配置,如果其中有一些需要根据服务改变的参数,也可以通过这种方式传递,极大地提高了灵活性。
2.4 iptables
通过 iptables 我们可以将指定的流量劫持到 proxy,并将部分流量排除在外。
代码语言:javascript复制iptables -t nat -A OUTPUT -p tcp -m owner --uid-owner 9527 -j RETURN
iptables -t nat -A OUTPUT -p tcp -d 172.17.0.0/16 -j REDIRECT --to-port 30000
上面的命令,表示将目标地址是 172.17.0.0/16
的流量 REDIRECT
到 30000 端口(proxy 所监听的端口)。但是 UID 为 9527 启动的进程除外。172.17.0.0/16
这个地址是 k8s 集群内部的 IP 段,我们只需要劫持这部分流量,对于访问集群外部的流量,暂时不劫持,如果劫持全部流量,对于 proxy 不能处理的请求,就需要通过 iptables 的规则去排除。
2.5 Init 容器
前文说过为了实现零侵入,我们需要通过 Init 容器的方式,在启动用户服务容器之前,就修改 iptables。这部分配置也可以通过 kubernetes 的 MutatingAdmissionWebhook
功能注入到用户的 Deployment 配置中。
将前面 sidecar 的配置中加上 Init 容器的配置:
代码语言:javascript复制apiVersion: apps/v1
kind: Deployment
metadata:
name: test
namespace: default
labels:
app: test
spec:
replicas: 1
template:
metadata:
labels:
app: test
spec:
initContainers:
- name: iptables-init
image: {iptables-image}
imagePullPolicy: IfNotPresent
command: ['sh', '-c', 'iptables -t nat -A OUTPUT -p tcp -m owner --uid-owner 9527 -j RETURN && iptables -t nat -A OUTPUT -p tcp -d 172.17.0.0/16 -j REDIRECT --to-port 30000']
securityContext:
capabilities:
add:
- NET_ADMIN
privileged: true
containers:
- name: test
image: {test-image}
ports:
- containerPort: 9100
- name: proxy
image: {proxy-image}
ports:
- containerPort: 30000
这个 Init 容器需要安装 iptables,在启动时会执行我们配置的 iptables 命令。
需要额外注意的是 securityContext
这个配置项,我们加了 NET_ADMIN
的权限。它用于定义 Pod 或 Container 的权限,如果不配置,则 iptables 执行命令时会提示错误。
3 问题:如何判断目标服务的类型?
我们将 172.17.0.0/16
的流量都劫持到了 proxy 内部,那么如何判断目标服务的协议类型?如果不知道协议类型,就不能确定如何去解析后续的请求。
在 kubernetes 的 service 中,我们可以为每一个 service 的端口指定一个名字,这个名字的格式可以固定为 {name}-{protocol}
,例如 {test-http}
,表示这个 service 的某个端口是 http 协议。
kind: Service
apiVersion: v1
metadata:
name: test
namespace: default
spec:
selector:
app: test
ports:
- name: test-http
port: 9100
targetPort: 9100
protocol: TCP
proxy 通过 discovery 服务获取到 service 对应的 Cluster IP 和端口名称,这样通过目标服务的 IP 和 port 就可以知道这个连接的通信协议类型,后面就可以交给对应的 Handler 去处理。
3.1 Cluster IP
在 kubernetes 中创建 Service,如果没有指定,默认采用 Cluster IP 的方式来访问,kube-proxy 会为此创建 iptables 规则,将 Cluster IP 转换为以负载均衡的方式转发到 Pod IP。
当存在 Cluster IP 时,service 的 DNS 解析会指向 Cluster IP,负载均衡由 iptables 来做。如果不存在,DNS 解析的结果会直接指向 Pod IP。
proxy 依赖于 service 的 Cluster IP 来判断用户访问的是哪一个服务,所以不能设置为 clusterIP: None
。因为 Pod IP 是有可能会经常变动的,当增减实例时,Pod IP 的集合都会改变,proxy 并不能实时的获取到这些变化。