penglongli / blog

18 stars 1 forks source link

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

Open penglongli opened 4 years ago

penglongli commented 4 years ago

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

本文基于 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]

实现过程

代码修改提交: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 上