在 Azure 上利用 Argo CD 与 Envoy Proxy 构建动态多租户搜索路由网关


我们面临的第一个问题,并非技术选型,而是运维流程的崩溃。业务增长带来了越来越多的独立租户,每个租户背后都是一套独立的搜索集群,最初用一个集中式的 Nginx Ingress 做反向代理。每当新租户上线或旧租户配置变更,就意味着一次 nginx.conf 的修改、测试,以及一次心惊胆战的 nginx -s reload。这个过程不仅容易出错,而且严重拖慢了业务交付速度。我们需要一个系统,能将路由规则的管理,像应用程序代码一样,通过版本控制和自动化流程进行交付,彻底消除手动干预。

初步构想很简单:将路由配置从网关实例中剥离出来,使其成为可独立部署的“资产”。Kubernetes 的声明式 API 给了我们灵感,而 GitOps 则是实现这一构想的最佳实践。技术栈的轮廓逐渐清晰:在 Azure Kubernetes Service (AKS) 上,使用 Envoy Proxy 作为高性能的数据平面处理流量,并利用 Argo CD 监听一个专门存放路由规则的 Git 仓库,实现配置的自动同步。这样,路由变更就简化成了一次 Git Pull Request。

阶段一:静态 Envoy 部署的基线

在引入动态化之前,必须先建立一个稳定、可预测的静态部署基线。这里的核心是构建一个包含基础配置的 Envoy 镜像,并在 AKS 中以标准 Deployment 的形式运行起来。

我们的 Envoy 配置没有太多花哨的东西,它只做三件事:监听 8080 端口,设置基本的管理接口,以及定义一个静态路由,将所有流量转发到一个预定义的后端服务。

envoy.yaml 配置文件如下,这是我们将打包进 Docker 镜像的基础:

# envoy.yaml - 静态基础配置
admin:
  access_log_path: /dev/stdout
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          access_log:
            - name: envoy.access_loggers.stdout
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
          http_filters:
          - name: envoy.filters.http.router
            typed_config: {}
          route_config:
            name: local_route
            virtual_hosts:
            - name: backend_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  # 这是一个硬编码的路由,用于验证基础设置
                  cluster: "fallback_service"

  clusters:
  - name: fallback_service
    connect_timeout: 0.25s
    type: STRICT_DNS
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: fallback_service
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                # 在k8s环境中,这通常是另一个服务的SVC name
                address: "some-default-backend.default.svc.cluster.local"
                port_value: 80

对应的 Dockerfile 极为简单,仅仅是把这个配置文件复制到官方镜像中。

# Dockerfile
FROM envoyproxy/envoy:v1.24.0
COPY envoy.yaml /etc/envoy/envoy.yaml
# 暴露监听端口和管理端口
EXPOSE 10000 9901

在 AKS 中部署它,也只是标准的 Kubernetes 资源清单。

# envoy-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: envoy-search-gateway
spec:
  replicas: 2
  selector:
    matchLabels:
      app: envoy-search-gateway
  template:
    metadata:
      labels:
        app: envoy-search-gateway
    spec:
      containers:
      - name: envoy
        image: your-acr.azurecr.io/envoy-search-gateway:1.0.0 # 替换为你的ACR镜像地址
        ports:
        - containerPort: 10000
          name: http
        - containerPort: 9901
          name: admin
---
apiVersion: v1
kind: Service
metadata:
  name: envoy-search-gateway-svc
spec:
  selector:
    app: envoy-search-gateway
  ports:
  - protocol: TCP
    port: 80
    targetPort: 10000
  type: LoadBalancer # 在Azure上会自动创建一个公网IP和负载均衡器

到这一步,我们拥有了一个功能正常但完全僵化的网关。任何路由变更都需要重新构建镜像、推送、更新 Deployment,这与我们最初的目标背道而驰。

阶段二:引入动态配置与 GitOps

真正的挑战在于如何让 Envoy 在不重启、不重新部署的情况下更新其路由规则。Envoy 的 xDS (Discovery Service) API 是为此而生的。完整的 xDS 需要一个复杂的控制平面,但在我们的场景下,可以采用一种更轻量级的方案:**基于文件的 xDS (File-based Discovery)**。

Envoy 可以监控文件系统中的特定文件,当文件内容发生变化时,它会自动、平滑地加载新的配置。这个机制与 Kubernetes ConfigMap 的能力完美契合:我们可以将路由配置存储在 ConfigMap 中,然后将其作为文件挂载到 Envoy Pod 内部。当 ConfigMap 更新时,Kubernetes 会自动更新挂载的文件,从而触发 Envoy 的热重载。

整个流程的架构如下:

graph TD
    subgraph Git Repository ["Git Repo (Routing Configs)"]
        A[routes/tenant-a.yaml]
        B[routes/tenant-b.yaml]
    end

    subgraph Azure DevOps/GitHub Actions
        C[CI Pipeline] --> D{Combine YAMLs};
        D --> E[Push to K8s Manifest Repo];
    end

    subgraph Kubernetes Manifest Repo
        F[configmaps/routes-configmap.yaml]
    end
    
    subgraph AKS Cluster
        G(Argo CD) -- watches --> F;
        G -- updates --> H(ConfigMap: envoy-routes);
        I(Envoy Pod) -- mounts --> H;
        I -- reads file --> J[RDS/CDS Files];
        J -- triggers hot-reload --> K(Envoy Runtime);
    end

    UserRequest -- traffic --> K

    A --> C;
    B --> C;
    F --> G;

改造 Envoy 配置以支持文件发现

首先,修改 envoy.yaml,将 route_configclusters 的定义从静态改为动态,指向文件系统中的路径。

# envoy.yaml - 改造为动态配置
admin:
  # ... (同上)

static_resources:
  listeners:
  # ... (listener_0 定义同上)
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          # ... (HttpConnectionManager 定义同上)
          # RDS (Route Discovery Service) 指向一个配置文件
          rds:
            config_source:
              path: "/etc/envoy/routes/routes.yaml" # 关键:指向路由配置文件
            route_config_name: "dynamic_routes"
          # ...

  clusters:
  # CDS (Cluster Discovery Service) 指向一个配置文件
  - name: cds_cluster
    type: EDS
    eds_cluster_config:
      eds_config:
        path: "/etc/envoy/clusters/clusters.yaml" # 关键:指向集群配置文件

# 注意:静态的 clusters 部分被移除了,完全依赖CDS

这个新的 envoy.yaml 不再包含任何具体的路由或上游集群信息,它只告诉 Envoy 去哪里 这些信息。

创建路由和集群的 ConfigMap

现在,我们将为租户 tenant-a 创建它的路由和集群定义。这些将是存在于 Git 仓库中的独立文件。

routes.yaml (用于 RDS):

# routes.yaml - 动态路由定义
resources:
- "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration
  name: "dynamic_routes"
  virtual_hosts:
  - name: "tenant_services"
    domains: ["*"]
    routes:
    # 规则一:根据Header路由到租户A的搜索服务
    - match:
        prefix: "/search"
        headers:
        - name: "x-tenant-id"
          exact_match: "tenant-a"
      route:
        cluster: "tenant_a_search_cluster"
        timeout: 5s
        retry_policy:
          retry_on: "5xx"
          num_retries: 2
          per_try_timeout: 1.5s

clusters.yaml (用于 CDS):

# clusters.yaml - 动态集群定义
resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: "tenant_a_search_cluster"
  connect_timeout: 1s
  type: STRICT_DNS
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: "tenant_a_search_cluster"
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: "tenant-a-search-svc.tenant-a.svc.cluster.local" # 指向租户A的 Kubernetes Service
              port_value: 9200
  # 添加健康检查,这是生产环境必须的
  health_checks:
  - timeout: 1s
    interval: 10s
    unhealthy_threshold: 3
    healthy_threshold: 2
    http_health_check:
      path: "/_cluster/health"
      expected_statuses:
      - start: 200
        end: 299

然后,在我们的 Kubernetes 清单仓库中,创建对应的 ConfigMap。

# envoy-dynamic-configs.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-routes-config
data:
  routes.yaml: |
    resources:
    - "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration
      name: "dynamic_routes"
      # ... (routes.yaml 内容)
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-clusters-config
data:
  clusters.yaml: |
    resources:
    - "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
      name: "tenant_a_search_cluster"
      # ... (clusters.yaml 内容)

更新 Deployment 以挂载 ConfigMaps

最后一步是修改 envoy-deployment.yaml,将这两个 ConfigMap 挂载到 Envoy Pod 的指定路径。

# envoy-deployment.yaml - 更新后
apiVersion: apps/v1
kind: Deployment
metadata:
  name: envoy-search-gateway
spec:
  replicas: 2
  selector:
    matchLabels:
      app: envoy-search-gateway
  template:
    metadata:
      labels:
        app: envoy-search-gateway
    spec:
      containers:
      - name: envoy
        image: your-acr.azurecr.io/envoy-search-gateway:2.0.0 # 使用包含动态配置的镜像
        ports:
        # ...
        volumeMounts:
        - name: envoy-routes-volume
          mountPath: /etc/envoy/routes
        - name: envoy-clusters-volume
          mountPath: /etc/envoy/clusters
      volumes:
      - name: envoy-routes-volume
        configMap:
          name: envoy-routes-config
      - name: envoy-clusters-volume
        configMap:
          name: envoy-clusters-config

配置 Argo CD

现在,所有 Kubernetes 资源都已定义完毕。我们在 Argo CD 中创建一个 Application,指向存放这些清单的 Git 仓库。

# argocd-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: envoy-gateway
  namespace: argocd
spec:
  project: default
  source:
    repoURL: 'https://github.com/your-org/k8s-manifests.git' # 你的清单仓库
    path: 'gateways/search'
    targetRevision: HEAD
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: 'gateway-system'
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

部署完成后,整个系统就活了。

阶段三:真实世界的考验与优化

理论上完美的系统,在生产环境中总会遇到意想不到的问题。

问题一:配置验证

一个常见的错误是,开发人员提交了一个有语法错误的 YAML 路由文件。Argo CD 会忠实地同步这个错误的 ConfigMap,导致 Envoy 加载失败,新路由无法生效,并在日志中产生大量错误。虽然 Envoy 不会因此崩溃,但这是一个潜在风险。

解决方案:CI 阶段的静态验证。我们在 CI 流水线中增加了一个步骤,在合并 Pull Request 之前,使用 envoy --mode validate 命令来检查配置文件的语法和基本逻辑。

# ci-validation-script.sh
# 假设这是在CI Runner中运行
# 下载一个临时的envoy二进制文件
docker cp $(docker create envoyproxy/envoy:v1.24.0):/usr/local/bin/envoy ./envoy

# 准备一个临时的、包含所有配置引用的根配置文件
cat > ./temp-envoy-validate.yaml <<EOF
# (包含 admin, static_resources, listeners 的基础配置)
# ...并引用本地文件路径的 rds 和 cds
  rds:
    config_source:
      path: "./routes.yaml" 
  cds:
    path: "./clusters.yaml"
# ...
EOF

# 运行验证
./envoy --mode validate -c ./temp-envoy-validate.yaml --service-cluster validation_cluster --service-node validation_node

if [ $? -ne 0 ]; then
  echo "Envoy configuration validation failed!"
  exit 1
fi

echo "Envoy configuration is valid."

这个脚本确保了任何进入主分支的配置都是 Envoy 可以解析的。

问题二:可观测性

系统运行后,我们如何知道路由是否正常工作?哪个租户的请求最多?哪个后端的延迟最高?

解决方案:集成 Prometheus 和 Grafana。Envoy 内置了强大的统计能力,可以轻松暴露 Prometheus 指标。只需在 envoy.yamladmin 部分增加配置。

# envoy.yaml - 增加Prometheus指标
admin:
  access_log_path: /dev/stdout
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901
  # 新增的Prometheus端点
  prometheus_scraping:
    path: "/stats/prometheus"
    # 限制指标数量,防止打爆Prometheus
    used_stats_matcher:
      inclusion_list:
        patterns:
        - "cluster.*.upstream_rq_*"
        - "listener.*.downstream_cx_*"

同时,在 Deployment 中添加 annotations,让 AKS 的 Prometheus Operator 能够自动发现并抓取这些指标。

# envoy-deployment.yaml - 添加Prometheus annotations
template:
  metadata:
    labels:
      app: envoy-search-gateway
    annotations:
      prometheus.io/scrape: "true"
      prometheus.io/path: "/stats/prometheus"
      prometheus.io/port: "9901"

通过 Grafana 仪表盘,我们可以清晰地看到每个 cluster(即每个租户)的请求量、成功率、P99 延迟等关键 SLI 指标,为容量规划和故障排查提供了数据支持。

当前方案的局限与未来演进

这个基于文件和 ConfigMap 的 xDS 实现,在租户数量达到几百个的规模下工作得相当不错。它简单、可靠,并且完美地融入了我们现有的 GitOps 工作流。

然而,它的局限性也很明显。当租户数量增长到数千级别,将所有路由和集群定义塞进一两个巨大的 ConfigMap 中,会触及 Kubernetes 对单个对象大小的限制(通常是 1MB)。此外,每次微小的变更都需要 Argo CD 同步整个 ConfigMap,这在规模扩大后可能会有效率问题。

下一个演进方向,是构建一个轻量级的 Go xDS 控制平面。这个控制平面将不再依赖 ConfigMap,而是直接监听 Kubernetes API(例如,通过 CRD 定义路由规则),或者从一个专门的数据库(如 etcd 或 Consul)中读取配置,并通过 gRPC 将配置流式传输给 Envoy 实例。这将彻底解决规模化问题,并允许我们实现更复杂的逻辑,比如基于后端健康状况的动态权重调整。但这将是另一个阶段的工程挑战了。


  目录