基于nginx ingress+云效Appstack实现灰度发布

1. 场景简介

灰度发布是降低生产部署风险,提升线上服务稳定性的重要手段,这在当前快速迭代的软件研发中尤为重要。相对于 k8s 默认的滚动部署或者简单的 Pod 分批,基于流量特征的灰度发布验证更精准,风险更低。

在云原生场景下,基于 nginx ingress 的灰度发布是被广泛使用的方案之一。该方案通过在流量入口侧进行灰度和正常流量的路由调配,将灰度流量导入到灰度的服务版本,从而可以在全量部署到生产环境前,对新版本进行验证,当验证不通过时,可以及时回退,将风险控制在较低的范围内。而整个发布和验证,能做到对终端用户服务的不间断和体验一致。

2. 基本原理

对于常见的 web 服务,ingress 灰度发布的基本逻辑是:将指向同一入口(HOST)的访问请求,根据特征或流量比例,将一部分流量路由到灰度服务中,通过对灰度流量的监控和验证,判断灰度服务是否符合上线标准。

按照流量的路径,我们可以画出 ingress 流量灰度在 k8s 上的简单架构(如下图)。

image

流量从入口的 HOST 进来后,会根据 nginx-ingress 的流量标识(定义在 ingress 的 annotation 中),将符合灰度标识的请求路由到灰度环境的 service 中,进而进入背后的工作负载 Pod。对于每个环境内部的 service 和 deployment,它们并不主动感知灰度标识,也就是说,通常情况下,内部的 rpc 流量并不携带灰度标识。因此,这种架构主要解决的是外部流量灰度的问题,适合服务数量不太多,服务间调用比较简单的场景。

3. 具体实践

接下来,我们结合云效 Appstack,来看下如何在阿里云 ACK 集群上进行应用的 ingress 灰度发布。

我们首先导入 ACK 集群并创建应用。

image.png

3.1. 定义和管理灰度环境

3.1.1. 定义环境

在应用中,我们分别定义灰度环境与生产环境,两个环境共享同一个 k8s 集群,我们将灰度环境命名为 grey,生产环境命名为 ack-prod。

image.png

3.1.2. 定义部署编排

我们打开应用设置,编排配置来定义部署编排。灰度发布生效的关键是 ingress 中的 canary 注解,需要查看你的 nginx-ingress-controller 版本,以确定可以支持的注解。我测试的集群上 nginx-ingress-controller 的版本是 0.30,采用 header 来标识灰度流量。

image.png

为了做到只在灰度环境中开启灰度的路由配置,我们使用了编排模板的条件语句,见下面的编排示例:

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .AppStack.appName }}-{{ .AppStack.envName }}
  namespace: {{ .Values.namespace }}
{{ if eq .AppStack.envName "grey" }}
  annotations:
    # 开启Canary。
    nginx.ingress.kubernetes.io/canary: "true"
    # 请求头为_env。
    nginx.ingress.kubernetes.io/canary-by-header: "_env"
    # 请求头_env的值为grey时,请求才会被路由到新版本服务中。
    nginx.ingress.kubernetes.io/canary-by-header-value: "grey"
{{ end }}
spec:
  rules:
  - host: {{ .Values.host }}
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: {{ .AppStack.appName }}-{{ .AppStack.envName }}
            port:
              number: 80

可以看到在 ingress 的编排中,仅名称为“grey”的环境,会开启 canary 的 annotaion,并且将灰度标识设置为通过 _env: grey 的 header 标签。

同时在 ingress 的 rules 配置中,通过变量 host 来指定路由关联的 HOST,这里 ACK 灰度环境和 ACK 生产环境分别关联灰度环境变量组和生产环境变量组,这两个变量组的 host 变量采用相同的值。

示例:

image.png

# 
namespace = demo-pre
# 
host = go.demo.prod
# 
namespace = demo-prod
# 
host = go.demo.prod

除了 ingress 外,还需要配置对应的 service 和 deployment。

---
apiVersion: v1
kind: Service
metadata:
  name: {{ .AppStack.appName }}-{{ .AppStack.envName }}
  namespace: {{ .Values.namespace }}
spec:
  selector:
    run: {{ .AppStack.appName }}-{{ .AppStack.envName }}
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .AppStack.appName }}-{{ .AppStack.envName }}
  labels:
    run: {{ .AppStack.appName }}-{{ .AppStack.envName }}
  namespace: {{ .Values.namespace }}
spec:
  replicas: {{ .Values.replicas }}
  selector:
    matchLabels:
      run: {{ .AppStack.appName }}-{{ .AppStack.envName }}
  template:
    metadata:
      labels:
        run: {{ .AppStack.appName }}-{{ .AppStack.envName }}
    spec:
      containers:
        - name: main
          image: {{ .AppStack.image.backend }}
          ports:
            - containerPort: 8080
          resources:
            limits:
              cpu: {{ .Values.cpuLimit }}
              memory: {{ .Values.memoryLimit }}
            requests:
              cpu: {{ .Values.cpuRequest }}
              memory: {{ .Values.memoryRequest }}

3.2. 准备示例代码并关联到应用中

可从 atomgit 上下载实例代码,路径为:https://atomgit.com/feiyuw/demo-go-echo.git

将代码导入到云效代码管理 Codeup,然后关联到应用中。

image.png

3.3. 定义灰度发布的流程

接下来,我们通过云效 Appstack 的研发流程,定义灰度发布的整个过程,见下图:

image.jpeg

在该流程中,每次执行,我们都会从 master 分支拉取代码进行镜像构建,构建完成后会转交运维进行审批,审批通过后即开始部署过程。

部署过程包含灰度部署、灰度验证、生产部署和灰度清理四个步骤。

  • 灰度部署步骤会将前面构建出来的镜像更新到 ACK 灰度环境,此时通过在请求中携带 _env:grey 的 header 就可进行灰度验证。

  • 灰度验证是一个人工卡点,用于对灰度环境的观测和验证,如果验证通过即自动进行生产环境的部署,如果验证失败,则跳过生产部署,执行灰度清理

  • 生产部署步骤会将镜像更新到 ACK 生产环境,此时新的服务版本将对普通用户可见。注意,这里为了降低风险,生产部署的策略被设置为了分批,且首批暂停的模式,保证线上仍然是逐步放量的,且有机会快速回退。

  • 灰度清理步骤主要是清理灰度环境的资源,一方面节约资源,另一方面避免灰度验证不通过时对线上的影响。

在 云效 Appstack 上相关应用的设置中,进行研发流程设置(如果仅是体验灰度发布,可仅配置生产阶段)。

image.png

上述流程可以参考下面的流水线 yaml 来定义,请注意将其中的acr_docker_build_step 步骤的镜像地址和服务连接修改为正确的值,并把grey_validate 和 ops_validate 包含的 userId 替换为自己的阿里云的账号 ID。

---
stages:
  build:
    name: "构建"
    jobs:
      go_build:
        name: "Go 镜像构建"
        steps:
          golang_build_step:
            name: "Golang 构建"
            step: "GolangBuild"
            with:
              goVersion: "1.20.x"
              run: |
                export GOPROXY=https://goproxy.cn
                make build
          upload_step:
            step: "ArtifactUpload"
            name: "构建物上传"
            with:
              serviceConnection: "wtdbdh89rrfdsod6"
              repo: "flow_generic_repo"
              artifact: "demo-go-echo"
              version: "prod-${CI_COMMIT_ID}.${DATETIME}"
              filePath:
              - "demo-go-echo"
              - "deploy.sh"
          acr_docker_build_step:
            name: "镜像构建并推送至阿里云镜像仓库个人版"
            step: "ACRDockerBuild"
            with:
              artifact: "image"
              dockerfilePath: "Dockerfile"
              dockerRegistry: "registry.cn-zhangjiakou.aliyuncs.com/docker007/demo-go-echo"
              dockerTag: "prod-${CI_COMMIT_ID}.${DATETIME}"
              region: "cn-zhangjiakou"
              serviceConnection: "<connectionId>"
  approve:
    name: "部署审核"
    jobs:
      ops_validate:
        name: "运维审批"
        component: "ManualValidate"
        with:
          validatorType: "users"
          validators:
          - <userId>
  grey:
    name: "灰度验证"
    jobs:
      grey_deploy_job:
        name: "ACK灰度部署"
        component: "AppStackFlowDeploy"
        with:
          application: "demo-go-echo"
          environment: "grey"
          artifacts:
          - label: "backend"
            value: "$[stages.build.go_build.acr_docker_build_step.artifacts.image.dockerUrl]"
          autoDeploy: true
      grey_validate:
        name: "灰度验证"
        component: "ManualValidate"
        needs:
        - "grey_deploy_job"
        with:
          validatorType: "users"
          validators:
          - <userId>
  deploy:
    name: "部署"
    jobs:
      ack_deploy_job:
        name: "ACK生产部署"
        component: "AppStackFlowDeploy"
        condition: |
          succeed('grey.grey_validate')
        with:
          application: "demo-go-echo"
          environment: "ack-prod"
          artifacts:
          - label: "backend"
            value: "$[stages.build.go_build.acr_docker_build_step.artifacts.image.dockerUrl]"
          autoDeploy: true
  cleanup:
    name: "清理环境"
    jobs:
      cleanup_grey_env_job:
        name: "清理灰度环境"
        component: "AppStackCleanEnv"
        needs:
        - "grey.grey_validate"
        - "deploy.ack_deploy_job"
        condition: |
          failed('grey.grey_validate') || succeed('deploy.ack_deploy_job')
        with:
          application: "demo-go-echo"
          environment: "grey"
          deleteEnv: "cleanEnv"

3.4. 验证灰度发布流程

我们假设研发流程仅包含生产阶段,先运行一次生产阶段,将应用部署到灰度和生产环境中,注意:环境第一次部署可能需要手动创建部署单。

修改代码,将 routes.go 里面的版本号修改为新的值,再次执行生产阶段,直到灰度验证步骤,此时生产环境与灰度环境运行不同的版本。

通过 kubectl get ing -A 获取 ingress 的出口 IP,在本地/etc/hosts 中将其绑定到 go.demo.prod 中,如:

127.0.0.1   go.demo.prod  # 将127.0.0.1修改为正确的出口IP

打开终端,通过 httpie 或者 curl 请求/version 接口,以 httpie 为例,请求命令为:

http -v http://go.demo.prod/version    # 请求正式环境
http -v http://go.demo.prod/version _env:grey    # 请求灰度环境

4. 常见问题

4.1. 有了灰度环境,生产环境部署的时候还需要分批吗?

建议在生产环境部署的时候仍然开启分批,因为灰度环境虽然验证通过了,但受到数据量等影响,生产仍然不建议全量一起上,通过分批,可以把风险控制在小范围内,避免大的故障的发生。

4.2. 如何在研发流程上整合配置变更和数据变更?

可以将配置变更和数据变更作为研发阶段的步骤,编排到研发流程的流水线 yaml 中,通常建议在应用部署前执行数据变更,部署后执行配置变更。同时,对应于 k8s 上的灰度环境,如果采用了类似 Nacos 这样的配置中心,也应当由对应的灰度 namespace,从而避免直接修改生产配置。

4.3. 灰度环境包含多个应用,如何保证其内部服务间调用也是走的灰度环境?

完整的方案建议参考 MSE 等产品。

如果应用数量比较少,链路比较简单,且接受基于 k8s 的简单方案,可以为每个应用都定义一个灰度环境,共享同一个集群和 namespace,应用间通过 service 进行调用。此时,由于 namespace 的隔离,灰度环境内的应用互相调用只会调用同 namespace 下的其它应用的灰度版本。

这种方案需要保证各应用灰度环境的长期可用,因此研发流程最后的清理环境步骤需要被移除。

4.4. 如何在流程中关联其它类型的发布如函数计算?

可以在函数计算的相关步骤编排到研发流程的流水线 yaml 中,从而实现双方的联动。

5. 参考