blog icon indicating copy to clipboard operation
blog copied to clipboard

基于 ingress-nginx 实现灰度发布和蓝绿发布

Open penglongli opened this issue 5 years ago • 0 comments

本文描述如何基于 https://github.com/kubernetes/ingress-nginx 实现灰度发布和蓝绿发布功能:

  • 灰度发布:
    • 基于Request Header的流量切分
    • 基于Cookie的流量切分
    • 基于Query Param的流量切分
  • 蓝绿发布:
    • 根据权重进行流量切分

本文基于 controller-0.32.0 版本

概述

ingress-nginx 目前其实已经做了灰度发布、蓝绿发布,但是在使用方式上需要建多个 ingress 的方式来实现。我们目前希望能够在一个 Ingress 中即实现上述功能,则需要做一部分的开发工作。

目标

此处的目标是实现与阿里云 相同的方式,阿里云的使用方式参考:Kubernetes 集群中通过 Ingress 实现灰度发布和蓝绿发布

灰度发布

在 Ingress 建立的时候,使用如下 annotation 来实现灰度发布:

nginx.ingress.kubernetes.io/service-match: |
  # 请求匹配到 cookie 存在 c=test,则转发到 new-nginx
  # 如:curl -H 'Cookie: c=test' canary.example.com
  new-nginx: cookie("c", "test")
  # 请求匹配到 header 存在 h=test,则转发到 new-nginx
  # 如:curl -H 'h: test' canary.example.com
  new-nginx: header("h", "test")
  # 请求匹配到 查询参数 存在 q=test,则转发到 new-nginx
  # 如:curl http://canary.example.com?q=test
  new-nginx: query("q", "test")

蓝绿发布

在 Ingress 建立时,使用如下 annotation 来实现灰度发布:

nginx.ingress.kubernetes.io/service-weight: |
  new-nginx: 20, old-nginx: 80

则会出现 20% 的请求量被分配到 new-ingress。(nginx-ingress此处的请求权重分配非绝对分配,并且分配不均匀)

对比阿里云

阿里云的实现更多的是对 balancer.lua 的修改,有兴趣可以看一下其修改的 LUA 模块。

ingress-nginx 原理

后端

  1. 开启 syncIngress 同步任务[https://github.com/penglongli/ingress-nginx/blob/controller-0.32.0/internal/ingress/controller/nginx.go#L310]

  2. 初始化同步任务[https://github.com/penglongli/ingress-nginx/blob/controller-0.32.0/internal/ingress/controller/nginx.go#L139]

  3. 获取并组装 Ingress 信息[https://github.com/penglongli/ingress-nginx/blob/controller-0.32.0/internal/ingress/controller/controller.go#L135]

    • 此步骤会将 Ingress 的信息拆解开,并通过 kubernetes API 获取 Service 对应的 Endpoint 地址,将其组装进来
  4. 判断本次是否需要 Reload Nginx 配置文件[https://github.com/penglongli/ingress-nginx/blob/controller-0.32.0/internal/ingress/controller/controller.go#L146]

LUA 模块

  1. Nginx 配置里的动态 Upstream [https://github.com/penglongli/ingress-nginx/blob/controller-0.32.0/rootfs/etc/nginx/template/nginx.tmpl#L443]

  2. LUA 动态 Upstream [https://github.com/penglongli/ingress-nginx/blob/controller-0.32.0/rootfs/etc/nginx/lua/balancer.lua#L274]

    • 后端会开启一个 10246 端口,LUA 模块会请求 http://127.0.0.1:10246/configuration/backends 获取配置(此为获取 HTTP Backend 配置)

实现过程

代码修改提交:https://github.com/penglongli/ingress-nginx/commit/ad38db951224a1d2696c7d6d2298832b09becdaf

灰度

controller.go:

func (n *NGINXController) getBackendServers(ingresses []*ingress.Ingress) ([]*ingress.Backend, []*ingress.Server) {
	for _, ing := range ingresses {
        ..............................
        ..............................

        // 解析 nginx.ingress.kubernetes.io/service-match 注解,抽出灰度发布的策略,组装成 GrayStrategy 对象
		svcStrategy := make(map[string]*ingress.GrayStrategy)
		if serviceMatch := ing.Annotations["nginx.ingress.kubernetes.io/service-match"]; strings.TrimSpace(serviceMatch) != "" {
			for _, annotation := range strings.Split(serviceMatch, "\n") {
				splitServiceMatch(annotation, svcStrategy)
			}
		}

		for _, rule := range ing.Spec.Rules {
			..............................
            ..............................

			// 设置每个 Rule(如:canary.example.com)下灰度策略的:灰度 Backend、灰度 Service
			var graySvcSlice []string
			for _, path := range rule.HTTP.Paths {
				if strategy := svcStrategy[path.Backend.ServiceName]; strategy != nil {
					graySvcSlice = append(graySvcSlice, path.Backend.ServiceName)

					strategy.Service = path.Backend.ServiceName
					strategy.Backend = upstreamName(ing.Namespace, path.Backend.ServiceName, path.Backend.ServicePort)
				}
			}

			for pathIndex, path := range rule.HTTP.Paths {
                ..............................
                ..............................


				for _, loc := range server.Locations {
					// 为第一个 Location 设置灰度策略
					if pathIndex == 0 {
						for _, svc := range graySvcSlice {
							loc.GrayStrategies = append(loc.GrayStrategies, svcStrategy[svc])
						}
					}
				}
            }
		}
	}

	return aUpstreams, aServers
}

nginx.tmpl 模板

为每个域名(如:canary.example.com)判断是否需要灰度策略,并通过 generateTmplate() 将策略生成到 nginx.conf 配置中。
优先级:Cookie > Header > Query

{{ range $k, $v := $location.GrayStrategies }}
    {{ $condition := (buildGrayIfCondition $v) }}

    # 判断是否存在 Query 灰度策略
    {{ if (ne $condition.QueryCondition "" )}}
    if ({{ $condition.QueryCondition }}) {
        set $service_name {{ $v.Service }};
        set $proxy_upstream_name {{ $v.Backend }};
    }
    {{ end }}

    # 判断是否存在 Header 灰度策略
    {{ if (ne $condition.HeaderCondition "" )}}
    if ({{ $condition.HeaderCondition }}) {
        set $service_name {{ $v.Service }};
        set $proxy_upstream_name {{ $v.Backend }};
    }
    {{ end }}

    # 判断是否存在 Cookie 灰度策略
    {{ if (ne $condition.CookieCondition "" )}}
    if ({{ $condition.CookieCondition }}) {
        set $service_name {{ $v.Service }};
        set $proxy_upstream_name {{ $v.Backend }};
    }
    {{ end }}

{{ end }}

权重

controller.go

// createUpstreams creates the NGINX upstreams (Endpoints) for each Service
// referenced in Ingress rules.
func (n *NGINXController) createUpstreams(data []*ingress.Ingress, du *ingress.Backend) map[string]*ingress.Backend {
	upstreams := make(map[string]*ingress.Backend)
	upstreams[defUpstreamName] = du

	for _, ing := range data {
		anns := ing.ParsedAnnotations

		var defBackend string
		
		// 解析 nginx.ingress.kubernetes.io/service-weight 权重策略注解
		serviceWeight := splitServiceWeight(anns)
		for _, rule := range ing.Spec.Rules {
			if rule.HTTP == nil {
				continue
			}

			for i, path := range rule.HTTP.Paths {
				// 为所有非 第一个 的 Path 设置权重参数
                // 为什么“非第一个”?因为第一个会被映射到 nginx.conf 文件中的 $serviceName
				if i != 0 {
					weight := serviceWeight[path.Backend.ServiceName]
					if weight > 0 && weight < 100 {
						upstreams[name].NoServer = true
						upstreams[name].TrafficShapingPolicy = ingress.TrafficShapingPolicy{
							Weight:        weight,
							Header:        anns.Canary.Header,
							HeaderValue:   anns.Canary.HeaderValue,
							HeaderPattern: anns.Canary.HeaderPattern,
							Cookie:        anns.Canary.Cookie,
						}
					}
				}
			}

			// 为第一个 Path 设置 alternativeService(此 Service 即为“替代 Service”,用于权重策略)
			if len(rule.HTTP.Paths) >= 2 {
				path := rule.HTTP.Paths[0]
				name := upstreamName(ing.Namespace, path.Backend.ServiceName, path.Backend.ServicePort)
				for i := 1; i < len(rule.HTTP.Paths); i++ {
					if serviceWeight[path.Backend.ServiceName] != 0 {
						upstreams[name].AlternativeBackends = append(
							upstreams[name].AlternativeBackends,
							upstreamName(ing.Namespace, rule.HTTP.Paths[i].Backend.ServiceName, rule.HTTP.Paths[i].Backend.ServicePort),
						)
					}
				}
			}
		}
	}

	return upstreams
}

调试

在 nginx-ingress-controller 服务启动后,修改代码,使用如下脚本来编译运行:

#!/bin/bash

# 编译
make build

# 拷贝 nginx-ingress-controller
docker ps | grep "/usr/bin/dumb-ini" | awk '{print $1}' | xargs -I {} docker cp bin/amd64/nginx-ingress-controller {}:/nginx-ingress-controller

# 拷贝 nginx.tmpl
docker ps | grep "/usr/bin/dumb-ini" | awk '{print $1}' | xargs -I {} docker cp rootfs/etc/nginx/template/nginx.tmpl {}:/etc/nginx/template/

# 重启 nginx-ingress-controller 容器
docker ps | grep "/usr/bin/dumb-ini" | awk '{print $1}' | xargs docker restart

使用

部署服务

首先,部署两个服务:test-python、test-nginx

# test-python 服务
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-python
  labels:
    app: python
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: python
  template:
    metadata:
      labels:
        app: python
    spec:
      containers:
      - name: centos
        image: centos:7
        command: ["python"]
        args:
          - "-m"
          - "SimpleHTTPServer"
          - "8080"
---
apiVersion: v1
kind: Service
metadata:
  name: test-python
  namespace: kube-system
spec:
  ports:
  - port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    app: python
  type: ClusterIP

# test-nginx 服务
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-nginx
  labels:
    app: nginx
  namespace: kube-system
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.18.0
        imagePullPolicy: IfNotPresent
---
apiVersion: v1
kind: Service
metadata:
  name: test-nginx
  namespace: kube-system
spec:
  ports:
  - port: 8080
    protocol: TCP
    targetPort: 80
  selector:
    app: nginx
  type: ClusterIP

创建 Ingress

权重

---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/service-weight: |
      test-python: 50, test-nginx: 50
  labels:
    app: demo
  name: demo-ingress
  namespace: kube-system
spec:
  rules:
  - host: canary.example.com
    http:
      paths:
      - backend:
          serviceName: test-python
          servicePort: 8080
        path: /
      - backend:
          serviceName: test-nginx
          servicePort: 8080
        path: /

请求域名判断是否能够按照 50% 分配到两个应用

灰度

---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/service-match: |
      test-nginx: cookie("c", "test")
  labels:
    app: demo
  name: demo-ingress
  namespace: kube-system
spec:
  rules:
  - host: canary.example.com
    http:
      paths:
      - backend:
          serviceName: test-python
          servicePort: 8080
        path: /
      - backend:
          serviceName: test-nginx
          servicePort: 8080
        path: /

使用 curl http://canary.example.com -H 'Cookie: c=test' 确定请求是否能够一直落在 test-nginx 上

penglongli avatar May 29 '20 03:05 penglongli